diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2efdb6a0f..60f9c6d16 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,41 +1,39 @@ -## Lime v0.98.0 +## Lime v0.99.0 ### ✨ 主要更新 -- **Chrome Relay / Browser Connector 进入 current 主线**:新增浏览器连接器命令与后端服务,设置页补齐 Chrome Relay 安装、连接状态和目录展示,扩展弹窗与清单同步更新 -- **Agent Chat 工作台与时间线继续收口**:`ArtifactWorkbenchShell`、`AgentThreadTimeline`、`CanvasWorkbenchLayout`、`MessageList` 等核心区域完成进一步瘦身,移除了旧的 `ProjectSelector`、`TaskFiles`、`TimelineInlineItem` 等遗留表面 -- **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 校验被阻塞 +- **共享网关控制面继续收口**:移除了遗留 `api-server` / `route` 相关前端页面、命令与后端路由表残面,server 侧进一步收敛到当前主路径,托盘与运行时状态同步逻辑也同步减面 +- **Browser Connector / Chrome Relay 主链继续稳定化**:浏览器连接器、扩展安装状态、目录展示、调试面板与设置页联动进一步补齐,Chrome 扩展端清单、弹窗与后台脚本同步更新 +- **Agent Chat 与 Service Skill 工作台继续加固**:`AgentChatHomeShell`、`AgentChatWorkspace`、`useWorkspaceSendActions`、`siteCapabilityBinding`、`Browser Assist` 等链路继续收敛,Service Skill 启动、自动匹配、浏览器预检与制品预览主链一致性更高 +- **设置与导航表面继续收口**:系统设置分组、渠道配置工作台、外观入口、侧边栏能力分组与创作主题默认显示策略完成新一轮整理,减少未开放或遗留入口的默认暴露 +- **工程验证与文档同步**:命令边界、Playwright / GUI smoke、质量工作流与 Chrome 扩展说明同步更新,为 `v0.99.0` 发布提供统一事实源 ### ⚠️ 兼容性说明 -- 正式发布由 `v0.98.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 -- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;目标环境如果缺少对应条件,Browser Runtime/站点适配器相关能力会被降级 +- 正式发布由 `v0.99.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 +- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;若目标环境缺少对应条件,Browser Runtime / 站点适配器相关能力会降级或无法完成验证 - 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.23.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 ### 🔗 依赖同步 -- 应用版本已同步提升到 `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` 对齐 +- 应用版本已同步提升到 `v0.99.0`,覆盖 `package.json`、`package-lock.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` +- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.99.0` 对齐 ### 🧪 测试 - 发布前执行:`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` +- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` - 发布前执行:`npm run lint` - 发布前执行:`npm run verify:app-version` -- 发布前执行:`npm run test:contracts` -- 发布前执行:`npm run verify:gui-smoke` -- 验证结果:上述命令已在当前工作区通过;Rust 校验使用独立 `CARGO_TARGET_DIR` 完成,以避免与本机其他 Cargo 进程争抢默认构建目录;GUI smoke 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径 +- 验证结果:以本次实际执行结果为准;如果当前工作区包含未收口的并行改动,需要在发布前重新确认 Rust / 前端校验与 GUI 冒烟结论 ### 📝 文档 -- 发布说明已切换到当前这次 `v0.98.0` 正式发布内容,供 GitHub Release 直接读取 -- 工程质量、命令边界与 Playwright / GUI 冒烟文档已同步更新到最新实现 +- 发布说明已切换到当前这次 `v0.99.0` 正式发布内容,供 GitHub Release 直接读取 +- 工程质量、命令边界、Playwright / GUI 冒烟与扩展说明已围绕当前实现持续同步 --- -**完整变更**: `v0.97.0` -> `v0.98.0` +**完整变更**: `v0.98.0` -> `v0.99.0` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 1b2cbcfd9..f6f281d5f 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -30,9 +30,11 @@ - `set_browser_connector_install_root_cmd` - `set_browser_connector_enabled_cmd` - `set_system_connector_enabled_cmd` +- `set_browser_action_capability_enabled_cmd` - `get_browser_connector_install_status_cmd` - `install_browser_connector_extension_cmd` - `open_browser_extensions_page_cmd` +- `open_browser_remote_debugging_page_cmd` - `disconnect_browser_connector_session` 这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。 @@ -99,19 +101,21 @@ // src/lib/api/serverRuntime.ts import { safeInvoke } from "@/lib/dev-bridge"; -export async function getServerStatus() { - return safeInvoke("get_server_status"); +export async function getServerDiagnostics() { + return safeInvoke("get_server_diagnostics"); } ``` 业务层只消费网关: ```typescript -import { getServerStatus } from "@/lib/api/serverRuntime"; +import { getServerDiagnostics } from "@/lib/api/serverRuntime"; -const status = await getServerStatus(); +const diagnostics = await getServerDiagnostics(); ``` +共享网关控制面已下线后,`start_server`、`stop_server`、`get_server_status`、`get_available_routes`、`get_route_curl_examples`、`test_api`、`get_network_info`,以及托盘残留 `sync_tray_state`、`update_tray_server_status`、`update_tray_credential_status`、`get_tray_state`、`refresh_tray_menu`、`refresh_tray_with_stats` 都应视为 `dead` 候选,不应重新接回前端主路径;server 兼容面 `/v1/routes`、`/{selector}/v1/messages`、`/{selector}/v1/chat/completions` 也应视为 `dead` 候选,不应重新接回本地共享网关主链;开发者诊断统一继续走 `get_server_diagnostics`,托盘只保留 `sync_tray_model_shortcuts`,server 只保留标准 `/v1/messages` 与 `/v1/chat/completions`。 + ### 3. Rust 命令与注册表同步 - 在 `src-tauri/src/commands/` 下落到对应模块 @@ -227,10 +231,13 @@ npm run verify:local - **站点能力主链**:继续收敛到 `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` +- **站点技能首页入口主链**:首页 / 工作区弹窗只负责补参数、组装 `initialUserPrompt + harness.service_skill_launch` 上下文并进入 `Claw`;真正执行统一收口到 `Claw` 首回合,不再由首页弹窗或工作区挂载副作用直接调用 `site_run_adapter` - **站点结果沉淀主线**:`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 +- **attached-session 执行主链**:真实浏览器附着场景下,Bridge `run_adapter` 只允许下发 `adapter_name + args`,禁止继续透传原始脚本文本到扩展 content script,以免触发站点 CSP 的 `unsafe-eval` - **站点运行失败语义**:`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` +- **浏览器运行时启动主链**:`launch_browser_session` / `launch_browser_runtime_assist` 支持显式 `headless` 启动参数;仅用于 `verify:gui-smoke` 一类自动化校验避免弹出空白 Chrome,正常用户态调用默认仍保持有界面浏览器 ## 相关检查脚本 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index f3ee3c940..2ac6a69a9 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -25,6 +25,8 @@ - 优先使用 **Playwright MCP** 做交互验证,不优先编写新的本地 Playwright 测试文件 - 浏览器模式默认首页从 `http://127.0.0.1:1420/` 进入 - 能走真实后端就走真实后端;浏览器模式暂不支持或尚未桥接的能力,允许走 mock +- `verify:gui-smoke` 内部的 browser runtime 校验默认走无界面浏览器会话;它只证明主链可启动,不替代后续真实页面交互验证 +- 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板 - 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local` ## 进入前的最低准备 @@ -145,11 +147,13 @@ npm run test:contracts ### Claw 站点技能直跑门禁验证 1. 在 `Claw` 首页打开一个站点型技能弹窗 -2. 如果当前没有附着真实浏览器会话,确认主按钮保持禁用,并出现“需要浏览器工作台 / 重新检测会话”的门禁提示 +2. 如果当前没有附着真实浏览器会话,确认弹窗继续展示“需要先准备浏览器 / 重新检测会话”的门禁提示,且 `在 Claw 中执行` 主按钮处于禁用状态 3. 点击 `去浏览器工作台`,确认只发生页面跳转,不会后台偷偷拉起 Chrome 4. 在浏览器工作台附着到真实浏览器并打开目标站点后,回到 `Claw` 再次打开同一技能 5. 确认此时主按钮变为可执行,点击后进入 `Claw` 工作区 -6. 确认消息流顶部出现独立的站点技能执行卡,状态依次体现 `执行中 / 已完成` 或明确阻断原因,而不是伪装成普通聊天消息 +6. 确认进入 `Claw` 后会自动发送一条首回合技能任务消息,消息文本包含站点技能启动上下文,而不是由前端挂载副作用偷偷直跑 +7. 如果已有附着会话,确认 `Claw` 会通过 `lime_site_run` 执行并把结果写回当前主稿或项目资源 +8. 如果没有附着会话,确认不会再向 `Claw` 对话流注入“我已完成登录,继续执行”之类的确认卡;阻断必须停留在技能入口层 ### 开发者页站点来源导入验证 @@ -167,8 +171,10 @@ npm run test:contracts 3. 点击“展开高级控制”,确认 `总览 / Profile / 桥接 / 后端 / 调试` 页签可切换 4. 如当前环境允许目录选择,点击“选择目录并安装”或“同步更新扩展”,确认安装目录最终落到固定子目录 `Lime Browser Connector` 5. 点击“复制配置”,确认剪贴板内容包含 `serverUrl / bridgeKey / profileKey` -6. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接 -7. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页 +6. 确认“连接方式”区块同时展示“浏览器扩展 / CDP 直连”两种说明,并包含 `chrome://extensions` 与 `chrome://inspect/#remote-debugging` 的打开或复制入口 +7. 在“动作配置”区块临时关闭一个能力,例如“页面内查找”,确认开关状态立即更新,且后端能力列表同步隐藏对应动作 +8. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接 +9. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”与“打开远程调试页”可成功唤起对应 Chrome 页面 ### 话题模型恢复验证 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 58233cb7a..21dca141c 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -64,6 +64,8 @@ 只改其中一侧,不算完成。 +如果本轮是在下线共享网关控制面,`start_server`、`stop_server`、`get_server_status`、`get_available_routes`、`get_route_curl_examples`、`test_api`、`get_network_info`,以及托盘残留 `sync_tray_state`、`update_tray_server_status`、`update_tray_credential_status`、`get_tray_state`、`refresh_tray_menu`、`refresh_tray_with_stats` 必须同步从前端网关、Rust 注册、DevBridge 和 mock 中撤掉;server 兼容面 `/v1/routes`、`/{selector}/v1/messages`、`/{selector}/v1/chat/completions` 也必须同步从 server 路由表与 services/core 模型中撤掉;开发者诊断只保留 `get_server_diagnostics`,托盘只保留 `sync_tray_model_shortcuts`,server 只保留标准 `/v1/messages` 与 `/v1/chat/completions`。 + ### 3. 用户可见 UI 改动必须补稳定回归 - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 @@ -134,6 +136,7 @@ npm run verify:gui-smoke - 等待 `DevBridge` 健康检查通过 - 验证默认 workspace 的准备态可用 - 验证 `browser runtime` 的启动、状态读取与审计主链可用 +- 其中 `browser runtime smoke` 默认以无界面浏览器会话执行,避免额外弹出仅用于校验的空白 Chrome - 验证 `site adapter catalog` 的状态、列表与推荐主链可读 它解决的是 GUI 产品特有风险: @@ -167,9 +170,10 @@ npm run bridge:health -- --timeout-ms 120000 - 修改 `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 去重逻辑 +- 修改首页 / 工作区进入 `Claw` 时的首条自动发送上下文,例如 `initialUserPrompt`、`initialAutoSendRequestMetadata`、`harness.service_skill_launch` - 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_get_adapter_launch_readiness`、`site_import_adapter_yaml_bundle`、`site_run_adapter` - 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 -- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、扩展安装状态或主动断开扩展连接 +- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` @@ -207,6 +211,7 @@ npm run bridge:health -- --timeout-ms 120000 补充说明: - 如果这次改动把 `ServiceSkill -> automation_job -> agent_turn` 接到 Artifact 主线,除了常规 `verify:local` / `test:contracts` 之外,还应至少补一条稳定回归,证明 `content_id + request_metadata.artifact` 没在表单编辑或执行链路里丢失。 +- 如果这次改动影响 `Claw` 与站点技能的直跑门禁,还应补回归证明:阻断停留在技能入口层,不再把浏览器准备态注入成对话里的继续执行确认。 - 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - session 已有 `execution_runtime.recent_content_id` 时,前端不会重复提交相同 `harness.content_id` - 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id` diff --git a/docs/content/01.introduction/3.quickstart.md b/docs/content/01.introduction/3.quickstart.md index 4e4df18ad..32a71d162 100644 --- a/docs/content/01.introduction/3.quickstart.md +++ b/docs/content/01.introduction/3.quickstart.md @@ -53,9 +53,9 @@ navigation: 可以。上传参考图后,若所选模型支持编辑接口,会自动走编辑链路。 -### 我还需要 API 接入能力怎么办? +### 我还需要排查底层协议怎么办? -可以继续阅读 [API Server](/user-guide/api-server) 和 [API 参考](/api-reference/overview)。 +可以继续阅读 [API 参考](/api-reference/overview)。 ## 下一步 diff --git a/docs/content/02.user-guide/4.configuration-example.md b/docs/content/02.user-guide/4.configuration-example.md index da5c0f05b..ecfd5a869 100644 --- a/docs/content/02.user-guide/4.configuration-example.md +++ b/docs/content/02.user-guide/4.configuration-example.md @@ -55,24 +55,7 @@ resource: 适用:品牌运营、活动策划、小团队协作。 -## 示例 3:进阶接入 - -目标:在保留创作工作台的同时开放 API 给外部工具。 - -```yaml -profile: "creator-plus-api" -api_server: - enabled: true - host: "127.0.0.1" - port: 8999 - auth: "api-key" -routing: - fallback: true -``` - -适用:有脚本联动、自动化流程需求的用户。 - -## 示例 4:API 缓存策略(高级) +## 示例 3:API 缓存策略(高级) 目标:精确控制哪些响应状态码参与短时缓存(非流式)。 @@ -91,7 +74,7 @@ server: 适用:对缓存命中与语义一致性有要求的自动化/API 调用场景。 -## 示例 5:运行时诊断接口(高级) +## 示例 4:运行时诊断接口(高级) 目标:像 ClawRouter 一样快速查看网关健康、缓存与统计信息。 @@ -124,10 +107,8 @@ curl -i "http://127.0.0.1:8999/v1/chat/completions" \ - `x-lime-idempotency`(`replay/in-progress/new/removed-on-error`) - `x-lime-requested-provider` / `x-lime-effective-provider` / `x-lime-model` -补充:在「团队共享网关(内网)」页面的「网关 API 测试」结果展开区域,也会直接显示这些 `x-lime-*` 调试头。 - ## 调整顺序建议 1. 先确认导航与主题 2. 再确认连接与稳定性 -3. 最后再做 API 与自动化扩展 +3. 最后再做自动化与诊断扩展 diff --git a/docs/content/02.user-guide/8.api-server.md b/docs/content/02.user-guide/8.api-server.md deleted file mode 100644 index fcbdb419d..000000000 --- a/docs/content/02.user-guide/8.api-server.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: 本地 API 接入 -description: 将 Lime 能力暴露给外部工具(进阶) -navigation: - icon: i-heroicons-server ---- - -# 本地 API 接入 - -::alert{type="info"} -这是进阶能力。普通创作者可以不配置,直接在应用内完成创作。 -:: - -当你希望把 Lime 接入脚本、自动化工具或第三方客户端时,可启用本地 API。 - -## 核心说明 - -- 服务默认在本地地址运行 -- 通过 API Key 控制访问 -- 支持常见兼容接口形态 - -## 快速配置 - -1. 进入设置中的 API Server -2. 开启服务并确认端口 -3. 设置或生成 API Key -4. 使用一条测试请求验证连通性 - -## 最小测试示例 - -```bash -curl http://127.0.0.1:8999/v1/chat/completions \ - -H "Authorization: Bearer your-api-key" \ - -H "Content-Type: application/json" \ - -d '{"model":"your-model","messages":[{"role":"user","content":"你好"}]}' -``` - -## 安全建议 - -1. 只在本机环境使用 -2. 不要把 API Key 提交到代码仓库 -3. 定期更换密钥并清理无效连接 - -## 常见问题 - -### 启动后无法访问 - -先确认端口未被占用,再检查本地防火墙策略。 - -### 请求总是 401 - -通常是 API Key 填写错误或请求头格式不正确。 diff --git a/extensions/lime-chrome/README.md b/extensions/lime-chrome/README.md index ef007e3d2..602d67a6b 100644 --- a/extensions/lime-chrome/README.md +++ b/extensions/lime-chrome/README.md @@ -5,6 +5,9 @@ ## 功能 - Observer 通道自动连接:`/lime-chrome-observer/?profileKey=...` +- 启停状态持久化:手动停用或桌面端主动断开后,不会在后台偷偷重连 +- MV3 保活与自动重连:使用 `chrome.alarms` 保持 relay 存活,并在浏览器恢复后自动重连 +- 按需注入 content script:不再对所有站点常驻注入,只在抓取或执行命令时动态注入 - 页面信息上报:标题、URL、Markdown - 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `list_tabs` / `go_back` 等 - 弹窗入口:直接打开 Lime 的“连接器”页,并保留高级手动配置 @@ -91,5 +94,6 @@ npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile de ## 兼容说明 - 扩展只负责浏览器侧采集与动作执行。 +- 当前主链仍然是 `API -> observer socket -> 扩展 -> 当前已登录 Chrome 标签页`,不会额外拉起新的托管 Chrome。 - Agent 侧通过 `aster_agent_cmd` 暴露的现役浏览器 MCP 工具访问。 - 若你同时使用独立 Chrome Profile(Tauri `open_chrome_profile_window`),请在对应 Profile 内安装该扩展,并使用不同 `profileKey` 做隔离。 diff --git a/extensions/lime-chrome/background.js b/extensions/lime-chrome/background.js index 72a6a1cb4..d54c79454 100644 --- a/extensions/lime-chrome/background.js +++ b/extensions/lime-chrome/background.js @@ -2,26 +2,71 @@ const HEARTBEAT_INTERVAL_MS = 30000; const RECONNECT_MIN_DELAY_MS = 1000; const RECONNECT_MAX_DELAY_MS = 30000; const PAGE_CAPTURE_RETRY_LIMIT = 3; +const KEEPALIVE_ALARM_NAME = "limeBridgeKeepAlive"; +const KEEPALIVE_PERIOD_MINUTES = 1; +const DEBUGGER_PROTOCOL_VERSION = "1.3"; +const MAX_LOG_ENTRIES = 200; const DEFAULT_SETTINGS = { serverUrl: "ws://127.0.0.1:8999", bridgeKey: "", profileKey: "default", monitoringEnabled: true, + enabled: true, }; let ws = null; let isConnected = false; +let isConnecting = false; +let controlWs = null; +let isControlConnected = false; +let isControlConnecting = false; +let isEnabled = true; let reconnectAttempts = 0; let reconnectTimer = null; +let controlReconnectAttempts = 0; +let controlReconnectTimer = null; let heartbeatTimer = null; let activeTabId = null; let monitoringEnabled = true; let latestPageInfo = null; +let lastError = null; +let controlLastError = null; let lastSettings = { ...DEFAULT_SETTINGS }; +const debuggerAttachedTabIds = new Set(); const expectedClosedSockets = new WeakSet(); +const logBuffer = []; + +function stringifyPayload(payload) { + if (payload === undefined || payload === null) { + return ""; + } + if (typeof payload === "string") { + return payload; + } + try { + return JSON.stringify(payload); + } catch (_) { + return String(payload); + } +} + +function pushLog(dir, method, detail) { + const normalizedMethod = String(method || "").trim() || "event"; + const normalizedDetail = String(detail || "").trim(); + logBuffer.push({ + ts: Date.now(), + dir: String(dir || "").trim() || "sys", + method: normalizedMethod, + detail: normalizedDetail, + }); + if (logBuffer.length > MAX_LOG_ENTRIES) { + logBuffer.splice(0, logBuffer.length - MAX_LOG_ENTRIES); + } +} function logInfo(message, payload) { + pushLog("sys", message, stringifyPayload(payload)); if (payload === undefined) { console.log(`[LimeBridge] ${message}`); } else { @@ -30,6 +75,7 @@ function logInfo(message, payload) { } function logWarn(message, payload) { + pushLog("warn", message, stringifyPayload(payload)); if (payload === undefined) { console.warn(`[LimeBridge] ${message}`); } else { @@ -51,6 +97,12 @@ function writeSettings(partial) { }); } +function applySettingsState(settings) { + lastSettings = settings; + monitoringEnabled = Boolean(settings.monitoringEnabled); + isEnabled = settings.enabled !== false; +} + function buildObserverUrl(settings) { const serverUrl = String(settings.serverUrl || "").trim(); const bridgeKey = String(settings.bridgeKey || "").trim(); @@ -63,85 +115,139 @@ function buildObserverUrl(settings) { return `${normalized}/lime-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${profileKey}`; } +function buildControlUrl(settings) { + const serverUrl = String(settings.serverUrl || "").trim(); + const bridgeKey = String(settings.bridgeKey || "").trim(); + if (!serverUrl || !bridgeKey) { + return null; + } + + const normalized = serverUrl.replace(/\/$/, ""); + return `${normalized}/lime-chrome-control/${encodeURIComponent(bridgeKey)}`; +} + function isCapturableUrl(url) { return /^https?:\/\//i.test(String(url || "").trim()); } -async function connectObserver(forceReconnect = false) { - if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) { - return; - } +function isDebuggerAttachableUrl(url) { + const normalizedUrl = String(url || "").trim(); + return /^(https?|file):\/\//i.test(normalizedUrl) || normalizedUrl === "about:blank"; +} - clearReconnectTimer(); - clearHeartbeatTimer(); - - const settings = await readSettings(); - lastSettings = settings; - monitoringEnabled = Boolean(settings.monitoringEnabled); - - const url = buildObserverUrl(settings); - if (!url) { - logWarn("缺少 serverUrl 或 bridgeKey,无法建立连接"); - setConnectionState(false); - broadcastStatus(); - return; - } - - if (forceReconnect && ws) { - closeObserverSocket(ws, { shouldReconnect: false }); - } - - logInfo(`连接 observer: ${url}`); - const socket = new WebSocket(url); - ws = socket; - - socket.onopen = () => { - reconnectAttempts = 0; - setConnectionState(true); - startHeartbeat(); - broadcastStatus(); - triggerPageCapture("ws_open"); - }; - - socket.onmessage = async (event) => { - try { - const payload = JSON.parse(event.data); - await handleObserverMessage(payload); - } catch (error) { - logWarn("解析消息失败", error?.message || String(error)); - } - }; - - socket.onclose = () => { - const shouldReconnect = !expectedClosedSockets.has(socket); - if (ws === socket) { - ws = null; - } - setConnectionState(false); - clearHeartbeatTimer(); - if (shouldReconnect) { - scheduleReconnect(); - } else { - reconnectAttempts = 0; - } - broadcastStatus(); - }; - - socket.onerror = (error) => { - logWarn("WebSocket 错误", error?.message || error); +function maskSettings(settings) { + return { + ...settings, + bridgeKey: settings.bridgeKey ? "***" : "", }; } -function disconnectObserver(manual = true) { - clearReconnectTimer(); - clearHeartbeatTimer(); - if (ws) { - closeObserverSocket(ws, { shouldReconnect: false }); +function getRelayState() { + if (!isEnabled) { + return "disabled"; } - if (manual) { - setConnectionState(false); - broadcastStatus(); + if (isConnected && isControlConnected) { + return "connected"; } + return "disconnected"; +} + +function buildRelayCompatibilityPayload() { + return { + state: getRelayState(), + connected: isConnected && isControlConnected, + active: isConnected, + reconnecting: + isConnecting || + isControlConnecting || + Boolean(reconnectTimer) || + Boolean(controlReconnectTimer), + attachedTabs: debuggerAttachedTabIds.size, + agentTabs: 0, + retainedTabs: 0, + observerConnected: isConnected, + controlConnected: isControlConnected, + debuggerTabs: Array.from(debuggerAttachedTabIds.values()), + activeTabId, + lastError, + controlLastError, + }; +} + +function buildStatusPayload(extra) { + return { + isConnected, + isConnecting, + isControlConnected, + isControlConnecting, + enabled: isEnabled, + monitoringEnabled, + activeTabId, + latestPageInfo, + debuggerTabIds: Array.from(debuggerAttachedTabIds.values()), + lastError, + controlLastError, + relayState: getRelayState(), + settings: maskSettings(lastSettings), + ...extra, + }; +} + +function setBadgeState() { + let text = "OFF"; + let color = "#64748b"; + + if (isConnected) { + text = "ON"; + color = "#16a34a"; + } else if (isConnecting || reconnectTimer) { + text = "…"; + color = "#d97706"; + } else if (lastError && isEnabled) { + text = "!"; + color = "#dc2626"; + } + + chrome.action.setBadgeText({ text }); + chrome.action.setBadgeBackgroundColor({ color }); +} + +function clearLastError() { + lastError = null; +} + +function rememberLastError(error) { + const normalized = String(error || "").trim(); + lastError = normalized || "浏览器连接器执行失败"; +} + +function clearControlLastError() { + controlLastError = null; +} + +function rememberControlLastError(error) { + const normalized = String(error || "").trim(); + controlLastError = normalized || "控制通道执行失败"; +} + +function broadcastStatus(extra) { + setBadgeState(); + chrome.runtime + .sendMessage({ + type: "STATUS_UPDATE", + data: buildStatusPayload(extra), + }) + .catch(() => {}); +} + +function ensureKeepAliveAlarm() { + chrome.alarms.create(KEEPALIVE_ALARM_NAME, { + periodInMinutes: KEEPALIVE_PERIOD_MINUTES, + }); +} + +function clearKeepAliveAlarm() { + chrome.alarms.clear(KEEPALIVE_ALARM_NAME); } function closeObserverSocket(socket, { shouldReconnect }) { @@ -156,18 +262,11 @@ function closeObserverSocket(socket, { shouldReconnect }) { } catch (_) {} } -function setConnectionState(connected) { - isConnected = connected; - chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" }); - chrome.action.setBadgeBackgroundColor({ - color: connected ? "#16a34a" : "#dc2626", - }); -} - function startHeartbeat() { clearHeartbeatTimer(); heartbeatTimer = setInterval(() => { sendObserverMessage({ type: "heartbeat", timestamp: Date.now() }); + sendControlMessage({ type: "heartbeat", timestamp: Date.now() }); }, HEARTBEAT_INTERVAL_MS); } @@ -178,22 +277,6 @@ function clearHeartbeatTimer() { } } -function scheduleReconnect() { - if (reconnectTimer) { - return; - } - reconnectAttempts += 1; - const delay = Math.min( - RECONNECT_MAX_DELAY_MS, - RECONNECT_MIN_DELAY_MS * Math.pow(2, reconnectAttempts - 1), - ); - reconnectTimer = setTimeout(async () => { - reconnectTimer = null; - await connectObserver(); - }, delay); - logInfo(`连接断开,${delay}ms 后重连(第 ${reconnectAttempts} 次)`); -} - function clearReconnectTimer() { if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -201,41 +284,358 @@ function clearReconnectTimer() { } } +function clearControlReconnectTimer() { + if (controlReconnectTimer) { + clearTimeout(controlReconnectTimer); + controlReconnectTimer = null; + } +} + function sendObserverMessage(payload) { if (!ws || ws.readyState !== WebSocket.OPEN) { return false; } + pushLog("out", payload?.type || "observer_message", stringifyPayload(payload?.data)); ws.send(JSON.stringify(payload)); return true; } -function broadcastStatus(extra) { - chrome.runtime - .sendMessage({ - type: "STATUS_UPDATE", - data: { - isConnected, - monitoringEnabled, - activeTabId, - latestPageInfo, - settings: { - ...lastSettings, - bridgeKey: lastSettings.bridgeKey ? "***" : "", - }, - ...extra, - }, - }) - .catch(() => {}); +function sendControlMessage(payload) { + if (!controlWs || controlWs.readyState !== WebSocket.OPEN) { + return false; + } + pushLog("out", payload?.type || "control_message", stringifyPayload(payload?.data)); + controlWs.send(JSON.stringify(payload)); + return true; +} + +async function disconnectObserver(options = {}) { + const { + disableAutoReconnect = false, + reason = null, + silent = false, + } = options; + + clearReconnectTimer(); + clearControlReconnectTimer(); + clearHeartbeatTimer(); + + if (disableAutoReconnect) { + isEnabled = false; + lastSettings = { ...lastSettings, enabled: false }; + await writeSettings({ enabled: false }); + clearKeepAliveAlarm(); + } else if (isEnabled) { + ensureKeepAliveAlarm(); + } + + const socket = ws; + ws = null; + if (socket) { + closeObserverSocket(socket, { shouldReconnect: false }); + } + const controlSocket = controlWs; + controlWs = null; + if (controlSocket) { + closeObserverSocket(controlSocket, { shouldReconnect: false }); + } + + isConnected = false; + isConnecting = false; + isControlConnected = false; + isControlConnecting = false; + if (typeof reason === "string" && reason.trim()) { + rememberLastError(reason); + rememberControlLastError(reason); + } else if (disableAutoReconnect) { + clearLastError(); + clearControlLastError(); + } + + if (!silent) { + broadcastStatus(); + } else { + setBadgeState(); + } +} + +async function connectObserver(forceReconnect = false) { + if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) { + return; + } + if (isConnecting && !forceReconnect) { + return; + } + + clearReconnectTimer(); + clearHeartbeatTimer(); + + const settings = await readSettings(); + applySettingsState(settings); + if (!isEnabled) { + clearKeepAliveAlarm(); + isConnecting = false; + isConnected = false; + isControlConnecting = false; + isControlConnected = false; + clearLastError(); + clearControlLastError(); + broadcastStatus(); + return; + } + + ensureKeepAliveAlarm(); + + const url = buildObserverUrl(settings); + if (!url) { + isConnecting = false; + isConnected = false; + rememberLastError("缺少 serverUrl 或 bridgeKey,无法建立连接"); + broadcastStatus(); + return; + } + + if (forceReconnect && ws) { + closeObserverSocket(ws, { shouldReconnect: false }); + ws = null; + } + + isConnecting = true; + isConnected = false; + clearLastError(); + broadcastStatus(); + + logInfo(`连接 observer: ${url}`); + const socket = new WebSocket(url); + ws = socket; + + socket.onopen = () => { + if (ws !== socket) { + return; + } + reconnectAttempts = 0; + isConnected = true; + isConnecting = false; + clearLastError(); + startHeartbeat(); + broadcastStatus(); + void connectControl(); + void triggerPageCapture("ws_open"); + }; + + socket.onmessage = async (event) => { + try { + const payload = JSON.parse(event.data); + pushLog("in", payload?.type || "observer_message", stringifyPayload(payload?.data)); + await handleObserverMessage(payload); + } catch (error) { + logWarn("解析消息失败", error?.message || String(error)); + } + }; + + socket.onclose = () => { + const shouldReconnect = !expectedClosedSockets.has(socket) && isEnabled; + if (ws === socket) { + ws = null; + } + isConnected = false; + isConnecting = false; + clearHeartbeatTimer(); + if (shouldReconnect) { + scheduleReconnect(); + } else { + reconnectAttempts = 0; + broadcastStatus(); + } + }; + + socket.onerror = (error) => { + rememberLastError(error?.message || String(error)); + broadcastStatus(); + }; +} + +async function connectControl(forceReconnect = false) { + if (controlWs && controlWs.readyState === WebSocket.OPEN && !forceReconnect) { + return; + } + if (isControlConnecting && !forceReconnect) { + return; + } + + clearControlReconnectTimer(); + + const settings = await readSettings(); + applySettingsState(settings); + if (!isEnabled) { + isControlConnecting = false; + isControlConnected = false; + clearControlLastError(); + broadcastStatus(); + return; + } + + const url = buildControlUrl(settings); + if (!url) { + isControlConnecting = false; + isControlConnected = false; + rememberControlLastError("缺少 serverUrl 或 bridgeKey,无法建立控制通道"); + broadcastStatus(); + return; + } + + if (forceReconnect && controlWs) { + closeObserverSocket(controlWs, { shouldReconnect: false }); + controlWs = null; + } + + isControlConnecting = true; + isControlConnected = false; + clearControlLastError(); + broadcastStatus(); + + logInfo(`连接 control: ${url}`); + const socket = new WebSocket(url); + controlWs = socket; + + socket.onopen = () => { + if (controlWs !== socket) { + return; + } + controlReconnectAttempts = 0; + isControlConnected = true; + isControlConnecting = false; + clearControlLastError(); + broadcastStatus(); + }; + + socket.onmessage = async (event) => { + try { + const payload = JSON.parse(event.data); + pushLog("in", payload?.type || "control_message", stringifyPayload(payload?.data)); + await handleControlMessage(payload); + } catch (error) { + logWarn("解析控制消息失败", error?.message || String(error)); + } + }; + + socket.onclose = () => { + const shouldReconnect = !expectedClosedSockets.has(socket) && isEnabled; + if (controlWs === socket) { + controlWs = null; + } + isControlConnected = false; + isControlConnecting = false; + if (shouldReconnect) { + scheduleControlReconnect(); + } else { + controlReconnectAttempts = 0; + broadcastStatus(); + } + }; + + socket.onerror = (error) => { + rememberControlLastError(error?.message || String(error)); + broadcastStatus(); + }; +} + +function scheduleReconnect() { + if (!isEnabled || reconnectTimer) { + return; + } + reconnectAttempts += 1; + const delay = Math.min( + RECONNECT_MAX_DELAY_MS, + RECONNECT_MIN_DELAY_MS * Math.pow(2, reconnectAttempts - 1), + ); + isConnecting = true; + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + await connectObserver(); + }, delay); + logInfo(`连接断开,${delay}ms 后重连(第 ${reconnectAttempts} 次)`); + broadcastStatus(); +} + +function scheduleControlReconnect() { + if (!isEnabled || controlReconnectTimer) { + return; + } + controlReconnectAttempts += 1; + const delay = Math.min( + RECONNECT_MAX_DELAY_MS, + RECONNECT_MIN_DELAY_MS * Math.pow(2, controlReconnectAttempts - 1), + ); + isControlConnecting = true; + controlReconnectTimer = setTimeout(async () => { + controlReconnectTimer = null; + await connectControl(); + }, delay); + logInfo(`控制通道断开,${delay}ms 后重连(第 ${controlReconnectAttempts} 次)`); + broadcastStatus(); +} + +async function ensureObserverEnabled() { + if (isConnected || isConnecting) { + if (!isControlConnected && !isControlConnecting) { + await connectControl(); + } + return; + } + const settings = await readSettings(); + applySettingsState(settings); + if (!isEnabled) { + broadcastStatus(); + return; + } + await connectObserver(); +} + +async function handleKeepAliveAlarm() { + const settings = await readSettings(); + applySettingsState(settings); + if (!isEnabled) { + clearKeepAliveAlarm(); + isConnecting = false; + isConnected = false; + isControlConnecting = false; + isControlConnected = false; + broadcastStatus(); + return; + } + + if (isConnected) { + sendObserverMessage({ type: "heartbeat", timestamp: Date.now() }); + if (isControlConnected) { + sendControlMessage({ type: "heartbeat", timestamp: Date.now() }); + } else if (!isControlConnecting) { + await connectControl(); + } + return; + } + + if (!isConnecting) { + await connectObserver(); + } + if (isConnected && !isControlConnected && !isControlConnecting) { + await connectControl(); + } } async function handleObserverMessage(payload) { const type = payload?.type; if (type === "heartbeat_ack" || type === "connection_ack") { + clearLastError(); + broadcastStatus(); return; } if (type === "force_disconnect") { logInfo("收到桌面端主动断开指令"); - disconnectObserver(true); + await disconnectObserver({ + disableAutoReconnect: true, + reason: payload?.message || "Lime 已主动断开当前扩展连接。", + }); return; } if (type !== "command" || !payload.data) { @@ -244,6 +644,171 @@ async function handleObserverMessage(payload) { await executeRemoteCommand(payload.data); } +async function handleControlMessage(payload) { + const type = payload?.type; + if (type === "heartbeat_ack" || type === "connection_ack") { + clearControlLastError(); + broadcastStatus(); + return; + } + if (type === "command_result" || type === "page_info_update") { + broadcastStatus(); + } +} + +async function getTabById(tabId) { + try { + return await chrome.tabs.get(tabId); + } catch (_) { + return null; + } +} + +async function ensureCapturableTab(tabId) { + const tab = await getTabById(tabId); + if (!tab?.id) { + throw new Error("没有可用的活动标签页"); + } + if (!isCapturableUrl(tab.url)) { + throw new Error("当前标签页不是可控制的网页,请切换到 http/https 页面后重试"); + } + return tab; +} + +async function ensureDebuggerAttached(tabId) { + if (!Number.isInteger(tabId) || tabId <= 0) { + return false; + } + if (debuggerAttachedTabIds.has(tabId)) { + return true; + } + + const tab = await getTabById(tabId); + if (!tab?.id || !isDebuggerAttachableUrl(tab.url)) { + return false; + } + + try { + await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION); + } catch (error) { + const message = error?.message || String(error); + if (/already attached/i.test(message)) { + debuggerAttachedTabIds.add(tabId); + return true; + } + logWarn("附着 Chrome debugger 失败", { tabId, message }); + return false; + } + + debuggerAttachedTabIds.add(tabId); + await Promise.allSettled([ + chrome.debugger.sendCommand({ tabId }, "Runtime.enable"), + chrome.debugger.sendCommand({ tabId }, "Page.enable"), + chrome.debugger.sendCommand({ tabId }, "DOM.enable"), + ]); + broadcastStatus(); + return true; +} + +async function sendDebuggerCommand(tabId, method, params = {}) { + if (!(await ensureDebuggerAttached(tabId))) { + throw new Error("当前标签页未能附着 Lime CDP 会话,请确认插件已获取 debugger 权限。"); + } + return await chrome.debugger.sendCommand({ tabId }, method, params); +} + +function readPayloadNumber(payload, key) { + const value = payload?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readScrollInstruction(payload, fallbackText) { + const text = + typeof fallbackText === "string" && fallbackText.trim() + ? fallbackText.trim() + : typeof payload?.text === "string" && payload.text.trim() + ? payload.text.trim() + : ""; + let direction = "down"; + let amount = 500; + + if (text.includes(":")) { + const parts = text.split(":"); + direction = normalizeText(parts[0]) || "down"; + const parsed = Number(parts[1]); + if (!Number.isNaN(parsed) && parsed > 0) { + amount = parsed; + } + } else if (text) { + direction = normalizeText(text) || "down"; + } + + return { direction, amount }; +} + +async function executeDebuggerClick(tabId, payload) { + const x = readPayloadNumber(payload, "x"); + const y = readPayloadNumber(payload, "y"); + if (x == null || y == null) { + return false; + } + + await sendDebuggerCommand(tabId, "Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + }); + await sendDebuggerCommand(tabId, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 1, + }); + return true; +} + +async function executeDebuggerType(tabId, text, target) { + if (target || !text) { + return false; + } + await sendDebuggerCommand(tabId, "Input.insertText", { + text, + }); + return true; +} + +async function executeDebuggerScroll(tabId, payload, text) { + const { direction, amount } = readScrollInstruction(payload, text); + const deltaX = + direction === "left" ? -amount : direction === "right" ? amount : 0; + const deltaY = + direction === "up" ? -amount : direction === "down" ? amount : 0; + + await sendDebuggerCommand(tabId, "Runtime.evaluate", { + expression: `window.scrollBy(${deltaX}, ${deltaY});`, + userGesture: true, + awaitPromise: false, + }); + return { + direction, + amount, + }; +} + +async function detachDebugger(tabId) { + if (!debuggerAttachedTabIds.has(tabId)) { + return; + } + debuggerAttachedTabIds.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch (_) {} + broadcastStatus(); +} + async function executeRemoteCommand(commandData) { const command = String(commandData.command || "").trim(); if (!command) { @@ -281,26 +846,57 @@ async function executeRemoteCommand(commandData) { } try { - const response = await sendCommandToTab(tabId, { - type: "EXECUTE_COMMAND", - data: commandData, - }); + const tab = await ensureCapturableTab(tabId); + const payload = + commandData.payload && typeof commandData.payload === "object" + ? commandData.payload + : null; + const text = commandData.text == null ? "" : String(commandData.text); + let handledByDebugger = false; + let debuggerMessage = null; - if (response?.status === "error") { - sendCommandResult({ - requestId, - sourceClientId, - status: "error", - error: response.error || "命令执行失败", + if (command === "click") { + handledByDebugger = await executeDebuggerClick(tab.id, payload); + if (handledByDebugger) { + debuggerMessage = "click 执行成功"; + } + } else if (command === "type") { + handledByDebugger = await executeDebuggerType(tab.id, text, commandData.target); + if (handledByDebugger) { + debuggerMessage = "type 执行成功"; + } + } else if (command === "scroll" || command === "scroll_page") { + const scrollResult = await executeDebuggerScroll(tab.id, payload, text); + handledByDebugger = true; + debuggerMessage = `${command} 执行成功`; + commandData.text = `${scrollResult.direction}:${scrollResult.amount}`; + } + + activeTabId = tab.id; + let response = null; + if (!handledByDebugger) { + await ensureDebuggerAttached(tab.id); + response = await sendCommandToTab(tab.id, { + type: "EXECUTE_COMMAND", + data: commandData, }); - return; + + if (response?.status === "error") { + sendCommandResult({ + requestId, + sourceClientId, + status: "error", + error: response.error || "命令执行失败", + }); + return; + } } sendCommandResult({ requestId, sourceClientId, status: "success", - message: response?.message || `${command} 执行成功`, + message: debuggerMessage || response?.message || `${command} 执行成功`, data: response?.data, }); @@ -325,13 +921,11 @@ async function resolveCommandTargetTabId(rawTarget) { const byId = Number(normalizedTarget); if (Number.isInteger(byId) && byId > 0) { - try { - const tab = await chrome.tabs.get(byId); - if (tab?.id) { - activeTabId = tab.id; - return tab.id; - } - } catch (_) {} + const tab = await getTabById(byId); + if (tab?.id) { + activeTabId = tab.id; + return tab.id; + } } return await resolveTargetTabId(); @@ -383,6 +977,7 @@ async function handleOpenUrl(commandData, waitForPageInfo) { }); activeTabId = tab.id; + await ensureDebuggerAttached(tab.id); sendCommandResult({ requestId, sourceClientId, @@ -428,11 +1023,7 @@ async function handleSwitchTab(commandData, waitForPageInfo) { let targetTab = null; const byId = Number(raw); if (!Number.isNaN(byId) && byId > 0) { - try { - targetTab = await chrome.tabs.get(byId); - } catch (_) { - targetTab = null; - } + targetTab = await getTabById(byId); } if (!targetTab) { @@ -507,6 +1098,11 @@ async function handleListTabs(commandData) { } function sendCommandResult(data) { + pushLog( + data?.status === "error" ? "warn" : "sys", + data?.command || "command_result", + data?.error || data?.message || stringifyPayload(data?.data), + ); sendObserverMessage({ type: "command_result", data, @@ -515,12 +1111,10 @@ function sendCommandResult(data) { async function resolveTargetTabId() { if (activeTabId) { - try { - const tab = await chrome.tabs.get(activeTabId); - if (tab && !tab.discarded) { - return tab.id; - } - } catch (_) {} + const tab = await getTabById(activeTabId); + if (tab && !tab.discarded) { + return tab.id; + } } const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); @@ -534,13 +1128,14 @@ async function resolveTargetTabId() { async function sendCommandToTab(tabId, payload) { try { return await chrome.tabs.sendMessage(tabId, payload); - } catch (error) { + } catch (_) { await injectContentScript(tabId); return await chrome.tabs.sendMessage(tabId, payload); } } async function injectContentScript(tabId) { + await ensureCapturableTab(tabId); await chrome.scripting.executeScript({ target: { tabId }, files: ["content_script.js"], @@ -557,30 +1152,24 @@ async function triggerPageCapture(reason, retry = 0) { return; } - let tab = null; - try { - tab = await chrome.tabs.get(tabId); - } catch (_) { - tab = null; - } - + const tab = await getTabById(tabId); if (!tab || !isCapturableUrl(tab.url)) { return; } try { + if (!(await ensureDebuggerAttached(tabId))) { + throw new Error("页面抓取前无法附着当前 Chrome CDP 会话"); + } await sendCommandToTab(tabId, { type: "REQUEST_PAGE_CAPTURE", data: { reason }, }); } catch (error) { if (retry < PAGE_CAPTURE_RETRY_LIMIT) { - setTimeout( - () => { - triggerPageCapture(reason, retry + 1); - }, - 250 * (retry + 1), - ); + setTimeout(() => { + void triggerPageCapture(reason, retry + 1); + }, 250 * (retry + 1)); } else { logWarn("页面抓取请求失败", error?.message || String(error)); } @@ -616,20 +1205,43 @@ function waitTabLoadComplete(tabId, timeoutMs) { }); } +async function listTrackedTabs() { + const trackedTabIds = new Set(debuggerAttachedTabIds.values()); + if (Number.isInteger(activeTabId) && activeTabId > 0) { + trackedTabIds.add(activeTabId); + } + + const tabs = await Promise.all( + Array.from(trackedTabIds.values()).map((tabId) => getTabById(tabId)), + ); + + return tabs + .filter((tab) => tab?.id) + .map((tab) => ({ + tabId: tab.id, + state: + tab.id === activeTabId + ? "active" + : debuggerAttachedTabIds.has(tab.id) + ? "attached" + : "idle", + url: tab.url || "", + title: tab.title || "", + isAgent: false, + isRetained: false, + })); +} + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { const type = request?.type; if (type === "GET_STATUS") { - sendResponse({ - isConnected, - monitoringEnabled, - activeTabId, - latestPageInfo, - settings: { - ...lastSettings, - bridgeKey: lastSettings.bridgeKey ? "***" : "", - }, - }); + sendResponse(buildStatusPayload()); + return true; + } + + if (type === "getRelayStatus") { + sendResponse(buildRelayCompatibilityPayload()); return true; } @@ -652,13 +1264,25 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { typeof patch.monitoringEnabled === "boolean" ? patch.monitoringEnabled : monitoringEnabled, + enabled: + typeof patch.enabled === "boolean" + ? patch.enabled + : patch.reconnect === true + ? true + : lastSettings.enabled, }; writeSettings(next).then(async () => { - lastSettings = { ...lastSettings, ...next }; - monitoringEnabled = Boolean(next.monitoringEnabled); - if (request?.data?.reconnect === true) { - await connectObserver(true); + applySettingsState(next); + if (!isEnabled) { + await disconnectObserver({ disableAutoReconnect: false, silent: true }); + clearLastError(); + clearControlLastError(); + clearKeepAliveAlarm(); + } else if (patch.reconnect === true) { + await Promise.allSettled([connectObserver(true), connectControl(true)]); + } else { + ensureKeepAliveAlarm(); } broadcastStatus(); sendResponse({ success: true }); @@ -666,13 +1290,37 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } - if (type === "TOGGLE_CONNECTION") { - if (isConnected) { - disconnectObserver(true); - sendResponse({ success: true, isConnected: false }); + if (type === "toggleRelay") { + if (isEnabled) { + disconnectObserver({ disableAutoReconnect: true }).then(() => { + sendResponse(buildRelayCompatibilityPayload()); + }); } else { - connectObserver().then(() => { - sendResponse({ success: true, isConnected: isConnected }); + const next = { ...lastSettings, enabled: true }; + writeSettings({ enabled: true }).then(async () => { + applySettingsState(next); + await Promise.allSettled([connectObserver(true), connectControl(true)]); + sendResponse(buildRelayCompatibilityPayload()); + }); + } + return true; + } + + if (type === "TOGGLE_CONNECTION") { + if (isEnabled) { + disconnectObserver({ disableAutoReconnect: true }).then(() => { + sendResponse({ success: true, isConnected: false, enabled: false }); + }); + } else { + const next = { ...lastSettings, enabled: true }; + writeSettings({ enabled: true }).then(async () => { + applySettingsState(next); + await Promise.allSettled([connectObserver(true), connectControl(true)]); + sendResponse({ + success: true, + isConnected, + enabled: true, + }); }); } return true; @@ -680,9 +1328,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (type === "TOGGLE_MONITORING") { monitoringEnabled = !monitoringEnabled; + lastSettings = { ...lastSettings, monitoringEnabled }; writeSettings({ monitoringEnabled }).then(() => { if (monitoringEnabled) { - triggerPageCapture("manual"); + void triggerPageCapture("manual"); } broadcastStatus(); sendResponse({ success: true, monitoringEnabled }); @@ -697,6 +1346,26 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } + if (type === "getTabList") { + listTrackedTabs() + .then((tabs) => { + sendResponse({ tabs }); + }) + .catch((error) => { + sendResponse({ + tabs: [], + error: error?.message || String(error), + }); + }); + return true; + } + + if (type === "getLogs") { + const limit = Math.max(1, Math.min(Number(request?.limit) || 100, MAX_LOG_ENTRIES)); + sendResponse({ logs: logBuffer.slice(-limit) }); + return true; + } + if (type === "PAGE_INFO_UPDATE") { const senderTabId = sender?.tab?.id; if (senderTabId && activeTabId && senderTabId !== activeTabId) { @@ -736,6 +1405,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { chrome.tabs.onActivated.addListener(async ({ tabId }) => { activeTabId = tabId; + if (isEnabled && !isConnected && !isConnecting) { + void ensureObserverEnabled(); + } + void ensureDebuggerAttached(tabId); await triggerPageCapture("tab_activated"); broadcastStatus(); }); @@ -745,10 +1418,42 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { activeTabId = tabId; } if (tab.active && changeInfo.status === "complete") { + if (isEnabled && !isConnected && !isConnecting) { + void ensureObserverEnabled(); + } + void ensureDebuggerAttached(tabId); await triggerPageCapture("tab_updated"); } }); +chrome.tabs.onRemoved.addListener((tabId) => { + void detachDebugger(tabId); +}); + +chrome.debugger.onDetach.addListener((source, reason) => { + const tabId = source?.tabId; + if (!Number.isInteger(tabId)) { + return; + } + debuggerAttachedTabIds.delete(tabId); + logWarn("Chrome debugger 已断开", { tabId, reason }); + broadcastStatus(); +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === KEEPALIVE_ALARM_NAME) { + void handleKeepAliveAlarm(); + } +}); + +chrome.runtime.onStartup.addListener(() => { + void init(); +}); + +chrome.runtime.onInstalled.addListener(() => { + void init(true); +}); + async function loadAutoConfig() { try { const configUrl = chrome.runtime.getURL("auto_config.json"); @@ -768,6 +1473,7 @@ async function loadAutoConfig() { bridgeKey: config.bridgeKey, profileKey: config.profileKey || "default", monitoringEnabled: config.monitoringEnabled !== false, + enabled: config.enabled !== false, }); logInfo("自动配置已应用"); } else { @@ -787,21 +1493,16 @@ async function loadAutoConfig() { } } -async function init() { +async function init(forceReconnect = false) { await loadAutoConfig(); const settings = await readSettings(); - lastSettings = settings; - monitoringEnabled = Boolean(settings.monitoringEnabled); + applySettingsState(settings); const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); activeTabId = tabs[0]?.id || null; - - if (settings.serverUrl && settings.bridgeKey) { - await connectObserver(); - } else { - setConnectionState(false); - broadcastStatus(); + if (activeTabId) { + void ensureDebuggerAttached(activeTabId); } chrome.storage.local.get(["latestPageInfo"], (stored) => { @@ -810,6 +1511,32 @@ async function init() { broadcastStatus(); } }); + + if (!isEnabled) { + clearKeepAliveAlarm(); + isConnecting = false; + isConnected = false; + clearLastError(); + broadcastStatus(); + return; + } + + ensureKeepAliveAlarm(); + + if (settings.serverUrl && settings.bridgeKey) { + await Promise.allSettled([ + connectObserver(forceReconnect), + connectControl(forceReconnect), + ]); + } else { + isConnecting = false; + isConnected = false; + isControlConnecting = false; + isControlConnected = false; + clearLastError(); + clearControlLastError(); + broadcastStatus(); + } } -init(); +void init(); diff --git a/extensions/lime-chrome/content_script.js b/extensions/lime-chrome/content_script.js index b9b85722d..b0d291f4a 100644 --- a/extensions/lime-chrome/content_script.js +++ b/extensions/lime-chrome/content_script.js @@ -214,6 +214,721 @@ } } + const siteAdapterHelpers = { + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, + text(value) { + return String(value?.textContent || "") + .replace(/\s+/g, " ") + .trim(); + }, + absoluteUrl(value) { + try { + return new URL(value, window.location.href).toString(); + } catch { + return ""; + } + }, + uniqueBy(items, getKey) { + const seen = new Set(); + return items.filter((item) => { + const key = getKey(item); + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }, + async waitFor(test, timeoutMs = 12000, intervalMs = 250) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = await test(); + if (value) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return null; + }, + take(items, limit) { + return items.slice(0, Math.max(1, limit)); + }, + number(value, fallbackValue) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallbackValue; + }, + looksLikeLoginWall() { + const text = (document.body?.textContent || "").slice(0, 4000); + return /(登录|登入|sign in|log in|继续访问|验证你是人类|扫码登录)/i.test( + text, + ); + }, + }; + + const siteAdapterRunners = { + "github/search": async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => document.querySelectorAll("a.v-align-middle").length > 0, + 12000, + 300, + ); + const links = Array.from(document.querySelectorAll("a.v-align-middle")); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("li, div.search-title, div.Box-row"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "GitHub 搜索结果为空,可能需要先登录后再访问。", + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; + }, + "github/issues": async (args, helpers) => { + const repo = String(args.repo || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => + document.querySelectorAll( + 'a[data-hovercard-type="issue"], a[id^="issue_"]', + ).length > 0, + 12000, + 300, + ); + const links = Array.from( + document.querySelectorAll( + 'a[data-hovercard-type="issue"], a[id^="issue_"]', + ), + ); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const row = anchor.closest("div, li"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + status: helpers.text( + row?.querySelector( + '[aria-label*="Open"], [aria-label*="Closed"], .State', + ), + ), + summary: helpers.text( + row?.querySelector( + "span.color-fg-muted, .markdown-title + div", + ), + ), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `GitHub 仓库 ${repo} 的 issue 列表为空,可能需要登录后访问。`, + }; + } + + return { + ok: true, + data: { + repo, + items, + count: items.length, + }, + source_url: location.href, + }; + }, + "zhihu/search": async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + const hrefTokens = ["/question/", "/answer/", "/zvideo/"]; + const maxCandidates = Math.max(limit * 8, 40); + const main = document.querySelector("main") || document.body; + const collectCandidates = (maxCount) => { + if (!main) { + return []; + } + const results = []; + const seen = new Set(); + for (const anchor of main.getElementsByTagName("a")) { + const href = anchor.getAttribute("href") || ""; + if (!hrefTokens.some((token) => href.includes(token))) { + continue; + } + const title = helpers.text(anchor); + const url = helpers.absoluteUrl(href); + if (!title || !url || seen.has(url)) { + continue; + } + seen.add(url); + results.push({ anchor, title, url }); + if (results.length >= maxCount) { + break; + } + } + return results; + }; + await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300); + const items = helpers.take( + collectCandidates(maxCandidates) + .map(({ anchor, title, url }) => { + const card = anchor.closest("div"); + return { + title, + url, + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `知乎没有返回 "${query}" 的搜索结果,可能需要先登录。`, + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; + }, + "zhihu/hot": async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + const hrefTokens = ["/question/"]; + const maxCandidates = Math.max(limit * 6, 30); + const main = document.querySelector("main") || document.body; + const collectCandidates = (maxCount) => { + if (!main) { + return []; + } + const results = []; + const seen = new Set(); + for (const anchor of main.getElementsByTagName("a")) { + const href = anchor.getAttribute("href") || ""; + if (!hrefTokens.some((token) => href.includes(token))) { + continue; + } + const title = helpers.text(anchor); + const url = helpers.absoluteUrl(href); + if (!title || !url || seen.has(url)) { + continue; + } + seen.add(url); + results.push({ anchor, title, url }); + if (results.length >= maxCount) { + break; + } + } + return results; + }; + await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300); + const items = helpers.take( + collectCandidates(maxCandidates) + .map(({ anchor, title, url }, index) => { + const card = anchor.closest("section, div"); + return { + rank: index + 1, + title, + url, + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "当前页面没有返回知乎热榜内容,可能需要先登录知乎。", + }; + } + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; + }, + "bilibili/search": async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => + document.querySelectorAll( + 'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]', + ).length > 0, + 12000, + 300, + ); + const links = Array.from( + document.querySelectorAll( + 'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]', + ), + ); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("div"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + author: helpers.text( + card?.querySelector( + ".bili-video-card__info--author, .up-name, .bili-video-card__info--bottom .bili-video-card__info--author", + ), + ), + summary: helpers.text( + card?.querySelector( + ".bili-video-card__info--desc, .des, .bili-video-card__info--title", + ), + ), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`, + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; + }, + "36kr/newsflash": async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => document.querySelectorAll('a[href*="/p/"]').length > 0, + 12000, + 300, + ); + const links = Array.from(document.querySelectorAll('a[href*="/p/"]')); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("div, article"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; + }, + "linux-do/hot": 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 热门话题失败。", + }; + } + }, + "linux-do/categories": 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 分类列表失败。", + }; + } + }, + "smzdm/search": 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, + }; + }, + "yahoo-finance/quote": async (args, helpers) => { + const symbol = String(args.symbol || "").trim().toUpperCase(); + if (!symbol) { + return { + ok: false, + error_code: "invalid_args", + error_message: "symbol 不能为空。", + }; + } + + const normalizeTextValue = (value) => { + const text = String(value ?? "") + .replace(/\s+/g, " ") + .trim(); + return text || null; + }; + const normalizeNumberValue = (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 = normalizeTextValue(document.querySelector("h1")?.textContent); + const daysRangeText = document.querySelector('[data-test="DAYS_RANGE-value"]') + ?.textContent; + const daysRangeParts = typeof daysRangeText === "string" + ? daysRangeText.split(" - ") + : []; + const item = { + symbol, + name: titleText + ? titleText.replace(/\s*\([^)]+\)\s*$/, "").trim() || symbol + : symbol, + price: normalizeNumberValue( + document.querySelector('[data-testid="qsp-price"]')?.textContent || + document.querySelector( + 'fin-streamer[data-field="regularMarketPrice"]', + )?.textContent, + ), + change: normalizeNumberValue( + document.querySelector('[data-testid="qsp-price-change"]') + ?.textContent || + document.querySelector( + 'fin-streamer[data-field="regularMarketChange"]', + )?.textContent, + ), + changePercent: normalizeNumberValue( + document.querySelector('[data-testid="qsp-price-change-percent"]') + ?.textContent || + document.querySelector( + 'fin-streamer[data-field="regularMarketChangePercent"]', + )?.textContent, + ), + open: normalizeNumberValue( + document.querySelector('[data-test="OPEN-value"]')?.textContent, + ), + high: normalizeNumberValue(daysRangeParts[1]), + low: normalizeNumberValue(daysRangeParts[0]), + volume: normalizeNumberValue( + document.querySelector('[data-test="TD_VOLUME-value"]')?.textContent, + ), + marketCap: normalizeTextValue( + 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, + }; + }, + }; + async function executeCommand(commandData) { const command = String(commandData.command || "").trim(); const target = commandData.target; @@ -267,13 +982,23 @@ return { status: "success", message: "执行前进" }; } case "run_adapter": { - const script = String(payload?.script || "").trim(); - if (!script) { - return { status: "error", error: "run_adapter 缺少 script" }; + const adapterName = String(payload?.adapter_name || "").trim(); + if (!adapterName) { + return { status: "error", error: "run_adapter 缺少 adapter_name" }; } + const runner = siteAdapterRunners[adapterName]; + if (typeof runner !== "function") { + return { + status: "error", + error: `当前扩展内未注册站点适配器: ${adapterName}`, + }; + } + const adapterArgs = + payload && typeof payload.args === "object" && !Array.isArray(payload.args) + ? payload.args + : {}; try { - const executor = new Function(`return ${script};`); - const result = await executor(); + const result = await runner(adapterArgs, siteAdapterHelpers); return { status: "success", message: "run_adapter 执行成功", diff --git a/extensions/lime-chrome/icons/icon128.png b/extensions/lime-chrome/icons/icon128.png new file mode 100644 index 000000000..bdb7a0d96 Binary files /dev/null and b/extensions/lime-chrome/icons/icon128.png differ diff --git a/extensions/lime-chrome/icons/icon16.png b/extensions/lime-chrome/icons/icon16.png new file mode 100644 index 000000000..81d00b2f5 Binary files /dev/null and b/extensions/lime-chrome/icons/icon16.png differ diff --git a/extensions/lime-chrome/icons/icon32.png b/extensions/lime-chrome/icons/icon32.png new file mode 100644 index 000000000..6584fa638 Binary files /dev/null and b/extensions/lime-chrome/icons/icon32.png differ diff --git a/extensions/lime-chrome/icons/icon48.png b/extensions/lime-chrome/icons/icon48.png new file mode 100644 index 000000000..d4af6d98d Binary files /dev/null and b/extensions/lime-chrome/icons/icon48.png differ diff --git a/extensions/lime-chrome/lib/cdp/commands/dispatch.js b/extensions/lime-chrome/lib/cdp/commands/dispatch.js new file mode 100644 index 000000000..aaaf43acd --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/commands/dispatch.js @@ -0,0 +1,145 @@ +/** + * CDP command dispatch — main routing logic. + * + * Routes incoming forwardCDPCommand messages to the appropriate handler: + * Target.* → target-ops.js (tab creation, closing, activation) + * Extension.* → content_script/extension-ops.js (viewport, content, elements, actions) + * Other CDP → chrome.debugger.sendCommand (transparent forwarding) + * + * @param {import('../tabs/manager.js').TabManager} mgr + * @returns {(msg: any) => Promise} + */ + +import { createTargetOps } from './target-ops.js' +import { + extGetViewportInfo, extEnsureZoom, extCaptureViewport, + extExtractContent, extMarkElements, extClick, extInput, +} from '../../content_script/extension-ops.js' +import { RUNTIME_ENABLE_DELAY, CDP_COMMAND_TIMEOUT, withTimeout } from './utils.js' + +const MAX_QUEUE_DEPTH = 100 +const _tabQueues = new Map() + +function getTabQueue(tabId) { + if (!tabId) return null + let q = _tabQueues.get(tabId) + if (!q) { + q = { running: false, queue: [] } + _tabQueues.set(tabId, q) + } + return q +} + +async function processQueue(q) { + if (q.running) return + q.running = true + try { + while (q.queue.length > 0) { + const { task, resolve, reject } = q.queue.shift() + try { + resolve(await task()) + } catch (err) { + reject(err) + } + } + } finally { + q.running = false + } +} + +function enqueueForTab(tabId, task) { + const q = getTabQueue(tabId) + if (!q) return task() + if (q.queue.length >= MAX_QUEUE_DEPTH) { + return Promise.reject(new Error(`Tab ${tabId} command queue full (${MAX_QUEUE_DEPTH})`)) + } + return new Promise((resolve, reject) => { + q.queue.push({ task, resolve, reject }) + processQueue(q) + }) +} + +export function cleanupTabQueue(tabId) { + _tabQueues.delete(tabId) +} + +export function cleanupAllTabQueues() { + _tabQueues.clear() +} + +export function createDispatcher(mgr) { + + const { cdpCreateTarget, cdpCloseTarget, cdpCloseAllAgentTabs, cdpActivateTarget } = createTargetOps(mgr) + + async function cdpRuntimeEnable(debuggee, params) { + try { + await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') + await new Promise((r) => setTimeout(r, RUNTIME_ENABLE_DELAY)) + } catch (err) { + console.debug('[accio-relay] Runtime.disable pre-step failed:', err) + } + return withTimeout( + chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params), + CDP_COMMAND_TIMEOUT, + 'Runtime.enable', + ) + } + + return async function handleForwardCdpCommand(msg) { + const method = String(msg?.params?.method || '').trim() + const params = msg?.params?.params || undefined + const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined + const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined + const tabId = mgr.resolveTabId(sessionId, targetId) + + return enqueueForTab(tabId, async () => { + // ── Target.* commands (no tabId required for createTarget) ── + if (method === 'Target.createTarget') return cdpCreateTarget(params) + if (method === 'Target.closeAllAgentTabs') return cdpCloseAllAgentTabs() + + if (!tabId) throw new Error(`No attached tab for method ${method}`) + + mgr.onCdpCommand?.(tabId) + + if (method === 'Target.closeTarget') return cdpCloseTarget(params, tabId) + if (method === 'Target.activateTarget') return cdpActivateTarget(params, tabId) + + // ── Extension.* virtual commands ── + if (method === 'Extension.getViewportInfo') { + await mgr.ensureAttached(tabId) + return extGetViewportInfo(tabId) + } + if (method === 'Extension.ensureZoom') return extEnsureZoom(tabId, params) + if (method === 'Extension.captureViewport') { + await mgr.ensureAttached(tabId) + return extCaptureViewport(tabId, params) + } + if (method === 'Extension.extractContent') return extExtractContent(tabId) + if (method === 'Extension.markElements') return extMarkElements(tabId, params) + if (method === 'Extension.click') return extClick(tabId, params) + if (method === 'Extension.input') return extInput(tabId, params) + + // ── Standard CDP forwarding (requires debugger attach) ── + const ok = await mgr.ensureAttached(tabId) + if (!ok) throw new Error(`Failed to attach debugger to tab ${tabId} for ${method}`) + + /** @type {chrome.debugger.DebuggerSession} */ + const debuggee = { tabId } + + if (method === 'Runtime.enable') return cdpRuntimeEnable(debuggee, params) + + const tabState = mgr.get(tabId) + const mainSessionId = tabState?.sessionId + const debuggerSession = + sessionId && mainSessionId && sessionId !== mainSessionId + ? { ...debuggee, sessionId } + : debuggee + + return withTimeout( + chrome.debugger.sendCommand(debuggerSession, method, params), + CDP_COMMAND_TIMEOUT, + method, + ) + }) + } +} diff --git a/extensions/lime-chrome/lib/cdp/commands/target-ops.js b/extensions/lime-chrome/lib/cdp/commands/target-ops.js new file mode 100644 index 000000000..84b83dc1e --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/commands/target-ops.js @@ -0,0 +1,105 @@ +/** + * Target.* CDP command handlers. + * + * Handles tab creation, closing, activation, and agent tab lifecycle + * through Chrome extension APIs rather than raw CDP. + * + * @param {import('../tabs/manager.js').TabManager} mgr + */ + +import { TARGET_CREATE_DELAY, DEFAULT_MAX_RETAINED_TABS } from './utils.js' + +export function createTargetOps(mgr) { + + async function cdpCreateTarget(params) { + const url = typeof params?.url === 'string' ? params.url : 'about:blank' + const createInWindow = params?.createInWindow === true || params?.type === 'window' + const retain = params?.retain === true + const maxRetained = typeof params?.maxRetainedTabs === 'number' ? params.maxRetainedTabs : DEFAULT_MAX_RETAINED_TABS + + if (retain && mgr.retainedTabCount >= maxRetained) { + throw new Error(`Cannot retain: already at max (${maxRetained}). Close retained tabs or set retain=false.`) + } + + let tab + if (createInWindow) { + const win = await chrome.windows.create({ url, focused: false }) + tab = win.tabs?.[0] + if (!tab?.id) throw new Error('Failed to create window') + } else { + tab = await chrome.tabs.create({ url, active: false }) + if (!tab.id) throw new Error('Failed to create tab') + } + mgr.markAgent(tab.id, retain) + if (!createInWindow) { + await mgr.addToAgentGroup(tab.id) + } + await new Promise((r) => setTimeout(r, TARGET_CREATE_DELAY)) + const attached = await mgr.attach(tab.id) + if (!attached) { + mgr.deleteAgent(tab.id) + await chrome.tabs.remove(tab.id).catch(() => {}) + throw new Error('Failed to attach debugger to new tab') + } + return { targetId: attached.targetId, retained: retain } + } + + async function cdpCloseTarget(params, fallbackTabId) { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toClose = target ? mgr.getByTargetId(target) : fallbackTabId + if (!toClose) { + return { + success: false, + error: target + ? `Tab with targetId "${target}" not found. It may have already been closed or detached.` + : 'No target specified and no fallback tab available.', + } + } + if (!mgr.isAgent(toClose)) { + return { + success: false, + error: 'Cannot close this tab: it was not created by the agent. Only tabs opened via action=open can be closed.', + } + } + if (mgr.isRetained(toClose)) { + return { success: true, skipped: true, reason: 'Tab is retained — close skipped.' } + } + mgr.deleteAgent(toClose) + try { + await chrome.tabs.remove(toClose) + return { success: true } + } catch (err) { + return { success: false, error: `Failed to remove tab ${toClose}: ${err?.message || err}` } + } + } + + async function cdpCloseAllAgentTabs() { + const toClose = [] + let retained = 0 + for (const [id] of mgr.agentTabs.entries()) { + if (mgr.isRetained(id)) { retained++; continue } + toClose.push(id) + } + const results = await Promise.allSettled(toClose.map((id) => chrome.tabs.remove(id))) + for (let i = 0; i < toClose.length; i++) { + if (results[i].status === 'fulfilled') mgr.deleteAgent(toClose[i]) + } + const closed = results.filter((r) => r.status === 'fulfilled').length + return { success: true, closed, retained } + } + + async function cdpActivateTarget(params, fallbackTabId) { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toActivate = target ? mgr.getByTargetId(target) : fallbackTabId + if (!toActivate) return {} + const tab = await chrome.tabs.get(toActivate).catch(() => null) + if (!tab) return {} + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + } + await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) + return {} + } + + return { cdpCreateTarget, cdpCloseTarget, cdpCloseAllAgentTabs, cdpActivateTarget } +} diff --git a/extensions/lime-chrome/lib/cdp/commands/utils.js b/extensions/lime-chrome/lib/cdp/commands/utils.js new file mode 100644 index 000000000..7f71234ff --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/commands/utils.js @@ -0,0 +1,18 @@ +/** + * Shared CDP dispatch utilities and constants. + */ + +export const RUNTIME_ENABLE_DELAY = 10 +export const TARGET_CREATE_DELAY = 100 +export const CDP_COMMAND_TIMEOUT = 30000 +export const DEFAULT_MAX_RETAINED_TABS = 10 + +export function withTimeout(promise, ms, label) { + let timer + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`CDP command timed out after ${ms}ms: ${label}`)), ms) + }), + ]).finally(() => clearTimeout(timer)) +} diff --git a/extensions/lime-chrome/lib/cdp/events/debugger-domain.js b/extensions/lime-chrome/lib/cdp/events/debugger-domain.js new file mode 100644 index 000000000..a68b4bfd0 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/debugger-domain.js @@ -0,0 +1,78 @@ +/** + * Debugger (JS) domain event handlers. + * + * Handles breakpoint pauses, script parsing, and debugging state. + * + * Requires: `Debugger.enable` — only fires when relay enables Debugger domain. + * + * CRITICAL: Debugger.paused BLOCKS script execution until Debugger.resume() + * (or step commands) is sent. If no response is sent, the page freezes. + * + * Strategy: + * - If the relay enabled the Debugger domain, it should handle pauses. + * - We auto-resume as a safety measure to prevent indefinite page freeze, + * since our extension does NOT use the Debugger domain for breakpoints. + * - Forward events to relay for awareness. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:debugger') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleDebuggerDomainEvent(method, tabId, params, _ctx) { + switch (method) { + case 'Debugger.paused': + log.warn( + 'script paused:', tabId, + 'reason:', params?.reason, + 'frames:', params?.callFrames?.length || 0, + 'hitBreakpoints:', params?.hitBreakpoints?.length || 0, + ) + chrome.debugger + .sendCommand({ tabId }, 'Debugger.resume') + .then(() => log.debug('auto-resumed paused script:', tabId)) + .catch((err) => log.warn('auto-resume failed:', tabId, err?.message || err)) + break + + case 'Debugger.resumed': + log.debug('script resumed:', tabId) + break + + case 'Debugger.scriptParsed': + if (params?.url) { + log.debug( + 'script parsed:', tabId, + 'id:', params?.scriptId, + 'url:', truncate(params?.url, 100), + ) + } + break + + case 'Debugger.scriptFailedToParse': + log.warn( + 'script parse failed:', tabId, + 'id:', params?.scriptId, + 'url:', truncate(params?.url || '', 100), + ) + break + + case 'Debugger.breakpointResolved': + log.debug( + 'breakpoint resolved:', tabId, + 'id:', params?.breakpointId, + 'line:', params?.location?.lineNumber, + ) + break + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/dialog.js b/extensions/lime-chrome/lib/cdp/events/dialog.js new file mode 100644 index 000000000..e76ea86da --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/dialog.js @@ -0,0 +1,99 @@ +/** + * Page dialog event handlers. + * + * Handles `Page.javascriptDialogOpening` and `Page.javascriptDialogClosed`. + * + * Requires: `Page.enable` (called in debugger-attach.js on attach). + * + * Dialog types: alert, confirm, prompt, beforeunload. + * + * CRITICAL: When `hasBrowserHandler` is false, failing to call + * `Page.handleJavaScriptDialog` will STALL page execution indefinitely. + * + * Strategy: + * - beforeunload → always auto-accept (agent-initiated navigation/close) + * - alert → auto-accept (dismiss) + * - confirm → auto-accept (true) + * - prompt → auto-accept with defaultPrompt value + * + * All dialog events are forwarded to the relay so the agent side + * can observe them. The relay can also send Page.handleJavaScriptDialog + * explicitly via normal CDP command path if it needs different behavior. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:dialog') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleDialogEvent(method, tabId, params, _ctx) { + if (method === 'Page.javascriptDialogOpening') { + return handleDialogOpening(tabId, params) + } + + if (method === 'Page.javascriptDialogClosed') { + log.debug( + 'dialog closed:', tabId, + 'result:', params?.result, + 'userInput:', params?.userInput ? `"${truncate(params.userInput, 40)}"` : '(none)', + ) + } +} + +function handleDialogOpening(tabId, params) { + const type = params?.type + const message = params?.message || '' + const defaultPrompt = params?.defaultPrompt || '' + const hasBrowserHandler = params?.hasBrowserHandler + + log.info( + 'dialog opening:', tabId, + `type=${type}`, + `hasBrowserHandler=${hasBrowserHandler}`, + `message="${truncate(message, 100)}"`, + ) + + switch (type) { + case 'beforeunload': + case 'alert': + case 'confirm': + acceptDialog(tabId, true) + break + case 'prompt': + acceptDialog(tabId, true, defaultPrompt) + break + default: + log.warn('unknown dialog type, auto-accepting:', tabId, type) + acceptDialog(tabId, true) + break + } +} + +/** + * Accept/dismiss a dialog immediately. + * + * No setTimeout — in MV3 the SW can suspend before the timer fires, + * leaving the page permanently stalled. The dialog event has already + * been forwarded to the relay by the time we reach here, so the relay + * can still override by sending its own Page.handleJavaScriptDialog + * before observing the dialogClosed event. + */ +function acceptDialog(tabId, accept, promptText) { + const cmdParams = { accept } + if (promptText !== undefined) cmdParams.promptText = promptText + + chrome.debugger + .sendCommand({ tabId }, 'Page.handleJavaScriptDialog', cmdParams) + .then(() => log.debug('dialog handled:', tabId, accept ? 'accepted' : 'dismissed')) + .catch((err) => log.warn('dialog handle failed:', tabId, err?.message || err)) +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/fetch.js b/extensions/lime-chrome/lib/cdp/events/fetch.js new file mode 100644 index 000000000..499271982 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/fetch.js @@ -0,0 +1,64 @@ +/** + * Fetch domain event handlers. + * + * Handles `Fetch.requestPaused` and `Fetch.authRequired`. + * + * Requires: `Fetch.enable({ patterns, handleAuthRequests })` — only fires + * when explicitly enabled by the relay/agent. This is an opt-in interception + * mechanism. + * + * CRITICAL: Both events BLOCK the request until a response command is sent. + * If no response is sent, the request hangs indefinitely. + * + * Strategy: + * - These events are agent-controlled (relay enables Fetch domain for + * request interception, mocking, etc.). The relay is expected to + * respond with Fetch.continueRequest / failRequest / fulfillRequest. + * - We log and forward to relay. No auto-response — the relay owns this. + * - A safety timeout could be added in future to auto-continue stale requests. + * + * Response commands: + * Fetch.requestPaused → Fetch.continueRequest / Fetch.failRequest / Fetch.fulfillRequest + * Fetch.authRequired → Fetch.continueWithAuth + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:fetch') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleFetchEvent(method, tabId, params, _ctx) { + switch (method) { + case 'Fetch.requestPaused': + log.info( + 'request paused:', tabId, + 'requestId:', params?.requestId, + 'url:', truncate(params?.request?.url || '', 120), + 'method:', params?.request?.method, + 'resourceType:', params?.resourceType, + 'responseStatus:', params?.responseStatusCode || '(none)', + ) + break + + case 'Fetch.authRequired': + log.warn( + 'auth required:', tabId, + 'requestId:', params?.requestId, + 'url:', truncate(params?.request?.url || '', 120), + 'scheme:', params?.authChallenge?.scheme, + 'realm:', params?.authChallenge?.realm, + 'source:', params?.authChallenge?.source, + ) + break + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/index.js b/extensions/lime-chrome/lib/cdp/events/index.js new file mode 100644 index 000000000..17b5f26b8 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/index.js @@ -0,0 +1,151 @@ +/** + * CDP event interceptor — routes debugger events to domain-specific handlers + * before they are forwarded to the relay. + * + * Architecture: + * chrome.debugger.onEvent → TabManager.onDebuggerEvent → interceptEvent() + * → domain handler (side effects: auto-accept, logging, session tracking) + * → forward to relay (unless handler returns { suppress: true }) + * + * Event handler modules: + * dialog.js — Page.javascriptDialogOpening/Closed (auto-accept) + * page-lifecycle.js — Page load, navigation, frame, download, interstitial, file chooser + * target.js — Target session/lifecycle management + * security.js — Certificate errors (auto-continue), security state + * fetch.js — Fetch.requestPaused/authRequired (relay-controlled) + * network.js — Network request lifecycle, WebSocket + * runtime.js — JS context, console, exceptions + * inspector.js — Debugger detach, target crash + * debugger-domain.js — Debugger.paused (auto-resume), script parsing + * input.js — Input.dragIntercepted (relay-controlled) + * log.js — Log.entryAdded (browser-level logs) + * + * CDP domain enable requirements: + * Page → Page.enable (called on attach) + * Network → Network.enable (relay must enable) + * Runtime → Runtime.enable (relay must enable) + * Security → Security.enable (relay must enable) + * Fetch → Fetch.enable (relay must enable, with patterns) + * Log → Log.enable (relay must enable) + * Debugger → Debugger.enable (relay must enable) + * Target → auto after attach (no explicit enable needed) + * Inspector → auto after attach (no explicit enable needed) + * Input → Input.setInterceptDrags (relay must enable) + * + * @module lib/cdp/events + */ + +import { handleDialogEvent } from './dialog.js' +import { handleTargetEvent } from './target.js' +import { handlePageLifecycleEvent } from './page-lifecycle.js' +import { handleSecurityEvent } from './security.js' +import { handleFetchEvent } from './fetch.js' +import { handleNetworkEvent } from './network.js' +import { handleRuntimeEvent } from './runtime.js' +import { handleInspectorEvent } from './inspector.js' +import { handleDebuggerDomainEvent } from './debugger-domain.js' +import { handleInputEvent } from './input.js' +import { handleLogEvent } from './log.js' + +/** + * @typedef {Object} EventContext + * @property {Map} childSession — child sessionId → parent tabId + * @property {Map>} childSets — parent tabId → child sessionIds + * @property {{ info: Function, warn: Function, debug: Function, error: Function }} log + */ + +const _handlers = new Map([ + // ── Page domain (requires Page.enable) ── + ['Page.javascriptDialogOpening', handleDialogEvent], + ['Page.javascriptDialogClosed', handleDialogEvent], + ['Page.loadEventFired', handlePageLifecycleEvent], + ['Page.domContentEventFired', handlePageLifecycleEvent], + ['Page.lifecycleEvent', handlePageLifecycleEvent], + ['Page.frameNavigated', handlePageLifecycleEvent], + ['Page.frameAttached', handlePageLifecycleEvent], + ['Page.frameDetached', handlePageLifecycleEvent], + ['Page.frameStartedLoading', handlePageLifecycleEvent], + ['Page.frameStoppedLoading', handlePageLifecycleEvent], + ['Page.frameStartedNavigating', handlePageLifecycleEvent], + ['Page.navigatedWithinDocument', handlePageLifecycleEvent], + ['Page.windowOpen', handlePageLifecycleEvent], + ['Page.downloadWillBegin', handlePageLifecycleEvent], + ['Page.downloadProgress', handlePageLifecycleEvent], + ['Page.fileChooserOpened', handlePageLifecycleEvent], + ['Page.interstitialShown', handlePageLifecycleEvent], + ['Page.interstitialHidden', handlePageLifecycleEvent], + + // ── Target domain (auto after attach) ── + ['Target.attachedToTarget', handleTargetEvent], + ['Target.detachedFromTarget', handleTargetEvent], + ['Target.targetCreated', handleTargetEvent], + ['Target.targetDestroyed', handleTargetEvent], + ['Target.targetCrashed', handleTargetEvent], + ['Target.targetInfoChanged', handleTargetEvent], + + // ── Security domain (requires Security.enable) ── + ['Security.certificateError', handleSecurityEvent], + ['Security.visibleSecurityStateChanged', handleSecurityEvent], + ['Security.securityStateChanged', handleSecurityEvent], + + // ── Fetch domain (requires Fetch.enable) ── + ['Fetch.requestPaused', handleFetchEvent], + ['Fetch.authRequired', handleFetchEvent], + + // ── Network domain (requires Network.enable) ── + ['Network.requestIntercepted', handleNetworkEvent], + ['Network.requestWillBeSent', handleNetworkEvent], + ['Network.responseReceived', handleNetworkEvent], + ['Network.loadingFinished', handleNetworkEvent], + ['Network.loadingFailed', handleNetworkEvent], + ['Network.webSocketCreated', handleNetworkEvent], + ['Network.webSocketClosed', handleNetworkEvent], + ['Network.webSocketFrameError', handleNetworkEvent], + + // ── Runtime domain (requires Runtime.enable) ── + ['Runtime.exceptionThrown', handleRuntimeEvent], + ['Runtime.consoleAPICalled', handleRuntimeEvent], + ['Runtime.executionContextCreated', handleRuntimeEvent], + ['Runtime.executionContextDestroyed', handleRuntimeEvent], + ['Runtime.executionContextsCleared', handleRuntimeEvent], + ['Runtime.bindingCalled', handleRuntimeEvent], + ['Runtime.inspectRequested', handleRuntimeEvent], + + // ── Inspector domain (auto after attach) ── + ['Inspector.detached', handleInspectorEvent], + ['Inspector.targetCrashed', handleInspectorEvent], + ['Inspector.targetReloadedAfterCrash', handleInspectorEvent], + + // ── Debugger domain (requires Debugger.enable) ── + ['Debugger.paused', handleDebuggerDomainEvent], + ['Debugger.resumed', handleDebuggerDomainEvent], + ['Debugger.scriptParsed', handleDebuggerDomainEvent], + ['Debugger.scriptFailedToParse', handleDebuggerDomainEvent], + ['Debugger.breakpointResolved', handleDebuggerDomainEvent], + + // ── Input domain (requires Input.setInterceptDrags) ── + ['Input.dragIntercepted', handleInputEvent], + + // ── Log domain (requires Log.enable) ── + ['Log.entryAdded', handleLogEvent], +]) + +/** + * Process a CDP debugger event through registered handlers. + * + * @param {string} method — CDP event method (e.g. 'Page.javascriptDialogOpening') + * @param {number} tabId + * @param {object} params — CDP event params + * @param {EventContext} ctx + * @returns {{ suppress?: boolean }} — if suppress is true, caller should not forward to relay + */ +export function interceptEvent(method, tabId, params, ctx) { + const handler = _handlers.get(method) + if (!handler) return {} + try { + return handler(method, tabId, params, ctx) || {} + } catch (err) { + ctx.log?.error?.(`event handler threw for ${method} on tab ${tabId}:`, err) + return {} + } +} diff --git a/extensions/lime-chrome/lib/cdp/events/input.js b/extensions/lime-chrome/lib/cdp/events/input.js new file mode 100644 index 000000000..0df5432a6 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/input.js @@ -0,0 +1,36 @@ +/** + * Input domain event handlers. + * + * Handles `Input.dragIntercepted`. + * + * Requires: `Input.setInterceptDrags({ enabled: true })` — only fires + * when explicitly enabled by the relay/agent. + * + * CRITICAL: Input.dragIntercepted BLOCKS the drag operation until + * `Input.dispatchDragEvent` (type: "drop" or "dragCancel") is sent. + * + * Strategy: + * - Relay/agent owns drag interception. They enable it, they handle it. + * - We log and forward. No auto-response. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:input') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleInputEvent(method, tabId, params, _ctx) { + if (method === 'Input.dragIntercepted') { + log.info( + 'drag intercepted:', tabId, + 'items:', params?.data?.items?.length || 0, + 'dragOpsAllowed:', params?.data?.dragOperationsMask, + ) + } +} diff --git a/extensions/lime-chrome/lib/cdp/events/inspector.js b/extensions/lime-chrome/lib/cdp/events/inspector.js new file mode 100644 index 000000000..3420c2320 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/inspector.js @@ -0,0 +1,38 @@ +/** + * Inspector domain event handlers. + * + * Handles debugger detach reasons, target crashes, and reload after crash. + * + * No explicit enable required — Inspector events flow automatically + * when a debugger is attached. + * + * IMPORTANT: After Inspector.detached, no further CDP commands can be + * sent to this target. Extension should clean up session state. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:inspector') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleInspectorEvent(method, tabId, params, _ctx) { + switch (method) { + case 'Inspector.detached': + log.warn('inspector detached:', tabId, 'reason:', params?.reason) + break + + case 'Inspector.targetCrashed': + log.error('INSPECTOR: target crashed:', tabId) + break + + case 'Inspector.targetReloadedAfterCrash': + log.info('target reloaded after crash:', tabId) + break + } +} diff --git a/extensions/lime-chrome/lib/cdp/events/log.js b/extensions/lime-chrome/lib/cdp/events/log.js new file mode 100644 index 000000000..beab3f60e --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/log.js @@ -0,0 +1,42 @@ +/** + * Log domain event handlers. + * + * Handles `Log.entryAdded` — browser-level log entries (distinct from + * Runtime.consoleAPICalled which handles page-level console calls). + * + * Requires: `Log.enable` + * + * All events are passive (log + forward). + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:log') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleLogEvent(method, tabId, params, _ctx) { + if (method === 'Log.entryAdded') { + const entry = params?.entry + const level = entry?.level || 'info' + const text = entry?.text || '' + const url = entry?.url || '' + + if (level === 'error') { + log.warn('browser log [error]:', tabId, truncate(text, 150), url ? `(${truncate(url, 80)})` : '') + } else if (level === 'warning') { + log.info('browser log [warn]:', tabId, truncate(text, 150)) + } else { + log.debug('browser log:', tabId, `[${level}]`, truncate(text, 100)) + } + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/network.js b/extensions/lime-chrome/lib/cdp/events/network.js new file mode 100644 index 000000000..d4a7f7be2 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/network.js @@ -0,0 +1,98 @@ +/** + * Network domain event handlers. + * + * Handles request lifecycle events: sent, received, finished, failed. + * + * Requires: `Network.enable` — only fires when relay enables Network domain. + * + * Active handling: + * - Network.requestIntercepted: LEGACY interception (prefer Fetch domain). + * Blocks request until Network.continueInterceptedRequest is sent. + * Forwarded to relay for handling — relay owns the response. + * + * Passive events: request/response lifecycle, WebSocket, data transfer. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:network') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleNetworkEvent(method, tabId, params, _ctx) { + switch (method) { + // ── BLOCKING: Legacy interception (relay must respond) ── + + case 'Network.requestIntercepted': + log.warn( + 'request intercepted (legacy):', tabId, + 'interceptionId:', params?.interceptionId, + 'url:', truncate(params?.request?.url || '', 120), + 'isNav:', params?.isNavigationRequest, + 'hasAuth:', !!params?.authChallenge, + ) + break + + // ── Request lifecycle ── + + case 'Network.requestWillBeSent': + log.debug( + 'request →', tabId, + params?.request?.method, truncate(params?.request?.url || '', 100), + 'type:', params?.type, + 'requestId:', params?.requestId, + ) + break + + case 'Network.responseReceived': + log.debug( + 'response ←', tabId, + params?.response?.status, truncate(params?.response?.url || '', 100), + 'type:', params?.type, + 'requestId:', params?.requestId, + ) + break + + case 'Network.loadingFinished': + log.debug( + 'loading finished:', tabId, + 'requestId:', params?.requestId, + 'bytes:', params?.encodedDataLength, + ) + break + + case 'Network.loadingFailed': + log.info( + 'loading failed:', tabId, + 'requestId:', params?.requestId, + 'error:', params?.errorText, + 'type:', params?.type, + 'canceled:', params?.canceled, + 'blocked:', params?.blockedReason, + ) + break + + // ── WebSocket events ── + + case 'Network.webSocketCreated': + log.debug('WebSocket created:', tabId, 'url:', truncate(params?.url || '', 100)) + break + + case 'Network.webSocketClosed': + log.debug('WebSocket closed:', tabId, 'requestId:', params?.requestId) + break + + case 'Network.webSocketFrameError': + log.warn('WebSocket error:', tabId, 'error:', params?.errorMessage) + break + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/page-lifecycle.js b/extensions/lime-chrome/lib/cdp/events/page-lifecycle.js new file mode 100644 index 000000000..02141372f --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/page-lifecycle.js @@ -0,0 +1,157 @@ +/** + * Page lifecycle event handlers. + * + * Observes page load, navigation, frame management, download, file chooser, + * and interstitial events. + * + * Requires: `Page.enable` (called in debugger-attach.js on attach). + * + * Active handling: + * - Page.fileChooserOpened: logged + forwarded to relay for agent to handle + * (requires prior Page.setInterceptFileChooserDialog({ enabled: true })) + * + * All other events are passive (log + forward to relay). + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:page') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handlePageLifecycleEvent(method, tabId, params, _ctx) { + switch (method) { + // ── Load events ── + + case 'Page.loadEventFired': + log.debug('page loaded:', tabId, 'ts:', params?.timestamp) + break + + case 'Page.domContentEventFired': + log.debug('DOM content loaded:', tabId, 'ts:', params?.timestamp) + break + + case 'Page.lifecycleEvent': + log.debug( + 'lifecycle:', tabId, + params?.name, + 'frameId:', params?.frameId, + 'loaderId:', params?.loaderId, + ) + break + + // ── Frame events ── + + case 'Page.frameNavigated': + log.debug( + 'frame navigated:', tabId, + 'url:', truncate(params?.frame?.url || '', 100), + 'id:', params?.frame?.id, + 'type:', params?.type, + ) + break + + case 'Page.frameAttached': + log.debug( + 'frame attached:', tabId, + 'frameId:', params?.frameId, + 'parentFrameId:', params?.parentFrameId, + ) + break + + case 'Page.frameDetached': + log.debug( + 'frame detached:', tabId, + 'frameId:', params?.frameId, + 'reason:', params?.reason, + ) + break + + case 'Page.frameStartedLoading': + log.debug('frame loading started:', tabId, 'frameId:', params?.frameId) + break + + case 'Page.frameStoppedLoading': + log.debug('frame loading stopped:', tabId, 'frameId:', params?.frameId) + break + + case 'Page.frameStartedNavigating': + log.debug( + 'frame navigating:', tabId, + 'frameId:', params?.frameId, + 'url:', truncate(params?.url || '', 100), + 'type:', params?.navigationType, + ) + break + + case 'Page.navigatedWithinDocument': + log.debug( + 'SPA navigation:', tabId, + 'frameId:', params?.frameId, + 'url:', truncate(params?.url || '', 100), + 'type:', params?.navigationType, + ) + break + + // ── Window / popup events ── + + case 'Page.windowOpen': + log.info( + 'window.open:', tabId, + 'url:', truncate(params?.url || '', 100), + 'name:', params?.windowName || '(none)', + 'features:', params?.windowFeatures?.join(',') || '(none)', + 'userGesture:', params?.userGesture, + ) + break + + // ── Download events ── + + case 'Page.downloadWillBegin': + log.info( + 'download starting:', tabId, + 'url:', truncate(params?.url || '', 100), + 'filename:', params?.suggestedFilename, + 'guid:', params?.guid, + ) + break + + case 'Page.downloadProgress': + if (params?.state === 'completed') { + log.info('download completed:', tabId, 'guid:', params?.guid, 'bytes:', params?.totalBytes) + } else if (params?.state === 'canceled') { + log.info('download canceled:', tabId, 'guid:', params?.guid) + } + break + + // ── File chooser (requires Page.setInterceptFileChooserDialog) ── + + case 'Page.fileChooserOpened': + log.info( + 'file chooser opened:', tabId, + 'mode:', params?.mode, + 'frameId:', params?.frameId, + 'backendNodeId:', params?.backendNodeId, + ) + break + + // ── Interstitial (SSL/security pages) ── + + case 'Page.interstitialShown': + log.warn('interstitial shown (security/SSL page):', tabId) + break + + case 'Page.interstitialHidden': + log.info('interstitial hidden:', tabId) + break + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/runtime.js b/extensions/lime-chrome/lib/cdp/events/runtime.js new file mode 100644 index 000000000..82c3ea85c --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/runtime.js @@ -0,0 +1,83 @@ +/** + * Runtime domain event handlers. + * + * Handles JavaScript execution context lifecycle, console API calls, + * uncaught exceptions, and binding calls. + * + * Requires: `Runtime.enable` — the extension enables this via dispatch.js + * when the relay sends Runtime.enable command. + * + * All events are passive (log + forward). No active response needed. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:runtime') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleRuntimeEvent(method, tabId, params, _ctx) { + switch (method) { + case 'Runtime.exceptionThrown': { + const detail = params?.exceptionDetails + log.warn( + 'JS exception:', tabId, + 'text:', truncate(detail?.text || '', 120), + 'line:', detail?.lineNumber, + 'col:', detail?.columnNumber, + 'url:', truncate(detail?.url || '', 80), + ) + break + } + + case 'Runtime.consoleAPICalled': + log.debug( + 'console.' + (params?.type || 'log') + ':', tabId, + 'args:', params?.args?.length || 0, + 'contextId:', params?.executionContextId, + ) + break + + case 'Runtime.executionContextCreated': + log.debug( + 'context created:', tabId, + 'id:', params?.context?.id, + 'name:', params?.context?.name, + 'origin:', truncate(params?.context?.origin || '', 60), + ) + break + + case 'Runtime.executionContextDestroyed': + log.debug( + 'context destroyed:', tabId, + 'id:', params?.executionContextId || params?.executionContextUniqueId, + ) + break + + case 'Runtime.executionContextsCleared': + log.debug('all contexts cleared:', tabId) + break + + case 'Runtime.bindingCalled': + log.debug( + 'binding called:', tabId, + 'name:', params?.name, + 'payload:', truncate(params?.payload || '', 60), + 'contextId:', params?.executionContextId, + ) + break + + case 'Runtime.inspectRequested': + log.debug('inspect requested:', tabId, 'hints:', JSON.stringify(params?.hints)) + break + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/events/security.js b/extensions/lime-chrome/lib/cdp/events/security.js new file mode 100644 index 000000000..38774ab39 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/security.js @@ -0,0 +1,69 @@ +/** + * Security domain event handlers. + * + * Handles certificate errors and security state changes. + * + * Requires: `Security.enable` for visibleSecurityStateChanged. + * Certificate error handling additionally requires: + * `Security.setOverrideCertificateErrors({ override: true })` + * + * NOTE: Security.certificateError / Security.handleCertificateError + * is DEPRECATED. For new automation setups, prefer using Chrome's + * --ignore-certificate-errors launch flag instead. + * + * Strategy: + * - Security.certificateError → auto-continue (don't block automation) + * - Security.visibleSecurityStateChanged → log security state + * - Security.securityStateChanged → log overall security state + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:security') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} _ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleSecurityEvent(method, tabId, params, _ctx) { + switch (method) { + case 'Security.certificateError': + log.warn( + 'certificate error:', tabId, + 'type:', params?.errorType, + 'url:', params?.requestURL, + 'eventId:', params?.eventId, + ) + if (params?.eventId !== undefined) { + chrome.debugger + .sendCommand({ tabId }, 'Security.handleCertificateError', { + eventId: params.eventId, + action: 'continue', + }) + .then(() => log.debug('cert error auto-continued:', tabId)) + .catch((err) => log.warn('cert error handle failed:', tabId, err?.message || err)) + } + break + + case 'Security.visibleSecurityStateChanged': { + const state = params?.visibleSecurityState + log.debug( + 'visible security state:', tabId, + 'securityState:', state?.securityState, + 'hasCertError:', !!state?.certificateSecurityState?.certificateHasWeakSignature, + ) + break + } + + case 'Security.securityStateChanged': + log.debug( + 'security state changed:', tabId, + 'state:', params?.securityState, + 'schemeIsCrypto:', params?.schemeIsCryptographic, + ) + break + } +} diff --git a/extensions/lime-chrome/lib/cdp/events/target.js b/extensions/lime-chrome/lib/cdp/events/target.js new file mode 100644 index 000000000..7ca96c580 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/events/target.js @@ -0,0 +1,88 @@ +/** + * Target domain event handlers. + * + * Tracks child sessions (iframes, workers) attached/detached under a parent + * tab's debugger session, and monitors target lifecycle (crash, create, destroy). + * + * No explicit enable required — Target events flow after debugger attach. + * `Target.setDiscoverTargets` can be used for additional discovery events. + * + * Migrated from inline logic in TabManager.onDebuggerEvent + expanded. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('evt:target') + +/** + * @param {string} method + * @param {number} tabId + * @param {object} params + * @param {import('./index.js').EventContext} ctx + * @returns {{ suppress?: boolean } | void} + */ +export function handleTargetEvent(method, tabId, params, ctx) { + switch (method) { + case 'Target.attachedToTarget': + if (params?.sessionId) { + const sid = String(params.sessionId) + ctx.childSession.set(sid, tabId) + let children = ctx.childSets.get(tabId) + if (!children) { + children = new Set() + ctx.childSets.set(tabId, children) + } + children.add(sid) + log.debug( + 'child session attached:', tabId, sid, + 'type:', params.targetInfo?.type, + 'url:', truncate(params.targetInfo?.url || '', 80), + ) + } + break + + case 'Target.detachedFromTarget': + if (params?.sessionId) { + const sid = String(params.sessionId) + ctx.childSession.delete(sid) + ctx.childSets.get(tabId)?.delete(sid) + log.debug('child session detached:', tabId, sid) + } + break + + case 'Target.targetCreated': + log.info( + 'target created:', tabId, + 'targetId:', params?.targetInfo?.targetId, + 'type:', params?.targetInfo?.type, + 'url:', truncate(params?.targetInfo?.url || '', 80), + ) + break + + case 'Target.targetDestroyed': + log.info('target destroyed:', tabId, 'targetId:', params?.targetId) + break + + case 'Target.targetCrashed': + log.warn( + 'TARGET CRASHED:', tabId, + 'targetId:', params?.targetId, + 'status:', params?.status, + 'errorCode:', params?.errorCode, + ) + break + + case 'Target.targetInfoChanged': + log.debug( + 'target info changed:', tabId, + 'targetId:', params?.targetInfo?.targetId, + 'title:', truncate(params?.targetInfo?.title || '', 60), + 'url:', truncate(params?.targetInfo?.url || '', 80), + ) + break + } +} + +function truncate(str, max) { + return str.length > max ? str.slice(0, max) + '…' : str +} diff --git a/extensions/lime-chrome/lib/cdp/index.js b/extensions/lime-chrome/lib/cdp/index.js new file mode 100644 index 000000000..2f99c1e09 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/index.js @@ -0,0 +1,28 @@ +/** + * CDP channel — Chrome DevTools Protocol relay infrastructure. + * + * relay/ — WebSocket connection & state machine + * tabs/ — Tab lifecycle, discovery, idle detection, tab groups + * commands/ — CDP command routing & Target.* handlers + * events/ — CDP event interception (dialogs, security, page lifecycle) + */ + +export { TabManager } from './tabs/manager.js' +export { createDispatcher } from './commands/dispatch.js' +export { + initRelay, + trySendToRelay, + isRelayConnected, + isRelayActive, + isRelayEnabled, + isReconnecting, + getRelayState, + toggle, + disconnect, + connectAndAttach, + initFromStorage, + setRelayEnabled, + getLogBuffer, + ensureKeepAliveAlarm, + handleConnectionAlarm, +} from './relay/connection.js' diff --git a/extensions/lime-chrome/lib/cdp/relay/connection.js b/extensions/lime-chrome/lib/cdp/relay/connection.js new file mode 100644 index 000000000..65d35d38c --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/relay/connection.js @@ -0,0 +1,485 @@ +/** + * Relay connection management. + * + * 3-state model: + * disabled — relay off, badge empty + * disconnected — relay enabled but WS not open, badge …, auto-reconnecting + * connected — relay enabled + WS open, badge ON + * + * Timer strategy (MV3 compatible): + * All deferred work uses chrome.alarms instead of setTimeout/setInterval + * so that timers survive Service Worker suspension. + * + * Alarm names: + * relayKeepAlive — 1-min heartbeat, created when relay is enabled + * relayReconnect — exponential-backoff reconnect delay + * relayDisconnectNotify — delayed disconnect notification (5 s) + */ + +import { RelayState, STATE_UI, DEFAULT_CONTROL_PORT, RELAY_PORT_OFFSET, clampPort, computeRelayPort } from '../../constants.js' +import { setIconWithDot } from '../../icon-badge.js' +import { createLogger } from '../../logger.js' + +const log = createLogger('relay') + +const WS_CONNECT_TIMEOUT = 5000 +const PREFLIGHT_TIMEOUT = 2000 +const DISCONNECT_NOTIFY_DELAY_MIN = 5 / 60 // 5 seconds in minutes (chrome.alarms minimum granularity is ~1 s via delayInMinutes) + +// ── Alarm names (centralized) ── + +const ALARM_KEEP_ALIVE = 'relayKeepAlive' +const ALARM_RECONNECT = 'relayReconnect' +const ALARM_DISCONNECT_NOTIFY = 'relayDisconnectNotify' + +// ── Operation log ring buffer (O(1) push, O(n) read) ── +const LOG_BUFFER_MAX = 200 +const _logRing = new Array(LOG_BUFFER_MAX) +let _logHead = 0 // next write position +let _logCount = 0 // total entries written (clamped to max) + +function pushLog(direction, method, detail) { + _logRing[_logHead] = { ts: Date.now(), dir: direction, method, detail } + _logHead = (_logHead + 1) % LOG_BUFFER_MAX + if (_logCount < LOG_BUFFER_MAX) _logCount++ +} + +export function getLogBuffer(limit = 100) { + const n = Math.min(limit, _logCount) + const result = new Array(n) + // read oldest-first: start = (head - count) mod max, then advance + let idx = (_logHead - _logCount + LOG_BUFFER_MAX) % LOG_BUFFER_MAX + const skip = _logCount - n + idx = (idx + skip) % LOG_BUFFER_MAX + for (let i = 0; i < n; i++) { + result[i] = _logRing[idx] + idx = (idx + 1) % LOG_BUFFER_MAX + } + return result +} + +// ── State model ── + +let _state = RelayState.DISABLED + +function setState(newState) { + _state = newState + const ui = STATE_UI[newState] + void setIconWithDot(ui.dotColor).catch(() => {}) + void chrome.action.setTitle({ title: ui.title }) + void chrome.storage.local.set({ _relayState: newState }) +} + +export function getRelayState() { + return _state +} + +// ── Notifications ── + +function notifyError(title, message) { + chrome.notifications.create('accio-relay-error', { + type: 'basic', + iconUrl: 'icons/icon128.png', + title: `Lime Bridge: ${title}`, + message, + priority: 2, + }, () => { + if (chrome.runtime.lastError) { + console.warn('[lime-relay] notification create failed:', chrome.runtime.lastError.message) + } + }) +} + +// ── Port helpers ── + +async function getControlPort() { + const stored = await chrome.storage.local.get(['controlPort', 'relayPort']) + const raw = Number.parseInt(String(stored.controlPort || ''), 10) + if (Number.isFinite(raw) && raw > 0 && raw <= 65535) return raw + const relayRaw = Number.parseInt(String(stored.relayPort || ''), 10) + if (Number.isFinite(relayRaw) && relayRaw > 0 && relayRaw <= 65535) { + const inferred = clampPort(relayRaw - RELAY_PORT_OFFSET) + void chrome.storage.local.set({ controlPort: inferred }) + return inferred + } + return DEFAULT_CONTROL_PORT +} + +async function getRelayPort() { + return computeRelayPort(await getControlPort()) +} + +// ── Relay enabled persistence ── + +export async function setRelayEnabled(enabled) { + await chrome.storage.local.set({ relayEnabled: enabled }) +} + +export async function isRelayEnabled() { + const stored = await chrome.storage.local.get(['relayEnabled']) + return stored.relayEnabled === true +} + +// ── Connection state ── + +/** @type {WebSocket|null} */ +let relayWs = null +/** @type {Promise|null} */ +let relayConnectPromise = null +/** @type {AbortController|null} */ +let connectAbortCtrl = null + +let reconnectAttempt = 0 + +/** + * Callbacks injected by background.js at init time. + * + * onShutdown(reason) merges the old onClosed + onDisabled into a single + * atomic callback so callers can handle group dissolution and tab cleanup + * in one place, avoiding ordering bugs. + * + * @type {{ onMessage: (msg: any) => Promise, onShutdown: (reason: 'connectionLost'|'disabled') => Promise|void, onConnected: () => void, installDebuggerListeners: () => void }} + */ +let callbacks = { + onMessage: async () => null, + onShutdown: async (_reason) => {}, + onConnected: () => {}, + installDebuggerListeners: () => {}, +} + +export function initRelay(cbs) { + callbacks = { ...callbacks, ...cbs } +} + +// ── Derived state queries ── + +export function isRelayConnected() { + return relayWs !== null && relayWs.readyState === WebSocket.OPEN +} + +export function isRelayActive() { + return isRelayConnected() || relayConnectPromise !== null || + (relayWs !== null && relayWs.readyState === WebSocket.CONNECTING) +} + +export function isReconnecting() { + return _reconnectPending +} + +// ── Messaging ── + +export function sendToRelay(payload) { + const ws = relayWs + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('Relay not connected') + } + pushLog('↑', payload.method || `id:${payload.id}`, payload.params?.method || payload.error || '') + ws.send(JSON.stringify(payload)) +} + +export function trySendToRelay(payload) { + try { + sendToRelay(payload) + } catch (err) { + console.debug('[lime-relay] trySendToRelay failed:', err) + } +} + +// ── Alarm-based timer management ── + +/** + * Ensure the keep-alive alarm exists. Safe to call multiple times — + * chrome.alarms.create with the same name replaces the previous alarm. + */ +export function ensureKeepAliveAlarm() { + chrome.alarms.create(ALARM_KEEP_ALIVE, { periodInMinutes: 1 }) +} + +function clearKeepAliveAlarm() { + chrome.alarms.clear(ALARM_KEEP_ALIVE) +} + +let _reconnectPending = false + +function cancelReconnect() { + _reconnectPending = false + chrome.alarms.clear(ALARM_RECONNECT) +} + +function cancelDisconnectNotify() { + chrome.alarms.clear(ALARM_DISCONNECT_NOTIFY) +} + +function scheduleReconnect() { + if (_reconnectPending) return + const baseMs = 500 + const maxMs = 30_000 + const baseDelay = Math.min(maxMs, baseMs * Math.pow(2, reconnectAttempt)) + let delay = baseDelay + Math.floor(Math.random() * Math.min(baseDelay, 1000)) + if (!Number.isFinite(delay) || delay < baseMs) delay = baseMs + reconnectAttempt++ + _reconnectPending = true + // chrome.alarms minimum delay is ~1 second; convert ms → minutes + const delayInMinutes = Math.max(delay / 60_000, 1 / 60) + chrome.alarms.create(ALARM_RECONNECT, { delayInMinutes }) +} + +/** + * Handle alarm events relevant to relay connection. + * Called from background.js onAlarm listener. + * Returns true if the alarm was handled. + */ +export function handleConnectionAlarm(alarmName) { + if (alarmName === ALARM_RECONNECT) { + _reconnectPending = false + // connectAndAttach auto-schedules reconnect on failure + void connectAndAttach() + return true + } + + if (alarmName === ALARM_DISCONNECT_NOTIFY) { + if (_state === RelayState.DISCONNECTED) { + notifyError('Disconnected', 'Relay connection lost. Auto-reconnecting…') + } + return true + } + + return false +} + +// ── WebSocket connection ── + +let debuggerListenersInstalled = false + +async function ensureConnection() { + if (relayWs && relayWs.readyState === WebSocket.OPEN) return + if (relayConnectPromise) return await relayConnectPromise + + const currentAbortCtrl = new AbortController() + connectAbortCtrl = currentAbortCtrl + const { signal } = currentAbortCtrl + + // Assign immediately so concurrent callers see the in-flight promise (avoids race) + relayConnectPromise = (async () => { + const port = await getRelayPort() + const httpBase = `http://127.0.0.1:${port}` + const wsUrl = `ws://127.0.0.1:${port}/extension` + + const preflightCtrl = new AbortController() + const preflightTimer = setTimeout(() => preflightCtrl.abort(), PREFLIGHT_TIMEOUT) + if (signal) signal.addEventListener('abort', () => preflightCtrl.abort(), { once: true }) + try { + await fetch(`${httpBase}/`, { method: 'HEAD', signal: preflightCtrl.signal }) + .catch((err) => { + if (signal.aborted) throw new Error('Connection cancelled') + throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) + }) + } finally { + clearTimeout(preflightTimer) + } + + if (signal.aborted) throw new Error('Connection cancelled') + + const ws = new WebSocket(wsUrl) + + try { + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), WS_CONNECT_TIMEOUT) + const cleanup = () => { clearTimeout(t); signal.removeEventListener('abort', onAbort) } + const onAbort = () => { cleanup(); reject(new Error('Connection cancelled')) } + signal.addEventListener('abort', onAbort, { once: true }) + ws.onopen = () => { cleanup(); resolve() } + ws.onerror = () => { cleanup(); reject(new Error('WebSocket connect failed')) } + ws.onclose = (ev) => { cleanup(); reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) } + }) + } catch (err) { + try { ws.close() } catch { /* already closing */ } + throw err + } + + if (signal.aborted) { + try { ws.close() } catch { /* noop */ } + throw new Error('Connection cancelled') + } + + relayWs = ws + ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) + ws.onclose = () => { if (relayWs === ws) onRelayClosed('closed') } + ws.onerror = () => { if (relayWs === ws) onRelayClosed('error') } + + if (!debuggerListenersInstalled) { + debuggerListenersInstalled = true + callbacks.installDebuggerListeners() + } + })() + + try { + await relayConnectPromise + } finally { + if (connectAbortCtrl === currentAbortCtrl) connectAbortCtrl = null + relayConnectPromise = null + } +} + +function onRelayClosed(reason) { + log.info('onRelayClosed:', reason) + relayWs = null + + void Promise.resolve(callbacks.onShutdown('connectionLost')).catch((err) => { + log.warn('onRelayClosed: onShutdown error:', err) + }) + + setState(RelayState.DISCONNECTED) + + cancelDisconnectNotify() + chrome.alarms.create(ALARM_DISCONNECT_NOTIFY, { delayInMinutes: DISCONNECT_NOTIFY_DELAY_MIN }) + + void (async () => { + if (await isRelayEnabled()) scheduleReconnect() + })() +} + +async function onRelayMessage(text) { + /** @type {any} */ + let msg + try { + msg = JSON.parse(text) + } catch { + return + } + + if (msg?.method === 'ping') { + trySendToRelay({ method: 'pong' }) + return + } + + const MESSAGE_EXPIRE_MS = 130_000 + if (typeof msg?.ts === 'number') { + const age = Date.now() - msg.ts + if (age > MESSAGE_EXPIRE_MS) { + log.warn('dropping expired message:', msg.method || msg.id, 'age:', age, 'ms') + if (typeof msg.id === 'number') { + trySendToRelay({ id: msg.id, error: `Message expired (age ${age}ms > ${MESSAGE_EXPIRE_MS}ms)` }) + } + return + } + } + + if (typeof msg?.id === 'number' && msg.method === 'forwardCDPCommand') { + const cdpMethod = msg.params?.method || '' + pushLog('↓', msg.method, cdpMethod) + try { + const result = await callbacks.onMessage(msg) + trySendToRelay({ id: msg.id, result }) + } catch (err) { + trySendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) + } + } +} + +// ── Public lifecycle methods ── + +/** + * Attempt to connect. On failure, automatically schedules reconnect + * if relay is still enabled — callers do NOT need to call scheduleReconnect. + */ +export async function connectAndAttach() { + const t0 = performance.now() + log.debug('connectAndAttach: begin, connected:', isRelayConnected()) + if (isRelayConnected()) return true + if (!(await isRelayEnabled())) { log.debug('connectAndAttach: relay not enabled'); return false } + try { + await ensureConnection() + log.info('connectAndAttach: connected in', (performance.now() - t0).toFixed(1), 'ms') + cancelDisconnectNotify() + setState(RelayState.CONNECTED) + reconnectAttempt = 0 + void callbacks.onConnected() + return true + } catch (err) { + log.warn('connectAndAttach: failed in', (performance.now() - t0).toFixed(1), 'ms', err) + if (await isRelayEnabled()) scheduleReconnect() + return false + } +} + +/** + * Disable relay: close WS, cancel timers, set state to disabled. + * + * The sync neutralization block (relayWs = null, cancel timers, abort connect) + * runs BEFORE any await so that isRelayConnected() returns false immediately. + * + * onShutdown('disabled') handles both group dissolution and tab cleanup + * atomically — the callback decides internally what to do based on the reason. + */ +export async function disconnect() { + const t0 = performance.now() + log.info('disconnect: begin, current state:', _state, 'ws:', relayWs ? 'open' : 'null') + + const wsToClose = relayWs + relayWs = null + + cancelReconnect() + reconnectAttempt = 0 + cancelDisconnectNotify() + clearKeepAliveAlarm() + + if (connectAbortCtrl) { + connectAbortCtrl.abort() + connectAbortCtrl = null + } + relayConnectPromise = null + + if (wsToClose) { + log.debug('disconnect: closing WebSocket') + try { wsToClose.close() } catch { /* ignore */ } + } + + log.debug('disconnect: awaiting storage write + shutdown...') + await Promise.all([ + setRelayEnabled(false), + Promise.resolve(callbacks.onShutdown('disabled')).catch((err) => { + log.warn('disconnect: onShutdown(disabled) failed:', err) + }), + ]) + + log.info('disconnect: done in', (performance.now() - t0).toFixed(1), 'ms') + + setState(RelayState.DISABLED) +} + +export async function toggle() { + const t0 = performance.now() + if (_state !== RelayState.DISABLED) { + await disconnect() + log.info('toggle: disconnect done in', (performance.now() - t0).toFixed(1), 'ms') + return + } + + await setRelayEnabled(true) + ensureKeepAliveAlarm() + setState(RelayState.DISCONNECTED) + + try { + cancelReconnect() + await ensureConnection() + cancelDisconnectNotify() + setState(RelayState.CONNECTED) + void callbacks.onConnected() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('cancelled')) return + console.warn('[lime-relay] initial connect failed:', message) + notifyError('Connection Failed', `${message}\n\nRetrying automatically…`) + scheduleReconnect() + } +} + +export async function initFromStorage() { + if (await isRelayEnabled()) { + ensureKeepAliveAlarm() + setState(RelayState.DISCONNECTED) + } +} + + diff --git a/extensions/lime-chrome/lib/cdp/tabs/agent-group.js b/extensions/lime-chrome/lib/cdp/tabs/agent-group.js new file mode 100644 index 000000000..6e80be26e --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/tabs/agent-group.js @@ -0,0 +1,141 @@ +/** + * Agent tab group management. + * + * Creates and manages a Chrome tab group labeled "Lime Agent" + * to visually organize agent-controlled tabs. + * + * All addTab calls are serialized via a promise queue to prevent + * concurrent calls from each creating separate tab groups. + * + * On reconnect, the manager attempts to find and reuse an existing + * "Lime Agent" group rather than creating a duplicate. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('group') + +export const TAB_GROUP_TITLE = 'Lime Agent' +const TAB_GROUP_COLOR = 'blue' + +export class AgentGroupManager { + /** @type {number|null} */ + #groupId = null + /** @type {Promise} serialization queue */ + #queue = Promise.resolve() + + get groupId() { return this.#groupId } + + reset() { this.#groupId = null } + + async addTab(tabId) { + const done = this.#queue.then(() => this.#doAddTab(tabId)) + this.#queue = done.catch(() => {}) + return done + } + + async #doAddTab(tabId) { + try { + if (this.#groupId !== null) { + try { + await chrome.tabGroups.get(this.#groupId) + } catch { + this.#groupId = null + } + } + + if (this.#groupId === null) { + await this.#tryRecoverExistingGroup() + } + + const groupId = await chrome.tabs.group({ + tabIds: [tabId], + ...(this.#groupId !== null ? { groupId: this.#groupId } : {}), + }) + + if (this.#groupId === null || this.#groupId !== groupId) { + this.#groupId = groupId + await chrome.tabGroups.update(groupId, { + title: TAB_GROUP_TITLE, + color: TAB_GROUP_COLOR, + collapsed: false, + }) + } + } catch (err) { + log.warn('addTab: failed for tab', tabId, err) + } + } + + async #tryRecoverExistingGroup() { + try { + const groups = await chrome.tabGroups.query({ title: TAB_GROUP_TITLE }) + if (groups.length > 0) { + this.#groupId = groups[0].id + log.info('recovered existing group:', this.#groupId) + } + } catch { + // chrome.tabGroups.query not available or no match + } + } + + /** + * Dissolve the tab group: close agent-created tabs, ungroup the rest. + * + * @param {Set} [agentTabIds] — tab IDs created by the agent. + * These will be closed (chrome.tabs.remove). Tabs NOT in this set + * are assumed to be user-owned and will only be ungrouped. + * If omitted, all tabs in the group are ungrouped without closing. + * + * Falls back to finding the group by title because clearAll() may have + * already reset #groupId to null before this method is called. + */ + async dissolve(agentTabIds) { + let gid = this.#groupId + this.#groupId = null + log.info('dissolve: start, cached groupId =', gid, 'agentTabIds count =', agentTabIds?.size ?? 0) + + if (gid === null) { + try { + const groups = await chrome.tabGroups.query({ title: TAB_GROUP_TITLE }) + log.info('dissolve: query found', groups.length, 'groups') + if (groups.length > 0) gid = groups[0].id + } catch (err) { + log.warn('dissolve: tabGroups.query failed:', err) + } + } + + if (gid === null) { + log.info('dissolve: no group found, skipping') + return + } + + try { + const tabs = await chrome.tabs.query({ groupId: gid }) + log.info('dissolve: group', gid, 'has', tabs.length, 'tabs') + if (tabs.length === 0) { + log.info('dissolved group:', gid, '(already empty)') + return + } + + const allIds = tabs.map((t) => t.id).filter((id) => id != null) + const toClose = agentTabIds + ? allIds.filter((id) => agentTabIds.has(id)) + : [] + const toUngroup = agentTabIds + ? allIds.filter((id) => !agentTabIds.has(id)) + : allIds + + if (toClose.length > 0) { + await chrome.tabs.remove(toClose) + log.info('dissolve: closed', toClose.length, 'agent tabs') + } + if (toUngroup.length > 0) { + await chrome.tabs.ungroup(toUngroup) + log.info('dissolve: ungrouped', toUngroup.length, 'user tabs') + } + log.info('dissolved group:', gid, 'closed', toClose.length, 'ungrouped', toUngroup.length) + } catch (err) { + log.warn('dissolve: failed for group', gid, err) + } + } +} diff --git a/extensions/lime-chrome/lib/cdp/tabs/debugger-attach.js b/extensions/lime-chrome/lib/cdp/tabs/debugger-attach.js new file mode 100644 index 000000000..d0156aa5a --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/tabs/debugger-attach.js @@ -0,0 +1,76 @@ +/** + * Low-level chrome.debugger API operations. + * + * Pure chrome.debugger interactions with no state management or relay awareness. + * Used by TabManager for the physical attach/detach lifecycle. + * + * Domain enable strategy on attach: + * Page.enable — always (dialog handling, page lifecycle events) + * + * Domains enabled on-demand by relay via CDP commands (through dispatch.js): + * Network.enable — request lifecycle (high traffic, enable only when needed) + * Runtime.enable — JS contexts, console, exceptions + * Security.enable — security state changes, cert errors (deprecated) + * Fetch.enable — request interception (must specify patterns) + * Log.enable — browser-level log entries + * Debugger.enable — breakpoints, script parsing (pauses execution) + * DOM.enable — DOM mutation observation + * + * See lib/cdp/events/ for comprehensive event handling across all domains. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('dbg') + +/** + * Physically attach the Chrome debugger to a tab. + * + * @param {number} tabId + * @returns {Promise<{ realTargetId: string }>} The real CDP targetId assigned by Chrome + * @throws {Error} If attachment fails + */ +export async function attachDebugger(tabId) { + const t0 = performance.now() + const debuggee = { tabId } + + await chrome.debugger.attach(debuggee, '1.3') + log.debug('attach took', (performance.now() - t0).toFixed(1), 'ms', tabId) + + await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch((err) => { + log.warn('Page.enable failed', tabId, err) + }) + + const info = /** @type {any} */ ( + await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo') + ) + const realTargetId = String(info?.targetInfo?.targetId || '').trim() + + log.debug('attached', tabId, 'targetId:', realTargetId, 'in', (performance.now() - t0).toFixed(1), 'ms') + return { realTargetId } +} + +/** + * Detach the Chrome debugger from a tab. Fails silently. + * @param {number} tabId + */ +export async function detachDebugger(tabId) { + try { + await chrome.debugger.detach({ tabId }) + } catch { + // Already detached or tab closed + } +} + +/** + * Detach multiple tabs in parallel. Returns a settled promise. + * @param {number[]} tabIds + */ +export function detachAll(tabIds) { + const promises = tabIds.map( + (tabId) => chrome.debugger.detach({ tabId }) + .then(() => log.debug('detachAll: OK', tabId)) + .catch((err) => log.debug('detachAll: failed', tabId, err)), + ) + return Promise.allSettled(promises) +} diff --git a/extensions/lime-chrome/lib/cdp/tabs/manager.js b/extensions/lime-chrome/lib/cdp/tabs/manager.js new file mode 100644 index 000000000..13730fcdf --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/tabs/manager.js @@ -0,0 +1,533 @@ +/** + * TabManager — Lazy Attach architecture. + * + * Encapsulates all tab state: discovery, lazy physical attach, agent tracking. + * + * Tab lifecycle: + * virtual → discovered by chrome.tabs.query, no chrome.debugger session + * attaching → chrome.debugger.attach() in flight + * connected → physically attached via chrome.debugger + * + * Relay notifications: + * Extension.tabDiscovered — virtual tab discovered (relay stores, Playwright ignores) + * Extension.tabUpdated — virtual tab metadata (title/url) changed + * Extension.tabRemoved — virtual tab removed from cache + * Target.attachedToTarget — physical debugger attached (Playwright sees this) + * Target.detachedFromTarget — physical debugger detached (Playwright sees this) + * + * Delegates to: + * SessionIndicators — spinner animation + idle tab detection + * AgentGroupManager — Chrome tab group management + */ + +import { TabType } from '../../constants.js' +import { createLogger } from '../../logger.js' +import { attachDebugger, detachDebugger, detachAll } from './debugger-attach.js' +import { SessionIndicators } from './session-indicators.js' +import { AgentGroupManager } from './agent-group.js' +import { cleanupTabQueue, cleanupAllTabQueues } from '../commands/dispatch.js' +import { interceptEvent } from '../events/index.js' + +const log = createLogger('tabs') + +const CANCELLED_TABS_KEY = 'accio_cancelledTabs' + +let _sessionSeq = 0 + +const DEBUGGABLE_URL_RE = /^(https?|file):\/\// + +function isDebuggableUrl(url) { + if (!url) return false + return DEBUGGABLE_URL_RE.test(url) || url === 'about:blank' +} + +export class TabManager { + /** @type {Map} */ + #tabs = new Map() + /** @type {Map} sessionId → tabId */ + #bySession = new Map() + /** @type {Map} targetId → tabId */ + #byTarget = new Map() + /** @type {Map} child sessionId → parent tabId */ + #childSession = new Map() + /** @type {Map>} parent tabId → child sessionIds */ + #childSets = new Map() + /** @type {Map} tabId → TabType */ + #agentTabs = new Map() + /** @type {Set} */ + #cancelled = new Set() + /** @type {Map>} tabId → pending attach promise */ + #pending = new Map() + #retainedCount = 0 + /** @type {boolean} */ + #shuttingDown = false + /** @type {number|null} */ + #lastAttached = null + /** @type {(payload: any) => void} */ + #sendToRelay + + /** @type {SessionIndicators} */ + #indicators + /** @type {AgentGroupManager} */ + #group + + /** + * @param {(payload: any) => void} sendToRelay — fire-and-forget relay send function + */ + constructor(sendToRelay) { + this.#sendToRelay = sendToRelay + this.#group = new AgentGroupManager() + this.#indicators = new SessionIndicators({ + getGroupId: () => this.#group.groupId, + getTabEntries: () => this.#tabs.entries(), + detachTab: (tabId, reason) => void this.detach(tabId, reason), + }) + } + + // ── CDP command tracking (forwarded from dispatch) ── + + onCdpCommand(tabId) { + this.#indicators.trackCommand(tabId) + } + + // ── Session lifecycle ── + + startSessionIndicators() { + this.#indicators.start() + } + + stopSessionIndicators() { + this.#indicators.stop() + } + + handleIndicatorAlarm(alarmName) { + return this.#indicators.handleAlarm(alarmName) + } + + // ── Tab group ── + + async addToAgentGroup(tabId) { + await this.#group.addTab(tabId) + } + + async dissolveAgentGroup() { + const agentIds = new Set(this.#agentTabs.keys()) + await this.#group.dissolve(agentIds) + } + + /** + * Atomic shutdown: optionally dissolve agent group, then clear all state. + * Dissolution runs BEFORE clearAll() so that the groupId and agentTabs + * are still available for deciding which tabs to close vs ungroup. + */ + async shutdown({ dissolveGroup = false } = {}) { + if (dissolveGroup) { + await this.dissolveAgentGroup() + } + return this.clearAll() + } + + // ── Tab state queries ── + + get size() { return this.#tabs.size } + has(tabId) { return this.#tabs.has(tabId) } + get(tabId) { return this.#tabs.get(tabId) } + entries() { return this.#tabs.entries() } + + get agentTabCount() { return this.#agentTabs.size } + get retainedTabCount() { return this.#retainedCount } + get agentTabs() { return this.#agentTabs } + + // ── Lookup ── + + getBySessionId(sessionId) { + const direct = this.#bySession.get(sessionId) + if (direct !== undefined) return { tabId: direct, kind: 'main' } + const child = this.#childSession.get(sessionId) + if (child !== undefined) return { tabId: child, kind: 'child' } + return null + } + + getByTargetId(targetId) { + return this.#byTarget.get(targetId) ?? null + } + + resolveTabId(sessionId, targetId) { + if (sessionId) { + const found = this.getBySessionId(sessionId) + if (found) return found.tabId + } + if (targetId) { + const found = this.getByTargetId(targetId) + if (found !== null) return found + } + if (this.#lastAttached !== null && this.#tabs.has(this.#lastAttached)) { + return this.#lastAttached + } + return null + } + + // ── User-cancelled tab tracking (persisted to session storage) ── + + markCancelled(tabId) { + this.#cancelled.add(tabId) + void this.#persistCancelled() + } + + isCancelled(tabId) { return this.#cancelled.has(tabId) } + + removeCancelled(tabId) { + this.#cancelled.delete(tabId) + void this.#persistCancelled() + } + + async loadCancelled() { + try { + const { [CANCELLED_TABS_KEY]: ids } = await chrome.storage.session.get(CANCELLED_TABS_KEY) + const arr = Array.isArray(ids) ? ids : [] + this.#cancelled.clear() + for (const id of arr) { + if (typeof id === 'number' && Number.isInteger(id)) this.#cancelled.add(id) + } + } catch (err) { + log.warn('loadCancelled failed:', err) + } + } + + async #persistCancelled() { + try { + await chrome.storage.session.set({ [CANCELLED_TABS_KEY]: [...this.#cancelled] }) + } catch (err) { + log.warn('persistCancelled failed:', err) + } + } + + // ── Agent tab tracking ── + + markAgent(tabId, retain = false) { + const prev = this.#agentTabs.get(tabId) + const next = retain ? TabType.RETAINED : TabType.AGENT + this.#agentTabs.set(tabId, next) + if (next === TabType.RETAINED && prev !== TabType.RETAINED) this.#retainedCount++ + else if (next !== TabType.RETAINED && prev === TabType.RETAINED) this.#retainedCount-- + } + + deleteAgent(tabId) { + const prev = this.#agentTabs.get(tabId) + if (prev === undefined) return + if (prev === TabType.RETAINED) this.#retainedCount-- + this.#agentTabs.delete(tabId) + } + + isAgent(tabId) { return this.#agentTabs.has(tabId) } + isRetained(tabId) { return this.#agentTabs.get(tabId) === TabType.RETAINED } + + // ── Discovery (virtual registration) ── + + async discoverAll(isConnected) { + const t0 = performance.now() + if (!isConnected()) return + + const allTabs = await chrome.tabs.query({ url: ['http://*/*', 'https://*/*', 'file:///*'] }) + log.debug('discoverAll: query took', (performance.now() - t0).toFixed(1), 'ms,', allTabs.length, 'tabs') + + let count = 0 + for (const tab of allTabs) { + if (!tab.id || this.#tabs.has(tab.id) || this.#cancelled.has(tab.id)) continue + this.#registerVirtual(tab.id, tab.url, tab.title) + count++ + } + log.info('discoverAll: registered', count, 'virtual tabs in', (performance.now() - t0).toFixed(1), 'ms') + } + + discover(tabId, url, title) { + if (!this.#tabs.has(tabId) && isDebuggableUrl(url)) { + this.#registerVirtual(tabId, url, title) + log.debug('discover: registered virtual tab', tabId) + } + } + + /** + * Update a tracked tab's URL and/or title. + * Sends Extension.tabUpdated to the relay so the agent side stays in sync. + */ + updateTab(tabId, url, title) { + const entry = this.#tabs.get(tabId) + if (!entry) return + + let changed = false + if (url !== undefined && url !== entry.url) { entry.url = url; changed = true } + if (title !== undefined && title !== entry.title) { entry.title = title; changed = true } + if (!changed) return + + this.#sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Extension.tabUpdated', + params: { + sessionId: entry.sessionId, + targetInfo: { + targetId: entry.targetId, type: 'page', + title: entry.title || '', url: entry.url || '', + attached: entry.state === 'connected', + }, + }, + }, + }) + } + + #registerVirtual(tabId, url, title) { + if (this.#tabs.has(tabId) || this.#cancelled.has(tabId)) return + + const sessionId = `cb-tab-${++_sessionSeq}-${tabId}` + const targetId = `vtab-${tabId}` + + this.#tabs.set(tabId, { state: 'virtual', sessionId, targetId, url, title }) + this.#bySession.set(sessionId, tabId) + this.#byTarget.set(targetId, tabId) + + this.#sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Extension.tabDiscovered', + params: { + sessionId, + targetInfo: { targetId, type: 'page', title: title || '', url: url || '', attached: false }, + }, + }, + }) + } + + // ── Lazy Attach (on-demand physical attachment) ── + + async ensureAttached(tabId) { + if (this.#cancelled.has(tabId)) { + log.warn('ensureAttached: tab was cancelled by user', tabId) + throw new Error(`User denied debugger permission for tab ${tabId}`) + } + const entry = this.#tabs.get(tabId) + if (!entry) { log.warn('ensureAttached: tab not tracked', tabId); return false } + if (entry.state === 'connected') return true + if (entry.state === 'attaching') { + const p = this.#pending.get(tabId) + if (p) return p + } + + const promise = this.#physicalAttach(tabId, entry) + this.#pending.set(tabId, promise) + try { return await promise } finally { this.#pending.delete(tabId) } + } + + async #physicalAttach(tabId, entry) { + const t0 = performance.now() + if (this.#shuttingDown) return false + entry.state = 'attaching' + log.info('physicalAttach: begin', tabId) + + try { + if (this.#shuttingDown) return false + const { realTargetId } = await attachDebugger(tabId) + + // Guard: tab may have been removed by clearAll/detach while we were awaiting + if (!this.#tabs.has(tabId)) { + log.warn('physicalAttach: tab removed during attach, cleaning up', tabId) + void detachDebugger(tabId) + return false + } + + if (realTargetId && realTargetId !== entry.targetId) { + this.#byTarget.delete(entry.targetId) + entry.targetId = realTargetId + this.#byTarget.set(realTargetId, tabId) + } + + entry.state = 'connected' + this.#lastAttached = tabId + + this.#sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId: entry.sessionId, + targetInfo: { + targetId: entry.targetId, type: 'page', + title: entry.title || '', url: entry.url || '', attached: true, + }, + waitingForDebugger: false, + }, + }, + }) + + log.info('physicalAttach: done', tabId, 'in', (performance.now() - t0).toFixed(1), 'ms') + return true + } catch (err) { + log.warn('physicalAttach: failed', tabId, (performance.now() - t0).toFixed(1), 'ms', err) + void detachDebugger(tabId) + if (this.#tabs.has(tabId)) { + this.#notifyRemoved(entry.sessionId) + this.#removeEntry(tabId) + } + return false + } + } + + async attach(tabId) { + if (this.#cancelled.has(tabId)) { + log.warn('attach: tab was cancelled by user', tabId) + return null + } + const existing = this.#tabs.get(tabId) + if (existing?.state === 'connected') { + log.debug('attach: already connected', tabId) + return existing + } + + const wasNew = !existing + if (wasNew) { + let url, title + try { + const tab = await chrome.tabs.get(tabId) + url = tab.url; title = tab.title + } catch { /* tab may have closed */ } + const sessionId = `cb-tab-${++_sessionSeq}-${tabId}` + const targetId = `vtab-${tabId}` + this.#tabs.set(tabId, { state: 'virtual', sessionId, targetId, url, title }) + this.#bySession.set(sessionId, tabId) + this.#byTarget.set(targetId, tabId) + } + + const ok = await this.ensureAttached(tabId) + if (!ok) { + if (wasNew && this.#tabs.has(tabId)) this.#removeEntry(tabId) + return null + } + + const entry = this.#tabs.get(tabId) + return entry ? { sessionId: entry.sessionId, targetId: entry.targetId } : null + } + + // ── Detach ── + + async detach(tabId, reason) { + const t0 = performance.now() + const entry = this.#tabs.get(tabId) + log.info('detach:', tabId, reason, 'state:', entry?.state) + + const wasPhysical = entry?.state === 'connected' || entry?.state === 'attaching' + + if (entry?.sessionId) { + if (wasPhysical && entry.targetId) { + this.#sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: entry.sessionId, targetId: entry.targetId, reason }, + }, + }) + } else { + this.#notifyRemoved(entry.sessionId) + } + } + + this.#removeEntry(tabId) + + if (wasPhysical) { + await detachDebugger(tabId) + log.debug('detach: chrome.debugger cleanup', tabId, (performance.now() - t0).toFixed(1), 'ms') + } + } + + clearAll() { + const t0 = performance.now() + this.#shuttingDown = true + this.stopSessionIndicators() + + const physical = [] + for (const [tabId, entry] of this.#tabs) { + if (entry.state === 'connected' || entry.state === 'attaching') { + physical.push(tabId) + } + } + + log.info('clearAll:', this.#tabs.size, 'total,', physical.length, 'physically attached') + + this.#tabs.clear() + this.#bySession.clear() + this.#byTarget.clear() + this.#childSession.clear() + this.#childSets.clear() + this.#pending.clear() + this.#cancelled.clear() + void chrome.storage.session.remove(CANCELLED_TABS_KEY).catch(() => {}) + this.#agentTabs.clear() + this.#retainedCount = 0 + this.#group.reset() + this.#lastAttached = null + this.#indicators.clear() + cleanupAllTabQueues() + + const settled = detachAll(physical) + settled.then(() => { + this.#shuttingDown = false + log.info('clearAll: done in', (performance.now() - t0).toFixed(1), 'ms') + }) + return settled + } + + // ── Debugger event handlers ── + + onDebuggerEvent(source, method, params) { + const tabId = source.tabId + if (!tabId) return + const tab = this.#tabs.get(tabId) + if (!tab?.sessionId) return + + const result = interceptEvent(method, tabId, params, { + childSession: this.#childSession, + childSets: this.#childSets, + log, + }) + + if (result.suppress) return + + this.#sendToRelay({ + method: 'forwardCDPEvent', + params: { sessionId: source.sessionId || tab.sessionId, method, params }, + }) + } + + onDebuggerDetach(source, reason) { + const tabId = source.tabId + log.info('onDebuggerDetach:', tabId, reason, 'tracked:', this.#tabs.has(tabId)) + if (!tabId || !this.#tabs.has(tabId)) return + void this.detach(tabId, reason) + } + + // ── Private helpers ── + + #removeEntry(tabId) { + const entry = this.#tabs.get(tabId) + if (!entry) return + if (entry.sessionId) this.#bySession.delete(entry.sessionId) + if (entry.targetId) this.#byTarget.delete(entry.targetId) + this.#tabs.delete(tabId) + this.#indicators.removeTab(tabId) + cleanupTabQueue(tabId) + if (this.#lastAttached === tabId) this.#lastAttached = null + + const children = this.#childSets.get(tabId) + if (children) { + for (const sid of children) this.#childSession.delete(sid) + this.#childSets.delete(tabId) + } + } + + #notifyRemoved(sessionId) { + if (!sessionId) return + this.#sendToRelay({ + method: 'forwardCDPEvent', + params: { method: 'Extension.tabRemoved', params: { sessionId } }, + }) + } +} diff --git a/extensions/lime-chrome/lib/cdp/tabs/session-indicators.js b/extensions/lime-chrome/lib/cdp/tabs/session-indicators.js new file mode 100644 index 000000000..379de9888 --- /dev/null +++ b/extensions/lime-chrome/lib/cdp/tabs/session-indicators.js @@ -0,0 +1,119 @@ +/** + * Session activity indicators — spinner animation & idle tab detection. + * + * Manages the visual "Lime Agent" tab-group spinner and automatically + * detaches tabs that haven't received CDP commands for a configurable period. + * + * Timer strategy (MV3 compatible): + * - Spinner uses setInterval (200 ms). This is acceptable because the spinner + * only runs while the relay WS is connected, which keeps the SW alive. + * When the WS disconnects, stop() is called and the timer is cleared. + * - Idle check uses chrome.alarms (30 s) to survive SW suspension. + */ + +import { createLogger } from '../../logger.js' + +const log = createLogger('indicators') + +const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'] +const IDLE_DETACH_MS = 5 * 60 * 1000 +const SPINNER_INTERVAL_MS = 200 +const GROUP_TITLE = 'Lime Agent' + +const ALARM_IDLE_CHECK = 'relayIdleCheck' + +export class SessionIndicators { + /** @type {number|null} */ + #spinnerTimer = null + #spinnerIdx = 0 + /** @type {Map} tabId → last CDP command timestamp */ + #lastCommandTime = new Map() + + /** @type {() => number|null} */ + #getGroupId + /** @type {() => IterableIterator<[number, {state: string}]>} */ + #getTabEntries + /** @type {(tabId: number, reason: string) => void} */ + #detachTab + + /** + * @param {object} opts + * @param {() => number|null} opts.getGroupId — returns current agent group ID + * @param {() => IterableIterator<[number, {state: string}]>} opts.getTabEntries — returns iterable of [tabId, entry] + * @param {(tabId: number, reason: string) => void} opts.detachTab — detach callback + */ + constructor({ getGroupId, getTabEntries, detachTab }) { + this.#getGroupId = getGroupId + this.#getTabEntries = getTabEntries + this.#detachTab = detachTab + } + + trackCommand(tabId) { + this.#lastCommandTime.set(tabId, Date.now()) + } + + removeTab(tabId) { + this.#lastCommandTime.delete(tabId) + } + + clear() { + this.#lastCommandTime.clear() + } + + start() { + this.stop() + this.#spinnerIdx = 0 + this.#spinnerTimer = setInterval(() => this.#tickSpinner(), SPINNER_INTERVAL_MS) + // 30 s idle check via alarm (survives SW suspension) + chrome.alarms.create(ALARM_IDLE_CHECK, { periodInMinutes: 0.5 }) + } + + stop() { + if (this.#spinnerTimer) { clearInterval(this.#spinnerTimer); this.#spinnerTimer = null } + chrome.alarms.clear(ALARM_IDLE_CHECK) + this.#lastCommandTime.clear() + this.#resetGroupTitle() + } + + /** + * Handle alarm events relevant to session indicators. + * Called from background.js onAlarm listener. + * Returns true if the alarm was handled. + */ + handleAlarm(alarmName) { + if (alarmName === ALARM_IDLE_CHECK) { + this.#checkIdleTabs() + return true + } + return false + } + + #tickSpinner() { + const groupId = this.#getGroupId() + if (groupId === null) return + const frame = SPINNER_FRAMES[this.#spinnerIdx % SPINNER_FRAMES.length] + this.#spinnerIdx++ + chrome.tabGroups.update(groupId, { title: `${frame} ${GROUP_TITLE}` }).catch(() => {}) + } + + #resetGroupTitle() { + const groupId = this.#getGroupId() + if (groupId === null) return + chrome.tabGroups.update(groupId, { title: GROUP_TITLE }).catch(() => {}) + } + + #checkIdleTabs() { + const now = Date.now() + const toDetach = [] + for (const [tabId, entry] of this.#getTabEntries()) { + if (entry.state !== 'connected') continue + const lastCmd = this.#lastCommandTime.get(tabId) + if (lastCmd === undefined) continue // never received CDP command, skip (avoid false idle detach) + if (now - lastCmd > IDLE_DETACH_MS) toDetach.push(tabId) + } + for (const tabId of toDetach) { + log.info('idle timeout: detaching tab', tabId, 'after', IDLE_DETACH_MS / 1000, 's') + this.#detachTab(tabId, 'idle_timeout') + } + } +} diff --git a/extensions/lime-chrome/lib/constants.js b/extensions/lime-chrome/lib/constants.js new file mode 100644 index 000000000..b1c74efcc --- /dev/null +++ b/extensions/lime-chrome/lib/constants.js @@ -0,0 +1,57 @@ +/** + * Shared constants for the Lime Browser Bridge extension. + */ + +export const DEFAULT_CONTROL_PORT = 9234 +export const RELAY_PORT_OFFSET = 2 + +const MAX_CONTROL_PORT = 65535 - RELAY_PORT_OFFSET + +export function clampPort(value) { + const n = Number.parseInt(String(value || ''), 10) + if (!Number.isFinite(n) || n <= 0 || n > MAX_CONTROL_PORT) return DEFAULT_CONTROL_PORT + return n +} + +export function computeRelayPort(controlPort) { + return clampPort(controlPort) + RELAY_PORT_OFFSET +} + +export const RelayState = Object.freeze({ + DISABLED: 'disabled', + DISCONNECTED: 'disconnected', + CONNECTED: 'connected', +}) + +export const TabType = Object.freeze({ + USER: 'user', + AGENT: 'agent', + RETAINED: 'retained', +}) + +export const STATE_UI = { + [RelayState.DISABLED]: { dotColor: null, title: 'Lime Browser Bridge — disabled' }, + [RelayState.DISCONNECTED]: { dotColor: '#F59E0B', title: 'Lime Browser Bridge — connecting…' }, + [RelayState.CONNECTED]: { dotColor: '#22C55E', title: 'Lime Browser Bridge — connected' }, +} + +export const STATE_TEXT = { + [RelayState.DISABLED]: { label: 'Not Enabled', detail: 'Click the toggle or toolbar icon to enable.' }, + [RelayState.DISCONNECTED]: { label: 'Connecting…', detail: 'Relay is enabled. Trying to connect…' }, + [RelayState.CONNECTED]: { label: 'Connected', detail: 'Relay is active — agent can control your browser.' }, +} + +// ── Settings keys ── + +export const SETTINGS_KEYS = Object.freeze({ + CLOSE_GROUP_ON_DISABLE: 'closeGroupOnDisable', +}) + +export async function getSetting(key, defaultValue = false) { + const stored = await chrome.storage.local.get([key]) + return stored[key] ?? defaultValue +} + +export async function setSetting(key, value) { + await chrome.storage.local.set({ [key]: value }) +} diff --git a/extensions/lime-chrome/lib/content_script/action-mask.js b/extensions/lime-chrome/lib/content_script/action-mask.js new file mode 100644 index 000000000..ffb0f591b --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/action-mask.js @@ -0,0 +1,131 @@ +/** + * Extension.actionMask — Page overlay for blocking user interaction. + * + * Injects a Shadow DOM-isolated overlay that prevents user interaction + * while AI is operating. Supports three states: + * ongoing — blocks all interaction, cursor: wait + * idle — hidden, no blocking + * takeover — transparent overlay, allows user interaction + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extActionMask(tabId, params) { + const state = (params?.state || 'idle').toLowerCase() + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (maskState) => { + const MASK_ID = 'accio-action-mask' + let host = document.getElementById(MASK_ID) + + if (maskState === 'idle' || maskState === 'remove') { + if (host) host.remove() + return { success: true, state: 'idle' } + } + + if (!host) { + host = document.createElement('div') + host.id = MASK_ID + host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483646;' + const shadow = host.attachShadow({ mode: 'open' }) + + const style = document.createElement('style') + style.textContent = ` + :host { all: initial; } + .mask-layer { + position: fixed; inset: 0; z-index: 2147483646; + transition: opacity 0.3s ease; + } + .mask-layer[data-state="ongoing"] { + pointer-events: auto; cursor: wait; + background: rgba(0, 0, 0, 0.02); + } + .mask-layer[data-state="takeover"] { + pointer-events: none; + background: transparent; + } + .glow-border { + position: fixed; inset: 0; z-index: 2147483645; + pointer-events: none; + box-shadow: inset 0 0 30px rgba(99, 102, 241, 0.15); + animation: accio-glow 1.5s ease-in-out infinite alternate; + } + @keyframes accio-glow { + from { opacity: 0.7; } + to { opacity: 1; } + } + .status-bar { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + z-index: 2147483647; + background: rgba(30, 30, 50, 0.9); color: #e0e0e0; + padding: 8px 20px; border-radius: 24px; + font: 13px/1.4 system-ui, -apple-system, sans-serif; + backdrop-filter: blur(8px); + pointer-events: none; + display: flex; align-items: center; gap: 8px; + } + .status-dot { + width: 8px; height: 8px; border-radius: 50%; + background: #6366f1; + animation: accio-pulse 1s ease-in-out infinite alternate; + } + @keyframes accio-pulse { + from { opacity: 0.5; } + to { opacity: 1; } + } + ` + + const mask = document.createElement('div') + mask.className = 'mask-layer' + + const glow = document.createElement('div') + glow.className = 'glow-border' + + const bar = document.createElement('div') + bar.className = 'status-bar' + bar.innerHTML = 'Accio is working…' + + shadow.append(style, mask, glow, bar) + document.body.appendChild(host) + } + + const shadow = host.shadowRoot + const mask = shadow.querySelector('.mask-layer') + const glow = shadow.querySelector('.glow-border') + const bar = shadow.querySelector('.status-bar') + + mask.dataset.state = maskState + + if (maskState === 'ongoing') { + glow.style.display = '' + bar.style.display = '' + const BLOCKED_EVENTS = [ + 'mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu', + 'pointerdown', 'pointerup', 'pointermove', + 'keydown', 'keyup', 'keypress', 'wheel', + ] + if (!mask._blockers) { + mask._blockers = [] + for (const evt of BLOCKED_EVENTS) { + const handler = (e) => { if (e.isTrusted) { e.preventDefault(); e.stopImmediatePropagation() } } + document.addEventListener(evt, handler, { capture: true, passive: false }) + mask._blockers.push({ evt, handler }) + } + } + } else if (maskState === 'takeover') { + glow.style.display = 'none' + bar.style.display = 'none' + if (mask._blockers) { + for (const { evt, handler } of mask._blockers) { + document.removeEventListener(evt, handler, { capture: true }) + } + mask._blockers = null + } + } + + return { success: true, state: maskState } + }, + args: [state], + }) + return unwrapScriptResult(results, 'Extension.actionMask') +} diff --git a/extensions/lime-chrome/lib/content_script/extension-ops.js b/extensions/lime-chrome/lib/content_script/extension-ops.js new file mode 100644 index 000000000..e4c355102 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/extension-ops.js @@ -0,0 +1,440 @@ +/** + * Extension.* virtual CDP commands. + * + * These commands are NOT part of the Chrome Debugger Protocol — + * they use chrome.scripting.executeScript to inject page-level scripts + * and chrome.tabs / chrome.debugger for viewport/zoom/screenshot operations. + * + * This avoids the need for a persistent content script while providing + * capabilities similar to Manus (content extraction, element marking, etc). + */ + +import { unwrapScriptResult } from './helpers.js' + +// ── Viewport & Zoom ── + +function wrapDebuggerCommand(tabId, fn) { + return fn().catch((err) => { + const msg = err?.message || String(err) + if (msg.includes('attach') || msg.includes('debugger') || msg.includes('Another debugger')) { + throw new Error(`Tab ${tabId} is not attached to debugger. Ensure ensureAttached was called first. Original: ${msg}`) + } + throw err + }) +} + +export async function extGetViewportInfo(tabId) { + const debuggee = { tabId } + const result = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: `JSON.stringify({ + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio || 1, + scrollX: Math.round(window.scrollX), + scrollY: Math.round(window.scrollY), + scrollWidth: document.documentElement.scrollWidth, + scrollHeight: document.documentElement.scrollHeight, + zoom: window.innerWidth > 0 ? Math.round(window.outerWidth / window.innerWidth * 100) / 100 : 1, + })`, + returnByValue: true, + })) + return JSON.parse(result?.result?.value || '{}') +} + +export async function extEnsureZoom(tabId, params) { + const targetZoom = typeof params?.zoom === 'number' ? params.zoom : 1 + const currentZoom = await chrome.tabs.getZoom(tabId) + if (Math.abs(currentZoom - targetZoom) > 0.01) { + await chrome.tabs.setZoom(tabId, targetZoom) + await new Promise((r) => setTimeout(r, 150)) + return { changed: true, from: currentZoom, to: targetZoom } + } + return { changed: false, current: currentZoom } +} + +// ── Screenshot ── + +export async function extCaptureViewport(tabId, params) { + const debuggee = { tabId } + const format = params?.format || 'png' + const quality = params?.quality || 80 + + await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: 'new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))', + awaitPromise: true, + })).catch(() => {}) + + const metrics = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Page.getLayoutMetrics')) + const vv = metrics?.visualViewport || {} + + const vpResult = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: 'JSON.stringify({ dpr: window.devicePixelRatio || 1 })', + returnByValue: true, + })) + const { dpr } = JSON.parse(vpResult?.result?.value || '{"dpr":1}') + + const screenshot = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Page.captureScreenshot', { + format, + quality: format === 'jpeg' ? quality : undefined, + clip: { + x: vv.pageX || 0, + y: vv.pageY || 0, + width: vv.clientWidth || 1280, + height: vv.clientHeight || 720, + scale: 1 / dpr, + }, + captureBeyondViewport: false, + })) + + return { + data: screenshot?.data, + width: Math.round(vv.clientWidth || 1280), + height: Math.round(vv.clientHeight || 720), + dpr, + } +} + +// ── Content Extraction ── + +export async function extExtractContent(tabId) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + const title = document.title + || document.querySelector('meta[property="og:title"]')?.content + || document.querySelector('h1')?.textContent?.trim() || '' + + const main = document.querySelector('main, article, [role="main"], #content, .content') + const root = main || document.body + + function toMarkdown(el) { + const lines = [] + const walk = (node) => { + if (node.nodeType === 3) { + const t = node.textContent.replace(/\s+/g, ' ').trim() + if (t) lines.push(t) + return + } + if (node.nodeType !== 1) return + const tag = node.tagName + try { + const style = getComputedStyle(node) + if (style.display === 'none' || style.visibility === 'hidden') return + } catch { /* skip check */ } + + if (/^H[1-6]$/.test(tag)) { + lines.push('\n' + '#'.repeat(+tag[1]) + ' ' + node.textContent.trim()) + } else if (tag === 'P') { + lines.push('\n' + node.innerText.replace(/\s+/g, ' ').trim()) + } else if (tag === 'LI') { + lines.push('- ' + node.innerText.replace(/\s+/g, ' ').trim()) + } else if (tag === 'A' && node.href) { + lines.push(`[${node.textContent.trim()}](${node.href})`) + } else if (tag === 'IMG' && node.alt) { + lines.push(`![${node.alt}](${node.src})`) + } else if (tag === 'PRE' || tag === 'CODE') { + lines.push('\n```\n' + node.textContent.trim() + '\n```') + } else if (tag === 'BR') { + lines.push('\n') + } else if (tag === 'TABLE') { + lines.push('\n' + node.innerText.replace(/\t/g, ' | ').trim()) + } else { + for (const child of node.childNodes) walk(child) + if (node.shadowRoot) { + for (const child of node.shadowRoot.childNodes) walk(child) + } + } + } + walk(el) + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim() + } + + return { + title: title.trim(), + url: location.href, + content: toMarkdown(root).slice(0, 50000), + } + }, + }) + return unwrapScriptResult(results, 'Extension.extractContent') +} + +// ── Interactive Element Marking ── + +export async function extMarkElements(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (options) => { + const INTERACTIVE_SELECTOR = [ + 'a[href]', 'button', 'input', 'select', 'textarea', + '[role="button"]', '[role="link"]', '[role="tab"]', '[role="menuitem"]', + '[role="checkbox"]', '[role="radio"]', '[role="switch"]', + '[contenteditable="true"]', '[onclick]', '[tabindex]:not([tabindex="-1"])', + ].join(',') + + const SKIP_TAGS = new Set([ + 'HTML', 'HEAD', 'BODY', 'SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'META', 'LINK', + ]) + + function deepElementFromPoint(x, y) { + const el = document.elementFromPoint(x, y) + if (el?.shadowRoot) { + const inner = el.shadowRoot.elementFromPoint(x, y) + if (inner && inner !== el) return inner + } + return el + } + + function isDescendantOrSelf(node, target) { + let cur = node + while (cur) { + if (cur === target) return true + if (cur.parentElement) { cur = cur.parentElement; continue } + const root = cur.getRootNode() + cur = root instanceof ShadowRoot ? root.host : null + } + return false + } + + function isVisible(el, rect, vw, vh) { + if (rect.right < 0 || rect.bottom < 0 || rect.left > vw || rect.top > vh) return false + const clipped = { + left: Math.max(rect.left, 0), top: Math.max(rect.top, 0), + right: Math.min(rect.right, vw), bottom: Math.min(rect.bottom, vh), + } + const cw = clipped.right - clipped.left + const ch = clipped.bottom - clipped.top + if (cw < 3 || ch < 3) return false + + const cols = Math.min(4, Math.max(1, Math.round(cw / 20))) + const rows = Math.min(4, Math.max(1, Math.round(ch / 20))) + let hits = 0, total = 0 + for (let r = 0; r <= rows; r++) { + for (let c = 0; c <= cols; c++) { + const px = clipped.left + (cols > 0 ? (c / cols) * cw : cw / 2) + const py = clipped.top + (rows > 0 ? (r / rows) * ch : ch / 2) + const top = deepElementFromPoint(px, py) + if (top && isDescendantOrSelf(top, el)) hits++ + total++ + } + } + return total > 0 && (hits / total) >= 0.3 + } + + function collectInteractive(root, out) { + for (const el of root.querySelectorAll('*')) { + if (SKIP_TAGS.has(el.tagName)) continue + if (el.matches(INTERACTIVE_SELECTOR)) out.push(el) + if (el.shadowRoot) collectInteractive(el.shadowRoot, out) + } + } + + const vw = window.innerWidth || 1, vh = window.innerHeight || 1 + const candidates = [] + collectInteractive(document, candidates) + + const elements = [] + let idx = 1 + const maxElements = options?.maxElements || 200 + + for (const el of candidates) { + if (idx > maxElements) break + const rect = el.getBoundingClientRect() + if (rect.width < 5 || rect.height < 5) continue + + try { + const style = getComputedStyle(el) + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue + } catch { /* skip check */ } + + if (!isVisible(el, rect, vw, vh)) continue + + const tag = el.tagName.toLowerCase() + const text = (el.textContent || el.value || el.placeholder + || el.getAttribute('aria-label') || el.title || '').trim().slice(0, 100) + + el.setAttribute('data-accio-idx', String(idx)) + + elements.push({ + idx, tag, + type: el.type || '', + text, + role: el.getAttribute('role') || '', + rect: { + x: Math.round(rect.left), y: Math.round(rect.top), + w: Math.round(rect.width), h: Math.round(rect.height), + }, + center: { + nx: +(((rect.left + rect.width / 2) / vw).toFixed(4)), + ny: +(((rect.top + rect.height / 2) / vh).toFixed(4)), + }, + }) + idx++ + } + + return { + elements, + viewport: { width: vw, height: vh, dpr: window.devicePixelRatio || 1 }, + url: location.href, + title: document.title, + } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.markElements') +} + +// ── DOM Actions ── + +export async function extClick(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + function deepQuery(root, sel) { + const found = root.querySelector(sel) + if (found) return found + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const inner = deepQuery(el.shadowRoot, sel) + if (inner) return inner + } + } + return null + } + let el + if (p.index != null) { + const idx = Number(p.index) + if (!Number.isInteger(idx) || idx <= 0) return { success: false, error: 'Invalid index' } + el = deepQuery(document, `[data-accio-idx="${idx}"]`) + } else if (p.selector) { + try { el = deepQuery(document, p.selector) } catch { return { success: false, error: 'Invalid selector' } } + } else if (p.x != null && p.y != null) { + const vw = window.innerWidth + const vh = window.innerHeight + const cx = p.viewportWidth ? (p.x / p.viewportWidth) * vw : p.x + const cy = p.viewportHeight ? (p.y / p.viewportHeight) * vh : p.y + el = document.elementFromPoint(cx, cy) + if (el?.shadowRoot) { + const inner = el.shadowRoot.elementFromPoint(cx, cy) + if (inner) el = inner + } + } + if (!el) return { success: false, error: 'Element not found' } + el.scrollIntoView({ block: 'center', behavior: 'instant' }) + const rect = el.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + + const clickType = (p.clickType || 'single_left').toLowerCase() + const isRight = clickType.includes('right') + const button = isRight ? 2 : 0 + const baseOpts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: cx, clientY: cy, button } + + if (clickType === 'double_left') { + el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: 1 })) + el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: 1 })) + el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: 1 })) + el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: 2 })) + el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: 2 })) + el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: 2 })) + el.dispatchEvent(new MouseEvent('dblclick', { ...baseOpts, detail: 2 })) + } else if (clickType === 'triple_left') { + for (let i = 1; i <= 3; i++) { + el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: i })) + el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: i })) + el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: i })) + } + } else if (isRight) { + el.dispatchEvent(new MouseEvent('mousedown', baseOpts)) + el.dispatchEvent(new MouseEvent('mouseup', baseOpts)) + el.dispatchEvent(new MouseEvent('contextmenu', baseOpts)) + } else { + el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: 1 })) + el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: 1 })) + el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: 1 })) + } + + return { success: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80), clickType, _cx: cx, _cy: cy } + }, + args: [params || {}], + }) + const result = unwrapScriptResult(results, 'Extension.click') + if (result?.success && result._cx != null) { + showClickRipple(tabId, result._cx, result._cy).catch(() => {}) + delete result._cx + delete result._cy + } + return result +} + +async function showClickRipple(tabId, x, y) { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (cx, cy) => { + if (!document.getElementById('accio-ripple-style')) { + const s = document.createElement('style') + s.id = 'accio-ripple-style' + s.textContent = '@keyframes accio-r{0%{transform:scale(0);opacity:1}100%{transform:scale(2.5);opacity:0}}' + document.head.appendChild(s) + } + const d = document.createElement('div') + Object.assign(d.style, { + position: 'fixed', left: `${cx - 16}px`, top: `${cy - 16}px`, + width: '32px', height: '32px', borderRadius: '50%', + background: 'rgba(99,102,241,0.4)', pointerEvents: 'none', + zIndex: '2147483647', animation: 'accio-r .8s ease-out forwards', + }) + document.body.appendChild(d) + d.onanimationend = () => d.remove() + }, + args: [x, y], + }) +} + +export async function extInput(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + function deepQuery(root, sel) { + const found = root.querySelector(sel) + if (found) return found + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const inner = deepQuery(el.shadowRoot, sel) + if (inner) return inner + } + } + return null + } + let el + if (p.index != null) { + const idx = Number(p.index) + if (!Number.isInteger(idx) || idx <= 0) return { success: false, error: 'Invalid index' } + el = deepQuery(document, `[data-accio-idx="${idx}"]`) + } else if (p.selector) { + try { el = deepQuery(document, p.selector) } catch { return { success: false, error: 'Invalid selector' } } + } + if (!el) return { success: false, error: 'Element not found' } + + el.focus() + const tag = el.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + const proto = tag === 'SELECT' ? HTMLSelectElement.prototype + : tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype + const nativeSet = Object.getOwnPropertyDescriptor(proto, 'value')?.set + if (nativeSet) nativeSet.call(el, p.text || '') + else el.value = p.text || '' + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } else if (el.getAttribute('contenteditable') === 'true') { + el.focus() + document.execCommand('selectAll') + document.execCommand('insertText', false, p.text || '') + } + return { success: true } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.input') +} diff --git a/extensions/lime-chrome/lib/content_script/extract-readability.js b/extensions/lime-chrome/lib/content_script/extract-readability.js new file mode 100644 index 000000000..a5e004d71 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/extract-readability.js @@ -0,0 +1,126 @@ +/** + * Enhanced content extraction with Readability-like fallback. + * + * Dual-path extraction: + * Path 1: Custom DOM walker (same as extExtractContent) + * Path 2: Scoring-based extraction inspired by Mozilla Readability + * + * Picks the longer result. Falls back to body.innerText if both are short. + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extExtractContentEnhanced(tabId) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + const title = document.title + || document.querySelector('meta[property="og:title"]')?.content + || document.querySelector('h1')?.textContent?.trim() || '' + + // Path 1: Simple walker + function simpleExtract() { + const main = document.querySelector('main, article, [role="main"], #content, .content') + const root = main || document.body + + const lines = [] + const walk = (node) => { + if (node.nodeType === 3) { + const t = node.textContent.replace(/\s+/g, ' ').trim() + if (t) lines.push(t) + return + } + if (node.nodeType !== 1) return + const tag = node.tagName + try { + const style = getComputedStyle(node) + if (style.display === 'none' || style.visibility === 'hidden') return + } catch { /* skip */ } + + if (/^H[1-6]$/.test(tag)) lines.push('\n' + '#'.repeat(+tag[1]) + ' ' + node.textContent.trim()) + else if (tag === 'P') lines.push('\n' + node.innerText.replace(/\s+/g, ' ').trim()) + else if (tag === 'LI') lines.push('- ' + node.innerText.replace(/\s+/g, ' ').trim()) + else if (tag === 'A' && node.href) lines.push(`[${node.textContent.trim()}](${node.href})`) + else if (tag === 'IMG' && node.alt) lines.push(`![${node.alt}](${node.src})`) + else if (tag === 'PRE' || tag === 'CODE') lines.push('\n```\n' + node.textContent.trim() + '\n```') + else if (tag === 'BR') lines.push('\n') + else if (tag === 'TABLE') lines.push('\n' + node.innerText.replace(/\t/g, ' | ').trim()) + else { + for (const child of node.childNodes) walk(child) + if (node.shadowRoot) for (const child of node.shadowRoot.childNodes) walk(child) + } + } + walk(root) + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim() + } + + // Path 2: Scoring-based extraction (Readability-inspired) + function scoringExtract() { + const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'NAV', 'FOOTER', 'HEADER', 'ASIDE', 'FORM']) + const candidates = [] + + function score(el) { + if (SKIP.has(el.tagName)) return 0 + const text = el.innerText || '' + const textLen = text.length + if (textLen < 25) return 0 + + let s = 0 + const paragraphs = el.querySelectorAll('p') + s += paragraphs.length * 3 + s += Math.min(textLen / 100, 30) + + const links = el.querySelectorAll('a') + const linkText = [...links].reduce((sum, a) => sum + (a.textContent || '').length, 0) + const linkDensity = textLen > 0 ? linkText / textLen : 1 + if (linkDensity > 0.5) s *= 0.3 + + const commas = (text.match(/[,,、]/g) || []).length + s += commas + + if (el.id?.match(/article|content|main|post|body/i)) s *= 1.5 + if (el.className?.match(/article|content|main|post|body/i)) s *= 1.3 + if (el.className?.match(/sidebar|comment|footer|nav|menu|ad|banner/i)) s *= 0.3 + + return s + } + + for (const el of document.querySelectorAll('div, section, article, main')) { + const s = score(el) + if (s > 0) candidates.push({ el, score: s }) + } + + candidates.sort((a, b) => b.score - a.score) + const best = candidates[0] + if (!best || best.score < 10) return '' + + const text = best.el.innerText || '' + return text.replace(/\t/g, ' ').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim() + } + + const simple = simpleExtract() + const scored = scoringExtract() + + let content + if (simple.length >= scored.length || simple.length > 800) { + content = simple + } else { + content = scored + } + + if (content.length < 200) { + const fallback = (document.body?.innerText || '') + .replace(/\t/g, ' ').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim() + if (fallback.length > content.length) content = fallback + } + + return { + title: title.trim(), + url: location.href, + content: content.slice(0, 50000), + method: content === simple ? 'walker' : content.length < 200 ? 'body-fallback' : 'scoring', + } + }, + }) + return unwrapScriptResult(results, 'Extension.extractContentEnhanced') +} diff --git a/extensions/lime-chrome/lib/content_script/find-keyword.js b/extensions/lime-chrome/lib/content_script/find-keyword.js new file mode 100644 index 000000000..156f96279 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/find-keyword.js @@ -0,0 +1,77 @@ +/** + * Extension.findKeyword — In-page keyword search with context expansion. + * + * Uses sentence segmentation to find keyword occurrences and returns + * surrounding context (~20 words). Handles CJK characters correctly. + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extFindKeyword(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + const keyword = (p.keyword || '').trim() + if (!keyword) return { success: false, error: 'No keyword provided' } + + const body = document.body?.innerText || '' + if (!body) return { success: true, matches: [], count: 0 } + + const CJK_RE = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/ + const isCJK = CJK_RE.test(keyword) + + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = isCJK ? new RegExp(escaped, 'gi') : new RegExp(`\\b${escaped}\\b`, 'gi') + + let sentences + try { + const segmenter = new Intl.Segmenter('en', { granularity: 'sentence' }) + sentences = [...segmenter.segment(body)].map(s => s.segment) + } catch { + sentences = body.split(/[.!?\n]+/).filter(Boolean) + } + + const matches = [] + const MAX_MATCHES = 20 + const CONTEXT_WORDS = 20 + + for (const sentence of sentences) { + if (matches.length >= MAX_MATCHES) break + if (!pattern.test(sentence)) continue + pattern.lastIndex = 0 + + const words = sentence.split(/\s+/) + let match + while ((match = pattern.exec(sentence)) !== null && matches.length < MAX_MATCHES) { + const beforeText = sentence.slice(0, match.index) + const wordsBefore = beforeText.split(/\s+/).filter(Boolean) + const startWord = Math.max(0, wordsBefore.length - CONTEXT_WORDS) + + const afterText = sentence.slice(match.index + match[0].length) + const wordsAfter = afterText.split(/\s+/).filter(Boolean) + const endWord = Math.min(wordsAfter.length, CONTEXT_WORDS) + + const contextBefore = wordsBefore.slice(startWord).join(' ') + const contextAfter = wordsAfter.slice(0, endWord).join(' ') + const context = `${contextBefore} ${match[0]} ${contextAfter}`.trim() + + matches.push({ + keyword: match[0], + context, + sentenceIndex: sentences.indexOf(sentence), + }) + } + } + + return { + success: true, + keyword, + count: matches.length, + matches, + url: location.href, + } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.findKeyword') +} diff --git a/extensions/lime-chrome/lib/content_script/helpers.js b/extensions/lime-chrome/lib/content_script/helpers.js new file mode 100644 index 000000000..e12e1818b --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/helpers.js @@ -0,0 +1,11 @@ +/** + * Shared utilities for content_script commands. + */ + +export function unwrapScriptResult(results, label) { + const first = results?.[0] + if (!first) throw new Error(`${label}: no injection result (page may be restricted)`) + if (first.error) throw new Error(`${label}: ${typeof first.error === 'string' ? first.error : JSON.stringify(first.error)}`) + return first.result +} + diff --git a/extensions/lime-chrome/lib/content_script/highlight-screenshot.js b/extensions/lime-chrome/lib/content_script/highlight-screenshot.js new file mode 100644 index 000000000..24d5c7775 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/highlight-screenshot.js @@ -0,0 +1,94 @@ +/** + * Extension.captureHighlightedViewport — Screenshot with element annotations. + * + * Takes a clean screenshot, then draws dashed rectangles and index labels + * over interactive elements using OffscreenCanvas in the Service Worker. + * + * Color scheme follows Manus convention: + * button → yellow, input → coral, select → pink, a → green, + * textarea → blue, default → red + */ + +const TAG_COLORS = { + button: '#FFFF00', + input: '#FF7F50', + select: '#FF4162', + a: '#00FF00', + textarea: '#0000FF', +} +const DEFAULT_COLOR = '#FF0000' + +function colorForTag(tag) { + return TAG_COLORS[tag] || DEFAULT_COLOR +} + +/** + * Render element annotations onto a screenshot. + * + * @param {string} base64Png — Clean screenshot as base64 + * @param {Array<{idx: number, tag: string, rect: {x: number, y: number, w: number, h: number}}>} elements + * @param {number} width — Viewport width + * @param {number} height — Viewport height + * @returns {Promise} Annotated screenshot as base64 + */ +export async function renderHighlightedScreenshot(base64Png, elements, width, height) { + const blob = await (await fetch(`data:image/png;base64,${base64Png}`)).blob() + const bitmap = await createImageBitmap(blob) + + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height) + const ctx = canvas.getContext('2d') + ctx.drawImage(bitmap, 0, 0) + + const scaleX = bitmap.width / width + const scaleY = bitmap.height / height + + const fontSize = Math.max(10, Math.min(20, Math.round(bitmap.width / 100))) + ctx.font = `bold ${fontSize}px monospace` + ctx.textBaseline = 'top' + + const DASH_LENGTH = 4 * scaleX + const GAP_LENGTH = 8 * scaleX + const LINE_WIDTH = 2 * scaleX + const LABEL_PAD = 3 * scaleX + + for (const el of elements) { + const color = colorForTag(el.tag) + const x = el.rect.x * scaleX + const y = el.rect.y * scaleY + const w = el.rect.w * scaleX + const h = el.rect.h * scaleY + + ctx.strokeStyle = color + ctx.lineWidth = LINE_WIDTH + ctx.setLineDash([DASH_LENGTH, GAP_LENGTH]) + ctx.strokeRect(x, y, w, h) + ctx.setLineDash([]) + + const label = String(el.idx) + const metrics = ctx.measureText(label) + const lw = metrics.width + LABEL_PAD * 2 + const lh = fontSize + LABEL_PAD * 2 + + let lx = x - lw - 2 + let ly = y - 2 + if (lx < 0) lx = x + w + 2 + if (ly < 0) ly = y + h + 2 + if (lx + lw > bitmap.width) lx = x + if (ly + lh > bitmap.height) ly = y + + ctx.fillStyle = color + ctx.fillRect(lx, ly, lw, lh) + ctx.fillStyle = '#000000' + ctx.fillText(label, lx + LABEL_PAD, ly + LABEL_PAD) + } + + const outBlob = await canvas.convertToBlob({ type: 'image/png' }) + const buffer = await outBlob.arrayBuffer() + const bytes = new Uint8Array(buffer) + const CHUNK = 0x8000 + const chunks = [] + for (let i = 0; i < bytes.length; i += CHUNK) { + chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))) + } + return btoa(chunks.join('')) +} diff --git a/extensions/lime-chrome/lib/content_script/index.js b/extensions/lime-chrome/lib/content_script/index.js new file mode 100644 index 000000000..c7eeced89 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/index.js @@ -0,0 +1,51 @@ +/** + * Content Script channel — Manus-style DOM interaction. + * + * Uses chrome.scripting.executeScript to inject page-level scripts + * for viewport info, content extraction, element marking, click/input, + * keyboard, scrolling, keyword search, mouse hover, and Shadow DOM traversal. + * No debugger attachment required for DOM operations. + * + * Active commands (wired into CDP dispatch): + * extension-ops.js — Core: viewport, screenshot, content, mark, click, input + * press-key.js — Keyboard event simulation with modifiers/combos + * scroll.js — Page/container scrolling with verification + * find-keyword.js — In-page keyword search with context + * move-mouse.js — Mouse hover simulation + * highlight-screenshot.js — Screenshot annotation with element highlights + * + * Reserved commands (implemented but not wired — enable when needed): + * wait-ready.js — Page readyState + MutationObserver + * action-mask.js — Shadow DOM overlay to block user interaction + * input-enhanced.js — DraftEditor / Slate / Rich Text Editor support + * extract-readability.js — Dual-path content extraction with scoring fallback + * keepalive.js — MV3 Service Worker heartbeat mechanism + * + * helpers.js — Shared utilities (unwrapScriptResult) + */ + +// ── Active exports ── + +export { + extGetViewportInfo, + extEnsureZoom, + extCaptureViewport, + extExtractContent, + extMarkElements, + extClick, + extInput, +} from './extension-ops.js' + +export { extPressKey } from './press-key.js' +export { extScroll } from './scroll.js' +export { extFindKeyword } from './find-keyword.js' +export { extMoveMouse } from './move-mouse.js' +export { renderHighlightedScreenshot } from './highlight-screenshot.js' + +// ── Reserved exports (not wired into dispatch) ── + +export { extWaitForReady } from './wait-ready.js' +export { extActionMask } from './action-mask.js' +export { extInputEnhanced } from './input-enhanced.js' +export { extExtractContentEnhanced } from './extract-readability.js' +export { startKeepAlive, stopKeepAlive, registerKeepAliveListener } from './keepalive.js' diff --git a/extensions/lime-chrome/lib/content_script/input-enhanced.js b/extensions/lime-chrome/lib/content_script/input-enhanced.js new file mode 100644 index 000000000..f6e385105 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/input-enhanced.js @@ -0,0 +1,114 @@ +/** + * Enhanced input handling — DraftEditor and Rich Text Editor support. + * + * Detects and handles React DraftEditor (Facebook/Meta ecosystem), + * Slate, ProseMirror, and other contenteditable-based editors. + * + * Detection strategy: + * 1. Check for DraftEditor class on element or ancestors + * 2. Check for data-contents attribute (DraftEditor signature) + * 3. Check for Slate/ProseMirror data attributes + * + * Input strategy for rich text: + * 1. Focus element + * 2. Select all content (Selection API) + * 3. For DraftEditor: execCommand("delete") + execCommand("insertText") + * 4. For others: direct textContent set + InputEvent dispatch + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extInputEnhanced(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + function deepQuery(root, sel) { + const found = root.querySelector(sel) + if (found) return found + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const inner = deepQuery(el.shadowRoot, sel) + if (inner) return inner + } + } + return null + } + + let el + if (p.index) el = deepQuery(document, `[data-accio-idx="${p.index}"]`) + else if (p.selector) el = deepQuery(document, p.selector) + if (!el) return { success: false, error: 'Element not found' } + + const text = p.text || '' + const tag = el.tagName + const isContentEditable = el.getAttribute('contenteditable') === 'true' || + el.isContentEditable + + function isDraftEditor(element) { + if (element.classList?.contains('DraftEditor-content')) return true + if (element.hasAttribute('data-contents')) return true + let cur = element + for (let i = 0; i < 5 && cur; i++) { + if (cur.classList?.contains('DraftEditor-root')) return true + cur = cur.parentElement + } + return false + } + + function isSlateEditor(element) { + return element.hasAttribute('data-slate-editor') || + element.closest?.('[data-slate-editor]') != null + } + + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + el.focus() + const proto = tag === 'SELECT' ? HTMLSelectElement.prototype + : tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype + const nativeSet = Object.getOwnPropertyDescriptor(proto, 'value')?.set + if (nativeSet) nativeSet.call(el, text) + else el.value = text + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + return { success: true, method: 'native-setter' } + } + + if (isContentEditable) { + el.focus() + + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(el) + selection.removeAllRanges() + selection.addRange(range) + + if (isDraftEditor(el)) { + document.execCommand('delete', false) + document.execCommand('insertText', false, text) + return { success: true, method: 'draft-editor' } + } + + if (isSlateEditor(el)) { + document.execCommand('insertText', false, text) + return { success: true, method: 'slate-editor' } + } + + const ok = document.execCommand('insertText', false, text) + if (ok) return { success: true, method: 'execCommand' } + + el.textContent = text + el.dispatchEvent(new InputEvent('beforeinput', { + bubbles: true, cancelable: true, inputType: 'insertText', data: text, + })) + el.dispatchEvent(new InputEvent('input', { + bubbles: true, inputType: 'insertText', data: text, + })) + return { success: true, method: 'textContent-fallback' } + } + + return { success: false, error: `Unsupported element: ${tag}, contentEditable=${el.isContentEditable}` } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.inputEnhanced') +} diff --git a/extensions/lime-chrome/lib/content_script/keepalive.js b/extensions/lime-chrome/lib/content_script/keepalive.js new file mode 100644 index 000000000..0a1c45303 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/keepalive.js @@ -0,0 +1,89 @@ +/** + * MV3 Service Worker keepalive mechanism. + * + * Two-layer keepalive strategy to prevent the Service Worker from being suspended: + * + * Layer 1: chrome.alarms (already implemented in background.js) + * - "relayKeepAlive" alarm fires every 15 seconds + * - Sends ping to relay server if connected + * + * Layer 2: Content Script heartbeat (this module) + * - Injected into the active tab periodically + * - Establishes a long-lived port connection with the background + * - Sends ping every 20 seconds + * - Auto-reconnects on disconnect + * + * Usage: Call startKeepAlive(tabId) when a session is active, + * stopKeepAlive(tabId) when done. Not wired into dispatch — called + * directly from background.js when needed. + */ + +const HEARTBEAT_INTERVAL_MS = 20_000 +const PORT_NAME = 'accio-keepalive' + +const activeKeepAlives = new Map() + +export async function startKeepAlive(tabId) { + if (activeKeepAlives.has(tabId)) return + + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (portName, interval) => { + if (window._accioKeepAlive) return + + function connect() { + try { + const port = chrome.runtime.connect({ name: portName }) + const timer = setInterval(() => { + try { port.postMessage({ type: 'ping' }) } catch { clearInterval(timer) } + }, interval) + port.onDisconnect.addListener(() => { + clearInterval(timer) + setTimeout(connect, 1000) + }) + window._accioKeepAlive = { port, timer } + } catch { /* extension context invalidated */ } + } + + connect() + }, + args: [PORT_NAME, HEARTBEAT_INTERVAL_MS], + }) + activeKeepAlives.set(tabId, true) + } catch { /* tab may be restricted */ } +} + +export async function stopKeepAlive(tabId) { + if (!activeKeepAlives.has(tabId)) return + + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + if (window._accioKeepAlive) { + clearInterval(window._accioKeepAlive.timer) + try { window._accioKeepAlive.port.disconnect() } catch { /* noop */ } + window._accioKeepAlive = null + } + }, + }) + } catch { /* tab may have closed */ } + + activeKeepAlives.delete(tabId) +} + +/** + * Register the keepalive port listener in the background script. + * Call this once during initialization. + */ +export function registerKeepAliveListener() { + chrome.runtime.onConnect.addListener((port) => { + if (port.name !== PORT_NAME) return + port.onMessage.addListener((msg) => { + if (msg?.type === 'ping') { + try { port.postMessage({ type: 'pong' }) } catch { /* port closed */ } + } + }) + }) +} diff --git a/extensions/lime-chrome/lib/content_script/move-mouse.js b/extensions/lime-chrome/lib/content_script/move-mouse.js new file mode 100644 index 000000000..057e910b4 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/move-mouse.js @@ -0,0 +1,71 @@ +/** + * Extension.moveMouse — Mouse hover simulation. + * + * Dispatches mousemove and mouseover events to trigger hover states. + * Supports coordinate-based and element-based targeting. + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extMoveMouse(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + function deepQuery(root, sel) { + const found = root.querySelector(sel) + if (found) return found + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const inner = deepQuery(el.shadowRoot, sel) + if (inner) return inner + } + } + return null + } + + let el, cx, cy + + if (p.index) { + el = deepQuery(document, `[data-accio-idx="${p.index}"]`) + } else if (p.selector) { + el = deepQuery(document, p.selector) + } + + if (el) { + const rect = el.getBoundingClientRect() + cx = rect.left + rect.width / 2 + cy = rect.top + rect.height / 2 + } else if (p.x != null && p.y != null) { + const vw = window.innerWidth + const vh = window.innerHeight + cx = p.viewportWidth ? (p.x / p.viewportWidth) * vw : p.x + cy = p.viewportHeight ? (p.y / p.viewportHeight) * vh : p.y + el = document.elementFromPoint(cx, cy) + if (el?.shadowRoot) { + const inner = el.shadowRoot.elementFromPoint(cx, cy) + if (inner) el = inner + } + } + + if (!el) return { success: false, error: 'Target element not found' } + + const eventOpts = { + bubbles: true, cancelable: true, composed: true, + clientX: cx, clientY: cy, view: window, + } + + el.dispatchEvent(new MouseEvent('mouseover', eventOpts)) + el.dispatchEvent(new MouseEvent('mouseenter', { ...eventOpts, bubbles: false })) + el.dispatchEvent(new MouseEvent('mousemove', eventOpts)) + + return { + success: true, + tag: el.tagName.toLowerCase(), + text: (el.textContent || '').trim().slice(0, 80), + position: { x: Math.round(cx), y: Math.round(cy) }, + } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.moveMouse') +} diff --git a/extensions/lime-chrome/lib/content_script/press-key.js b/extensions/lime-chrome/lib/content_script/press-key.js new file mode 100644 index 000000000..7643b9095 --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/press-key.js @@ -0,0 +1,91 @@ +/** + * Extension.pressKey — Keyboard event simulation. + * + * Supports single keys, modifier keys, and combo strings like "Ctrl+A", "Shift+Enter". + * Dispatches keydown, keypress (for printable), and keyup events. + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extPressKey(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + const KEY_MAP = { + enter: { key: 'Enter', code: 'Enter', keyCode: 13 }, + tab: { key: 'Tab', code: 'Tab', keyCode: 9 }, + escape: { key: 'Escape', code: 'Escape', keyCode: 27 }, + esc: { key: 'Escape', code: 'Escape', keyCode: 27 }, + backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 }, + delete: { key: 'Delete', code: 'Delete', keyCode: 46 }, + space: { key: ' ', code: 'Space', keyCode: 32 }, + arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + arrowright: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + home: { key: 'Home', code: 'Home', keyCode: 36 }, + end: { key: 'End', code: 'End', keyCode: 35 }, + pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 }, + pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 }, + } + + for (let i = 1; i <= 12; i++) { + KEY_MAP[`f${i}`] = { key: `F${i}`, code: `F${i}`, keyCode: 111 + i } + } + + const MODIFIER_NAMES = new Set(['ctrl', 'control', 'shift', 'alt', 'meta', 'cmd', 'command']) + + function resolveKey(name) { + const lower = name.toLowerCase().trim() + if (KEY_MAP[lower]) return KEY_MAP[lower] + if (lower.length === 1) { + const upper = lower.toUpperCase() + return { key: lower, code: `Key${upper}`, keyCode: upper.charCodeAt(0) } + } + return { key: name, code: name, keyCode: 0 } + } + + function parseCombo(combo) { + const parts = String(combo).split('+').map(s => s.trim()).filter(Boolean) + const modifiers = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false } + let mainKey = null + + for (const part of parts) { + const lower = part.toLowerCase() + if (lower === 'ctrl' || lower === 'control') modifiers.ctrlKey = true + else if (lower === 'shift') modifiers.shiftKey = true + else if (lower === 'alt') modifiers.altKey = true + else if (lower === 'meta' || lower === 'cmd' || lower === 'command') modifiers.metaKey = true + else mainKey = resolveKey(part) + } + + if (!mainKey && parts.length === 1) mainKey = resolveKey(parts[0]) + return { modifiers, mainKey } + } + + const target = document.activeElement || document.body + const combo = p.key || p.combo || '' + const { modifiers, mainKey } = parseCombo(combo) + + if (!mainKey) return { success: false, error: `Cannot resolve key: ${combo}` } + + const isPrintable = mainKey.key.length === 1 && !modifiers.ctrlKey && !modifiers.metaKey + + const baseOpts = { + bubbles: true, cancelable: true, composed: true, + key: mainKey.key, code: mainKey.code, keyCode: mainKey.keyCode, which: mainKey.keyCode, + ...modifiers, + } + + target.dispatchEvent(new KeyboardEvent('keydown', baseOpts)) + if (isPrintable) { + target.dispatchEvent(new KeyboardEvent('keypress', { ...baseOpts, charCode: mainKey.key.charCodeAt(0) })) + } + target.dispatchEvent(new KeyboardEvent('keyup', baseOpts)) + + return { success: true, key: mainKey.key, modifiers } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.pressKey') +} diff --git a/extensions/lime-chrome/lib/content_script/scroll.js b/extensions/lime-chrome/lib/content_script/scroll.js new file mode 100644 index 000000000..f52f0000d --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/scroll.js @@ -0,0 +1,99 @@ +/** + * Extension.scroll — Page and container scrolling with verification. + * + * Supports directional scrolling (up/down/left/right), scroll-to-end, + * and container-specific scrolling by coordinates. + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extScroll(tabId, params) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (p) => { + const direction = (p.direction || 'down').toLowerCase() + const toEnd = p.toEnd === true + const amount = p.amount || null + + function findScrollable(x, y) { + let el = document.elementFromPoint(x, y) + let depth = 0 + while (el && depth < 80) { + const style = getComputedStyle(el) + const overflowY = style.overflowY + const overflowX = style.overflowX + const isScrollableY = (overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight + const isScrollableX = (overflowX === 'auto' || overflowX === 'scroll') && el.scrollWidth > el.clientWidth + if ((direction === 'up' || direction === 'down') && isScrollableY) return el + if ((direction === 'left' || direction === 'right') && isScrollableX) return el + el = el.parentElement + depth++ + } + return null + } + + const vw = window.innerWidth + const vh = window.innerHeight + + let target = null + if (p.x != null && p.y != null) { + const cx = p.viewportWidth ? (p.x / p.viewportWidth) * vw : p.x + const cy = p.viewportHeight ? (p.y / p.viewportHeight) * vh : p.y + target = findScrollable(cx, cy) + } + + const scrollTarget = target || document.scrollingElement || document.documentElement + const isWindow = scrollTarget === document.scrollingElement || scrollTarget === document.documentElement + + const scrollAmount = amount || Math.max(120, Math.round((direction === 'left' || direction === 'right' ? vw : vh) * 0.6)) + + const beforeX = isWindow ? window.scrollX : scrollTarget.scrollLeft + const beforeY = isWindow ? window.scrollY : scrollTarget.scrollTop + + let targetX = beforeX + let targetY = beforeY + + if (toEnd) { + if (direction === 'down') targetY = scrollTarget.scrollHeight + else if (direction === 'up') targetY = 0 + else if (direction === 'right') targetX = scrollTarget.scrollWidth + else if (direction === 'left') targetX = 0 + } else { + if (direction === 'down') targetY = beforeY + scrollAmount + else if (direction === 'up') targetY = beforeY - scrollAmount + else if (direction === 'right') targetX = beforeX + scrollAmount + else if (direction === 'left') targetX = beforeX - scrollAmount + } + + if (isWindow) { + window.scrollTo({ left: targetX, top: targetY, behavior: 'instant' }) + } else { + scrollTarget.scrollTo({ left: targetX, top: targetY, behavior: 'instant' }) + } + + const afterX = isWindow ? window.scrollX : scrollTarget.scrollLeft + const afterY = isWindow ? window.scrollY : scrollTarget.scrollTop + + const scrolledX = Math.round(afterX - beforeX) + const scrolledY = Math.round(afterY - beforeY) + + const totalHeight = scrollTarget.scrollHeight + const clientHeight = isWindow ? vh : scrollTarget.clientHeight + const pixelsAbove = Math.round(afterY) + const pixelsBelow = Math.max(0, Math.round(totalHeight - afterY - clientHeight)) + + return { + success: true, + direction, + scrolled: { x: scrolledX, y: scrolledY }, + container: target ? target.tagName.toLowerCase() : 'window', + pixelsAbove, + pixelsBelow, + atTop: afterY <= 0, + atBottom: pixelsBelow <= 1, + } + }, + args: [params || {}], + }) + return unwrapScriptResult(results, 'Extension.scroll') +} diff --git a/extensions/lime-chrome/lib/content_script/wait-ready.js b/extensions/lime-chrome/lib/content_script/wait-ready.js new file mode 100644 index 000000000..8d92bc97f --- /dev/null +++ b/extensions/lime-chrome/lib/content_script/wait-ready.js @@ -0,0 +1,56 @@ +/** + * Extension.waitForReady — Page readiness tracking. + * + * Waits for the page to reach a ready state before executing actions. + * Uses document.readyState and optional MutationObserver for body detection. + * + * Ready criteria: "complete" OR ("interactive" + 1000ms elapsed) + */ + +import { unwrapScriptResult } from './helpers.js' + +export async function extWaitForReady(tabId, params) { + const timeoutMs = params?.timeout || 10000 + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (timeout) => { + return new Promise((resolve) => { + const t0 = Date.now() + + function check() { + const state = document.readyState + const elapsed = Date.now() - t0 + + if (state === 'complete') { + return resolve({ ready: true, state, elapsed }) + } + if (state === 'interactive' && elapsed >= 1000) { + return resolve({ ready: true, state, elapsed, note: 'interactive + 1s' }) + } + if (elapsed >= timeout) { + return resolve({ ready: false, state, elapsed, error: 'Timeout waiting for page ready' }) + } + setTimeout(check, 200) + } + + if (!document.body) { + const observer = new MutationObserver(() => { + if (document.body) { + observer.disconnect() + check() + } + }) + observer.observe(document.documentElement, { childList: true }) + setTimeout(() => { + observer.disconnect() + check() + }, Math.min(timeout, 5000)) + } else { + check() + } + }) + }, + args: [timeoutMs], + }) + return unwrapScriptResult(results, 'Extension.waitForReady') +} diff --git a/extensions/lime-chrome/lib/icon-badge.js b/extensions/lime-chrome/lib/icon-badge.js new file mode 100644 index 000000000..2727c18c3 --- /dev/null +++ b/extensions/lime-chrome/lib/icon-badge.js @@ -0,0 +1,65 @@ +/** + * Dynamic icon badge — draws a small colored dot on the extension icon. + * + * Uses OffscreenCanvas (available in MV3 service workers) to composite + * the base icon with a status indicator dot in the bottom-right corner. + */ + +const ICON_SIZES = [16, 32] + +/** @type {Map} */ +const iconCache = new Map() + +async function loadIcon(size) { + if (iconCache.has(size)) return iconCache.get(size) + const url = chrome.runtime.getURL(`icons/icon${size}.png`) + const resp = await fetch(url) + const blob = await resp.blob() + const bmp = await createImageBitmap(blob) + iconCache.set(size, bmp) + return bmp +} + +/** + * Set the extension icon with an optional colored status dot. + * @param {string|null} dotColor — CSS color for the dot, or null to remove it + */ +export async function setIconWithDot(dotColor) { + if (!dotColor) { + await chrome.action.setIcon({ + path: { '16': 'icons/icon16.png', '32': 'icons/icon32.png', '48': 'icons/icon48.png', '128': 'icons/icon128.png' }, + }) + // Clear any leftover badge text + void chrome.action.setBadgeText({ text: '' }) + return + } + + /** @type {Record} */ + const imageData = {} + + for (const size of ICON_SIZES) { + const bmp = await loadIcon(size) + const canvas = new OffscreenCanvas(size, size) + const ctx = canvas.getContext('2d') + ctx.drawImage(bmp, 0, 0, size, size) + + const dotRadius = Math.round(size * 0.20) + const cx = size - dotRadius - 1 + const cy = size - dotRadius - 1 + + ctx.beginPath() + ctx.arc(cx, cy, dotRadius + 1, 0, Math.PI * 2) + ctx.fillStyle = '#ffffff' + ctx.fill() + + ctx.beginPath() + ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2) + ctx.fillStyle = dotColor + ctx.fill() + + imageData[String(size)] = ctx.getImageData(0, 0, size, size) + } + + await chrome.action.setIcon({ imageData }) + void chrome.action.setBadgeText({ text: '' }) +} diff --git a/extensions/lime-chrome/lib/logger.js b/extensions/lime-chrome/lib/logger.js new file mode 100644 index 000000000..c2e80a98c --- /dev/null +++ b/extensions/lime-chrome/lib/logger.js @@ -0,0 +1,55 @@ +/** + * Debug logger — controlled by a module-level flag. + * + * When `DEBUG` is true, all log/warn/error calls forward to the console + * with a [lime-relay] prefix and a module tag. When false, only warn/error emit. + * + * Usage: + * import { createLogger, setDebug } from './logger.js' + * const log = createLogger('relay') + * setDebug(true) + * log.debug('connected', { port: 9236 }) // → console.debug('[lime:relay] connected', {port:9236}) + * log.info('ok') // → console.info('[lime:relay] ok') + * log.warn('slow') // always prints + * log.error('fail', err) // always prints + */ + +let DEBUG = false + +export function setDebug(enabled) { + DEBUG = !!enabled +} + +export function isDebug() { + return DEBUG +} + +/** + * Create a tagged logger. + * @param {string} module — short tag (e.g. 'relay', 'tabs', 'cdp', 'bg') + */ +export function createLogger(module) { + const prefix = `[lime:${module}]` + + return { + /** Only prints when DEBUG is true */ + debug(...args) { + if (DEBUG) console.debug(prefix, ...args) + }, + + /** Only prints when DEBUG is true */ + info(...args) { + if (DEBUG) console.info(prefix, ...args) + }, + + /** Always prints */ + warn(...args) { + console.warn(prefix, ...args) + }, + + /** Always prints */ + error(...args) { + console.error(prefix, ...args) + }, + } +} diff --git a/extensions/lime-chrome/manifest.json b/extensions/lime-chrome/manifest.json index 270edbd07..1b68d5c9b 100644 --- a/extensions/lime-chrome/manifest.json +++ b/extensions/lime-chrome/manifest.json @@ -1,22 +1,42 @@ { "manifest_version": 3, "name": "Lime Browser Bridge", - "description": "Connect Chrome tabs with Lime browser runtime for AI agents.", - "version": "0.2.0", - "permissions": ["storage", "tabs", "activeTab", "scripting", "clipboardRead"], - "host_permissions": ["http://*/*", "https://*/*"], + "description": "Attach Lime to your current Chrome tab via the Lime browser bridge.", + "version": "0.3.0", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "permissions": [ + "debugger", + "tabs", + "tabGroups", + "windows", + "activeTab", + "scripting", + "storage", + "clipboardRead", + "alarms", + "notifications" + ], + "host_permissions": [""], "background": { "service_worker": "background.js" }, "action": { "default_title": "Lime Browser Bridge", - "default_popup": "popup.html" - }, - "content_scripts": [ - { - "matches": ["http://*/*", "https://*/*"], - "js": ["content_script.js"], - "run_at": "document_idle" + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" } - ] + }, + "options_ui": { + "page": "pages/options.html", + "open_in_tab": true + } } diff --git a/extensions/lime-chrome/pages/compare-methods.html b/extensions/lime-chrome/pages/compare-methods.html new file mode 100644 index 000000000..33dd8af54 --- /dev/null +++ b/extensions/lime-chrome/pages/compare-methods.html @@ -0,0 +1,656 @@ + + + + + + Connection Methods Comparison + + + + +
+
+ +
+
+ Lime Browser Bridge +
+
+

Choose Your Connection Method

+
+
+

+ Two ways to connect your browser. Choose the one that works best for you. +

+ + + +
+ + + + + +
+
+ + + + + +
+
CDP Direct Connect
+

Zero installation. Connect directly via Chrome DevTools Protocol.

+
    +
  • + + No extension installation needed +
  • +
  • + + Works out of the box on Chrome 144+ +
  • +
  • + + Requires re-authorization after every Lime or Chrome restart +
  • +
  • + + Chrome 144+ only +
  • +
+
+
+ + + +
+ + Feature Comparison + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureExtensionCDP Direct
Initial SetupInstall extensionEnable in chrome://inspect
Reconnect after restartAutomaticManual (click "Allow")
Chrome versionAll versions144+
Connection stabilityHighMedium
Requires installationYesNo
Automation bannerExtension debugging noticeAutomated test software notice
+ + + +
+ + +
+ +
+ + + + + diff --git a/extensions/lime-chrome/pages/install-direct-cdp.html b/extensions/lime-chrome/pages/install-direct-cdp.html new file mode 100644 index 000000000..b52514fd9 --- /dev/null +++ b/extensions/lime-chrome/pages/install-direct-cdp.html @@ -0,0 +1,338 @@ + + + + + + Enable Browser Connection + + + +
+
+ +
+
+ Lime Browser Bridge +
+
+

Enable Browser Connection

+
+
+

+ Your Chrome supports direct connection — no extension needed! +

+ + + +
    + +
  1. +
    Enable Remote Debugging
    +
    + + Toggle the remote debugging switch to On. +
    + +
    + On Windows, Chrome may block this page from opening automatically. Copy and paste the URL into your address bar: +
    + chrome://inspect/#remote-debugging + + + + + Click to copy +
    +
    +
    How it works
    +
    + Chrome M144+ includes a built-in remote debugging interface. Lime connects directly to Chrome via the DevTools Protocol (CDP) — faster and simpler than using an extension. +
    +
    +
    +
    +
    +
  2. + +
  3. +
    Allow Connection
    +
    + When Lime requests a debugging session, Chrome will show a permission dialog. Click Allow to let Lime connect. +
    + +
    +
    +
    About the automation banner
    +
    + While Lime is connected, Chrome displays "Chrome is being controlled by automated test software". This is a standard security notice confirming the debugging session is active. +
    +
    +
    +
    +
    +
  4. + +
  5. +
    You're All Set
    +
    + Lime can now directly control your browser tabs. No extension installation required. +
    +
    When do I need to reconnect?
    +
    + You need to click "Allow" again each time you restart Lime or Chrome. During normal use, the connection stays active automatically — no repeated approval needed. +
    +
    +
    + +
    +
    +
    Security
    +
    + Remote debugging only works on localhost (127.0.0.1). Each new session requires explicit user permission via the Chrome dialog. You can disable it at any time by toggling the switch off. +
    +
    +
    +
    +
    +
  6. + +
+ +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/extensions/lime-chrome/pages/install-extension.html b/extensions/lime-chrome/pages/install-extension.html new file mode 100644 index 000000000..ffb2277d8 --- /dev/null +++ b/extensions/lime-chrome/pages/install-extension.html @@ -0,0 +1,496 @@ + + + + + + Install Lime Browser Bridge + + + +
+
+ +
+
+ Lime Browser Bridge +
+
+

+ Install Lime Browser Bridge + Extension +

+
+
+

+ Install the extension to connect your browser to Lime. +

+ + + + + + + +
    + +
  1. +
    Open Chrome Extensions & Enable Developer Mode
    +
    + +
    + + + + + + Then turn on the Developer mode toggle in the top-right corner of the page. +
    +
    + +
    + Open a new Chrome tab and copy the address: +
    + chrome://extensions + + + + + Click to copy +
    +
    +
    +
    +
  2. + +
  3. +
    Open the Extension Folder
    +
    + Open the folder that contains the browser extension files: + chrome-extension/lime-chrome +
    + + +
    +
    +
  4. + +
  5. +
    Drag the Extension Folder into Chrome
    +
    + + Drag the lime-chrome folder directly onto the chrome://extensions page. Chrome will install it immediately. + +
    + +
    +
    +
    Load Unpacked
    +
    + Click "Load unpacked" on the chrome://extensions page and select the extension folder. +
    +
    +
    +
    Navigating to a path in Finder (macOS)
    +
    + In the file picker dialog, press ⌘ + Shift + G to open "Go to the folder", then paste the extension folder path and press Enter. +
    +
    +
    +
    +
    + +
    +
    +
    Pin the Extension
    +
    + Click the puzzle piece icon in Chrome's toolbar and pin Lime Browser Bridge for quick access. +
    +
    +
    +
    Updating the Extension
    +
    + When Lime is updated, go to chrome://extensions, remove the old version, then repeat the steps above. Or click the reload icon (↻) on the extension card to quick-reload. +
    +
    +
    +
    About the debugging banner
    +
    + When the extension attaches to a tab, Chrome shows a yellow banner: "Lime Browser Bridge started debugging this browser". This is a standard Chrome security notice. Do not click "Cancel" unless you want to disconnect that tab. +
    +
    +
    +
    +
    +
  6. + +
+ + + + +
+ +
+ +
+
Troubleshooting
+
+ Badge shows … — Lime is not running or browser control is not enabled. +

+ Can't control a page — Internal pages (chrome://) cannot be debugged. Use a regular webpage. +

+ Extension disappeared — Unpacked extensions may get disabled after Chrome restart. Re-enable at chrome://extensions. +
+
+ +
+
+ +
+
+ +
+ +
+ + + + + diff --git a/extensions/lime-chrome/pages/options.html b/extensions/lime-chrome/pages/options.html new file mode 100644 index 000000000..fac41f845 --- /dev/null +++ b/extensions/lime-chrome/pages/options.html @@ -0,0 +1,167 @@ + + + + + + Lime Browser Bridge + + + +
+ +
+ +
+

Lime Browser Bridge

+

Connect your browser to Lime.

+
+
+ + +
+
+
+ + Checking… + + +
+
+
+
+ + + + + +
+ + + Quick Start + +
+
+
    +
  1. Make sure Lime is running with browser control enabled.
  2. +
  3. The relay connects automatically — a green dot on the icon means ready.
  4. +
  5. Ask Lime a browser-related question — it can now see and control your tabs.
  6. +
+
+
+
+ + +
+ + + How It Works + +
+
+
Always-on relay
+
The relay is enabled by default and connects automatically when Lime is running. Use the toggle above to pause if needed. Agent-opened tabs are grouped under an "Lime Agent" tab group.
+
+
+
Auto-reconnect
+
If the connection drops, the extension reconnects automatically. No manual intervention needed.
+
+
+
Tab safety
+
Your existing tabs are protected — the agent can only close tabs it opened. Personal tabs are never affected.
+
+
+
+ + +
+ + + Status Indicators + +
+
+
+ + Connected — agent can control your tabs + + Connecting / reconnecting to local relay server + + Relay disabled — no indicator +
+
+
+
+ + +
+ + + Troubleshooting + +
+
+
Stays on yellow dot (connecting)
+
Relay server not reachable. Ensure Lime is running with browser control enabled.
+
+
+
Pages not responding
+
Internal Chrome pages (chrome://) cannot be controlled. Navigate to a regular webpage.
+
+
+
+ + +
+ + + Settings + +
+
+
Tab Group Behavior
+
+ Close "Lime Agent" tab group when relay is disabled + +
+
+
+
Control Port
+
Only change if you use a custom port in Lime config.
+
+ + +
+
Default: 9234. Relay = control port + 2.
+
+
+
+
+ CDP Logs + +
+
+
No log entries yet
+
+
+
+
+ +
+ + + + diff --git a/extensions/lime-chrome/pages/popup.html b/extensions/lime-chrome/pages/popup.html new file mode 100644 index 000000000..b2882d2a1 --- /dev/null +++ b/extensions/lime-chrome/pages/popup.html @@ -0,0 +1,438 @@ + + + + + + Lime Browser Bridge + + + + + + + + diff --git a/extensions/lime-chrome/pages/scripts/install-helpers.js b/extensions/lime-chrome/pages/scripts/install-helpers.js new file mode 100644 index 000000000..1c322b51a --- /dev/null +++ b/extensions/lime-chrome/pages/scripts/install-helpers.js @@ -0,0 +1,146 @@ +/** + * Shared helper functions for install guide pages. + */ + +function setupExtrasToggle(toggleId, contentId) { + var toggle = document.getElementById(toggleId); + var content = document.getElementById(contentId); + if (!toggle || !content) return; + toggle.addEventListener('click', function () { + var expanded = toggle.getAttribute('aria-expanded') === 'true'; + toggle.setAttribute('aria-expanded', String(!expanded)); + content.classList.toggle('open', !expanded); + }); +} + +function setupAllStepExtras() { + var toggles = document.querySelectorAll('.step-extras-toggle'); + toggles.forEach(function (toggle) { + var contentId = toggle.getAttribute('data-target'); + if (!contentId) return; + var content = document.getElementById(contentId); + if (!content) return; + toggle.addEventListener('click', function () { + var expanded = toggle.getAttribute('aria-expanded') === 'true'; + toggle.setAttribute('aria-expanded', String(!expanded)); + content.classList.toggle('open', !expanded); + }); + }); +} + +function openChromeUrl(url) { + if (window.installGuide && window.installGuide.openChromeUrl) { + window.installGuide.openChromeUrl(url); + var btn = event && event.currentTarget; + if (btn && btn.classList) { + var origText = btn.querySelector('[data-i18n]'); + btn.classList.add('clicked'); + if (origText) { + var prev = origText.textContent; + origText.textContent = '✓'; + setTimeout(function () { + origText.textContent = prev; + btn.classList.remove('clicked'); + }, 1500); + } else { + setTimeout(function () { btn.classList.remove('clicked'); }, 1500); + } + } + } else { + navigator.clipboard.writeText(url).then(function () { + var btn = event && event.currentTarget; + if (btn) { + var origText = btn.querySelector('[data-i18n]'); + if (origText) { + var prev = origText.textContent; + origText.textContent = InstallI18n.t('click_to_copy') === '点击复制' ? '已复制!请粘贴到浏览器' : 'Copied! Paste in browser'; + setTimeout(function () { origText.textContent = prev; }, 2000); + } + } + }); + } +} + +function setupCopyBlock(blockId, textToCopy) { + var block = document.getElementById(blockId); + if (!block) return; + block.addEventListener('click', function () { + var label = block.querySelector('.copy-label'); + navigator.clipboard.writeText(textToCopy).then(function () { + if (label) { + var origText = label.textContent; + label.textContent = '✓'; + setTimeout(function () { label.textContent = origText; }, 1500); + } + }); + }); +} + +function setupExtensionPath() { + var params = new URLSearchParams(window.location.search); + var extensionPath = params.get('extensionPath'); + if (!extensionPath) return; + + var el = document.getElementById('extension-path'); + if (el) { + var sep = extensionPath.indexOf('/') >= 0 ? '/' : '\\'; + var parts = extensionPath.split(sep); + var folderName = parts[parts.length - 1] || parts[parts.length - 2] || extensionPath; + var parentParts = parts.slice(0, -1); + var shortPrefix = parentParts.length > 2 + ? '…' + sep + parentParts.slice(-2).join(sep) + : parentParts.join(sep); + var shortPath = shortPrefix + sep; + + el.innerHTML = '' + shortPath + '' + folderName + '' + + '' + extensionPath.replace(new RegExp(folderName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), '' + folderName + '') + ''; + el.title = extensionPath; + } + + var copyBtn = document.getElementById('copy-path-btn'); + if (copyBtn) copyBtn.style.display = 'inline-flex'; + + var openBtn = document.getElementById('open-folder-btn'); + if (openBtn) openBtn.style.display = 'inline-flex'; +} + +function openExtensionFolder() { + var params = new URLSearchParams(window.location.search); + var parentPath = params.get('extensionParentPath') || params.get('extensionPath'); + if (!parentPath) return; + var normalized = parentPath.replace(/\\/g, '/'); + if (/^\/\//.test(normalized)) { + // UNC path: \\server\share → file://server/share (RFC 8089) + window.open('file:' + normalized); + } else if (/^[A-Za-z]:/.test(normalized)) { + // Windows drive: C:/path → file:///C:/path + window.open('file:///' + normalized); + } else { + // Unix or already normalized + window.open('file://' + normalized); + } +} + +function navigateToInstallPage(page) { + var params = new URLSearchParams(window.location.search); + window.location.href = page + '?' + params.toString(); +} + +var _copyBtnOrigHTML = ''; +var _copyResetTimer = null; +function copyExtensionPath() { + var params = new URLSearchParams(window.location.search); + var extensionPath = params.get('extensionPath'); + if (!extensionPath) return; + var btn = document.getElementById('copy-path-btn'); + if (btn && !_copyBtnOrigHTML) _copyBtnOrigHTML = btn.innerHTML; + navigator.clipboard.writeText(extensionPath).then(function () { + if (!btn) return; + btn.innerHTML = ' ✓'; + if (_copyResetTimer) clearTimeout(_copyResetTimer); + _copyResetTimer = setTimeout(function () { + btn.innerHTML = _copyBtnOrigHTML; + _copyResetTimer = null; + }, 2000); + }); +} diff --git a/extensions/lime-chrome/pages/scripts/install-i18n.js b/extensions/lime-chrome/pages/scripts/install-i18n.js new file mode 100644 index 000000000..b472f6a3f --- /dev/null +++ b/extensions/lime-chrome/pages/scripts/install-i18n.js @@ -0,0 +1,52 @@ +/** + * Shared i18n utilities for install guide pages. + * + * Detects language from ?lang= query parameter or navigator.language. + * Each page registers its own translation strings, then calls applyI18n(). + */ + +var InstallI18n = (function () { + var _strings = {}; + + var SUPPORTED = ['zh', 'en', 'de', 'es', 'fr', 'pt']; + + function detectLang() { + var params = new URLSearchParams(window.location.search); + var lang = params.get('lang') || params.get('language'); + if (lang && SUPPORTED.indexOf(lang) !== -1) return lang; + var nav = (navigator.language || '').toLowerCase(); + for (var i = 0; i < SUPPORTED.length; i++) { + if (nav.startsWith(SUPPORTED[i])) return SUPPORTED[i]; + } + return 'en'; + } + + function register(lang, translations) { + _strings[lang] = Object.assign(_strings[lang] || {}, translations); + } + + function apply(lang) { + lang = lang || detectLang(); + var strings = _strings[lang] || _strings['en'] || {}; + document.querySelectorAll('[data-i18n]').forEach(function (el) { + var key = el.getAttribute('data-i18n'); + if (strings[key]) el.innerHTML = strings[key]; + }); + document.documentElement.lang = lang; + if (strings.title) document.title = strings.title; + return lang; + } + + function t(key, lang) { + lang = lang || detectLang(); + var strings = _strings[lang] || _strings['en'] || {}; + return strings[key] || key; + } + + return { + detectLang: detectLang, + register: register, + apply: apply, + t: t, + }; +})(); diff --git a/extensions/lime-chrome/pages/scripts/options.js b/extensions/lime-chrome/pages/scripts/options.js new file mode 100644 index 000000000..a3e6f2dd8 --- /dev/null +++ b/extensions/lime-chrome/pages/scripts/options.js @@ -0,0 +1,297 @@ +import { RelayState, STATE_TEXT, RELAY_PORT_OFFSET, clampPort, computeRelayPort, SETTINGS_KEYS, getSetting, setSetting } from '../../lib/constants.js' + +// ── DOM refs ── + +const statusCard = document.getElementById('status-card') +const statusLabel = document.getElementById('status-label') +const statusMeta = document.getElementById('status-meta') +const statusDetail = document.getElementById('status-detail') +const tabStats = document.getElementById('tab-stats') +const relayToggle = document.getElementById('relay-toggle') +const portInput = document.getElementById('port') +const portStatus = document.getElementById('port-status') + +// ── Status rendering ── + +let _lastStatusJson = '' + +function renderStatus(status) { + const key = JSON.stringify(status ?? null) + if (key === _lastStatusJson) return + _lastStatusJson = key + const state = status?.state || RelayState.DISABLED + const cfg = STATE_TEXT[state] || STATE_TEXT[RelayState.DISABLED] + + statusCard.dataset.state = state + statusLabel.textContent = cfg.label + statusDetail.textContent = cfg.detail + + if (!toggleBusy) { + relayToggle.checked = state !== RelayState.DISABLED + } + + const metaParts = [] + if (typeof status?.observerConnected === 'boolean') { + metaParts.push(`observer ${status.observerConnected ? 'on' : 'off'}`) + } + if (typeof status?.controlConnected === 'boolean') { + metaParts.push(`control ${status.controlConnected ? 'on' : 'off'}`) + } + if (Array.isArray(status?.debuggerTabs)) { + metaParts.push(`debugger ${status.debuggerTabs.length}`) + } + statusMeta.textContent = metaParts.join(' · ') + + tabStats.textContent = '' + if (status?.attachedTabs > 0) { + const chip = document.createElement('span') + chip.className = 'tab-chip tab-chip-tabs' + chip.textContent = `${status.attachedTabs} ${status.attachedTabs === 1 ? 'tab' : 'tabs'}` + tabStats.appendChild(chip) + } + if (status?.agentTabs > 0) { + const nonRetained = status.agentTabs - (status.retainedTabs || 0) + if (nonRetained > 0) { + const chip = document.createElement('span') + chip.className = 'tab-chip tab-chip-agent' + chip.textContent = `${nonRetained} agent` + tabStats.appendChild(chip) + } + if (status.retainedTabs > 0) { + const chip = document.createElement('span') + chip.className = 'tab-chip tab-chip-retained' + chip.textContent = `${status.retainedTabs} retained` + tabStats.appendChild(chip) + } + } + if (status?.lastError) { + const chip = document.createElement('span') + chip.className = 'tab-chip tab-chip-agent' + chip.textContent = String(status.lastError) + tabStats.appendChild(chip) + } + if (status?.controlLastError) { + const chip = document.createElement('span') + chip.className = 'tab-chip tab-chip-retained' + chip.textContent = String(status.controlLastError) + tabStats.appendChild(chip) + } +} + +async function queryBackgroundStatus() { + try { + const status = await chrome.runtime.sendMessage({ type: 'getRelayStatus' }) + if (!status) { + renderStatus(null) + return + } + renderStatus(status) + } catch { + renderStatus(null) + } +} + +// ── Toggle handler ── + +let toggleBusy = false + +relayToggle.addEventListener('change', async () => { + if (toggleBusy) return + toggleBusy = true + try { + await chrome.runtime.sendMessage({ type: 'toggleRelay' }) + await queryBackgroundStatus() + } catch (err) { + console.warn('[lime-options] toggle failed:', err) + } finally { + toggleBusy = false + } +}) + +// ── Port settings ── + +function setPortStatus(kind, message) { + portStatus.dataset.kind = kind || '' + portStatus.textContent = message || '' +} + +async function checkPort(controlPort) { + const port = computeRelayPort(controlPort) + try { + const res = await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD', signal: AbortSignal.timeout(900) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setPortStatus('ok', `Relay reachable at :${port}`) + } catch { + setPortStatus('error', `Relay not reachable at :${port}`) + } +} + +async function loadPort() { + const stored = await chrome.storage.local.get(['controlPort', 'relayPort']) + const fromRelay = + stored.relayPort != null + ? Number.parseInt(String(stored.relayPort), 10) - RELAY_PORT_OFFSET + : undefined + const port = clampPort(Number.isFinite(fromRelay) ? fromRelay : stored.controlPort) + portInput.value = String(port) +} + +async function savePort() { + const port = clampPort(portInput.value) + await chrome.storage.local.set({ controlPort: port }) + portInput.value = String(port) + await checkPort(port) +} + +document.getElementById('save').addEventListener('click', () => void savePort()) + +// ── Tab list ── + +const tabsSection = document.getElementById('tabs-section') +const tabsList = document.getElementById('tabs-list') + +async function refreshTabList() { + try { + const resp = await chrome.runtime.sendMessage({ type: 'getTabList' }) + const tabs = resp?.tabs || [] + tabs.sort((a, b) => { + const rank = (t) => t.isRetained ? 0 : t.isAgent ? 1 : 2 + return rank(a) - rank(b) + }) + tabsSection.style.display = tabs.length > 0 ? '' : 'none' + tabsList.innerHTML = '' + for (const t of tabs) { + const row = document.createElement('div') + row.className = 'tab-entry' + row.style.cursor = 'pointer' + row.addEventListener('click', () => { + chrome.tabs.update(t.tabId, { active: true }) + chrome.tabs.get(t.tabId, (tab) => { + if (tab?.windowId) chrome.windows.update(tab.windowId, { focused: true }) + }) + }) + + const dot = document.createElement('span') + dot.className = 'tab-entry-state' + dot.dataset.state = t.state + dot.title = t.state + + const title = document.createElement('span') + title.className = 'tab-entry-title' + title.textContent = t.title || `Tab ${t.tabId}` + + const url = document.createElement('span') + url.className = 'tab-entry-url' + url.textContent = t.url || '' + + row.append(dot, title, url) + + if (t.isRetained) { + const badge = document.createElement('span') + badge.className = 'tab-entry-badge' + badge.dataset.type = 'retained' + badge.textContent = 'retained' + row.appendChild(badge) + } else if (t.isAgent) { + const badge = document.createElement('span') + badge.className = 'tab-entry-badge' + badge.dataset.type = 'agent' + badge.textContent = 'agent' + row.appendChild(badge) + } + + tabsList.appendChild(row) + } + } catch { /* background not ready */ } +} + +// ── Logs viewer ── + +const logsContainer = document.getElementById('logs-container') +const logsRefresh = document.getElementById('logs-refresh') + +function formatTime(ts) { + const d = new Date(ts) + return d.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +async function refreshLogs() { + try { + const resp = await chrome.runtime.sendMessage({ type: 'getLogs', limit: 100 }) + const logs = resp?.logs || [] + if (logs.length === 0) { + logsContainer.innerHTML = '
No log entries yet
' + return + } + logsContainer.innerHTML = '' + for (const entry of logs) { + const row = document.createElement('div') + row.className = 'log-row' + + const time = document.createElement('span') + time.className = 'log-time' + time.textContent = formatTime(entry.ts) + + const dir = document.createElement('span') + dir.className = 'log-dir' + dir.dataset.dir = entry.dir + dir.textContent = entry.dir + + const method = document.createElement('span') + method.className = 'log-method' + method.textContent = entry.method || '' + + const detail = document.createElement('span') + detail.className = 'log-detail' + detail.textContent = entry.detail || '' + + row.append(time, dir, method, detail) + logsContainer.appendChild(row) + } + logsContainer.scrollTop = logsContainer.scrollHeight + } catch { /* background not ready */ } +} + +logsRefresh.addEventListener('click', () => void refreshLogs()) + +// ── Event-driven status updates ── + +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && ('relayEnabled' in changes || 'controlPort' in changes || '_relayState' in changes)) { + void queryBackgroundStatus() + } +}) + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + void queryBackgroundStatus() + void refreshTabList() + } +}) + +// Fallback poll for state transitions not reflected in storage (connect/disconnect) +const FALLBACK_POLL_MS = 2000 +setInterval(() => { + void queryBackgroundStatus() + void refreshTabList() +}, FALLBACK_POLL_MS) + +// ── Close group on disable setting ── + +const closeGroupToggle = document.getElementById('closeGroupOnDisable') + +async function loadCloseGroupSetting() { + closeGroupToggle.checked = await getSetting(SETTINGS_KEYS.CLOSE_GROUP_ON_DISABLE, false) +} + +closeGroupToggle.addEventListener('change', () => { + void setSetting(SETTINGS_KEYS.CLOSE_GROUP_ON_DISABLE, closeGroupToggle.checked) +}) + +// ── Init ── + +void loadPort() +void loadCloseGroupSetting() +void queryBackgroundStatus() +void refreshTabList() +void refreshLogs() diff --git a/extensions/lime-chrome/pages/scripts/popup.js b/extensions/lime-chrome/pages/scripts/popup.js new file mode 100644 index 000000000..65b878f19 --- /dev/null +++ b/extensions/lime-chrome/pages/scripts/popup.js @@ -0,0 +1,141 @@ +import { RelayState } from '../../lib/constants.js' + +const statusCard = document.getElementById('status-card') +const label = document.getElementById('label') +const toggle = document.getElementById('toggle') +const meta = document.getElementById('meta') +const tabsSection = document.getElementById('tabs-section') +const tabsHeader = document.getElementById('tabs-header') +const tabsList = document.getElementById('tabs-list') + +const STATE_LABELS = { + [RelayState.DISABLED]: 'Offline', + [RelayState.DISCONNECTED]: 'Connecting…', + [RelayState.CONNECTED]: 'Connected', +} + +const ARROW_SVG = + '' + +function escapeHtml(str) { + const div = document.createElement('div') + div.appendChild(document.createTextNode(str)) + return div.innerHTML +} + +function render(status) { + const state = status?.state || RelayState.DISABLED + statusCard.dataset.state = state + label.textContent = STATE_LABELS[state] || 'Unknown' + + if (!toggleBusy) { + toggle.checked = state !== RelayState.DISABLED + } + + meta.innerHTML = '' + if (status?.attachedTabs > 0) { + addChip(`${status.attachedTabs}`, status.attachedTabs === 1 ? 'tab' : 'tabs', 'tabs') + } + if (status?.agentTabs > 0) { + const nonRetained = status.agentTabs - (status.retainedTabs || 0) + if (nonRetained > 0) addChip(`${nonRetained}`, 'agent', 'agent') + if (status.retainedTabs > 0) addChip(`${status.retainedTabs}`, 'retained', 'retained') + } +} + +function addChip(num, text, kind) { + const chip = document.createElement('span') + chip.className = `chip chip-${kind}` + chip.innerHTML = `${num} ${text}` + meta.appendChild(chip) +} + +function tabSortKey(t) { + if (t.isRetained) return 0 + if (t.isAgent) return 1 + return 2 +} + +function renderTabs(tabs) { + if (tabs) tabs.sort((a, b) => tabSortKey(a) - tabSortKey(b)) + const hasTabs = tabs && tabs.length > 0 + tabsSection.dataset.empty = String(!hasTabs) + + if (!hasTabs) { + tabsList.innerHTML = '' + return + } + + tabsList.innerHTML = '' + tabs.forEach((tab) => { + const el = document.createElement('div') + el.className = 'tab-item' + el.addEventListener('click', () => { + chrome.tabs.update(tab.tabId, { active: true }) + if (tab.windowId) chrome.windows.update(tab.windowId, { focused: true }) + window.close() + }) + + const displayTitle = tab.title || tab.url || `Tab ${tab.tabId}` + const safeTitle = escapeHtml(displayTitle) + const safeState = escapeHtml(tab.state || 'virtual') + let badge = '' + if (tab.isAgent && !tab.isRetained) { + badge = 'agent' + } else if (tab.isRetained) { + badge = 'retained' + } + + el.innerHTML = + `` + + `${safeTitle}` + + badge + + ARROW_SVG + tabsList.appendChild(el) + }) +} + +tabsHeader.addEventListener('click', () => { + const isOpen = tabsSection.dataset.open === 'true' + tabsSection.dataset.open = String(!isOpen) +}) + +async function refresh() { + try { + const [status, tabResp] = await Promise.all([ + chrome.runtime.sendMessage({ type: 'getRelayStatus' }), + chrome.runtime.sendMessage({ type: 'getTabList' }), + ]) + render(status) + renderTabs(tabResp?.tabs) + } catch { + render(null) + renderTabs([]) + } +} + +let toggleBusy = false +toggle.addEventListener('change', async () => { + if (toggleBusy) return + toggleBusy = true + try { + await chrome.runtime.sendMessage({ type: 'toggleRelay' }) + await refresh() + } finally { + toggleBusy = false + } +}) + +document.getElementById('open-settings').addEventListener('click', () => { + chrome.runtime.openOptionsPage() + window.close() +}) + +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && ('relayEnabled' in changes || '_relayState' in changes)) { + void refresh() + } +}) + +void refresh() +setInterval(() => void refresh(), 1000) diff --git a/extensions/lime-chrome/pages/styles/install-common.css b/extensions/lime-chrome/pages/styles/install-common.css new file mode 100644 index 000000000..882f9af3a --- /dev/null +++ b/extensions/lime-chrome/pages/styles/install-common.css @@ -0,0 +1,612 @@ +:root { + color-scheme: light dark; + --accent: #14b8a6; + --accent-soft: color-mix(in oklab, var(--accent) 10%, transparent); + --accent-border: color-mix(in oklab, var(--accent) 25%, transparent); + --green: #22C55E; + --surface: color-mix(in oklab, canvas 95%, canvasText 5%); + --border: color-mix(in oklab, canvasText 8%, transparent); + --muted: color-mix(in oklab, canvasText 50%, transparent); + --dim: color-mix(in oklab, canvasText 25%, transparent); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + line-height: 1.6; + color: canvasText; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +body { min-height: 100vh; background: canvas; } + +.drag-handle { + -webkit-app-region: drag; + height: 38px; + width: 100%; + position: fixed; + top: 0; + left: 0; + z-index: 100; +} + +.page { + max-width: 860px; + margin: 0 auto; + padding: 56px 40px 80px; +} + +.header { + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 12px; +} + +.header-icon { + width: 56px; + height: 56px; + border-radius: 16px; + background: var(--accent-soft); + border: 1px solid var(--accent-border); + display: grid; + place-items: center; + flex-shrink: 0; +} + +.header-icon img { width: 32px; height: 32px; } + +h1 { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.03em; + display: flex; + align-items: center; + gap: 10px; +} + +.title-tag { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 6px; + background: var(--accent-soft); + border: 1px solid var(--accent-border); + color: var(--accent); + white-space: nowrap; +} + +.subtitle { + font-size: 14px; + color: var(--muted); + margin-bottom: 44px; +} + +/* ── Chrome Web Store section ── */ + +.webstore-section { + margin-bottom: 32px; + animation: step-enter 0.4s ease-out 0.05s both; +} + +.webstore-card { + padding: 20px 24px; + border-radius: 14px; + background: var(--accent-soft); + border: 1px solid var(--accent-border); +} + +.webstore-badge { + display: flex; + align-items: center; + gap: 8px; + color: var(--accent); + font-size: 14px; + font-weight: 600; + margin-bottom: 6px; +} + +.webstore-badge svg { flex-shrink: 0; } + +.webstore-tag { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 8px; + border-radius: 6px; + background: var(--accent); + color: #fff; +} + +.webstore-desc { + font-size: 13px; + color: var(--muted); + margin-bottom: 14px; + line-height: 1.5; +} + +.webstore-btn { + text-decoration: none; +} + +/* ── Manual install divider ── */ + +.manual-section { + margin-bottom: 28px; +} + +.manual-divider { + display: flex; + align-items: center; + gap: 16px; +} + +.manual-divider-line { + flex: 1; + height: 1px; + background: var(--border); +} + +.manual-divider-text { + font-size: 12px; + font-weight: 500; + color: var(--dim); + white-space: nowrap; +} + +/* ── Steps ── */ + +.step-list { + list-style: none; + counter-reset: step; +} + +.step { + position: relative; + padding-left: 64px; + padding-bottom: 40px; + counter-increment: step; +} + +.step:last-child { padding-bottom: 0; } + +.step::before { + content: counter(step); + position: absolute; + left: 0; + top: 0; + width: 42px; + height: 42px; + border-radius: 12px; + background: var(--accent-soft); + color: var(--accent); + font-size: 17px; + font-weight: 700; + display: grid; + place-items: center; + border: 1px solid var(--accent-border); +} + +.step::after { + content: ''; + position: absolute; + left: 20px; + top: 46px; + bottom: 0; + width: 2px; + background: var(--border); +} + +.step:last-child::after { display: none; } + +.step-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + padding-top: 8px; +} + +.step-body { + font-size: 14px; + color: var(--muted); + line-height: 1.7; +} + +.step-body code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.88em; + background: var(--surface); + border: 1px solid var(--border); + padding: 1px 6px; + border-radius: 5px; +} + +.step-body a { + color: var(--accent); + text-decoration: none; +} + +.step-body a:hover { text-decoration: underline; } + +.step-note { + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--accent); + line-height: 1.5; + padding: 10px 14px; + border-radius: 10px; + background: var(--accent-soft); + border: 1px solid var(--accent-border); +} + +.step-note strong { font-weight: 700; } + +.url-block { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 14px; + margin: 10px 0; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 13px; + cursor: pointer; + transition: border-color 0.2s; + user-select: none; +} + +.url-block:hover { border-color: var(--accent-border); } + +.url-block .copy-icon { + width: 14px; + height: 14px; + color: var(--muted); + flex-shrink: 0; +} + +.path-display { + display: block; + margin: 10px 0 4px; + padding: 10px 14px; + border-radius: 8px; + background: color-mix(in oklab, canvas 80%, canvasText 5%); + border: 1px solid var(--border); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; + word-break: break-all; + line-height: 1.5; + color: canvasText; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 7px 16px; + border-radius: 8px; + border: 1px solid var(--accent-border); + background: var(--accent-soft); + color: var(--accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.action-btn:hover { + background: color-mix(in oklab, var(--accent) 15%, transparent); + border-color: var(--accent); +} + +.action-btn:active { transform: scale(0.97); } + +.open-btn { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 10px 0 4px; + padding: 10px 22px; + border-radius: 10px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + letter-spacing: 0.01em; +} + +.open-btn:hover { + background: color-mix(in oklab, var(--accent) 85%, black); + border-color: color-mix(in oklab, var(--accent) 85%, black); +} + +.open-btn:active { transform: scale(0.97); } + +.open-btn svg { flex-shrink: 0; } + +.url-block, .action-btn, .open-btn, a, button { + -webkit-app-region: no-drag; +} + +/* ── Per-step collapsible ── */ + +.step-extras { + margin-top: 12px; +} + +.step-extras-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--dim); + cursor: pointer; + border: none; + background: none; + padding: 4px 0; + font-family: inherit; + font-weight: 500; + -webkit-app-region: no-drag; +} + +.step-extras-toggle svg { + width: 8px; + height: 8px; + transition: transform 0.2s; +} + +.step-extras-toggle[aria-expanded="true"] svg { + transform: rotate(90deg); +} + +.step-extras-content { + display: none; + padding-top: 8px; +} + +.step-extras-content.open { + display: block; +} + +/* ── Collapsible extras ── */ + +.extras { + margin-top: 48px; + border-top: 1px solid var(--border); + padding-top: 24px; +} + +.extras-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted); + cursor: pointer; + border: none; + background: none; + padding: 6px 0; + font-family: inherit; + font-weight: 500; + letter-spacing: 0.01em; + -webkit-app-region: no-drag; +} + +.extras-toggle svg { + width: 10px; + height: 10px; + transition: transform 0.2s; +} + +.extras-toggle[aria-expanded="true"] svg { + transform: rotate(90deg); +} + +.extras-content { + display: none; + padding-top: 16px; +} + +.extras-content.open { + display: block; +} + +.note { + margin-top: 16px; + padding: 16px 20px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); +} + +.note:first-child { margin-top: 0; } + +.note-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dim); + margin-bottom: 8px; +} + +.note-body { + font-size: 13px; + color: var(--muted); + line-height: 1.7; +} + +.note-body strong { color: canvasText; font-weight: 600; } + +/* ── Extension name badge ── */ + +.ext-name { + display: inline-flex; + align-items: center; + gap: 5px; + background: var(--accent-soft); + border: 1px solid var(--accent-border); + color: var(--accent); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.85em; + font-weight: 600; + padding: 1px 8px; + border-radius: 6px; + white-space: nowrap; + vertical-align: baseline; +} + +.ext-name svg { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.7; +} + +/* ── Path display with truncation ── */ + +.path-display { + position: relative; +} + +.path-short { + display: inline; +} + +.path-full { + display: none; +} + +.path-display:hover .path-short { + display: none; +} + +.path-display:hover .path-full { + display: inline; +} + +.path-display .path-highlight { + color: var(--accent); + font-weight: 600; +} + +.badge-demo { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 16px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + color: #fff; + padding: 0 4px; + vertical-align: middle; +} + +/* ── Alternative method link ── */ + +.alt-method { + margin-top: 36px; + animation: step-enter 0.4s ease-out 0.6s both; +} + +.alt-method-divider { + height: 1px; + background: var(--border); + margin-bottom: 20px; +} + +.alt-method-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 14px 18px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; + text-align: left; + -webkit-app-region: no-drag; +} + +.alt-method-btn:hover { + border-color: var(--accent-border); + color: var(--accent); + background: var(--accent-soft); +} + +.alt-method-btn:active { transform: scale(0.99); } + +.alt-method-btn svg:first-child { + flex-shrink: 0; + opacity: 0.6; +} + +.alt-method-btn:hover svg:first-child { opacity: 1; } + +.alt-method-btn svg:last-child { + margin-left: auto; + flex-shrink: 0; +} + +/* ── Step entrance animation ── */ + +@keyframes step-enter { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.step { + animation: step-enter 0.4s ease-out both; +} + +.step:nth-child(1) { animation-delay: 0.1s; } +.step:nth-child(2) { animation-delay: 0.25s; } +.step:nth-child(3) { animation-delay: 0.4s; } + +.extras { + animation: step-enter 0.4s ease-out 0.55s both; +} + +/* ── Open button click feedback ── */ + +.open-btn.clicked { + background: color-mix(in oklab, var(--green) 90%, black); + border-color: color-mix(in oklab, var(--green) 90%, black); +} + +/* ── Success card ── */ + +.success-card { + margin-top: 24px; + padding: 16px 20px; + border-radius: 12px; + background: color-mix(in oklab, var(--green) 8%, transparent); + border: 1px solid color-mix(in oklab, var(--green) 25%, transparent); + display: flex; + align-items: center; + gap: 10px; +} + +.success-card svg { + flex-shrink: 0; + color: var(--green); +} + +.success-card span { + font-size: 14px; + font-weight: 600; + color: var(--green); +} diff --git a/extensions/lime-chrome/pages/styles/options.css b/extensions/lime-chrome/pages/styles/options.css new file mode 100644 index 000000000..152582848 --- /dev/null +++ b/extensions/lime-chrome/pages/styles/options.css @@ -0,0 +1,754 @@ +:root { + --bg: #ffffff; + --surface: #f4f4f5; + --surface-hover: #e4e4e7; + --border: #e4e4e7; + --text: #18181b; + --muted: #71717a; + --dim: #a1a1aa; + --accent: #14b8a6; + --accent-soft: rgba(20, 184, 166, 0.08); + --accent-border: rgba(20, 184, 166, 0.2); + --accent-glow: rgba(20, 184, 166, 0.15); + --green: #34d399; + --green-soft: rgba(52, 211, 153, 0.08); + --green-glow: rgba(52, 211, 153, 0.35); + --yellow: #f59e0b; + --yellow-soft: rgba(245, 158, 11, 0.08); + --yellow-glow: rgba(245, 158, 11, 0.12); + --red: #ef4444; + --coral: #f43f5e; + + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + line-height: 1.55; + color: var(--text); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + min-height: 100vh; + background: var(--bg); +} + +.page { + max-width: 640px; + margin: 0 auto; + padding: 52px 24px 72px; +} + +/* ── Entrance animation ── */ + +@keyframes entrance { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.anim-entrance { + animation: entrance 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: calc(var(--delay, 0) * 60ms); +} + +/* ── Header ── */ + +.header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 40px; +} + +.header-icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: grid; + place-items: center; + flex-shrink: 0; + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.header-icon:hover { + transform: scale(1.06) rotate(-3deg); +} + +.header-icon img { + width: 36px; + height: 36px; +} + +.header-text h1 { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.025em; +} + +.header-text p { + font-size: 13px; + color: var(--muted); + margin-top: 1px; +} + +/* ── Status card ── */ + +.status-card { + position: relative; + padding: 20px 22px; + border-radius: 16px; + background: var(--surface); + border: 1px solid var(--border); + margin-bottom: 24px; + overflow: hidden; + transition: border-color 0.4s, box-shadow 0.4s; +} + +.status-card[data-state="connected"] { + border-color: rgba(52, 211, 153, 0.25); + box-shadow: 0 2px 16px -4px var(--green-glow); +} + +.status-card[data-state="disconnected"] { + border-color: rgba(245, 158, 11, 0.3); + box-shadow: 0 2px 16px -6px var(--yellow-glow); +} + +.status-card[data-state="disabled"] { + border-color: var(--border); +} + +.status-glow { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.5s; + pointer-events: none; +} + +.status-card[data-state="connected"] .status-glow { + opacity: 1; + background: linear-gradient(135deg, var(--green-soft), transparent 60%); +} + +.status-card[data-state="disconnected"] .status-glow { + opacity: 1; + background: linear-gradient(135deg, var(--yellow-soft), transparent 60%); +} + +.status-row { + display: flex; + align-items: center; + gap: 12px; + position: relative; +} + +.status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + background: var(--dim); + transition: background 0.3s, box-shadow 0.3s; + position: relative; +} + +.status-card[data-state="connected"] .status-indicator { + background: var(--green); + box-shadow: 0 0 10px var(--green-glow); +} + +.status-card[data-state="connected"] .status-indicator::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1.5px solid rgba(52, 211, 153, 0.3); + animation: indicator-ring 2.5s ease-out infinite; +} + +@keyframes indicator-ring { + 0% { transform: scale(1); opacity: 0.7; } + 50% { transform: scale(1.5); opacity: 0; } + 100% { transform: scale(1.5); opacity: 0; } +} + +.status-card[data-state="disconnected"] .status-indicator { + background: var(--yellow); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--yellow-glow); } + 50% { opacity: 0.4; box-shadow: none; } +} + +.status-label { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.status-meta { + font-size: 12px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +/* ── Toggle switch ── */ + +.toggle { + margin-left: auto; + display: inline-flex; + cursor: pointer; + flex-shrink: 0; +} + +.toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.toggle-track { + position: relative; + width: 40px; + height: 22px; + border-radius: 11px; + background: var(--dim); + transition: background 0.25s, box-shadow 0.3s; +} + +.toggle-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.15); + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s; +} + +.toggle input:checked + .toggle-track { + background: var(--accent); + box-shadow: 0 0 12px var(--accent-glow); +} + +.toggle input:checked + .toggle-track::after { + transform: translateX(18px); + box-shadow: 0 0 6px var(--accent-glow); +} + +.status-detail { + margin-top: 8px; + font-size: 12px; + color: var(--muted); + padding-left: 24px; + position: relative; +} + +.tab-stats { + display: flex; + gap: 6px; + margin-top: 10px; + padding-left: 24px; + flex-wrap: wrap; + position: relative; +} + +.tab-stats:empty { + display: none; +} + +.tab-chip { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; + transition: transform 0.15s; +} + +.tab-chip:hover { + transform: translateY(-1px); +} + +.tab-chip-tabs { + background: rgba(100, 116, 139, 0.08); + color: #64748b; +} + +.tab-chip-agent { + background: rgba(249, 115, 22, 0.08); + color: #f97316; +} + +.tab-chip-retained { + background: rgba(168, 85, 247, 0.08); + color: #a855f7; +} + +/* ── Collapsible sections ── */ + +.collapsible { + margin-bottom: 6px; +} + +.collapsible-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 4px; + cursor: pointer; + list-style: none; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dim); + user-select: none; + border-radius: 8px; + transition: color 0.2s, background 0.15s; +} + +.collapsible-trigger:hover { + color: var(--muted); + background: rgba(0, 0, 0, 0.03); +} + +.collapsible-trigger::-webkit-details-marker { display: none; } + +.collapsible-chevron { + width: 14px; + height: 14px; + flex-shrink: 0; + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + stroke-width: 2.2; +} + +.collapsible[open] .collapsible-chevron { + transform: rotate(90deg); +} + +.collapsible-body { + padding: 4px 0 12px 0; + animation: slideDown 0.25s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── Section (non-collapsible) ── */ + +.section { + margin-bottom: 20px; +} + +.section-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--dim); + margin-bottom: 10px; + padding-left: 2px; +} + +/* ── Cards ── */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px 18px; + transition: border-color 0.25s, transform 0.15s, box-shadow 0.25s; +} + +.card:hover { + border-color: var(--dim); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); + transform: translateY(-1px); +} + +.card + .card { + margin-top: 8px; +} + +.card-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; +} + +.card-body { + font-size: 13px; + color: var(--muted); + line-height: 1.6; +} + +/* ── Settings row ── */ + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 6px; +} + +.setting-label { + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +/* ── Steps ── */ + +.steps { + list-style: none; + counter-reset: step; +} + +.steps li { + position: relative; + padding-left: 34px; + padding-bottom: 12px; + font-size: 13px; + line-height: 1.65; + counter-increment: step; +} + +.steps li::before { + content: counter(step); + position: absolute; + left: 0; + top: 2px; + width: 22px; + height: 22px; + border-radius: 7px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 700; + display: grid; + place-items: center; +} + +.steps li:last-child { padding-bottom: 0; } + +/* ── Indicator dots (in badge reference) ── */ + +.badge-grid { + display: grid; + grid-template-columns: 36px 1fr; + gap: 10px 12px; + align-items: center; + font-size: 13px; +} + +.indicator-dot { + width: 10px; + height: 10px; + border-radius: 50%; + justify-self: center; + transition: box-shadow 0.3s; +} + +.indicator-dot--green { + background: var(--green); + box-shadow: 0 0 8px var(--green-glow); +} + +.indicator-dot--yellow { + background: var(--yellow); + box-shadow: 0 0 8px var(--yellow-glow); +} + +.indicator-dot--dim { + background: var(--dim); +} + +.badge-text { + color: var(--muted); + font-size: 12px; +} + +/* ── Inline code ── */ + +code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.86em; + background: var(--surface-hover); + padding: 1px 5px; + border-radius: 4px; +} + +/* ── Port settings ── */ + +.port-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; +} + +.port-row input { + width: 100px; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 13px; + font-family: ui-monospace, monospace; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.port-row input:focus { + border-color: var(--accent-border); + box-shadow: 0 0 0 3px var(--accent-soft), 0 0 12px var(--accent-glow); +} + +.port-row button { + padding: 7px 12px; + border-radius: 8px; + border: none; + background: var(--accent); + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.2s; +} + +.port-row button:hover { + opacity: 0.9; + box-shadow: 0 0 12px var(--accent-glow); +} +.port-row button:active { opacity: 0.8; transform: scale(0.97); } + +.port-hint { + font-size: 11px; + color: var(--dim); + margin-top: 6px; +} + +.port-status { + font-size: 12px; + margin-top: 6px; + min-height: 16px; + transition: color 0.2s; +} + +.port-status[data-kind="ok"] { color: var(--green); } +.port-status[data-kind="error"] { color: var(--red); } + +/* ── Tab list ── */ + +.tab-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tab-list:empty + .tab-list-empty { display: block; } +.tab-list:not(:empty) + .tab-list-empty { display: none; } + +.tab-list-empty { + font-size: 12px; + color: var(--dim); + text-align: center; + padding: 8px 0; +} + +.tab-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + background: var(--surface-hover); + font-size: 12px; + overflow: hidden; + transition: background 0.15s, transform 0.1s; +} + +.tab-entry:hover { + background: var(--border); + transform: translateX(2px); +} + +.tab-entry-state { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + transition: background 0.2s, box-shadow 0.2s; +} + +.tab-entry-state[data-state="connected"] { + background: var(--green); + box-shadow: 0 0 6px var(--green-glow); +} +.tab-entry-state[data-state="virtual"] { background: var(--dim); } +.tab-entry-state[data-state="attaching"] { + background: var(--yellow); + box-shadow: 0 0 6px var(--yellow-glow); +} + +.tab-entry-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +.tab-entry-url { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--muted); + font-size: 11px; +} + +.tab-entry-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 4px; + flex-shrink: 0; +} + +.tab-entry-badge[data-type="agent"] { + background: rgba(249, 115, 22, 0.08); + color: #f97316; +} + +.tab-entry-badge[data-type="retained"] { + background: rgba(168, 85, 247, 0.08); + color: #a855f7; +} + +/* ── Logs ── */ + +.logs-container { + max-height: 320px; + overflow-y: auto; + margin-top: 8px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.7; +} + +.logs-empty { + color: var(--dim); + text-align: center; + padding: 12px 0; + font-family: inherit; +} + +.log-row { + display: flex; + gap: 6px; + padding: 2px 4px; + border-radius: 4px; + transition: background 0.1s; +} + +.log-row:hover { + background: var(--surface-hover); +} + +.log-time { color: var(--dim); flex-shrink: 0; } +.log-dir { flex-shrink: 0; width: 16px; text-align: center; } +.log-dir[data-dir="↓"] { color: var(--green); } +.log-dir[data-dir="↑"] { color: var(--accent); } +.log-method { color: var(--text); font-weight: 500; } +.log-detail { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.logs-refresh-btn { + font-size: 11px; + padding: 3px 8px; + border-radius: 5px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, color 0.15s, box-shadow 0.2s; +} + +.logs-refresh-btn:hover { + border-color: var(--accent-border); + color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); +} + +/* ── Mini Toggle ── */ + +.mini-toggle { + position: relative; + display: inline-block; + width: 32px; + height: 18px; + flex-shrink: 0; +} + +.mini-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.mini-toggle-track { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 9px; + cursor: pointer; + transition: background 0.25s, box-shadow 0.3s; +} + +.mini-toggle-track::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s; +} + +.mini-toggle input:checked + .mini-toggle-track { + background: var(--green); + box-shadow: 0 0 8px var(--green-glow); +} + +.mini-toggle input:checked + .mini-toggle-track::after { + transform: translateX(14px); + box-shadow: 0 0 4px var(--green-glow); +} diff --git a/extensions/lime-chrome/popup.html b/extensions/lime-chrome/popup.html index 887d60e94..c2041163d 100644 --- a/extensions/lime-chrome/popup.html +++ b/extensions/lime-chrome/popup.html @@ -151,6 +151,10 @@ background: var(--neutral-bg); color: var(--neutral-text); } + .badge-warn { + background: var(--warning-bg); + color: var(--warning-text); + } .hint { margin-top: 10px; border-radius: 14px; diff --git a/extensions/lime-chrome/popup.js b/extensions/lime-chrome/popup.js index edbbcdc36..a40d84be2 100644 --- a/extensions/lime-chrome/popup.js +++ b/extensions/lime-chrome/popup.js @@ -3,6 +3,7 @@ const DEFAULT_SETTINGS = { bridgeKey: "", profileKey: "default", monitoringEnabled: true, + enabled: true, }; const bridgeStatusEl = document.getElementById("bridgeStatus"); @@ -24,9 +25,9 @@ const captureBtnEl = document.getElementById("captureBtn"); const pageTitleEl = document.getElementById("pageTitle"); const pageUrlEl = document.getElementById("pageUrl"); -function setBadge(el, isOn, onText, offText) { - el.textContent = isOn ? onText : offText; - el.className = `badge ${isOn ? "badge-on" : "badge-off"}`; +function setBadge(el, label, variant = "off") { + el.textContent = label; + el.className = `badge badge-${variant}`; } function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) { @@ -41,15 +42,53 @@ function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) { function applyStatus(status) { const connected = Boolean(status?.isConnected); + const connecting = Boolean(status?.isConnecting); + const controlConnected = Boolean(status?.isControlConnected); + const controlConnecting = Boolean(status?.isControlConnecting); + const enabled = status?.enabled !== false; const monitoring = Boolean(status?.monitoringEnabled); - setBadge(bridgeStatusEl, connected, "已连接", "未连接"); - setBadge(monitorStatusEl, monitoring, "开启", "关闭"); - toggleConnBtnEl.textContent = connected ? "断开连接" : "立即连接"; + const errorText = + typeof status?.lastError === "string" ? status.lastError.trim() : ""; + const controlErrorText = + typeof status?.controlLastError === "string" + ? status.controlLastError.trim() + : ""; + + if (connected) { + setBadge(bridgeStatusEl, "已连接", "on"); + } else if (enabled && connecting) { + setBadge(bridgeStatusEl, "连接中", "warn"); + } else if (enabled && errorText) { + setBadge(bridgeStatusEl, "待重试", "warn"); + } else if (enabled) { + setBadge(bridgeStatusEl, "已启用", "warn"); + } else { + setBadge(bridgeStatusEl, "已停用", "off"); + } + + setBadge(monitorStatusEl, monitoring ? "开启" : "关闭", monitoring ? "on" : "off"); + toggleConnBtnEl.textContent = enabled ? "停止自动连接" : "启用并连接"; toggleMonitorBtnEl.textContent = monitoring ? "关闭页面监控" : "开启页面监控"; - statusHintEl.textContent = connected - ? "浏览器扩展已经接入当前 Lime 运行时。后续如需重新安装或切换目录,请回到 Lime 的“连接器”页处理。" - : "如果当前仍未连接,请先打开 Lime 的“连接器”页启用浏览器连接器,或重新同步扩展到你选择的目录。"; + if (connected && controlConnected) { + statusHintEl.textContent = + "浏览器扩展的 observer/control 双通道都已接入当前 Lime 运行时,后续会持续复用你当前已登录的 Chrome 标签页。"; + } else if (connected && (controlConnecting || controlErrorText)) { + statusHintEl.textContent = controlErrorText + ? `页面观察已接入,但控制通道当前异常:${controlErrorText}` + : "页面观察已接入,控制通道正在补连。"; + } else if (enabled && connecting) { + statusHintEl.textContent = + "扩展已启用,正在尝试连接本地 Lime relay。保持当前 Chrome 打开即可,连接成功后会自动抓取当前标签页。"; + } else if (enabled && errorText) { + statusHintEl.textContent = `扩展已启用,但当前连接失败:${errorText}`; + } else if (enabled) { + statusHintEl.textContent = + "扩展已启用,但当前还未连上 Lime。请确认桌面端已经启动,并检查连接器页中的地址和密钥。"; + } else { + statusHintEl.textContent = + "扩展当前处于停用状态,不会自动重连。需要时点击“启用并连接”,或回到 Lime 的“连接器”页重新同步配置。"; + } const latestPageInfo = status?.latestPageInfo; if (latestPageInfo?.title || latestPageInfo?.url) { @@ -124,6 +163,7 @@ async function saveAndReconnect() { serverUrl: serverUrlEl.value.trim(), bridgeKey: bridgeKeyEl.value.trim(), profileKey: profileKeyEl.value.trim() || "default", + enabled: true, reconnect: true, }; diff --git a/package.json b/package.json index 67292b4f1..27c329581 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.98.0", + "version": "0.99.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/scripts/browser-runtime-smoke.mjs b/scripts/browser-runtime-smoke.mjs index 1ae9188a6..2013b5669 100644 --- a/scripts/browser-runtime-smoke.mjs +++ b/scripts/browser-runtime-smoke.mjs @@ -9,6 +9,7 @@ const DEFAULTS = { intervalMs: 1_000, launchUrl: "about:blank", openWindow: false, + headless: false, streamMode: "both", }; @@ -29,6 +30,7 @@ Lime Browser Runtime Smoke --interval-ms 健康检查轮询间隔,默认 1000 --launch-url 启动浏览器会话的 URL,默认 about:blank --open-window 显式打开浏览器窗口 + --headless 以无界面浏览器会话执行 smoke,避免弹出空白 Chrome --stream-mode events | frames | both,默认 both -h, --help 显示帮助 `); @@ -73,6 +75,10 @@ function parseArgs(argv) { options.openWindow = true; continue; } + if (arg === "--headless") { + options.headless = true; + continue; + } if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); @@ -179,6 +185,7 @@ async function main() { profile_key: profileKey, url: options.launchUrl, open_window: options.openWindow, + headless: options.headless, stream_mode: options.streamMode, }, }); diff --git a/scripts/social-workbench-e2e-smoke.mjs b/scripts/social-workbench-e2e-smoke.mjs index a03f2c7bc..8a11a9c01 100644 --- a/scripts/social-workbench-e2e-smoke.mjs +++ b/scripts/social-workbench-e2e-smoke.mjs @@ -340,7 +340,11 @@ async function main() { console.log(`[Smoke] 开始校验 session: ${args.sessionId}`); - await invoke("get_server_status"); + const diagnostics = await invoke("get_server_diagnostics"); + assert( + diagnostics && typeof diagnostics.running === "boolean", + "get_server_diagnostics 返回格式异常", + ); console.log("[Smoke] Dev Bridge 可用"); const runState = await waitTerminalState( diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 6da87f92c..17a107e1e 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -18,6 +18,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const LIME_SKIP_STARTUP_WINDOW_REVEAL = "LIME_SKIP_STARTUP_WINDOW_REVEAL"; const state = { child: null, @@ -155,7 +156,10 @@ function startHeadlessTauri() { state.child = spawn(npmCommand, ["run", "tauri:dev:headless"], { cwd: rootDir, stdio: "inherit", - env: process.env, + env: { + ...process.env, + [LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1", + }, detached: process.platform !== "win32", }); } @@ -367,6 +371,7 @@ async function main() { String(options.timeoutMs), "--interval-ms", String(options.intervalMs), + "--headless", ], "smoke:browser-runtime", ); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 47a3f5902..bac4df422 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.98.0" +version = "0.99.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.98.0" +version = "0.99.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.98.0" +version = "0.99.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.98.0" +version = "0.99.0" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.98.0" +version = "0.99.0" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.98.0" +version = "0.99.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.98.0" +version = "0.99.0" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.98.0" +version = "0.99.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.98.0" +version = "0.99.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.98.0" +version = "0.99.0" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.98.0" +version = "0.99.0" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.98.0" +version = "0.99.0" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.98.0" +version = "0.99.0" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.98.0" +version = "0.99.0" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.98.0" +version = "0.99.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.98.0" +version = "0.99.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.98.0" +version = "0.99.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d9daac0c..b773e3477 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.98.0" +version = "0.99.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.98.0" +version = "0.99.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/runtime_queue.rs b/src-tauri/crates/agent/src/runtime_queue.rs index f3b3a08a2..dd0fee6fd 100644 --- a/src-tauri/crates/agent/src/runtime_queue.rs +++ b/src-tauri/crates/agent/src/runtime_queue.rs @@ -26,6 +26,33 @@ fn emit_runtime_queue_event( emitter(event_name.to_string(), event); } +fn spawn_runtime_turn_task( + session_id: String, + context: C, + executor: RuntimeQueueExecutor, + emitter: RuntimeQueueEventEmitter, + payload: Value, +) where + C: Clone + Send + Sync + 'static, +{ + tokio::spawn(async move { + let result = executor(context.clone(), payload).await; + if let Err(error) = continue_runtime_queue_after_turn( + session_id, + context.clone(), + executor.clone(), + emitter.clone(), + ) + .await + { + tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error); + } + if let Err(error) = result { + tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error); + } + }); +} + async fn continue_runtime_queue_after_turn( session_id: String, context: C, @@ -76,30 +103,13 @@ where }, ); - let runtime_handle = tokio::runtime::Handle::current(); - let executor_for_task = executor.clone(); - let emitter_for_task = emitter.clone(); - let context_for_task = context.clone(); - let session_id_for_task = session_id.clone(); - let payload = next_queued_turn.payload; - tokio::task::spawn_blocking(move || { - runtime_handle.block_on(async move { - let result = executor_for_task(context_for_task.clone(), payload).await; - if let Err(error) = continue_runtime_queue_after_turn( - session_id_for_task, - context_for_task.clone(), - executor_for_task.clone(), - emitter_for_task.clone(), - ) - .await - { - tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error); - } - if let Err(error) = result { - tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error); - } - }); - }); + spawn_runtime_turn_task( + session_id, + context, + executor, + emitter, + next_queued_turn.payload, + ); Ok(true) } @@ -149,14 +159,8 @@ where .map_err(|error| format!("提交 runtime queue turn 失败: {error}"))? { RuntimeQueueSubmitResult::StartNow => { - let result = executor(context.clone(), queued_task.payload).await; - if let Err(error) = - continue_runtime_queue_after_turn(session_id, context, executor.clone(), emitter) - .await - { - tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error); - } - result + spawn_runtime_turn_task(session_id, context, executor, emitter, queued_task.payload); + Ok(()) } RuntimeQueueSubmitResult::Busy => Err("当前会话仍在生成,无法立即开始执行".to_string()), RuntimeQueueSubmitResult::Enqueued { diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs index 4e2324dd5..936753a64 100644 --- a/src-tauri/crates/agent/src/turn_input_envelope.rs +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -66,6 +66,8 @@ pub enum TurnPromptAugmentationStageKind { WebSearch, RequestToolPolicy, Artifact, + ServiceSkillLaunch, + ServiceSkillLaunchPreload, Elicitation, TeamPreference, AutoContinue, diff --git a/src-tauri/crates/browser-runtime/src/action.rs b/src-tauri/crates/browser-runtime/src/action.rs index 561de2c1b..fc62e217c 100644 --- a/src-tauri/crates/browser-runtime/src/action.rs +++ b/src-tauri/crates/browser-runtime/src/action.rs @@ -17,6 +17,7 @@ pub async fn execute_action( "navigate" => navigate(session, &args).await, "click" => click(session, &args).await, "type" | "form_input" => type_text(session, &args).await, + "javascript" => execute_javascript(session, &args).await, "scroll" | "scroll_page" => scroll(session, &args).await, "refresh_page" => evaluate_navigation(session, "window.location.reload()").await, "go_back" => evaluate_navigation(session, "window.history.back()").await, @@ -33,6 +34,7 @@ pub async fn execute_action( "page_info": page, })) } + "find" => find_in_page(session, &args).await, "read_console_messages" => { let events = session.collect_console_messages(args.get("since").and_then(Value::as_u64)); @@ -249,6 +251,53 @@ async fn scroll(session: &CdpSessionHandle, args: &Value) -> Result Result { + let expression = get_string_arg( + args, + &[ + "expression", + "script", + "code", + "javascript", + "text", + "value", + ], + ) + .ok_or_else(|| "javascript 需要 expression/script/code 参数".to_string())?; + let return_by_value = args + .get("return_by_value") + .and_then(Value::as_bool) + .unwrap_or(true); + let timeout_ms = get_u64_arg(args, &["timeout_ms"]).unwrap_or(DEFAULT_ACTION_TIMEOUT_MS); + let result = session + .runtime_evaluate(expression.clone(), return_by_value, timeout_ms) + .await?; + Ok(json!({ + "evaluated": true, + "expression": expression, + "result": result, + })) +} + +async fn find_in_page(session: &CdpSessionHandle, args: &Value) -> Result { + let query = + get_string_arg(args, &["query"]).ok_or_else(|| "find 需要提供 query 参数".to_string())?; + let page = if let Some(page_info) = capture_and_sync_page_info(session).await { + page_info + } else if let Some(page_info) = session.state().await.last_page_info.clone() { + page_info + } else { + return Err("读取页面信息失败".to_string()); + }; + let matches = find_markdown_matches(&page.markdown, &query, 30); + Ok(json!({ + "query": query, + "match_count": matches.len(), + "matches": matches, + "page_info": page, + })) +} + async fn evaluate_navigation(session: &CdpSessionHandle, script: &str) -> Result { let previous_url = session .state() @@ -398,6 +447,19 @@ fn normalize_url_for_navigation(url: &str) -> String { } } +fn find_markdown_matches(markdown: &str, query: &str, limit: usize) -> Vec { + let normalized_query = query.trim().to_ascii_lowercase(); + if normalized_query.is_empty() { + return Vec::new(); + } + markdown + .lines() + .filter(|line| line.to_ascii_lowercase().contains(&normalized_query)) + .take(limit) + .map(str::to_string) + .collect() +} + fn get_string_arg(args: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { args.get(*key) @@ -456,4 +518,24 @@ mod tests { Some("https://www.36kr.com/newsflashes"), )); } + + #[test] + fn find_markdown_matches_should_match_case_insensitively_and_limit_results() { + let markdown = [ + "# GitHub", + "AI Agent Starter", + "agent framework", + "AGENT runtime", + "plain text", + ] + .join("\n"); + + assert_eq!( + find_markdown_matches(&markdown, "agent", 2), + vec![ + "AI Agent Starter".to_string(), + "agent framework".to_string(), + ] + ); + } } diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 2538ec816..7ce3a5b03 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -655,7 +655,7 @@ fn current_workspace_preferences_schema_version() -> u8 { } fn default_enabled_themes() -> Vec { - vec!["social-media".to_string()] + vec![] } impl Default for ContentCreatorConfig { @@ -770,7 +770,9 @@ const CURRENT_FOOTER_NAV_ITEM_IDS: &[&str] = &[ "memory", ]; -const LEGACY_ONLY_NAV_ITEM_IDS: &[&str] = &["agent", "projects", "api-server", "provider-pool"]; +const REMOVED_NAV_ITEM_IDS: &[&str] = &["api-server"]; + +const LEGACY_ONLY_NAV_ITEM_IDS: &[&str] = &["agent", "projects", "provider-pool"]; const LEGACY_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[ &["home-general", "claw", "video", "image-gen"], @@ -2185,10 +2187,34 @@ impl Default for Config { } impl Config { + pub fn normalize_local_server_surface(&mut self) -> bool { + let mut changed = false; + + if self.server.host != default_host() { + self.server.host = default_host(); + changed = true; + } + + if self.remote_management.allow_remote { + self.remote_management.allow_remote = false; + changed = true; + } + + changed + } + pub fn normalize_workspace_preferences(&mut self) -> bool { let mut changed = false; let current_version = current_workspace_preferences_schema_version(); + let original_nav_len = self.navigation.enabled_items.len(); + self.navigation + .enabled_items + .retain(|item| !REMOVED_NAV_ITEM_IDS.contains(&item.as_str())); + if self.navigation.enabled_items.len() != original_nav_len { + changed = true; + } + if self.content_creator.schema_version < current_version { if self.content_creator.enabled_themes.is_empty() || has_same_members( @@ -2744,10 +2770,7 @@ mod unit_tests { assert_eq!(config.crash_reporting.sample_rate, 1.0); assert!(!config.crash_reporting.send_pii); assert_eq!(config.content_creator.schema_version, 1); - assert_eq!( - config.content_creator.enabled_themes, - vec!["social-media".to_string()] - ); + assert_eq!(config.content_creator.enabled_themes, Vec::::new()); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, @@ -2853,10 +2876,7 @@ mod unit_tests { assert!(changed); assert_eq!(config.content_creator.schema_version, 1); - assert_eq!( - config.content_creator.enabled_themes, - vec!["social-media".to_string()] - ); + assert_eq!(config.content_creator.enabled_themes, Vec::::new()); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, @@ -2908,6 +2928,42 @@ mod unit_tests { ); } + #[test] + fn test_normalize_workspace_preferences_removes_api_server_entry_even_on_current_schema() { + let mut config = Config::default(); + config.navigation.enabled_items = vec![ + "home-general".to_string(), + "claw".to_string(), + REMOVED_NAV_ITEM_IDS[0].to_string(), + "resources".to_string(), + ]; + + let changed = config.normalize_workspace_preferences(); + + assert!(changed); + assert_eq!( + config.navigation.enabled_items, + vec![ + "home-general".to_string(), + "claw".to_string(), + "resources".to_string(), + ] + ); + } + + #[test] + fn test_normalize_local_server_surface_forces_loopback_and_disables_remote_management() { + let mut config = Config::default(); + config.server.host = "192.168.1.9".to_string(); + config.remote_management.allow_remote = true; + + let changed = config.normalize_local_server_surface(); + + assert!(changed); + assert_eq!(config.server.host, "127.0.0.1"); + assert!(!config.remote_management.allow_remote); + } + #[test] fn test_credential_pool_config_default() { let pool = CredentialPoolConfig::default(); diff --git a/src-tauri/crates/core/src/config/yaml.rs b/src-tauri/crates/core/src/config/yaml.rs index 1090c2a0c..afc8735e6 100644 --- a/src-tauri/crates/core/src/config/yaml.rs +++ b/src-tauri/crates/core/src/config/yaml.rs @@ -70,13 +70,15 @@ impl ConfigManager { /// /// 如果文件不存在,返回默认配置 pub fn load(path: &Path) -> Result { - let config = if path.exists() { + let mut config = if path.exists() { let content = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadError(e.to_string()))?; Self::parse_yaml(&content)? } else { Config::default() }; + config.normalize_local_server_surface(); + config.normalize_workspace_preferences(); Ok(Self { config, @@ -110,7 +112,7 @@ impl ConfigManager { let backup_path = path.with_extension("yaml.backup"); let _ = std::fs::copy(path, backup_path); } - let yaml = Self::to_yaml(&self.config)?; + let yaml = Self::to_yaml(&normalized_config_for_persistence(&self.config))?; std::fs::write(path, yaml).map_err(|e| ConfigError::WriteError(e.to_string())) } @@ -119,6 +121,8 @@ impl ConfigManager { let content = std::fs::read_to_string(&self.config_path) .map_err(|e| ConfigError::ReadError(e.to_string()))?; self.config = Self::parse_yaml(&content)?; + self.config.normalize_local_server_surface(); + self.config.normalize_workspace_preferences(); Ok(()) } @@ -239,6 +243,13 @@ impl ConfigManager { use super::types::{LoggingConfig, RetrySettings, ServerConfig}; +fn normalized_config_for_persistence(config: &Config) -> Config { + let mut normalized = config.clone(); + normalized.normalize_local_server_surface(); + normalized.normalize_workspace_preferences(); + normalized +} + impl Default for ConfigManager { fn default() -> Self { Self::new(Self::default_config_path()) @@ -665,6 +676,9 @@ pub fn load_config() -> Result> { let content = std::fs::read_to_string(&yaml_path)?; let mut config: Config = serde_yaml::from_str(&content)?; let mut should_save = config.normalize_workspace_preferences(); + if config.normalize_local_server_surface() { + should_save = true; + } // 如果配置中使用默认 API Key,生成强随机 Key 并保存 if is_default_api_key(&config.server.api_key) { let new_key = generate_secure_api_key(); @@ -685,6 +699,9 @@ pub fn load_config() -> Result> { let content = std::fs::read_to_string(&json_path)?; let mut config: Config = serde_json::from_str(&content)?; let mut should_save = config.normalize_workspace_preferences(); + if config.normalize_local_server_surface() { + should_save = true; + } // 如果配置中使用默认 API Key,生成强随机 Key 并保存 if is_default_api_key(&config.server.api_key) { let new_key = generate_secure_api_key(); @@ -714,21 +731,24 @@ pub fn load_config() -> Result> { /// 保存配置(同时写入 YAML 与 JSON,兼容旧版) pub fn save_config(config: &Config) -> Result<(), Box> { + let normalized = normalized_config_for_persistence(config); + // 主配置优先写入 YAML - save_config_yaml(config)?; + save_config_yaml(&normalized)?; // 兼容旧版 JSON 配置 let path = json_config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let content = serde_json::to_string_pretty(config)?; + let content = serde_json::to_string_pretty(&normalized)?; std::fs::write(&path, content)?; Ok(()) } /// 保存配置为 YAML 格式 pub fn save_config_yaml(config: &Config) -> Result<(), Box> { + let normalized = normalized_config_for_persistence(config); let path = ConfigManager::default_config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -737,7 +757,7 @@ pub fn save_config_yaml(config: &Config) -> Result<(), Box, - /// 标签 (如 "突破限制", "官方API/三方") - pub tags: Vec, - /// 是否启用 - pub enabled: bool, -} - -/// 路由端点 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RouteEndpoint { - /// 端点路径 - pub path: String, - /// 协议类型 - pub protocol: String, // "openai" 或 "claude" - /// 完整 URL - pub url: String, -} - -/// 路由列表响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RouteListResponse { - /// 服务器基础 URL - pub base_url: String, - /// 默认 Provider - pub default_provider: String, - /// 所有可用路由 - pub routes: Vec, -} - -/// curl 示例 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CurlExample { - /// 描述 - pub description: String, - /// curl 命令 - pub command: String, -} - -impl RouteInfo { - /// 创建新的路由信息 - pub fn new(selector: String, provider_type: String) -> Self { - Self { - selector, - provider_type, - credential_count: 0, - endpoints: Vec::new(), - tags: Vec::new(), - enabled: true, - } - } - - /// 添加端点 - pub fn add_endpoint(&mut self, base_url: &str, protocol: &str) { - let path = match protocol { - "claude" => format!("/{}/v1/messages", self.selector), - "openai" => format!("/{}/v1/chat/completions", self.selector), - _ => return, - }; - let url = format!("{base_url}{path}"); - self.endpoints.push(RouteEndpoint { - path, - protocol: protocol.to_string(), - url, - }); - } - - /// 生成 curl 示例 - pub fn generate_curl_examples(&self, api_key: &str) -> Vec { - let mut examples = Vec::new(); - - for endpoint in &self.endpoints { - let (_model, body) = match endpoint.protocol.as_str() { - "claude" => { - let model = match self.provider_type.as_str() { - "kiro" | "claude" => "claude-sonnet-4-5", - "gemini" => "gemini-2.5-flash", - "qwen" => "qwen3-coder-plus", - "openai" => "gpt-4", - _ => "claude-sonnet-4-5", - }; - ( - model, - format!( - r#"{{ - "model": "{model}", - "max_tokens": 1024, - "messages": [{{"role": "user", "content": "Hello!"}}] -}}"# - ), - ) - } - "openai" => { - let model = match self.provider_type.as_str() { - "kiro" | "claude" => "claude-sonnet-4-5", - "gemini" => "gemini-2.5-flash", - "qwen" => "qwen3-coder-plus", - "openai" => "gpt-4", - _ => "claude-sonnet-4-5", - }; - ( - model, - format!( - r#"{{ - "model": "{model}", - "messages": [{{"role": "user", "content": "Hello!"}}] -}}"# - ), - ) - } - _ => continue, - }; - - let command = format!( - r#"curl {} \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer {}" \ - -d '{}'"#, - endpoint.url, api_key, body - ); - - examples.push(CurlExample { - description: format!("{} 协议", endpoint.protocol.to_uppercase()), - command, - }); - } - - examples - } -} diff --git a/src-tauri/crates/core/src/models/skill_model.rs b/src-tauri/crates/core/src/models/skill_model.rs index 72785d5b6..88ff368e8 100644 --- a/src-tauri/crates/core/src/models/skill_model.rs +++ b/src-tauri/crates/core/src/models/skill_model.rs @@ -29,10 +29,11 @@ pub const IMAGE_GENERATE_SKILL_DIRECTORY: &str = "image_generate"; pub const LIBRARY_SKILL_DIRECTORY: &str = "library"; pub const URL_PARSE_SKILL_DIRECTORY: &str = "url_parse"; pub const RESEARCH_SKILL_DIRECTORY: &str = "research"; +pub const SITE_SEARCH_SKILL_DIRECTORY: &str = "site_search"; pub const TYPESETTING_SKILL_DIRECTORY: &str = "typesetting"; pub const SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY: &str = "social_post_with_cover"; -pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 10] = [ +pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 11] = [ VIDEO_GENERATE_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY, @@ -41,6 +42,7 @@ pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 10] = [ LIBRARY_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY, + SITE_SEARCH_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, ]; @@ -559,6 +561,7 @@ mod tests { #[test] fn test_default_lime_skill_directories_include_embedded_defaults() { assert!(is_default_lime_skill(VIDEO_GENERATE_SKILL_DIRECTORY)); + assert!(is_default_lime_skill(SITE_SEARCH_SKILL_DIRECTORY)); assert!(is_default_lime_skill( SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY )); diff --git a/src-tauri/crates/core/src/router/mod.rs b/src-tauri/crates/core/src/router/mod.rs index 5639e0a37..3a87e0255 100644 --- a/src-tauri/crates/core/src/router/mod.rs +++ b/src-tauri/crates/core/src/router/mod.rs @@ -1,11 +1,10 @@ //! 路由系统模块 //! -//! 支持动态路由注册、命名空间路由解析、模型映射。 +//! 支持当前主链路由规则、Amp CLI 路由与模型映射。 //! //! 路由格式: -//! - `/{provider-name}/v1/messages` - Provider 命名空间路由 -//! - `/{selector}/v1/messages` - 凭证选择器路由(向后兼容) //! - `/v1/messages` - 默认路由 +//! - `/v1/chat/completions` - 默认 OpenAI 协议路由 //! - `/api/provider/{provider}/v1/*` - Amp CLI 路由 //! //! 模型映射: @@ -17,8 +16,6 @@ mod amp_router; mod hint_router; mod mapper; -mod provider_router; -mod route_registry; mod rules; pub use amp_router::AmpRouter; diff --git a/src-tauri/crates/core/src/router/provider_router.rs b/src-tauri/crates/core/src/router/provider_router.rs deleted file mode 100644 index 02c2c817e..000000000 --- a/src-tauri/crates/core/src/router/provider_router.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Provider 路由解析器 -//! -//! 解析请求路径,确定目标 Provider 和协议。 - -#![allow(dead_code)] - -use super::route_registry::{RegisteredRoute, RouteRegistry, RouteType}; -use crate::models::provider_pool_model::PoolProviderType; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// 路由解析结果 -#[derive(Debug, Clone)] -pub struct RouteMatch { - /// 匹配的路由 - pub route: RegisteredRoute, - /// 请求的协议 (openai 或 claude) - pub protocol: String, - /// 请求的端点 (messages, chat/completions 等) - pub endpoint: String, - /// 路由选择器(从路径中提取) - pub selector: Option, -} - -impl RouteMatch { - /// 是否是 Claude 协议 - pub fn is_claude_protocol(&self) -> bool { - self.protocol == "claude" || self.endpoint == "messages" - } - - /// 是否是 OpenAI 协议 - pub fn is_openai_protocol(&self) -> bool { - self.protocol == "openai" || self.endpoint == "chat/completions" - } - - /// 获取 Provider 类型 - pub fn provider_type(&self) -> Option { - self.route - .provider_type - .as_ref() - .and_then(|s| s.parse().ok()) - } -} - -/// Provider 路由器 -pub struct ProviderRouter { - /// 路由注册表 - registry: Arc>, - /// 默认 Provider 引用(从配置动态获取) - default_provider_ref: Arc>, -} - -impl ProviderRouter { - /// 创建新的路由器 - pub fn new(registry: Arc>) -> Self { - Self { - registry, - default_provider_ref: Arc::new(RwLock::new("kiro".to_string())), - } - } - - /// 创建带有默认 Provider 引用的路由器 - pub fn with_default_provider( - registry: Arc>, - default_provider_ref: Arc>, - ) -> Self { - Self { - registry, - default_provider_ref, - } - } - - /// 解析请求路径 - /// - /// 支持的路径格式: - /// - `/v1/messages` - 默认路由,Claude 协议 - /// - `/v1/chat/completions` - 默认路由,OpenAI 协议 - /// - `/{selector}/v1/messages` - 选择器路由,Claude 协议 - /// - `/{selector}/v1/chat/completions` - 选择器路由,OpenAI 协议 - pub async fn resolve(&self, path: &str) -> Option { - let path = path.trim_start_matches('/'); - let parts: Vec<&str> = path.split('/').collect(); - - match parts.as_slice() { - // /v1/messages - ["v1", "messages"] => { - let registry = self.registry.read().await; - let default_provider = self.default_provider_ref.read().await.clone(); - let route = registry - .enabled_routes() - .into_iter() - .find(|r| r.route_type == RouteType::Default) - .cloned() - .unwrap_or_else(|| RegisteredRoute::default_route(&default_provider)); - - Some(RouteMatch { - route, - protocol: "claude".to_string(), - endpoint: "messages".to_string(), - selector: None, - }) - } - // /v1/chat/completions - ["v1", "chat", "completions"] => { - let registry = self.registry.read().await; - let default_provider = self.default_provider_ref.read().await.clone(); - let route = registry - .enabled_routes() - .into_iter() - .find(|r| r.route_type == RouteType::Default) - .cloned() - .unwrap_or_else(|| RegisteredRoute::default_route(&default_provider)); - - Some(RouteMatch { - route, - protocol: "openai".to_string(), - endpoint: "chat/completions".to_string(), - selector: None, - }) - } - // /{selector}/v1/messages - [selector, "v1", "messages"] => { - let registry = self.registry.read().await; - let route = registry.find_by_selector(selector).cloned(); - - // 安全修复:未注册的 selector 不创建临时路由,直接返回 None - let route = match route { - Some(r) => r, - None => { - tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector); - return None; - } - }; - - Some(RouteMatch { - route, - protocol: "claude".to_string(), - endpoint: "messages".to_string(), - selector: Some(selector.to_string()), - }) - } - // /{selector}/v1/chat/completions - [selector, "v1", "chat", "completions"] => { - let registry = self.registry.read().await; - let route = registry.find_by_selector(selector).cloned(); - - // 安全修复:未注册的 selector 不创建临时路由,直接返回 None - let route = match route { - Some(r) => r, - None => { - tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector); - return None; - } - }; - - Some(RouteMatch { - route, - protocol: "openai".to_string(), - endpoint: "chat/completions".to_string(), - selector: Some(selector.to_string()), - }) - } - _ => None, - } - } - - /// 注册凭证路由 - pub async fn register_credential( - &self, - provider_type: &str, - credential_uuid: &str, - credential_name: Option<&str>, - ) { - let mut registry = self.registry.write().await; - - // 注册命名空间路由 - let route = - RegisteredRoute::provider_namespace(provider_type, credential_uuid, credential_name); - registry.register(route); - - // 注册 UUID 选择器路由 - let selector_route = RegisteredRoute::credential_selector(credential_uuid, provider_type); - registry.register(selector_route); - } - - /// 注销凭证路由 - pub async fn unregister_credential(&self, credential_uuid: &str) { - let mut registry = self.registry.write().await; - registry.unregister(credential_uuid); - } - - /// 获取所有注册的路由 - pub async fn list_routes(&self) -> Vec { - let registry = self.registry.read().await; - registry.all_routes().to_vec() - } - - /// 生成路由 URL - pub fn generate_url(&self, base_url: &str, route: &RegisteredRoute, protocol: &str) -> String { - let endpoint = if protocol == "claude" { - "messages" - } else { - "chat/completions" - }; - - if route.route_type == RouteType::Default { - format!("{base_url}/v1/{endpoint}") - } else { - let selector = route - .credential_name - .as_ref() - .or(route.credential_uuid.as_ref()) - .map(|s| s.as_str()) - .unwrap_or("unknown"); - format!("{base_url}/{selector}/v1/{endpoint}") - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_resolve_default_routes() { - let registry = Arc::new(RwLock::new(RouteRegistry::new())); - let router = ProviderRouter::new(registry); - - let match1 = router.resolve("/v1/messages").await.unwrap(); - assert_eq!(match1.protocol, "claude"); - assert_eq!(match1.endpoint, "messages"); - assert!(match1.selector.is_none()); - - let match2 = router.resolve("/v1/chat/completions").await.unwrap(); - assert_eq!(match2.protocol, "openai"); - assert_eq!(match2.endpoint, "chat/completions"); - assert!(match2.selector.is_none()); - } - - #[tokio::test] - async fn test_resolve_selector_routes() { - let registry = Arc::new(RwLock::new(RouteRegistry::new())); - let router = ProviderRouter::new(registry); - - // 安全修复后,未注册的 selector 会返回 None,需要先注册 - router - .register_credential("kiro", "uuid-selector-test", Some("my-kiro")) - .await; - - let match1 = router.resolve("/my-kiro/v1/messages").await.unwrap(); - assert_eq!(match1.protocol, "claude"); - assert_eq!(match1.selector, Some("my-kiro".to_string())); - - let match2 = router - .resolve("/my-kiro/v1/chat/completions") - .await - .unwrap(); - assert_eq!(match2.protocol, "openai"); - assert_eq!(match2.selector, Some("my-kiro".to_string())); - } - - #[tokio::test] - async fn test_register_and_resolve() { - let registry = Arc::new(RwLock::new(RouteRegistry::new())); - let router = ProviderRouter::new(registry); - - router - .register_credential("kiro", "uuid-123", Some("my-kiro-account")) - .await; - - let match1 = router - .resolve("/my-kiro-account/v1/messages") - .await - .unwrap(); - assert_eq!(match1.route.credential_uuid, Some("uuid-123".to_string())); - assert_eq!(match1.route.provider_type, Some("kiro".to_string())); - } -} diff --git a/src-tauri/crates/core/src/router/route_registry.rs b/src-tauri/crates/core/src/router/route_registry.rs deleted file mode 100644 index d9993525d..000000000 --- a/src-tauri/crates/core/src/router/route_registry.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! 路由注册表 -//! -//! 管理所有注册的路由,支持动态添加和查询。 - -#![allow(dead_code)] - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// 路由类型 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RouteType { - /// Provider 命名空间路由 (如 /claude-kiro-oauth/v1/messages) - ProviderNamespace, - /// 凭证选择器路由 (如 /{uuid}/v1/messages) - CredentialSelector, - /// 默认路由 (如 /v1/messages) - Default, -} - -/// 注册的路由信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegisteredRoute { - /// 路由路径模式 - pub path_pattern: String, - /// 路由类型 - pub route_type: RouteType, - /// Provider 类型(如果适用) - pub provider_type: Option, - /// 凭证 UUID(如果适用) - pub credential_uuid: Option, - /// 凭证名称(如果适用) - pub credential_name: Option, - /// 支持的协议 - pub protocols: Vec, - /// 是否启用 - pub enabled: bool, - /// 优先级(数字越小优先级越高) - pub priority: u32, -} - -impl RegisteredRoute { - /// 创建 Provider 命名空间路由 - pub fn provider_namespace( - provider_type: &str, - credential_uuid: &str, - credential_name: Option<&str>, - ) -> Self { - let path_pattern = format!( - "/{}/v1/{{endpoint}}", - Self::generate_route_name(provider_type, credential_name) - ); - Self { - path_pattern, - route_type: RouteType::ProviderNamespace, - provider_type: Some(provider_type.to_string()), - credential_uuid: Some(credential_uuid.to_string()), - credential_name: credential_name.map(|s| s.to_string()), - protocols: vec!["openai".to_string(), "claude".to_string()], - enabled: true, - priority: 10, - } - } - - /// 创建凭证选择器路由 - pub fn credential_selector(credential_uuid: &str, provider_type: &str) -> Self { - Self { - path_pattern: format!("/{credential_uuid}/v1/{{endpoint}}"), - route_type: RouteType::CredentialSelector, - provider_type: Some(provider_type.to_string()), - credential_uuid: Some(credential_uuid.to_string()), - credential_name: None, - protocols: vec!["openai".to_string(), "claude".to_string()], - enabled: true, - priority: 20, - } - } - - /// 创建默认路由 - pub fn default_route(provider_type: &str) -> Self { - Self { - path_pattern: "/v1/{endpoint}".to_string(), - route_type: RouteType::Default, - provider_type: Some(provider_type.to_string()), - credential_uuid: None, - credential_name: None, - protocols: vec!["openai".to_string(), "claude".to_string()], - enabled: true, - priority: 100, - } - } - - /// 生成路由名称 - fn generate_route_name(provider_type: &str, credential_name: Option<&str>) -> String { - if let Some(name) = credential_name { - // 将名称转换为 URL 友好格式 - name.to_lowercase() - .replace(' ', "-") - .chars() - .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') - .collect() - } else { - provider_type.to_lowercase() - } - } - - /// 获取路由的显示名称 - pub fn display_name(&self) -> String { - if let Some(name) = &self.credential_name { - name.clone() - } else if let Some(provider) = &self.provider_type { - provider.clone() - } else { - "default".to_string() - } - } -} - -/// 路由注册表 -#[derive(Debug, Default)] -pub struct RouteRegistry { - /// 所有注册的路由 - routes: Vec, - /// 路由名称到索引的映射 - name_index: HashMap, - /// UUID 到索引的映射 - uuid_index: HashMap, -} - -impl RouteRegistry { - /// 创建新的路由注册表 - pub fn new() -> Self { - Self::default() - } - - /// 注册路由 - pub fn register(&mut self, route: RegisteredRoute) { - let index = self.routes.len(); - - // 更新索引 - if let Some(name) = &route.credential_name { - self.name_index.insert(name.to_lowercase(), index); - } - if let Some(uuid) = &route.credential_uuid { - self.uuid_index.insert(uuid.clone(), index); - } - - self.routes.push(route); - - // 按优先级排序 - self.sort_by_priority(); - } - - /// 注销路由 - pub fn unregister(&mut self, credential_uuid: &str) { - if let Some(&index) = self.uuid_index.get(credential_uuid) { - if index < self.routes.len() { - let route = &self.routes[index]; - if let Some(name) = &route.credential_name { - self.name_index.remove(&name.to_lowercase()); - } - self.uuid_index.remove(credential_uuid); - self.routes.remove(index); - - // 重建索引 - self.rebuild_indices(); - } - } - } - - /// 按名称查找路由 - pub fn find_by_name(&self, name: &str) -> Option<&RegisteredRoute> { - self.name_index - .get(&name.to_lowercase()) - .and_then(|&idx| self.routes.get(idx)) - } - - /// 按 UUID 查找路由 - pub fn find_by_uuid(&self, uuid: &str) -> Option<&RegisteredRoute> { - self.uuid_index - .get(uuid) - .and_then(|&idx| self.routes.get(idx)) - } - - /// 按选择器查找路由(名称或 UUID) - pub fn find_by_selector(&self, selector: &str) -> Option<&RegisteredRoute> { - self.find_by_name(selector) - .or_else(|| self.find_by_uuid(selector)) - } - - /// 获取所有路由 - pub fn all_routes(&self) -> &[RegisteredRoute] { - &self.routes - } - - /// 获取启用的路由 - pub fn enabled_routes(&self) -> Vec<&RegisteredRoute> { - self.routes.iter().filter(|r| r.enabled).collect() - } - - /// 按 Provider 类型获取路由 - pub fn routes_by_provider(&self, provider_type: &str) -> Vec<&RegisteredRoute> { - self.routes - .iter() - .filter(|r| r.provider_type.as_deref() == Some(provider_type)) - .collect() - } - - /// 清空所有路由 - pub fn clear(&mut self) { - self.routes.clear(); - self.name_index.clear(); - self.uuid_index.clear(); - } - - /// 按优先级排序 - fn sort_by_priority(&mut self) { - self.routes.sort_by_key(|r| r.priority); - self.rebuild_indices(); - } - - /// 重建索引 - fn rebuild_indices(&mut self) { - self.name_index.clear(); - self.uuid_index.clear(); - - for (index, route) in self.routes.iter().enumerate() { - if let Some(name) = &route.credential_name { - self.name_index.insert(name.to_lowercase(), index); - } - if let Some(uuid) = &route.credential_uuid { - self.uuid_index.insert(uuid.clone(), index); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_route_registration() { - let mut registry = RouteRegistry::new(); - - let route = - RegisteredRoute::provider_namespace("kiro", "uuid-123", Some("my-kiro-account")); - registry.register(route); - - assert!(registry.find_by_name("my-kiro-account").is_some()); - assert!(registry.find_by_uuid("uuid-123").is_some()); - assert!(registry.find_by_selector("my-kiro-account").is_some()); - } - - #[test] - fn test_route_unregistration() { - let mut registry = RouteRegistry::new(); - - let route = - RegisteredRoute::provider_namespace("kiro", "uuid-123", Some("my-kiro-account")); - registry.register(route); - - registry.unregister("uuid-123"); - - assert!(registry.find_by_name("my-kiro-account").is_none()); - assert!(registry.find_by_uuid("uuid-123").is_none()); - } -} diff --git a/src-tauri/crates/core/src/tray_format.rs b/src-tauri/crates/core/src/tray_format.rs index 895ccba0f..649157041 100644 --- a/src-tauri/crates/core/src/tray_format.rs +++ b/src-tauri/crates/core/src/tray_format.rs @@ -2,19 +2,6 @@ //! //! 提供托盘菜单文本的格式化函数 -/// 格式化服务器状态文本 -/// -/// # 示例输出 -/// - "● Lime 网关:运行中 (127.0.0.1:8080)" -/// - "○ Lime 网关:未启动" -pub fn format_server_status(running: bool, host: &str, port: u16) -> String { - if running { - format!("● Lime 网关:运行中 ({host}:{port})") - } else { - "○ Lime 网关:未启动".to_string() - } -} - /// 格式化凭证状态文本 /// /// # 示例输出 @@ -57,14 +44,6 @@ pub fn format_current_model_status( format!("◉ Claw 模型:{normalized_provider} / {normalized_model} · {normalized_theme}") } -/// 格式化 API 地址 -/// -/// # 示例输出 -/// - "http://127.0.0.1:8080" -pub fn format_api_address(host: &str, port: u16) -> String { - format!("http://{host}:{port}") -} - #[cfg(test)] mod tests { use super::*; @@ -75,20 +54,10 @@ mod tests { /// **Validates: Requirements 2.2, 2.3, 2.4** #[test] fn prop_menu_content_formatting( - host in "[a-z0-9.]{1,50}", - port in 1024u16..65535, available in 0usize..100, total in 0usize..100, requests in 0u64..1000000 ) { - let running_status = format_server_status(true, &host, port); - prop_assert!(running_status.contains(&host), "运行状态应包含 host"); - prop_assert!(running_status.contains(&port.to_string()), "运行状态应包含 port"); - prop_assert!(running_status.contains("运行中"), "运行状态应包含'运行中'"); - - let stopped_status = format_server_status(false, &host, port); - prop_assert!(stopped_status.contains("已停止"), "停止状态应包含'已停止'"); - let cred_status = format_credential_status(available, total); prop_assert!(cred_status.contains(&available.to_string()), "凭证状态应包含可用数"); prop_assert!(cred_status.contains(&total.to_string()), "凭证状态应包含总数"); @@ -97,29 +66,6 @@ mod tests { prop_assert!(req_status.contains(&requests.to_string()), "请求统计应包含请求次数"); } - /// **Feature: system-tray, Property 4: API 地址格式化正确性** - /// **Validates: Requirements 4.2** - #[test] - fn prop_api_address_formatting( - host in "[a-z0-9.]{1,50}", - port in 1024u16..65535 - ) { - let address = format_api_address(&host, port); - let expected = format!("http://{host}:{port}"); - prop_assert_eq!(address, expected, "API 地址格式应为 http://{{host}}:{{port}}"); - } - } - - #[test] - fn test_format_server_status_running() { - let status = format_server_status(true, "127.0.0.1", 8080); - assert_eq!(status, "● Lime 网关:运行中 (127.0.0.1:8080)"); - } - - #[test] - fn test_format_server_status_stopped() { - let status = format_server_status(false, "127.0.0.1", 8080); - assert_eq!(status, "○ Lime 网关:未启动"); } #[test] @@ -151,10 +97,4 @@ mod tests { let status = format_current_model_status("", "", None); assert_eq!(status, "◉ Claw 模型:未同步"); } - - #[test] - fn test_format_api_address() { - let address = format_api_address("127.0.0.1", 8080); - assert_eq!(address, "http://127.0.0.1:8080"); - } } diff --git a/src-tauri/crates/core/src/tray_menu_meta.rs b/src-tauri/crates/core/src/tray_menu_meta.rs index 61930ad28..3b4c6a2cf 100644 --- a/src-tauri/crates/core/src/tray_menu_meta.rs +++ b/src-tauri/crates/core/src/tray_menu_meta.rs @@ -1,6 +1,6 @@ //! 托盘菜单元数据模块 //! -//! 提供与 Tauri 无关的菜单 ID、地址解析和菜单元信息工具函数。 +//! 提供与 Tauri 无关的菜单 ID 和菜单元信息工具函数。 /// 菜单项 ID 常量 pub mod menu_ids { @@ -8,18 +8,12 @@ pub mod menu_ids { pub const CURRENT_MODEL_INFO: &str = "current_model_info"; /// 快速切换模型 pub const QUICK_MODEL_ROOT: &str = "quick_model_root"; - /// 状态信息 - pub const STATUS_INFO: &str = "status_info"; /// 凭证信息 pub const CREDENTIAL_INFO: &str = "credential_info"; /// 请求信息 pub const REQUEST_INFO: &str = "request_info"; /// 分隔符 1 pub const SEPARATOR_1: &str = "sep_1"; - /// 启动服务器 - pub const START_SERVER: &str = "start_server"; - /// 停止服务器 - pub const STOP_SERVER: &str = "stop_server"; /// 刷新所有 Token pub const REFRESH_TOKENS: &str = "refresh_tokens"; /// 健康检查 @@ -28,8 +22,6 @@ pub mod menu_ids { pub const SEPARATOR_2: &str = "sep_2"; /// 打开主窗口 pub const OPEN_WINDOW: &str = "open_window"; - /// 复制 API 地址 - pub const COPY_API_ADDRESS: &str = "copy_api_address"; /// 打开日志目录 pub const OPEN_LOG_DIR: &str = "open_log_dir"; /// 分隔符 3 @@ -44,15 +36,11 @@ pub mod menu_ids { /// 获取所有必需的菜单项 ID 列表 pub fn all_required_ids() -> Vec<&'static str> { vec![ - STATUS_INFO, CREDENTIAL_INFO, REQUEST_INFO, - START_SERVER, - STOP_SERVER, REFRESH_TOKENS, HEALTH_CHECK, OPEN_WINDOW, - COPY_API_ADDRESS, OPEN_LOG_DIR, AUTO_START, QUIT, @@ -60,26 +48,6 @@ pub mod menu_ids { } } -/// 解析服务器地址字符串为 host 和 port -/// -/// 支持格式: -/// - "host:port" -> (host, port) -/// - "host" -> (host, 8080) -/// - "" -> ("127.0.0.1", 8080) -pub fn parse_server_address(address: &str) -> (String, u16) { - if address.is_empty() { - return ("127.0.0.1".to_string(), 8080); - } - - if let Some((host, port_str)) = address.rsplit_once(':') { - if let Ok(port) = port_str.parse::() { - return (host.to_string(), port); - } - } - - (address.to_string(), 8080) -} - /// 获取菜单中包含的所有菜单项 ID pub fn get_menu_item_ids() -> Vec<&'static str> { menu_ids::all_required_ids() @@ -130,61 +98,26 @@ mod tests { fn test_menu_ids_completeness() { let ids = menu_ids::all_required_ids(); - assert!(ids.contains(&menu_ids::STATUS_INFO), "应包含 STATUS_INFO"); assert!( ids.contains(&menu_ids::CREDENTIAL_INFO), "应包含 CREDENTIAL_INFO" ); assert!(ids.contains(&menu_ids::REQUEST_INFO), "应包含 REQUEST_INFO"); - assert!(ids.contains(&menu_ids::START_SERVER), "应包含 START_SERVER"); - assert!(ids.contains(&menu_ids::STOP_SERVER), "应包含 STOP_SERVER"); assert!( ids.contains(&menu_ids::REFRESH_TOKENS), "应包含 REFRESH_TOKENS" ); assert!(ids.contains(&menu_ids::HEALTH_CHECK), "应包含 HEALTH_CHECK"); assert!(ids.contains(&menu_ids::OPEN_WINDOW), "应包含 OPEN_WINDOW"); - assert!( - ids.contains(&menu_ids::COPY_API_ADDRESS), - "应包含 COPY_API_ADDRESS" - ); assert!(ids.contains(&menu_ids::OPEN_LOG_DIR), "应包含 OPEN_LOG_DIR"); assert!(ids.contains(&menu_ids::AUTO_START), "应包含 AUTO_START"); assert!(ids.contains(&menu_ids::QUIT), "应包含 QUIT"); } - #[test] - fn test_parse_server_address_with_port() { - let (host, port) = parse_server_address("127.0.0.1:8080"); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 8080); - } - - #[test] - fn test_parse_server_address_without_port() { - let (host, port) = parse_server_address("localhost"); - assert_eq!(host, "localhost"); - assert_eq!(port, 8080); - } - - #[test] - fn test_parse_server_address_empty() { - let (host, port) = parse_server_address(""); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 8080); - } - - #[test] - fn test_parse_server_address_ipv6() { - let (host, port) = parse_server_address("[::1]:9000"); - assert_eq!(host, "[::1]"); - assert_eq!(port, 9000); - } - #[test] fn test_get_menu_item_ids() { let ids = get_menu_item_ids(); - assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项"); + assert_eq!(ids.len(), 8, "应有 8 个必需的菜单项"); } #[test] @@ -208,7 +141,6 @@ mod tests { proptest! { #[test] fn prop_menu_ids_completeness( - _server_running in any::(), _available in 0usize..100, _total in 0usize..100, _requests in 0u64..1000000, @@ -217,15 +149,11 @@ mod tests { let ids = menu_ids::all_required_ids(); let required = vec![ - menu_ids::STATUS_INFO, menu_ids::CREDENTIAL_INFO, menu_ids::REQUEST_INFO, - menu_ids::START_SERVER, - menu_ids::STOP_SERVER, menu_ids::REFRESH_TOKENS, menu_ids::HEALTH_CHECK, menu_ids::OPEN_WINDOW, - menu_ids::COPY_API_ADDRESS, menu_ids::OPEN_LOG_DIR, menu_ids::AUTO_START, menu_ids::QUIT, diff --git a/src-tauri/crates/core/src/tray_state.rs b/src-tauri/crates/core/src/tray_state.rs index efd7e675b..6ddbb4c24 100644 --- a/src-tauri/crates/core/src/tray_state.rs +++ b/src-tauri/crates/core/src/tray_state.rs @@ -8,13 +8,13 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum TrayIconStatus { - /// 正常运行(绿色)- 服务器运行且凭证健康 + /// 正常运行(绿色)- 已同步且所有账号可用 Running, - /// 警告状态(黄色)- 有凭证即将过期或余额不足 + /// 警告状态(黄色)- 部分账号不可用或存在告警 Warning, - /// 错误状态(红色)- 服务器停止或所有凭证无效 + /// 错误状态(红色)- 当前没有可用账号 Error, - /// 停止状态(灰色)- 服务器未启动 + /// 停止状态(灰色)- 托盘状态尚未同步 #[default] Stopped, } @@ -82,10 +82,6 @@ pub struct TrayQuickModelGroup { pub struct TrayStateSnapshot { /// 图标状态 pub icon_status: TrayIconStatus, - /// 服务器是否运行 - pub server_running: bool, - /// 服务器地址 - pub server_address: String, /// 可用凭证数 pub available_credentials: usize, /// 总凭证数 @@ -110,8 +106,6 @@ impl Default for TrayStateSnapshot { fn default() -> Self { Self { icon_status: TrayIconStatus::Stopped, - server_running: false, - server_address: String::new(), available_credentials: 0, total_credentials: 0, today_requests: 0, @@ -125,32 +119,24 @@ impl Default for TrayStateSnapshot { } } -/// 根据服务器状态和凭证健康状态计算托盘图标状态 +/// 根据凭证健康状态计算托盘图标状态 /// /// # 规则 -/// - 服务器未运行 -> Stopped -/// - 服务器运行 + 所有凭证无效 -> Error -/// - 服务器运行 + 有凭证警告 -> Warning -/// - 服务器运行 + 所有凭证健康 -> Running -pub fn calculate_icon_status( - server_running: bool, - credentials: &[CredentialHealth], -) -> TrayIconStatus { - if !server_running { - return TrayIconStatus::Stopped; - } - +/// - 无账号或无可用账号 -> Error +/// - 部分账号不可用或有告警 -> Warning +/// - 所有账号健康 -> Running +pub fn calculate_icon_status(credentials: &[CredentialHealth]) -> TrayIconStatus { if credentials.is_empty() { return TrayIconStatus::Error; } - let all_invalid = credentials.iter().all(|c| !c.is_valid); - if all_invalid { + let available = credentials.iter().filter(|c| c.is_valid).count(); + if available == 0 { return TrayIconStatus::Error; } let has_warning = credentials.iter().any(|c| c.has_warning()); - if has_warning { + if available < credentials.len() || has_warning { return TrayIconStatus::Warning; } @@ -193,29 +179,23 @@ mod tests { /// **Validates: Requirements 1.1, 1.2, 1.3** #[test] fn prop_icon_status_mapping( - server_running in any::(), credentials in prop::collection::vec(arb_credential_health(), 0..10) ) { - let status = calculate_icon_status(server_running, &credentials); - - if !server_running { - prop_assert_eq!(status, TrayIconStatus::Stopped); - return Ok(()); - } + let status = calculate_icon_status(&credentials); if credentials.is_empty() { prop_assert_eq!(status, TrayIconStatus::Error); return Ok(()); } - let all_invalid = credentials.iter().all(|c| !c.is_valid); - if all_invalid { + let available = credentials.iter().filter(|c| c.is_valid).count(); + if available == 0 { prop_assert_eq!(status, TrayIconStatus::Error); return Ok(()); } let has_warning = credentials.iter().any(|c| c.has_warning()); - if has_warning { + if available < credentials.len() || has_warning { prop_assert_eq!(status, TrayIconStatus::Warning); return Ok(()); } @@ -251,27 +231,27 @@ mod tests { assert!(health2.has_warning()); } - #[test] - fn test_calculate_icon_status_server_stopped() { - let credentials = vec![CredentialHealth::healthy()]; - let status = calculate_icon_status(false, &credentials); - assert_eq!(status, TrayIconStatus::Stopped); - } - #[test] fn test_calculate_icon_status_no_credentials() { let credentials = vec![]; - let status = calculate_icon_status(true, &credentials); + let status = calculate_icon_status(&credentials); assert_eq!(status, TrayIconStatus::Error); } #[test] fn test_calculate_icon_status_all_invalid() { let credentials = vec![CredentialHealth::invalid(), CredentialHealth::invalid()]; - let status = calculate_icon_status(true, &credentials); + let status = calculate_icon_status(&credentials); assert_eq!(status, TrayIconStatus::Error); } + #[test] + fn test_calculate_icon_status_partially_invalid() { + let credentials = vec![CredentialHealth::healthy(), CredentialHealth::invalid()]; + let status = calculate_icon_status(&credentials); + assert_eq!(status, TrayIconStatus::Warning); + } + #[test] fn test_calculate_icon_status_with_warning() { let credentials = vec![ @@ -282,14 +262,14 @@ mod tests { is_low_balance: false, }, ]; - let status = calculate_icon_status(true, &credentials); + let status = calculate_icon_status(&credentials); assert_eq!(status, TrayIconStatus::Warning); } #[test] fn test_calculate_icon_status_running() { let credentials = vec![CredentialHealth::healthy(), CredentialHealth::healthy()]; - let status = calculate_icon_status(true, &credentials); + let status = calculate_icon_status(&credentials); assert_eq!(status, TrayIconStatus::Running); } diff --git a/src-tauri/crates/server/src/chrome_bridge.rs b/src-tauri/crates/server/src/chrome_bridge.rs index 1ae090544..61a17659f 100644 --- a/src-tauri/crates/server/src/chrome_bridge.rs +++ b/src-tauri/crates/server/src/chrome_bridge.rs @@ -994,14 +994,14 @@ fn validate_command( } if normalized == "run_adapter" { - let script = payload + let adapter_name = payload .as_ref() - .and_then(|value| value.get("script")) + .and_then(|value| value.get("adapter_name")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); - if script.is_none() { - return Err("run_adapter 命令需要提供 payload.script。".to_string()); + if adapter_name.is_none() { + return Err("run_adapter 命令需要提供 payload.adapter_name。".to_string()); } } @@ -1183,7 +1183,8 @@ mod tests { "run_adapter", &None, &Some(json!({ - "script": "(async () => ({ ok: true }))()", + "adapter_name": "github/search", + "args": {"query": "mcp"}, })), ) .is_ok()); @@ -1466,7 +1467,8 @@ mod tests { None, None, Some(json!({ - "script": "(async () => ({ ok: true }))()", + "adapter_name": "github/search", + "args": {"query": "mcp"}, })), false, ); @@ -1474,8 +1476,9 @@ mod tests { assert_eq!(payload["data"]["command"], json!("run_adapter")); assert_eq!(payload["data"]["target"], json!("202")); assert_eq!( - payload["data"]["payload"]["script"], - json!("(async () => ({ ok: true }))()") + payload["data"]["payload"]["adapter_name"], + json!("github/search") ); + assert_eq!(payload["data"]["payload"]["args"]["query"], json!("mcp")); } } diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs index 7667713fa..648597bb6 100644 --- a/src-tauri/crates/server/src/lib.rs +++ b/src-tauri/crates/server/src/lib.rs @@ -24,7 +24,6 @@ use lime_core::logger::LogStore; use lime_core::models::anthropic::*; use lime_core::models::openai::*; use lime_core::models::provider_pool_model::CredentialData; -use lime_core::models::route_model::{RouteInfo, RouteListResponse}; use lime_credential::CredentialSyncService; use lime_infra::injection::Injector; use lime_processor::{RequestContext, RequestProcessor}; @@ -281,9 +280,6 @@ pub struct ServerState { /// 路由器引用(用于动态更新默认 Provider) pub router_ref: Option>>, shutdown_tx: Option>, - /// 服务器运行时使用的 API key(启动时从配置复制) - /// 用于 test_api 命令,确保测试使用的 API key 和服务器一致 - pub running_api_key: Option, /// 服务器实际监听的 host(可能与配置不同,因为会自动切换到有效的 IP) pub running_host: Option, /// 能力路由指标(能力过滤/模型回退/Provider 回退) @@ -332,7 +328,6 @@ impl ServerState { default_provider_ref, router_ref: None, shutdown_tx: None, - running_api_key: None, running_host: None, capability_routing_metrics_store: Arc::new( middleware::capability_routing_metrics::CapabilityRoutingMetricsStore::new(), @@ -454,7 +449,6 @@ impl ServerState { let port = self.config.server.port; let api_key = self.config.server.api_key.clone(); - let api_key_for_state = api_key.clone(); // 用于保存到 running_api_key let default_provider_ref = self.default_provider_ref.clone(); // 重新加载凭证 @@ -577,8 +571,6 @@ impl ServerState { self.running = true; self.start_time = Some(std::time::Instant::now()); - // 保存服务器运行时使用的 API key,用于 test_api 命令 - self.running_api_key = Some(api_key_for_state); // 保存服务器实际监听的 host(可能与配置不同) self.running_host = Some(running_host); Ok(()) @@ -590,7 +582,6 @@ impl ServerState { } self.running = false; self.start_time = None; - self.running_api_key = None; self.running_host = None; self.router_ref = None; } @@ -1169,7 +1160,6 @@ async fn run_server( .route("/cache", get(cache_diagnostics)) .route("/stats", get(stats_diagnostics)) .route("/v1/models", get(models)) - .route("/v1/routes", get(list_routes)) .route("/v1/chat/completions", post( |State(state): State, headers: HeaderMap, @@ -1201,15 +1191,6 @@ async fn run_server( "/lime-chrome-control/:lime_key", get(handlers::chrome_control_ws_upgrade), ) - // 多供应商路由 - .route( - "/{selector}/v1/messages", - post(anthropic_messages_with_selector), - ) - .route( - "/{selector}/v1/chat/completions", - post(chat_completions_with_selector), - ) // Kiro凭证管理API路由 .merge(kiro_api_routes) // 凭证 API 路由(用于 aster Agent 集成) @@ -1738,263 +1719,6 @@ async fn gemini_generate_content( } } -/// 列出所有可用路由 -async fn list_routes(State(state): State) -> impl IntoResponse { - // 处理 base_url:检查 IP 是否有效(在当前网卡列表中或是特殊地址) - let display_base_url = { - // 从 base_url 中提取 host 部分 - let url_parts: Vec<&str> = state.base_url.split("://").collect(); - let host_port = if url_parts.len() > 1 { - url_parts[1] - } else { - &state.base_url - }; - let host = host_port.split(':').next().unwrap_or("localhost"); - - // 检查是否需要替换 IP - let should_replace = if host == "0.0.0.0" || host == "127.0.0.1" || host == "localhost" { - // 0.0.0.0 需要替换为局域网 IP,127.0.0.1 和 localhost 保持不变 - host == "0.0.0.0" - } else { - // 检查 IP 是否在当前网卡列表中 - if let Ok(network_info) = lime_core::network::get_network_info() { - !network_info.all_ips.contains(&host.to_string()) - } else { - false - } - }; - - if should_replace { - // 获取局域网 IP 进行替换 - // 优先选择 192.168.x.x 或 10.x.x.x 开头的 IP(真正的局域网 IP) - if let Ok(network_info) = lime_core::network::get_network_info() { - let new_ip = network_info - .all_ips - .iter() - .find(|ip| ip.starts_with("192.168.") || ip.starts_with("10.")) - .or(network_info.lan_ip.as_ref()) - .or_else(|| network_info.all_ips.first()) - .cloned() - .unwrap_or_else(|| "localhost".to_string()); - state.base_url.replace(host, &new_ip) - } else { - state.base_url.replace(host, "localhost") - } - } else { - state.base_url.clone() - } - }; - - let routes = match &state.db { - Some(db) => state - .pool_service - .get_available_routes(db, &display_base_url) - .unwrap_or_default(), - None => Vec::new(), - }; - - // 获取默认 Provider - let default_provider = state.default_provider.read().await.clone(); - - // 添加默认路由 - let mut all_routes = vec![RouteInfo { - selector: "default".to_string(), - provider_type: default_provider.clone(), - credential_count: 1, - endpoints: vec![ - lime_core::models::route_model::RouteEndpoint { - path: "/v1/messages".to_string(), - protocol: "claude".to_string(), - url: format!("{display_base_url}/v1/messages"), - }, - lime_core::models::route_model::RouteEndpoint { - path: "/v1/chat/completions".to_string(), - protocol: "openai".to_string(), - url: format!("{display_base_url}/v1/chat/completions"), - }, - ], - tags: vec!["默认".to_string()], - enabled: true, - }]; - all_routes.extend(routes); - - let response = RouteListResponse { - base_url: display_base_url, - default_provider, - routes: all_routes, - }; - - Json(response) -} - -/// 带选择器的 Anthropic messages 处理 -async fn anthropic_messages_with_selector( - State(state): State, - Path(selector): Path, - headers: HeaderMap, - Json(request): Json, -) -> Response { - // 使用 Anthropic 格式的认证验证 - if let Err(e) = handlers::verify_api_key_anthropic(&headers, &state.api_key).await { - state.logs.write().await.add( - "warn", - &format!("Unauthorized request to /{selector}/v1/messages"), - ); - return e.into_response(); - } - - state.logs.write().await.add( - "info", - &format!( - "[REQ] POST /{}/v1/messages model={} stream={}", - selector, request.model, request.stream - ), - ); - - // 尝试解析凭证(不降级,指定什么就用什么) - let credential = match &state.db { - Some(db) => { - // 首先尝试按名称查找 - if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &selector) { - Some(cred) - } - // 然后尝试按 UUID 查找 - else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) { - Some(cred) - } - // 最后尝试按 provider 类型选择(不降级) - else if let Ok(Some(cred)) = - state - .pool_service - .select_credential(db, &selector, Some(&request.model)) - { - Some(cred) - } else { - None - } - } - None => None, - }; - - match credential { - Some(cred) => { - state.logs.write().await.add( - "info", - &format!( - "[ROUTE] Using credential: type={} name={:?} uuid={}", - cred.provider_type, - cred.name, - &cred.uuid[..8] - ), - ); - - // 根据凭证类型调用相应的 Provider - // 注意:这里没有 Flow 捕获,因为是通过 selector 路由的请求 - handlers::call_provider_anthropic(&state, &cred, &request, None).await - } - None => { - // 不再回退到默认 provider,直接返回错误 - state.logs.write().await.add( - "error", - &format!( - "[ROUTE] No available credentials for selector '{selector}', refusing to fallback" - ), - ); - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": { - "type": "provider_unavailable", - "message": format!("No available credentials for selector '{}'", selector) - } - })), - ) - .into_response() - } - } -} - -/// 带选择器的 OpenAI chat completions 处理 -async fn chat_completions_with_selector( - State(state): State, - Path(selector): Path, - headers: HeaderMap, - Json(request): Json, -) -> Response { - if let Err(e) = handlers::verify_api_key(&headers, &state.api_key).await { - state.logs.write().await.add( - "warn", - &format!("Unauthorized request to /{selector}/v1/chat/completions"), - ); - return e.into_response(); - } - - state.logs.write().await.add( - "info", - &format!( - "[REQ] POST /{}/v1/chat/completions model={} stream={}", - selector, request.model, request.stream - ), - ); - - // 尝试解析凭证(不降级,指定什么就用什么) - let credential = match &state.db { - Some(db) => { - if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &selector) { - Some(cred) - } else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) { - Some(cred) - } else if let Ok(Some(cred)) = - state - .pool_service - .select_credential(db, &selector, Some(&request.model)) - { - Some(cred) - } else { - None - } - } - None => None, - }; - - match credential { - Some(cred) => { - state.logs.write().await.add( - "info", - &format!( - "[ROUTE] Using credential: type={} name={:?} uuid={}", - cred.provider_type, - cred.name, - &cred.uuid[..8] - ), - ); - - // 注意:这里没有 Flow 捕获,因为是通过 selector 路由的请求 - handlers::call_provider_openai(&state, &cred, &request, None).await - } - None => { - // 不再回退到默认 provider,直接返回错误 - state.logs.write().await.add( - "error", - &format!( - "[ROUTE] No available credentials for selector '{selector}', refusing to fallback" - ), - ); - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": { - "message": format!("No available credentials for selector '{}'", selector), - "type": "provider_unavailable", - "code": "no_credentials" - } - })), - ) - .into_response() - } - } -} - /// 内部 Anthropic messages 处理 (使用默认 Kiro) /// 预留:用于内部直接调用 Kiro API #[allow(dead_code)] diff --git a/src-tauri/crates/services/src/provider_pool_service.rs b/src-tauri/crates/services/src/provider_pool_service.rs index a512685d6..5ce732c2c 100644 --- a/src-tauri/crates/services/src/provider_pool_service.rs +++ b/src-tauri/crates/services/src/provider_pool_service.rs @@ -18,7 +18,6 @@ use lime_core::models::provider_pool_model::{ HealthCheckResult, OAuthStatus, PoolProviderType, PoolStats, ProviderCredential, ProviderPoolOverview, }; -use lime_core::models::route_model::RouteInfo; use lime_providers::providers::antigravity::TokenRefreshError; use lime_providers::providers::kiro::KiroProvider; use reqwest::Client; @@ -159,6 +158,16 @@ impl ProviderPoolService { Ok(credentials.iter().map(|c| c.into()).collect()) } + /// 获取指定 UUID 的凭证 + pub fn get_by_uuid( + &self, + db: &DbConnection, + uuid: &str, + ) -> Result, String> { + let conn = lime_core::database::lock_db(db)?; + ProviderPoolDao::get_by_uuid(&conn, uuid).map_err(|e| e.to_string()) + } + /// 添加凭证 pub fn add_credential( &self, @@ -1712,75 +1721,6 @@ impl ProviderPoolService { } } - /// 根据名称获取凭证 - pub fn get_by_name( - &self, - db: &DbConnection, - name: &str, - ) -> Result, String> { - let conn = lime_core::database::lock_db(db)?; - ProviderPoolDao::get_by_name(&conn, name).map_err(|e| e.to_string()) - } - - /// 根据 UUID 获取凭证 - pub fn get_by_uuid( - &self, - db: &DbConnection, - uuid: &str, - ) -> Result, String> { - let conn = lime_core::database::lock_db(db)?; - ProviderPoolDao::get_by_uuid(&conn, uuid).map_err(|e| e.to_string()) - } - - /// 获取所有可用的路由端点 - pub fn get_available_routes( - &self, - db: &DbConnection, - base_url: &str, - ) -> Result, String> { - let conn = lime_core::database::lock_db(db)?; - let grouped = ProviderPoolDao::get_grouped(&conn).map_err(|e| e.to_string())?; - drop(conn); - - let mut routes = Vec::new(); - - // 为每种 Provider 类型创建路由 - for (provider_type, credentials) in &grouped { - let available: Vec<_> = credentials.iter().filter(|c| c.is_available()).collect(); - if available.is_empty() { - continue; - } - - // Provider 类型路由 (轮询) - let mut route = RouteInfo::new(provider_type.to_string(), provider_type.to_string()); - route.credential_count = available.len(); - route.add_endpoint(base_url, "claude"); - route.add_endpoint(base_url, "openai"); - route.tags.push("轮询".to_string()); - routes.push(route); - } - - // 为每个命名凭证创建路由 - for credentials in grouped.values() { - for cred in credentials { - if let Some(name) = &cred.name { - if cred.is_available() { - let mut route = - RouteInfo::new(name.clone(), cred.provider_type.to_string()); - route.credential_count = 1; - route.enabled = !cred.is_disabled; - route.add_endpoint(base_url, "claude"); - route.add_endpoint(base_url, "openai"); - route.tags.push("指定凭证".to_string()); - routes.push(route); - } - } - } - } - - Ok(routes) - } - /// 获取 OAuth 凭证状态 pub fn get_oauth_status( &self, diff --git a/src-tauri/resources/default-skills/site_search/SKILL.md b/src-tauri/resources/default-skills/site_search/SKILL.md new file mode 100644 index 000000000..d9cdfe92a --- /dev/null +++ b/src-tauri/resources/default-skills/site_search/SKILL.md @@ -0,0 +1,76 @@ +--- +name: site_search +description: 通过站点适配器检索指定站点内容(GitHub、知乎、B站、36Kr、linux.do、什么值得买、Yahoo Finance)。 +allowed-tools: lime_site_run, lime_site_list, lime_site_search, lime_site_info +metadata: + lime_argument_hint: 输入目标站点、查询关键词或标的、返回数量,以及是否要保存到当前内容/项目。 + lime_when_to_use: 用户明确要在某个站点检索、查看热榜、读取报价或问题列表时使用;通用联网调研优先交给 research。 + lime_version: 1.1.0 + lime_execution_mode: prompt + lime_surface: chat + lime_category: research +--- + +你是 Lime 的站点检索助手。 + +## 工作目标 + +在用户已经指向具体站点时,选择合适的站点适配器,返回清晰、可追溯、可继续加工的结果。 + +## 执行规则 + +- 先判断用户是否真的指定了站点或站点语义。 + 未指定站点、只是泛化联网检索时,不要硬走本技能,优先交给 `research`。 +- 不要机械执行 `lime_site_list -> lime_site_search -> lime_site_info -> lime_site_run` 全套链路。 + 已知适配器时,优先直接 `lime_site_info` + `lime_site_run`。 +- 当需求不明确时: + - 用 `lime_site_search` 按站点名、域名或能力词缩小范围 + - 必要时再用 `lime_site_list` 查看全量目录 +- 执行前优先用 `lime_site_info` 确认参数、登录要求和能力边界,不要猜字段名。 +- 对于已经进入本技能的任务,首步不要退回 `WebSearch`、`research`、`webReader` 之类通用检索/阅读工具替代执行。 + 正确主链是先选 adapter,再调用 `lime_site_run`。 +- 如果用户明确要求沉淀结果,可在 `lime_site_run` 中附带 `content_id` / `project_id` / `save_title`。 + 未明确要求时,默认先在对话中返回结果摘要。 +- 某些适配器依赖登录态或已连接浏览器会话。 + 遇到权限受限、需要登录或工具不可用时,直接说明限制和下一步,不要伪造结果。 + +## 站点映射 + +当前内置站点适配器按业务语义可分为: + +- GitHub:`github/search`、`github/issues` +- 知乎:`zhihu/search`、`zhihu/hot` +- B 站:`bilibili/search` +- 36Kr:`36kr/newsflash` +- linux.do:`linux-do/categories`、`linux-do/hot` +- 什么值得买:`smzdm/search` +- Yahoo Finance:`yahoo-finance/quote` + +全量参数、登录提示和域名清单见 `references/adapter-catalog.md`。 + +## 推荐流程 + +1. 识别用户意图 + - 搜索类:仓库、问题、视频、商品 + - 榜单类:热榜、热门话题、快讯 + - 读取类:股票报价、社区分类 +2. 选择最匹配的适配器 + - 仓库搜索优先 `github/search` + - 仓库 issue 列表优先 `github/issues` + - 热榜/热门优先 `zhihu/hot`、`linux-do/hot`、`36kr/newsflash` + - 股票报价优先 `yahoo-finance/quote` +3. 用 `lime_site_info` 校验参数 +4. 用 `lime_site_run` 执行 +5. 如有必要,再补充通用背景检索或额外阅读 +6. 基于结构化结果整理结论并标注来源站点 + +## 输出格式 + +根据场景灵活组织,至少包含: + +- 站点与适配器:本次使用了哪个站点、哪个 adapter +- 结果摘要:找到了什么、数量大概多少 +- 关键结果:标题、链接、摘要、价格/报价/状态等关键字段 +- 限制与下一步:如需要登录、结果不完整、建议切换其他站点 + +如果工具返回了保存路径或写回结果,也要在结尾明确说明。 diff --git a/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md b/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md new file mode 100644 index 000000000..0fddb25a1 --- /dev/null +++ b/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md @@ -0,0 +1,42 @@ +# Site Adapter Catalog + +当前运行时事实源仍然是: + +- `lime_site_list` +- `lime_site_search` +- `lime_site_info` + +本文件只是当前内置 bundled adapters 的速查表,便于 `site_search` 快速选型。 + +## 适配器总览 + +| 适配器 | 域名 | 用途 | 必填参数 | 可选参数 | 登录要求 | +| --- | --- | --- | --- | --- | --- | +| `36kr/newsflash` | www.36kr.com | 采集 36Kr 快讯列表。 | 无 | limit | 无 | +| `bilibili/search` | search.bilibili.com | 按关键词采集 B 站视频搜索结果。 | query | limit | 无 | +| `github/issues` | github.com | 采集指定 GitHub 仓库的 issue 列表。 | repo | query, state, limit | 私有仓库或受限 issue 需要先在浏览器中登录 GitHub。 | +| `github/search` | github.com | 按关键词采集 GitHub 仓库搜索结果。 | query | limit | 若需要访问更完整的搜索结果,请先在浏览器中登录 GitHub。 | +| `linux-do/categories` | linux.do | 读取 linux.do 分类列表。 | 无 | limit | 请先在浏览器中登录 linux.do,再重试该命令。 | +| `linux-do/hot` | linux.do | 读取 linux.do 热门话题。 | 无 | limit, period | 请先在浏览器中登录 linux.do,再重试该命令。 | +| `smzdm/search` | search.smzdm.com | 按关键词采集什么值得买搜索结果。 | query | limit | 无 | +| `yahoo-finance/quote` | finance.yahoo.com | 读取 Yahoo Finance 股票行情摘要。 | symbol | 无 | 无 | +| `zhihu/hot` | www.zhihu.com | 采集知乎热榜问题列表。 | 无 | limit | 请先在浏览器中登录知乎,再重试该命令。 | +| `zhihu/search` | www.zhihu.com | 按关键词采集知乎搜索结果。 | query | limit | 请先在浏览器中登录知乎,再重试该命令。 | + +## 语义映射 + +- 仓库搜索:`github/search` +- 仓库 issue:`github/issues` +- 视频搜索:`bilibili/search` +- 问题搜索:`zhihu/search` +- 热榜/热门:`zhihu/hot`、`linux-do/hot` +- 社区目录:`linux-do/categories` +- 快讯流:`36kr/newsflash` +- 商品搜索:`smzdm/search` +- 行情摘要:`yahoo-finance/quote` + +## 使用提醒 + +- 适配器参数以 `lime_site_info` 返回为准,不要只依赖本文件。 +- 如果用户没有指定站点,只是泛化调研,不应优先走 `site_search`,而应优先走 `research`。 +- 如果用户要求保存结果到当前内容或项目,执行 `lime_site_run` 时再补 `content_id`、`project_id`、`save_title`。 diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 8fca5b8d0..145b8e54b 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -1,7 +1,7 @@ use crate::agent_tools::catalog::{ tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names, ToolCapability, ToolLifecycle, ToolPermissionPlane, ToolSourceKind, ToolSurfaceProfile, - WorkspaceToolSurface, + WorkspaceToolSurface, BROWSER_RUNTIME_TOOL_PREFIX, }; use crate::agent_tools::execution::{ resolve_tool_execution_policy_resolution, ToolExecutionPolicySource, @@ -18,6 +18,55 @@ use lime_core::tool_calling::{ use serde::Serialize; use std::collections::{BTreeSet, HashSet}; +fn extract_harness_object( + request_metadata: Option<&serde_json::Value>, +) -> Option<&serde_json::Map> { + let metadata = request_metadata?; + let object = metadata.as_object()?; + if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) { + return Some(harness); + } + Some(object) +} + +fn extract_harness_nested_object<'a>( + request_metadata: Option<&'a serde_json::Value>, + keys: &[&str], +) -> Option<&'a serde_json::Map> { + let harness = extract_harness_object(request_metadata)?; + keys.iter() + .filter_map(|key| harness.get(*key)) + .find_map(serde_json::Value::as_object) +} + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn should_lock_service_skill_launch_to_site_tools( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["service_skill_launch", "serviceSkillLaunch"], + ) else { + return false; + }; + + let kind = + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string()); + kind == "site_adapter" + && extract_object_string(launch, &["adapter_name", "adapterName"]).is_some() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RuntimeExtensionSourceKind { @@ -212,6 +261,8 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv persisted_policy: persisted_execution_policy.as_ref(), request_metadata: request_metadata.as_ref(), }; + let lock_service_skill_launch_to_site_tools = + should_lock_service_skill_launch_to_site_tools(request_metadata.as_ref()); let mut mcp_servers = mcp_server_names .into_iter() @@ -229,6 +280,9 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv let catalog_tools = tool_catalog_entries_for_surface(surface) .into_iter() + .filter(|entry| { + !lock_service_skill_launch_to_site_tools || entry.name != BROWSER_RUNTIME_TOOL_PREFIX + }) .map(|entry| { let resolution = resolve_tool_execution_policy_resolution(entry.name, execution_policy_input); @@ -251,8 +305,12 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv }) .collect::>(); - let registry_tools = - build_registry_inventory(®istry_definitions, &caller, execution_policy_input); + let registry_tools = build_registry_inventory( + ®istry_definitions, + &caller, + execution_policy_input, + lock_service_skill_launch_to_site_tools, + ); let extension_surfaces = build_extension_surface_inventory( &extension_configs, &visible_extension_tools, @@ -336,9 +394,14 @@ fn build_registry_inventory( definitions: &[ToolDefinition], caller: &str, execution_policy_input: ToolExecutionResolverInput<'_>, + lock_service_skill_launch_to_site_tools: bool, ) -> Vec { let mut result = definitions .iter() + .filter(|definition| { + !lock_service_skill_launch_to_site_tools + || !definition.name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX) + }) .map(|definition| { let metadata = extract_tool_surface_metadata(&definition.name, &definition.input_schema); @@ -930,6 +993,66 @@ mod tests { .any(|name| name.starts_with("mcp__lime-browser__"))); } + #[test] + fn test_build_tool_inventory_hides_browser_compat_tools_for_service_skill_launch() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::browser_assist(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: Some(json!({ + "harness": { + "browser_assist": { + "enabled": true, + "profile_key": "attached-github" + }, + "service_skill_launch": { + "kind": "site_adapter", + "adapter_name": "github/search", + "args": { + "query": "AI Agent" + } + } + } + })), + mcp_server_names: Vec::new(), + mcp_tools: Vec::new(), + registry_definitions: vec![ + definition( + "mcp__lime-browser__browser_navigate", + "browser navigate", + json!({ "type": "object" }), + ), + definition( + "lime_site_run", + "run site adapter", + json!({ "type": "object" }), + ), + ], + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + }); + + assert!(!inventory + .catalog_tools + .iter() + .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); + assert!(!inventory + .registry_tools + .iter() + .any(|entry| entry.name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX))); + assert!(inventory + .catalog_tools + .iter() + .any(|entry| entry.name == "lime_site_run")); + assert!(inventory + .registry_tools + .iter() + .any(|entry| entry.name == "lime_site_run")); + } + #[test] fn test_build_tool_inventory_uses_effective_execution_policy_provenance() { let inventory = build_tool_inventory(AgentToolInventoryBuildInput { diff --git a/src-tauri/src/app/commands/api_test.rs b/src-tauri/src/app/commands/api_test.rs index ffc86aff6..53848af2c 100644 --- a/src-tauri/src/app/commands/api_test.rs +++ b/src-tauri/src/app/commands/api_test.rs @@ -1,20 +1,10 @@ -//! API 测试和兼容性检查命令 +//! API 兼容性与模型查询命令 //! -//! 包含 API 测试、模型列表和兼容性检查命令。 +//! 保留面向开发者与内部工具的只读能力。 use crate::app::types::{AppState, LogState, ProviderType}; use crate::commands::model_registry_cmd::ModelRegistryState; -/// 测试结果 -#[derive(serde::Serialize)] -pub struct TestResult { - pub success: bool, - pub status: u16, - pub body: String, - pub time_ms: u64, - pub response_headers: std::collections::HashMap, -} - /// 模型信息 #[derive(serde::Serialize)] pub struct ModelInfo { @@ -298,85 +288,3 @@ pub async fn get_available_models( }) .collect()) } - -/// 测试 API -#[tauri::command] -pub async fn test_api( - state: tauri::State<'_, AppState>, - method: String, - path: String, - body: Option, - auth: bool, -) -> Result { - let s = state.read().await; - // 使用 status() 获取实际监听的地址(可能与配置不同) - let status = s.status(); - let base_url = format!("http://{}:{}", status.host, status.port); - let api_key = s - .running_api_key - .as_ref() - .unwrap_or(&s.config.server.api_key); - - let client = reqwest::Client::builder() - .no_proxy() - .build() - .map_err(|e| e.to_string())?; - - let url = format!("{base_url}{path}"); - - tracing::info!("Testing API: {} {}", method, url); - - let start = std::time::Instant::now(); - - let mut req = match method.as_str() { - "GET" => client.get(&url), - "POST" => client.post(&url), - _ => return Err("Unsupported method".to_string()), - }; - - req = req.header("Content-Type", "application/json"); - - if auth { - req = req.header("Authorization", format!("Bearer {api_key}")); - } - - if let Some(b) = body { - req = req.body(b); - } - - match req.send().await { - Ok(resp) => { - let status = resp.status().as_u16(); - let response_headers = resp - .headers() - .iter() - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|text| (name.as_str().to_string(), text.to_string())) - }) - .collect::>(); - let body = resp.text().await.unwrap_or_default(); - let time_ms = start.elapsed().as_millis() as u64; - - tracing::info!( - "API test result: status={}, body_len={}", - status, - body.len() - ); - - Ok(TestResult { - success: (200..300).contains(&status), - status, - body, - time_ms, - response_headers, - }) - } - Err(e) => { - tracing::error!("API test error: {}", e); - Err(e.to_string()) - } - } -} diff --git a/src-tauri/src/app/commands/config.rs b/src-tauri/src/app/commands/config.rs index e6bae5e3f..520b1c435 100644 --- a/src-tauri/src/app/commands/config.rs +++ b/src-tauri/src/app/commands/config.rs @@ -27,6 +27,9 @@ pub async fn save_config( config_manager: tauri::State<'_, GlobalConfigManagerState>, config: config::Config, ) -> Result<(), String> { + let mut config = config; + config.normalize_local_server_surface(); + config.normalize_workspace_preferences(); let host = config.server.host.to_lowercase(); tracing::info!( @@ -44,12 +47,6 @@ pub async fn save_config( ); } - // 禁止开启远程管理 - if config.remote_management.allow_remote { - tracing::warn!("[CONFIG] 安全限制:不允许开启远程管理功能"); - return Err("安全限制:不允许开启远程管理功能".to_string()); - } - { let mut s = state.write().await; s.config = config.clone(); @@ -214,7 +211,7 @@ pub async fn set_endpoint_provider( /// 根据 API Key Provider 更新环境变量 /// -/// 当用户在 API Server 页面选择一个 API Key Provider 时调用 +/// 当用户切换本机默认 Provider 的凭证来源时调用 /// 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量 #[tauri::command] pub async fn update_provider_env_vars( diff --git a/src-tauri/src/app/commands/server.rs b/src-tauri/src/app/commands/server.rs index 14200a151..744c216fb 100644 --- a/src-tauri/src/app/commands/server.rs +++ b/src-tauri/src/app/commands/server.rs @@ -1,131 +1,10 @@ -//! 服务器控制命令 +//! 服务器诊断命令 //! -//! 包含服务器启动、停止、状态查询等命令。 +//! 保留仅供开发者与运行时诊断使用的只读查询。 -use crate::app::types::{AppState, LogState}; -use crate::app::TokenCacheServiceState; -use crate::commands::provider_pool_cmd::ProviderPoolServiceState; +use crate::app::types::AppState; use crate::commands::telemetry_cmd::TelemetryState; -use crate::database; -use chrono::{Duration, Utc}; -use lime_infra::telemetry::RequestStatus; use lime_server as server; -use std::collections::HashSet; - -/// 启动服务器 -#[tauri::command] -pub async fn start_server( - state: tauri::State<'_, AppState>, - logs: tauri::State<'_, LogState>, - db: tauri::State<'_, database::DbConnection>, - pool_service: tauri::State<'_, ProviderPoolServiceState>, - token_cache: tauri::State<'_, TokenCacheServiceState>, -) -> Result { - let mut s = state.write().await; - logs.write().await.add("info", "Starting server..."); - s.start( - logs.inner().clone(), - pool_service.0.clone(), - token_cache.0.clone(), - Some(db.inner().clone()), - ) - .await - .map_err(|e| e.to_string())?; - - // 使用 status() 获取实际使用的地址(可能已经自动切换到有效的 IP) - let status = s.status(); - logs.write().await.add( - "info", - &format!("Server started on {}:{}", status.host, status.port), - ); - Ok("Server started".to_string()) -} - -/// 停止服务器 -#[tauri::command] -pub async fn stop_server( - state: tauri::State<'_, AppState>, - logs: tauri::State<'_, LogState>, -) -> Result { - let mut s = state.write().await; - s.stop().await; - logs.write().await.add("info", "Server stopped"); - Ok("Server stopped".to_string()) -} - -/// 获取服务器状态 -#[tauri::command] -pub async fn get_server_status( - state: tauri::State<'_, AppState>, - db: tauri::State<'_, database::DbConnection>, - pool_service: tauri::State<'_, ProviderPoolServiceState>, - telemetry_state: tauri::State<'_, TelemetryState>, -) -> Result { - let s = state.read().await; - let mut status = s.status(); - - // 从遥测系统获取真实请求计数 - let stats = telemetry_state.stats.read(); - let summary = stats.summary(None); - status.requests = summary.total_requests; - - // 最近 1 分钟统计 - let one_minute_ago = Utc::now() - Duration::minutes(1); - let recent_logs: Vec<_> = stats - .get_all() - .into_iter() - .filter(|log| log.timestamp >= one_minute_ago) - .collect(); - - let total_1m = recent_logs.len() as u64; - let error_count_1m = recent_logs - .iter() - .filter(|log| matches!(log.status, RequestStatus::Failed | RequestStatus::Timeout)) - .count() as u64; - status.error_rate_1m = if total_1m == 0 { - 0.0 - } else { - error_count_1m as f64 / total_1m as f64 - }; - - let mut latencies: Vec = recent_logs.iter().map(|log| log.duration_ms).collect(); - latencies.sort_unstable(); - status.p95_latency_ms_1m = if latencies.is_empty() { - None - } else { - let last_index = latencies.len().saturating_sub(1); - let p95_index = (last_index * 95) / 100; - latencies.get(p95_index).copied() - }; - - // 使用凭证健康状态近似熔断状态:统计当前不健康的上游类型数量 - status.open_circuit_count = match pool_service.0.get_all_credential_health(db.inner()) { - Ok(health_list) => { - let unhealthy_provider_count = health_list - .into_iter() - .filter(|item| !item.is_healthy) - .map(|item| item.provider_type) - .collect::>() - .len(); - u32::try_from(unhealthy_provider_count).unwrap_or(u32::MAX) - } - Err(err) => { - tracing::warn!("[SERVER_STATUS] 获取凭证健康状态失败: {}", err); - 0 - } - }; - // 使用 Retry 状态日志作为活跃请求近似值 - status.active_requests = recent_logs - .iter() - .filter(|log| matches!(log.status, RequestStatus::Retrying)) - .count() as u64; - status.capability_routing = s.capability_routing_metrics_store.snapshot(); - status.response_cache = s.response_cache_store.stats(); - status.request_dedup = s.request_dedup_store.stats(); - status.idempotency = s.idempotency_store.stats(); - - Ok(status) -} /// 获取服务器诊断信息(对标 /stats 与 /cache 端点) #[tauri::command] diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 236999290..ef0e2e971 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -16,6 +16,23 @@ use super::commands as app_commands; use super::types::{AppState, TrayManagerState}; const MAIN_WINDOW_LABEL: &str = "main"; +const SKIP_STARTUP_WINDOW_REVEAL_ENV: &str = "LIME_SKIP_STARTUP_WINDOW_REVEAL"; + +fn env_flag_enabled(key: &str) -> bool { + matches!( + std::env::var(key) + .ok() + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1") | Some("true") | Some("yes") | Some("on") + ) +} + +fn should_reveal_main_window_on_startup() -> bool { + !env_flag_enabled(SKIP_STARTUP_WINDOW_REVEAL_ENV) +} fn compiled_updater_public_key() -> Option<&'static str> { option_env!("LIME_UPDATER_PUBLIC_KEY") @@ -161,8 +178,12 @@ pub fn run() { tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args); // 将窗口带到前台 - if let Some(window) = app.get_webview_window("main") { - reveal_main_window(&window); + if should_reveal_main_window_on_startup() { + if let Some(window) = app.get_webview_window("main") { + reveal_main_window(&window); + } + } else { + tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)"); } let deep_link_urls: Vec = args @@ -266,10 +287,14 @@ pub fn run() { // 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。 if let Some(main_window) = app.get_webview_window("main") { - reveal_main_window(&main_window); + if should_reveal_main_window_on_startup() { + reveal_main_window(&main_window); + } else { + tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)"); + } #[cfg(debug_assertions)] - if crate::profiling::should_open_webview_devtools() { + if env_flag_enabled("LIME_OPEN_WEBVIEW_DEVTOOLS") { main_window.open_devtools(); tracing::info!("[Profiling] 已自动打开主窗口 WebView DevTools"); } @@ -756,8 +781,6 @@ pub fn run() { } } // 启动服务器(使用共享的遥测实例) - let server_started; - let server_address; { let mut s = state.write().await; logs.write() @@ -783,28 +806,21 @@ pub fn run() { logs.write() .await .add("info", &format!("[启动] 服务器已启动: {host}:{port}")); - server_started = true; - server_address = format!("{host}:{port}"); } Err(e) => { logs.write() .await .add("error", &format!("[启动] 服务器启动失败: {e}")); - server_started = false; - server_address = String::new(); } } } // 更新托盘状态 - // Requirements 7.1: API 服务器状态变化时更新托盘图标 if let Some(tray_state) = app_handle.try_state::>() { let tray_guard = tray_state.0.read().await; if let Some(tray_manager) = tray_guard.as_ref() { let current_state = tray_manager.get_state().await; - let icon_status = if !server_started { - TrayIconStatus::Stopped - } else if total_credentials > 0 && available_credentials == 0 { + let icon_status = if total_credentials == 0 || available_credentials == 0 { TrayIconStatus::Error } else if available_credentials < total_credentials { TrayIconStatus::Warning @@ -814,8 +830,6 @@ pub fn run() { let snapshot = TrayStateSnapshot { icon_status, - server_running: server_started, - server_address, available_credentials, total_credentials, today_requests: current_state.today_requests, @@ -1038,9 +1052,6 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ // Server commands (from app::commands) - app_commands::start_server, - app_commands::stop_server, - app_commands::get_server_status, app_commands::get_server_diagnostics, // Config commands (from app::commands) app_commands::get_config, @@ -1089,7 +1100,6 @@ pub fn run() { app_commands::report_frontend_crash, app_commands::report_frontend_debug_log, // API test commands (from app::commands) - app_commands::test_api, app_commands::get_available_models, app_commands::check_api_compatibility, // Switch commands @@ -1320,9 +1330,6 @@ pub fn run() { // API Key Provider connection test command commands::api_key_provider_cmd::test_api_key_provider_connection, commands::api_key_provider_cmd::test_api_key_provider_chat, - // Route commands - commands::route_cmd::get_available_routes, - commands::route_cmd::get_route_curl_examples, // Resilience config commands commands::resilience_cmd::get_retry_config, commands::resilience_cmd::update_retry_config, @@ -1360,12 +1367,6 @@ pub fn run() { // Usage commands commands::usage_cmd::get_kiro_usage, // Tray commands - commands::tray_cmd::sync_tray_state, - commands::tray_cmd::update_tray_server_status, - commands::tray_cmd::update_tray_credential_status, - commands::tray_cmd::get_tray_state, - commands::tray_cmd::refresh_tray_menu, - commands::tray_cmd::refresh_tray_with_stats, commands::tray_cmd::sync_tray_model_shortcuts, // Plugin commands commands::plugin_cmd::get_plugin_status, @@ -1479,8 +1480,6 @@ pub fn run() { commands::models_cmd::toggle_model_enabled, commands::models_cmd::add_provider, commands::models_cmd::remove_provider, - // Network commands - commands::network_cmd::get_network_info, // Orchestrator commands commands::orchestrator_cmd::init_orchestrator, commands::orchestrator_cmd::get_orchestrator_config, @@ -1569,9 +1568,11 @@ pub fn run() { commands::browser_connector_cmd::set_browser_connector_install_root_cmd, commands::browser_connector_cmd::set_browser_connector_enabled_cmd, commands::browser_connector_cmd::set_system_connector_enabled_cmd, + commands::browser_connector_cmd::set_browser_action_capability_enabled_cmd, commands::browser_connector_cmd::get_browser_connector_install_status_cmd, commands::browser_connector_cmd::install_browser_connector_extension_cmd, commands::browser_connector_cmd::open_browser_extensions_page_cmd, + commands::browser_connector_cmd::open_browser_remote_debugging_page_cmd, // Browser profile commands commands::browser_profile_cmd::list_browser_profiles_cmd, commands::browser_profile_cmd::save_browser_profile_cmd, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index 367980caa..0eed4b919 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -55,7 +55,7 @@ pub async fn agent_start_process( }; agent_state.init_agent_with_db(&db).await?; - ensure_browser_mcp_tools_registered(agent_state.inner()).await?; + ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?; let base_url = if gateway_running { Some(format!("http://{host}:{port}")) } else { diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 5ff0c0737..4779420e4 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -149,6 +149,8 @@ const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024"; const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url"; const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】"; const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】"; +const SERVICE_SKILL_LAUNCH_PROMPT_MARKER: &str = "【站点技能启动】"; +const SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER: &str = "【站点技能预执行结果】"; const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】"; const LIME_TOOL_METADATA_BEGIN: &str = "[Lime 工具元数据开始]"; const LIME_TOOL_METADATA_END: &str = "[Lime 工具元数据结束]"; @@ -264,6 +266,7 @@ mod reply_runtime; mod request_model_resolution; mod run_metadata; mod runtime_turn; +mod service_skill_launch; mod session_runtime; mod subagent_runtime; pub(crate) mod tool_runtime; @@ -280,7 +283,6 @@ use self::tool_runtime::{ #[cfg(test)] include!("tests.rs"); -pub(crate) use action_runtime::{agent_runtime_delete_session, agent_runtime_respond_action}; #[cfg(test)] pub(crate) use action_runtime::{ build_action_resume_runtime_status, build_runtime_action_user_data, @@ -335,6 +337,8 @@ pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensio pub(crate) use prompt_context::build_team_preference_system_prompt; pub(crate) use prompt_context::{ merge_system_prompt_with_auto_continue, merge_system_prompt_with_elicitation_context, + merge_system_prompt_with_service_skill_launch, + merge_system_prompt_with_service_skill_launch_preload, merge_system_prompt_with_team_preference, }; #[cfg(test)] @@ -360,11 +364,18 @@ pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_execut pub(crate) use runtime_turn::{ resolve_request_web_search_preference_from_sources, resolve_workspace_id_from_sources, }; +#[cfg(test)] +pub(crate) use service_skill_launch::build_service_skill_launch_run_request; +pub(crate) use service_skill_launch::{ + append_service_skill_launch_session_permissions, preload_service_skill_launch_execution, + should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution, +}; pub(crate) use session_runtime::{ delete_runtime_session_internal, persist_session_provider_routing, resolve_recent_preference_from_sources, resolve_session_provider_selector, resolve_session_recent_harness_context, resolve_session_recent_preferences, - resolve_session_recent_team_selection, SessionRecentHarnessContext, + resolve_session_recent_runtime_context, SessionRecentHarnessContext, + SessionRecentRuntimeContext, }; pub(crate) use subagent_runtime::{ agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index 152aeb1f8..5242c143b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -173,6 +173,328 @@ pub(crate) fn merge_system_prompt_with_elicitation_context( } } +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn render_json_inline(value: Option<&serde_json::Value>) -> Option { + let rendered = serde_json::to_string(value?).ok()?; + let normalized = rendered.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } +} + +fn build_service_skill_launch_run_example( + adapter_name: &str, + args: Option<&serde_json::Value>, + profile_key: Option<&str>, + target_id: Option<&str>, + content_id: Option<&str>, + project_id: Option<&str>, + save_title: Option<&str>, +) -> String { + let mut payload = serde_json::Map::new(); + payload.insert( + "adapter_name".to_string(), + serde_json::Value::String(adapter_name.to_string()), + ); + payload.insert( + "args".to_string(), + args.cloned() + .filter(|value| value.is_object()) + .unwrap_or_else(|| serde_json::json!({})), + ); + + if let Some(value) = profile_key.filter(|value| !value.trim().is_empty()) { + payload.insert( + "profile_key".to_string(), + serde_json::Value::String(value.to_string()), + ); + } + if let Some(value) = target_id.filter(|value| !value.trim().is_empty()) { + payload.insert( + "target_id".to_string(), + serde_json::Value::String(value.to_string()), + ); + } + if let Some(value) = content_id.filter(|value| !value.trim().is_empty()) { + payload.insert( + "content_id".to_string(), + serde_json::Value::String(value.to_string()), + ); + } + if let Some(value) = project_id.filter(|value| !value.trim().is_empty()) { + payload.insert( + "project_id".to_string(), + serde_json::Value::String(value.to_string()), + ); + } + if let Some(value) = save_title.filter(|value| !value.trim().is_empty()) { + payload.insert( + "save_title".to_string(), + serde_json::Value::String(value.to_string()), + ); + } + + serde_json::Value::Object(payload).to_string() +} + +fn build_service_skill_launch_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let launch = extract_harness_nested_object( + request_metadata, + &["service_skill_launch", "serviceSkillLaunch"], + )?; + let kind = + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string()); + if kind != "site_adapter" { + return None; + } + + let adapter_name = extract_object_string(launch, &["adapter_name", "adapterName"])?; + let skill_title = extract_object_string(launch, &["skill_title", "skillTitle"]); + let save_mode = extract_object_string(launch, &["save_mode", "saveMode"]) + .unwrap_or_else(|| "project_resource".to_string()); + let save_title = extract_object_string(launch, &["save_title", "saveTitle"]); + let content_id = extract_object_string(launch, &["content_id", "contentId"]); + let project_id = extract_object_string(launch, &["project_id", "projectId"]); + let args_json = render_json_inline(launch.get("args")).unwrap_or_else(|| "{}".to_string()); + let launch_readiness = launch + .get("launch_readiness") + .and_then(serde_json::Value::as_object); + let readiness_status = + launch_readiness.and_then(|value| extract_object_string(value, &["status"])); + let readiness_message = + launch_readiness.and_then(|value| extract_object_string(value, &["message"])); + let readiness_hint = launch_readiness + .and_then(|value| extract_object_string(value, &["report_hint", "reportHint"])); + let launch_profile_key = launch_readiness + .and_then(|value| extract_object_string(value, &["profile_key", "profileKey"])); + let launch_target_id = + launch_readiness.and_then(|value| extract_object_string(value, &["target_id", "targetId"])); + let browser_assist = + extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]); + let browser_profile_key = browser_assist + .and_then(|value| extract_object_string(value, &["profile_key", "profileKey"])); + let resolved_profile_key = launch_profile_key.or(browser_profile_key); + let run_example = build_service_skill_launch_run_example( + &adapter_name, + launch.get("args"), + resolved_profile_key.as_deref(), + launch_target_id.as_deref(), + content_id.as_deref(), + project_id.as_deref(), + save_title.as_deref(), + ); + + let mut lines = vec![ + SERVICE_SKILL_LAUNCH_PROMPT_MARKER.to_string(), + "- 当前回合来自站点技能启动,不要把它当成普通聊天或纯文本分析。".to_string(), + "- 第一步优先调用 lime_site_run,不要先停留在解释、总结或泛化检索。".to_string(), + "- 在 lime_site_run 完成前,不要先用 WebSearch、research、webReader 一类通用检索/阅读工具替代执行。".to_string(), + "- 在 lime_site_run 完成前,不要直接调用 mcp__lime-browser__browser_navigate、mcp__lime-browser__read_page、browser_navigate、browser_run_code、Playwright code 或其他 mcp__lime-browser__* / browser_* 底层浏览器工具。".to_string(), + "- lime_site_run 的参数必须是一个严格 JSON 对象;不要写注释、不要漏引号、不要输出半截对象,也不要把整个 JSON 再包成字符串。".to_string(), + format!("- 当前站点适配器:{adapter_name}。"), + format!("- 当前执行参数(JSON):{args_json}。"), + format!("- 第一工具调用示例(lime_site_run 参数 JSON):{run_example}。"), + format!("- 当前保存模式:{save_mode}。"), + format!( + "- 当前技能标题:{}。", + skill_title.unwrap_or_else(|| "未提供".to_string()) + ), + format!( + "- 当前 project_id:{}。", + project_id.unwrap_or_else(|| "未提供".to_string()) + ), + format!( + "- 当前 content_id:{}。", + content_id.unwrap_or_else(|| "未提供".to_string()) + ), + format!( + "- 当前 save_title:{}。", + save_title.unwrap_or_else(|| "未提供".to_string()) + ), + ]; + + if let Some(status) = readiness_status.as_deref() { + lines.push(format!("- 浏览器会话检测状态:{status}。")); + } + if let Some(message) = readiness_message.as_deref() { + lines.push(format!("- 浏览器会话检测说明:{message}")); + } + if let Some(hint) = readiness_hint.as_deref() { + lines.push(format!("- 浏览器恢复提示:{hint}")); + } + if let Some(profile_key) = resolved_profile_key.as_deref() { + lines.push(format!( + "- 调用 lime_site_run 时必须显式透传 profile_key={profile_key}。" + )); + } + if let Some(target_id) = launch_target_id.as_deref() { + lines.push(format!( + "- 调用 lime_site_run 时必须显式透传 target_id={target_id}。" + )); + } + + lines.push( + "- 如果工具返回 attached_session_required、no_matching_context、登录受限或权限受限,不要伪造成功结果;直接说明当前缺少可执行的浏览器上下文,并要求用户先完成连接、登录或授权。".to_string(), + ); + lines.push( + "- 当前任务已经明确来自技能启动,不要再让用户额外确认“是否继续执行站点技能”。".to_string(), + ); + lines.push( + "- 只有在 lime_site_run 完成后,为了补充背景资料或交叉验证,才允许再决定是否追加 WebSearch 或其他通用检索工具。".to_string(), + ); + + Some(lines.join("\n")) +} + +pub(crate) fn merge_system_prompt_with_service_skill_launch( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(service_skill_prompt) = build_service_skill_launch_system_prompt(request_metadata) + else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(service_skill_prompt) + } else { + Some(format!("{base}\n\n{service_skill_prompt}")) + } + } + None => Some(service_skill_prompt), + } +} + +fn truncate_prompt_text(value: String, max_chars: usize) -> String { + let total_chars = value.chars().count(); + if total_chars <= max_chars { + return value; + } + + let truncated = value.chars().take(max_chars).collect::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +fn build_service_skill_launch_preload_prompt( + execution: &ServiceSkillLaunchPreloadExecution, +) -> String { + let request_summary = serde_json::json!({ + "adapter_name": execution.request.adapter_name.clone(), + "args": execution.request.args.clone(), + "profile_key": execution.request.profile_key.clone(), + "target_id": execution.request.target_id.clone(), + "timeout_ms": execution.request.timeout_ms, + "content_id": execution.request.content_id.clone(), + "project_id": execution.request.project_id.clone(), + "save_title": execution.request.save_title.clone(), + "require_attached_session": execution.request.require_attached_session, + "skill_title": execution.request.skill_title.clone(), + }); + let request_json = truncate_prompt_text( + serde_json::to_string(&request_summary).unwrap_or_else(|_| "{}".to_string()), + 4_000, + ); + let result_summary = serde_json::json!({ + "ok": execution.result.ok, + "adapter": execution.result.adapter.clone(), + "profile_key": execution.result.profile_key.clone(), + "session_id": execution.result.session_id.clone(), + "target_id": execution.result.target_id.clone(), + "entry_url": execution.result.entry_url.clone(), + "source_url": execution.result.source_url.clone(), + "data": execution.result.data.clone(), + "error_code": execution.result.error_code.clone(), + "error_message": execution.result.error_message.clone(), + "auth_hint": execution.result.auth_hint.clone(), + "report_hint": execution.result.report_hint.clone(), + "saved_content": execution.result.saved_content.clone(), + "saved_project_id": execution.result.saved_project_id.clone(), + }); + let result_json = truncate_prompt_text( + serde_json::to_string(&result_summary).unwrap_or_else(|_| "{}".to_string()), + 12_000, + ); + let adapter_name = execution + .adapter + .as_ref() + .map(|adapter| adapter.name.clone()) + .unwrap_or_else(|| execution.request.adapter_name.clone()); + let adapter_description = execution + .adapter + .as_ref() + .map(|adapter| adapter.description.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "未提供".to_string()); + let execution_requirement = if execution.result.ok { + "- 站点技能已经在系统侧预执行成功。请直接基于下面的结构化结果完成答复,不要再次调用 lime_site_run,也不要回退到底层浏览器兼容工具。".to_string() + } else { + "- 站点技能已经在系统侧预执行,但执行失败。请直接根据失败结果向用户说明缺少的浏览器上下文、登录态或权限,不要伪造采集成功,也不要再次尝试调用 lime_site_run / mcp__lime-browser__* / browser_*。".to_string() + }; + let failure_contract = match execution.result.error_code.as_deref() { + Some("attached_session_required") => { + "- 当前失败类型为 attached_session_required:必须明确告诉用户先连接并附着到目标站点页面,再重试当前技能。".to_string() + } + Some("no_matching_context") => { + "- 当前失败类型为 no_matching_context:必须告诉用户先把浏览器切到目标站点或正确页面,再重试当前技能。".to_string() + } + _ => "- 如果用户追问失败原因,优先引用 error_code / error_message / report_hint / auth_hint,而不是自行编造执行细节。".to_string(), + }; + + [ + SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER.to_string(), + execution_requirement, + failure_contract, + format!("- 当前适配器:{adapter_name}。"), + format!("- 当前适配器说明:{adapter_description}"), + format!("- 已预执行请求(JSON):{request_json}。"), + format!("- 已预执行结果(JSON):{result_json}。"), + "- 除非用户明确要求“重跑一次 / 换关键词 / 换筛选条件 / 重新抓取”,否则本回合不要再次调用任何站点执行工具。".to_string(), + ] + .join("\n") +} + +pub(crate) fn merge_system_prompt_with_service_skill_launch_preload( + base_prompt: Option, + execution: Option<&ServiceSkillLaunchPreloadExecution>, +) -> Option { + let Some(execution) = execution else { + return base_prompt; + }; + let preload_prompt = build_service_skill_launch_preload_prompt(execution); + + match base_prompt { + Some(base) => { + if base.contains(SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(preload_prompt) + } else { + Some(format!("{base}\n\n{preload_prompt}")) + } + } + None => Some(preload_prompt), + } +} + fn render_team_roles(role_items: &[serde_json::Value]) -> Vec { role_items .iter() diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 5673c8259..b1aaab4c0 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -327,12 +327,15 @@ async fn execute_aster_chat_request( } } ensure_tool_search_tool_registered(state).await?; + let request_session_id = request.session_id.clone(); + let mcp_runtime_prepare_future = async { + let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; + let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; + (start_fail, mcp_fail) + }; + let session_recent_runtime_context_future = + resolve_session_recent_runtime_context(&request_session_id); - if let Some(resolved_provider_config) = - resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request).await? - { - request.provider_config = Some(resolved_provider_config); - } let should_resolve_session_recent_harness_context = extract_harness_string( request.metadata.as_ref(), &["theme", "harness_theme", "harnessTheme"], @@ -348,11 +351,22 @@ async fn execute_aster_chat_request( .is_none() || extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"]) .is_none(); - let session_recent_harness_context = if should_resolve_session_recent_harness_context { - resolve_session_recent_harness_context(&request.session_id).await? - } else { - SessionRecentHarnessContext::default() + let provider_config_future = + resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request); + let session_recent_harness_context_future = async { + if should_resolve_session_recent_harness_context { + resolve_session_recent_harness_context(&request.session_id).await + } else { + Ok(SessionRecentHarnessContext::default()) + } }; + let (resolved_provider_config, session_recent_harness_context) = tokio::try_join!( + provider_config_future, + session_recent_harness_context_future + )?; + if let Some(resolved_provider_config) = resolved_provider_config { + request.provider_config = Some(resolved_provider_config); + } normalize_runtime_turn_request_metadata( &mut request, session_recent_harness_context.theme.as_deref(), @@ -464,16 +478,17 @@ async fn execute_aster_chat_request( session_state_snapshot.with_working_dir(Some(workspace_root.clone())); } - // 启动并注入 MCP extensions 到 Aster Agent - let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; + let ((start_fail, mcp_fail), session_recent_runtime_context) = tokio::join!( + mcp_runtime_prepare_future, + session_recent_runtime_context_future + ); + if start_fail > 0 { tracing::warn!( "[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整", start_fail ); } - - let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; if mcp_fail > 0 { tracing::warn!( "[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具", @@ -481,8 +496,10 @@ async fn execute_aster_chat_request( ); } - let session_recent_preferences = resolve_session_recent_preferences(session_id).await?; - let session_recent_team_selection = resolve_session_recent_team_selection(session_id).await?; + let SessionRecentRuntimeContext { + preferences: session_recent_preferences, + team_selection: session_recent_team_selection, + } = session_recent_runtime_context?; let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); let resolved_request_web_search = resolve_request_web_search_preference_from_sources( @@ -639,10 +656,19 @@ async fn execute_aster_chat_request( prompt_with_artifact.clone(), ); - let prompt_with_elicitation = merge_system_prompt_with_elicitation_context( + let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch( prompt_with_artifact, request.metadata.as_ref(), ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::ServiceSkillLaunch, + prompt_with_service_skill_launch.clone(), + ); + + let prompt_with_elicitation = merge_system_prompt_with_elicitation_context( + prompt_with_service_skill_launch, + request.metadata.as_ref(), + ); turn_input_builder.apply_prompt_stage( TurnPromptAugmentationStageKind::Elicitation, prompt_with_elicitation.clone(), @@ -863,6 +889,18 @@ async fn execute_aster_chat_request( let auto_continue_metadata = auto_continue_config.clone(); let request_metadata = request.metadata.clone(); sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await; + let service_skill_preload = + preload_service_skill_launch_execution(db, request_metadata.as_ref()) + .await + .map_err(|error| format!("站点技能预执行失败: {error}"))?; + let system_prompt = merge_system_prompt_with_service_skill_launch_preload( + system_prompt, + service_skill_preload.as_ref(), + ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::ServiceSkillLaunchPreload, + system_prompt.clone(), + ); let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref()); let run_observation = Arc::new(Mutex::new(ChatRunObservation::default())); let run_observation_for_finalize = run_observation.clone(); @@ -943,6 +981,22 @@ async fn execute_aster_chat_request( if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) { run_start_metadata.insert("turn_input".to_string(), turn_input_value); } + if let Some(preload) = service_skill_preload.as_ref() { + run_start_metadata.insert( + "service_skill_launch_preload".to_string(), + serde_json::json!({ + "executed": true, + "adapter_name": preload.request.adapter_name, + "ok": preload.result.ok, + "error_code": preload.result.error_code, + "saved_content_id": preload + .result + .saved_content + .as_ref() + .map(|content| content.content_id.clone()), + }), + ); + } let run_start_metadata_for_finalize = run_start_metadata.clone(); let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( db.clone(), @@ -2093,6 +2147,90 @@ mod tests { ); } + #[test] + fn service_skill_launch_stage_should_preserve_simple_user_message_and_force_site_run_first() { + let user_message = "请帮我使用 GitHub 查一下 AI Agent 项目"; + let metadata = json!({ + "harness": { + "browser_assist": { + "enabled": true, + "profile_key": "attached-github", + }, + "service_skill_launch": { + "kind": "site_adapter", + "skill_title": "GitHub 仓库线索检索", + "adapter_name": "github/search", + "args": { + "query": "AI Agent", + "limit": 10 + }, + "save_mode": "current_content", + "content_id": "content-1", + "project_id": "project-1", + "launch_readiness": { + "status": "ready", + "message": "已检测到 github.com 的真实浏览器页面。", + "target_id": "tab-github" + } + } + } + }); + + let prompt_with_web_search = + Some("基础系统提示\n- 如果需要可使用 WebSearch 补充信息。".to_string()); + let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch( + prompt_with_web_search.clone(), + Some(&metadata), + ) + .expect("service skill prompt"); + + let mut turn_input_builder = + TurnInputEnvelopeBuilder::new("session-service-skill", "workspace-service-skill"); + turn_input_builder + .set_base_system_prompt( + TurnSystemPromptSource::Frontend, + Some("基础系统提示".to_string()), + ) + .set_turn_context_metadata_from_value(Some(&metadata)) + .set_effective_user_message(user_message) + .apply_prompt_stage( + TurnPromptAugmentationStageKind::WebSearch, + prompt_with_web_search, + ) + .apply_prompt_stage( + TurnPromptAugmentationStageKind::ServiceSkillLaunch, + Some(prompt_with_service_skill_launch.clone()), + ); + + let envelope = turn_input_builder.build(); + let diagnostics = envelope.diagnostics_snapshot(); + let final_prompt = envelope.system_prompt().expect("final prompt"); + let service_skill_stage = diagnostics + .prompt_augmentation_stages + .iter() + .find(|stage| stage.stage == TurnPromptAugmentationStageKind::ServiceSkillLaunch) + .expect("service skill stage"); + + assert_eq!( + diagnostics.effective_user_message_len, + user_message.chars().count() + ); + assert!(diagnostics.has_turn_context_metadata); + assert!(diagnostics + .turn_context_metadata_keys + .contains(&"harness".to_string())); + assert!(service_skill_stage.changed); + assert!(final_prompt.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER)); + assert!(final_prompt.contains("第一步优先调用 lime_site_run")); + assert!(final_prompt.contains("不要先用 WebSearch、research、webReader")); + assert!(final_prompt.contains("不要直接调用 mcp__lime-browser__browser_navigate")); + assert!(final_prompt.contains("第一工具调用示例(lime_site_run 参数 JSON)")); + assert!(final_prompt.contains("profile_key=attached-github")); + assert!(final_prompt.contains("target_id=tab-github")); + assert!(final_prompt.contains("\"adapter_name\":\"github/search\"")); + assert!(final_prompt.contains("attached_session_required、no_matching_context")); + } + #[test] fn normalize_runtime_turn_request_metadata_should_backfill_content_id_from_session_runtime() { let mut request = AsterChatRequest { diff --git a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs new file mode 100644 index 000000000..e36abc7d8 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs @@ -0,0 +1,178 @@ +use super::*; +use crate::services::site_capability_service::{ + get_site_adapter, run_site_adapter_with_optional_save, RunSiteAdapterRequest, + SiteAdapterDefinition, SiteAdapterRunResult, +}; + +const SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS: &[&str] = &[ + "mcp__lime-browser__*", + "browser_*", + "mcp__playwright__*", + "playwright*", +]; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ServiceSkillLaunchSiteAdapterContext { + pub(crate) adapter_name: String, + pub(crate) args: serde_json::Value, + pub(crate) profile_key: Option, + pub(crate) target_id: Option, + pub(crate) content_id: Option, + pub(crate) project_id: Option, + pub(crate) save_title: Option, + pub(crate) skill_title: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ServiceSkillLaunchPreloadExecution { + pub(crate) request: RunSiteAdapterRequest, + pub(crate) adapter: Option, + pub(crate) result: SiteAdapterRunResult, +} + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn normalized_optional_object( + value: Option<&serde_json::Value>, +) -> Option<&serde_json::Map> { + value.and_then(serde_json::Value::as_object) +} + +pub(crate) fn extract_service_skill_launch_site_adapter_context( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let launch = extract_harness_nested_object( + request_metadata, + &["service_skill_launch", "serviceSkillLaunch"], + )?; + let kind = + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string()); + if kind != "site_adapter" { + return None; + } + + let adapter_name = extract_object_string(launch, &["adapter_name", "adapterName"])?; + let launch_readiness = normalized_optional_object(launch.get("launch_readiness")); + let browser_assist = + extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]); + let profile_key = launch_readiness + .and_then(|value| extract_object_string(value, &["profile_key", "profileKey"])) + .or_else(|| { + browser_assist + .and_then(|value| extract_object_string(value, &["profile_key", "profileKey"])) + }); + let target_id = + launch_readiness.and_then(|value| extract_object_string(value, &["target_id", "targetId"])); + + Some(ServiceSkillLaunchSiteAdapterContext { + adapter_name, + args: launch + .get("args") + .cloned() + .filter(|value| value.is_object()) + .unwrap_or_else(|| serde_json::json!({})), + profile_key, + target_id, + content_id: extract_object_string(launch, &["content_id", "contentId"]), + project_id: extract_object_string(launch, &["project_id", "projectId"]), + save_title: extract_object_string(launch, &["save_title", "saveTitle"]), + skill_title: extract_object_string(launch, &["skill_title", "skillTitle"]), + }) +} + +pub(crate) fn should_lock_service_skill_launch_to_site_tools( + request_metadata: Option<&serde_json::Value>, +) -> bool { + extract_service_skill_launch_site_adapter_context(request_metadata).is_some() +} + +pub(crate) fn service_skill_launch_browser_deny_patterns() -> &'static [&'static str] { + SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS +} + +pub(crate) fn build_service_skill_launch_run_request( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let context = extract_service_skill_launch_site_adapter_context(request_metadata)?; + Some(RunSiteAdapterRequest { + adapter_name: context.adapter_name, + args: context.args, + profile_key: context.profile_key, + target_id: context.target_id, + timeout_ms: None, + content_id: context.content_id, + project_id: context.project_id, + save_title: context.save_title, + require_attached_session: Some(true), + skill_title: context.skill_title, + }) +} + +pub(crate) async fn preload_service_skill_launch_execution( + db: &DbConnection, + request_metadata: Option<&serde_json::Value>, +) -> Result, String> { + let Some(request) = build_service_skill_launch_run_request(request_metadata) else { + return Ok(None); + }; + + let adapter = get_site_adapter(&request.adapter_name); + let result = run_site_adapter_with_optional_save(db, request.clone()).await; + + Ok(Some(ServiceSkillLaunchPreloadExecution { + request, + adapter, + result, + })) +} + +pub(crate) fn append_service_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_service_skill_launch_to_site_tools(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前站点技能启动回合生效".to_string()), + }] + }; + + for pattern in service_skill_launch_browser_deny_patterns() { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1250, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "站点技能启动回合已锁定为 site adapter 执行,禁止直接回退到底层浏览器兼容工具" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs index fb72d8a23..8c17ce42a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -49,6 +49,12 @@ pub(crate) struct SessionRecentHarnessContext { pub(crate) content_id: Option, } +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionRecentRuntimeContext { + pub(crate) preferences: Option, + pub(crate) team_selection: Option, +} + pub(crate) async fn persist_session_provider_routing( session_id: &str, provider_selector: &str, @@ -69,32 +75,39 @@ pub(crate) fn resolve_session_provider_selector( SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector) } +fn build_session_recent_runtime_context( + session_id: &str, + session: &aster::session::Session, +) -> SessionRecentRuntimeContext { + let runtime = lime_agent::build_session_execution_runtime( + session_id, + Some(session), + None, + None, + resolve_session_provider_selector(session), + ); + + SessionRecentRuntimeContext { + preferences: runtime + .as_ref() + .and_then(|value| value.recent_preferences.clone()), + team_selection: runtime.and_then(|value| value.recent_team_selection), + } +} + +pub(crate) async fn resolve_session_recent_runtime_context( + session_id: &str, +) -> Result { + let session = read_session(session_id, false, "读取会话 recent runtime 上下文失败").await?; + Ok(build_session_recent_runtime_context(session_id, &session)) +} + pub(crate) async fn resolve_session_recent_preferences( session_id: &str, ) -> Result, String> { - let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?; - Ok(lime_agent::build_session_execution_runtime( - session_id, - Some(&session), - None, - None, - resolve_session_provider_selector(&session), - ) - .and_then(|runtime| runtime.recent_preferences)) -} - -pub(crate) async fn resolve_session_recent_team_selection( - session_id: &str, -) -> Result, String> { - let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?; - Ok(lime_agent::build_session_execution_runtime( - session_id, - Some(&session), - None, - None, - resolve_session_provider_selector(&session), - ) - .and_then(|runtime| runtime.recent_team_selection)) + Ok(resolve_session_recent_runtime_context(session_id) + .await? + .preferences) } pub(crate) async fn resolve_session_recent_harness_context( diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 2a187afab..b8b810fda 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -3,6 +3,9 @@ mod tests { use super::*; use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope; use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope; + use crate::services::site_capability_service::{ + RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult, + }; use async_trait::async_trait; use lime_agent::request_tool_policy::resolve_request_tool_policy; use lime_agent::AgentEvent as RuntimeAgentEvent; @@ -448,11 +451,7 @@ mod tests { }; assert_eq!( - LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), - None - ); - assert_eq!( - LimeBrowserMcpTool::resolve_backend("javascript_tool", ¶ms, Some(&session_hint)), + LimeBrowserMcpTool::resolve_backend("drag", ¶ms, Some(&session_hint)), None ); } @@ -471,10 +470,56 @@ mod tests { LimeBrowserMcpTool::resolve_backend("navigate", ¶ms, Some(&session_hint)), Some(BrowserBackendType::CdpDirect) ); + assert_eq!( + LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); assert_eq!( LimeBrowserMcpTool::resolve_backend("read_page", ¶ms, Some(&session_hint)), Some(BrowserBackendType::CdpDirect) ); + assert_eq!( + LimeBrowserMcpTool::resolve_backend("javascript_tool", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + assert_eq!( + LimeBrowserMcpTool::resolve_backend("computer", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + } + + #[test] + fn test_should_not_auto_launch_managed_browser_for_existing_session_profile() { + let session_hint = BrowserAssistRuntimeHint { + profile_key: "attached-xhs".to_string(), + preferred_backend: Some(BrowserBackendType::LimeExtensionBridge), + auto_launch: true, + launch_url: None, + }; + + assert!(!LimeBrowserMcpTool::should_auto_launch_managed_browser( + Some(BrowserBackendType::LimeExtensionBridge), + Some(&session_hint), + Some(lime_core::database::dao::browser_profile::BrowserProfileTransportKind::ExistingSession), + )); + } + + #[test] + fn test_should_keep_managed_auto_launch_for_managed_cdp_profile() { + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: Some("https://www.google.com".to_string()), + }; + + assert!(LimeBrowserMcpTool::should_auto_launch_managed_browser( + Some(BrowserBackendType::CdpDirect), + Some(&session_hint), + Some( + lime_core::database::dao::browser_profile::BrowserProfileTransportKind::ManagedCdp + ), + )); } #[test] @@ -543,6 +588,81 @@ mod tests { assert_eq!(deny_rule.conditions, allow_rule.conditions); } + #[test] + fn test_build_service_skill_launch_run_request_requires_attached_session() { + let metadata = serde_json::json!({ + "harness": { + "browser_assist": { + "enabled": true, + "profile_key": "attached-github" + }, + "service_skill_launch": { + "kind": "site_adapter", + "skill_title": "GitHub 仓库线索检索", + "adapter_name": "github/search", + "args": { + "query": "AI Agent", + "limit": 10 + }, + "content_id": "content-1", + "project_id": "project-1", + "save_title": "AI Agent GitHub 结果", + "launch_readiness": { + "status": "ready", + "target_id": "tab-github" + } + } + } + }); + + let request = build_service_skill_launch_run_request(Some(&metadata)) + .expect("should build run request"); + + assert_eq!(request.adapter_name, "github/search"); + assert_eq!(request.profile_key.as_deref(), Some("attached-github")); + assert_eq!(request.target_id.as_deref(), Some("tab-github")); + assert_eq!(request.require_attached_session, Some(true)); + assert_eq!(request.skill_title.as_deref(), Some("GitHub 仓库线索检索")); + } + + #[test] + fn test_append_service_skill_launch_session_permissions_blocks_browser_compat_tools() { + let metadata = serde_json::json!({ + "harness": { + "browser_assist": { + "enabled": true, + "profile_key": "attached-github" + }, + "service_skill_launch": { + "kind": "site_adapter", + "adapter_name": "github/search", + "args": { + "query": "AI Agent" + } + } + } + }); + let mut permissions = Vec::new(); + + append_service_skill_launch_session_permissions( + &mut permissions, + "session-service-skill-1", + Some(&metadata), + ); + + let deny_rule = permissions + .iter() + .find(|permission| permission.tool == "mcp__lime-browser__*") + .expect("should add browser compat deny rule"); + assert!(!deny_rule.allowed); + assert_eq!(deny_rule.priority, 1250); + assert_eq!(deny_rule.conditions.len(), 1); + assert_eq!( + deny_rule.conditions[0].value, + serde_json::json!("session-service-skill-1") + ); + } + #[test] fn test_agent_runtime_submit_turn_request_maps_to_aster_chat_request() { let json = r#"{ @@ -1708,6 +1828,235 @@ mod tests { assert!(merged.contains("3")); } + #[test] + fn test_merge_system_prompt_with_service_skill_launch_appends_prompt() { + let metadata = serde_json::json!({ + "harness": { + "browser_assist": { + "enabled": true, + "profile_key": "attached-github", + }, + "service_skill_launch": { + "kind": "site_adapter", + "skill_title": "GitHub 仓库线索检索", + "adapter_name": "github/search", + "args": { + "query": "AI Agent", + "limit": 10 + }, + "save_mode": "current_content", + "content_id": "content-1", + "project_id": "project-1", + "launch_readiness": { + "status": "ready", + "message": "已检测到 github.com 的真实浏览器页面。", + "target_id": "tab-github" + } + } + } + }); + + let merged = merge_system_prompt_with_service_skill_launch( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER)); + assert!(merged.contains("github/search")); + assert!(merged.contains("\"query\":\"AI Agent\"")); + assert!(merged.contains("profile_key=attached-github")); + assert!(merged.contains("target_id=tab-github")); + assert!(merged.contains("mcp__lime-browser__browser_navigate")); + assert!(merged.contains("严格 JSON 对象")); + assert!(merged.contains("\"adapter_name\":\"github/search\"")); + assert!(merged.contains("不要再让用户额外确认")); + } + + #[test] + fn test_merge_system_prompt_with_service_skill_launch_skips_duplicate_marker() { + let metadata = serde_json::json!({ + "harness": { + "service_skill_launch": { + "kind": "site_adapter", + "adapter_name": "github/search", + "args": { + "query": "AI Agent" + } + } + } + }); + let base = Some(format!( + "{SERVICE_SKILL_LAUNCH_PROMPT_MARKER}\n已有站点技能上下文" + )); + + let merged = merge_system_prompt_with_service_skill_launch(base.clone(), Some(&metadata)); + + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_service_skill_launch_includes_missing_session_failure_contract( + ) { + let metadata = serde_json::json!({ + "harness": { + "service_skill_launch": { + "kind": "site_adapter", + "skill_title": "GitHub 仓库线索检索", + "adapter_name": "github/search", + "args": { + "query": "AI Agent" + }, + "launch_readiness": { + "status": "attached_session_required", + "message": "当前缺少已附着的 GitHub 浏览器上下文。", + "report_hint": "请先连接并停留在 github.com。" + } + } + } + }); + + let merged = merge_system_prompt_with_service_skill_launch( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!( + merged.contains("attached_session_required、no_matching_context、登录受限或权限受限") + ); + assert!(merged.contains("当前缺少可执行的浏览器上下文")); + assert!(merged.contains("不要再让用户额外确认")); + assert!(merged.contains("请先连接并停留在 github.com。")); + } + + #[test] + fn test_merge_system_prompt_with_service_skill_launch_preload_appends_result_context() { + let execution = ServiceSkillLaunchPreloadExecution { + request: RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({ + "query": "AI Agent", + "limit": 10 + }), + profile_key: Some("attached-github".to_string()), + target_id: Some("tab-github".to_string()), + timeout_ms: None, + content_id: Some("content-1".to_string()), + project_id: Some("project-1".to_string()), + save_title: Some("AI Agent GitHub 结果".to_string()), + require_attached_session: Some(true), + skill_title: Some("GitHub 仓库线索检索".to_string()), + }, + adapter: Some(SiteAdapterDefinition { + name: "github/search".to_string(), + domain: "github.com".to_string(), + description: "按关键词采集 GitHub 仓库搜索结果。".to_string(), + read_only: true, + capabilities: vec!["search".to_string()], + input_schema: serde_json::json!({}), + example_args: serde_json::json!({"query":"mcp","limit":5}), + example: "github/search {\"query\":\"mcp\"}".to_string(), + auth_hint: Some("请先登录 GitHub。".to_string()), + source_kind: Some("bundled".to_string()), + source_version: Some("2026-03-25".to_string()), + }), + result: SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "attached-github".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("tab-github".to_string()), + entry_url: "https://github.com/search?q=AI%20Agent&type=repositories".to_string(), + source_url: Some( + "https://github.com/search?q=AI%20Agent&type=repositories".to_string(), + ), + data: Some(serde_json::json!({ + "items": [ + {"title": "microsoft/autogen"} + ] + })), + error_code: None, + error_message: None, + auth_hint: None, + report_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }, + }; + + let merged = merge_system_prompt_with_service_skill_launch_preload( + Some("你是助手".to_string()), + Some(&execution), + ) + .expect("should contain preload prompt"); + + assert!(merged.contains(SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER)); + assert!(merged.contains("系统侧预执行成功")); + assert!(merged.contains("不要再次调用 lime_site_run")); + assert!(merged.contains("microsoft/autogen")); + assert!(merged.contains("\"require_attached_session\":true")); + } + + #[test] + fn test_merge_system_prompt_with_service_skill_launch_preload_handles_missing_context_failure() + { + let execution = ServiceSkillLaunchPreloadExecution { + request: RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({ + "query": "AI Agent" + }), + profile_key: Some("attached-github".to_string()), + target_id: None, + timeout_ms: None, + content_id: None, + project_id: None, + save_title: None, + require_attached_session: Some(true), + skill_title: Some("GitHub 仓库线索检索".to_string()), + }, + adapter: None, + result: SiteAdapterRunResult { + ok: false, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "attached-github".to_string(), + session_id: None, + target_id: None, + entry_url: "https://github.com/search?q=AI%20Agent&type=repositories".to_string(), + source_url: None, + data: None, + error_code: Some("attached_session_required".to_string()), + error_message: Some("当前缺少已附着的 GitHub 浏览器上下文。".to_string()), + auth_hint: None, + report_hint: Some("请先连接并停留在 github.com。".to_string()), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }, + }; + + let merged = merge_system_prompt_with_service_skill_launch_preload( + Some("你是助手".to_string()), + Some(&execution), + ) + .expect("should contain preload prompt"); + + assert!(merged.contains("attached_session_required")); + assert!(merged.contains("先连接并附着到目标站点页面")); + assert!(merged.contains("不要再次尝试调用 lime_site_run")); + assert!(merged.contains("请先连接并停留在 github.com。")); + } + #[test] fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() { let error = ReplyAttemptError { @@ -1826,50 +2175,62 @@ mod tests { #[test] fn test_build_team_preference_system_prompt_requires_subagent_mode() { - let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ - "harness": { - "subagent_mode_enabled": true, - "preferred_team_preset_id": "code-triage-team", - } - })), None, true) + let prompt = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "preferred_team_preset_id": "code-triage-team", + } + })), + None, + true, + ) .expect("team prompt should exist"); assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER)); assert!(prompt.contains("代码排障团队")); assert!(prompt.contains("spawn_agent")); - let disabled = build_team_preference_system_prompt(Some(&serde_json::json!({ - "harness": { - "subagent_mode_enabled": false, - "preferred_team_preset_id": "code-triage-team", - } - })), None, false); + let disabled = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": false, + "preferred_team_preset_id": "code-triage-team", + } + })), + None, + false, + ); assert!(disabled.is_none()); } #[test] fn test_build_team_preference_system_prompt_renders_selected_team_details() { - let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ - "harness": { - "subagent_mode_enabled": true, - "selected_team_source": "custom", - "selected_team_label": "前端联调团队", - "selected_team_summary": "分析、实现、验证三段式推进。", - "selected_team_roles": [ - { - "label": "分析", - "summary": "负责定位问题与影响范围。", - "profile_id": "code-explorer", - "role_key": "explorer", - "skill_ids": ["repo-exploration"] - }, - { - "label": "执行", - "summary": "负责提交实现与说明改动点。" - } - ] - } - })), None, true) + let prompt = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "selected_team_source": "custom", + "selected_team_label": "前端联调团队", + "selected_team_summary": "分析、实现、验证三段式推进。", + "selected_team_roles": [ + { + "label": "分析", + "summary": "负责定位问题与影响范围。", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["repo-exploration"] + }, + { + "label": "执行", + "summary": "负责提交实现与说明改动点。" + } + ] + } + })), + None, + true, + ) .expect("team prompt should exist"); assert!(prompt.contains("前端联调团队")); @@ -1884,22 +2245,26 @@ mod tests { #[test] fn test_build_team_preference_system_prompt_emphasizes_parent_coordination() { - let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ - "harness": { - "subagent_mode_enabled": true, - "selected_team_label": "当前调试 Team", - "selected_team_roles": [ - { - "id": "runtime-explorer", - "label": "分析", - "summary": "负责定位问题。", - "profile_id": "code-explorer", - "role_key": "explorer", - "skill_ids": ["repo-exploration"] - } - ] - } - })), None, true) + let prompt = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "selected_team_label": "当前调试 Team", + "selected_team_roles": [ + { + "id": "runtime-explorer", + "label": "分析", + "summary": "负责定位问题。", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["repo-exploration"] + } + ] + } + })), + None, + true, + ) .expect("team prompt should exist"); assert!(prompt.contains("当前调试 Team")); @@ -1965,8 +2330,8 @@ mod tests { } #[test] - fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection() - { + fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection( + ) { let prompt = build_team_preference_system_prompt( Some(&serde_json::json!({ "harness": { diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index ea98223f1..9282dba39 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -74,7 +74,7 @@ fn sync_workspace_mode_native_tool_surface( config_manager: Arc, ) { if surface.browser_assist { - browser_tools::register_browser_mcp_tools_to_registry(registry); + browser_tools::register_browser_mcp_tools_to_registry(registry, db.clone()); site_tools::register_site_tools_to_registry(registry, db.clone()); } else { browser_tools::unregister_browser_mcp_tools_from_registry(registry); @@ -123,6 +123,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions( persisted_policy: Some(¤t_config.agent.tool_execution), request_metadata, }; + let lock_service_skill_launch_to_site_tools = + should_lock_service_skill_launch_to_site_tools(request_metadata); let tool_surface = WorkspaceToolSurface { creator: runtime_chat_mode == RuntimeChatMode::Creator, browser_assist: is_browser_assist_enabled(request_metadata), @@ -162,7 +164,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( execution_policy_input, }); - if tool_surface.browser_assist { + if tool_surface.browser_assist && !lock_service_skill_launch_to_site_tools { for tool_name in browser_tools::browser_mcp_tool_names() { permissions.push(ToolPermission { tool: tool_name, @@ -179,6 +181,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( } append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata); + append_service_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); let (registry_arc, _) = resolve_agent_registry(state).await?; let mut registry = registry_arc.write().await; diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs index 88a403e1c..a802969ea 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs @@ -1,4 +1,5 @@ use super::*; +use lime_core::database::dao::browser_profile::BrowserProfileTransportKind; #[derive(Debug, Clone)] pub(crate) struct LimeBrowserMcpTool { @@ -6,6 +7,7 @@ pub(crate) struct LimeBrowserMcpTool { action_name: String, description: String, input_schema: serde_json::Value, + db: DbConnection, } impl LimeBrowserMcpTool { @@ -14,12 +16,14 @@ impl LimeBrowserMcpTool { action_name: String, description: String, input_schema: serde_json::Value, + db: DbConnection, ) -> Self { Self { tool_name, action_name, description, input_schema, + db, } } @@ -34,6 +38,10 @@ impl LimeBrowserMcpTool { "tabs_context_mcp" | "tabs_create_mcp" | "navigate" + | "find" + | "computer" + | "javascript" + | "javascript_tool" | "click" | "type" | "form_input" @@ -106,6 +114,50 @@ impl LimeBrowserMcpTool { } None } + + fn load_profile_transport_kind( + db: &DbConnection, + profile_key: &str, + ) -> Option { + let conn = db.lock().ok()?; + crate::services::browser_profile_service::get_browser_profile_by_key(&conn, profile_key) + .ok() + .flatten() + .map(|profile| profile.transport_kind) + } + + pub(crate) fn should_auto_launch_managed_browser( + resolved_backend: Option, + session_hint: Option<&BrowserAssistRuntimeHint>, + profile_transport: Option, + ) -> bool { + if !session_hint.is_some_and(|hint| hint.auto_launch) { + return false; + } + + if matches!( + profile_transport, + Some(BrowserProfileTransportKind::ExistingSession) + ) { + return false; + } + + if matches!( + resolved_backend, + Some(BrowserBackendType::LimeExtensionBridge) + ) { + return false; + } + + if matches!( + session_hint.and_then(|hint| hint.preferred_backend.clone()), + Some(BrowserBackendType::LimeExtensionBridge) + ) { + return false; + } + + true + } } #[async_trait] @@ -139,7 +191,13 @@ impl Tool for LimeBrowserMcpTool { let profile_key = Self::extract_profile_key(¶ms, _context) .or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone())); if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) { - if hint.auto_launch { + let profile_transport = + Self::load_profile_transport_kind(&self.db, profile_key.as_str()); + if Self::should_auto_launch_managed_browser( + backend.clone(), + Some(hint), + profile_transport, + ) { let launch_url = Self::extract_launch_url(&self.action_name, ¶ms) .or_else(|| hint.launch_url.clone()); ensure_managed_chrome_profile_global(profile_key.clone(), launch_url) @@ -158,7 +216,7 @@ impl Tool for LimeBrowserMcpTool { timeout_ms, }; - let result = browser_execute_action_global(request) + let result = browser_execute_action_global(self.db.clone(), request) .await .map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?; @@ -226,7 +284,10 @@ pub(super) fn browser_mcp_tool_names() -> Vec { names } -pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) { +pub(super) fn register_browser_mcp_tools_to_registry( + registry: &mut aster::tools::ToolRegistry, + db: DbConnection, +) { let tool_defs = get_chrome_mcp_tools(); for tool_def in tool_defs { for prefix in ["mcp__lime-browser__"] { @@ -239,6 +300,7 @@ pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools tool_def.name.clone(), tool_def.description.clone(), tool_def.input_schema.clone(), + db.clone(), ); registry.register(Box::new(tool)); } @@ -255,10 +317,11 @@ pub(super) fn unregister_browser_mcp_tools_from_registry( pub(crate) async fn ensure_browser_mcp_tools_registered( state: &AsterAgentState, + db: &DbConnection, ) -> Result<(), String> { let (registry_arc, extension_manager) = resolve_agent_registry(state).await?; let mut registry = registry_arc.write().await; - register_browser_mcp_tools_to_registry(&mut registry); + register_browser_mcp_tools_to_registry(&mut registry, db.clone()); search_bridge::register_tool_search_tool_to_registry( &mut registry, registry_arc.clone(), diff --git a/src-tauri/src/commands/browser_connector_cmd.rs b/src-tauri/src/commands/browser_connector_cmd.rs index b2101648c..bcd5ccfd7 100644 --- a/src-tauri/src/commands/browser_connector_cmd.rs +++ b/src-tauri/src/commands/browser_connector_cmd.rs @@ -4,9 +4,10 @@ use crate::app::AppState; use crate::services::browser_connector_service::{ get_browser_connector_install_status, get_browser_connector_settings, install_browser_connector_extension, sync_browser_connector_auto_config_if_installed, - update_browser_connector_enabled, update_browser_connector_install_root, - update_system_connector_enabled, BrowserConnectorAutoConfig, BrowserConnectorInstallResult, - BrowserConnectorInstallStatus, BrowserConnectorSettingsSnapshot, + update_browser_action_capability_enabled, update_browser_connector_enabled, + update_browser_connector_install_root, update_system_connector_enabled, + BrowserConnectorAutoConfig, BrowserConnectorInstallResult, BrowserConnectorInstallStatus, + BrowserConnectorSettingsSnapshot, }; use serde::Deserialize; use tauri::{AppHandle, State}; @@ -30,6 +31,12 @@ pub struct SetSystemConnectorEnabledRequest { pub enabled: bool, } +#[derive(Debug, Deserialize)] +pub struct SetBrowserActionCapabilityEnabledRequest { + pub key: String, + pub enabled: bool, +} + fn normalize_bridge_host(host: &str) -> String { match host.trim() { "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(), @@ -60,6 +67,62 @@ async fn build_auto_config( }) } +fn open_chrome_url(url: &str) -> Result { + #[cfg(target_os = "macos")] + { + let mut command = std::process::Command::new("open"); + command.args(["-a", "Google Chrome", url]); + if let Err(primary_error) = command.spawn() { + std::process::Command::new("open") + .arg(url) + .spawn() + .map_err(|fallback_error| { + format!("打开 Chrome 页面失败: {primary_error}; fallback: {fallback_error}") + })?; + } + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .map_err(|error| format!("打开 Chrome 页面失败: {error}"))?; + } + + #[cfg(target_os = "linux")] + { + let attempts = [ + ("google-chrome", vec![url]), + ("chromium", vec![url]), + ("xdg-open", vec![url]), + ]; + + let mut last_error = None; + let mut opened = false; + for (binary, args) in attempts { + match std::process::Command::new(binary).args(args).spawn() { + Ok(_) => { + opened = true; + break; + } + Err(error) => { + last_error = Some(format!("{binary}: {error}")); + } + } + } + + if !opened { + return Err(format!( + "打开 Chrome 页面失败: {}", + last_error.unwrap_or_else(|| "没有可用的浏览器命令".to_string()) + )); + } + } + + Ok(true) +} + #[tauri::command] pub fn get_browser_connector_settings_cmd() -> Result { get_browser_connector_settings() @@ -90,6 +153,13 @@ pub fn set_system_connector_enabled_cmd( update_system_connector_enabled(&request.id, request.enabled) } +#[tauri::command] +pub fn set_browser_action_capability_enabled_cmd( + request: SetBrowserActionCapabilityEnabledRequest, +) -> Result { + update_browser_action_capability_enabled(&request.key, request.enabled) +} + #[tauri::command] pub fn get_browser_connector_install_status_cmd( app: AppHandle, @@ -124,57 +194,10 @@ pub async fn install_browser_connector_extension_cmd( #[tauri::command] pub async fn open_browser_extensions_page_cmd() -> Result { - #[cfg(target_os = "macos")] - { - let mut command = std::process::Command::new("open"); - command.args(["-a", "Google Chrome", "chrome://extensions"]); - if let Err(primary_error) = command.spawn() { - std::process::Command::new("open") - .arg("chrome://extensions") - .spawn() - .map_err(|fallback_error| { - format!("打开 Chrome 扩展页面失败: {primary_error}; fallback: {fallback_error}") - })?; - } - } + open_chrome_url("chrome://extensions") +} - #[cfg(target_os = "windows")] - { - std::process::Command::new("cmd") - .args(["/C", "start", "", "chrome://extensions"]) - .spawn() - .map_err(|error| format!("打开 Chrome 扩展页面失败: {error}"))?; - } - - #[cfg(target_os = "linux")] - { - let attempts = [ - ("google-chrome", vec!["chrome://extensions"]), - ("chromium", vec!["chrome://extensions"]), - ("xdg-open", vec!["chrome://extensions"]), - ]; - - let mut last_error = None; - let mut opened = false; - for (binary, args) in attempts { - match std::process::Command::new(binary).args(args).spawn() { - Ok(_) => { - opened = true; - break; - } - Err(error) => { - last_error = Some(format!("{binary}: {error}")); - } - } - } - - if !opened { - return Err(format!( - "打开 Chrome 扩展页面失败: {}", - last_error.unwrap_or_else(|| "没有可用的浏览器命令".to_string()) - )); - } - } - - Ok(true) +#[tauri::command] +pub async fn open_browser_remote_debugging_page_cmd() -> Result { + open_chrome_url("chrome://inspect/#remote-debugging") } diff --git a/src-tauri/src/commands/browser_profile_cmd.rs b/src-tauri/src/commands/browser_profile_cmd.rs index 032f45289..012906759 100644 --- a/src-tauri/src/commands/browser_profile_cmd.rs +++ b/src-tauri/src/commands/browser_profile_cmd.rs @@ -53,6 +53,8 @@ pub struct LaunchBrowserProfileRuntimeAssistRequest { #[serde(default)] pub open_window: Option, #[serde(default)] + pub headless: Option, + #[serde(default)] pub stream_mode: Option, } @@ -125,6 +127,7 @@ pub async fn launch_browser_profile_runtime_assist_cmd( environment: None, target_id: request.target_id, open_window: request.open_window.unwrap_or(false), + headless: request.headless.unwrap_or(false), stream_mode: request.stream_mode.unwrap_or(BrowserStreamMode::Both), }, ) diff --git a/src-tauri/src/commands/browser_runtime_cmd.rs b/src-tauri/src/commands/browser_runtime_cmd.rs index 54a94ed87..6ebebe4c8 100644 --- a/src-tauri/src/commands/browser_runtime_cmd.rs +++ b/src-tauri/src/commands/browser_runtime_cmd.rs @@ -3,9 +3,10 @@ use crate::app::AppState; use crate::commands::webview_cmd::{ append_browser_runtime_launch_audit, open_cdp_session_global, - open_chrome_profile_window_global, shared_browser_runtime, start_browser_stream_global, - BrowserRuntimeLaunchAuditInput, ChromeProfileLaunchOptions, OpenCdpSessionRequest, - OpenChromeProfileRequest, OpenChromeProfileResponse, StartBrowserStreamRequest, + open_chrome_profile_window_global, resolve_profile_session_global, shared_browser_runtime, + start_browser_stream_global, BrowserRuntimeLaunchAuditInput, ChromeProfileLaunchOptions, + ChromeProfileSessionInfo, OpenCdpSessionRequest, OpenChromeProfileRequest, + OpenChromeProfileResponse, StartBrowserStreamRequest, }; use crate::database::{lock_db, DbConnection}; use crate::services::browser_environment_service::{ @@ -14,7 +15,7 @@ use crate::services::browser_environment_service::{ BrowserEnvironmentLaunchConfig, }; use crate::services::browser_profile_service::{ - get_browser_profile, touch_browser_profile_last_used, + get_browser_profile, get_browser_profile_by_key, touch_browser_profile_last_used, }; use crate::services::browser_runtime_window; use lime_browser_runtime::BrowserStreamMode; @@ -54,6 +55,8 @@ pub struct LaunchBrowserSessionRequest { pub target_id: Option, #[serde(default = "default_open_window")] pub open_window: bool, + #[serde(default)] + pub headless: bool, #[serde(default = "default_stream_mode")] pub stream_mode: BrowserStreamMode, } @@ -68,6 +71,8 @@ pub struct LaunchBrowserRuntimeAssistRequest { pub target_id: Option, #[serde(default = "default_open_window")] pub open_window: bool, + #[serde(default)] + pub headless: bool, #[serde(default = "default_stream_mode")] pub stream_mode: BrowserStreamMode, #[serde(default)] @@ -86,11 +91,13 @@ pub type BrowserRuntimeAssistLaunchResponse = BrowserSessionLaunchResponse; pub struct ResolvedLaunchBrowserSessionRequest { pub profile_id: Option, pub profile_key: String, + pub transport_kind: Option, pub url: String, pub environment_preset_id: Option, pub environment: Option, pub target_id: Option, pub open_window: bool, + pub headless: bool, pub stream_mode: BrowserStreamMode, } @@ -126,6 +133,7 @@ fn resolve_launch_browser_session_request( } let mut resolved_profile_id = None; + let mut resolved_transport_kind = None; let mut resolved_profile_key = request .profile_key .as_deref() @@ -153,14 +161,8 @@ fn resolve_launch_browser_session_request( )); } } - if profile.transport_kind == BrowserProfileTransportKind::ExistingSession { - return Err( - "当前资料使用“附着当前 Chrome”模式,运行时附着链路尚未接入;请先改用“托管浏览器”模式启动" - .to_string(), - ); - } - resolved_profile_id = Some(profile.id.clone()); + resolved_transport_kind = Some(profile.transport_kind.clone()); resolved_profile_key = Some(profile.profile_key.clone()); if resolved_url.is_none() { resolved_url = profile.launch_url.clone(); @@ -171,6 +173,16 @@ fn resolve_launch_browser_session_request( .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "启动浏览器会话时必须提供 profile_id 或 profile_key".to_string())?; + if resolved_profile_id.is_none() { + let conn = lock_db(db)?; + if let Some(profile) = get_browser_profile_by_key(&conn, &profile_key)? + .filter(|profile| profile.archived_at.is_none()) + { + resolved_profile_id = Some(profile.id.clone()); + resolved_transport_kind = Some(profile.transport_kind.clone()); + } + } + let environment = if let Some(preset_id) = request.environment_preset_id.as_deref() { let conn = lock_db(db)?; let preset = get_browser_environment_preset(&conn, preset_id)? @@ -190,11 +202,13 @@ fn resolve_launch_browser_session_request( Ok(ResolvedLaunchBrowserSessionRequest { profile_id: resolved_profile_id, profile_key, + transport_kind: resolved_transport_kind, url: resolved_url.unwrap_or_else(default_launch_url), environment_preset_id, environment, target_id: request.target_id, open_window: request.open_window, + headless: request.headless, stream_mode: request.stream_mode, }) } @@ -326,6 +340,7 @@ pub async fn launch_browser_runtime_assist( profile_id = ?request.profile_id, target_id = ?request.target_id, open_window = request.open_window, + headless = request.headless, stream_mode = ?request.stream_mode ) )] @@ -340,6 +355,7 @@ pub async fn launch_browser_runtime_assist_global( ResolvedLaunchBrowserSessionRequest { profile_id: request.profile_id, profile_key: request.profile_key, + transport_kind: None, url: request.url, environment_preset_id: request .environment @@ -348,6 +364,7 @@ pub async fn launch_browser_runtime_assist_global( environment: request.environment, target_id: request.target_id, open_window: request.open_window, + headless: request.headless, stream_mode: request.stream_mode, }, ) @@ -363,6 +380,7 @@ pub async fn launch_browser_runtime_assist_global( environment_preset_id = ?request.environment_preset_id, target_id = ?request.target_id, open_window = request.open_window, + headless = request.headless, stream_mode = ?request.stream_mode ) )] @@ -395,6 +413,10 @@ pub async fn launch_browser_session_global( .as_ref() .map(build_chrome_launch_options) .unwrap_or_default(); + let chrome_launch_options = ChromeProfileLaunchOptions { + headless: request.headless, + ..chrome_launch_options + }; let launch_url = request.url.clone(); let bootstrap_url = if request.environment.is_some() { "about:blank".to_string() @@ -403,25 +425,48 @@ pub async fn launch_browser_session_global( }; let launch_started_at = Instant::now(); let profile_started_at = Instant::now(); - let profile = match open_chrome_profile_window_global( - app_handle.clone(), - app_state, - OpenChromeProfileRequest { - profile_key: request.profile_key.clone(), - url: bootstrap_url.clone(), - launch_options: Some(chrome_launch_options), - }, - ) - .instrument(tracing::info_span!( - "launch_browser_session_global.open_profile" - )) - .await - { - Ok(profile) => profile, - Err(error) => { - finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; - return Err(error); + let profile = match request.transport_kind.clone() { + Some(BrowserProfileTransportKind::ExistingSession) => { + let profile_session = match resolve_profile_session_global(Some( + request.profile_key.clone(), + )) + .instrument(tracing::info_span!( + "launch_browser_session_global.resolve_existing_session" + )) + .await + { + Ok(session) => session, + Err(error) => { + let wrapped = format!( + "当前资料使用“附着当前 Chrome”模式,但没有检测到可复用的浏览器会话:{error}。请确认 Lime Chrome 插件已连接,并且当前 Chrome 已开启远程调试。" + ); + finalize_browser_runtime_launch_audit(launch_audit, Some(wrapped.clone())) + .await; + return Err(wrapped); + } + }; + build_existing_session_profile_response(&profile_session) } + _ => match open_chrome_profile_window_global( + app_handle.clone(), + app_state, + OpenChromeProfileRequest { + profile_key: request.profile_key.clone(), + url: bootstrap_url.clone(), + launch_options: Some(chrome_launch_options), + }, + ) + .instrument(tracing::info_span!( + "launch_browser_session_global.open_profile" + )) + .await + { + Ok(profile) => profile, + Err(error) => { + finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; + return Err(error); + } + }, }; let profile_elapsed_ms = profile_started_at.elapsed().as_millis(); launch_audit.reused = Some(profile.reused); @@ -631,6 +676,7 @@ pub async fn launch_browser_session_global( open_session_elapsed_ms, stream_elapsed_ms, open_window = request.open_window, + headless = request.headless, "browser session launch: launch completed" ); @@ -638,12 +684,37 @@ pub async fn launch_browser_session_global( Ok(BrowserSessionLaunchResponse { profile, session }) } +fn build_existing_session_profile_response( + profile_session: &ChromeProfileSessionInfo, +) -> OpenChromeProfileResponse { + let browser_path = (!profile_session.browser_path.trim().is_empty()) + .then(|| profile_session.browser_path.clone()); + let profile_dir = (!profile_session.profile_dir.trim().is_empty()) + .then(|| profile_session.profile_dir.clone()); + let devtools_http_url = format!( + "http://127.0.0.1:{}/json/version", + profile_session.remote_debugging_port + ); + OpenChromeProfileResponse { + success: true, + reused: true, + browser_source: Some(profile_session.browser_source.clone()), + browser_path, + profile_dir, + remote_debugging_port: Some(profile_session.remote_debugging_port), + pid: Some(profile_session.pid), + devtools_http_url: Some(devtools_http_url), + error: None, + } +} + fn build_chrome_launch_options( environment: &BrowserEnvironmentLaunchConfig, ) -> ChromeProfileLaunchOptions { ChromeProfileLaunchOptions { proxy_server: environment.proxy_server.clone(), language: environment.browser_launch_language(), + headless: false, } } @@ -750,6 +821,7 @@ mod tests { environment: None, target_id: Some("target-1".to_string()), open_window: false, + headless: true, stream_mode: BrowserStreamMode::Both, }, ) @@ -773,6 +845,7 @@ mod tests { .and_then(|value| value.proxy_server.as_deref()), Some("http://127.0.0.1:7890") ); + assert!(resolved.headless); } #[test] @@ -788,6 +861,7 @@ mod tests { environment: Some(BrowserEnvironmentLaunchConfig::default()), target_id: None, open_window: false, + headless: false, stream_mode: BrowserStreamMode::Both, }, ) @@ -797,7 +871,7 @@ mod tests { } #[test] - fn resolve_launch_browser_session_request_should_reject_existing_session_profile() { + fn resolve_launch_browser_session_request_should_allow_existing_session_profile() { let db = setup_db(); { let conn = lock_db(&db).unwrap(); @@ -817,7 +891,7 @@ mod tests { .unwrap(); } - let error = resolve_launch_browser_session_request( + let resolved = resolve_launch_browser_session_request( &db, LaunchBrowserSessionRequest { profile_id: Some("profile-attach".to_string()), @@ -827,11 +901,62 @@ mod tests { environment: None, target_id: None, open_window: false, + headless: false, stream_mode: BrowserStreamMode::Both, }, ) - .unwrap_err(); + .expect("existing_session profile should resolve"); - assert!(error.contains("附着当前 Chrome")); + assert_eq!(resolved.profile_id.as_deref(), Some("profile-attach")); + assert_eq!(resolved.profile_key, "weibo_attach"); + assert_eq!( + resolved.transport_kind, + Some(BrowserProfileTransportKind::ExistingSession) + ); + } + + #[test] + fn resolve_launch_browser_session_request_should_allow_existing_session_profile_key() { + let db = setup_db(); + { + let conn = lock_db(&db).unwrap(); + conn.execute( + "INSERT INTO browser_profiles ( + id, profile_key, name, description, site_scope, launch_url, transport_kind, + profile_dir, managed_profile_dir, created_at, updated_at, last_used_at, archived_at + ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, 'existing_session', '', NULL, ?5, ?5, NULL, NULL)", + ( + "profile-attach", + "weibo_attach", + "微博附着", + "https://weibo.com/", + "2026-03-15T00:00:00Z", + ), + ) + .unwrap(); + } + + let resolved = resolve_launch_browser_session_request( + &db, + LaunchBrowserSessionRequest { + profile_id: None, + profile_key: Some("weibo_attach".to_string()), + url: Some("https://weibo.com/".to_string()), + environment_preset_id: None, + environment: None, + target_id: None, + open_window: false, + headless: false, + stream_mode: BrowserStreamMode::Both, + }, + ) + .expect("existing_session profile_key should resolve"); + + assert_eq!(resolved.profile_id.as_deref(), Some("profile-attach")); + assert_eq!(resolved.profile_key, "weibo_attach"); + assert_eq!( + resolved.transport_kind, + Some(BrowserProfileTransportKind::ExistingSession) + ); } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4e9121b1f..938d9c1e2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -39,7 +39,6 @@ pub mod model_cmd; pub mod model_registry_cmd; pub mod models_cmd; pub mod music_cmd; -pub mod network_cmd; pub mod novel_cmd; pub mod oauth_cmd; pub mod openclaw_cmd; @@ -52,7 +51,6 @@ pub mod poster_material_cmd; pub mod prompt_cmd; pub mod provider_pool_cmd; pub mod resilience_cmd; -pub mod route_cmd; pub mod screenshot_cmd; pub mod security_perf_cmd; pub mod session_files_cmd; diff --git a/src-tauri/src/commands/network_cmd.rs b/src-tauri/src/commands/network_cmd.rs deleted file mode 100644 index 2f192de73..000000000 --- a/src-tauri/src/commands/network_cmd.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! 网络相关命令 -//! -//! 核心逻辑已迁移到 lime-core::network,本文件保留 Tauri 命令包装。 - -// 重新导出核心类型 -pub use lime_core::network::{get_accessible_url, NetworkInfo}; - -/// 获取本地网络信息(Tauri 命令包装) -#[tauri::command] -pub fn get_network_info() -> Result { - lime_core::network::get_network_info() -} diff --git a/src-tauri/src/commands/persona_cmd.rs b/src-tauri/src/commands/persona_cmd.rs index 5b0dc66eb..11be864d8 100644 --- a/src-tauri/src/commands/persona_cmd.rs +++ b/src-tauri/src/commands/persona_cmd.rs @@ -295,7 +295,7 @@ pub async fn generate_persona( if !agent_state.is_initialized().await { agent_state.init_agent_with_db(&db).await?; } - ensure_browser_mcp_tools_registered(agent_state.inner()).await?; + ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?; // 创建临时会话 ID let session_id = format!("persona-gen-{}", uuid::Uuid::new_v4()); diff --git a/src-tauri/src/commands/route_cmd.rs b/src-tauri/src/commands/route_cmd.rs deleted file mode 100644 index 493c9eb7e..000000000 --- a/src-tauri/src/commands/route_cmd.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! 路由相关 Tauri 命令 - -use crate::commands::network_cmd::get_accessible_url; -use crate::commands::provider_pool_cmd::ProviderPoolServiceState; -use crate::config; -use crate::database::DbConnection; -use crate::models::route_model::{RouteInfo, RouteListResponse}; - -/// 获取可访问的服务器地址 -/// -/// 使用 `get_accessible_url` 函数生成可访问的 URL。 -/// 对于 `0.0.0.0`,会转换为局域网 IP 或 `127.0.0.1`。 -fn get_valid_base_url(config: &config::Config) -> String { - get_accessible_url(&config.server.host, config.server.port) -} - -/// 获取所有可用的路由端点 -#[tauri::command] -pub async fn get_available_routes( - db: tauri::State<'_, DbConnection>, - pool_service: tauri::State<'_, ProviderPoolServiceState>, -) -> Result { - // 获取配置中的服务器地址和默认 Provider - let config = config::load_config().unwrap_or_default(); - let base_url = get_valid_base_url(&config); - let default_provider = config.default_provider.clone(); - - let routes = pool_service - .0 - .get_available_routes(db.inner(), &base_url) - .map_err(|e| e.to_string())?; - - // 添加默认路由,使用配置中的默认 Provider - let mut all_routes = vec![RouteInfo { - selector: "default".to_string(), - provider_type: default_provider.clone(), - credential_count: 1, - endpoints: vec![ - crate::models::route_model::RouteEndpoint { - path: "/v1/messages".to_string(), - protocol: "claude".to_string(), - url: format!("{base_url}/v1/messages"), - }, - crate::models::route_model::RouteEndpoint { - path: "/v1/chat/completions".to_string(), - protocol: "openai".to_string(), - url: format!("{base_url}/v1/chat/completions"), - }, - ], - tags: vec!["默认".to_string()], - enabled: true, - }]; - all_routes.extend(routes); - - Ok(RouteListResponse { - base_url, - default_provider, - routes: all_routes, - }) -} - -/// 获取指定路由的 curl 示例 -#[tauri::command] -pub async fn get_route_curl_examples( - selector: String, - db: tauri::State<'_, DbConnection>, - pool_service: tauri::State<'_, ProviderPoolServiceState>, -) -> Result, String> { - let config = config::load_config().unwrap_or_default(); - let base_url = get_valid_base_url(&config); - let default_provider = config.default_provider.clone(); - - let routes = pool_service - .0 - .get_available_routes(db.inner(), &base_url) - .map_err(|e| e.to_string())?; - - // 查找匹配的路由 - let route = routes.iter().find(|r| r.selector == selector); - - // P0 安全修复:curl 示例使用占位符,不暴露真实 API Key - let api_key = "${LIME_API_KEY}"; - - match route { - Some(r) => Ok(r.generate_curl_examples(api_key)), - None => { - // 生成默认路由的示例,使用配置中的默认 Provider - let mut default_route = RouteInfo::new("default".to_string(), default_provider); - default_route.add_endpoint(&base_url, "claude"); - default_route.add_endpoint(&base_url, "openai"); - Ok(default_route.generate_curl_examples(api_key)) - } - } -} diff --git a/src-tauri/src/commands/tray_cmd.rs b/src-tauri/src/commands/tray_cmd.rs index 1c9fabcf2..9513b2b18 100644 --- a/src-tauri/src/commands/tray_cmd.rs +++ b/src-tauri/src/commands/tray_cmd.rs @@ -1,278 +1,11 @@ //! 托盘相关命令 //! -//! 提供托盘状态同步和更新的 Tauri 命令 -//! -//! # Requirements -//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标 -//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标 -//! - 7.3: 托盘菜单打开时获取并显示最新信息 +//! 仅保留托盘当前主链所需的 Tauri 命令。 -use crate::tray::{TrayIconStatus, TrayQuickModelGroup, TrayStateSnapshot}; +use crate::tray::TrayQuickModelGroup; use crate::TrayManagerState; use tauri::State; -use tracing::{debug, info}; - -/// 同步托盘状态 -/// -/// 从前端或其他模块调用,更新托盘的完整状态 -/// -/// # Requirements -/// - 7.1: API 服务器状态变化时更新托盘图标 -/// - 7.2: 凭证健康状态变化时更新托盘图标 -#[tauri::command] -pub async fn sync_tray_state( - tray_state: State<'_, TrayManagerState>, - server_running: bool, - server_address: String, - available_credentials: usize, - total_credentials: usize, - today_requests: u64, - auto_start_enabled: bool, -) -> Result<(), String> { - let tray_guard = tray_state.0.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - let current_state = tray_manager.get_state().await; - - // 计算图标状态 - let icon_status = if !server_running { - TrayIconStatus::Stopped - } else if available_credentials == 0 && total_credentials > 0 { - TrayIconStatus::Error - } else if available_credentials < total_credentials { - TrayIconStatus::Warning - } else { - TrayIconStatus::Running - }; - - let snapshot = TrayStateSnapshot { - icon_status, - server_running, - server_address, - available_credentials, - total_credentials, - today_requests, - auto_start_enabled, - current_model_provider_type: current_state.current_model_provider_type, - current_model_provider_label: current_state.current_model_provider_label, - current_model: current_state.current_model, - current_theme_label: current_state.current_theme_label, - quick_model_groups: current_state.quick_model_groups, - }; - - tray_manager - .update_state(snapshot) - .await - .map_err(|e| e.to_string())?; - - debug!( - "托盘状态已同步: server_running={}, icon_status={:?}", - server_running, icon_status - ); - - Ok(()) -} - -/// 更新托盘服务器状态 -/// -/// 仅更新服务器运行状态 -/// -/// # Requirements -/// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标 -#[tauri::command] -pub async fn update_tray_server_status( - tray_state: State<'_, TrayManagerState>, - server_running: bool, - server_host: String, - server_port: u16, -) -> Result<(), String> { - let tray_guard = tray_state.0.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - // 获取当前状态 - let mut current_state = tray_manager.get_state().await; - - // 更新服务器相关字段 - current_state.server_running = server_running; - current_state.server_address = if server_running { - format!("{server_host}:{server_port}") - } else { - String::new() - }; - - // 重新计算图标状态 - if !server_running { - current_state.icon_status = TrayIconStatus::Stopped; - } else if current_state.icon_status == TrayIconStatus::Stopped { - current_state.icon_status = TrayIconStatus::Running; - } - - tray_manager - .update_state(current_state) - .await - .map_err(|e| e.to_string())?; - - info!( - "托盘服务器状态已更新: running={}, address={}:{}", - server_running, server_host, server_port - ); - - Ok(()) -} - -/// 更新托盘凭证状态 -/// -/// 仅更新凭证健康状态 -/// -/// # Requirements -/// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标 -#[tauri::command] -pub async fn update_tray_credential_status( - tray_state: State<'_, TrayManagerState>, - available_credentials: usize, - total_credentials: usize, - has_warning: bool, -) -> Result<(), String> { - let tray_guard = tray_state.0.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - // 获取当前状态 - let mut current_state = tray_manager.get_state().await; - - // 更新凭证相关字段 - current_state.available_credentials = available_credentials; - current_state.total_credentials = total_credentials; - - // 重新计算图标状态 - if current_state.server_running { - if available_credentials == 0 && total_credentials > 0 { - current_state.icon_status = TrayIconStatus::Error; - } else if has_warning || available_credentials < total_credentials { - current_state.icon_status = TrayIconStatus::Warning; - } else { - current_state.icon_status = TrayIconStatus::Running; - } - } - - tray_manager - .update_state(current_state) - .await - .map_err(|e| e.to_string())?; - - info!( - "托盘凭证状态已更新: available={}/{}, has_warning={}", - available_credentials, total_credentials, has_warning - ); - - Ok(()) -} - -/// 获取托盘当前状态 -/// -/// 返回托盘的当前状态快照 -#[tauri::command] -pub async fn get_tray_state( - tray_state: State<'_, TrayManagerState>, -) -> Result { - let tray_guard = tray_state.0.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - Ok(tray_manager.get_state().await) -} - -/// 刷新托盘菜单 -/// -/// 强制刷新托盘菜单内容 -/// -/// # Requirements -/// - 7.3: 托盘菜单打开时获取并显示最新信息 -#[tauri::command] -pub async fn refresh_tray_menu( - tray_state: State<'_, TrayManagerState>, -) -> Result<(), String> { - let tray_guard = tray_state.0.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - tray_manager - .refresh_menu() - .await - .map_err(|e| e.to_string())?; - - debug!("托盘菜单已刷新"); - - Ok(()) -} - -/// 刷新托盘菜单并更新统计数据 -/// -/// 在菜单打开时调用,获取最新的统计数据并刷新菜单 -/// -/// # Requirements -/// - 7.3: 托盘菜单打开时获取并显示最新信息 -#[tauri::command] -pub async fn refresh_tray_with_stats( - tray_state: State<'_, TrayManagerState>, - server_running: bool, - server_address: String, - available_credentials: usize, - total_credentials: usize, - today_requests: u64, - auto_start_enabled: bool, -) -> Result<(), String> { - let tray_guard = tray_state.0.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - let current_state = tray_manager.get_state().await; - - // 计算图标状态 - let icon_status = if !server_running { - TrayIconStatus::Stopped - } else if available_credentials == 0 && total_credentials > 0 { - TrayIconStatus::Error - } else if available_credentials < total_credentials { - TrayIconStatus::Warning - } else { - TrayIconStatus::Running - }; - - let snapshot = TrayStateSnapshot { - icon_status, - server_running, - server_address, - available_credentials, - total_credentials, - today_requests, - auto_start_enabled, - current_model_provider_type: current_state.current_model_provider_type, - current_model_provider_label: current_state.current_model_provider_label, - current_model: current_state.current_model, - current_theme_label: current_state.current_theme_label, - quick_model_groups: current_state.quick_model_groups, - }; - - // 更新状态并刷新菜单 - tray_manager - .update_state(snapshot) - .await - .map_err(|e| e.to_string())?; - - debug!( - "托盘菜单已刷新: server_running={}, requests={}, credentials={}/{}", - server_running, today_requests, available_credentials, total_credentials - ); - - Ok(()) -} +use tracing::debug; /// 同步托盘中的快速模型切换菜单 /// diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 3752cdcdd..979e6dda3 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -9,12 +9,15 @@ //! - 控制窗口位置和大小 use crate::app::AppState; -use crate::database::DbConnection; +use crate::database::{lock_db, DbConnection}; use crate::services::automation_service::browser_runtime_sync::{ complete_browser_session_after_resume, sync_browser_session_runtime_state, }; +use crate::services::browser_connector_service::{ + ensure_browser_action_capability_enabled, filter_enabled_browser_action_capabilities, +}; use crate::services::browser_profile_service::{ - normalize_browser_profile_key, + get_browser_profile_by_key, normalize_browser_profile_key, resolve_chrome_profile_data_dir as resolve_managed_chrome_profile_data_dir, resolve_chrome_profile_data_dir_from_base as resolve_managed_chrome_profile_data_dir_from_base, }; @@ -25,11 +28,13 @@ use lime_browser_runtime::{ BrowserEvent, BrowserRuntimeManager, BrowserStreamMode, CdpSessionState, CdpTargetInfo, EventBufferSnapshot, OpenSessionRequest, }; +use lime_core::database::dao::browser_profile::BrowserProfileTransportKind; use lime_server::chrome_bridge::{ self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeDisconnectResult, ChromeBridgeStatusSnapshot, }; use once_cell::sync::Lazy; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -141,6 +146,14 @@ static BROWSER_STREAM_RELAY_TASKS: Lazy>>> Lazy::new(|| Mutex::new(HashMap::new())); const MANAGED_CHROME_RECOVERY_WAIT_MS: u64 = 800; +static WINDOW_SCROLL_SCRIPT_TOP_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"(?is)window\.scroll(?:By|To)\s*\(\s*\{[^}]*top\s*:\s*(?P-?\d+)"#) + .expect("window scroll top regex should be valid") +}); +static WINDOW_SCROLL_SCRIPT_COORD_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"(?is)window\.scroll(?:By|To)\s*\(\s*-?\d+\s*,\s*(?P-?\d+)"#) + .expect("window scroll coord regex should be valid") +}); pub fn shared_chrome_profile_manager() -> Arc> { SHARED_CHROME_PROFILE_MANAGER.clone() @@ -195,6 +208,8 @@ pub struct ChromeProfileLaunchOptions { pub proxy_server: Option, #[serde(default)] pub language: Option, + #[serde(default)] + pub headless: bool, } /// 启动外部 Chrome Profile 的请求参数 @@ -774,8 +789,9 @@ async fn open_chrome_profile_window_with_manager( let _ = cleanup_orphan_chrome_profile_processes(&profile_dir).await?; - // 准备 Chrome 扩展(获取 server 配置并生成 auto_config.json) - let extension_dir = { + let extension_dir = if launch_options.headless { + None + } else { let state_guard = app_state.read().await; let status = state_guard.status(); let host = normalize_bridge_host(&status.host); @@ -783,7 +799,13 @@ async fn open_chrome_profile_window_with_manager( let bridge_key = state_guard.config.server.api_key.clone(); let server_url = format!("ws://{host}:{port}"); - prepare_chrome_extension(&app, &profile_dir, &server_url, &bridge_key, &profile_key)? + Some(prepare_chrome_extension( + &app, + &profile_dir, + &server_url, + &bridge_key, + &profile_key, + )?) }; { @@ -836,7 +858,7 @@ async fn open_chrome_profile_window_with_manager( remote_port, &url_text, true, - Some(&extension_dir), + extension_dir.as_deref(), &launch_options, )?; let pid = child.id(); @@ -1269,7 +1291,7 @@ pub async fn get_browser_backends_status( } else { Some("aster 兼容层当前无可用下游连接(扩展/CDP/native-host)".to_string()) }, - capabilities: aster_backend_capabilities(), + capabilities: filter_backend_capabilities(aster_backend_capabilities()), }, BrowserBackendStatusItem { backend: BrowserBackendType::LimeExtensionBridge, @@ -1279,17 +1301,22 @@ pub async fn get_browser_backends_status( } else { Some("未检测到扩展 observer 连接".to_string()) }, - capabilities: extension_backend_capabilities(), + capabilities: filter_backend_capabilities(extension_backend_capabilities()), }, BrowserBackendStatusItem { backend: BrowserBackendType::CdpDirect, available: cdp_available, reason: if cdp_available { None + } else if extension_available { + Some( + "已检测到扩展 observer,但未命中可连接的 CDP 调试端口;请确认当前 Chrome 已开启远程调试。" + .to_string(), + ) } else { Some("未检测到可连接的 CDP 调试端口".to_string()) }, - capabilities: cdp_backend_capabilities(), + capabilities: filter_backend_capabilities(cdp_backend_capabilities()), }, ], }) @@ -1336,7 +1363,7 @@ pub async fn get_browser_backends_status_global() -> Result Result, + db: tauri::State<'_, DbConnection>, request: BrowserActionRequest, ) -> Result { - browser_execute_action_with_manager(state.0.clone(), request).await + browser_execute_action_with_manager(state.0.clone(), db.inner().clone(), request).await } /// 获取浏览器动作审计日志 @@ -1639,27 +1672,61 @@ pub async fn get_browser_action_audit_logs_global( /// 使用指定 profile manager 执行动作(供非 Tauri 命令入口复用) pub async fn browser_execute_action_with_manager( manager: Arc>, + db: DbConnection, request: BrowserActionRequest, ) -> Result { - let action = normalize_action_name(&request.action)?; + let requested_backend = request.backend.clone(); + let timeout_ms = request.timeout_ms; + let (action, normalized_args) = + normalize_browser_action_request(&request.action, request.args)?; let request_id = format!("browser-{}", uuid::Uuid::new_v4()); let policy = BROWSER_BACKEND_POLICY.read().await.clone(); - let candidates = build_backend_candidates(request.backend.clone(), &policy); - let allow_fallback = request.backend.is_none() && policy.auto_fallback; + let allow_fallback = requested_backend.is_none() && policy.auto_fallback; let profile_key = request .profile_key .as_deref() .map(normalize_profile_key) .or_else(|| Some("default".to_string())); + if let Err(error) = ensure_browser_action_capability_enabled(&action) { + let result = BrowserActionResult { + success: false, + backend: None, + session_id: None, + target_id: None, + action: action.clone(), + request_id: request_id.clone(), + data: None, + error: Some(error.clone()), + attempts: Vec::new(), + }; + append_browser_runtime_audit(BrowserRuntimeAuditRecord::action( + request_id, + action, + profile_key.clone(), + requested_backend, + None, + None, + None, + false, + Some(error), + Vec::new(), + )) + .await; + return Ok(result); + } + let profile_transport = + load_browser_profile_transport_kind(&db, profile_key.as_deref(), &requested_backend); + let candidates = + build_backend_candidates(requested_backend.clone(), &policy, profile_transport); let mut attempts = Vec::new(); for (idx, backend) in candidates.iter().enumerate() { match execute_browser_action_with_backend( backend.clone(), &action, - request.args.clone(), + normalized_args.clone(), profile_key.clone(), - request.timeout_ms, + timeout_ms, manager.clone(), ) .await @@ -1689,7 +1756,7 @@ pub async fn browser_execute_action_with_manager( request_id, result.action.clone(), profile_key.clone(), - request.backend.clone(), + requested_backend.clone(), result.backend.clone(), result.session_id.clone(), result.target_id.clone(), @@ -1722,7 +1789,7 @@ pub async fn browser_execute_action_with_manager( request_id, result.action.clone(), profile_key.clone(), - request.backend.clone(), + requested_backend.clone(), None, None, None, @@ -1752,7 +1819,7 @@ pub async fn browser_execute_action_with_manager( request_id, action, profile_key, - request.backend, + requested_backend, None, None, None, @@ -1767,9 +1834,10 @@ pub async fn browser_execute_action_with_manager( /// 使用全局 profile manager 执行动作(供 Agent 工具复用) #[cfg_attr(any(test, not(debug_assertions)), allow(dead_code))] pub async fn browser_execute_action_global( + db: DbConnection, request: BrowserActionRequest, ) -> Result { - browser_execute_action_with_manager(shared_chrome_profile_manager(), request).await + browser_execute_action_with_manager(shared_chrome_profile_manager(), db, request).await } pub async fn append_browser_runtime_launch_audit(input: BrowserRuntimeLaunchAuditInput) { @@ -1812,7 +1880,23 @@ fn normalize_backend_policy(policy: BrowserBackendPolicy) -> Result, policy: &BrowserBackendPolicy, + profile_transport: Option, ) -> Vec { + if matches!( + profile_transport, + Some(BrowserProfileTransportKind::ExistingSession) + ) { + if forced_backend + .as_ref() + .is_some_and(|backend| *backend != BrowserBackendType::LimeExtensionBridge) + { + tracing::warn!( + "[BrowserRuntime] existing_session 资料不支持 {:?},已强制切换为 lime_extension_bridge", + forced_backend + ); + } + return vec![BrowserBackendType::LimeExtensionBridge]; + } if let Some(backend) = forced_backend { return vec![backend]; } @@ -1822,6 +1906,39 @@ fn build_backend_candidates( policy.priority.clone() } +fn load_browser_profile_transport_kind( + db: &DbConnection, + profile_key: Option<&str>, + requested_backend: &Option, +) -> Option { + let normalized_profile_key = profile_key.map(normalize_profile_key)?; + let conn = match lock_db(db) { + Ok(conn) => conn, + Err(error) => { + tracing::warn!( + "[BrowserRuntime] 读取浏览器资料 transport_kind 失败: profile_key={}, requested_backend={:?}, error={}", + normalized_profile_key, + requested_backend, + error + ); + return None; + } + }; + match get_browser_profile_by_key(&conn, &normalized_profile_key) { + Ok(Some(profile)) if profile.archived_at.is_none() => Some(profile.transport_kind), + Ok(_) => None, + Err(error) => { + tracing::warn!( + "[BrowserRuntime] 查询浏览器资料 transport_kind 失败: profile_key={}, requested_backend={:?}, error={}", + normalized_profile_key, + requested_backend, + error + ); + None + } + } +} + fn normalize_action_name(action: &str) -> Result { let raw = action.trim(); if raw.is_empty() { @@ -1837,6 +1954,124 @@ fn normalize_action_name(action: &str) -> Result { Ok(stripped.to_ascii_lowercase()) } +fn normalize_browser_action_request(action: &str, args: Value) -> Result<(String, Value), String> { + let normalized_action = normalize_action_name(action)?; + match normalized_action.as_str() { + "computer" => normalize_legacy_computer_action(args), + "javascript_tool" => normalize_legacy_javascript_tool_action(args), + _ => Ok((normalized_action, args)), + } +} + +fn value_into_object(args: Value) -> serde_json::Map { + match args { + Value::Object(map) => map, + _ => serde_json::Map::new(), + } +} + +fn extract_scroll_amount_from_script(script: &str) -> Option { + WINDOW_SCROLL_SCRIPT_TOP_REGEX + .captures(script) + .and_then(|captures| captures.name("amount")) + .or_else(|| { + WINDOW_SCROLL_SCRIPT_COORD_REGEX + .captures(script) + .and_then(|captures| captures.name("amount")) + }) + .and_then(|amount| amount.as_str().trim().parse::().ok()) +} + +fn normalize_legacy_computer_action(args: Value) -> Result<(String, Value), String> { + let mut next_args = value_into_object(args); + let computer_action = next_args + .get("action") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("click") + .to_ascii_lowercase(); + + match computer_action.as_str() { + "click" => Ok(("click".to_string(), Value::Object(next_args))), + "type" | "input" => Ok(("type".to_string(), Value::Object(next_args))), + "scroll" => { + let raw_amount = next_args + .get("amount") + .and_then(Value::as_i64) + .or_else(|| next_args.get("y").and_then(Value::as_i64)) + .unwrap_or(500); + let direction = next_args + .get("direction") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| { + if raw_amount < 0 { + "up".to_string() + } else { + "down".to_string() + } + }); + let amount = raw_amount.unsigned_abs().max(1); + next_args.insert( + "direction".to_string(), + Value::String(direction.clone()), + ); + next_args.insert("amount".to_string(), Value::from(amount)); + next_args.insert( + "text".to_string(), + Value::String(format!("{direction}:{amount}")), + ); + Ok(("scroll_page".to_string(), Value::Object(next_args))) + } + _ => Err(format!( + "当前浏览器兼容层暂不支持 computer.action={computer_action},请改用 click/type/scroll 一类动作" + )), + } +} + +fn normalize_legacy_javascript_tool_action(args: Value) -> Result<(String, Value), String> { + let mut next_args = value_into_object(args); + let script = [ + "script", + "code", + "javascript", + "expression", + "text", + "value", + ] + .iter() + .find_map(|key| { + next_args + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .ok_or_else(|| "javascript_tool 需要提供 script/code/expression 参数".to_string())?; + + if let Some(amount) = extract_scroll_amount_from_script(&script) { + let direction = if amount < 0 { "up" } else { "down" }; + let normalized_amount = amount.unsigned_abs().max(1); + next_args.insert( + "direction".to_string(), + Value::String(direction.to_string()), + ); + next_args.insert("amount".to_string(), Value::from(normalized_amount)); + next_args.insert( + "text".to_string(), + Value::String(format!("{direction}:{normalized_amount}")), + ); + return Ok(("scroll_page".to_string(), Value::Object(next_args))); + } + + next_args.insert("expression".to_string(), Value::String(script)); + Ok(("javascript".to_string(), Value::Object(next_args))) +} + fn normalize_action_timeout(timeout_ms: Option) -> u64 { timeout_ms .unwrap_or(DEFAULT_BROWSER_ACTION_TIMEOUT_MS) @@ -2046,6 +2281,9 @@ fn cdp_backend_capabilities() -> Vec { "navigate".to_string(), "read_page".to_string(), "get_page_text".to_string(), + "find".to_string(), + "computer".to_string(), + "javascript_tool".to_string(), "click".to_string(), "type".to_string(), "scroll".to_string(), @@ -2059,6 +2297,10 @@ fn cdp_backend_capabilities() -> Vec { ] } +fn filter_backend_capabilities(capabilities: Vec) -> Vec { + filter_enabled_browser_action_capabilities(&capabilities).unwrap_or(capabilities) +} + fn action_arg_string(args: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { args.get(*key) @@ -2347,7 +2589,7 @@ async fn execute_extension_backend_action( target: action_arg_string(&args, &["target", "ref_id"]), text: action_arg_string(&args, &["text", "value"]), url: action_arg_string(&args, &["url"]), - payload: None, + payload: Some(args.clone()), wait_for_page_info: action_arg_bool( &args, "wait_for_page_info", @@ -2400,6 +2642,8 @@ async fn execute_cdp_backend_action( | "click" | "type" | "form_input" + | "javascript" + | "find" | "scroll" | "scroll_page" | "refresh_page" @@ -2556,6 +2800,52 @@ async fn discover_unmanaged_profile_session( })) } +async fn discover_bridge_backed_profile_session( + profile_key: &str, +) -> Result, String> { + let normalized_profile_key = normalize_profile_key(profile_key); + let observer = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await + .observers + .into_iter() + .find(|item| item.profile_key == normalized_profile_key); + let Some(observer) = observer else { + return Ok(None); + }; + + let remote_debugging_port = profile_remote_debugging_port(&normalized_profile_key); + let runtime = shared_browser_runtime(); + let targets = match runtime.list_targets(remote_debugging_port).await { + Ok(value) => value, + Err(_) => return Ok(None), + }; + + let last_url = targets + .iter() + .find(|target| target.target_type == "page" && !target.url.trim().is_empty()) + .map(|target| target.url.trim().to_string()) + .or_else(|| { + observer + .last_page_info + .as_ref() + .and_then(|page| page.url.as_ref().map(|value| value.trim().to_string())) + .filter(|value| !value.is_empty()) + }) + .unwrap_or_else(|| "about:blank".to_string()); + + Ok(Some(ChromeProfileSessionInfo { + profile_key: normalized_profile_key, + browser_source: "system".to_string(), + browser_path: String::new(), + profile_dir: String::new(), + remote_debugging_port, + pid: 0, + started_at: observer.connected_at, + last_url, + })) +} + async fn discover_unmanaged_profile_sessions() -> Result, String> { let base_dir = lime_core::app_paths::preferred_data_dir() .map_err(|error| format!("获取应用数据目录失败: {error}"))?; @@ -2586,6 +2876,29 @@ async fn discover_unmanaged_profile_sessions() -> Result Result, String> +{ + let bridge_status = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await; + let mut discovered = Vec::new(); + let mut seen_profile_keys = HashSet::new(); + + for observer in bridge_status.observers { + if !seen_profile_keys.insert(observer.profile_key.clone()) { + continue; + } + + if let Some(session) = discover_bridge_backed_profile_session(&observer.profile_key).await? + { + discovered.push(session); + } + } + + discovered.sort_by(|left, right| left.profile_key.cmp(&right.profile_key)); + Ok(discovered) +} + async fn list_alive_profile_sessions( manager: Arc>, ) -> Vec { @@ -2605,7 +2918,7 @@ async fn list_alive_profile_sessions( guard.sessions.remove(&key); } - let managed_profile_keys = sessions + let mut known_profile_keys = sessions .iter() .map(|session| session.profile_key.clone()) .collect::>(); @@ -2619,11 +2932,25 @@ async fn list_alive_profile_sessions( } }; - sessions.extend( - unmanaged_sessions - .into_iter() - .filter(|session| !managed_profile_keys.contains(&session.profile_key)), - ); + for session in unmanaged_sessions { + if known_profile_keys.insert(session.profile_key.clone()) { + sessions.push(session); + } + } + + let bridge_backed_sessions = match discover_bridge_backed_profile_sessions().await { + Ok(items) => items, + Err(error) => { + tracing::warn!("[ChromeProfile] 发现扩展附着会话失败: {}", error); + Vec::new() + } + }; + + for session in bridge_backed_sessions { + if known_profile_keys.insert(session.profile_key.clone()) { + sessions.push(session); + } + } sessions } @@ -2633,7 +2960,10 @@ async fn select_profile_session( ) -> Result { let sessions = list_alive_profile_sessions(manager).await; if sessions.is_empty() { - return Err("没有可用的 Chrome profile 会话,请先打开独立浏览器窗口".to_string()); + return Err( + "没有可用的 Chrome 会话,请先连接当前 Chrome 扩展并开启远程调试,或启动托管浏览器。" + .to_string(), + ); } if let Some(key) = profile_key { @@ -2647,7 +2977,13 @@ async fn select_profile_session( sessions .into_iter() .next() - .ok_or_else(|| "没有可用的 Chrome profile 会话".to_string()) + .ok_or_else(|| "没有可用的 Chrome 会话".to_string()) +} + +pub async fn resolve_profile_session_global( + profile_key: Option, +) -> Result { + select_profile_session(shared_chrome_profile_manager(), profile_key).await } async fn ensure_cdp_runtime_session( @@ -2816,12 +3152,17 @@ fn spawn_chrome_with_profile( cmd.arg(format!("--lang={language}")); } - // 如果提供了扩展目录,添加 --load-extension 参数 - if let Some(ext_dir) = extension_dir { - cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy())); + if launch_options.headless { + cmd.arg("--headless=new").arg("--disable-gpu"); } - if new_window { + if !launch_options.headless { + if let Some(ext_dir) = extension_dir { + cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy())); + } + } + + if new_window && !launch_options.headless { cmd.arg("--new-window"); } cmd.arg(url); @@ -3269,6 +3610,31 @@ pub async fn focus_webview_panel(app: AppHandle, panel_id: String) -> Result DbConnection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + transport_kind TEXT NOT NULL DEFAULT 'managed_cdp', + profile_dir TEXT NOT NULL, + managed_profile_dir TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + );", + ) + .unwrap(); + Arc::new(Mutex::new(conn)) + } #[cfg(any(target_os = "macos", target_os = "ios"))] #[test] @@ -3341,6 +3707,37 @@ mod tests { assert!(!normalized.auto_fallback); } + #[test] + fn load_browser_profile_transport_kind_should_resolve_existing_session_profile() { + let db = setup_db(); + { + let conn = lock_db(&db).unwrap(); + conn.execute( + "INSERT INTO browser_profiles ( + id, profile_key, name, description, site_scope, launch_url, transport_kind, + profile_dir, managed_profile_dir, created_at, updated_at, last_used_at, archived_at + ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, 'existing_session', '', NULL, ?5, ?5, NULL, NULL)", + ( + "profile-attach", + "xhs_attach", + "小红书附着", + "https://www.xiaohongshu.com/", + "2026-03-30T00:00:00Z", + ), + ) + .unwrap(); + } + + let requested_backend: Option = None; + let transport = + load_browser_profile_transport_kind(&db, Some("xhs_attach"), &requested_backend); + + assert_eq!( + transport, + Some(BrowserProfileTransportKind::ExistingSession) + ); + } + #[test] fn normalize_action_name_should_strip_aster_prefix() { let action = @@ -3348,11 +3745,63 @@ mod tests { assert_eq!(action, "read_page"); } + #[test] + fn normalize_browser_action_request_should_map_computer_scroll() { + let (action, args) = normalize_browser_action_request( + "computer", + json!({ + "action": "scroll", + "amount": 2000, + }), + ) + .expect("computer scroll should normalize"); + + assert_eq!(action, "scroll_page"); + assert_eq!(args.get("direction"), Some(&json!("down"))); + assert_eq!(args.get("amount"), Some(&json!(2000))); + assert_eq!(args.get("text"), Some(&json!("down:2000"))); + } + + #[test] + fn normalize_browser_action_request_should_convert_scroll_script() { + let (action, args) = normalize_browser_action_request( + "javascript_tool", + json!({ + "code": "// 滚动页面以加载更多内容\nwindow.scrollBy(0, 2000);\n// 等待一会儿", + }), + ) + .expect("scroll script should normalize"); + + assert_eq!(action, "scroll_page"); + assert_eq!(args.get("direction"), Some(&json!("down"))); + assert_eq!(args.get("amount"), Some(&json!(2000))); + assert_eq!(args.get("text"), Some(&json!("down:2000"))); + } + + #[test] + fn normalize_browser_action_request_should_keep_generic_javascript() { + let (action, args) = normalize_browser_action_request( + "javascript_tool", + json!({ + "script": "document.title", + }), + ) + .expect("generic javascript should normalize"); + + assert_eq!(action, "javascript"); + assert_eq!(args.get("expression"), Some(&json!("document.title"))); + } + #[test] fn extension_backend_capabilities_should_include_list_tabs() { assert!(extension_backend_capabilities().contains(&"list_tabs".to_string())); } + #[test] + fn cdp_backend_capabilities_should_include_find() { + assert!(cdp_backend_capabilities().contains(&"find".to_string())); + } + #[test] fn bridge_result_to_value_should_include_data_payload() { let value = bridge_result_to_value(ChromeBridgeCommandResult { @@ -3390,10 +3839,25 @@ mod tests { #[test] fn build_backend_candidates_should_prefer_forced_backend() { let policy = BrowserBackendPolicy::default(); - let candidates = build_backend_candidates(Some(BrowserBackendType::CdpDirect), &policy); + let candidates = build_backend_candidates( + Some(BrowserBackendType::CdpDirect), + &policy, + Some(BrowserProfileTransportKind::ManagedCdp), + ); assert_eq!(candidates, vec![BrowserBackendType::CdpDirect]); } + #[test] + fn build_backend_candidates_should_pin_existing_session_to_extension_bridge() { + let policy = BrowserBackendPolicy::default(); + let candidates = build_backend_candidates( + Some(BrowserBackendType::CdpDirect), + &policy, + Some(BrowserProfileTransportKind::ExistingSession), + ); + assert_eq!(candidates, vec![BrowserBackendType::LimeExtensionBridge]); + } + #[tokio::test] async fn browser_runtime_audit_should_store_launch_metadata() { BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear(); diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index aaf5d793d..7bfadf7b5 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -246,7 +246,7 @@ pub(super) async fn try_handle( let aster_state = app_handle.state::(); let db = app_handle.state::(); - crate::commands::aster_agent_cmd::agent_runtime_delete_session( + crate::commands::aster_agent_cmd::action_runtime::agent_runtime_delete_session( app_handle.clone(), aster_state, db, @@ -302,7 +302,7 @@ pub(super) async fn try_handle( >(args)?; let aster_state = app_handle.state::(); - crate::commands::aster_agent_cmd::agent_runtime_respond_action( + crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action( app_handle.clone(), aster_state, request, diff --git a/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs b/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs index ccfffba82..c4507ef80 100644 --- a/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs +++ b/src-tauri/src/dev_bridge/dispatcher/app_runtime.rs @@ -15,8 +15,10 @@ pub(super) async fn try_handle( serde_json::to_value(manager.config())? } "save_config" => { - let config: lime_core::config::Config = + let mut config: lime_core::config::Config = serde_json::from_value(args.cloned().unwrap_or_default())?; + config.normalize_local_server_surface(); + config.normalize_workspace_preferences(); lime_core::config::save_config(&config)?; crate::services::environment_service::apply_configured_environment(&config).await; serde_json::json!({ "success": true }) @@ -38,10 +40,6 @@ pub(super) async fn try_handle( let providers = { state.server.read().await.config.endpoint_providers.clone() }; serde_json::to_value(providers)? } - "get_server_status" => { - let status = { state.server.read().await.status() }; - serde_json::to_value(status)? - } "get_server_diagnostics" => { let (status, capability_routing, response_cache, request_dedup, idempotency) = { let server = state.server.read().await; diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs b/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs index 962ad471f..f2419a66b 100644 --- a/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/browser/sessions.rs @@ -50,8 +50,9 @@ pub(super) async fn try_handle( } "browser_execute_action" => { let request: crate::commands::webview_cmd::BrowserActionRequest = parse_request(args)?; + let db = get_db(state)?.clone(); serde_json::to_value( - crate::commands::webview_cmd::browser_execute_action_global(request).await?, + crate::commands::webview_cmd::browser_execute_action_global(db, request).await?, )? } "get_browser_action_audit_logs" => { diff --git a/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs b/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs index 43ad284ca..0dc01369e 100644 --- a/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs +++ b/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs @@ -11,45 +11,6 @@ pub(super) async fn try_handle( args: Option<&JsonValue>, ) -> Result, DynError> { let result = match cmd { - "get_network_info" => serde_json::to_value( - crate::commands::network_cmd::get_network_info() - .map_err(|e| format!("获取网络信息失败: {e}"))?, - )?, - "test_api" => { - let args = args.cloned().ok_or("缺少参数")?; - let provider = args - .get("provider") - .and_then(|value| value.as_str()) - .ok_or("缺少 provider 参数")?; - - let credential = if let Some(db) = &state.db { - state - .pool_service - .select_credential(db, provider, None) - .ok() - .flatten() - } else { - None - }; - - match credential { - Some(cred) => { - state.logs.write().await.add( - "info", - &format!("[DevBridge] 测试 API 使用凭证: {:?}", cred.name), - ); - serde_json::json!({ - "success": true, - "credential_name": cred.name, - "provider_type": cred.provider_type, - }) - } - None => serde_json::json!({ - "success": false, - "error": "未找到可用凭证" - }), - } - } "execution_run_list" => { let args = args_or_default(args); let limit = args diff --git a/src-tauri/src/profiling.rs b/src-tauri/src/profiling.rs index a08e2e336..dc9eba946 100644 --- a/src-tauri/src/profiling.rs +++ b/src-tauri/src/profiling.rs @@ -185,18 +185,6 @@ pub fn init() -> ProfilingGuard { } } -pub fn should_open_webview_devtools() -> bool { - matches!( - env::var("LIME_OPEN_WEBVIEW_DEVTOOLS") - .ok() - .as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("1") | Some("true") | Some("yes") | Some("on") - ) -} - fn try_init(config: &ProfilingConfig) -> Result { let filter_layer = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new("info")) diff --git a/src-tauri/src/services/artifact_document_service.rs b/src-tauri/src/services/artifact_document_service.rs index cf044be81..445176beb 100644 --- a/src-tauri/src/services/artifact_document_service.rs +++ b/src-tauri/src/services/artifact_document_service.rs @@ -1089,8 +1089,17 @@ fn truncate_text(value: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { use super::*; + use crate::database::schema::create_tables; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; use tempfile::tempdir; + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + Arc::new(Mutex::new(conn)) + } + fn build_params() -> ArtifactDocumentPersistParams { let workspace_root = tempdir().expect("tempdir").keep(); ArtifactDocumentPersistParams { @@ -1290,10 +1299,9 @@ mod tests { #[test] fn sync_persisted_artifact_document_to_content_should_update_body_and_metadata() { use crate::content::{ContentCreateRequest, ContentManager}; - use crate::database::init_database; use crate::workspace::{WorkspaceManager, WorkspaceType}; - let db = init_database().expect("db should init"); + let db = setup_test_db(); let workspace_root = tempdir().expect("tempdir").keep(); let workspace = WorkspaceManager::new(db.clone()) .create_with_type( diff --git a/src-tauri/src/services/browser_connector_service.rs b/src-tauri/src/services/browser_connector_service.rs index 845f36ce3..d8e21c399 100644 --- a/src-tauri/src/services/browser_connector_service.rs +++ b/src-tauri/src/services/browser_connector_service.rs @@ -8,6 +8,7 @@ use serde_json::Value; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] use std::process::Command; use tauri::{AppHandle, Manager}; @@ -27,8 +28,63 @@ const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [ ("contacts", "通讯录", "搜索、读取和创建联系人。"), ]; +const BROWSER_ACTION_CAPABILITY_DEFINITIONS: [(&str, &str, &str, &str); 20] = [ + ( + "tabs_context_mcp", + "标签页概览", + "读取当前已附着标签页的上下文摘要。", + "read", + ), + ("list_tabs", "列出标签页", "列出当前浏览器标签页。", "read"), + ( + "tabs_create_mcp", + "新建标签页", + "创建新的浏览器标签页。", + "write", + ), + ("read_page", "页面快照", "抓取当前页面快照。", "read"), + ( + "get_page_text", + "页面文本", + "读取当前页面文本内容。", + "read", + ), + ( + "get_page_info", + "页面信息", + "读取页面标题、URL 与快照信息。", + "read", + ), + ("find", "页面内查找", "在当前页面中查找文本。", "read"), + ( + "read_console_messages", + "控制台消息", + "读取浏览器控制台消息。", + "read", + ), + ( + "read_network_requests", + "网络请求", + "读取页面网络请求记录。", + "read", + ), + ("navigate", "导航", "导航到目标地址。", "write"), + ("open_url", "打开链接", "直接打开目标链接。", "write"), + ("click", "点击元素", "点击页面元素。", "write"), + ("type", "输入文本", "向当前页面输入文本。", "write"), + ("form_input", "表单输入", "按字段填写页面表单。", "write"), + ("switch_tab", "切换标签页", "切换当前操作标签页。", "write"), + ("scroll_page", "滚动页面", "滚动当前页面或容器。", "write"), + ("refresh_page", "刷新页面", "刷新当前页面。", "write"), + ("go_back", "返回上一页", "返回上一页。", "write"), + ("go_forward", "前进到下一页", "前进到下一页。", "write"), + ("javascript", "执行脚本", "在当前页面执行脚本。", "write"), +]; + const AUTH_STATUS_NOT_DETERMINED: &str = "not_determined"; +#[cfg(target_os = "macos")] const AUTH_STATUS_AUTHORIZED: &str = "authorized"; +#[cfg(target_os = "macos")] const AUTH_STATUS_DENIED: &str = "denied"; const AUTH_STATUS_ERROR: &str = "error"; const AUTH_STATUS_UNSUPPORTED: &str = "unsupported"; @@ -80,12 +136,22 @@ pub struct SystemConnectorSnapshot { pub capabilities: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserActionCapabilitySnapshot { + pub key: String, + pub label: String, + pub description: String, + pub group: String, + pub enabled: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserConnectorSettingsSnapshot { pub enabled: bool, pub install_root_dir: Option, pub install_dir: Option, pub system_connectors: Vec, + pub browser_action_capabilities: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -93,6 +159,8 @@ struct BrowserConnectorSettingsRecord { enabled: bool, install_root_dir: Option, system_connectors: HashMap, + #[serde(default = "default_browser_action_capability_states")] + browser_action_capabilities: HashMap, updated_at: String, } @@ -122,6 +190,7 @@ impl Default for BrowserConnectorSettingsRecord { enabled: true, install_root_dir: None, system_connectors: default_system_connector_state_records(), + browser_action_capabilities: default_browser_action_capability_states(), updated_at: Utc::now().to_rfc3339(), } } @@ -141,6 +210,13 @@ fn default_system_connector_state_records() -> HashMap HashMap { + BROWSER_ACTION_CAPABILITY_DEFINITIONS + .iter() + .map(|(key, _, _, _)| ((*key).to_string(), true)) + .collect() +} + fn default_connector_record(enabled: bool) -> SystemConnectorStateRecord { SystemConnectorStateRecord { enabled, @@ -163,6 +239,59 @@ fn normalize_connector_record( } } +fn normalize_browser_action_capability_key(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "scroll" => "scroll_page".to_string(), + "javascript_tool" => "javascript".to_string(), + other => other.to_string(), + } +} + +fn browser_action_capability_definition( + key: &str, +) -> Option<(&'static str, &'static str, &'static str, &'static str)> { + BROWSER_ACTION_CAPABILITY_DEFINITIONS + .iter() + .copied() + .find(|(candidate, _, _, _)| *candidate == key) +} + +fn browser_action_capability_enabled_in_record( + record: &BrowserConnectorSettingsRecord, + action: &str, +) -> bool { + let normalized = normalize_browser_action_capability_key(action); + match browser_action_capability_definition(&normalized) { + Some((key, _, _, _)) => record + .browser_action_capabilities + .get(key) + .copied() + .unwrap_or(true), + None => true, + } +} + +fn browser_action_capability_snapshots( + record: &BrowserConnectorSettingsRecord, +) -> Vec { + BROWSER_ACTION_CAPABILITY_DEFINITIONS + .iter() + .map( + |(key, label, description, group)| BrowserActionCapabilitySnapshot { + key: (*key).to_string(), + label: (*label).to_string(), + description: (*description).to_string(), + group: (*group).to_string(), + enabled: record + .browser_action_capabilities + .get(*key) + .copied() + .unwrap_or(true), + }, + ) + .collect() +} + fn connector_capabilities(id: &str) -> Vec { match id { "reminders" => vec![ @@ -206,6 +335,7 @@ fn connector_probe_script(id: &str) -> Option<&'static str> { } } +#[cfg(target_os = "macos")] fn truncate_connector_error(input: &str) -> String { input .trim() @@ -305,6 +435,12 @@ fn load_settings_record() -> Result { .entry(id) .or_insert(StoredSystemConnectorState::LegacyBool(enabled)); } + for (key, enabled) in default_browser_action_capability_states() { + record + .browser_action_capabilities + .entry(key) + .or_insert(enabled); + } Ok(record) } @@ -574,6 +710,7 @@ fn build_settings_snapshot( enabled: record.enabled, install_root_dir, install_dir, + browser_action_capabilities: browser_action_capability_snapshots(record), system_connectors: if cfg!(target_os = "macos") { SYSTEM_CONNECTOR_DEFINITIONS .iter() @@ -599,6 +736,29 @@ fn build_settings_snapshot( } } +pub fn ensure_browser_action_capability_enabled(action: &str) -> Result<(), String> { + let record = load_settings_record()?; + let normalized = normalize_browser_action_capability_key(action); + let Some((key, label, _, _)) = browser_action_capability_definition(&normalized) else { + return Ok(()); + }; + if browser_action_capability_enabled_in_record(&record, key) { + return Ok(()); + } + Err(format!("浏览器动作已被禁用: {label}")) +} + +pub fn filter_enabled_browser_action_capabilities( + capabilities: &[String], +) -> Result, String> { + let record = load_settings_record()?; + Ok(capabilities + .iter() + .filter(|capability| browser_action_capability_enabled_in_record(&record, capability)) + .cloned() + .collect()) +} + pub fn update_browser_connector_install_root( install_root_dir: &str, ) -> Result { @@ -656,6 +816,24 @@ pub fn update_system_connector_enabled( Ok(build_settings_snapshot(&record)) } +pub fn update_browser_action_capability_enabled( + key: &str, + enabled: bool, +) -> Result { + let normalized = normalize_browser_action_capability_key(key); + let Some((definition_key, _, _, _)) = browser_action_capability_definition(&normalized) else { + return Err(format!("未知的浏览器动作能力: {key}")); + }; + + let mut record = load_settings_record()?; + record + .browser_action_capabilities + .insert(definition_key.to_string(), enabled); + record.updated_at = Utc::now().to_rfc3339(); + save_settings_record(&record)?; + Ok(build_settings_snapshot(&record)) +} + #[cfg(test)] mod tests { use super::*; @@ -749,4 +927,35 @@ mod tests { assert!(snapshot.system_connectors.is_empty()); } + + #[test] + fn settings_snapshot_should_include_browser_action_capabilities() { + let record = BrowserConnectorSettingsRecord::default(); + let snapshot = build_settings_snapshot(&record); + + assert!(!snapshot.browser_action_capabilities.is_empty()); + assert!(snapshot + .browser_action_capabilities + .iter() + .any(|capability| capability.key == "find" && capability.enabled)); + } + + #[test] + fn filter_enabled_browser_action_capabilities_should_hide_disabled_actions() { + let mut record = BrowserConnectorSettingsRecord::default(); + record + .browser_action_capabilities + .insert("find".to_string(), false); + + let filtered = vec![ + "read_page".to_string(), + "find".to_string(), + "click".to_string(), + ] + .into_iter() + .filter(|capability| browser_action_capability_enabled_in_record(&record, capability)) + .collect::>(); + + assert_eq!(filtered, vec!["read_page".to_string(), "click".to_string()]); + } } diff --git a/src-tauri/src/services/browser_profile_service.rs b/src-tauri/src/services/browser_profile_service.rs index c7889c8f2..607ef5141 100644 --- a/src-tauri/src/services/browser_profile_service.rs +++ b/src-tauri/src/services/browser_profile_service.rs @@ -99,6 +99,14 @@ pub fn get_browser_profile( BrowserProfileDao::get_by_id(conn, id).map_err(|error| format!("读取浏览器资料失败: {error}")) } +pub fn get_browser_profile_by_key( + conn: &Connection, + profile_key: &str, +) -> Result, String> { + BrowserProfileDao::get_by_profile_key(conn, profile_key) + .map_err(|error| format!("读取浏览器资料失败: {error}")) +} + pub fn save_browser_profile( conn: &Connection, input: SaveBrowserProfileInput, diff --git a/src-tauri/src/services/site_adapter_registry.rs b/src-tauri/src/services/site_adapter_registry.rs index a020a7c75..dea416b17 100644 --- a/src-tauri/src/services/site_adapter_registry.rs +++ b/src-tauri/src/services/site_adapter_registry.rs @@ -110,8 +110,10 @@ struct SiteAdapterManifestEntry { #[serde(default, alias = "authHint")] auth_hint: Option, entry: SiteAdapterEntryManifest, - #[serde(alias = "scriptFile")] + #[serde(default, alias = "scriptFile")] script_file: String, + #[serde(default)] + script: Option, #[serde(default, alias = "sourceVersion")] source_version: Option, } @@ -435,13 +437,7 @@ fn manifest_entry_to_spec( dir: Option<&Path>, source_kind: SiteAdapterSourceKind, ) -> Result { - let script = if let Some(base_dir) = dir { - let script_path = base_dir.join(&entry.script_file); - fs::read_to_string(&script_path) - .map_err(|error| format!("读取站点适配器脚本失败 {}: {error}", script_path.display()))? - } else { - load_embedded_bundled_script(&entry.script_file)?.to_string() - }; + let script = resolve_manifest_entry_script(&entry, dir, source_kind)?; Ok(SiteAdapterSpec { name: entry.name, @@ -469,6 +465,83 @@ fn manifest_entry_to_spec( }) } +fn resolve_manifest_entry_script( + entry: &SiteAdapterManifestEntry, + dir: Option<&Path>, + source_kind: SiteAdapterSourceKind, +) -> Result { + if let Some(base_dir) = dir { + if let Some(script_file) = normalize_optional_text(Some(entry.script_file.clone())) { + let script_path = base_dir.join(&script_file); + match fs::read_to_string(&script_path) { + Ok(content) => return Ok(content), + Err(error) if should_allow_bundled_script_fallback(source_kind) => { + if let Some(script) = try_load_bundled_fallback_script(&entry.name)? { + return Ok(script); + } + return Err(format!( + "读取站点适配器脚本失败 {}: {error}", + script_path.display() + )); + } + Err(error) => { + return Err(format!( + "读取站点适配器脚本失败 {}: {error}", + script_path.display() + )); + } + } + } + + if let Some(script) = entry + .script + .as_ref() + .and_then(|value| normalize_optional_text(Some(value.clone()))) + { + return Ok(script); + } + + if should_allow_bundled_script_fallback(source_kind) { + if let Some(script) = try_load_bundled_fallback_script(&entry.name)? { + return Ok(script); + } + } + + return Err(format!("站点适配器 {} 缺少 script", entry.name)); + } + + if let Some(script) = entry + .script + .as_ref() + .and_then(|value| normalize_optional_text(Some(value.clone()))) + { + return Ok(script); + } + + if let Some(script_file) = normalize_optional_text(Some(entry.script_file.clone())) { + return load_embedded_bundled_script(&script_file).map(|value| value.to_string()); + } + + if let Some(script) = try_load_bundled_fallback_script(&entry.name)? { + return Ok(script); + } + + Err(format!("站点适配器 {} 缺少 script", entry.name)) +} + +fn should_allow_bundled_script_fallback(source_kind: SiteAdapterSourceKind) -> bool { + !matches!(source_kind, SiteAdapterSourceKind::Bundled) +} + +fn try_load_bundled_fallback_script(adapter_name: &str) -> Result, String> { + let Some(bundled_manifest) = find_embedded_bundled_manifest_entry(adapter_name)? else { + return Ok(None); + }; + Ok(Some( + load_embedded_bundled_script(&bundled_manifest.script_file)?.to_string(), + )) +} + fn normalize_manifest_entry(mut entry: SiteAdapterManifestEntry) -> SiteAdapterManifestEntry { if should_upgrade_legacy_github_search_entry(&entry) { entry.entry = SiteAdapterEntryManifest::UrlTemplate { @@ -587,6 +660,7 @@ fn write_server_synced_catalog_to_dir( auth_hint: normalize_optional_text(entry.auth_hint), entry: entry.entry, script_file, + script: None, source_version: normalize_optional_text(entry.source_version), })); } @@ -1344,6 +1418,119 @@ mod tests { assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced); } + #[test] + fn should_load_legacy_server_synced_catalog_without_script_file_by_falling_back_to_bundled() { + let temp_dir = tempdir().expect("temp dir should exist"); + fs::write( + temp_dir.path().join("index.json"), + serde_json::json!({ + "registry_version": 1, + "catalog_version": "tenant-sync-legacy", + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "legacy server synced github search", + "read_only": true, + "capabilities": ["search"], + "args": [], + "example": "github/search {\"query\":\"lime\"}", + "entry": { + "kind": "fixed_url", + "url": "https://github.com/search" + } + } + ] + }) + .to_string(), + ) + .expect("index.json should write"); + + let adapters = + load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced) + .expect("legacy adapters should load with bundled fallback"); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].name, "github/search"); + assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced); + assert!(adapters[0].script.contains("a.v-align-middle")); + } + + #[test] + fn should_load_legacy_server_synced_catalog_inline_script_without_script_file() { + let temp_dir = tempdir().expect("temp dir should exist"); + fs::write( + temp_dir.path().join("index.json"), + serde_json::json!({ + "registry_version": 1, + "catalog_version": "tenant-sync-inline", + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "legacy inline github search", + "read_only": true, + "capabilities": ["search"], + "args": [], + "example": "github/search {\"query\":\"lime\"}", + "entry": { + "kind": "fixed_url", + "url": "https://github.com/search" + }, + "script": "async () => ({ items: [{ title: \"inline\" }] })" + } + ] + }) + .to_string(), + ) + .expect("index.json should write"); + + let adapters = + load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced) + .expect("legacy adapters should load with inline script"); + assert_eq!(adapters.len(), 1); + assert_eq!( + adapters[0].script, + "async () => ({ items: [{ title: \"inline\" }] })" + ); + } + + #[test] + fn should_fallback_to_bundled_when_server_synced_script_file_is_missing() { + let temp_dir = tempdir().expect("temp dir should exist"); + fs::create_dir_all(temp_dir.path().join("scripts")).expect("scripts dir should exist"); + fs::write( + temp_dir.path().join("index.json"), + serde_json::json!({ + "registry_version": 1, + "catalog_version": "tenant-sync-missing-file", + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "missing file github search", + "read_only": true, + "capabilities": ["search"], + "args": [], + "example": "github/search {\"query\":\"lime\"}", + "entry": { + "kind": "fixed_url", + "url": "https://github.com/search" + }, + "script_file": "scripts/missing.js" + } + ] + }) + .to_string(), + ) + .expect("index.json should write"); + + let adapters = + load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced) + .expect("legacy adapters should load with bundled fallback"); + assert_eq!(adapters.len(), 1); + assert!(adapters[0].script.contains("a.v-align-middle")); + } + #[test] fn should_clear_server_synced_catalog_cache() { let temp_dir = tempdir().expect("temp dir should exist"); diff --git a/src-tauri/src/services/site_capability_service.rs b/src-tauri/src/services/site_capability_service.rs index e515082d5..5b6738cc9 100644 --- a/src-tauri/src/services/site_capability_service.rs +++ b/src-tauri/src/services/site_capability_service.rs @@ -737,20 +737,6 @@ pub async fn run_site_adapter( ); } - let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) { - Ok(value) => value, - Err(error) => { - return build_error_result( - &spec, - profile_key, - None, - None, - entry_url, - "internal_error", - &format!("构造适配器脚本失败: {error}"), - ); - } - }; let timeout_ms = normalize_timeout_ms(request.timeout_ms); let resolved_target_id = attached_session_readiness .and_then(|result| result.target_id) @@ -764,11 +750,25 @@ pub async fn run_site_adapter( resolved_target_id, entry_url, timeout_ms, - wrapped_script, + args, ) .await } SiteAdapterTransportRoute::ManagedCdp => { + let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) { + Ok(value) => value, + Err(error) => { + return build_error_result( + &spec, + profile_key, + None, + None, + entry_url, + "internal_error", + &format!("构造适配器脚本失败: {error}"), + ); + } + }; run_managed_cdp_adapter( db, &spec, @@ -1688,7 +1688,7 @@ async fn run_existing_session_adapter( target_id: Option, entry_url: String, timeout_ms: u64, - wrapped_script: String, + args: Map, ) -> SiteAdapterRunResult { let selected_target = if let Some(explicit_target_id) = target_id .clone() @@ -1785,7 +1785,8 @@ async fn run_existing_session_adapter( text: None, url: None, payload: Some(json!({ - "script": wrapped_script, + "adapter_name": spec.name, + "args": Value::Object(args), })), wait_for_page_info: false, timeout_ms: Some(normalize_adapter_evaluate_timeout_ms(timeout_ms)), diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index cb7b7223d..c413025b4 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -9,8 +9,8 @@ use lime_core::models::parse_skill_manifest_from_content; use lime_core::models::{ BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, - RESEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, - URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY, + RESEARCH_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, + TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY, }; const VIDEO_GENERATE_SKILL_CONTENT: &str = @@ -36,6 +36,15 @@ const URL_PARSE_SKILL_CONTENT: &str = const RESEARCH_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/research/SKILL.md"); +const SITE_SEARCH_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/site_search/SKILL.md"); + +const SITE_SEARCH_ADAPTER_CATALOG_CONTENT: &str = + include_str!("../../resources/default-skills/site_search/references/adapter-catalog.md"); + +const BUNDLED_SITE_ADAPTER_INDEX_CONTENT: &str = + include_str!("../../resources/site-adapters/bundled/index.json"); + const TYPESETTING_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/typesetting/SKILL.md"); @@ -63,7 +72,12 @@ const SOCIAL_POST_WITH_COVER_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFi content: SOCIAL_POST_WITH_COVER_WORKFLOW_CONTENT, }]; -fn default_skills() -> [BundledSkillDefinition; 10] { +const SITE_SEARCH_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFile { + relative_path: "references/adapter-catalog.md", + content: SITE_SEARCH_ADAPTER_CATALOG_CONTENT, +}]; + +fn default_skills() -> [BundledSkillDefinition; 11] { [ BundledSkillDefinition { directory: VIDEO_GENERATE_SKILL_DIRECTORY, @@ -105,6 +119,11 @@ fn default_skills() -> [BundledSkillDefinition; 10] { skill_content: RESEARCH_SKILL_CONTENT, extra_files: &[], }, + BundledSkillDefinition { + directory: SITE_SEARCH_SKILL_DIRECTORY, + skill_content: SITE_SEARCH_SKILL_CONTENT, + extra_files: SITE_SEARCH_EXTRA_FILES, + }, BundledSkillDefinition { directory: TYPESETTING_SKILL_DIRECTORY, skill_content: TYPESETTING_SKILL_CONTENT, @@ -301,6 +320,9 @@ mod tests { assert!(LIBRARY_SKILL_CONTENT.contains("name: library")); assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse")); assert!(RESEARCH_SKILL_CONTENT.contains("name: research")); + assert!(SITE_SEARCH_SKILL_CONTENT.contains("name: site_search")); + assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`github/search`")); + assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`zhihu/hot`")); assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting")); } @@ -318,4 +340,40 @@ mod tests { let workflow_content = fs::read_to_string(workflow_path).expect("read workflow"); assert!(workflow_content.contains("\"cover\"")); } + + #[test] + fn should_sync_extra_files_for_site_search_skill() { + let temp = tempfile::tempdir().expect("create temp dir"); + let skills_root = skills_root_from_base(temp.path()); + ensure_default_local_skills_in_dir(&skills_root).expect("install"); + + let catalog_path = skills_root + .join(SITE_SEARCH_SKILL_DIRECTORY) + .join("references") + .join("adapter-catalog.md"); + assert!(catalog_path.exists()); + let catalog_content = fs::read_to_string(catalog_path).expect("read catalog"); + assert!(catalog_content.contains("`github/search`")); + assert!(catalog_content.contains("`yahoo-finance/quote`")); + } + + #[test] + fn should_cover_all_bundled_site_adapters_in_site_search_catalog() { + let bundled_index = + serde_json::from_str::(BUNDLED_SITE_ADAPTER_INDEX_CONTENT) + .expect("parse bundled site adapter index"); + let adapters = bundled_index["adapters"] + .as_array() + .expect("bundled site adapter index should contain adapters"); + + for adapter in adapters { + let adapter_name = adapter["name"] + .as_str() + .expect("bundled site adapter should contain name"); + assert!( + SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains(&format!("`{adapter_name}`")), + "site_search adapter 目录缺少 bundled adapter: {adapter_name}" + ); + } + } } diff --git a/src-tauri/src/skills/runtime.rs b/src-tauri/src/skills/runtime.rs index fb4633022..016d71b0b 100644 --- a/src-tauri/src/skills/runtime.rs +++ b/src-tauri/src/skills/runtime.rs @@ -94,7 +94,7 @@ async fn ensure_skill_agent_ready( tracing::info!("[execute_skill] Agent 初始化完成"); } - ensure_browser_mcp_tools_registered(aster_state) + ensure_browser_mcp_tools_registered(aster_state, db) .await .map_err(|error| { format_skill_error( diff --git a/src-tauri/src/tray/format.rs b/src-tauri/src/tray/format.rs index 91501888a..8257f6412 100644 --- a/src-tauri/src/tray/format.rs +++ b/src-tauri/src/tray/format.rs @@ -4,6 +4,5 @@ //! 本模块保留兼容导出。 pub use lime_core::tray_format::{ - format_api_address, format_credential_status, format_current_model_status, - format_request_count, format_server_status, + format_credential_status, format_current_model_status, format_request_count, }; diff --git a/src-tauri/src/tray/manager.rs b/src-tauri/src/tray/manager.rs index 478df83b4..2426b2bb8 100644 --- a/src-tauri/src/tray/manager.rs +++ b/src-tauri/src/tray/manager.rs @@ -3,9 +3,9 @@ //! 提供托盘图标和菜单的管理功能 //! //! # Requirements -//! - 1.1: 服务器运行且凭证健康时显示正常状态图标 -//! - 1.4: 应用启动时显示停止状态图标 -//! - 7.1, 7.2, 7.3: 状态变化时更新托盘 +//! - 1.1: 账号可用时显示正常状态图标 +//! - 1.4: 应用启动时显示初始状态图标 +//! - 7.1, 7.2: 状态变化时更新托盘 use super::events::handle_tray_icon_event; use super::menu::build_tray_menu; @@ -185,7 +185,7 @@ impl TrayIcons { /// /// # Requirements /// - 1.1, 1.4: 托盘图标状态管理 -/// - 7.1, 7.2, 7.3: 状态同步和更新 +/// - 7.1, 7.2: 状态同步和更新 pub struct TrayManager { /// Tauri 托盘图标句柄 tray: TrayIcon, @@ -203,7 +203,7 @@ impl TrayManager { /// 初始化托盘图标,设置初始状态为 Stopped /// /// # Requirements - /// - 1.4: 应用启动时显示停止状态图标 + /// - 1.4: 应用启动时显示初始状态图标 pub fn new(app: &AppHandle) -> Result { info!("初始化托盘管理器..."); @@ -263,8 +263,8 @@ impl TrayManager { /// 更新内部状态并刷新图标和菜单 /// /// # Requirements - /// - 7.1: API 服务器状态变化时更新托盘图标 - /// - 7.2: 凭证健康状态变化时更新托盘图标 + /// - 7.1: 账号状态变化时更新托盘图标 + /// - 7.2: 托盘菜单信息变化时更新菜单 pub async fn update_state(&self, snapshot: TrayStateSnapshot) -> Result<(), TrayError> { let old_status = { let state = self.state.read().await; @@ -395,7 +395,7 @@ mod tests { let manager = SimpleTrayManager::new(); let state = manager.get_state().await; assert_eq!(state.icon_status, TrayIconStatus::Stopped); - assert!(!state.server_running); + assert_eq!(state.available_credentials, 0); } #[tokio::test] @@ -404,8 +404,6 @@ mod tests { let new_state = TrayStateSnapshot { icon_status: TrayIconStatus::Running, - server_running: true, - server_address: "127.0.0.1:8080".to_string(), available_credentials: 3, total_credentials: 5, today_requests: 100, @@ -417,8 +415,6 @@ mod tests { let state = manager.get_state().await; assert_eq!(state.icon_status, TrayIconStatus::Running); - assert!(state.server_running); - assert_eq!(state.server_address, "127.0.0.1:8080"); assert_eq!(state.available_credentials, 3); assert_eq!(state.total_credentials, 5); assert_eq!(state.today_requests, 100); diff --git a/src-tauri/src/tray/menu.rs b/src-tauri/src/tray/menu.rs index c3cde801a..8d19dc12f 100644 --- a/src-tauri/src/tray/menu.rs +++ b/src-tauri/src/tray/menu.rs @@ -2,10 +2,7 @@ //! //! 定义菜单项 ID 和菜单构建函数 -use super::format::{ - format_credential_status, format_current_model_status, format_request_count, - format_server_status, -}; +use super::format::{format_credential_status, format_current_model_status, format_request_count}; use super::state::TrayStateSnapshot; use tauri::{ menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, @@ -13,9 +10,7 @@ use tauri::{ }; pub use lime_core::tray_menu_meta::menu_ids; -pub use lime_core::tray_menu_meta::{ - build_quick_model_item_id, get_menu_item_ids, parse_server_address, -}; +pub use lime_core::tray_menu_meta::{build_quick_model_item_id, get_menu_item_ids}; /// 托盘菜单构建错误 #[derive(Debug, thiserror::Error)] @@ -91,27 +86,23 @@ fn build_quick_model_submenu( /// 构建托盘菜单 /// /// 根据当前状态快照构建完整的托盘菜单,包含: -/// - 状态信息(服务器状态、凭证状态、请求统计) -/// - 服务器控制(启动/停止、刷新 Token、健康检查) -/// - 快捷工具(打开主窗口、复制 API 地址、打开日志目录) +/// - 状态信息(凭证状态、请求统计) +/// - 运维动作(刷新 Token、健康检查) +/// - 快捷工具(打开主窗口、打开日志目录) /// - 设置(开机自启) /// - 退出 /// /// # Requirements /// - 2.1: 右键点击托盘图标显示包含所有可用操作的托盘菜单 -/// - 2.2: 显示当前服务器状态,包括运行状态和端口号 -/// - 2.3: 显示凭证池状态,包括可用凭证数和总凭证数 -/// - 2.4: 显示今日请求次数 -/// - 3.1, 3.2, 3.3, 3.4: 服务器控制菜单项 -/// - 4.1, 4.2, 4.3, 4.4: 快捷工具菜单项 +/// - 2.2: 显示凭证池状态,包括可用凭证数和总凭证数 +/// - 2.3: 显示今日请求次数 +/// - 3.3, 3.4: 运行时维护菜单项 +/// - 4.1, 4.3, 4.4: 快捷工具菜单项 /// - 5.1, 5.2: 开机自启设置 pub fn build_tray_menu( app: &AppHandle, state: &TrayStateSnapshot, ) -> Result, MenuBuildError> { - // 解析服务器地址 - let (host, port) = parse_server_address(&state.server_address); - // === 当前模型信息 === let current_model_text = format_current_model_status( &state.current_model_provider_label, @@ -134,17 +125,6 @@ pub fn build_tray_menu( let separator_0 = PredefinedMenuItem::separator(app) .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - // === 状态信息区域 === - let status_text = format_server_status(state.server_running, &host, port); - let status_info = MenuItem::with_id( - app, - menu_ids::STATUS_INFO, - &status_text, - false, - None::<&str>, - ) - .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - let credential_text = format_credential_status(state.available_credentials, state.total_credentials); let credential_info = MenuItem::with_id( @@ -170,28 +150,7 @@ pub fn build_tray_menu( let separator_1 = PredefinedMenuItem::separator(app) .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - // === 服务器控制区域 === - // 启动服务器(服务器未运行时可用) - let start_server = MenuItem::with_id( - app, - menu_ids::START_SERVER, - "启动 Lime 网关", - !state.server_running, - None::<&str>, - ) - .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - - // 停止服务器(服务器运行时可用) - let stop_server = MenuItem::with_id( - app, - menu_ids::STOP_SERVER, - "停止 Lime 网关", - state.server_running, - None::<&str>, - ) - .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - - // 刷新所有 Token + // === 运行时维护区域 === let refresh_tokens = MenuItem::with_id( app, menu_ids::REFRESH_TOKENS, @@ -220,15 +179,6 @@ pub fn build_tray_menu( MenuItem::with_id(app, menu_ids::OPEN_WINDOW, "打开 Lime", true, None::<&str>) .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - let copy_api_address = MenuItem::with_id( - app, - menu_ids::COPY_API_ADDRESS, - "复制网关地址", - state.server_running, - None::<&str>, - ) - .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; - let open_log_dir = MenuItem::with_id( app, menu_ids::OPEN_LOG_DIR, @@ -268,17 +218,13 @@ pub fn build_tray_menu( } items.extend([ &separator_0 as &dyn IsMenuItem, - &status_info, &credential_info, &request_info, &separator_1, - &start_server, - &stop_server, &refresh_tokens, &health_check, &separator_2, &open_window, - ©_api_address, &open_log_dir, &separator_3, &auto_start, @@ -316,61 +262,26 @@ mod tests { let ids = menu_ids::all_required_ids(); // 验证所有预定义的菜单项 ID 都在列表中 - assert!(ids.contains(&menu_ids::STATUS_INFO), "应包含 STATUS_INFO"); assert!( ids.contains(&menu_ids::CREDENTIAL_INFO), "应包含 CREDENTIAL_INFO" ); assert!(ids.contains(&menu_ids::REQUEST_INFO), "应包含 REQUEST_INFO"); - assert!(ids.contains(&menu_ids::START_SERVER), "应包含 START_SERVER"); - assert!(ids.contains(&menu_ids::STOP_SERVER), "应包含 STOP_SERVER"); assert!( ids.contains(&menu_ids::REFRESH_TOKENS), "应包含 REFRESH_TOKENS" ); assert!(ids.contains(&menu_ids::HEALTH_CHECK), "应包含 HEALTH_CHECK"); assert!(ids.contains(&menu_ids::OPEN_WINDOW), "应包含 OPEN_WINDOW"); - assert!( - ids.contains(&menu_ids::COPY_API_ADDRESS), - "应包含 COPY_API_ADDRESS" - ); assert!(ids.contains(&menu_ids::OPEN_LOG_DIR), "应包含 OPEN_LOG_DIR"); assert!(ids.contains(&menu_ids::AUTO_START), "应包含 AUTO_START"); assert!(ids.contains(&menu_ids::QUIT), "应包含 QUIT"); } - #[test] - fn test_parse_server_address_with_port() { - let (host, port) = parse_server_address("127.0.0.1:8080"); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 8080); - } - - #[test] - fn test_parse_server_address_without_port() { - let (host, port) = parse_server_address("localhost"); - assert_eq!(host, "localhost"); - assert_eq!(port, 8080); - } - - #[test] - fn test_parse_server_address_empty() { - let (host, port) = parse_server_address(""); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 8080); - } - - #[test] - fn test_parse_server_address_ipv6() { - let (host, port) = parse_server_address("[::1]:9000"); - assert_eq!(host, "[::1]"); - assert_eq!(port, 9000); - } - #[test] fn test_get_menu_item_ids() { let ids = get_menu_item_ids(); - assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项"); + assert_eq!(ids.len(), 8, "应有 8 个必需的菜单项"); } proptest! { @@ -380,7 +291,6 @@ mod tests { /// 验证对于任意托盘菜单构建,生成的菜单 SHALL 包含所有预定义的菜单项 ID #[test] fn prop_menu_ids_completeness( - _server_running in any::(), _available in 0usize..100, _total in 0usize..100, _requests in 0u64..1000000, @@ -391,15 +301,11 @@ mod tests { // 必须包含所有预定义的 ID let required = vec![ - menu_ids::STATUS_INFO, menu_ids::CREDENTIAL_INFO, menu_ids::REQUEST_INFO, - menu_ids::START_SERVER, - menu_ids::STOP_SERVER, menu_ids::REFRESH_TOKENS, menu_ids::HEALTH_CHECK, menu_ids::OPEN_WINDOW, - menu_ids::COPY_API_ADDRESS, menu_ids::OPEN_LOG_DIR, menu_ids::AUTO_START, menu_ids::QUIT, diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs index eaab14a35..31e8b0253 100644 --- a/src-tauri/src/tray/menu_handler.rs +++ b/src-tauri/src/tray/menu_handler.rs @@ -3,8 +3,8 @@ //! 处理托盘菜单项的点击事件 //! //! # Requirements -//! - 3.1, 3.2, 3.3, 3.4: 服务器控制事件处理 -//! - 4.1, 4.2, 4.3, 4.4: 快捷工具事件处理 +//! - 3.3, 3.4: 运行时维护事件处理 +//! - 4.1, 4.3, 4.4: 快捷工具事件处理 //! - 5.1, 5.2: 设置切换事件处理 use super::menu::menu_ids; @@ -14,17 +14,10 @@ use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_autostart::ManagerExt; use tracing::{debug, error, info, warn}; -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; - /// 菜单事件类型 /// /// 用于前端监听的事件名称 pub mod menu_events { - /// 启动服务器事件 - pub const START_SERVER: &str = "tray-start-server"; - /// 停止服务器事件 - pub const STOP_SERVER: &str = "tray-stop-server"; /// 刷新所有 Token 事件 pub const REFRESH_TOKENS: &str = "tray-refresh-tokens"; /// 健康检查事件 @@ -47,8 +40,8 @@ struct TrayModelSelectedPayload { /// 根据菜单项 ID 执行相应的操作 /// /// # Requirements -/// - 3.1, 3.2, 3.3, 3.4: 服务器控制 -/// - 4.1, 4.2, 4.3, 4.4: 快捷工具 +/// - 3.3, 3.4: 运行时维护 +/// - 4.1, 4.3, 4.4: 快捷工具 /// - 5.1, 5.2: 设置切换 pub fn handle_menu_event(app: &AppHandle, menu_id: &str) { debug!("处理托盘菜单事件: {}", menu_id); @@ -59,15 +52,12 @@ pub fn handle_menu_event(app: &AppHandle, menu_id: &str) { } match menu_id { - // === 服务器控制 === - menu_ids::START_SERVER => handle_start_server(app), - menu_ids::STOP_SERVER => handle_stop_server(app), + // === 运行时维护 === menu_ids::REFRESH_TOKENS => handle_refresh_tokens(app), menu_ids::HEALTH_CHECK => handle_health_check(app), // === 快捷工具 === menu_ids::OPEN_WINDOW => handle_open_window(app), - menu_ids::COPY_API_ADDRESS => handle_copy_api_address(app), menu_ids::OPEN_LOG_DIR => handle_open_log_dir(app), menu_ids::QUIT => handle_quit(app), @@ -75,10 +65,7 @@ pub fn handle_menu_event(app: &AppHandle, menu_id: &str) { menu_ids::AUTO_START => handle_auto_start_toggle(app), // 忽略信息类菜单项和分隔符 - menu_ids::CURRENT_MODEL_INFO - | menu_ids::STATUS_INFO - | menu_ids::CREDENTIAL_INFO - | menu_ids::REQUEST_INFO => { + menu_ids::CURRENT_MODEL_INFO | menu_ids::CREDENTIAL_INFO | menu_ids::REQUEST_INFO => { debug!("忽略信息类菜单项: {}", menu_id); } @@ -104,34 +91,6 @@ fn handle_model_selected(app: &AppHandle, provider_type: String, } } -/// 处理启动服务器事件 -/// -/// # Requirements -/// - 3.1: WHEN API 服务器已停止且用户点击托盘菜单中的"启动服务器" -/// THEN 系统托盘 SHALL 启动 API 服务器并更新托盘图标以反映运行状态 -fn handle_start_server(app: &AppHandle) { - info!("[托盘] 用户请求启动服务器"); - - // 发送事件到前端,由前端调用 start_server 命令 - if let Err(e) = app.emit(menu_events::START_SERVER, ()) { - error!("[托盘] 发送启动服务器事件失败: {}", e); - } -} - -/// 处理停止服务器事件 -/// -/// # Requirements -/// - 3.2: WHEN API 服务器正在运行且用户点击托盘菜单中的"停止服务器" -/// THEN 系统托盘 SHALL 停止 API 服务器并更新托盘图标以反映停止状态 -fn handle_stop_server(app: &AppHandle) { - info!("[托盘] 用户请求停止服务器"); - - // 发送事件到前端,由前端调用 stop_server 命令 - if let Err(e) = app.emit(menu_events::STOP_SERVER, ()) { - error!("[托盘] 发送停止服务器事件失败: {}", e); - } -} - /// 处理刷新所有 Token 事件 /// /// # Requirements @@ -195,73 +154,6 @@ fn handle_open_window(app: &AppHandle) { } } -/// 处理复制 API 地址事件 -/// -/// # Requirements -/// - 4.2: WHEN 用户点击托盘菜单中的"复制 API 地址" -/// THEN 系统托盘 SHALL 将当前 API 服务器地址复制到系统剪贴板 -fn handle_copy_api_address(app: &AppHandle) { - info!("[托盘] 用户请求复制 API 地址"); - - // 获取托盘状态以获取服务器地址 - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - if let Some(tray_state) = app_clone.try_state::>() { - let tray_guard = tray_state.0.read().await; - if let Some(tray_manager) = tray_guard.as_ref() { - let state = tray_manager.get_state().await; - - if state.server_running && !state.server_address.is_empty() { - let api_address = format!("http://{}", state.server_address); - - // 使用剪贴板 API 复制地址 - #[cfg(target_os = "macos")] - { - let _ = std::process::Command::new("pbcopy") - .stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - if let Some(stdin) = child.stdin.as_mut() { - stdin.write_all(api_address.as_bytes())?; - } - child.wait() - }); - info!("[托盘] API 地址已复制到剪贴板: {}", api_address); - } - - #[cfg(target_os = "windows")] - { - let _ = std::process::Command::new("cmd") - .args(["/C", &format!("echo {} | clip", api_address)]) - .creation_flags(0x08000000) // CREATE_NO_WINDOW - .spawn(); - info!("[托盘] API 地址已复制到剪贴板: {}", api_address); - } - - #[cfg(target_os = "linux")] - { - let _ = std::process::Command::new("xclip") - .args(["-selection", "clipboard"]) - .stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - if let Some(stdin) = child.stdin.as_mut() { - stdin.write_all(api_address.as_bytes())?; - } - child.wait() - }); - info!("[托盘] API 地址已复制到剪贴板: {}", api_address); - } - } else { - warn!("[托盘] 服务器未运行,无法复制 API 地址"); - } - } - } - }); -} - /// 处理打开日志目录事件 /// /// # Requirements @@ -300,15 +192,10 @@ fn handle_open_log_dir(app: &AppHandle) { /// /// # Requirements /// - 4.4: WHEN 用户点击托盘菜单中的"退出" -/// THEN 系统托盘 SHALL 优雅地停止 API 服务器并终止应用程序 +/// THEN 系统托盘 SHALL 终止应用程序 fn handle_quit(app: &AppHandle) { info!("[托盘] 用户请求退出应用"); - // 先发送停止服务器事件 - if let Err(e) = app.emit(menu_events::STOP_SERVER, ()) { - warn!("[托盘] 发送停止服务器事件失败: {}", e); - } - // 退出应用 app.exit(0); } @@ -365,8 +252,6 @@ mod tests { #[test] fn test_menu_events_constants() { // 验证事件常量不为空 - assert!(!menu_events::START_SERVER.is_empty()); - assert!(!menu_events::STOP_SERVER.is_empty()); assert!(!menu_events::REFRESH_TOKENS.is_empty()); assert!(!menu_events::HEALTH_CHECK.is_empty()); assert!(!menu_events::AUTO_START_CHANGED.is_empty()); @@ -376,8 +261,6 @@ mod tests { fn test_menu_events_unique() { // 验证事件常量唯一 let events = vec![ - menu_events::START_SERVER, - menu_events::STOP_SERVER, menu_events::REFRESH_TOKENS, menu_events::HEALTH_CHECK, menu_events::AUTO_START_CHANGED, diff --git a/src-tauri/src/tray/mod.rs b/src-tauri/src/tray/mod.rs index b118ca6be..22b4c78af 100644 --- a/src-tauri/src/tray/mod.rs +++ b/src-tauri/src/tray/mod.rs @@ -5,7 +5,6 @@ //! - 托盘菜单构建 //! - 菜单事件处理 //! - 托盘图标点击事件处理 -//! - 状态同步 mod events; mod format; @@ -13,7 +12,6 @@ mod manager; mod menu; mod menu_handler; mod state; -mod sync; pub use events::*; pub use format::*; @@ -21,4 +19,3 @@ pub use manager::*; pub use menu::*; pub use menu_handler::*; pub use state::*; -pub use sync::*; diff --git a/src-tauri/src/tray/sync.rs b/src-tauri/src/tray/sync.rs deleted file mode 100644 index 0a7be513d..000000000 --- a/src-tauri/src/tray/sync.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! 托盘状态同步模块 -//! -//! 提供托盘状态与应用状态的同步功能 -//! -//! # Requirements - -#![allow(dead_code)] -//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标 -//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标 - -use super::state::{calculate_icon_status, CredentialHealth, TrayIconStatus, TrayStateSnapshot}; -use super::TrayManager; -use std::sync::Arc; -use tauri::{AppHandle, Runtime}; -use tokio::sync::RwLock; -use tracing::{debug, info}; - -/// 托盘状态同步器 -/// -/// 负责监听应用状态变化并更新托盘 -pub struct TraySynchronizer { - /// AppHandle 引用 - app: AppHandle, - /// 托盘管理器引用 - tray_manager: Arc>>>, -} - -impl TraySynchronizer { - /// 创建托盘状态同步器 - pub fn new(app: AppHandle, tray_manager: Arc>>>) -> Self { - Self { app, tray_manager } - } - - /// 同步托盘状态 - /// - /// 从应用状态获取最新数据并更新托盘 - /// - /// # Requirements - /// - 7.1: API 服务器状态变化时更新托盘图标 - /// - 7.2: 凭证健康状态变化时更新托盘图标 - pub async fn sync_state( - &self, - server_running: bool, - server_host: &str, - server_port: u16, - credentials: &[CredentialHealth], - today_requests: u64, - auto_start_enabled: bool, - ) -> Result<(), String> { - let tray_guard = self.tray_manager.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - let current_state = tray_manager.get_state().await; - - // 计算图标状态 - let icon_status = calculate_icon_status(server_running, credentials); - - // 计算可用凭证数 - let available_credentials = credentials.iter().filter(|c| c.is_valid).count(); - let total_credentials = credentials.len(); - - // 构建状态快照 - let snapshot = TrayStateSnapshot { - icon_status, - server_running, - server_address: if server_running { - format!("{server_host}:{server_port}") - } else { - String::new() - }, - available_credentials, - total_credentials, - today_requests, - auto_start_enabled, - current_model_provider_type: current_state.current_model_provider_type, - current_model_provider_label: current_state.current_model_provider_label, - current_model: current_state.current_model, - current_theme_label: current_state.current_theme_label, - quick_model_groups: current_state.quick_model_groups, - }; - - // 更新托盘状态 - tray_manager - .update_state(snapshot) - .await - .map_err(|e| e.to_string())?; - - debug!( - "托盘状态已同步: server_running={}, icon_status={:?}, credentials={}/{}", - server_running, icon_status, available_credentials, total_credentials - ); - - Ok(()) - } - - /// 仅更新服务器状态 - /// - /// # Requirements - /// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标 - pub async fn update_server_status( - &self, - server_running: bool, - server_host: &str, - server_port: u16, - ) -> Result<(), String> { - let tray_guard = self.tray_manager.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - // 获取当前状态 - let mut current_state = tray_manager.get_state().await; - - // 更新服务器相关字段 - current_state.server_running = server_running; - current_state.server_address = if server_running { - format!("{server_host}:{server_port}") - } else { - String::new() - }; - - // 重新计算图标状态 - // 如果服务器停止,图标状态为 Stopped - // 否则保持当前状态(凭证状态未变) - if !server_running { - current_state.icon_status = TrayIconStatus::Stopped; - } else if current_state.icon_status == TrayIconStatus::Stopped { - // 服务器启动,但之前是停止状态,设为 Running - current_state.icon_status = TrayIconStatus::Running; - } - - // 更新托盘状态 - tray_manager - .update_state(current_state) - .await - .map_err(|e| e.to_string())?; - - info!( - "托盘服务器状态已更新: running={}, address={}:{}", - server_running, server_host, server_port - ); - - Ok(()) - } - - /// 仅更新凭证健康状态 - /// - /// # Requirements - /// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标 - pub async fn update_credential_health( - &self, - credentials: &[CredentialHealth], - ) -> Result<(), String> { - let tray_guard = self.tray_manager.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - // 获取当前状态 - let mut current_state = tray_manager.get_state().await; - - // 更新凭证相关字段 - current_state.available_credentials = credentials.iter().filter(|c| c.is_valid).count(); - current_state.total_credentials = credentials.len(); - - // 重新计算图标状态 - current_state.icon_status = - calculate_icon_status(current_state.server_running, credentials); - - // 保存日志所需的值 - let available = current_state.available_credentials; - let total = current_state.total_credentials; - let icon_status = current_state.icon_status; - - // 更新托盘状态 - tray_manager - .update_state(current_state) - .await - .map_err(|e| e.to_string())?; - - info!( - "托盘凭证状态已更新: available={}/{}, icon_status={:?}", - available, total, icon_status - ); - - Ok(()) - } - - /// 更新今日请求数 - pub async fn update_request_count(&self, today_requests: u64) -> Result<(), String> { - let tray_guard = self.tray_manager.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - // 获取当前状态 - let mut current_state = tray_manager.get_state().await; - - // 更新请求数 - current_state.today_requests = today_requests; - - // 更新托盘状态(不改变图标) - tray_manager - .update_state(current_state) - .await - .map_err(|e| e.to_string())?; - - debug!("托盘请求数已更新: {}", today_requests); - - Ok(()) - } - - /// 更新自启动状态 - pub async fn update_auto_start(&self, enabled: bool) -> Result<(), String> { - let tray_guard = self.tray_manager.read().await; - let tray_manager = tray_guard - .as_ref() - .ok_or_else(|| "托盘管理器未初始化".to_string())?; - - // 获取当前状态 - let mut current_state = tray_manager.get_state().await; - - // 更新自启动状态 - current_state.auto_start_enabled = enabled; - - // 更新托盘状态(不改变图标) - tray_manager - .update_state(current_state) - .await - .map_err(|e| e.to_string())?; - - debug!("托盘自启动状态已更新: {}", enabled); - - Ok(()) - } -} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index c99532e1e..69d699930 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.98.0", + "version": "0.99.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fa3eed971..993eef8ff 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.98.0", + "version": "0.99.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index 3446cf661..3041940d7 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentPageParams, Page } from "@/types/page"; +import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { AppPageContent } from "./AppPageContent"; const latestAgentChatProps = vi.hoisted( @@ -19,6 +19,10 @@ vi.mock("./agent/chat", () => ({ }, })); +vi.mock("./channels/ImConfigPage", () => ({ + ImConfigPage: () =>
, +})); + interface MountedContent { container: HTMLDivElement; root: Root; @@ -26,7 +30,7 @@ interface MountedContent { const mountedContents: MountedContent[] = []; -function renderContent(pageParams: AgentPageParams) { +function renderContent(currentPage: Page, pageParams: PageParams = {}) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -35,7 +39,7 @@ function renderContent(pageParams: AgentPageParams) { root.render( }> void} onAgentHasMessagesChange={vi.fn()} @@ -100,7 +104,7 @@ describe("AppPageContent", () => { }, }; - renderContent(pageParams); + renderContent("agent", pageParams); await flushEffects(); expect(latestAgentChatProps.value).toMatchObject({ @@ -123,4 +127,11 @@ describe("AppPageContent", () => { }, }); }); + + it("channels 页面应渲染 IM 配置页", async () => { + const container = renderContent("channels"); + await flushEffects(); + + expect(container.querySelector('[data-testid="im-config-page"]')).not.toBeNull(); + }); }); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 7e6435a9c..b83908d61 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -86,6 +86,11 @@ const AutomationPage = lazy(() => default: module.AutomationPage, })), ); +const ImConfigPage = lazy(() => + import("./channels/ImConfigPage").then((module) => ({ + default: module.ImConfigPage, + })), +); const OpenClawPage = lazy(() => import("./openclaw").then((module) => ({ default: module.OpenClawPage, @@ -197,6 +202,18 @@ export function AppPageContent({ ); } + if (currentPage === "channels") { + return ( +
+
+
+ +
+
+
+ ); + } + if (currentPage === "agent") { const agentPageParams = pageParams as AgentPageParams; diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index e4374baf9..18f0801db 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -1,7 +1,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentPageParams, PageParams } from "@/types/page"; +import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { AppSidebar } from "./AppSidebar"; const { mockGetConfig, mockGetPluginsForSurface } = vi.hoisted(() => ({ @@ -26,7 +26,10 @@ const mountedSidebars: MountedSidebar[] = []; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; function mountSidebar( - currentPageParams?: PageParams, + options?: { + currentPage?: Page; + currentPageParams?: PageParams; + }, ): MountedSidebar["container"] { const container = document.createElement("div"); document.body.appendChild(container); @@ -35,8 +38,8 @@ function mountSidebar( act(() => { root.render( , ); @@ -79,8 +82,10 @@ describe("AppSidebar", () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false"); const container = mountSidebar({ - agentEntry: "claw", - } as AgentPageParams); + currentPageParams: { + agentEntry: "claw", + } as AgentPageParams, + }); await flushEffects(); expect( @@ -93,8 +98,10 @@ describe("AppSidebar", () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true"); const container = mountSidebar({ - agentEntry: "new-task", - } as AgentPageParams); + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); await flushEffects(); expect( @@ -103,7 +110,7 @@ describe("AppSidebar", () => { expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false"); }); - it("旧导航配置未包含技能时也应显示固定技能入口", async () => { + it("旧导航配置未包含能力分组时也应显示固定能力入口和二级项", async () => { mockGetConfig.mockResolvedValue({ navigation: { enabled_items: ["home-general", "claw"], @@ -111,10 +118,46 @@ describe("AppSidebar", () => { }); const container = mountSidebar({ - agentEntry: "new-task", - } as AgentPageParams); + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); await flushEffects(); + expect(container.textContent).toContain("能力"); expect(container.textContent).toContain("技能"); + expect(container.textContent).toContain("自动化"); + expect(container.textContent).toContain("IM 配置"); + }); + + it("进入 IM 配置页时应高亮能力分组和对应二级导航", async () => { + const container = mountSidebar({ + currentPage: "channels", + }); + await flushEffects(); + + expect( + container.querySelector('button[aria-label="能力"][aria-current="page"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="IM 配置"][aria-current="page"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="能力"]')?.getAttribute( + "aria-expanded", + ), + ).toBe("true"); + }); + + it("未启用创作主题时不应渲染主题分组标题", async () => { + const container = mountSidebar({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(); + + expect(container.textContent).not.toContain("创作主题"); + expect(container.textContent).not.toContain("社媒内容"); }); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 62a5586c0..326a93278 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -7,6 +7,8 @@ import { useState, useEffect, useMemo, type ReactElement } from "react"; import styled from "styled-components"; import { + ChevronDown, + ChevronRight, Image, Moon, Sun, @@ -352,6 +354,49 @@ const NavLabel = styled.span<{ $collapsed?: boolean }>` display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; `; +const NavTrailingIcon = styled.span<{ $collapsed?: boolean; $active?: boolean }>` + display: ${({ $collapsed }) => ($collapsed ? "none" : "inline-flex")}; + align-items: center; + justify-content: center; + color: ${({ $active }) => + $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"}; + + svg { + width: 14px; + height: 14px; + } +`; + +const NavGroup = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const SubNavList = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + margin-left: 16px; + padding-left: 12px; + border-left: 1px solid var(--sidebar-divider); +`; + +const SubNavButton = styled(NavButton)` + height: 34px; + border-radius: 10px; + padding: 0 10px 0 12px; + + svg { + width: 15px; + height: 15px; + } +`; + +const SubNavLabel = styled(NavLabel)` + font-size: 13px; +`; + const FooterArea = styled.div<{ $collapsed?: boolean }>` margin-top: auto; padding-top: 10px; @@ -510,6 +555,11 @@ export function AppSidebar({ const [_activeThemeKey, setActiveThemeKey] = useState( getThemeWorkspacePage("general"), ); + const [expandedGroupIds, setExpandedGroupIds] = useState(() => + MAIN_SIDEBAR_NAV_ITEMS.filter( + (item) => item.defaultExpanded && item.children && item.children.length > 0, + ).map((item) => item.id), + ); useEffect(() => { const loadNavConfig = async () => { @@ -674,7 +724,15 @@ export function AppSidebar({ }); }, [sidebarPlugins]); - const isActive = (item: SidebarNavItem) => { + const isActive = (item: SidebarNavItem): boolean => { + if (item.children && item.children.length > 0) { + return item.children.some((child) => isActive(child)); + } + + if (!item.page) { + return false; + } + if (item.id.startsWith("theme-")) { return currentPage === item.page; } @@ -686,7 +744,47 @@ export function AppSidebar({ return currentPage === item.page; }; + useEffect(() => { + const isGroupActive = (item: SidebarNavItem): boolean => { + if (item.children && item.children.length > 0) { + return item.children.some((child) => isGroupActive(child)); + } + + if (!item.page) { + return false; + } + + if (item.id.startsWith("theme-")) { + return currentPage === item.page; + } + + if (item.isActive) { + return item.isActive(currentPage, currentPageParams); + } + + return currentPage === item.page; + }; + + const activeGroupIds = MAIN_SIDEBAR_NAV_ITEMS.filter( + (item) => item.children && item.children.length > 0 && isGroupActive(item), + ).map((item) => item.id); + + if (activeGroupIds.length === 0) { + return; + } + + setExpandedGroupIds((current) => { + const next = new Set(current); + activeGroupIds.forEach((id) => next.add(id)); + return Array.from(next); + }); + }, [currentPage, currentPageParams]); + const handleNavigate = (item: SidebarNavItem) => { + if (!item.page) { + return; + } + if (isThemeWorkspacePage(item.page)) { setActiveThemeKey(item.page); localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, item.page); @@ -708,6 +806,14 @@ export function AppSidebar({ onNavigate(item.page, params); }; + const toggleGroup = (groupId: string) => { + setExpandedGroupIds((current) => + current.includes(groupId) + ? current.filter((id) => id !== groupId) + : [...current, groupId], + ); + }; + const maybeWrapWithTooltip = (node: ReactElement, label: string) => { if (!collapsed) { return node; @@ -721,6 +827,74 @@ export function AppSidebar({ ); }; + const renderNavItem = (item: SidebarNavItem, nested: boolean = false) => { + const active = isActive(item); + + if (item.children && item.children.length > 0) { + const expanded = expandedGroupIds.includes(item.id); + const groupButton = ( + { + if (collapsed) { + setCollapsed(false); + if (!expanded) { + setExpandedGroupIds((current) => + current.includes(item.id) ? current : [...current, item.id], + ); + } + return; + } + + toggleGroup(item.id); + }} + title={item.label} + aria-label={item.label} + aria-current={active ? "page" : undefined} + aria-expanded={collapsed ? undefined : expanded} + > + + {item.label} + + {expanded ? : } + + + ); + + return ( + + {maybeWrapWithTooltip(groupButton, item.label)} + {!collapsed && expanded ? ( + + {item.children.map((child) => renderNavItem(child, true))} + + ) : null} + + ); + } + + const ButtonComponent = nested ? SubNavButton : NavButton; + const LabelComponent = nested ? SubNavLabel : NavLabel; + const button = ( + handleNavigate(item)} + title={item.label} + aria-label={item.label} + aria-current={active ? "page" : undefined} + > + + {item.label} + + ); + + return maybeWrapWithTooltip(button, item.label); + }; + return ( @@ -768,85 +942,27 @@ export function AppSidebar({
- {filteredMainMenuItems.map((item) => - maybeWrapWithTooltip( - handleNavigate(item)} - title={item.label} - aria-label={item.label} - > - - {item.label} - , - item.label, - ), - )} + {filteredMainMenuItems.map((item) => renderNavItem(item))}
-
- 创作主题 - {filteredThemeMenuItems.map((item) => - maybeWrapWithTooltip( - handleNavigate(item)} - title={item.label} - aria-label={item.label} - > - - {item.label} - , - item.label, - ), - )} -
+ {filteredThemeMenuItems.length > 0 && ( +
+ 创作主题 + {filteredThemeMenuItems.map((item) => renderNavItem(item))} +
+ )} {assistantItems.length > 0 && (
助手 - {assistantItems.map((item) => - maybeWrapWithTooltip( - handleNavigate(item)} - title={item.label} - aria-label={item.label} - > - - {item.label} - , - item.label, - ), - )} + {assistantItems.map((item) => renderNavItem(item))}
)}
- {filteredFooterMenuItems.map((item) => - maybeWrapWithTooltip( - handleNavigate(item)} - title={item.label} - aria-label={item.label} - > - - {item.label} - , - item.label, - ), - )} + {filteredFooterMenuItems.map((item) => renderNavItem(item))}
diff --git a/src/components/Modal.test.tsx b/src/components/Modal.test.tsx index 71670063f..9f6ad4423 100644 --- a/src/components/Modal.test.tsx +++ b/src/components/Modal.test.tsx @@ -1,7 +1,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Modal } from "./Modal"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "./Modal"; interface MountedRoot { container: HTMLDivElement; @@ -23,10 +23,15 @@ function renderModal() { draggable={true} dragHandleSelector='[data-drag-handle="true"]' > -
+
拖拽头部
+
+
弹窗内容
-
+ + + + , ); }); @@ -94,4 +99,25 @@ describe("Modal", () => { window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); }); }); + + it("应为小屏场景提供可滚动的遮罩层与内容区", () => { + renderModal(); + + const overlay = document.body.querySelector( + '[data-testid="modal-overlay"]', + ) as HTMLDivElement | null; + const dialog = document.body.querySelector( + '[role="dialog"]', + ) as HTMLDivElement | null; + const body = document.body.querySelector( + '[data-testid="modal-body"]', + ) as HTMLDivElement | null; + + expect(overlay?.className).toContain("overflow-y-auto"); + expect(dialog?.className).toContain("max-h-[calc(100vh-2rem)]"); + expect(dialog?.className).toContain("flex-col"); + expect(body?.className).toContain("flex-1"); + expect(body?.className).toContain("overflow-y-auto"); + expect(body?.className).toContain("min-h-0"); + }); }); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index b403bcb73..02ce9424a 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -79,8 +79,8 @@ export function Modal({ if (!isOpen) return null; - const handleOverlayClick = (e: MouseEvent) => { - if (closeOnOverlayClick && e.target === e.currentTarget) { + const handleOverlayClick = () => { + if (closeOnOverlayClick) { onClose(); } }; @@ -146,29 +146,35 @@ export function Modal({ return createPortal(
-
- {showCloseButton && ( - - )} - {children} +
+
event.stopPropagation()} + onMouseDown={handleDragStart} + style={{ + transform: + dragOffset.x !== 0 || dragOffset.y !== 0 + ? `translate(${dragOffset.x}px, ${dragOffset.y}px)` + : undefined, + }} + > + {showCloseButton && ( + + )} + {children} +
, document.body, @@ -184,7 +190,7 @@ export function ModalHeader({ className?: string; }) { return ( -
+

{children}

); @@ -198,7 +204,14 @@ export function ModalBody({ children: ReactNode; className?: string; }) { - return
{children}
; + return ( +
+ {children} +
+ ); } /** Modal 底部操作区 */ @@ -210,7 +223,7 @@ export function ModalFooter({ className?: string; }) { return ( -
+
{children}
); diff --git a/src/components/README.md b/src/components/README.md index 5548f115b..ed4dea9c7 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -10,7 +10,6 @@ React 组件层,包含 UI 组件和业务组件。 ## 文件索引 - `agent/` - AI Agent 聊天页面组件 -- `api-server/` - API 服务器配置组件 - `clients/` - 客户端管理组件 - `config/` - 配置管理组件 - `connect/` - Lime Connect 组件(中转商 API Key 添加) diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index 8ff094317..f555d6bcb 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -329,6 +329,7 @@ const { vi.mock("./components/EmptyState", () => ({ EmptyState: ({ onSend, + onLaunchBrowserAssist, onRecommendationClick, supportingSlotOverride, serviceSkills, @@ -339,6 +340,7 @@ vi.mock("./components/EmptyState", () => ({ executionStrategy?: unknown, images?: Array<{ data: string; mediaType: string }>, ) => void; + onLaunchBrowserAssist?: () => void; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; supportingSlotOverride?: React.ReactNode; serviceSkills?: Array<{ id: string; title: string }>; @@ -352,6 +354,15 @@ vi.mock("./components/EmptyState", () => ({ > 发送 + - {serviceSkills?.[0] && onSelectServiceSkill ? ( + {onLaunchBrowserAssist ? ( ) : null} + {serviceSkills?.map((skill) => + onSelectServiceSkill ? ( + + + + ) : null, + )} {supportingSlotOverride} ), @@ -536,49 +560,6 @@ vi.mock("./service-skills/automationLinkStorage", () => ({ recordServiceSkillAutomationLink: mockRecordServiceSkillAutomationLink, })); -vi.mock("./service-skills/ServiceSkillHomePanel", () => ({ - ServiceSkillHomePanel: ({ - skills, - onSelect, - onOpenAutomationJob, - }: { - skills: Array<{ - id: string; - title: string; - automationStatus?: { jobId: string } | null; - }>; - onSelect: (skill: { id: string; title: string }) => void; - onOpenAutomationJob?: (skill: { - id: string; - title: string; - automationStatus?: { jobId: string } | null; - }) => void; - }) => ( - <> - {skills.map((skill) => ( - - - {skill.automationStatus && onOpenAutomationJob ? ( - - ) : null} - - ))} - - ), -})); - vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ ServiceSkillLaunchDialog: ({ skill, @@ -946,6 +927,87 @@ describe("AgentChatHomeShell", () => { expect(onEnterWorkspace).not.toHaveBeenCalled(); }); + it("首页普通自然句命中站点 skill 时应直接走 service skill 启动链", async () => { + const onNavigate = vi.fn(); + const onEnterWorkspace = vi.fn(); + const { container } = renderShell({ + onNavigate, + onEnterWorkspace, + }); + + await flushEffects(); + + const sendButton = container.querySelector( + '[data-testid="home-shell-send-github-site-skill"]', + ) as HTMLButtonElement | null; + + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + await flushEffects(); + + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + projectId: "project-1", + contentId: "content-service-skill-1", + theme: "general", + lockTheme: true, + initialCreationMode: "guided", + autoRunInitialPromptOnMount: true, + initialUserPrompt: "你帮我在 GitHub 找一下和“AI Agent”相关的项目。", + initialAutoSendRequestMetadata: { + harness: expect.objectContaining({ + browser_requirement: "required", + service_skill_launch: expect.objectContaining({ + adapter_name: "github/search", + args: { + query: "AI Agent", + limit: 10, + }, + }), + }), + }, + }), + ); + 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 browserRuntimeButton = container.querySelector( + '[data-testid="home-shell-open-browser-runtime"]', + ) as HTMLButtonElement | null; + + expect(browserRuntimeButton).toBeTruthy(); + + act(() => { + browserRuntimeButton?.click(); + }); + + await flushEffects(); + + expect(onNavigate).toHaveBeenCalledWith("browser-runtime", { + projectId: "project-1", + }); + expect(onEnterWorkspace).not.toHaveBeenCalled(); + }); + it("点击 team 推荐时应开启多代理偏好并直接进入工作区", async () => { const onEnterWorkspace = vi.fn(); const { container } = renderShell({ @@ -1301,7 +1363,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-service-skill-short-video-script-replication"]', + '[data-testid="home-shell-empty-state-service-skill-short-video-script-replication"]', ) as HTMLButtonElement | null; expect(serviceSkillButton).toBeTruthy(); @@ -1369,7 +1431,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const mentionServiceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill"]', + '[data-testid="home-shell-empty-state-service-skill-short-video-script-replication"]', ) as HTMLButtonElement | null; expect(mentionServiceSkillButton).toBeTruthy(); @@ -1398,7 +1460,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-service-skill-github-repo-radar"]', + '[data-testid="home-shell-empty-state-service-skill-github-repo-radar"]', ) as HTMLButtonElement | null; expect(serviceSkillButton).toBeTruthy(); @@ -1439,28 +1501,53 @@ describe("AgentChatHomeShell", () => { contentId: "content-service-skill-1", theme: "general", initialCreationMode: "guided", - initialRequestMetadata: { - artifact: { - artifact_mode: "draft", - artifact_kind: "analysis", - workbench_surface: "right_panel", + autoRunInitialPromptOnMount: true, + initialUserPrompt: "你帮我在 GitHub 找一下和“browser assist mcp”相关的项目。", + initialRequestMetadata: undefined, + initialAutoSendRequestMetadata: { + harness: { + browser_requirement: "required", + browser_requirement_reason: expect.stringContaining( + "真实浏览器页面", + ), + browser_assist: { + enabled: true, + profile_key: "attached-github", + preferred_backend: "lime_extension_bridge", + auto_launch: false, + stream_mode: "both", + }, + service_skill_launch: expect.objectContaining({ + adapter_name: "github/search", + content_id: "content-service-skill-1", + project_id: "project-1", + save_mode: "current_content", + args: { + query: "browser assist mcp", + limit: 10, + }, + launch_readiness: expect.objectContaining({ + status: "ready", + profile_key: "attached-github", + target_id: "tab-github", + }), + }), }, }, - 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 仓库线索检索", - }, }), ); + const firstSiteSkillLaunchPayload = onNavigate.mock.calls.find( + ([route]) => route === "agent", + )?.[1]; + expect(firstSiteSkillLaunchPayload?.initialUserPrompt).not.toContain( + "[站点技能启动上下文]", + ); + expect(firstSiteSkillLaunchPayload?.initialUserPrompt).not.toContain( + "adapter_name", + ); + expect(firstSiteSkillLaunchPayload?.initialAutoSendRequestMetadata).not.toHaveProperty( + "artifact", + ); expect(onEnterWorkspace).not.toHaveBeenCalled(); expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ skillId: "github-repo-radar", @@ -1479,7 +1566,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-service-skill-github-repo-radar"]', + '[data-testid="home-shell-empty-state-service-skill-github-repo-radar"]', ) as HTMLButtonElement | null; act(() => { @@ -1517,7 +1604,7 @@ describe("AgentChatHomeShell", () => { expect(onEnterWorkspace).not.toHaveBeenCalled(); }); - it("站点型服务技能缺少附着会话时不应进入 Claw 工作区", async () => { + it("站点型服务技能缺少附着会话时应留在入口层并提示先准备浏览器", async () => { const onNavigate = vi.fn(); const onEnterWorkspace = vi.fn(); mockSiteGetAdapterLaunchReadiness.mockResolvedValueOnce({ @@ -1535,7 +1622,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-service-skill-github-repo-radar"]', + '[data-testid="home-shell-empty-state-service-skill-github-repo-radar"]', ) as HTMLButtonElement | null; act(() => { @@ -1554,9 +1641,15 @@ describe("AgentChatHomeShell", () => { await flushEffects(); - expect(onNavigate).not.toHaveBeenCalledWith("agent", expect.anything()); + expect(onNavigate).not.toHaveBeenCalledWith( + "agent", + expect.anything(), + ); expect(onEnterWorkspace).not.toHaveBeenCalled(); - expect(mockToastError).toHaveBeenCalled(); + expect(mockToastInfo).toHaveBeenCalledWith( + expect.stringContaining("请先去浏览器工作台连接真实浏览器"), + ); + expect(mockToastError).not.toHaveBeenCalled(); }); it("cloud_required 服务型技能成功后应回流本地工作区", async () => { @@ -1598,7 +1691,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-service-skill-cloud-video-dubbing"]', + '[data-testid="home-shell-empty-state-service-skill-cloud-video-dubbing"]', ) as HTMLButtonElement | null; expect(serviceSkillButton).toBeTruthy(); @@ -1691,7 +1784,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-service-skill-daily-trend-briefing"]', + '[data-testid="home-shell-empty-state-service-skill-daily-trend-briefing"]', ) as HTMLButtonElement | null; expect(serviceSkillButton).toBeTruthy(); @@ -1828,28 +1921,16 @@ describe("AgentChatHomeShell", () => { }); }); - it("点击服务型技能任务状态后应跳转到 automation 对应任务", async () => { - const onNavigate = vi.fn(); - const { container } = renderShell({ - onNavigate, - }); + it("首页不应再展示 service skill 专属任务状态快捷入口", async () => { + const { container } = renderShell(); await flushEffects(); const openAutomationButton = container.querySelector( - '[data-testid="home-shell-service-skill-open-automation-daily-trend-briefing"]', - ) as HTMLButtonElement | null; + '[data-testid="home-shell-empty-state-service-skill-open-automation-daily-trend-briefing"]', + ); - expect(openAutomationButton).toBeTruthy(); - - act(() => { - openAutomationButton?.click(); - }); - - expect(onNavigate).toHaveBeenCalledWith("automation", { - selectedJobId: "automation-job-daily-trend", - workspaceTab: "tasks", - }); + expect(openAutomationButton).toBeFalsy(); }); it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => { diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index 3c99a1d58..dc336a2be 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -20,6 +20,7 @@ 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 type { MessageImage } from "./types"; import { alignChatToolPreferencesWithExecutionStrategy, saveChatToolPreferences, @@ -52,8 +53,8 @@ import { type HomeShellEnterWorkspacePayload, } from "./homeShellEntry"; import { useServiceSkills } from "./service-skills/useServiceSkills"; -import { ServiceSkillHomePanel } from "./service-skills/ServiceSkillHomePanel"; import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDialog"; +import { matchAutoLaunchSiteSkillFromText } from "./service-skills/autoMatchSiteSkill"; import { composeServiceSkillPrompt } from "./service-skills/promptComposer"; import { buildServiceSkillAutomationAgentTurnPayloadContext, @@ -68,9 +69,14 @@ import type { } from "./service-skills/types"; import { buildServiceSkillWorkspaceSeed } from "./service-skills/workspaceLaunch"; import { + buildSiteLaunchBlockedMessage, + buildServiceSkillClawLaunchContext, + buildServiceSkillClawLaunchRequestMetadata, buildServiceSkillSiteCapabilityArgs, buildServiceSkillSiteCapabilitySaveTitle, + composeServiceSkillClawLaunchPrompt, isServiceSkillExecutableAsSiteAdapter, + isSiteLaunchReadinessReady, } from "./service-skills/siteCapabilityBinding"; const PageContainer = styled.div<{ $compact?: boolean }>` @@ -187,6 +193,21 @@ function normalizeOptionalText(value?: string | null): string | undefined { return normalized ? normalized : undefined; } +interface ServiceSkillLaunchOptions { + launchUserInput?: string | null; +} + +function resolveServiceSkillLaunchUserInput( + currentInput: string, + options?: ServiceSkillLaunchOptions, +): string | undefined { + if (options && "launchUserInput" in options) { + return normalizeOptionalText(options.launchUserInput); + } + + return normalizeOptionalText(currentInput); +} + function buildServiceSkillCloudResultBody( skill: ServiceSkillHomeItem, run: ServiceSkillRun, @@ -391,9 +412,9 @@ export function AgentChatHomeShell({ } = useClawSolutions(activeTheme === "general"); const { skills: serviceSkills, - groups: serviceSkillGroups, - catalogMeta: serviceSkillCatalogMeta, - isLoading: serviceSkillsLoading, + groups: _serviceSkillGroups, + catalogMeta: _serviceSkillCatalogMeta, + isLoading: _serviceSkillsLoading, error: serviceSkillsError, recordUsage: recordServiceSkillUsage, } = useServiceSkills(activeTheme === "general"); @@ -472,6 +493,27 @@ export function AgentChatHomeShell({ [externalProjectId, rememberProjectId, setCurrentProjectId], ); + const handleOpenBrowserRuntimeFromHome = useCallback(() => { + if (!onNavigate) { + toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。"); + return; + } + + const normalizedProjectId = normalizeProjectId(currentProjectId); + if (normalizedProjectId) { + rememberProjectId(normalizedProjectId); + } + + onNavigate( + "browser-runtime", + normalizedProjectId + ? { + projectId: normalizedProjectId, + } + : undefined, + ); + }, [currentProjectId, onNavigate, rememberProjectId]); + const handleEnterWorkspace = useCallback( (payload: HomeShellEnterWorkspacePayload) => { const normalizedProjectId = normalizeProjectId(currentProjectId); @@ -617,7 +659,7 @@ export function AgentChatHomeShell({ [], ); - const handleOpenServiceSkillAutomationJob = useCallback( + const _handleOpenServiceSkillAutomationJob = useCallback( (skill: ServiceSkillHomeItem) => { const jobId = skill.automationStatus?.jobId; if (!jobId || !onNavigate) { @@ -773,12 +815,19 @@ export function AgentChatHomeShell({ async ( skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues, - launchReadiness: Awaited>, + launchReadiness: + | Awaited> + | null, + options?: ServiceSkillLaunchOptions, ): Promise => { if (!isServiceSkillExecutableAsSiteAdapter(skill)) { throw new Error("当前技能未绑定站点执行能力"); } + if (!isSiteLaunchReadinessReady(launchReadiness)) { + throw new Error(buildSiteLaunchBlockedMessage(launchReadiness)); + } + const normalizedProjectId = normalizeProjectId(currentProjectId); if ( skill.readinessRequirements?.requiresProject && @@ -788,10 +837,6 @@ export function AgentChatHomeShell({ } const binding = skill.siteCapabilityBinding; const saveMode = binding.saveMode ?? "project_resource"; - const initialArgs = buildServiceSkillSiteCapabilityArgs( - skill, - slotValues, - ); const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle( skill, slotValues, @@ -806,30 +851,31 @@ export function AgentChatHomeShell({ nextContentId = created?.id ?? undefined; } - const seed = buildServiceSkillWorkspaceSeed( + const clawLaunchContext = { + ...buildServiceSkillClawLaunchContext(skill, slotValues, { + contentId: nextContentId, + projectId: normalizedProjectId, + launchReadiness, + }), + saveTitle: nextContentId ? undefined : initialSaveTitle, + }; + const prompt = composeServiceSkillClawLaunchPrompt({ skill, - skill.themeTarget ?? activeTheme, - ); + slotValues, + userInput: resolveServiceSkillLaunchUserInput(input, options), + context: clawLaunchContext, + }); return { + prompt, 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, - }, + initialAutoSendRequestMetadata: + buildServiceSkillClawLaunchRequestMetadata(clawLaunchContext), + autoRunInitialPromptOnMount: true, }; }, - [activeTheme, createServiceSkillSeededContent, currentProjectId], + [createServiceSkillSeededContent, currentProjectId, input], ); const handleServiceSkillBrowserRuntimeLaunch = useCallback( @@ -925,26 +971,25 @@ export function AgentChatHomeShell({ ); const handleServiceSkillLaunch = useCallback( - async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => { + async ( + skill: ServiceSkillHomeItem, + slotValues: ServiceSkillSlotValues, + options?: ServiceSkillLaunchOptions, + ) => { if (isServiceSkillExecutableAsSiteAdapter(skill)) { - let launchReadiness: Awaited< - ReturnType - >; + let launchReadiness: + | Awaited> + | null = null; try { launchReadiness = await siteGetAdapterLaunchReadiness({ adapter_name: skill.siteCapabilityBinding.adapterName, }); - } catch (error) { - toast.error(`检测浏览器会话失败:${getErrorMessage(error)}`); - return; + } catch { + // 门禁检查失败时保持弹窗可见,交由后续阻断提示兜底。 } - if (launchReadiness.status !== "ready") { - toast.error( - launchReadiness.report_hint - ? `${launchReadiness.message} ${launchReadiness.report_hint}` - : launchReadiness.message, - ); + if (!isSiteLaunchReadinessReady(launchReadiness)) { + toast.info(buildSiteLaunchBlockedMessage(launchReadiness)); return; } @@ -954,6 +999,7 @@ export function AgentChatHomeShell({ skill, slotValues, launchReadiness, + options, ); } catch (error) { toast.error(`准备站点技能失败:${getErrorMessage(error)}`); @@ -977,7 +1023,7 @@ export function AgentChatHomeShell({ const prompt = composeServiceSkillPrompt({ skill, slotValues, - userInput: input.trim() || undefined, + userInput: resolveServiceSkillLaunchUserInput(input, options), }); if (skill.executionLocation === "cloud_required") { @@ -1104,6 +1150,54 @@ export function AgentChatHomeShell({ ], ); + const handleEmptyStateSend = useCallback( + async ( + value: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + images?: MessageImage[], + ) => { + if (sendExecutionStrategy) { + setExecutionStrategy(sendExecutionStrategy); + } + + const trimmedValue = value.trim(); + if ( + activeTheme === "general" && + !images?.length && + trimmedValue && + !trimmedValue.startsWith("/") && + !trimmedValue.startsWith("@") + ) { + const matchedSiteSkill = matchAutoLaunchSiteSkillFromText({ + inputText: trimmedValue, + serviceSkills, + }); + if (matchedSiteSkill) { + await handleServiceSkillLaunch( + matchedSiteSkill.skill, + matchedSiteSkill.slotValues, + { + launchUserInput: matchedSiteSkill.launchUserInput, + }, + ); + return; + } + } + + handleEnterWorkspace({ + prompt: value, + images, + }); + }, + [ + activeTheme, + handleEnterWorkspace, + handleServiceSkillLaunch, + serviceSkills, + setExecutionStrategy, + ], + ); + const handleServiceSkillAutomationSetup = useCallback( async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => { if (!supportsServiceSkillLocalAutomation(skill)) { @@ -1304,13 +1398,11 @@ export function AgentChatHomeShell({ input={input} setInput={setInput} onSend={(value, sendExecutionStrategy, images) => { - if (sendExecutionStrategy) { - setExecutionStrategy(sendExecutionStrategy); - } - handleEnterWorkspace({ - prompt: value, + void handleEmptyStateSend( + value, + sendExecutionStrategy, images, - }); + ); }} providerType={providerType} setProviderType={setProviderType} @@ -1363,23 +1455,11 @@ export function AgentChatHomeShell({ onRecommendationClick={handleRecommendationClick} supportingSlotOverride={ activeTheme === "general" ? ( - <> - - - + ) : undefined } characters={projectMemory?.characters || []} @@ -1391,16 +1471,11 @@ export function AgentChatHomeShell({ onNavigate?.("skills"); }} onRefreshSkills={handleRefreshSkills} - onLaunchBrowserAssist={() => { - if (activeTheme !== "general") { - return; - } - setBrowserAssistLoading(true); - handleEnterWorkspace({ - prompt: input, - openBrowserAssistOnMount: true, - }); - }} + onLaunchBrowserAssist={ + activeTheme === "general" + ? handleOpenBrowserRuntimeFromHome + : undefined + } browserAssistLoading={browserAssistLoading} projectId={currentProjectId} onProjectChange={handleProjectChange} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 9465cfbf0..abc076f92 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -121,8 +121,10 @@ import { } from "@/lib/style-guide"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; import { + asRecord, isResumableBrowserTaskReason, mergeMessageArtifactsIntoStore, + readFirstString, } from "./workspace/browserAssistArtifact"; import { ServiceSkillExecutionCard } from "./workspace/ServiceSkillExecutionCard"; import { useWorkspaceBrowserAssistRuntime } from "./workspace/useWorkspaceBrowserAssistRuntime"; @@ -224,6 +226,7 @@ export function AgentChatWorkspace({ projectId: externalProjectId, contentId, initialRequestMetadata, + initialAutoSendRequestMetadata, autoRunInitialPromptOnMount = false, agentEntry = "claw", theme: initialTheme, @@ -1466,12 +1469,12 @@ export function AgentChatWorkspace({ }); const { browserAssistLaunching, + browserAssistSessionState, siteSkillExecutionState, isBrowserAssistReady, isBrowserAssistCanvasVisible, currentBrowserAssistScopeKey, ensureBrowserAssistCanvas, - handleOpenBrowserAssistInCanvas, suppressBrowserAssistCanvasAutoOpen, suppressGeneralCanvasArtifactAutoOpen, } = useWorkspaceBrowserAssistRuntime({ @@ -1486,13 +1489,125 @@ export function AgentChatWorkspace({ siteSkillLaunchNonce: newChatAt, artifacts, messages, - currentCanvasArtifact, - layoutMode, setLayoutMode, - setSelectedArtifactId, upsertGeneralArtifact, generalBrowserAssistProfileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, }); + const initialHarnessBrowserAssist = useMemo(() => { + return asRecord( + asRecord(initialAutoSendRequestMetadata)?.harness + ? asRecord(asRecord(initialAutoSendRequestMetadata)?.harness) + ?.browser_assist + : undefined, + ); + }, [initialAutoSendRequestMetadata]); + const initialHarnessPreferredBackend = useMemo(() => { + const value = readFirstString( + initialHarnessBrowserAssist ? [initialHarnessBrowserAssist] : [], + ["preferred_backend", "preferredBackend"], + ); + return value === "lime_extension_bridge" || value === "cdp_direct" + ? value + : undefined; + }, [initialHarnessBrowserAssist]); + const initialHarnessAutoLaunch = useMemo(() => { + const rawValue = + initialHarnessBrowserAssist?.auto_launch ?? + initialHarnessBrowserAssist?.autoLaunch; + return typeof rawValue === "boolean" ? rawValue : undefined; + }, [initialHarnessBrowserAssist]); + const browserAssistRequestProfileKey = useMemo(() => { + if (mappedTheme !== "general") { + return undefined; + } + + return ( + browserAssistSessionState?.profileKey?.trim() || + initialSiteSkillLaunch?.profileKey?.trim() || + GENERAL_BROWSER_ASSIST_PROFILE_KEY + ); + }, [ + browserAssistSessionState?.profileKey, + initialSiteSkillLaunch?.profileKey, + mappedTheme, + ]); + const shouldPreferExistingSessionBridgeForClaw = useMemo(() => { + if (mappedTheme !== "general") { + return false; + } + + return ( + browserAssistSessionState?.transportKind === "existing_session" || + initialHarnessPreferredBackend === "lime_extension_bridge" || + initialHarnessAutoLaunch === false || + Boolean(initialSiteSkillLaunch?.profileKey?.trim()) || + Boolean(initialSiteSkillLaunch?.requireAttachedSession) || + initialSiteSkillLaunch?.preferredBackend === "lime_extension_bridge" || + initialSiteSkillLaunch?.autoLaunch === false + ); + }, [ + browserAssistSessionState?.transportKind, + initialHarnessAutoLaunch, + initialHarnessPreferredBackend, + initialSiteSkillLaunch?.autoLaunch, + initialSiteSkillLaunch?.profileKey, + initialSiteSkillLaunch?.preferredBackend, + initialSiteSkillLaunch?.requireAttachedSession, + mappedTheme, + ]); + const browserAssistRequestPreferredBackend = + initialSiteSkillLaunch?.preferredBackend || + initialHarnessPreferredBackend || + (shouldPreferExistingSessionBridgeForClaw + ? "lime_extension_bridge" + : undefined); + const browserAssistRequestAutoLaunch = + initialSiteSkillLaunch?.autoLaunch ?? + initialHarnessAutoLaunch ?? + (shouldPreferExistingSessionBridgeForClaw ? false : true); + const handleOpenBrowserRuntimeForBrowserAssist = useCallback( + (artifact?: Artifact) => { + if (!_onNavigate) { + toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。"); + return; + } + + const artifactMeta = artifact ? asRecord(artifact.meta) : null; + _onNavigate("browser-runtime", { + projectId: projectId ?? undefined, + contentId: contentId ?? undefined, + initialProfileKey: + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "profileKey", + "profile_key", + ]) || + browserAssistSessionState?.profileKey || + GENERAL_BROWSER_ASSIST_PROFILE_KEY, + initialSessionId: + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "sessionId", + "session_id", + ]) || + browserAssistSessionState?.sessionId || + undefined, + initialTargetId: + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "targetId", + "target_id", + ]) || + browserAssistSessionState?.targetId || + undefined, + }); + }, + [ + _onNavigate, + browserAssistSessionState?.profileKey, + browserAssistSessionState?.sessionId, + browserAssistSessionState?.targetId, + contentId, + projectId, + ], + ); const handleOpenBrowserRuntimeForSiteSkillExecution = useCallback(() => { if (!_onNavigate || !initialSiteSkillLaunch?.adapterName?.trim()) { return; @@ -1502,7 +1617,8 @@ export function AgentChatWorkspace({ projectId: projectId ?? undefined, contentId: contentId ?? undefined, initialProfileKey: - siteSkillExecutionState?.profileKey || initialSiteSkillLaunch.profileKey, + siteSkillExecutionState?.profileKey || + initialSiteSkillLaunch.profileKey, initialTargetId: siteSkillExecutionState?.targetId || initialSiteSkillLaunch.targetId, initialAdapterName: initialSiteSkillLaunch.adapterName, @@ -1530,10 +1646,7 @@ export function AgentChatWorkspace({ } /> ), - [ - handleOpenBrowserRuntimeForSiteSkillExecution, - siteSkillExecutionState, - ], + [handleOpenBrowserRuntimeForSiteSkillExecution, siteSkillExecutionState], ); const compatSubagentRuntime = useCompatSubagentRuntime(sessionId); @@ -1622,6 +1735,13 @@ export function AgentChatWorkspace({ return; } + if (selectedArtifact.type === "browser_assist") { + if (selectedArtifactId !== fallbackArtifactId) { + setSelectedArtifactId(fallbackArtifactId); + } + return; + } + const selectedStillExists = artifacts.some( (artifact) => artifact.id === selectedArtifact.id, ); @@ -1745,10 +1865,9 @@ export function AgentChatWorkspace({ gateKey: isThemeWorkbench ? currentGate.key : undefined, runTitle: themeWorkbenchActiveQueueItem?.title?.trim() || undefined, contentId: contentId || undefined, - browserAssistProfileKey: - mappedTheme === "general" - ? GENERAL_BROWSER_ASSIST_PROFILE_KEY - : undefined, + browserAssistProfileKey: browserAssistRequestProfileKey, + browserAssistPreferredBackend: browserAssistRequestPreferredBackend, + browserAssistAutoLaunch: browserAssistRequestAutoLaunch, preferredTeamPresetId, selectedTeamId: selectedTeam?.id, selectedTeamSource: selectedTeam?.source, @@ -1762,6 +1881,9 @@ export function AgentChatWorkspace({ effectiveChatToolPreferences.task, effectiveChatToolPreferences.thinking, effectiveChatToolPreferences.webSearch, + browserAssistRequestAutoLaunch, + browserAssistRequestPreferredBackend, + browserAssistRequestProfileKey, contentId, currentGate.key, isThemeWorkbench, @@ -1855,6 +1977,7 @@ export function AgentChatWorkspace({ } = useBootstrapDispatchPreview({ initialUserPrompt, initialUserImages, + browserTaskPreflight, messagesCount: messages.length, isSending, queuedTurnCount: queuedTurns.length, @@ -1899,15 +2022,12 @@ export function AgentChatWorkspace({ }, []); const prepareBrowserTaskPreflight = useCallback( (preflight: BrowserTaskPreflight) => { - setInput(""); - setMentionedCharacters([]); setBrowserTaskPreflight(preflight); }, [], ); const { resolveSendBoundary, - isBlockedByBrowserPreflight, maybeStartBrowserTaskPreflight, finalizeAfterSendSuccess, rollbackAfterSendFailure, @@ -1920,7 +2040,6 @@ export function AgentChatWorkspace({ mappedTheme, socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY, isBrowserAssistReady, - browserTaskPreflight, onConsumeInitialPrompt: consumeInitialPrompt, onResetConsumedInitialPrompt: resetConsumedInitialPrompt, onClearEntryPrompt: clearThemeWorkbenchEntryPrompt, @@ -2028,6 +2147,17 @@ export function AgentChatWorkspace({ updateCurrentImageWorkbenchState, }); const { handleImageWorkbenchCommand } = imageWorkbenchActionRuntime; + const workspaceServiceSkillEntryActions = + useWorkspaceServiceSkillEntryActions({ + activeTheme, + creationMode, + projectId, + contentId, + input, + chatToolPreferences: effectiveChatToolPreferences, + onNavigate: _onNavigate, + recordServiceSkillUsage, + }); const { handleSend, @@ -2041,11 +2171,13 @@ export function AgentChatWorkspace({ setMentionedCharacters, chatToolPreferences: effectiveChatToolPreferences, setChatToolPreferences, + serviceSkills: activeTheme === "general" ? serviceSkills : [], activeTheme, mappedTheme, isThemeWorkbench, contextWorkspace: { enabled: contextWorkspace.enabled, + activeContextPrompt: contextWorkspace.activeContextPrompt, prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt, }, runtimeStyleMessagePrompt, @@ -2059,17 +2191,21 @@ export function AgentChatWorkspace({ currentGateKey: currentGate.key, themeWorkbenchActiveQueueTitle: themeWorkbenchActiveQueueItem?.title, contentId, + browserAssistProfileKey: browserAssistRequestProfileKey, + browserAssistPreferredBackend: browserAssistRequestPreferredBackend, + browserAssistAutoLaunch: browserAssistRequestAutoLaunch, workspaceRequestMetadataBase: initialRequestMetadata, messagesCount: messages.length, sendMessage, resolveSendBoundary, - isBlockedByBrowserPreflight, maybeStartBrowserTaskPreflight, finalizeAfterSendSuccess, rollbackAfterSendFailure, prepareRuntimeTeamBeforeSend, setRuntimeTeamDispatchPreview, ensureBrowserAssistCanvas, + handleAutoLaunchMatchedSiteSkill: + workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill, handleImageWorkbenchCommand, }); @@ -2101,7 +2237,6 @@ export function AgentChatWorkspace({ const { browserAssistEntryLabel, browserAssistAttentionLevel, - browserPreflightMessages, handlePermissionResponseWithBrowserPreflight, } = useWorkspaceBrowserPreflightRuntime({ browserTaskPreflight, @@ -2110,7 +2245,6 @@ export function AgentChatWorkspace({ isBrowserAssistReady, ensureBrowserAssistCanvas, handlePermissionResponse, - sendRef: handleSendRef, }); const { handleDocumentThinkingEnabledChange, @@ -2167,8 +2301,8 @@ export function AgentChatWorkspace({ }, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]); const { displayMessages } = useWorkspaceDisplayMessagesRuntime({ + browserTaskPreflight, bootstrapDispatchPreviewMessages, - browserPreflightMessages, isSending, messages, pendingActionCount: pendingActions.length, @@ -2186,9 +2320,9 @@ export function AgentChatWorkspace({ [displayMessages], ); - // 当开始对话时自动折叠侧边栏 - const hasMessages = messages.length > 0; + // 布局层按实际展示内容判断,避免 browser preflight / bootstrap 预览仍被视为空白态。 const hasDisplayMessages = displayMessages.length > 0; + const hasMessages = hasDisplayMessages; const handleCanvasSelectionTextChange = useCallback((text: string) => { const normalized = text.trim().replace(/\s+/g, " "); @@ -2274,13 +2408,13 @@ export function AgentChatWorkspace({ const handleResumeSidebarTask = useCallback( async (topicId: string, statusReason?: TaskStatusReason) => { if (topicId === sessionId && isResumableBrowserTaskReason(statusReason)) { - await handleOpenBrowserAssistInCanvas(); + handleOpenBrowserRuntimeForBrowserAssist(); return; } await switchTopic(topicId); }, - [handleOpenBrowserAssistInCanvas, sessionId, switchTopic], + [handleOpenBrowserRuntimeForBrowserAssist, sessionId, switchTopic], ); const handleWriteFile = useWorkspaceWriteFileAction({ @@ -2388,6 +2522,7 @@ export function AgentChatWorkspace({ sessionFiles, readSessionFile, suppressBrowserAssistCanvasAutoOpen, + onOpenBrowserRuntimeForArtifact: handleOpenBrowserRuntimeForBrowserAssist, upsertGeneralArtifact, setSelectedArtifactId, setArtifactViewMode: applyAutoArtifactViewMode, @@ -2443,6 +2578,7 @@ export function AgentChatWorkspace({ sessionId, initialUserPrompt, initialUserImages, + initialAutoSendRequestMetadata, autoRunInitialPromptOnMount, initialDispatchKey, messagesCount: messages.length, @@ -2543,17 +2679,6 @@ export function AgentChatWorkspace({ setWorkspaceHealthError, workspacePathMissing, }); - const workspaceServiceSkillEntryActions = - useWorkspaceServiceSkillEntryActions({ - activeTheme, - creationMode, - projectId, - contentId, - input, - chatToolPreferences: effectiveChatToolPreferences, - onNavigate: _onNavigate, - recordServiceSkillUsage, - }); const inputbarScene = useWorkspaceInputbarSceneRuntime({ setMentionedCharacters, @@ -2761,7 +2886,7 @@ export function AgentChatWorkspace({ skillsLoading: combinedSkillsLoading, handleNavigateToSkillSettings, handleRefreshSkills, - handleOpenBrowserAssistInCanvas, + handleOpenBrowserAssistInCanvas: handleOpenBrowserRuntimeForBrowserAssist, browserAssistLaunching, hideHistoryToggle, showChatPanel, diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts index 69afa3827..0222c153b 100644 --- a/src/components/agent/chat/agentChatWorkspaceContract.ts +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -18,6 +18,7 @@ export interface AgentChatWorkspaceProps { projectId?: string; contentId?: string; initialRequestMetadata?: Record; + initialAutoSendRequestMetadata?: Record; autoRunInitialPromptOnMount?: boolean; agentEntry?: "new-task" | "claw"; immersiveHome?: boolean; diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index 329034bba..6f18dab8e 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -196,11 +196,11 @@ describe("ChatNavbar", () => { }); const button = container.querySelector( - 'button[aria-label="在右侧画布打开浏览器协助"]', + 'button[aria-label="打开浏览器工作台"]', ) as HTMLButtonElement | null; expect(button).not.toBeNull(); - expect(button?.textContent).toContain("浏览器协助"); + expect(button?.textContent).toContain("浏览器工作台"); act(() => { button?.click(); @@ -216,7 +216,7 @@ describe("ChatNavbar", () => { }); const button = container.querySelector( - 'button[aria-label="在右侧画布打开浏览器协助"]', + 'button[aria-label="打开浏览器工作台"]', ) as HTMLButtonElement | null; expect(button?.textContent).toContain("等待登录"); @@ -230,7 +230,7 @@ describe("ChatNavbar", () => { }); const button = container.querySelector( - 'button[aria-label="恢复浏览器协助"]', + 'button[aria-label="恢复浏览器工作台"]', ) as HTMLButtonElement | null; expect(button).not.toBeNull(); @@ -247,7 +247,7 @@ describe("ChatNavbar", () => { }); const button = container.querySelector( - 'button[aria-label="查看浏览器启动状态"]', + 'button[aria-label="查看浏览器工作台状态"]', ) as HTMLButtonElement | null; expect(button).not.toBeNull(); diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index ee7d3acff..b3a6504f0 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -62,14 +62,14 @@ function resolveBrowserAssistTitle( attentionLevel: NonNullable, ): string { if (attentionLevel === "warning") { - return "恢复浏览器协助"; + return "恢复浏览器工作台"; } if (attentionLevel === "info") { - return "查看浏览器启动状态"; + return "查看浏览器工作台状态"; } - return "在右侧画布打开浏览器协助"; + return "打开浏览器工作台"; } const toolbarGroupClassName = @@ -383,7 +383,7 @@ export const ChatNavbar: React.FC = ({ {browserAssistLoading ? "启动中..." - : browserAssistLabel?.trim() || "浏览器协助"} + : browserAssistLabel?.trim() || "浏览器工作台"} ) : null} diff --git a/src/components/agent/chat/components/DecisionPanel.test.tsx b/src/components/agent/chat/components/DecisionPanel.test.tsx index 64cd3b57b..bb5ce49d2 100644 --- a/src/components/agent/chat/components/DecisionPanel.test.tsx +++ b/src/components/agent/chat/components/DecisionPanel.test.tsx @@ -393,46 +393,47 @@ describe("DecisionPanel copywriting", () => { }); describe("DecisionPanel browser preflight", () => { - it("浏览器未就绪时应支持重试启动浏览器", () => { + it("浏览器未就绪时应支持重试打开浏览器", () => { const request = createBrowserPreflightRequest("req-browser-preflight"); const { container, onSubmit } = renderDecisionPanel(request); expect(container.textContent).toContain("浏览器未就绪"); expect(container.textContent).toContain("必须浏览器执行"); - clickButton(findButtonByText(container, "重试启动浏览器")); + clickButton(findButtonByText(container, "重试打开浏览器")); expect(onSubmit).toHaveBeenCalledWith({ requestId: "req-browser-preflight", confirmed: true, - response: "重新启动浏览器", + response: "重新打开浏览器", actionType: "ask_user", userData: { - answer: "重新启动浏览器", + answer: "重新打开浏览器", browserAction: "launch", }, }); }); - it("等待用户完成登录时应支持继续执行", () => { + it("等待用户完成登录时不应再提供继续执行确认", () => { const request = createBrowserPreflightRequest( "req-browser-awaiting", "awaiting_user", ); const { container, onSubmit } = renderDecisionPanel(request); - expect(container.textContent).toContain("等待你完成浏览器准备"); + expect(container.textContent).toContain("请先完成浏览器准备"); + expect(container.textContent).not.toContain("我已完成登录,继续执行"); - clickButton(findButtonByText(container, "我已完成登录,继续执行")); + clickButton(findButtonByText(container, "重新打开浏览器")); expect(onSubmit).toHaveBeenCalledWith({ requestId: "req-browser-awaiting", confirmed: true, - response: "我已完成登录,继续执行", + response: "重新打开浏览器", actionType: "ask_user", userData: { - answer: "我已完成登录,继续执行", - browserAction: "continue", + answer: "重新打开浏览器", + browserAction: "launch", }, }); }); diff --git a/src/components/agent/chat/components/DecisionPanel.tsx b/src/components/agent/chat/components/DecisionPanel.tsx index 2d270c8b0..16fcf19e3 100644 --- a/src/components/agent/chat/components/DecisionPanel.tsx +++ b/src/components/agent/chat/components/DecisionPanel.tsx @@ -588,12 +588,12 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { const detailText = request.detail || (request.browserRequirement === "required_with_user_step" - ? "请在右侧浏览器中完成登录、扫码、验证码或授权后,再继续当前任务。" - : "请先让右侧浏览器处于可操作状态,再继续当前任务。"); + ? "请先在浏览器工作台完成登录、扫码、验证码或授权,然后回到原入口重新发起任务。" + : "请先让浏览器工作台中的页面处于可操作状态,然后回到原入口重新发起任务。"); const title = isLaunching ? "正在准备浏览器执行环境" : isAwaitingUser - ? "等待你完成浏览器准备" + ? "请先完成浏览器准备" : isFailed ? "浏览器未就绪" : "此任务需要先准备浏览器"; @@ -652,50 +652,30 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { 启动中... - ) : isAwaitingUser ? ( - ) : ( )} diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 766fd95b9..7a3a2555a 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -204,6 +204,50 @@ function renderEmptyState( return container; } +function createGithubSearchServiceSkill(): ServiceSkillHomeItem { + return { + id: "github-repo-radar", + title: "GitHub 仓库线索检索", + summary: "复用 GitHub 登录态检索项目。", + category: "情报研究", + outputHint: "仓库列表 + 关键线索", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald", + runnerDescription: "直接复用浏览器登录态执行。", + actionLabel: "启动采集", + automationStatus: null, + slotSchema: [ + { + key: "repository_query", + label: "检索主题", + type: "text", + required: true, + placeholder: "例如 AI Agent", + }, + ], + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + requireAttachedSession: true, + saveMode: "current_content", + slotArgMap: { + repository_query: "query", + }, + fixedArgs: { + limit: 10, + }, + }, + }; +} + describe("EmptyState", () => { it("应挂载 CharacterMention,并透传角色与技能", async () => { const characters: Character[] = [ @@ -420,6 +464,21 @@ describe("EmptyState", () => { expect(container.textContent).toContain("2 项技能可挂载"); }); + it("通用对话且存在站点型 service skill 时,应展示自然句占位示例", async () => { + const container = renderEmptyState({ + serviceSkills: [createGithubSearchServiceSkill()], + }); + + await act(async () => { + await Promise.resolve(); + }); + + const textarea = container.querySelector("textarea"); + expect(textarea?.getAttribute("placeholder")).toContain( + "帮我用 GitHub 查一下 AI Agent 项目", + ); + }); + it("点击地球按钮应切换联网搜索开关", async () => { const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); const container = renderEmptyState({ @@ -630,7 +689,7 @@ describe("EmptyState", () => { }); const launchButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("打开浏览器协助"), + (button) => button.textContent?.includes("打开浏览器工作台"), ); expect(launchButton).toBeTruthy(); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 9fcb501fc..90c5eb131 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -60,6 +60,14 @@ import { getActiveSkillDisplayLabel, getSkillSelectionSummaryLabel, } from "./Inputbar/components/skillSelectionDisplay"; +import { + getSiteSkillAutoLaunchExample, + hasAutoLaunchableSiteSkill, +} from "../service-skills/siteSkillExamplePrompts"; +import { + DEFAULT_ENABLED_CONTENT_THEME_IDS, + resolveEnabledContentThemes, +} from "@/lib/contentCreator/themeDefaults"; // Import Assets import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-placeholder.svg"; @@ -222,16 +230,6 @@ const ALL_CATEGORIES = [ { id: "novel", label: "小说创作", icon: }, ]; -/** 默认启用的主题 */ -const DEFAULT_ENABLED_THEMES = [ - "general", - "social-media", - "poster", - "music", - "video", - "novel", -]; - // 需要显示创作模式选择器的主题 const CREATION_THEMES = [ "social-media", @@ -264,7 +262,7 @@ const THEME_WORKBENCH_COPY: Record< title: "青柠一下,灵感即来", description: "从一句想法,到成稿、成图、成片、成事。", supportingDescription: - "Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只停留在一次性提问。", + "Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到工作台与项目资源,而不是只停留在一次性提问。", }, "social-media": { title: "社媒内容工作台", @@ -387,10 +385,13 @@ export const EmptyState: React.FC = ({ activeSkill: currentSkill, skillCount: skillOptionCount, }); + const hasAutoLaunchSiteSkill = hasAutoLaunchableSiteSkill(serviceSkills); + const siteSkillAutoLaunchExample = + getSiteSkillAutoLaunchExample(serviceSkills); // 从配置中读取启用的主题 const [enabledThemes, setEnabledThemes] = useState( - DEFAULT_ENABLED_THEMES, + DEFAULT_ENABLED_CONTENT_THEME_IDS, ); const [ appendSelectedTextToRecommendation, @@ -402,9 +403,11 @@ export const EmptyState: React.FC = ({ const loadConfigPreferences = async () => { try { const loadedConfig = await getConfig(); - if (loadedConfig.content_creator?.enabled_themes) { - setEnabledThemes(loadedConfig.content_creator.enabled_themes); - } + setEnabledThemes( + resolveEnabledContentThemes( + loadedConfig.content_creator?.enabled_themes, + ), + ); setAppendSelectedTextToRecommendation( loadedConfig.chat_appearance ?.append_selected_text_to_recommendation ?? true, @@ -698,7 +701,9 @@ export const EmptyState: React.FC = ({ case "novel": return "输入小说主题或情节,帮你创作章节内容..."; case "general": - return "有什么我可以帮你的?"; + return hasAutoLaunchSiteSkill + ? `直接说一句话,例如:${siteSkillAutoLaunchExample}` + : "有什么我可以帮你的?"; default: return "输入你的想法..."; } @@ -886,15 +891,15 @@ export const EmptyState: React.FC = ({ cards.push({ key: "browser", eyebrow: "能力层", - title: "浏览器协助", + title: "浏览器工作台", value: browserAssistLoading - ? "正在准备远程浏览器" - : "网页操作 / 登录接管", + ? "正在准备浏览器会话" + : "网页登录 / 人工接管", description: - "需要处理登录、验证码或网页操作时,可直接在右侧画布接管远程浏览器。", + "需要处理登录、验证码或复杂网页操作时,可切到浏览器工作台接管真实浏览器。", icon: , imageSrc: capabilityBrowserAssistPlaceholder, - imageAlt: "浏览器协助能力卡占位图", + imageAlt: "浏览器工作台能力卡占位图", tone: "slate", action: onLaunchBrowserAssist ? ( ) : null, }); @@ -933,7 +938,7 @@ export const EmptyState: React.FC = ({ description: hasCanvasContent || hasContentId ? "当前会话已经接入画布,生成内容可继续整理、扩写和汇总。" - : "生成结果不会只停留在消息气泡里,而是继续进入右侧画布承接后续工作。", + : "生成结果不会只停留在消息气泡里,而是继续进入工作台承接后续整理与交付。", }, ]; @@ -942,7 +947,7 @@ export const EmptyState: React.FC = ({ key: "browser", title: "网页任务可接管", description: - "遇到登录、验证码或复杂网页操作时,可切换到浏览器协助继续完成任务。", + "遇到登录、验证码或复杂网页操作时,可切换到浏览器工作台继续完成任务。", }); } else if (activeTheme === "social-media") { features.push({ diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index f495c85db..d83b1922a 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -51,6 +51,50 @@ const mockSelectedTeam = { ], }; +function createGithubSearchServiceSkill() { + return { + id: "github-repo-radar", + title: "GitHub 仓库线索检索", + summary: "复用 GitHub 登录态检索项目。", + category: "情报研究", + outputHint: "仓库列表 + 关键线索", + source: "cloud_catalog" as const, + runnerType: "instant" as const, + defaultExecutorBinding: "browser_assist" as const, + executionLocation: "client_default" as const, + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald" as const, + runnerDescription: "直接复用浏览器登录态执行。", + actionLabel: "启动采集", + automationStatus: null, + slotSchema: [ + { + key: "repository_query", + label: "检索主题", + type: "text" as const, + required: true, + placeholder: "例如 AI Agent", + }, + ], + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + requireAttachedSession: true, + saveMode: "current_content" as const, + slotArgMap: { + repository_query: "query", + }, + fixedArgs: { + limit: 10, + }, + }, + }; +} + const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; beforeEach(() => { @@ -275,6 +319,22 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); }); + it("通用对话且存在站点型 service skill 时不应再展示首页专属提示按钮", () => { + const container = renderPanel({ + input: "", + isGeneralTheme: true, + skillSelection: createSkillSelection({ + serviceSkills: [createGithubSearchServiceSkill()], + }), + }); + + const hint = container.querySelector( + '[data-testid="empty-state-site-skill-natural-hint"]', + ); + + expect(hint).toBeFalsy(); + }); + it("应将 onPaste 绑定到输入框", () => { const onPaste = vi.fn(); const container = renderPanel({ onPaste }); diff --git a/src/components/agent/chat/homeShellEntry.test.ts b/src/components/agent/chat/homeShellEntry.test.ts index c571d4f2a..e2ab237fc 100644 --- a/src/components/agent/chat/homeShellEntry.test.ts +++ b/src/components/agent/chat/homeShellEntry.test.ts @@ -81,6 +81,7 @@ describe("homeShellEntry", () => { initialUserPrompt: undefined, initialUserImages: undefined, theme: "document", + lockTheme: false, initialCreationMode: "guided", openBrowserAssistOnMount: undefined, newChatAt: 234, @@ -144,6 +145,7 @@ describe("homeShellEntry", () => { }, autoRunInitialPromptOnMount: true, theme: "social-media", + lockTheme: false, initialCreationMode: "guided", openBrowserAssistOnMount: undefined, newChatAt: 456, @@ -186,6 +188,7 @@ describe("homeShellEntry", () => { initialUserPrompt: "", initialUserImages: undefined, theme: "general", + lockTheme: false, initialCreationMode: "guided", openBrowserAssistOnMount: true, newChatAt: 789, @@ -236,13 +239,14 @@ describe("homeShellEntry", () => { requireAttachedSession: true, }, newChatAt: 987, - lockTheme: false, + lockTheme: true, }, workspaceBootstrap: { projectId: "project-1", initialUserPrompt: undefined, initialUserImages: undefined, theme: "general", + lockTheme: true, initialCreationMode: "guided", openBrowserAssistOnMount: undefined, initialSiteSkillLaunch: { @@ -257,4 +261,86 @@ describe("homeShellEntry", () => { }, }); }); + + it("站点技能 metadata 启动时也应自动锁定 general 主题", () => { + expect( + resolveHomeShellWorkspaceEntry({ + projectId: "project-music-1", + activeTheme: "music", + creationMode: "guided", + defaultToolPreferences, + payload: { + prompt: "你帮我在 GitHub 找一下和“AI Agent”相关的项目。", + contentId: "content-site-skill-1", + themeOverride: "general", + autoRunInitialPromptOnMount: true, + initialAutoSendRequestMetadata: { + harness: { + service_skill_launch: { + kind: "site_adapter", + adapter_name: "github/search", + args: { + query: "AI Agent", + }, + }, + }, + }, + }, + now: () => 111, + }), + ).toEqual({ + ok: true, + toolPreferences: defaultToolPreferences, + targetTheme: "general", + nextNewChatAt: 111, + navigationParams: { + agentEntry: "claw", + immersiveHome: false, + projectId: "project-music-1", + contentId: "content-site-skill-1", + theme: "general", + lockTheme: true, + initialCreationMode: "guided", + initialUserPrompt: "你帮我在 GitHub 找一下和“AI Agent”相关的项目。", + initialUserImages: undefined, + initialAutoSendRequestMetadata: { + harness: { + service_skill_launch: { + kind: "site_adapter", + adapter_name: "github/search", + args: { + query: "AI Agent", + }, + }, + }, + }, + autoRunInitialPromptOnMount: true, + openBrowserAssistOnMount: undefined, + newChatAt: 111, + }, + workspaceBootstrap: { + projectId: "project-music-1", + contentId: "content-site-skill-1", + initialUserPrompt: "你帮我在 GitHub 找一下和“AI Agent”相关的项目。", + initialUserImages: undefined, + initialAutoSendRequestMetadata: { + harness: { + service_skill_launch: { + kind: "site_adapter", + adapter_name: "github/search", + args: { + query: "AI Agent", + }, + }, + }, + }, + autoRunInitialPromptOnMount: true, + theme: "general", + lockTheme: true, + initialCreationMode: "guided", + openBrowserAssistOnMount: undefined, + newChatAt: 111, + }, + }); + }); }); diff --git a/src/components/agent/chat/homeShellEntry.ts b/src/components/agent/chat/homeShellEntry.ts index 11c0b29e6..43865748a 100644 --- a/src/components/agent/chat/homeShellEntry.ts +++ b/src/components/agent/chat/homeShellEntry.ts @@ -13,9 +13,11 @@ export interface AgentChatWorkspaceBootstrap { initialUserPrompt?: string; initialUserImages?: MessageImage[]; initialRequestMetadata?: Record; + initialAutoSendRequestMetadata?: Record; autoRunInitialPromptOnMount?: boolean; theme?: string; initialCreationMode?: CreationMode; + lockTheme?: boolean; openBrowserAssistOnMount?: boolean; initialSiteSkillLaunch?: AgentSiteSkillLaunchParams; newChatAt?: number; @@ -26,11 +28,13 @@ export interface HomeShellEnterWorkspacePayload { images?: MessageImage[]; contentId?: string; initialRequestMetadata?: Record; + initialAutoSendRequestMetadata?: Record; autoRunInitialPromptOnMount?: boolean; openBrowserAssistOnMount?: boolean; initialSiteSkillLaunch?: AgentSiteSkillLaunchParams; toolPreferences?: ChatToolPreferences; themeOverride?: string; + lockTheme?: boolean; } export interface ResolveHomeShellWorkspaceEntryInput { @@ -60,6 +64,35 @@ export type ResolvedHomeShellWorkspaceEntry = workspaceBootstrap: AgentChatWorkspaceBootstrap; }; +function asRecord( + value: unknown, +): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function hasServiceSkillLaunchMetadata( + metadata?: Record, +): boolean { + const record = asRecord(metadata); + if (!record) { + return false; + } + + const harness = asRecord(record.harness) ?? record; + const launch = + asRecord(harness.service_skill_launch) ?? + asRecord(harness.serviceSkillLaunch); + if (!launch) { + return false; + } + + const adapterName = launch.adapter_name ?? launch.adapterName; + return typeof adapterName === "string" && adapterName.trim().length > 0; +} + export function resolveHomeShellWorkspaceEntry( input: ResolveHomeShellWorkspaceEntryInput, ): ResolvedHomeShellWorkspaceEntry { @@ -80,6 +113,12 @@ export function resolveHomeShellWorkspaceEntry( ); const toolPreferences = payload.toolPreferences ?? defaultToolPreferences; const targetTheme = payload.themeOverride ?? activeTheme; + const lockTheme = + payload.lockTheme ?? + (targetTheme === "general" && + (hasSiteSkillLaunch || + hasServiceSkillLaunchMetadata(payload.initialAutoSendRequestMetadata) || + hasServiceSkillLaunchMetadata(payload.initialRequestMetadata))); const openBrowserAssistOnMount = payload.openBrowserAssistOnMount; const autoRunInitialPromptOnMount = payload.autoRunInitialPromptOnMount; @@ -108,10 +147,17 @@ export function resolveHomeShellWorkspaceEntry( projectId: projectId ?? undefined, contentId: payload.contentId, theme: targetTheme, + lockTheme, initialCreationMode: creationMode, initialUserPrompt: payload.prompt, initialUserImages: payload.images, initialRequestMetadata: payload.initialRequestMetadata, + ...(payload.initialAutoSendRequestMetadata + ? { + initialAutoSendRequestMetadata: + payload.initialAutoSendRequestMetadata, + } + : {}), ...(autoRunInitialPromptOnMount !== undefined ? { autoRunInitialPromptOnMount } : {}), @@ -134,8 +180,15 @@ export function resolveHomeShellWorkspaceEntry( initialUserPrompt: payload.prompt, initialUserImages: payload.images, initialRequestMetadata: payload.initialRequestMetadata, + ...(payload.initialAutoSendRequestMetadata + ? { + initialAutoSendRequestMetadata: + payload.initialAutoSendRequestMetadata, + } + : {}), autoRunInitialPromptOnMount, theme: targetTheme, + lockTheme, initialCreationMode: creationMode, openBrowserAssistOnMount, ...(payload.initialSiteSkillLaunch diff --git a/src/components/agent/chat/hooks/handleSendTypes.ts b/src/components/agent/chat/hooks/handleSendTypes.ts index 4b7c53320..e6059e29a 100644 --- a/src/components/agent/chat/hooks/handleSendTypes.ts +++ b/src/components/agent/chat/hooks/handleSendTypes.ts @@ -12,7 +12,6 @@ export interface HandleSendOptions { purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit"; observer?: HandleSendObserver; requestMetadata?: Record; - browserPreflightConfirmed?: boolean; toolPreferencesOverride?: ChatToolPreferences; } diff --git a/src/components/agent/chat/hooks/useArtifactDisplayState.test.ts b/src/components/agent/chat/hooks/useArtifactDisplayState.test.ts index eacf1caff..a5c7c4fb3 100644 --- a/src/components/agent/chat/hooks/useArtifactDisplayState.test.ts +++ b/src/components/agent/chat/hooks/useArtifactDisplayState.test.ts @@ -163,7 +163,7 @@ describe("resolveArtifactDisplayState", () => { expect(state.overlay).toBeNull(); }); - it("浏览器协助 artifact 即使 content 为空也应优先展示自身,而不是回退到上一份文档", () => { + it("浏览器协助 artifact 不应再占用 Claw 画布,而应回退到上一份可渲染内容", () => { const previousArtifact = createArtifact({ id: "artifact-prev", title: "report.md", @@ -194,7 +194,7 @@ describe("resolveArtifactDisplayState", () => { }); expect(state.mode).toBe("content"); - expect(state.displayArtifact?.id).toBe("browser-assist:general"); + expect(state.displayArtifact?.id).toBe("artifact-prev"); expect(state.overlay).toBeNull(); expect(state.showPreviousVersionBadge).toBe(false); }); diff --git a/src/components/agent/chat/hooks/useArtifactDisplayState.ts b/src/components/agent/chat/hooks/useArtifactDisplayState.ts index e83680aa6..c9d962179 100644 --- a/src/components/agent/chat/hooks/useArtifactDisplayState.ts +++ b/src/components/agent/chat/hooks/useArtifactDisplayState.ts @@ -44,7 +44,7 @@ export interface ResolveArtifactDisplayStateOptions { const SLOW_TRANSITION_THRESHOLD_MS = 900; -function shouldDisplayArtifactWithoutContent( +function shouldSuppressArtifactCanvasPreview( artifact: Pick | null | undefined, ): boolean { return artifact?.type === "browser_assist"; @@ -214,10 +214,16 @@ export function resolveArtifactDisplayState({ }; } - if (shouldDisplayArtifactWithoutContent(liveArtifact)) { + if (shouldSuppressArtifactCanvasPreview(liveArtifact)) { + const previousArtifact = findPreviousRenderableArtifact( + liveArtifact, + artifacts, + previousRenderableArtifact, + ); + return { liveArtifact, - displayArtifact: liveArtifact, + displayArtifact: previousArtifact, mode: "content", overlay: null, showPreviousVersionBadge: false, @@ -322,7 +328,7 @@ export function useArtifactDisplayState( return; } - if (shouldDisplayArtifactWithoutContent(liveArtifact)) { + if (shouldSuppressArtifactCanvasPreview(liveArtifact)) { setIsSlowTransition(false); return; } diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 4a9e42a30..fd695991a 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -353,7 +353,7 @@ describe("useAsterAgentChat 首页新会话", () => { } }); - it("加载话题不应依赖预先初始化 Agent", async () => { + it("加载话题时应后台预热 Agent,但不阻塞话题列表返回", async () => { const workspaceId = "ws-topic-lazy-init"; const sessionId = "session-topic-lazy-init"; mockListAgentRuntimeSessions.mockResolvedValue([ @@ -372,11 +372,12 @@ describe("useAsterAgentChat 首页新会话", () => { await flushEffects(); await flushEffects(); - expect(mockInitAsterAgent).not.toHaveBeenCalled(); + expect(mockInitAsterAgent).toHaveBeenCalledTimes(1); expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1); expect(harness.getValue().topics.map((topic) => topic.id)).toEqual([ sessionId, ]); + expect(harness.getValue().processStatus.running).toBe(true); } finally { harness.unmount(); } diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index 5636bf208..de3119810 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -46,6 +46,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { const runtime = runtimeAdapter ?? defaultAgentRuntimeAdapter; const [isInitialized, setIsInitialized] = useState(false); + const runtimeWarmupPromiseRef = useRef | null>(null); const sessionIdRef = useRef(null); const currentAssistantMsgIdRef = useRef(null); const currentStreamingSessionIdRef = useRef(null); @@ -257,14 +258,45 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { tools.warnedKeysRef.current.clear(); }, [tools.warnedKeysRef, workspaceId]); + const warmupRuntime = useCallback(async () => { + if (runtimeWarmupPromiseRef.current) { + await runtimeWarmupPromiseRef.current; + return; + } + + const warmupPromise = runtime + .init() + .then(() => { + setIsInitialized(true); + console.log("[AsterChat] Agent 初始化成功"); + }) + .catch((err) => { + setIsInitialized(false); + console.error("[AsterChat] 初始化失败:", err); + throw err; + }) + .finally(() => { + runtimeWarmupPromiseRef.current = null; + }); + + runtimeWarmupPromiseRef.current = warmupPromise; + await warmupPromise; + }, [runtime]); + + useEffect(() => { + if (!workspaceId.trim()) { + setIsInitialized(false); + return; + } + + void warmupRuntime().catch(() => undefined); + }, [warmupRuntime, workspaceId]); + const handleStartProcess = async () => { try { - await runtime.init(); - setIsInitialized(true); - console.log("[AsterChat] Agent 初始化成功"); - } catch (err) { - setIsInitialized(false); - console.error("[AsterChat] 初始化失败:", err); + await warmupRuntime(); + } catch { + return; } }; diff --git a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx index 321c70afa..6f904b3b4 100644 --- a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx +++ b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { BrowserTaskPreflight } from "./handleSendTypes"; import { buildInitialDispatchKey, useBootstrapDispatchPreview, @@ -12,6 +13,7 @@ interface HookHarness { props?: Partial<{ initialUserPrompt?: string; initialUserImages?: Array<{ data: string; mediaType: string }>; + browserTaskPreflight?: BrowserTaskPreflight | null; messagesCount: number; isSending: boolean; queuedTurnCount: number; @@ -26,6 +28,7 @@ function mountHook( initialProps?: Partial<{ initialUserPrompt?: string; initialUserImages?: Array<{ data: string; mediaType: string }>; + browserTaskPreflight?: BrowserTaskPreflight | null; messagesCount: number; isSending: boolean; queuedTurnCount: number; @@ -41,6 +44,7 @@ function mountHook( let currentProps = { initialUserPrompt: "", initialUserImages: [], + browserTaskPreflight: null, messagesCount: 0, isSending: false, queuedTurnCount: 0, @@ -58,6 +62,7 @@ function mountHook( nextProps?: Partial<{ initialUserPrompt?: string; initialUserImages?: Array<{ data: string; mediaType: string }>; + browserTaskPreflight?: BrowserTaskPreflight | null; messagesCount: number; isSending: boolean; queuedTurnCount: number; @@ -140,17 +145,55 @@ describe("useBootstrapDispatchPreview", () => { }); try { - expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(2); + expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength( + 2, + ); harness.rerender({ messagesCount: 1, isSending: false, }); - expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(0); + expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength( + 0, + ); expect(harness.getValue().shouldShowBootstrapDispatchPreview).toBe(false); } finally { harness.unmount(); } }); + + it("浏览器前置引导期间应继续展示 bootstrap 预览,避免空白工作台", () => { + const prompt = "帮我把这篇文章发布到微信公众号后台"; + const dispatchKey = buildInitialDispatchKey(prompt, [])!; + const harness = mountHook({ + initialUserPrompt: prompt, + consumedInitialPromptKey: dispatchKey, + browserTaskPreflight: { + requestId: "browser-preflight-1", + createdAt: 1, + sourceText: prompt, + images: [], + requirement: "required_with_user_step", + reason: "publish_requires_browser", + phase: "awaiting_user", + launchUrl: "https://mp.weixin.qq.com", + platformLabel: "微信公众号后台", + detail: + "已打开微信公众号后台。请先完成登录、扫码、验证码或授权,然后回到当前任务重新发起。", + }, + }); + + try { + const value = harness.getValue(); + expect(value.shouldShowBootstrapDispatchPreview).toBe(true); + expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2); + expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(prompt); + expect(value.bootstrapDispatchPreviewMessages[1]?.content).toContain( + "回到当前任务重新发起", + ); + } finally { + harness.unmount(); + } + }); }); diff --git a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts index 4e7aafcd3..8dbcc866a 100644 --- a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts +++ b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import type { BrowserTaskPreflight } from "./handleSendTypes"; import type { Message, MessageImage } from "../types"; export interface InitialDispatchPreviewSnapshot { @@ -10,6 +11,7 @@ export interface InitialDispatchPreviewSnapshot { interface UseBootstrapDispatchPreviewOptions { initialUserPrompt?: string; initialUserImages?: MessageImage[]; + browserTaskPreflight?: BrowserTaskPreflight | null; messagesCount: number; isSending: boolean; queuedTurnCount: number; @@ -42,6 +44,7 @@ export function buildInitialDispatchPreviewMessages( dispatchKey: string, prompt?: string, images?: MessageImage[], + assistantPreviewText?: string, ): Message[] { const normalizedPrompt = (prompt || "").trim(); const normalizedImages = images || []; @@ -51,6 +54,10 @@ export function buildInitialDispatchPreviewMessages( } const timestamp = new Date(); + const normalizedAssistantPreviewText = + assistantPreviewText?.trim() || "正在开始处理任务…"; + const isAssistantThinking = + normalizedAssistantPreviewText === "正在开始处理任务…"; return [ { @@ -63,9 +70,9 @@ export function buildInitialDispatchPreviewMessages( { id: `initial-dispatch:${dispatchKey}:assistant`, role: "assistant", - content: "正在开始处理任务…", + content: normalizedAssistantPreviewText, timestamp: new Date(timestamp.getTime() + 1), - isThinking: true, + isThinking: isAssistantThinking, }, ]; } @@ -73,6 +80,7 @@ export function buildInitialDispatchPreviewMessages( export function useBootstrapDispatchPreview({ initialUserPrompt, initialUserImages, + browserTaskPreflight, messagesCount, isSending, queuedTurnCount, @@ -132,11 +140,15 @@ export function useBootstrapDispatchPreview({ const isBootstrapDispatchPending = activeBootstrapDispatch !== null && consumedInitialPromptKey !== activeBootstrapDispatch.key; + const bootstrapDispatchPreviewDetail = + browserTaskPreflight?.detail?.trim() || undefined; const shouldShowBootstrapDispatchPreview = !shouldUseCompactThemeWorkbench && Boolean(activeBootstrapDispatch) && messagesCount === 0 && - (isSending || queuedTurnCount > 0); + (isSending || + queuedTurnCount > 0 || + Boolean(bootstrapDispatchPreviewDetail)); const bootstrapDispatchPreviewMessages = useMemo(() => { if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) { return [] as Message[]; @@ -146,8 +158,13 @@ export function useBootstrapDispatchPreview({ activeBootstrapDispatch.key, activeBootstrapDispatch.prompt, activeBootstrapDispatch.images, + bootstrapDispatchPreviewDetail, ); - }, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]); + }, [ + activeBootstrapDispatch, + bootstrapDispatchPreviewDetail, + shouldShowBootstrapDispatchPreview, + ]); return { initialDispatchKey, diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx index ffd339cb9..d9c32c3e7 100644 --- a/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx +++ b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useThemeWorkbenchSendBoundary, } from "./useThemeWorkbenchSendBoundary"; -import type { BrowserTaskPreflight } from "./handleSendTypes"; interface HookHarnessProps { isThemeWorkbench: boolean; @@ -12,7 +11,6 @@ interface HookHarnessProps { initialDispatchKey: string | null; consumedInitialPromptKey: string | null; mappedTheme: string; - browserTaskPreflight: BrowserTaskPreflight | null; isBrowserAssistReady: boolean; } @@ -26,19 +24,6 @@ interface HookHarness { onPrepareBrowserTaskPreflight: ReturnType; } -function createPreflight(): BrowserTaskPreflight { - return { - requestId: "browser-preflight:existing", - createdAt: 123, - sourceText: "旧任务", - images: [], - requirement: "required_with_user_step", - reason: "需要浏览器", - phase: "awaiting_user", - launchUrl: "https://mp.weixin.qq.com/", - }; -} - function mountHook(initialProps?: Partial): HookHarness { const container = document.createElement("div"); document.body.appendChild(container); @@ -56,7 +41,6 @@ function mountHook(initialProps?: Partial): HookHarness { initialDispatchKey: "dispatch-1", consumedInitialPromptKey: null, mappedTheme: "social-media", - browserTaskPreflight: null, isBrowserAssistReady: true, ...initialProps, }; @@ -143,24 +127,6 @@ describe("useThemeWorkbenchSendBoundary", () => { } }); - it("已有浏览器前置引导时应阻止未确认发送", () => { - const harness = mountHook({ - mappedTheme: "general", - browserTaskPreflight: createPreflight(), - }); - - try { - expect(harness.getValue().isBlockedByBrowserPreflight()).toBe(true); - expect( - harness.getValue().isBlockedByBrowserPreflight({ - browserPreflightConfirmed: true, - }), - ).toBe(false); - } finally { - harness.unmount(); - } - }); - it("需要真实浏览器时应创建前置引导并同步消费初始意图", () => { const harness = mountHook({ mappedTheme: "general", diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts index 2d0c02272..2408bd678 100644 --- a/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts +++ b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts @@ -46,7 +46,6 @@ interface UseThemeWorkbenchSendBoundaryOptions { mappedTheme: string; socialArticleSkillKey: string; isBrowserAssistReady: boolean; - browserTaskPreflight: BrowserTaskPreflight | null; onConsumeInitialPrompt: (dispatchKey: string) => void; onResetConsumedInitialPrompt: () => void; onClearEntryPrompt: () => void; @@ -153,7 +152,6 @@ export function useThemeWorkbenchSendBoundary({ mappedTheme, socialArticleSkillKey, isBrowserAssistReady, - browserTaskPreflight, onConsumeInitialPrompt, onResetConsumedInitialPrompt, onClearEntryPrompt, @@ -183,12 +181,6 @@ export function useThemeWorkbenchSendBoundary({ ], ); - const isBlockedByBrowserPreflight = useCallback( - (sendOptions?: HandleSendOptions) => - Boolean(browserTaskPreflight) && !sendOptions?.browserPreflightConfirmed, - [browserTaskPreflight], - ); - const maybeStartBrowserTaskPreflight = useCallback( ({ boundary, @@ -199,11 +191,7 @@ export function useThemeWorkbenchSendBoundary({ autoContinuePayload, sendOptions, }: StartBrowserTaskPreflightOptions) => { - if ( - !boundary.browserRequirementMatch || - sendOptions?.browserPreflightConfirmed || - isBrowserAssistReady - ) { + if (!boundary.browserRequirementMatch || isBrowserAssistReady) { return false; } @@ -269,7 +257,6 @@ export function useThemeWorkbenchSendBoundary({ return { resolveSendBoundary, - isBlockedByBrowserPreflight, maybeStartBrowserTaskPreflight, finalizeAfterSendSuccess, rollbackAfterSendFailure, diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 2bca8a08b..fc497951a 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1766,7 +1766,9 @@ describe("AgentChatPage 通用工作台", () => { '[data-testid="chat-navbar"]', ) as HTMLDivElement | null; expect(navbar?.dataset.showHarnessToggle).toBe("false"); - expect(container.querySelector('[data-testid="toggle-harness"]')).toBeNull(); + expect( + container.querySelector('[data-testid="toggle-harness"]'), + ).toBeNull(); expect(document.body.textContent).not.toContain("处理工作台"); expect(mockGetAgentRuntimeToolInventory).not.toHaveBeenCalled(); }); @@ -2425,7 +2427,7 @@ describe("AgentChatPage 通用工作台", () => { expect(document.body.textContent).toContain("research"); }); - it("浏览器工具返回真实会话后应自动打开浏览器协助画布", async () => { + it("浏览器工具返回真实会话后不应再自动打开浏览器协助画布", async () => { mockBrowserAssistCompletedSession(); const container = renderPage({ @@ -2438,10 +2440,7 @@ describe("AgentChatPage 通用工作台", () => { container .querySelector('[data-testid="layout-transition"]') ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); - expect(mockSetSelectedArtifactIdAtom).toHaveBeenCalledWith( - "browser-assist:general", - ); + ).toBe("chat"); expect(mockJotaiState.artifacts).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -2461,7 +2460,7 @@ describe("AgentChatPage 通用工作台", () => { ).toBeNull(); expect( container.querySelector('[data-testid="artifact-renderer"]'), - ).not.toBeNull(); + ).toBeNull(); expect( container.querySelector('[data-testid="artifact-toolbar"]'), ).toBeNull(); @@ -2523,7 +2522,9 @@ describe("AgentChatPage 通用工作台", () => { await flushEffects(10); expect(mockJotaiState.selectedArtifactId).toBe("artifact-doc-1"); - expect(mockSetSelectedArtifactIdAtom).toHaveBeenCalledWith("artifact-doc-1"); + expect(mockSetSelectedArtifactIdAtom).toHaveBeenCalledWith( + "artifact-doc-1", + ); expect(mockSetSelectedArtifactIdAtom).not.toHaveBeenCalledWith( "browser-assist:general", ); @@ -2567,7 +2568,7 @@ describe("AgentChatPage 通用工作台", () => { ).toBe("chat"); }); - it("浏览器工具刚启动且只有 profile_key 时应自动拉起实时会话并打开浏览器协助画布", async () => { + it("浏览器工具刚启动且只有 profile_key 时不应自动打开浏览器协助画布", async () => { mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { observedWorkspaceIds.push(workspaceId); @@ -2640,35 +2641,20 @@ describe("AgentChatPage 通用工作台", () => { container .querySelector('[data-testid="layout-transition"]') ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); - expect(mockLaunchBrowserSession).toHaveBeenCalledWith({ - profile_key: "general_browser_assist", - url: "https://accounts.example.com", - open_window: false, - stream_mode: "both", - }); - expect(mockJotaiState.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "browser-assist:general", - type: "browser_assist", - meta: expect.objectContaining({ - sessionId: "auto-browser-session-1", - profileKey: "general_browser_assist", - url: "https://accounts.example.com", - }), - }), - ]), - ); + ).toBe("chat"); + expect( + mockJotaiState.artifacts.some( + (artifact) => + artifact.type === "browser_assist" && artifact.status !== "complete", + ), + ).toBe(false); }); it("浏览器协助自动拉起失败后不应重复自旋重试", async () => { const consoleWarnSpy = vi .spyOn(console, "warn") .mockImplementation(() => undefined); - mockLaunchBrowserSession.mockRejectedValue( - new Error("Chrome 启动失败"), - ); + mockLaunchBrowserSession.mockRejectedValue(new Error("Chrome 启动失败")); mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { observedWorkspaceIds.push(workspaceId); @@ -2743,23 +2729,8 @@ describe("AgentChatPage 通用工作台", () => { ), ); - expect(mockLaunchBrowserSession).toHaveBeenCalledTimes(1); - expect(autoLaunchWarnings).toHaveLength(1); - expect(mockJotaiState.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "browser-assist:general", - type: "browser_assist", - status: "error", - error: "Chrome 启动失败", - meta: expect.objectContaining({ - profileKey: "general_browser_assist", - url: "https://accounts.example.com", - launchState: "failed", - }), - }), - ]), - ); + expect(mockLaunchBrowserSession.mock.calls.length).toBeLessThanOrEqual(1); + expect(autoLaunchWarnings.length).toBeLessThanOrEqual(1); }); it("即使没有最新 tool result,也应从 session scoped Browser Assist 状态恢复实时画布", async () => { @@ -2785,10 +2756,7 @@ describe("AgentChatPage 通用工作台", () => { container .querySelector('[data-testid="layout-transition"]') ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); - expect(mockSetSelectedArtifactIdAtom).toHaveBeenCalledWith( - "browser-assist:general", - ); + ).toBe("chat"); expect(mockLaunchBrowserSession).not.toHaveBeenCalled(); expect(mockJotaiState.artifacts).toEqual( expect.arrayContaining([ @@ -2806,7 +2774,7 @@ describe("AgentChatPage 通用工作台", () => { ); }); - it("手动关闭浏览器协助画布后,同 scope 会话更新不应自动重新打开", async () => { + it("同 scope 会话更新不应再自动重新打开浏览器协助画布", async () => { mockBrowserAssistCompletedSession(); const harness = mountPage({ @@ -2815,15 +2783,6 @@ describe("AgentChatPage 通用工作台", () => { }); await flushEffects(10); - expect( - harness.container - .querySelector('[data-testid="layout-transition"]') - ?.getAttribute("data-mode"), - ).toBe("chat-canvas"); - - clickButton(harness.container, "toggle-canvas"); - await flushEffects(6); - expect( harness.container .querySelector('[data-testid="layout-transition"]') @@ -3038,43 +2997,88 @@ describe("AgentChatPage 通用工作台", () => { await flushEffects(12); expect(sharedSendMessageMock).not.toHaveBeenCalled(); - expect(mockLaunchBrowserSession).toHaveBeenCalledWith( - expect.objectContaining({ - profile_key: "general_browser_assist", - url: "https://mp.weixin.qq.com/", - }), - ); const messageListProps = mockMessageList.mock.calls.at(-1)?.[0] as | { messages?: Array>; } | undefined; + expect(messageListProps?.messages).toEqual([ + expect.objectContaining({ + role: "user", + content: prompt, + }), + expect.objectContaining({ + role: "assistant", + content: expect.stringContaining("回到当前任务重新发起"), + }), + ]); const latestAssistant = messageListProps?.messages?.at(-1) as | { actionRequests?: Array>; } | undefined; - const latestAction = latestAssistant?.actionRequests?.[0]; - expect(latestAction).toMatchObject({ - uiKind: "browser_preflight", - browserRequirement: "required_with_user_step", - browserPrepState: "awaiting_user", - }); - expect( + expect(latestAssistant?.actionRequests?.[0]).toBeUndefined(); + expect(mockToast.info).toHaveBeenCalledWith( + expect.stringContaining("回到当前任务重新发起"), + ); + const browserAssistLabel = container + .querySelector('[data-testid="chat-navbar"]') + ?.getAttribute("data-browser-assist-label"); + expect(["待浏览器处理", "浏览器已就绪"]).toContain(browserAssistLabel); + expect(["warning", "idle"]).toContain( container .querySelector('[data-testid="chat-navbar"]') - ?.getAttribute("data-browser-assist-label"), - ).toBe("等待登录"); - expect( - container - .querySelector('[data-testid="chat-navbar"]') - ?.getAttribute("data-browser-assist-attention"), - ).toBe("warning"); + ?.getAttribute("data-browser-assist-attention") || "idle", + ); }); - it("浏览器前置引导继续后应恢复原任务发送并禁用检索降级", async () => { + it("自动首条命中浏览器前置引导时也应按已有展示消息处理,避免工作台继续落回空白态", async () => { + const onHasMessagesChange = vi.fn(); + const prompt = "帮我把这篇文章发布到微信公众号后台"; + + renderPage({ + projectId: "project-browser-required-bootstrap", + contentId: "content-browser-required-bootstrap", + theme: "general", + lockTheme: true, + initialUserPrompt: prompt, + autoRunInitialPromptOnMount: true, + initialAutoSendRequestMetadata: { + harness: { + browser_assist: { + enabled: true, + profile_key: "general_browser_assist", + preferred_backend: "lime_extension_bridge", + auto_launch: false, + }, + }, + }, + onHasMessagesChange, + }); + await flushEffects(12); + + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + const messageListProps = mockMessageList.mock.calls.at(-1)?.[0] as + | { + messages?: Array>; + } + | undefined; + expect(messageListProps?.messages).toEqual([ + expect.objectContaining({ + role: "user", + content: prompt, + }), + expect.objectContaining({ + role: "assistant", + content: expect.stringContaining("回到当前任务重新发起"), + }), + ]); + expect(onHasMessagesChange).toHaveBeenLastCalledWith(true); + }); + + it("浏览器前置引导不应再注入继续执行确认消息", async () => { renderPage({ projectId: "project-browser-required-continue", theme: "general", @@ -3103,13 +3107,6 @@ describe("AgentChatPage 通用工作台", () => { const messageListProps = mockMessageList.mock.calls.at(-1)?.[0] as | { messages?: Array>; - onPermissionResponse?: (payload: { - requestId: string; - confirmed: boolean; - actionType?: "ask_user"; - response?: string; - userData?: unknown; - }) => Promise; } | undefined; const latestAssistant = messageListProps?.messages?.at(-1) as @@ -3117,45 +3114,11 @@ describe("AgentChatPage 通用工作台", () => { actionRequests?: Array>; } | undefined; - const requestId = latestAssistant?.actionRequests?.[0]?.requestId as - | string - | undefined; - act(() => { - void messageListProps?.onPermissionResponse?.({ - requestId: requestId || "", - confirmed: true, - actionType: "ask_user", - response: "我已完成登录,继续执行", - userData: { - answer: "我已完成登录,继续执行", - browserAction: "continue", - }, - }); - }); - await flushEffects(12); - - expect(sharedSendMessageMock).toHaveBeenCalledTimes(1); - const sendCall = getSendMessageCall(); - expect(sendCall.content).toBe(prompt); - expect(sendCall.images).toEqual([]); - expect(sendCall.webSearch).toBe(false); - expect(sendCall.thinking).toBe(false); - expect(sendCall.skipUserMessage).toBe(false); - expect(sendCall.executionStrategy).toBe("auto"); - expect(sendCall.modelOverride).toBeUndefined(); - expect(sendCall.autoContinue).toBeUndefined(); - expect(sendCall.options).toEqual( - expect.objectContaining({ - browserPreflightConfirmed: true, - requestMetadata: expect.objectContaining({ - harness: expect.objectContaining({ - browser_requirement: "required_with_user_step", - browser_launch_url: "https://mp.weixin.qq.com/", - browser_user_step_required: true, - }), - }), - }), + expect(latestAssistant?.actionRequests?.[0]).toBeUndefined(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + expect(mockToast.info).toHaveBeenCalledWith( + expect.stringContaining("回到当前任务重新发起"), ); }); @@ -3197,7 +3160,8 @@ describe("AgentChatPage 通用工作台", () => { expect(mockJotaiState.artifacts).toEqual([]); }); - it("当前待继续任务可从侧栏直接打开浏览器协助", async () => { + it("当前待继续任务可从侧栏直接打开浏览器工作台", async () => { + const onNavigate = vi.fn(); mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { observedWorkspaceIds.push(workspaceId); @@ -3259,6 +3223,7 @@ describe("AgentChatPage 通用工作台", () => { ); const container = renderPage({ + onNavigate, projectId: "project-sidebar-resume", theme: "general", lockTheme: true, @@ -3271,11 +3236,14 @@ describe("AgentChatPage 通用工作台", () => { await flushEffects(12); expect(sharedSwitchTopicMock).not.toHaveBeenCalled(); - expect(mockLaunchBrowserSession).toHaveBeenCalledWith( - expect.objectContaining({ - profile_key: "general_browser_assist", - }), - ); + expect(onNavigate).toHaveBeenCalledWith("browser-runtime", { + projectId: "project-sidebar-resume", + contentId: undefined, + initialProfileKey: "general_browser_assist", + initialSessionId: undefined, + initialTargetId: undefined, + }); + expect(mockLaunchBrowserSession).not.toHaveBeenCalled(); }); }); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 3052e8490..3d3b20f25 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -62,6 +62,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { entryBannerMessage, newChatAt, autoRunInitialPromptOnMount = false, + initialAutoSendRequestMetadata, initialSiteSkillLaunch, onHasMessagesChange, onSessionChange, @@ -166,6 +167,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { initialCreationMode={ activeBootstrap?.initialCreationMode ?? initialCreationMode } + lockTheme={activeBootstrap?.lockTheme ?? lockTheme} initialUserPrompt={ activeBootstrap?.initialUserPrompt ?? initialUserPrompt } @@ -176,6 +178,10 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { activeBootstrap?.initialRequestMetadata ?? props.initialRequestMetadata } + initialAutoSendRequestMetadata={ + activeBootstrap?.initialAutoSendRequestMetadata ?? + initialAutoSendRequestMetadata + } autoRunInitialPromptOnMount={ activeBootstrap?.autoRunInitialPromptOnMount ?? autoRunInitialPromptOnMount diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx index 0b9e4ccb4..8ddabf031 100644 --- a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx @@ -451,6 +451,10 @@ describe("ServiceSkillLaunchDialog", () => { await flushEffects(); + expect(document.body.textContent).toContain( + "你帮我在 GitHub 找一下和“browser assist mcp”相关的项目。", + ); + act(() => { browserRuntimeButton?.click(); }); @@ -466,7 +470,7 @@ describe("ServiceSkillLaunchDialog", () => { ); }); - it("站点技能缺少附着会话时应禁用 Claw 主执行按钮并提示转去浏览器工作台", async () => { + it("站点技能缺少附着会话时应阻断 Claw 主按钮,并提示先准备浏览器", async () => { mockSiteGetAdapterLaunchReadiness.mockResolvedValueOnce({ status: "requires_browser_runtime", adapter: "github/search", @@ -511,9 +515,11 @@ describe("ServiceSkillLaunchDialog", () => { await flushEffects(); - expect(document.body.textContent).toContain("需要浏览器工作台"); + expect(document.body.textContent).toContain("需要先准备浏览器"); expect(document.body.textContent).toContain("Claw 不会在后台偷偷启动浏览器"); + expect(document.body.textContent).toContain("请先进入浏览器工作台连接真实浏览器并打开目标站点页面"); expect(launchButton?.disabled).toBe(true); + expect(launchButton?.textContent).toBe("先准备浏览器再执行"); }); it("云端托管技能应显示云端运行文案且不暴露本地自动化入口", async () => { diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx index 37d5bc548..a8045d87e 100644 --- a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx @@ -28,7 +28,12 @@ import { listServiceSkillDependencies, } from "./skillPresentation"; import { supportsServiceSkillLocalAutomation } from "./automationDraft"; -import { isServiceSkillExecutableAsSiteAdapter } from "./siteCapabilityBinding"; +import { + buildServiceSkillNaturalLaunchMessage, + buildSiteLaunchBlockedMessage, + isServiceSkillExecutableAsSiteAdapter, + isSiteLaunchReadinessReady, +} from "./siteCapabilityBinding"; import type { ServiceSkillHomeItem, ServiceSkillSlotDefinition, @@ -168,6 +173,12 @@ export function ServiceSkillLaunchDialog({ if (!skill) { return ""; } + if (isServiceSkillExecutableAsSiteAdapter(skill)) { + return buildServiceSkillNaturalLaunchMessage({ + skill, + slotValues, + }); + } return formatServiceSkillPromptPreview(skill, slotValues); }, [skill, slotValues]); const supportsAutomation = useMemo( @@ -191,11 +202,14 @@ export function ServiceSkillLaunchDialog({ ); const primaryActionLabel = skill ? isSiteSkill - ? "在 Claw 中执行" + ? siteLaunchReadiness.phase === "blocked" + ? "先准备浏览器再执行" + : "在 Claw 中执行" : getServiceSkillPrimaryActionLabel(skill, canCreateAutomation) : "进入工作区"; const canLaunchInClaw = - validation.valid && (!isSiteSkill || siteLaunchReadiness.phase === "ready"); + validation.valid && + (!isSiteSkill || siteLaunchReadiness.phase !== "blocked"); const refreshSiteLaunchReadiness = useCallback(async () => { if (!open || !skill || !isServiceSkillExecutableAsSiteAdapter(skill)) { @@ -270,7 +284,7 @@ export function ServiceSkillLaunchDialog({ siteLaunchReadiness.phase === "ready" ? "可直接执行" : siteLaunchReadiness.phase === "blocked" - ? "需要浏览器工作台" + ? "需要先准备浏览器" : siteLaunchReadiness.phase === "error" ? "检测失败" : "检测中"; @@ -358,6 +372,12 @@ export function ServiceSkillLaunchDialog({ {readinessHint}

) : null} + {siteLaunchReadiness.phase === "blocked" && + canOpenBrowserRuntime ? ( +

+ {buildSiteLaunchBlockedMessage(siteLaunchReadiness.result)} +

+ ) : null} -
- ) : null} - -
-
-
- -
-
-
- - GATEWAY CONTROL - -
-

- {hideHeader - ? "统一控制内网共享、默认 Provider 与兼容性测试" - : "团队共享网关(内网)"} -

-

- Agent 默认直连 Provider。只有在需要让同网段设备复用这套服务、 - 默认模型和兼容接口时,再开启共享网关即可。 -

-
- -
- - 共享状态:{gatewayStatusLabel} - - - 共享模式:{gatewayModeLabel} - - - 默认 Provider:{activeProviderLabel} - - - 测试地址:{serverUrl} - -
-
- -
- - - -
-
-
-
- -
- {[ - { id: "server" as TabId, name: "网关控制" }, - { id: "logs" as TabId, name: "网关日志" }, - ].map((tab) => ( - - ))} -
- - {activeTab === "server" ? ( -
-
- - {status?.running ? "已对外共享" : "待启动"} - - } - > -
-
- - - - 当前测试地址:{serverUrl} - -
- -
-
-
-
-

- 共享模式 -

-

- 仅本机适合本地调试,内网共享适合同网段协作接入。 -

-
- - 当前:{gatewayModeLabel} - -
- -
- - -
-
- -
-
-

- 网关地址与鉴权 -

-

- 运行中会锁定配置,避免协作接入过程中误改监听地址。 -

-
- -
-
- - - - - - - - - - - - {hostOptions.map((ip) => { - let label = "(局域网)"; - if (ip === "127.0.0.1") { - label = "(仅本机)"; - } else if (ip === "0.0.0.0") { - label = "(所有接口)"; - } else if ( - !networkInfo?.all_ips.includes(ip) - ) { - label = "(已配置)"; - } - - return ( - - - - - - - - {ip} - - - {label} - - - - - ); - })} - - - - -
- -
-
- - setEditPort(e.target.value)} - className={INPUT_CLASS_NAME} - disabled={status?.running} - /> -
-
- - setEditApiKey(e.target.value)} - className={INPUT_CLASS_NAME} - disabled={status?.running} - /> -
-
-
-
-
- -
-
- {!status?.running - ? "仅本机模式只允许当前设备访问;内网共享模式会对同网段设备开放。建议完成地址与 API Key 配置后再开启共享。" - : `当前处于${gatewayModeLabel}模式。为了避免影响协作者访问,修改配置前请先关闭共享。`} -
- {hostMismatch ? ( -
- 配置的共享地址 {config?.server.host}{" "} - 当前不可用,运行时已自动切换到 - {status?.host}。关闭共享后可更新为新的可用地址。 -
- ) : null} -
-
-
- - - - {providerSwitchMsg} - - ) : null - } - > - {availableProviders.length === 0 ? ( -
- 暂无可用凭证,请先在凭证池或 API Key 设置中添加凭证。 -
- ) : ( -
-
- {availableProviders.map((p) => ( - - ))} -
- - {(() => { - const poolProviderType = - defaultProvider === "codex_oauth" - ? "codex" - : defaultProvider; - const currentOverview = poolOverview.find( - (o) => o.provider_type === poolProviderType, - ); - const oauthCredentials = ( - currentOverview?.credentials || [] - ).filter((cred) => !cred.is_disabled); - const matchingApiKeyProviders = - defaultProvider === "codex_oauth" - ? [] - : apiKeyProviders.filter((p) => { - if (p.id === defaultProvider && p.enabled) { - return true; - } - const mappedId = mapApiKeyProviderToId(p.type); - return mappedId === defaultProvider && p.enabled; - }); - const apiKeys = matchingApiKeyProviders.flatMap((p) => - p.api_keys.filter((k) => k.enabled), - ); - - if (oauthCredentials.length === 0 && apiKeys.length === 0) { - return ( -
- 当前 Provider 暂无可用凭证,请先补充账号或 API Key。 -
- ); - } - - return ( -
- {oauthCredentials.length > 0 ? ( -
-
-

- OAuth 凭证 -

- - {oauthCredentials.length} 个 - -
-
- {oauthCredentials.map((cred) => ( -
-
- - - {cred.name || cred.uuid.slice(0, 8)} - - - OAuth - -
- - 使用 {cred.usage_count} 次 - -
- ))} -
-
- ) : null} - - {apiKeys.length > 0 ? ( -
-
-

- API Key -

- - {apiKeys.length} 个 - -
-
- {apiKeys.map((key) => ( -
-
- - - {key.alias || key.api_key_masked} - - - API Key - -
- - 使用 {key.usage_count} 次 - -
- ))} -
-
- ) : null} -
- ); - })()} -
- )} -
- - - - 测试全部 - - } - > -
-
- - 测试模型 - - - - - - - - - - - - {allModels.map((model) => ( - - - - - - - {model.display_name} - - {model.provider_name} - - - {model.tier} - - - - - ))} - - - - -
- -
- {testEndpoints.map((endpoint) => { - const result = testResults[endpoint.id]; - const isExpanded = expandedTest === endpoint.id; - const curlCmd = getCurlCommand(endpoint); - const limeDebugHeaders = Object.entries( - result?.responseHeaders || {}, - ).filter(([key]) => - key.toLowerCase().startsWith("x-lime-"), - ); - - return ( -
-
-
- - {endpoint.method} - - - {endpoint.name} - - - {endpoint.path} - - {getStatusBadge(result)} -
-
- - - -
-
- - {isExpanded ? ( -
-
-
-

- curl 命令 -

-
-                                  {curlCmd}
-                                
-
- {result?.response ? ( -
-

- 响应{" "} - {result.httpStatus && - `(HTTP ${result.httpStatus})`} -

-
-                                    {(() => {
-                                      try {
-                                        return JSON.stringify(
-                                          JSON.parse(result.response),
-                                          null,
-                                          2,
-                                        );
-                                      } catch {
-                                        return result.response || "(空响应)";
-                                      }
-                                    })()}
-                                  
-
- ) : null} - {limeDebugHeaders.length > 0 ? ( -
-

- 调试头(x-lime-*) -

-
- {limeDebugHeaders.map( - ([key, value]) => ( -
- - {key} - - - {value} - -
- ), - )} -
-
- ) : null} -
-
- ) : null} -
- ); - })} -
-
-
-
- -
- - 每 3 秒刷新 - - } - > -
-
-
-

- 最近 1 分钟错误率 -

-

- {((status?.error_rate_1m ?? 0) * 100).toFixed(1)}% -

-
-
-

- 最近 1 分钟 P95 延迟 -

-

- {status?.p95_latency_ms_1m != null - ? `${status.p95_latency_ms_1m}ms` - : "-"} -

-
-
-

- 活跃请求 -

-

- {status?.active_requests ?? 0} -

-
-
-

- 熔断上游 -

-

- {status?.open_circuit_count ?? 0} -

-
-
- -
-
-

- 响应缓存 -

-
-
命中:{status?.response_cache?.hits ?? 0}
-
未命中:{status?.response_cache?.misses ?? 0}
-
缓存条目:{status?.response_cache?.size ?? 0}
-
- 淘汰次数:{status?.response_cache?.evictions ?? 0} -
-
-

- 缓存命中率:{cacheHitRate.toFixed(1)}% -

-
- -
-

- 请求去重 -

-
-
- 新请求:{status?.request_dedup?.check_new_total ?? 0} -
-
- In-Flight 等待: - {status?.request_dedup?.check_in_progress_total ?? 0} -
-
- 直接回放: - {status?.request_dedup?.check_completed_total ?? 0} -
-
- 等待回放成功: - {status?.request_dedup?.wait_success_total ?? 0} -
-
- 等待超时: - {status?.request_dedup?.wait_timeout_total ?? 0} -
-
- 当前 In-Flight: - {status?.request_dedup?.inflight_size ?? 0} -
-
-

- 去重回放率:{requestDedupReplayRate.toFixed(1)}% -

-
- -
-

- 幂等闭环 -

-
-
- 新键:{status?.idempotency?.check_new_total ?? 0} -
-
- 处理中冲突: - {status?.idempotency?.check_in_progress_total ?? 0} -
-
- 已完成回放: - {status?.idempotency?.check_completed_total ?? 0} -
-
- 完成写入: - {status?.idempotency?.complete_total ?? 0} -
-
- 当前 In-Progress: - {status?.idempotency?.in_progress_size ?? 0} -
-
- 当前 Completed: - {status?.idempotency?.completed_size ?? 0} -
-
-

- 幂等回放率:{idempotencyReplayRate.toFixed(1)}% -

-
- -
-

- 能力路由与回退 -

-
-
- 过滤评估: - {status?.capability_routing?.filter_eval_total ?? 0} -
-
- 过滤排除: - {status?.capability_routing?.filter_excluded_total ?? 0} -
-
- Provider 回退: - {status?.capability_routing?.provider_fallback_total ?? - 0} -
-
- 模型回退: - {status?.capability_routing?.model_fallback_total ?? 0} -
-
- 候选全排空: - {status?.capability_routing - ?.all_candidates_excluded_total ?? 0} -
-
原因样本:{capabilityExcludedReasonTotal}
-
-
- - tools: - {status?.capability_routing - ?.filter_excluded_tools_total ?? 0} - - - vision: - {status?.capability_routing - ?.filter_excluded_vision_total ?? 0} - - - context: - {status?.capability_routing - ?.filter_excluded_context_total ?? 0} - -
-
-
-
-
- - -
-
- - - {canExportSupportBundle ? ( - <> - - - - ) : null} -
- -
-                  {diagnosticsJson || "点击“拉取诊断”获取当前服务诊断 JSON。"}
-                
- -
- {canExportSupportBundle - ? supportBundleResult?.bundle_path - ? `最近一次支持包:${supportBundleResult.bundle_path}` - : "支持包默认导出到桌面;若桌面不可用,则回退到下载目录或系统临时目录。" - : "支持包导出仅在桌面端可用。"} -
-
-
-
-
- ) : ( - - - - )} -
- ); -} diff --git a/src/components/api-server/LogsTab.tsx b/src/components/api-server/LogsTab.tsx deleted file mode 100644 index f2c410bee..000000000 --- a/src/components/api-server/LogsTab.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { Trash2, Download } from "lucide-react"; -import { clearLogs, getLogs, type LogEntry } from "@/lib/api/logs"; - -export function LogsTab() { - const [logs, setLogs] = useState([]); - const logsContainerRef = useRef(null); - - useEffect(() => { - fetchLogs(); - const interval = setInterval(fetchLogs, 1000); - return () => clearInterval(interval); - }, []); - - const fetchLogs = async () => { - try { - const l = await getLogs(); - // 过滤掉 API 调用相关的日志,只保留系统日志 - // API 调用日志已由 Flow Monitor 接管 - const systemLogs = l.filter((log) => { - const msg = log.message; - // 排除 API 请求相关日志 - if (msg.includes("[REQ]")) return false; - if (msg.includes("[ROUTE]")) return false; - if (msg.includes("[CLIENT]")) return false; - if (msg.includes("request_id=")) return false; - if (msg.includes("POST /v1/")) return false; - if (msg.includes("GET /v1/")) return false; - if (msg.includes("Using pool credential")) return false; - return true; - }); - setLogs(systemLogs); - } catch (e) { - // 如果后端还没实现,使用空数组 - console.error(e); - } - }; - - const handleClear = async () => { - try { - await clearLogs(); - setLogs([]); - } catch { - setLogs([]); - } - }; - - const handleExport = () => { - const content = logs - .map( - (l) => - `[${new Date(l.timestamp).toLocaleString()}] [${l.level.toUpperCase()}] ${l.message}`, - ) - .join("\n"); - - const blob = new Blob([content], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `lime-logs-${new Date().toISOString().slice(0, 10)}.txt`; - a.click(); - URL.revokeObjectURL(url); - }; - - const getLevelColor = (level: string) => { - switch (level) { - case "error": - return "text-red-500"; - case "warn": - return "text-yellow-500"; - case "debug": - return "text-gray-400"; - default: - return "text-blue-500"; - } - }; - - const getLevelBg = (level: string) => { - switch (level) { - case "error": - return "bg-red-500/10"; - case "warn": - return "bg-yellow-500/10"; - default: - return ""; - } - }; - - return ( -
-
- - -
- -
-
- {logs.length === 0 ? ( -

- 暂无日志,软件运行时将显示网关与系统日志 -

- ) : ( - logs.map((log, i) => ( -
- - {new Date(log.timestamp).toLocaleTimeString()} - - - [{log.level.toUpperCase()}] - - {log.message} -
- )) - )} -
-
-
- ); -} diff --git a/src/components/api-server/ModelsTab.tsx b/src/components/api-server/ModelsTab.tsx deleted file mode 100644 index 063d37edc..000000000 --- a/src/components/api-server/ModelsTab.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { useState, useEffect, useMemo } from "react"; -import { Cpu, RefreshCw, Copy, Check, Search } from "lucide-react"; -import { getAvailableModels, type ModelInfo } from "@/lib/api/modelCatalog"; - -// 根据 provider_id 获取分组配置 -const PROVIDER_GROUPS: Record = { - anthropic: { - name: "Anthropic", - color: - "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", - }, - google: { - name: "Google", - color: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", - }, - openai: { - name: "OpenAI", - color: - "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", - }, - dashscope: { - name: "阿里云", - color: - "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", - }, - deepseek: { - name: "DeepSeek", - color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300", - }, - zhipu: { - name: "智谱", - color: - "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300", - }, - moonshot: { - name: "月之暗面", - color: - "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300", - }, - mistral: { - name: "Mistral", - color: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", - }, - cohere: { - name: "Cohere", - color: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", - }, -}; - -export function ModelsTab() { - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [copied, setCopied] = useState(null); - const [search, setSearch] = useState(""); - const [selectedProvider, setSelectedProvider] = useState(null); - - useEffect(() => { - fetchModels(); - }, []); - - const fetchModels = async () => { - setLoading(true); - setError(null); - - try { - const data = await getAvailableModels(); - setModels(data || []); - } catch (e: any) { - setError(e.toString()); - setModels([]); - } - - setLoading(false); - }; - - const copyModelId = (id: string) => { - navigator.clipboard.writeText(id); - setCopied(id); - setTimeout(() => setCopied(null), 2000); - }; - - const getProviderBadge = (providerId: string) => { - const config = PROVIDER_GROUPS[providerId]; - if (!config) { - return ( - - {providerId} - - ); - } - return ( - - {config.name} - - ); - }; - - // 按 provider 分组统计 - const providerCounts = useMemo(() => { - return models.reduce( - (acc, model) => { - const provider = model.owned_by; - acc[provider] = (acc[provider] || 0) + 1; - return acc; - }, - {} as Record, - ); - }, [models]); - - // 获取所有 provider 列表(按数量排序) - const providers = useMemo(() => { - return Object.entries(providerCounts) - .sort((a, b) => b[1] - a[1]) - .map(([id]) => id); - }, [providerCounts]); - - // 过滤模型 - const filteredModels = useMemo(() => { - return models.filter((model) => { - const matchesSearch = model.id - .toLowerCase() - .includes(search.toLowerCase()); - const matchesProvider = - !selectedProvider || model.owned_by === selectedProvider; - return matchesSearch && matchesProvider; - }); - }, [models, search, selectedProvider]); - - return ( -
- {error && ( -
- {error} -
- )} - - {/* 搜索和过滤 */} -
-
- - setSearch(e.target.value)} - className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm" - /> -
- -
- - {/* Provider 过滤标签 */} -
- - {providers.map((providerId) => { - const count = providerCounts[providerId] || 0; - const config = PROVIDER_GROUPS[providerId]; - return ( - - ); - })} -
- - {/* 模型列表 */} -
-
-
- 模型列表 - - {filteredModels.length} 个模型 - -
-
- - {loading ? ( -
- -
- ) : filteredModels.length === 0 ? ( -
- -

暂无模型数据

-
- ) : ( -
- {filteredModels.map((model) => ( -
-
- -
-
- {model.id} - {getProviderBadge(model.owned_by)} -
-

- {model.owned_by} -

-
-
- -
- ))} -
- )} -
- - {/* 使用说明 */} -
-

使用说明

-
-

• 点击模型 ID 右侧的复制按钮可快速复制模型名称

-

- • 在 API 请求中使用{" "} - model 参数指定模型 -

-

• 不同 Provider 支持的模型不同,请确保已配置对应的凭证

-
-
-
- ); -} diff --git a/src/components/artifact/renderers/BrowserAssistRenderer.test.tsx b/src/components/artifact/renderers/BrowserAssistRenderer.test.tsx index 234dcc880..26b8d7ea9 100644 --- a/src/components/artifact/renderers/BrowserAssistRenderer.test.tsx +++ b/src/components/artifact/renderers/BrowserAssistRenderer.test.tsx @@ -4,31 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Artifact } from "@/lib/artifact/types"; import { BrowserAssistRenderer } from "./BrowserAssistRenderer"; -const { mockBrowserRuntimeWorkspace } = vi.hoisted(() => ({ - mockBrowserRuntimeWorkspace: vi.fn( - ({ - initialProfileKey, - initialSessionId, - initialTargetId, - }: { - initialProfileKey?: string; - initialSessionId?: string; - initialTargetId?: string; - }) => ( -
- ), - ), -})); - -vi.mock("@/features/browser-runtime", () => ({ - BrowserRuntimeWorkspace: mockBrowserRuntimeWorkspace, -})); - const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; function createArtifact(overrides: Partial = {}): Artifact { @@ -104,7 +79,7 @@ describe("BrowserAssistRenderer", () => { expect(container.textContent).toContain("https://example.com"); }); - it("完整会话 Artifact 应渲染浏览器工作区", async () => { + it("完整会话 Artifact 也不应再在 Claw 画布内渲染浏览器工作区", async () => { const container = await renderArtifact( createArtifact({ status: "complete", @@ -116,15 +91,10 @@ describe("BrowserAssistRenderer", () => { }), ); - const workspace = container.querySelector( - "[data-testid=\"browser-runtime-workspace\"]", - ); - - expect(workspace).not.toBeNull(); - expect(workspace?.getAttribute("data-profile-key")).toBe( - "general_browser_assist", - ); - expect(workspace?.getAttribute("data-session-id")).toBe("session-1"); - expect(workspace?.getAttribute("data-target-id")).toBe("target-1"); + expect(container.textContent).toContain("浏览器协助已迁移到浏览器工作台"); + expect(container.textContent).toContain("session-1"); + expect( + container.querySelector("[data-testid=\"browser-runtime-workspace\"]"), + ).toBeNull(); }); }); diff --git a/src/components/artifact/renderers/BrowserAssistRenderer.tsx b/src/components/artifact/renderers/BrowserAssistRenderer.tsx index adea5130c..3c870c4db 100644 --- a/src/components/artifact/renderers/BrowserAssistRenderer.tsx +++ b/src/components/artifact/renderers/BrowserAssistRenderer.tsx @@ -1,13 +1,12 @@ /** * @file Browser Assist Artifact 渲染器 - * @description 在通用对话画布中承载浏览器协助工作台 + * @description 仅展示浏览器协助状态说明,不再在 Claw 画布中嵌入浏览器工作台 * @module components/artifact/renderers/BrowserAssistRenderer */ import React, { memo } from "react"; import { AlertCircle, Loader2 } from "lucide-react"; import type { ArtifactRendererProps } from "@/lib/artifact/types"; -import { BrowserRuntimeWorkspace } from "@/features/browser-runtime"; function readMetaString( meta: Record, @@ -66,7 +65,7 @@ export const BrowserAssistRenderer: React.FC = memo(

{launchHint || - "正在启动 Chrome、连接调试通道并等待首帧画面,通常需要 3–8 秒。"} + "正在启动 Chrome 并准备浏览器工作台会话,通常需要 3–8 秒。"}

{launchUrl ? (
@@ -121,13 +120,30 @@ export const BrowserAssistRenderer: React.FC = memo( } return ( -
- +
+
+
+ +
+
+ 浏览器协助已迁移到浏览器工作台 +
+

+ Claw 不再在当前工作区中渲染浏览器实时画面。请从首页、顶栏或技能入口打开浏览器工作台继续接管与调试。 +

+ {launchUrl ? ( +
+ {launchUrl} +
+ ) : null} + {initialSessionId || initialProfileKey || initialTargetId ? ( +

+ {initialSessionId + ? `当前会话 ${initialSessionId} 已可用于浏览器工作台接管。` + : `当前配置 ${initialProfileKey || initialTargetId} 已可用于浏览器工作台接管。`} +

+ ) : null} +
); }, diff --git a/src/components/channels/ImConfigPage.test.tsx b/src/components/channels/ImConfigPage.test.tsx new file mode 100644 index 000000000..41dfbc53f --- /dev/null +++ b/src/components/channels/ImConfigPage.test.tsx @@ -0,0 +1,384 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockGetConfig, + mockSaveConfig, + mockUseConfiguredProviders, + mockTelegramChannelProbe, + mockFeishuChannelProbe, + mockWechatChannelListAccounts, + mockWechatChannelLoginStart, + mockWechatChannelLoginWait, + mockWechatChannelProbe, + mockGatewayChannelStart, + mockQrCodeToDataUrl, +} = vi.hoisted(() => ({ + mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), + mockUseConfiguredProviders: vi.fn(), + mockTelegramChannelProbe: vi.fn(), + mockFeishuChannelProbe: vi.fn(), + mockWechatChannelListAccounts: vi.fn(), + mockWechatChannelLoginStart: vi.fn(), + mockWechatChannelLoginWait: vi.fn(), + mockWechatChannelProbe: vi.fn(), + mockGatewayChannelStart: vi.fn(), + mockQrCodeToDataUrl: vi.fn(), +})); + +vi.mock("@/lib/api/appConfig", () => ({ + getConfig: mockGetConfig, + saveConfig: mockSaveConfig, +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + useConfiguredProviders: mockUseConfiguredProviders, +})); + +vi.mock("@/lib/api/channelsRuntime", () => ({ + telegramChannelProbe: mockTelegramChannelProbe, + feishuChannelProbe: mockFeishuChannelProbe, + wechatChannelListAccounts: mockWechatChannelListAccounts, + wechatChannelLoginStart: mockWechatChannelLoginStart, + wechatChannelLoginWait: mockWechatChannelLoginWait, + wechatChannelProbe: mockWechatChannelProbe, + gatewayChannelStart: mockGatewayChannelStart, +})); + +vi.mock("qrcode", () => ({ + default: { + toDataURL: mockQrCodeToDataUrl, + }, +})); + +vi.mock("@/components/settings-v2/system/channels/LegacyChannelsWorkbench", () => ({ + ChannelsDebugWorkbench: () =>
, +})); + +import { ImConfigPage } from "./ImConfigPage"; + +interface MountedPage { + container: HTMLDivElement; + root: Root; +} + +const mountedPages: MountedPage[] = []; + +function createConfig() { + return { + channels: { + telegram: { + enabled: true, + bot_token: "123456:telegram-token", + allowed_user_ids: ["10001"], + default_model: "openai/gpt-4.1", + }, + discord: { + enabled: false, + bot_token: "", + allowed_server_ids: [], + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "partial", + reply_to_mode: "off", + }, + feishu: { + enabled: true, + app_id: "cli_test_demo", + app_secret: "secret", + default_model: "openai/gpt-4.1", + dm_policy: "pairing", + allow_from: ["ou_test"], + group_policy: "allowlist", + group_allow_from: [], + }, + wechat: { + enabled: true, + bot_token: "", + base_url: "https://ilinkai.weixin.qq.com", + cdn_base_url: "https://novac2c.cdn.weixin.qq.com/c2c", + default_model: "openai/gpt-4.1", + default_account: "wechat_1", + accounts: { + wechat_1: { + enabled: true, + name: "微信一号", + base_url: "https://ilinkai.weixin.qq.com", + }, + }, + dm_policy: "pairing", + allow_from: ["wx_user_1"], + group_policy: "allowlist", + group_allow_from: [], + }, + }, + } as any; +} + +function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedPages.push({ container, root }); + return container; +} + +async function flushEffects(times = 6) { + await act(async () => { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } + }); +} + +function findButtonByTestId(testId: string): HTMLButtonElement { + const button = document.body.querySelector(`[data-testid="${testId}"]`); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`未找到按钮: ${testId}`); + } + return button; +} + +function findButtonByText(text: string): HTMLButtonElement { + const button = Array.from(document.body.querySelectorAll("button")).find((item) => + item.textContent?.includes(text), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`未找到按钮文案: ${text}`); + } + return button; +} + +async function clickButton(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); +} + +describe("ImConfigPage", () => { + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + + mockGetConfig.mockResolvedValue(createConfig()); + mockSaveConfig.mockResolvedValue(undefined); + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "openai", + label: "OpenAI", + type: "openai", + registryId: "openai", + customModels: ["gpt-4.1"], + }, + ], + loading: false, + }); + mockTelegramChannelProbe.mockResolvedValue({ + ok: true, + message: "ok", + account_id: "default", + }); + mockFeishuChannelProbe.mockResolvedValue({ + ok: true, + message: "ok", + account_id: "default", + }); + mockWechatChannelListAccounts.mockResolvedValue([ + { + accountId: "wechat_1", + enabled: true, + name: "微信一号", + hasToken: true, + }, + ]); + mockWechatChannelLoginStart.mockResolvedValue({ + sessionKey: "wechat-session", + qrcodeUrl: "wechat-login-qr", + message: "ok", + }); + mockWechatChannelLoginWait.mockResolvedValue({ + connected: false, + message: "waiting", + }); + mockWechatChannelProbe.mockResolvedValue({ + ok: true, + message: "ok", + accountId: "wechat_1", + }); + mockGatewayChannelStart.mockResolvedValue({ + channel: "wechat", + status: { ok: true }, + }); + mockQrCodeToDataUrl.mockResolvedValue("data:image/png;base64,qr"); + }); + + afterEach(() => { + while (mountedPages.length > 0) { + const mounted = mountedPages.pop(); + if (!mounted) { + continue; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("应将 Telegram、飞书、微信作为同级主入口展示", async () => { + renderPage(); + await flushEffects(); + + const text = document.body.textContent ?? ""; + expect(text).toContain("IM 配置"); + expect(text).toContain("Telegram"); + expect(text).toContain("飞书"); + expect(text).toContain("微信"); + expect(text).toContain("即将开放"); + expect(text).toContain("Discord"); + expect(text).toContain("钉钉"); + expect(text).toContain("高级排障"); + expect(text).not.toContain("调试配置"); + }); + + it("点击 Telegram 配置后应打开弹窗并支持保存", async () => { + renderPage(); + await flushEffects(); + + await clickButton(findButtonByTestId("telegram-config-button")); + await flushEffects(); + + expect(document.body.textContent).toContain("Telegram 配置"); + expect(document.body.textContent).toContain("机器人 Token"); + expect(document.body.textContent).toContain("联调检查"); + expect(document.body.textContent).toContain("查看日志排查"); + + await clickButton(findButtonByText("保存")); + await flushEffects(); + + expect(mockSaveConfig).toHaveBeenCalledTimes(1); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ + channels: expect.objectContaining({ + telegram: expect.objectContaining({ + bot_token: "123456:telegram-token", + enabled: true, + }), + }), + }), + ); + }); + + it("飞书配置弹窗应承接高级接入参数", async () => { + renderPage(); + await flushEffects(); + + await clickButton(findButtonByTestId("feishu-config-button")); + await flushEffects(); + + let text = document.body.textContent ?? ""; + expect(text).toContain("配置飞书"); + expect(text).toContain("配置状态"); + expect(text).toContain("当前机器人"); + expect(text).toContain("准入策略"); + expect(text).toContain("已允许用户"); + + await clickButton(findButtonByText("高级配置")); + await flushEffects(); + + text = document.body.textContent ?? ""; + expect(text).toContain("回调方式"); + expect(text).toContain("回调路径"); + expect(text).toContain("验证 Token"); + }); + + it("飞书名单编辑应并回配置弹窗,不再暴露独立入口", async () => { + renderPage(); + await flushEffects(); + + await clickButton(findButtonByTestId("feishu-config-button")); + await flushEffects(); + + const text = document.body.textContent ?? ""; + expect(text).toContain("已允许用户"); + expect(text).toContain("配对模式"); + expect(text).not.toContain("飞书配对管理"); + }); + + it("点击微信扫码配置后应生成二维码并开始登录流程", async () => { + renderPage(); + await flushEffects(); + + await clickButton(findButtonByTestId("wechat-config-button")); + await flushEffects(10); + await clickButton(findButtonByText("高级参数")); + await flushEffects(); + + expect(mockWechatChannelLoginStart).toHaveBeenCalledTimes(1); + expect(mockWechatChannelLoginWait).toHaveBeenCalledTimes(1); + expect(mockQrCodeToDataUrl).toHaveBeenCalledWith( + "wechat-login-qr", + expect.objectContaining({ + width: 320, + }), + ); + expect(document.body.querySelector('img[alt="微信扫码二维码"]')).not.toBeNull(); + expect(document.body.textContent).toContain("群组策略"); + expect(document.body.textContent).toContain("账号参数"); + expect(document.body.textContent).toContain("联调检查"); + expect(document.body.textContent).toContain("展开账号"); + expect(document.body.textContent).not.toContain("账号名称"); + + await clickButton(findButtonByText("展开账号")); + await flushEffects(); + await clickButton(findButtonByText("微信一号")); + await flushEffects(); + + expect(document.body.textContent).toContain("编辑账号详情"); + expect(document.body.textContent).toContain("账号名称"); + }); + + it("配置弹窗里的日志排查入口应展开旧的高级工作台", async () => { + renderPage(); + await flushEffects(); + + await clickButton(findButtonByTestId("telegram-config-button")); + await flushEffects(); + await clickButton(findButtonByText("查看日志排查")); + await flushEffects(); + + expect(document.body.textContent).toContain("高级排障"); + expect( + document.body.querySelector('[data-testid="channels-debug-workbench"]'), + ).not.toBeNull(); + }); + + it("展开高级区后应保留旧的调试工作台", async () => { + renderPage(); + await flushEffects(); + + await clickButton(findButtonByText("高级排障")); + await flushEffects(); + + expect( + document.body.querySelector('[data-testid="channels-debug-workbench"]'), + ).not.toBeNull(); + }); +}); diff --git a/src/components/channels/ImConfigPage.tsx b/src/components/channels/ImConfigPage.tsx new file mode 100644 index 000000000..a15b2b61c --- /dev/null +++ b/src/components/channels/ImConfigPage.tsx @@ -0,0 +1,2614 @@ +import { + Suspense, + lazy, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { + Bot, + ChevronDown, + ChevronRight, + MessageSquare, + ScanQrCode, + Send, + ShieldCheck, + Sparkles, +} from "lucide-react"; +import QRCode from "qrcode"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "@/components/Modal"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; +import { + feishuChannelProbe, + gatewayChannelStart, + telegramChannelProbe, + wechatChannelListAccounts, + wechatChannelLoginStart, + wechatChannelLoginWait, + wechatChannelProbe, + type ChannelsConfig, + type FeishuBotConfig, + type TelegramBotConfig, + type WechatBotConfig, + type WechatConfiguredAccount, + type WechatLoginWaitResult, +} from "@/lib/api/channelsRuntime"; +import { + filterProviderModelsByCompatibility, + getProviderModelCompatibilityIssue, +} from "@/components/agent/chat/utils/providerModelCompatibility"; +import { cn } from "@/lib/utils"; + +const ChannelsDebugWorkbench = lazy(() => + import("@/components/settings-v2/system/channels/LegacyChannelsWorkbench").then( + (module) => ({ + default: module.ChannelsDebugWorkbench, + }), + ), +); + +type FlashMessage = + | { + type: "success" | "error"; + text: string; + } + | null; + +type ManagedChannel = "telegram" | "feishu" | "wechat"; + +const DEFAULT_WECHAT_BASE_URL = "https://ilinkai.weixin.qq.com"; +const DEFAULT_WECHAT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"; +const DEFAULT_WECHAT_BOT_TYPE = "3"; + +const DEFAULT_CHANNELS: ChannelsConfig = { + telegram: { + enabled: false, + bot_token: "", + allowed_user_ids: [], + default_model: undefined, + }, + discord: { + enabled: false, + bot_token: "", + allowed_server_ids: [], + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "partial", + reply_to_mode: "off", + }, + feishu: { + enabled: false, + app_id: "", + app_secret: "", + default_model: undefined, + dm_policy: "open", + allow_from: ["*"], + group_policy: "allowlist", + group_allow_from: [], + }, + wechat: { + enabled: false, + bot_token: "", + base_url: "", + cdn_base_url: "", + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "off", + reply_to_mode: "off", + }, +}; + +function normalizeChannelsConfig(input?: Partial): ChannelsConfig { + return { + telegram: { + ...DEFAULT_CHANNELS.telegram, + ...(input?.telegram ?? {}), + allowed_user_ids: + input?.telegram?.allowed_user_ids ?? + DEFAULT_CHANNELS.telegram.allowed_user_ids, + }, + discord: { + ...DEFAULT_CHANNELS.discord, + ...(input?.discord ?? {}), + accounts: input?.discord?.accounts ?? DEFAULT_CHANNELS.discord.accounts, + allow_from: + input?.discord?.allow_from ?? DEFAULT_CHANNELS.discord.allow_from, + group_allow_from: + input?.discord?.group_allow_from ?? + DEFAULT_CHANNELS.discord.group_allow_from, + }, + feishu: { + ...DEFAULT_CHANNELS.feishu, + ...(input?.feishu ?? {}), + allow_from: + input?.feishu?.allow_from ?? DEFAULT_CHANNELS.feishu.allow_from, + group_allow_from: + input?.feishu?.group_allow_from ?? + DEFAULT_CHANNELS.feishu.group_allow_from, + groups: input?.feishu?.groups ?? DEFAULT_CHANNELS.feishu.groups, + }, + wechat: { + ...DEFAULT_CHANNELS.wechat, + ...(input?.wechat ?? {}), + accounts: input?.wechat?.accounts ?? DEFAULT_CHANNELS.wechat.accounts, + allow_from: + input?.wechat?.allow_from ?? DEFAULT_CHANNELS.wechat.allow_from, + group_allow_from: + input?.wechat?.group_allow_from ?? + DEFAULT_CHANNELS.wechat.group_allow_from, + groups: input?.wechat?.groups ?? DEFAULT_CHANNELS.wechat.groups, + }, + }; +} + +function describeDmPolicy(policy?: string): string { + switch ((policy || "").trim().toLowerCase()) { + case "open": + return "开放模式"; + case "allowlist": + return "白名单"; + case "pairing": + return "配对模式"; + case "disabled": + return "已关闭"; + default: + return "未设置"; + } +} + +function describeTelegramScope(config: TelegramBotConfig): string { + return config.allowed_user_ids.length === 0 + ? "所有用户可发起" + : `已限制 ${config.allowed_user_ids.length} 个用户`; +} + +function describeWechatStatus(config: WechatBotConfig): string { + const accountCount = Object.keys(config.accounts ?? {}).length; + if (!config.enabled) { + return "未启用"; + } + if (accountCount === 0) { + return "等待扫码"; + } + return `已接入 ${accountCount} 个账号`; +} + +function describeWechatAccountModel(account: { + default_model?: string; +}): string { + return account.default_model?.trim() || "跟随全局模型"; +} + +function describeWechatAccountRoute( + account: { + base_url?: string; + }, + fallbackBaseUrl?: string, +): string { + const accountBaseUrl = account.base_url?.trim(); + const normalizedFallback = fallbackBaseUrl?.trim(); + if (!accountBaseUrl || accountBaseUrl === normalizedFallback) { + return "沿用默认地址"; + } + return "自定义地址"; +} + +function normalizeConnectionMode(value?: string): "webhook" | "websocket" { + return (value || "").trim().toLowerCase() === "websocket" + ? "websocket" + : "webhook"; +} + +function normalizeStreamingMode(value?: string): "partial" | "block" | "off" { + const normalized = (value || "").trim().toLowerCase(); + if (normalized === "block" || normalized === "off") { + return normalized; + } + return "partial"; +} + +function normalizeReplyToMode(value?: string): "off" | "first" | "all" { + const normalized = (value || "").trim().toLowerCase(); + if (normalized === "first" || normalized === "all") { + return normalized; + } + return "off"; +} + +function normalizeWechatGroupPolicy( + value?: string, +): "allowlist" | "open" | "disabled" { + const normalized = (value || "").trim().toLowerCase(); + if (normalized === "open" || normalized === "disabled") { + return normalized; + } + return "allowlist"; +} + +function maskMiddle(value: string, keep = 4): string { + const trimmed = value.trim(); + if (trimmed.length <= keep * 2) { + return trimmed || "未填写"; + } + return `${trimmed.slice(0, keep)}...${trimmed.slice(-keep)}`; +} + +function buildNextConfig( + currentConfig: Config | null, + channels: ChannelsConfig, +): Config { + return { + ...(currentConfig ?? ({} as Config)), + channels, + } as Config; +} + +function SectionMessage({ message }: { message: FlashMessage }) { + if (!message) { + return null; + } + + return ( +
+ {message.text} +
+ ); +} + +function StatusBadge({ + tone, + children, +}: { + tone: "success" | "warning" | "neutral"; + children: string; +}) { + return ( + + {children} + + ); +} + +function ActionButton({ + kind = "secondary", + onClick, + children, + disabled, + type = "button", + ariaLabel, + dataTestId, +}: { + kind?: "primary" | "secondary"; + onClick?: () => void; + children: string; + disabled?: boolean; + type?: "button" | "submit"; + ariaLabel?: string; + dataTestId?: string; +}) { + return ( + + ); +} + +function SwitchField({ + checked, + onChange, + title, + description, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + title: string; + description: string; +}) { + return ( +
+
+
{title}
+

{description}

+
+ +
+ ); +} + +function FieldLabel({ + label, + optional, +}: { + label: string; + optional?: boolean; +}) { + return ( + + ); +} + +function TextField({ + label, + value, + onChange, + placeholder, + optional, + mono, + type = "text", +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + optional?: boolean; + mono?: boolean; + type?: "text" | "number"; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + className={cn( + "h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200", + mono && "font-mono", + )} + /> +
+ ); +} + +function SecretField({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + setVisible(false); + }, [label]); + + return ( +
+ +
+ onChange(event.target.value)} + placeholder={placeholder} + className="h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200" + /> + setVisible((current) => !current)}> + {visible ? "隐藏" : "显示"} + +
+
+ ); +} + +function StringListEditor({ + label, + values, + onChange, + placeholder, +}: { + label: string; + values: string[]; + onChange: (values: string[]) => void; + placeholder: string; +}) { + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + setInputValue(""); + }, [label]); + + const addItems = (raw: string) => { + const candidates = raw + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + if (candidates.length === 0) { + return; + } + const nextValues = Array.from(new Set([...values, ...candidates])); + onChange(nextValues); + setInputValue(""); + }; + + const removeValue = (target: string) => { + onChange(values.filter((item) => item !== target)); + }; + + return ( +
+ +
+ {values.length > 0 ? ( +
+ {values.map((value) => ( + + {value} + + + ))} +
+ ) : null} +
+ setInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + addItems(inputValue); + } + }} + placeholder={placeholder} + className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200" + /> + addItems(inputValue)}>添加 +
+
+
+ ); +} + +function ModelSelect({ + value, + onChange, +}: { + value?: string; + onChange: (value: string | undefined) => void; +}) { + const { providers, loading } = useConfiguredProviders(); + + useEffect(() => { + if (!value || loading) { + return; + } + + const separatorIndex = value.indexOf("/"); + if (separatorIndex < 0) { + return; + } + + const providerKey = value.slice(0, separatorIndex); + const modelName = value.slice(separatorIndex + 1); + const provider = providers.find((item) => item.key === providerKey); + if (!provider) { + return; + } + + const issue = getProviderModelCompatibilityIssue({ + providerType: provider.type, + configuredProviderType: provider.type, + model: modelName, + }); + if (issue) { + onChange(undefined); + } + }, [loading, onChange, providers, value]); + + return ( +
+ + +
+ ); +} + +function InlineMessage({ message }: { message: FlashMessage }) { + if (!message) { + return null; + } + + return ( +
+ {message.text} +
+ ); +} + +function ProbePanel({ + description, + message, + busy, + onProbe, + onOpenAdvanced, +}: { + description: string; + message: FlashMessage; + busy: boolean; + onProbe: () => void | Promise; + onOpenAdvanced?: () => void; +}) { + return ( +
+
+
+
联调检查
+

{description}

+
+
+ void onProbe()} disabled={busy}> + {busy ? "检测中..." : "检测连接"} + + {onOpenAdvanced ? ( + 查看日志排查 + ) : null} +
+
+ {message ? ( +
+ +
+ ) : null} +
+ ); +} + +function QrCodePreview({ value }: { value: string }) { + const [dataUrl, setDataUrl] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const trimmed = value.trim(); + + if (!trimmed) { + setDataUrl(""); + setError(null); + return; + } + + setError(null); + void QRCode.toDataURL(trimmed, { + width: 320, + margin: 1, + errorCorrectionLevel: "M", + }) + .then((nextValue) => { + if (!cancelled) { + setDataUrl(nextValue); + } + }) + .catch((reason: unknown) => { + if (!cancelled) { + setDataUrl(""); + setError(reason instanceof Error ? reason.message : String(reason)); + } + }); + + return () => { + cancelled = true; + }; + }, [value]); + + if (error) { + return ( +
+ 二维码生成失败:{error} +
+ ); + } + + if (!dataUrl) { + return ( +
+ 正在生成二维码... +
+ ); + } + + return ( +
+ 微信扫码二维码 +
+ ); +} + +function ChannelCard({ + icon, + title, + subtitle, + status, + statusTone, + details, + actions, +}: { + icon: ReactNode; + title: string; + subtitle: string; + status: string; + statusTone: "success" | "warning" | "neutral"; + details: Array<{ label: string; value: string }>; + actions: ReactNode; +}) { + return ( +
+
+
+
+ {icon} +
+
+
+

+ {title} +

+ {status} +
+

{subtitle}

+
+
+
+
+ {details.map((detail) => ( +
+
{detail.label}
+
{detail.value}
+
+ ))} +
+
{actions}
+
+ ); +} + +interface TelegramConfigDialogProps { + isOpen: boolean; + config: TelegramBotConfig; + onClose: () => void; + onSave: (config: TelegramBotConfig, successText: string) => Promise; + onOpenAdvanced?: () => void; +} + +function TelegramConfigDialog({ + isOpen, + config, + onClose, + onSave, + onOpenAdvanced, +}: TelegramConfigDialogProps) { + const [draft, setDraft] = useState(config); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + if (isOpen) { + setDraft(config); + setBusy(false); + setMessage(null); + } + }, [config, isOpen]); + + const handleProbe = async () => { + setBusy(true); + setMessage(null); + try { + const result = await telegramChannelProbe(); + setMessage({ + type: result.ok ? "success" : "error", + text: result.message || (result.ok ? "Telegram 连接正常" : "Telegram 连接失败"), + }); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusy(false); + } + }; + + const handleSave = async () => { + if (draft.enabled && !draft.bot_token.trim()) { + setMessage({ type: "error", text: "启用 Telegram 前请先填写机器人 Token" }); + return; + } + + setBusy(true); + setMessage(null); + try { + await onSave( + { + ...draft, + bot_token: draft.bot_token.trim(), + allowed_user_ids: draft.allowed_user_ids.map((item) => item.trim()).filter(Boolean), + }, + "Telegram 配置已保存", + ); + onClose(); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusy(false); + } + }; + + return ( + + Telegram 配置 + + setDraft((current) => ({ ...current, enabled: checked }))} + title="启用 Telegram" + description="机器人 Token、用户范围和联调都在这里。" + /> + setDraft((current) => ({ ...current, bot_token: value }))} + placeholder="123456:ABC-DEF..." + /> + + setDraft((current) => ({ ...current, allowed_user_ids: values })) + } + placeholder="输入 Telegram 用户 ID" + /> + setDraft((current) => ({ ...current, default_model: value }))} + /> + + + + 取消 + void handleSave()} disabled={busy}> + 保存 + + + + ); +} + +interface FeishuConfigDialogProps { + isOpen: boolean; + config: FeishuBotConfig; + onClose: () => void; + onSave: (config: FeishuBotConfig, successText: string) => Promise; + onOpenAdvanced?: () => void; +} + +function FeishuConfigDialog({ + isOpen, + config, + onClose, + onSave, + onOpenAdvanced, +}: FeishuConfigDialogProps) { + const [draft, setDraft] = useState(config); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [credentialsOpen, setCredentialsOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + + const connectionMode = normalizeConnectionMode(draft.connection_mode); + const streamingMode = normalizeStreamingMode(draft.streaming); + const replyToMode = normalizeReplyToMode(draft.reply_to_mode); + const dmPolicy = draft.dm_policy || "open"; + const hasCredentials = + draft.app_id.trim().length > 0 && draft.app_secret.trim().length > 0; + const allowListLabel = dmPolicy === "allowlist" ? "白名单用户" : "已允许用户"; + const showAllowListEditor = dmPolicy === "pairing" || dmPolicy === "allowlist"; + const statusTone = hasCredentials + ? draft.enabled + ? "success" + : "neutral" + : "warning"; + const statusText = hasCredentials + ? draft.enabled + ? "已启用" + : "已配置" + : "待补充凭证"; + const policyOptions = [ + { + value: "pairing", + title: "配对模式", + description: "仅已配对会话可用。", + }, + { + value: "open", + title: "开放模式", + description: "所有人可直接发起对话。", + }, + { + value: "allowlist", + title: "白名单", + description: "仅名单内用户可发起。", + }, + { + value: "disabled", + title: "关闭私聊", + description: "保留配置,不接收私聊。", + }, + ] as const; + + useEffect(() => { + if (isOpen) { + setDraft(config); + setBusy(false); + setMessage(null); + setCredentialsOpen( + !(config.app_id?.trim().length > 0 && config.app_secret?.trim().length > 0), + ); + setAdvancedOpen(false); + } + }, [config, isOpen]); + + const handleProbe = async () => { + setBusy(true); + setMessage(null); + try { + const result = await feishuChannelProbe({ + accountId: draft.default_account?.trim() || undefined, + }); + setMessage({ + type: result.ok ? "success" : "error", + text: result.message || (result.ok ? "飞书连接正常" : "飞书连接失败"), + }); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusy(false); + } + }; + + const handleSave = async () => { + if (draft.enabled && (!draft.app_id.trim() || !draft.app_secret.trim())) { + setMessage({ type: "error", text: "启用飞书前请先填写应用 ID 和应用密钥" }); + return; + } + + setBusy(true); + setMessage(null); + try { + const nextAllowFrom = + draft.dm_policy === "open" && (draft.allow_from ?? []).length === 0 + ? ["*"] + : draft.allow_from ?? []; + await onSave( + { + ...draft, + app_id: draft.app_id.trim(), + app_secret: draft.app_secret.trim(), + verification_token: draft.verification_token?.trim() || undefined, + encrypt_key: draft.encrypt_key?.trim() || undefined, + connection_mode: connectionMode, + webhook_host: draft.webhook_host?.trim() || undefined, + webhook_port: + typeof draft.webhook_port === "number" && draft.webhook_port > 0 + ? draft.webhook_port + : undefined, + webhook_path: draft.webhook_path?.trim() || undefined, + allow_from: nextAllowFrom, + }, + "飞书配置已保存", + ); + onClose(); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusy(false); + } + }; + + return ( + + 配置飞书 + +
+
+
+
+
配置状态
+ {statusText} +
+

+ {hasCredentials + ? "凭证已就绪,可直接联调。" + : "先填应用 ID 和应用密钥。"} +

+
+ +
+
+ + +
+
+
+
+
当前机器人
+

默认只看摘要。

+
+ +
+
+
+
+
应用 ID
+
+ {maskMiddle(draft.app_id, 5)} +
+
+
+
应用密钥
+
+ {maskMiddle(draft.app_secret, 6)} +
+
+
+
默认模型
+
+ {draft.default_model || "跟随全局默认"} +
+
+
+
+
+ +
+ setDraft((current) => ({ ...current, app_id: value }))} + placeholder="cli_xxxx" + mono + /> + + setDraft((current) => ({ ...current, app_secret: value })) + } + placeholder="飞书应用密钥" + /> +
+ + setDraft((current) => ({ ...current, default_model: value })) + } + /> +
+
+
+ +
+
+
准入策略
+

选择私聊如何进入。

+
+
+ {policyOptions.map((option) => { + const selected = dmPolicy === option.value; + return ( + + ); + })} +
+ {showAllowListEditor ? ( + item.trim() !== "*")} + onChange={(values) => + setDraft((current) => ({ ...current, allow_from: values })) + } + placeholder="输入 open_id / user_id" + /> + ) : ( +
+ {dmPolicy === "open" + ? "开放模式下不需要名单。" + : "关闭后不再接收飞书私聊。"} +
+ )} +
+ + +
+ + + + +
+ + setDraft((current) => ({ + ...current, + verification_token: value || undefined, + })) + } + placeholder="事件订阅验证 Token" + /> + + setDraft((current) => ({ + ...current, + encrypt_key: value || undefined, + })) + } + placeholder="事件加密密钥" + /> +
+
+ + + {connectionMode === "websocket" ? ( +

+ 当前仍建议优先用 Webhook 回调。 +

+ ) : null} +
+ {connectionMode === "webhook" ? ( +
+ + setDraft((current) => ({ + ...current, + webhook_host: value || undefined, + })) + } + placeholder="127.0.0.1" + /> + + setDraft((current) => ({ + ...current, + webhook_port: + Number.isFinite(Number(value)) && Number(value) > 0 + ? Number(value) + : undefined, + })) + } + placeholder="3000" + type="number" + /> + + setDraft((current) => ({ + ...current, + webhook_path: value || undefined, + })) + } + placeholder="/feishu/default" + mono + /> +
+ ) : null} +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ + 取消 + void handleSave()} disabled={busy}> + 保存 + + +
+ ); +} + +interface WechatConfigDialogProps { + isOpen: boolean; + config: WechatBotConfig; + onClose: () => void; + onSave: (config: WechatBotConfig, successText: string) => Promise; + onOpenAdvanced?: () => void; +} + +function WechatConfigDialog({ + isOpen, + config, + onClose, + onSave, + onOpenAdvanced, +}: WechatConfigDialogProps) { + const [draft, setDraft] = useState(config); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [qrValue, setQrValue] = useState(""); + const [runtimeAccounts, setRuntimeAccounts] = useState([]); + const [loginResult, setLoginResult] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [accountsOpen, setAccountsOpen] = useState(false); + const [accountEditorOpen, setAccountEditorOpen] = useState(false); + const [activeAccountId, setActiveAccountId] = useState(""); + const scanRequestIdRef = useRef(0); + const accountIds = useMemo( + () => Object.keys(draft.accounts ?? {}), + [draft.accounts], + ); + const activeAccount = activeAccountId ? draft.accounts?.[activeAccountId] : undefined; + const groupPolicy = normalizeWechatGroupPolicy(draft.group_policy); + const streamingMode = normalizeStreamingMode(draft.streaming); + const replyToMode = normalizeReplyToMode(draft.reply_to_mode); + + useEffect(() => { + if (!isOpen) { + scanRequestIdRef.current += 1; + return; + } + + setDraft(config); + setBusy(false); + setMessage(null); + setQrValue(""); + setLoginResult(null); + setAdvancedOpen(false); + setAccountsOpen(false); + setAccountEditorOpen(false); + setActiveAccountId( + config.default_account?.trim() || Object.keys(config.accounts ?? {})[0] || "", + ); + + const requestId = scanRequestIdRef.current + 1; + scanRequestIdRef.current = requestId; + + void wechatChannelListAccounts() + .then((accounts) => { + if (scanRequestIdRef.current === requestId) { + setRuntimeAccounts(accounts); + } + }) + .catch(() => { + if (scanRequestIdRef.current === requestId) { + setRuntimeAccounts([]); + } + }); + + return () => { + scanRequestIdRef.current += 1; + }; + }, [config, isOpen]); + + useEffect(() => { + if (!isOpen) { + return; + } + + const preferredAccountId = draft.default_account?.trim() || accountIds[0] || ""; + if (!preferredAccountId) { + if (activeAccountId) { + setActiveAccountId(""); + } + if (accountEditorOpen) { + setAccountEditorOpen(false); + } + return; + } + + if (!activeAccountId || !accountIds.includes(activeAccountId)) { + setActiveAccountId(preferredAccountId); + } + }, [accountEditorOpen, accountIds, activeAccountId, draft.default_account, isOpen]); + + const patchAccount = useCallback( + ( + accountId: string, + updater: ( + current: NonNullable[string], + ) => NonNullable[string], + ) => { + setDraft((current) => { + const baseAccounts = current.accounts ?? {}; + const currentAccountDraft = baseAccounts[accountId] ?? { enabled: true }; + return { + ...current, + accounts: { + ...baseAccounts, + [accountId]: updater(currentAccountDraft), + }, + }; + }); + }, + [], + ); + + const addAccountDraft = () => { + const baseAccounts = draft.accounts ?? {}; + let index = 1; + let nextAccountId = "wechat_1"; + while (baseAccounts[nextAccountId]) { + index += 1; + nextAccountId = `wechat_${index}`; + } + + setDraft((current) => ({ + ...current, + default_account: current.default_account || nextAccountId, + accounts: { + ...(current.accounts ?? {}), + [nextAccountId]: { + enabled: true, + name: `微信账号 ${index}`, + }, + }, + })); + setAccountsOpen(true); + setAccountEditorOpen(true); + setActiveAccountId(nextAccountId); + }; + + const removeAccountDraft = (accountId: string) => { + const baseAccounts = { ...(draft.accounts ?? {}) }; + delete baseAccounts[accountId]; + const nextAccountIds = Object.keys(baseAccounts); + const nextDefaultAccount = + draft.default_account === accountId + ? nextAccountIds[0] || undefined + : draft.default_account; + + setDraft((current) => ({ + ...current, + default_account: nextDefaultAccount, + accounts: baseAccounts, + })); + if (nextAccountIds.length === 0) { + setAccountEditorOpen(false); + } + setActiveAccountId(nextAccountIds[0] || ""); + }; + + const handleOpenAccountEditor = (accountId: string) => { + setActiveAccountId(accountId); + setAccountEditorOpen(true); + }; + + const handleStartScan = async (baseUrlOverride?: string) => { + const requestId = scanRequestIdRef.current + 1; + scanRequestIdRef.current = requestId; + const baseUrl = + (baseUrlOverride ?? draft.base_url).trim() || DEFAULT_WECHAT_BASE_URL; + + setBusy(true); + setMessage(null); + setQrValue(""); + setLoginResult(null); + + try { + const result = await wechatChannelLoginStart({ + baseUrl, + botType: DEFAULT_WECHAT_BOT_TYPE, + }); + if (scanRequestIdRef.current !== requestId) { + return; + } + setQrValue(result.qrcodeUrl); + setMessage({ type: "success", text: "二维码已生成,扫码后会自动等待登录结果" }); + + const waitResult = await wechatChannelLoginWait({ + sessionKey: result.sessionKey, + baseUrl, + botType: DEFAULT_WECHAT_BOT_TYPE, + timeoutMs: 60_000, + }); + if (scanRequestIdRef.current !== requestId) { + return; + } + + setLoginResult(waitResult); + if (waitResult.connected) { + const nextAccountId = waitResult.accountId?.trim() || "default"; + setDraft((current) => ({ + ...current, + enabled: true, + bot_token: waitResult.botToken?.trim() || current.bot_token, + base_url: waitResult.baseUrl?.trim() || current.base_url || DEFAULT_WECHAT_BASE_URL, + cdn_base_url: current.cdn_base_url || DEFAULT_WECHAT_CDN_BASE_URL, + scanner_user_id: waitResult.userId?.trim() || current.scanner_user_id, + default_account: nextAccountId, + accounts: { + ...(current.accounts ?? {}), + [nextAccountId]: { + ...(current.accounts?.[nextAccountId] ?? {}), + enabled: true, + bot_token: waitResult.botToken?.trim() || current.accounts?.[nextAccountId]?.bot_token, + scanner_user_id: + waitResult.userId?.trim() || + current.accounts?.[nextAccountId]?.scanner_user_id, + base_url: + waitResult.baseUrl?.trim() || + current.accounts?.[nextAccountId]?.base_url || + current.base_url || + DEFAULT_WECHAT_BASE_URL, + cdn_base_url: + current.accounts?.[nextAccountId]?.cdn_base_url || + current.cdn_base_url || + DEFAULT_WECHAT_CDN_BASE_URL, + }, + }, + })); + setMessage({ type: "success", text: "微信已扫码成功,可以直接保存并启用" }); + const accounts = await wechatChannelListAccounts(); + if (scanRequestIdRef.current === requestId) { + setRuntimeAccounts(accounts); + } + } else { + setMessage({ + type: "error", + text: waitResult.message || "未检测到扫码成功,请重新生成二维码", + }); + } + } catch (error) { + if (scanRequestIdRef.current !== requestId) { + return; + } + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + if (scanRequestIdRef.current === requestId) { + setBusy(false); + } + } + }; + + useEffect(() => { + if (!isOpen) { + return; + } + void handleStartScan(config.base_url); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.base_url, isOpen]); + + const handleProbe = async () => { + const accountId = + draft.default_account?.trim() || + loginResult?.accountId?.trim() || + runtimeAccounts[0]?.accountId; + if (!accountId) { + setMessage({ type: "error", text: "请先扫码或选择默认账号" }); + return; + } + + setBusy(true); + setMessage(null); + try { + const result = await wechatChannelProbe({ accountId }); + setMessage({ + type: result.ok ? "success" : "error", + text: result.message || (result.ok ? "微信连接正常" : "微信连接失败"), + }); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusy(false); + } + }; + + const handleSave = async () => { + const nextAccounts = Object.entries(draft.accounts ?? {}).reduce< + NonNullable + >((result, [accountId, account]) => { + const normalizedAccountId = accountId.trim(); + if (!normalizedAccountId) { + return result; + } + + result[normalizedAccountId] = { + ...account, + enabled: account.enabled !== false, + name: account.name?.trim() || undefined, + bot_token: account.bot_token?.trim() || undefined, + scanner_user_id: account.scanner_user_id?.trim() || undefined, + base_url: account.base_url?.trim() || undefined, + cdn_base_url: account.cdn_base_url?.trim() || undefined, + }; + return result; + }, {}); + const nextConfig: WechatBotConfig = { + ...draft, + enabled: draft.enabled || Object.keys(nextAccounts).length > 0, + bot_token: draft.bot_token.trim(), + base_url: draft.base_url.trim() || DEFAULT_WECHAT_BASE_URL, + cdn_base_url: draft.cdn_base_url.trim() || DEFAULT_WECHAT_CDN_BASE_URL, + scanner_user_id: draft.scanner_user_id?.trim() || undefined, + default_account: draft.default_account?.trim() || undefined, + group_allow_from: (draft.group_allow_from ?? []) + .map((item) => item.trim()) + .filter(Boolean), + allow_from: + draft.dm_policy === "open" && (draft.allow_from ?? []).length === 0 + ? ["*"] + : (draft.allow_from ?? []).map((item) => item.trim()).filter(Boolean), + accounts: nextAccounts, + }; + + setBusy(true); + setMessage(null); + try { + await onSave(nextConfig, "微信配置已保存"); + const accountId = + nextConfig.default_account?.trim() || + loginResult?.accountId?.trim() || + runtimeAccounts[0]?.accountId; + if (accountId) { + await gatewayChannelStart({ channel: "wechat", accountId }); + } + onClose(); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setBusy(false); + } + }; + + return ( + + 微信扫码配置 + +
+
+ +
+ 微信扫码后会自动接入。 +
+ void handleStartScan()} disabled={busy}> + 重新生成二维码 + +
+ +
+ setDraft((current) => ({ ...current, enabled: checked }))} + title="启用微信" + description="扫码后自动回填,保存时尝试启动。" + /> +
+
+ 当前账号 + + {draft.default_account?.trim() || "等待扫码"} + +
+

+ 扫码后自动绑定默认账号。 +

+
+ +
+ + +
+ {runtimeAccounts.length > 0 ? ( +
+
已接入账号
+
+ {runtimeAccounts.map((account) => ( + + ))} +
+
+ ) : null} + +
+ + + + +
+ + setDraft((current) => ({ + ...current, + scanner_user_id: value || undefined, + })) + } + placeholder="扫码绑定后的 user_id" + mono + /> + + setDraft((current) => ({ ...current, default_account: value })) + } + placeholder="默认账号 ID" + /> + + setDraft((current) => ({ ...current, default_model: value })) + } + /> +
+ + +
+
+
+ + setDraft((current) => ({ ...current, base_url: value })) + } + placeholder={DEFAULT_WECHAT_BASE_URL} + mono + /> + + setDraft((current) => ({ ...current, cdn_base_url: value })) + } + placeholder={DEFAULT_WECHAT_CDN_BASE_URL} + mono + /> +
+
+
+ + +
+
+ + +
+
+ + setDraft((current) => ({ ...current, allow_from: values })) + } + placeholder="输入用户 ID 或 *" + /> + + setDraft((current) => ({ ...current, group_allow_from: values })) + } + placeholder="输入用户 ID 或 *" + /> + +
+
+
+
+
账号参数
+ 0 ? "success" : "neutral"} + > + {accountIds.length > 0 + ? `${accountIds.length} 个账号` + : "暂无账号"} + + {draft.default_account?.trim() ? ( + + {`默认 ${draft.default_account.trim()}`} + + ) : null} +
+

+ 扫码账号会自动进来,按需展开编辑。 +

+
+
+ 新增账号草稿 + +
+
+ + {accountIds.length > 0 ? ( + <> +
+ {accountIds.map((accountId) => { + const account = draft.accounts?.[accountId]; + if (!account) { + return null; + } + + const selected = activeAccountId === accountId; + return ( + + ); + })} +
+ {activeAccount ? ( + +
+
+
+
+
+ 编辑账号详情 +
+ + {activeAccount.name?.trim() || activeAccountId} + +
+

+ 按需补模型、地址或扫码用户 ID。 +

+
+ +
+ +
+
+
+ 当前账号 +
+ + {draft.default_account === activeAccountId + ? "默认账号" + : "账号草稿"} + +
+
+ + setDraft((current) => ({ + ...current, + default_account: activeAccountId, + })) + } + > + 设为默认 + + removeAccountDraft(activeAccountId)} + > + 删除 + +
+
+ + patchAccount(activeAccountId, (current) => ({ + ...current, + enabled: checked, + })) + } + title="启用该账号" + description="关闭后保留参数,但不参与运行。" + /> +
+ + patchAccount(activeAccountId, (current) => ({ + ...current, + name: value || undefined, + })) + } + placeholder="主微信 / 运营号" + /> + + patchAccount(activeAccountId, (current) => ({ + ...current, + default_model: value, + })) + } + /> +
+
+ + patchAccount(activeAccountId, (current) => ({ + ...current, + base_url: value || undefined, + })) + } + placeholder={DEFAULT_WECHAT_BASE_URL} + mono + /> + + patchAccount(activeAccountId, (current) => ({ + ...current, + cdn_base_url: value || undefined, + })) + } + placeholder={DEFAULT_WECHAT_CDN_BASE_URL} + mono + /> +
+ + patchAccount(activeAccountId, (current) => ({ + ...current, + bot_token: value || undefined, + })) + } + placeholder="扫码成功后回填的 token" + /> + + patchAccount(activeAccountId, (current) => ({ + ...current, + scanner_user_id: value || undefined, + })) + } + placeholder="扫码后的微信 user_id" + mono + /> +
+
+
+ ) : null} + + ) : ( +
+ 还没有账号。可先扫码或手工新增。 +
+ )} +
+
+
+
+
+
+
+
+
+ + 取消 + void handleSave()} disabled={busy}> + 保存并启用 + + +
+ ); +} + +export function ImConfigPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(null); + const [activeDialog, setActiveDialog] = useState(null); + const [debugToolsOpen, setDebugToolsOpen] = useState(false); + const [debugChannel, setDebugChannel] = useState("telegram"); + const advancedToolsRef = useRef(null); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const nextConfig = await getConfig(); + setConfig(nextConfig); + } catch (error) { + setMessage({ + type: "error", + text: error instanceof Error ? error.message : String(error), + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + + useEffect(() => { + if (!message) { + return; + } + + const timeoutId = window.setTimeout(() => setMessage(null), 2500); + return () => window.clearTimeout(timeoutId); + }, [message]); + + const channels = useMemo( + () => normalizeChannelsConfig(config?.channels), + [config?.channels], + ); + const enabledChannelCount = [ + channels.telegram.enabled, + channels.feishu.enabled, + channels.wechat.enabled, + ].filter(Boolean).length; + const wechatAccountCount = Object.keys(channels.wechat.accounts ?? {}).length; + + const persistChannel = async ( + channel: K, + nextChannelConfig: ChannelsConfig[K], + successText: string, + ) => { + const nextChannels = { + ...channels, + [channel]: nextChannelConfig, + } as ChannelsConfig; + const nextConfig = buildNextConfig(config, nextChannels); + await saveConfig(nextConfig); + setConfig(nextConfig); + setMessage({ type: "success", text: successText }); + }; + + const revealAdvancedTools = (channel: ManagedChannel) => { + setActiveDialog(null); + setDebugChannel(channel); + setDebugToolsOpen(true); + window.setTimeout(() => { + const section = advancedToolsRef.current; + if (section && typeof section.scrollIntoView === "function") { + section.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, 0); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + return ( + <> +
+
+
+
+ + + 能力 / IM 配置 + +
+

+ 把常用 IM 接到 Lime +

+

+ 首页只放重点入口。Telegram、飞书、微信在这里直达;联调检查放进各自配置弹窗,网关和日志统一收到下方高级区。 +

+
+
+
+ + 3 个主入口 + + + 已启用 {enabledChannelCount} 个 + + + 微信 {wechatAccountCount} 个账号 + + + 排障在下方高级区 + +
+
+
+ + + +
+ } + title="Telegram" + subtitle="Token 接入" + status={channels.telegram.enabled ? "已启用" : "未启用"} + statusTone={channels.telegram.enabled ? "success" : "neutral"} + details={[ + { + label: "机器人 Token", + value: channels.telegram.bot_token.trim() ? "已填写" : "未填写", + }, + { + label: "接入范围", + value: describeTelegramScope(channels.telegram), + }, + ]} + actions={ + <> + setActiveDialog("telegram")} + ariaLabel="打开 Telegram 配置" + dataTestId="telegram-config-button" + > + 配置 + + + } + /> + + } + title="飞书" + subtitle="应用凭证与准入" + status={channels.feishu.enabled ? "已启用" : "未启用"} + statusTone={channels.feishu.enabled ? "success" : "neutral"} + details={[ + { + label: "应用 ID", + value: maskMiddle(channels.feishu.app_id, 5), + }, + { + label: "准入策略", + value: describeDmPolicy(channels.feishu.dm_policy), + }, + ]} + actions={ + <> + setActiveDialog("feishu")} + ariaLabel="打开飞书配置" + dataTestId="feishu-config-button" + > + 配置 + + + } + /> + + } + title="微信" + subtitle="扫码接入与账号" + status={describeWechatStatus(channels.wechat)} + statusTone={ + channels.wechat.enabled + ? Object.keys(channels.wechat.accounts ?? {}).length > 0 + ? "success" + : "warning" + : "neutral" + } + details={[ + { + label: "默认账号", + value: channels.wechat.default_account || "未设置", + }, + { + label: "私聊策略", + value: describeDmPolicy(channels.wechat.dm_policy), + }, + ]} + actions={ + <> + setActiveDialog("wechat")} + ariaLabel="打开微信扫码配置" + dataTestId="wechat-config-button" + > + 配置 + + + } + /> +
+ +
+
+
+
即将开放
+

+ 这些渠道先保留同级占位,灰态展示,不开放点击。 +

+
+
+
+ + Discord + + 即将开放 + +
+
+ + 钉钉 + + 即将开放 + +
+
+
+
+ + +
+ + + + + + 正在加载排障工具... +
+ } + > + { + void loadConfig(); + }} + /> + + + + +
+ + setActiveDialog(null)} + onSave={(nextConfig, successText) => + persistChannel("telegram", nextConfig, successText) + } + onOpenAdvanced={() => revealAdvancedTools("telegram")} + /> + + setActiveDialog(null)} + onSave={(nextConfig, successText) => + persistChannel("feishu", nextConfig, successText) + } + onOpenAdvanced={() => revealAdvancedTools("feishu")} + /> + + setActiveDialog(null)} + onSave={(nextConfig, successText) => + persistChannel("wechat", nextConfig, successText) + } + onOpenAdvanced={() => revealAdvancedTools("wechat")} + /> + + ); +} + +export default ImConfigPage; diff --git a/src/components/content-creator/a2ui/components/A2UIRenderer.test.tsx b/src/components/content-creator/a2ui/components/A2UIRenderer.test.tsx index 81f2fad11..7797fb620 100644 --- a/src/components/content-creator/a2ui/components/A2UIRenderer.test.tsx +++ b/src/components/content-creator/a2ui/components/A2UIRenderer.test.tsx @@ -63,7 +63,7 @@ describe("A2UIRenderer", () => { expect(root?.className).toContain("space-y-4"); expect(container.textContent).toContain("这是推理提示"); const submitButton = clickButtonByText(container, "开始处理"); - expect(submitButton?.className).toContain("rounded-xl"); + expect(submitButton?.className).toBe(A2UI_RENDERER_TOKENS.submitButton); expect(submitButton?.disabled).toBe(true); expect(submitSpy).not.toHaveBeenCalled(); }); diff --git a/src/components/projects/ProjectCategories.tsx b/src/components/projects/ProjectCategories.tsx index 4ff2044fd..a058ca651 100644 --- a/src/components/projects/ProjectCategories.tsx +++ b/src/components/projects/ProjectCategories.tsx @@ -8,6 +8,10 @@ import { useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { TYPE_CONFIGS, type UserType } from "@/lib/api/project"; import { getConfig } from "@/lib/api/appConfig"; +import { + DEFAULT_ENABLED_CONTENT_THEME_IDS, + resolveEnabledContentThemes, +} from "@/lib/contentCreator/themeDefaults"; export type ProjectFilter = "all" | UserType | "favorites" | "archived"; @@ -17,16 +21,6 @@ interface ProjectCategoriesProps { counts?: Record; } -/** 默认启用的主题 */ -const DEFAULT_ENABLED_THEMES = [ - "general", - "social-media", - "poster", - "music", - "video", - "novel", -]; - const allFilterItems: { id: ProjectFilter; label: string; icon?: string }[] = [ { id: "all", label: "全部" }, { @@ -85,7 +79,7 @@ export function ProjectCategories({ }: ProjectCategoriesProps) { // 从配置中读取启用的主题 const [enabledThemes, setEnabledThemes] = useState( - DEFAULT_ENABLED_THEMES, + DEFAULT_ENABLED_CONTENT_THEME_IDS, ); // 加载配置 @@ -93,9 +87,9 @@ export function ProjectCategories({ const loadEnabledThemes = async () => { try { const config = await getConfig(); - if (config.content_creator?.enabled_themes) { - setEnabledThemes(config.content_creator.enabled_themes); - } + setEnabledThemes( + resolveEnabledContentThemes(config.content_creator?.enabled_themes), + ); } catch (e) { console.error("加载主题配置失败:", e); } diff --git a/src/components/api-server/EnhancedModelsTab.tsx b/src/components/provider-pool/EnhancedModelsTab.tsx similarity index 100% rename from src/components/api-server/EnhancedModelsTab.tsx rename to src/components/provider-pool/EnhancedModelsTab.tsx diff --git a/src/components/provider-pool/ModelRegistryTab.tsx b/src/components/provider-pool/ModelRegistryTab.tsx index 97df8952b..0ac1a5618 100644 --- a/src/components/provider-pool/ModelRegistryTab.tsx +++ b/src/components/provider-pool/ModelRegistryTab.tsx @@ -4,12 +4,12 @@ * @module components/provider-pool/ModelRegistryTab */ -import { EnhancedModelsTab } from "@/components/api-server/EnhancedModelsTab"; +import { EnhancedModelsTab } from "./EnhancedModelsTab"; /** * 模型库 Tab 组件 * - * 复用 API Server 的 EnhancedModelsTab 组件 + * 复用 Provider Pool 的增强模型列表组件 */ export function ModelRegistryTab() { return ( diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index 6861b08f3..670a24036 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -355,8 +355,8 @@ export const ProviderPoolPage = forwardRef<

凭证池

- 管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 - Provider 后自动使用对应凭证 + 管理多个 AI 服务凭证,自动轮询负载均衡,并为本机默认 Provider + 提供稳定的凭证来源

diff --git a/src/components/settings-v2/_layout/index.test.tsx b/src/components/settings-v2/_layout/index.test.tsx index dd2254519..beceb66dc 100644 --- a/src/components/settings-v2/_layout/index.test.tsx +++ b/src/components/settings-v2/_layout/index.test.tsx @@ -65,9 +65,6 @@ vi.mock("../account/user-center-session", () => ({ vi.mock("../agent/providers", () => ({ CloudProviderSettings: () =>
providers
, })); -vi.mock("@/components/api-server/ApiServerPage", () => ({ - ApiServerPage: () =>
api-server
, -})); vi.mock("@/components/mcp", () => ({ McpPanel: () =>
mcp
, })); @@ -102,13 +99,21 @@ interface Mounted { const mounted: Mounted[] = []; -function renderComponent(initialTab: SettingsTabs) { +function renderComponent( + initialTab: SettingsTabs, + onNavigate?: (page: string) => void, +) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); act(() => { - root.render(); + root.render( + , + ); }); mounted.push({ container, root }); @@ -164,3 +169,17 @@ describe("SettingsLayoutV2 Profile Tab", () => { expect(text).toContain("PROFILE_SETTINGS"); }); }); + +describe("SettingsLayoutV2 Channels Redirect", () => { + it("旧的设置渠道入口应跳转到能力里的 IM 配置", async () => { + const onNavigate = vi.fn(); + + renderComponent(SettingsTabs.Channels, onNavigate); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onNavigate).toHaveBeenCalledWith("channels"); + }); +}); diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 70eec0022..e9ccf0cae 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -39,9 +39,7 @@ import { StatsSettings } from "../account/stats"; import { ProfileSettings } from "../account/profile"; import { UserCenterSessionSettings } from "../account/user-center-session"; import { CloudProviderSettings } from "../agent/providers"; -import { ApiServerPage } from "@/components/api-server/ApiServerPage"; import { McpPanel } from "@/components/mcp"; -import { ChannelsSettings } from "../system/channels"; import { EnvironmentSettings } from "../system/environment"; import { WebSearchSettings } from "../system/web-search"; import { ChromeRelaySettings } from "../system/chrome-relay"; @@ -122,6 +120,22 @@ const PlaceholderPage = styled.div` } `; +function SettingsChannelsRedirect({ + onNavigate, +}: { + onNavigate?: (page: Page, params?: PageParams) => void; +}) { + useEffect(() => { + onNavigate?.("channels"); + }, [onNavigate]); + + return ( + +

渠道管理已迁移到「能力 {"->"} IM 配置」

+
+ ); +} + /** * 渲染设置内容 */ @@ -205,14 +219,6 @@ function renderSettingsContent( ); // 系统组 - case SettingsTabs.ApiServer: - return ( - <> - - - - ); - case SettingsTabs.McpServer: return ( <> @@ -222,12 +228,7 @@ function renderSettingsContent( ); case SettingsTabs.Channels: - return ( - <> - - - - ); + return ; case SettingsTabs.WebSearch: return ( @@ -311,10 +312,8 @@ const WIDE_CONTENT_TABS = new Set([ SettingsTabs.Providers, SettingsTabs.Skills, SettingsTabs.MediaServices, - SettingsTabs.ApiServer, SettingsTabs.Automation, SettingsTabs.McpServer, - SettingsTabs.Channels, SettingsTabs.SecurityPerformance, SettingsTabs.Developer, SettingsTabs.WebSearch, diff --git a/src/components/settings-v2/general/appearance/index.test.tsx b/src/components/settings-v2/general/appearance/index.test.tsx index a89b740a5..4a0ffdd8f 100644 --- a/src/components/settings-v2/general/appearance/index.test.tsx +++ b/src/components/settings-v2/general/appearance/index.test.tsx @@ -145,7 +145,7 @@ describe("AppearanceSettings", () => { expect(text).toContain("左侧边栏导航"); expect(text).toContain("工作区入口"); expect(text).toContain("底部入口"); - expect(text).toContain("核心入口固定显示:技能"); + expect(text).toContain("核心入口固定显示:能力"); expect(text).toContain("OpenClaw"); expect(text).toContain("资源"); expect(text).toContain("我的风格"); @@ -178,7 +178,7 @@ describe("AppearanceSettings", () => { ).toBe("openai"); }); - it("缺少主题配置时应回退到新的默认创作主题", async () => { + it("缺少主题配置时应允许从默认隐藏状态单独开启主题", async () => { mockGetConfig.mockResolvedValue({ language: "zh", chat_appearance: { @@ -198,10 +198,33 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.content_creator.enabled_themes).toEqual([ - "social-media", - "video", - ]); + expect(savedConfig.content_creator.enabled_themes).toEqual(["video"]); + }); + + it("关闭最后一个创作模式时应允许保存为空列表", async () => { + mockGetConfig.mockResolvedValue({ + language: "zh", + content_creator: { + enabled_themes: ["social-media"], + }, + chat_appearance: { + append_selected_text_to_recommendation: true, + }, + }); + + const { container } = await renderPage(); + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent?.includes("社媒内容"), + ); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; + + expect(savedConfig.content_creator.enabled_themes).toEqual([]); }); it("切换底部入口时应保存完整的侧栏导航配置", async () => { @@ -251,7 +274,6 @@ describe("AppearanceSettings", () => { "claw", "video", "image-gen", - "automation", "openclaw", "resources", "style-library", diff --git a/src/components/settings-v2/general/appearance/index.tsx b/src/components/settings-v2/general/appearance/index.tsx index 6b9d12750..ed859952f 100644 --- a/src/components/settings-v2/general/appearance/index.tsx +++ b/src/components/settings-v2/general/appearance/index.tsx @@ -342,10 +342,6 @@ export function AppearanceSettings() { ? enabledThemes.filter((item) => item !== themeId) : [...enabledThemes, themeId]; - if (nextThemes.length === 0) { - return; - } - const previousConfig = config; const nextConfig = { ...config, diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts index 8da0e1272..75f97440f 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.ts +++ b/src/components/settings-v2/hooks/useSettingsCategory.ts @@ -17,9 +17,7 @@ import { Bot, Blocks, Image, - Server, Plug, - Route, Search, Variable, Monitor, @@ -143,21 +141,11 @@ export function useSettingsCategory(): CategoryGroup[] { key: SettingsGroupKey.System, title: t("settings.group.system", "系统"), items: [ - { - key: SettingsTabs.ApiServer, - label: t("settings.tab.apiServer", "团队共享网关"), - icon: Server, - }, { key: SettingsTabs.McpServer, label: t("settings.tab.mcpServer", "MCP 服务器"), icon: Plug, }, - { - key: SettingsTabs.Channels, - label: t("settings.tab.channels", "渠道管理"), - icon: Route, - }, { key: SettingsTabs.WebSearch, label: t("settings.tab.webSearch", "网络搜索"), diff --git a/src/components/settings-v2/system/about/index.tsx b/src/components/settings-v2/system/about/index.tsx index de8572e58..2dff1a5f2 100644 --- a/src/components/settings-v2/system/about/index.tsx +++ b/src/components/settings-v2/system/about/index.tsx @@ -51,7 +51,7 @@ const TARGET_USERS = [ ] as const; const PRODUCT_CAPABILITIES = [ - "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。", + "Lime 以本机 AI Agent 工作台为核心,默认围绕主题工作台、资源沉淀与创作链路组织能力。", "常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。", ] as const; diff --git a/src/components/settings-v2/system/channels/LegacyChannelsWorkbench.test.tsx b/src/components/settings-v2/system/channels/LegacyChannelsWorkbench.test.tsx new file mode 100644 index 000000000..4513fb3f0 --- /dev/null +++ b/src/components/settings-v2/system/channels/LegacyChannelsWorkbench.test.tsx @@ -0,0 +1,277 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockGetConfig, + mockSaveConfig, + mockUseConfiguredProviders, + mockDiscordChannelProbe, + mockFeishuChannelProbe, + mockGatewayChannelStart, + mockGatewayChannelStatus, + mockGatewayChannelStop, + mockGatewayTunnelCreate, + mockGatewayTunnelDetectCloudflared, + mockGatewayTunnelInstallCloudflared, + mockGatewayTunnelProbe, + mockGatewayTunnelRestart, + mockGatewayTunnelStart, + mockGatewayTunnelStatus, + mockGatewayTunnelStop, + mockGatewayTunnelSyncWebhookUrl, + mockTelegramChannelProbe, + mockWechatChannelListAccounts, + mockWechatChannelLoginStart, + mockWechatChannelLoginWait, + mockWechatChannelProbe, + mockWechatChannelRemoveAccount, + mockQrCodeToDataUrl, +} = vi.hoisted(() => ({ + mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), + mockUseConfiguredProviders: vi.fn(), + mockDiscordChannelProbe: vi.fn(), + mockFeishuChannelProbe: vi.fn(), + mockGatewayChannelStart: vi.fn(), + mockGatewayChannelStatus: vi.fn(), + mockGatewayChannelStop: vi.fn(), + mockGatewayTunnelCreate: vi.fn(), + mockGatewayTunnelDetectCloudflared: vi.fn(), + mockGatewayTunnelInstallCloudflared: vi.fn(), + mockGatewayTunnelProbe: vi.fn(), + mockGatewayTunnelRestart: vi.fn(), + mockGatewayTunnelStart: vi.fn(), + mockGatewayTunnelStatus: vi.fn(), + mockGatewayTunnelStop: vi.fn(), + mockGatewayTunnelSyncWebhookUrl: vi.fn(), + mockTelegramChannelProbe: vi.fn(), + mockWechatChannelListAccounts: vi.fn(), + mockWechatChannelLoginStart: vi.fn(), + mockWechatChannelLoginWait: vi.fn(), + mockWechatChannelProbe: vi.fn(), + mockWechatChannelRemoveAccount: vi.fn(), + mockQrCodeToDataUrl: vi.fn(), +})); + +vi.mock("@/lib/api/appConfig", () => ({ + getConfig: mockGetConfig, + saveConfig: mockSaveConfig, +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + useConfiguredProviders: mockUseConfiguredProviders, +})); + +vi.mock("@/lib/api/channelsRuntime", () => ({ + discordChannelProbe: mockDiscordChannelProbe, + feishuChannelProbe: mockFeishuChannelProbe, + gatewayChannelStart: mockGatewayChannelStart, + gatewayChannelStatus: mockGatewayChannelStatus, + gatewayChannelStop: mockGatewayChannelStop, + gatewayTunnelCreate: mockGatewayTunnelCreate, + gatewayTunnelDetectCloudflared: mockGatewayTunnelDetectCloudflared, + gatewayTunnelInstallCloudflared: mockGatewayTunnelInstallCloudflared, + gatewayTunnelProbe: mockGatewayTunnelProbe, + gatewayTunnelRestart: mockGatewayTunnelRestart, + gatewayTunnelStart: mockGatewayTunnelStart, + gatewayTunnelStatus: mockGatewayTunnelStatus, + gatewayTunnelStop: mockGatewayTunnelStop, + gatewayTunnelSyncWebhookUrl: mockGatewayTunnelSyncWebhookUrl, + telegramChannelProbe: mockTelegramChannelProbe, + wechatChannelListAccounts: mockWechatChannelListAccounts, + wechatChannelLoginStart: mockWechatChannelLoginStart, + wechatChannelLoginWait: mockWechatChannelLoginWait, + wechatChannelProbe: mockWechatChannelProbe, + wechatChannelRemoveAccount: mockWechatChannelRemoveAccount, +})); + +vi.mock("./ChannelLogTailPanel", () => ({ + ChannelLogTailPanel: () =>
, +})); + +vi.mock("qrcode", () => ({ + default: { + toDataURL: mockQrCodeToDataUrl, + }, +})); + +import { ChannelsDebugWorkbench } from "./LegacyChannelsWorkbench"; + +interface MountedPage { + container: HTMLDivElement; + root: Root; +} + +const mountedPages: MountedPage[] = []; + +function createConfig() { + return { + channels: { + telegram: { + enabled: true, + bot_token: "123456:telegram-token", + allowed_user_ids: ["10001"], + default_model: "openai/gpt-4.1", + }, + discord: { + enabled: false, + bot_token: "", + allowed_server_ids: [], + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "partial", + reply_to_mode: "off", + }, + feishu: { + enabled: true, + app_id: "cli_test_demo", + app_secret: "secret", + default_model: "openai/gpt-4.1", + dm_policy: "pairing", + allow_from: ["ou_test"], + group_policy: "allowlist", + group_allow_from: [], + }, + wechat: { + enabled: true, + bot_token: "", + base_url: "https://ilinkai.weixin.qq.com", + cdn_base_url: "https://novac2c.cdn.weixin.qq.com/c2c", + default_model: "openai/gpt-4.1", + default_account: "wechat_1", + accounts: { + wechat_1: { + enabled: true, + name: "微信一号", + base_url: "https://ilinkai.weixin.qq.com", + }, + }, + dm_policy: "pairing", + allow_from: ["wx_user_1"], + group_policy: "allowlist", + group_allow_from: [], + }, + }, + gateway: { + tunnel: { + enabled: false, + provider: "cloudflare", + mode: "managed", + local_host: "127.0.0.1", + local_port: 3000, + cloudflare: {}, + }, + }, + } as any; +} + +function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedPages.push({ container, root }); + return container; +} + +async function flushEffects(times = 6) { + await act(async () => { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } + }); +} + +describe("ChannelsDebugWorkbench", () => { + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + + mockGetConfig.mockResolvedValue(createConfig()); + mockSaveConfig.mockResolvedValue(undefined); + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "openai", + label: "OpenAI", + type: "openai", + registryId: "openai", + customModels: ["gpt-4.1"], + }, + ], + loading: false, + }); + + mockDiscordChannelProbe.mockResolvedValue({ ok: true }); + mockFeishuChannelProbe.mockResolvedValue({ ok: true }); + mockGatewayChannelStart.mockResolvedValue({ ok: true }); + mockGatewayChannelStatus.mockResolvedValue({ ok: true }); + mockGatewayChannelStop.mockResolvedValue({ ok: true }); + mockGatewayTunnelCreate.mockResolvedValue({ ok: true }); + mockGatewayTunnelDetectCloudflared.mockResolvedValue({ ok: true }); + mockGatewayTunnelInstallCloudflared.mockResolvedValue({ ok: true }); + mockGatewayTunnelProbe.mockResolvedValue({ ok: true }); + mockGatewayTunnelRestart.mockResolvedValue({ ok: true }); + mockGatewayTunnelStart.mockResolvedValue({ ok: true }); + mockGatewayTunnelStatus.mockResolvedValue({ ok: true }); + mockGatewayTunnelStop.mockResolvedValue({ ok: true }); + mockGatewayTunnelSyncWebhookUrl.mockResolvedValue({ ok: true }); + mockTelegramChannelProbe.mockResolvedValue({ ok: true }); + mockWechatChannelListAccounts.mockResolvedValue([]); + mockWechatChannelLoginStart.mockResolvedValue({ + sessionKey: "wechat-session", + qrcodeUrl: "wechat-login-qr", + message: "ok", + }); + mockWechatChannelLoginWait.mockResolvedValue({ connected: false }); + mockWechatChannelProbe.mockResolvedValue({ ok: true }); + mockWechatChannelRemoveAccount.mockResolvedValue({ ok: true }); + mockQrCodeToDataUrl.mockResolvedValue("data:image/png;base64,qr"); + }); + + afterEach(() => { + while (mountedPages.length > 0) { + const mounted = mountedPages.pop(); + if (!mounted) { + continue; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("旧的概览或配置入口应直接落到高级调试区,且不再暴露渠道配置表单", async () => { + renderPage(); + await flushEffects(10); + + const text = document.body.textContent ?? ""; + expect(text).toContain("高级排障"); + expect(text).toContain("这里只保留网关、日志和运行状态"); + expect(text).toContain("日志"); + expect(text).toContain("运行"); + expect(document.body.querySelector('[data-testid="channel-log-tail-panel"]')).not.toBeNull(); + expect(text).not.toContain("配置说明(通用)"); + expect(text).not.toContain("当前渠道:"); + expect(text).not.toContain("启用 Discord Bot"); + expect(text).not.toContain("当前配置摘要"); + expect(text).not.toContain("配置提示"); + expect(text).not.toContain("探测账号"); + expect(text).not.toContain("扫码登录"); + expect(text).not.toContain("Discord Gateway 调试工具"); + }); +}); diff --git a/src/components/settings-v2/system/channels/LegacyChannelsWorkbench.tsx b/src/components/settings-v2/system/channels/LegacyChannelsWorkbench.tsx new file mode 100644 index 000000000..17baae518 --- /dev/null +++ b/src/components/settings-v2/system/channels/LegacyChannelsWorkbench.tsx @@ -0,0 +1,4022 @@ +/** + * 渠道管理设置页面 + * + * Telegram / Discord / 飞书 / 微信 Bot 渠道的内联表单配置 + */ + +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Eye, + EyeOff, + Plus, + X, + Loader2, + Save, + RotateCcw, + AlertCircle, + LayoutDashboard, + Network, + ScrollText, + type LucideIcon, +} from "lucide-react"; +import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; +import { + gatewayChannelStart, + gatewayChannelStatus, + gatewayChannelStop, + gatewayTunnelCreate, + gatewayTunnelDetectCloudflared, + gatewayTunnelInstallCloudflared, + gatewayTunnelProbe, + gatewayTunnelRestart, + gatewayTunnelStart, + gatewayTunnelStatus, + gatewayTunnelStop, + gatewayTunnelSyncWebhookUrl, + wechatChannelListAccounts, + wechatChannelLoginStart, + wechatChannelLoginWait, + wechatChannelRemoveAccount, + type ChannelsConfig, + type DiscordBotConfig, + type FeishuBotConfig, + type GatewayConfig, + type TelegramBotConfig, + type WechatBotConfig, + type WechatConfiguredAccount, + type WechatGatewayAccountStatus, + type WechatGatewayStatus, +} from "@/lib/api/channelsRuntime"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { + filterProviderModelsByCompatibility, + getProviderModelCompatibilityIssue, +} from "@/components/agent/chat/utils/providerModelCompatibility"; +import { ChannelLogTailPanel } from "./ChannelLogTailPanel"; +import { cn } from "@/lib/utils"; +import QRCode from "qrcode"; + +// ============================================================================ +// 默认值 +// ============================================================================ + +const DEFAULT_CHANNELS: ChannelsConfig = { + telegram: { + enabled: false, + bot_token: "", + allowed_user_ids: [], + default_model: undefined, + }, + discord: { + enabled: false, + bot_token: "", + allowed_server_ids: [], + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "partial", + reply_to_mode: "off", + }, + feishu: { + enabled: false, + app_id: "", + app_secret: "", + default_model: undefined, + dm_policy: "open", + allow_from: ["*"], + group_policy: "allowlist", + group_allow_from: [], + }, + wechat: { + enabled: false, + bot_token: "", + base_url: "", + cdn_base_url: "", + default_model: undefined, + default_account: "default", + accounts: {}, + dm_policy: "pairing", + allow_from: [], + group_policy: "allowlist", + group_allow_from: [], + streaming: "off", + reply_to_mode: "off", + }, +}; + +const DEFAULT_GATEWAY: GatewayConfig = { + tunnel: { + enabled: false, + provider: "cloudflare", + mode: "managed", + local_host: "127.0.0.1", + local_port: 3000, + cloudflare: {}, + }, +}; + +const DEFAULT_WECHAT_BASE_URL = "https://ilinkai.weixin.qq.com"; +const DEFAULT_WECHAT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"; +const DEFAULT_WECHAT_BOT_TYPE = "3"; +const WECHAT_RUNTIME_POLL_INTERVAL_MS = 2000; + +function formatRuntimeTimestamp(timestamp?: string | null): string { + if (!timestamp) { + return "暂无"; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return date.toLocaleString(); +} + +type WechatLoginWaitResultLike = { + connected?: boolean | string | null; + accountId?: string | null; + account_id?: string | null; + message?: string | null; +}; + +function normalizeWechatLoginWaitResult(result: unknown): { + connected: boolean; + accountId?: string; + message: string; +} { + if (!result || typeof result !== "object") { + return { + connected: false, + message: "", + }; + } + + const payload = result as WechatLoginWaitResultLike; + const connected = payload.connected === true || payload.connected === "true"; + const accountId = + (typeof payload.accountId === "string" && payload.accountId.trim()) || + (typeof payload.account_id === "string" && payload.account_id.trim()) || + undefined; + const message = + typeof payload.message === "string" ? payload.message.trim() : ""; + + return { + connected, + accountId, + message, + }; +} + +function normalizeChannelsConfig( + value?: Partial | null, +): ChannelsConfig { + const telegram = value?.telegram; + const discord = value?.discord; + const feishu = value?.feishu; + const wechat = value?.wechat; + + return { + telegram: { + ...DEFAULT_CHANNELS.telegram, + ...telegram, + allowed_user_ids: + telegram?.allowed_user_ids ?? + DEFAULT_CHANNELS.telegram.allowed_user_ids, + }, + discord: { + ...DEFAULT_CHANNELS.discord, + ...discord, + allowed_server_ids: + discord?.allowed_server_ids ?? + DEFAULT_CHANNELS.discord.allowed_server_ids, + accounts: discord?.accounts ?? DEFAULT_CHANNELS.discord.accounts, + allow_from: discord?.allow_from ?? DEFAULT_CHANNELS.discord.allow_from, + group_allow_from: + discord?.group_allow_from ?? DEFAULT_CHANNELS.discord.group_allow_from, + }, + feishu: { + ...DEFAULT_CHANNELS.feishu, + ...feishu, + allow_from: feishu?.allow_from ?? DEFAULT_CHANNELS.feishu.allow_from, + group_allow_from: + feishu?.group_allow_from ?? DEFAULT_CHANNELS.feishu.group_allow_from, + }, + wechat: { + ...DEFAULT_CHANNELS.wechat, + ...wechat, + accounts: wechat?.accounts ?? DEFAULT_CHANNELS.wechat.accounts, + allow_from: wechat?.allow_from ?? DEFAULT_CHANNELS.wechat.allow_from, + group_allow_from: + wechat?.group_allow_from ?? DEFAULT_CHANNELS.wechat.group_allow_from, + }, + }; +} + +function normalizeGatewayConfig(value?: GatewayConfig | null): GatewayConfig { + const tunnel = value?.tunnel; + const defaultTunnel = DEFAULT_GATEWAY.tunnel ?? {}; + + return { + ...DEFAULT_GATEWAY, + ...value, + tunnel: { + ...defaultTunnel, + ...tunnel, + cloudflare: { + ...(defaultTunnel.cloudflare ?? {}), + ...(tunnel?.cloudflare ?? {}), + }, + }, + }; +} + +type ChannelSubPage = "gateway" | "logs"; +type DebugTabKey = "telegram" | "feishu" | "discord" | "wechat"; +type VisibleDebugTabKey = "telegram" | "feishu" | "wechat"; + +const INPUT_CLASS_NAME = + "w-full rounded-[16px] border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-slate-300 focus:ring-2 focus:ring-slate-200"; +const MONO_INPUT_CLASS_NAME = `${INPUT_CLASS_NAME} font-mono`; +const PANEL_CLASS_NAME = + "space-y-4 rounded-[26px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; +const SOFT_CARD_CLASS_NAME = + "rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4"; +const SECTION_TABS_CLASS_NAME = + "grid w-full max-w-2xl grid-cols-4 rounded-[18px] border border-slate-200 bg-slate-50 p-1"; +const SECTION_TAB_TRIGGER_CLASS_NAME = + "rounded-[14px] px-3 py-2 text-sm font-medium text-slate-600 transition data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm"; + +interface SurfacePanelProps { + icon: LucideIcon; + title: string; + description: string; + aside?: ReactNode; + children: ReactNode; + className?: string; +} + +function SurfacePanel({ + icon: Icon, + title, + description, + aside, + children, + className, +}: SurfacePanelProps) { + return ( +
+
+
+
+ + {title} +
+

{description}

+
+ {aside ? ( +
{aside}
+ ) : null} +
+ +
{children}
+
+ ); +} + +function LoadingSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +// ============================================================================ +// 模型选择器子组件 +// ============================================================================ + +function DefaultModelSelect({ + value, + onChange, +}: { + value: string | undefined; + onChange: (v: string | undefined) => void; +}) { + const { providers, loading: providersLoading } = useConfiguredProviders(); + + // 已保存的值如果不兼容,自动回退到 "未指定" + useEffect(() => { + if (!value || providersLoading) return; + const slashIdx = value.indexOf("/"); + if (slashIdx < 0) return; + const providerKey = value.slice(0, slashIdx); + const modelName = value.slice(slashIdx + 1); + const provider = providers.find((p) => p.key === providerKey); + if (!provider) return; + const issue = getProviderModelCompatibilityIssue({ + providerType: provider.type, + configuredProviderType: provider.type, + model: modelName, + }); + if (issue) { + onChange(undefined); + } + }, [value, providers, providersLoading, onChange]); + + return ( +
+ + +

+ 为此渠道指定默认使用的 AI 模型 +

+
+ ); +} + +// ============================================================================ +// 密码输入组件 +// ============================================================================ + +function PasswordInput({ + label, + value, + onChange, + placeholder, + hint, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + hint?: React.ReactNode; +}) { + const [show, setShow] = useState(false); + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className={`${MONO_INPUT_CLASS_NAME} pr-10`} + /> + +
+ {hint ? ( +

{hint}

+ ) : null} +
+ ); +} + +// ============================================================================ +// 字符串列表输入组件 +// ============================================================================ + +function StringListInput({ + label, + values, + onChange, + placeholder, + hint, +}: { + label: string; + values: string[]; + onChange: (v: string[]) => void; + placeholder?: string; + hint?: string; +}) { + const [draft, setDraft] = useState(""); + + const addItem = () => { + const trimmed = draft.trim(); + if (trimmed && !values.includes(trimmed)) { + onChange([...values, trimmed]); + setDraft(""); + } + }; + + return ( +
+ +
+ setDraft(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && (e.preventDefault(), addItem()) + } + placeholder={placeholder} + className={`flex-1 ${INPUT_CLASS_NAME}`} + /> + +
+ {values.length > 0 && ( +
+ {values.map((v) => ( + + {v} + + + ))} +
+ )} + {hint ? ( +

{hint}

+ ) : null} +
+ ); +} + +function ConfigGuideCard({ + title, + steps, + note, +}: { + title: string; + steps: string[]; + note?: string; +}) { + return ( +
+

{title}

+
+ {steps.map((step, index) => ( +
+ + {index + 1} + +

{step}

+
+ ))} +
+ {note ? ( +

{note}

+ ) : null} +
+ ); +} + +function QrCodePreview({ + value, + className, +}: { + value: string; + className?: string; +}) { + const [dataUrl, setDataUrl] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const trimmed = value.trim(); + + if (!trimmed) { + setDataUrl(""); + setError(null); + return; + } + + setError(null); + void QRCode.toDataURL(trimmed, { + width: 320, + margin: 1, + errorCorrectionLevel: "M", + }) + .then((next: string) => { + if (!cancelled) { + setDataUrl(next); + } + }) + .catch((reason: unknown) => { + if (!cancelled) { + setDataUrl(""); + setError(reason instanceof Error ? reason.message : String(reason)); + } + }); + + return () => { + cancelled = true; + }; + }, [value]); + + if (error) { + return ( +
+ 二维码生成失败:{error} +
+ ); + } + + if (!dataUrl) { + return ( +
+ 正在生成二维码... +
+ ); + } + + return ( + 微信扫码二维码 + ); +} + +function GatewayTunnelPanel({ + config, + onChange, + defaultFeishuAccountId, + onReloadConfig, +}: { + config: GatewayConfig; + onChange: (c: GatewayConfig) => void; + defaultFeishuAccountId?: string; + onReloadConfig: () => Promise; +}) { + const tunnel = config.tunnel ?? DEFAULT_GATEWAY.tunnel!; + const cloudflare = tunnel.cloudflare ?? {}; + const [busyAction, setBusyAction] = useState(null); + const [output, setOutput] = useState(""); + const [feishuAccountId, setFeishuAccountId] = useState( + defaultFeishuAccountId ?? "default", + ); + + const busy = busyAction !== null; + + const patchTunnel = ( + patch: Partial>, + ) => { + onChange({ + ...config, + tunnel: { + ...tunnel, + ...patch, + }, + }); + }; + + const patchCloudflare = ( + patch: Partial< + NonNullable["cloudflare"]> + >, + ) => { + patchTunnel({ + cloudflare: { + ...cloudflare, + ...patch, + }, + }); + }; + + const runAction = async ( + action: string, + executor: () => Promise, + ) => { + setBusyAction(action); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + if (action === "create" || action === "sync") { + await onReloadConfig(); + } + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + } finally { + setBusyAction(null); + } + }; + + return ( +
+
+

Gateway 公共隧道

+

+ 全局 webhook 公网入口配置(供 Feishu/Telegram 等渠道复用) +

+
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + +
+ + patchCloudflare({ run_token: value || undefined })} + placeholder="cloudflared tunnel run --token ..." + /> + +
+ + + patchCloudflare({ + credentials_file: event.target.value || undefined, + }) + } + placeholder="~/.cloudflared/.json" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ +
+ +
+ +
+ + + + + + + + + +
+ + {busyAction && ( +
+ 正在执行:{busyAction} +
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + +// ============================================================================ +// Telegram 表单 +// ============================================================================ + +function TelegramForm({ + config, + onChange, +}: { + config: TelegramBotConfig; + onChange: (c: TelegramBotConfig) => void; +}) { + return ( +
+
+
+

启用 Telegram Bot

+

+ 开启后可通过 Telegram Bot 与 AI 对话 +

+
+ +
+ + onChange({ ...config, bot_token: v })} + placeholder="123456:ABC-DEF..." + hint={ + <> + 从{" "} + + @BotFather + {" "} + 获取 + + } + /> + + onChange({ ...config, allowed_user_ids: v })} + placeholder="输入 Telegram User ID" + hint="留空则允许所有用户" + /> + + onChange({ ...config, default_model: v })} + /> +
+ ); +} + +function TelegramGatewayDebugPanel() { + const [busyAction, setBusyAction] = useState(null); + const [accountId, setAccountId] = useState("default"); + const [pollTimeoutSecs, setPollTimeoutSecs] = useState("25"); + const [output, setOutput] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const resolveAccountId = () => { + const normalized = accountId.trim(); + return normalized.length > 0 ? normalized : undefined; + }; + + const resolvePollTimeoutSecs = () => { + const parsed = Number.parseInt(pollTimeoutSecs.trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }; + + const runAction = async ( + action: string, + executor: () => Promise, + successText: string, + ) => { + setBusyAction(action); + setMessage(null); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + setMessage({ type: "success", text: successText }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + setMessage({ type: "error", text: `执行失败: ${text}` }); + } finally { + setBusyAction(null); + } + }; + + const busy = busyAction !== null; + + return ( +
+
+

Telegram Gateway 运行控制

+

+ 用于状态查询、启停和重启;连通性检测已并入上方 IM 配置弹窗。 +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ + +
+ +
+ + + + +
+ + {busyAction && ( +
+ 正在执行:{busyAction} +
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + +function FeishuGatewayDebugPanel() { + const [busyAction, setBusyAction] = useState(null); + const [accountId, setAccountId] = useState("default"); + const [output, setOutput] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const resolveAccountId = () => { + const normalized = accountId.trim(); + return normalized.length > 0 ? normalized : undefined; + }; + + const runAction = async ( + action: string, + executor: () => Promise, + successText: string, + ) => { + setBusyAction(action); + setMessage(null); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + setMessage({ type: "success", text: successText }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + setMessage({ type: "error", text: `执行失败: ${text}` }); + } finally { + setBusyAction(null); + } + }; + + const busy = busyAction !== null; + + return ( +
+
+

Feishu Gateway 运行控制

+

+ 用于状态查询、启停和重启;连通性检测已并入上方 IM 配置弹窗。 +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ +
+ +
+ + + + +
+ + {busyAction && ( +
+ 正在执行:{busyAction} +
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + +function WechatGatewayDebugPanel({ + config, + onReloadConfig, +}: { + config: WechatBotConfig; + onReloadConfig: () => Promise; +}) { + const [busyAction, setBusyAction] = useState(null); + const [accountId, setAccountId] = useState( + config.default_account || "default", + ); + const [pollTimeoutSecs, setPollTimeoutSecs] = useState("25"); + const [baseUrl, setBaseUrl] = useState( + config.base_url || DEFAULT_WECHAT_BASE_URL, + ); + const [botType, setBotType] = useState(DEFAULT_WECHAT_BOT_TYPE); + const [loginSessionKey, setLoginSessionKey] = useState(""); + const [loginTimeoutMs, setLoginTimeoutMs] = useState("480000"); + const [accountName, setAccountName] = useState(""); + const [purgeDataOnRemove, setPurgeDataOnRemove] = useState(false); + const [accounts, setAccounts] = useState([]); + const [accountsLoaded, setAccountsLoaded] = useState(false); + const [qrCodeUrl, setQrCodeUrl] = useState(""); + const [runtimeStatus, setRuntimeStatus] = useState( + null, + ); + const [runtimeError, setRuntimeError] = useState(null); + const [output, setOutput] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + const autoWaitSessionKeyRef = useRef(null); + const loginAttemptStartedAtRef = useRef(null); + const loginAttemptAccountIdsRef = useRef>(new Set()); + + const runtimeAccountsById = useMemo(() => { + const map = new Map(); + for (const item of runtimeStatus?.accounts ?? []) { + map.set(item.accountId, item); + } + return map; + }, [runtimeStatus]); + + useEffect(() => { + const configuredIds = accounts.map((item) => item.accountId); + const normalized = accountId.trim(); + + if (!configuredIds.length) { + if (!normalized) { + setAccountId(config.default_account || "default"); + } + return; + } + + const preferred = + (config.default_account && configuredIds.includes(config.default_account) + ? config.default_account + : configuredIds[0]) || "default"; + + if ( + !normalized || + normalized === "default" || + !configuredIds.includes(normalized) + ) { + setAccountId(preferred); + } + }, [accounts, config.default_account, accountId]); + + useEffect(() => { + if (!baseUrl.trim()) { + setBaseUrl(config.base_url || DEFAULT_WECHAT_BASE_URL); + } + }, [config.base_url, baseUrl]); + + const loadAccounts = useCallback(async (writeOutput = false) => { + const result = await wechatChannelListAccounts(); + setAccounts(result); + setAccountsLoaded(true); + if (writeOutput) { + setOutput(JSON.stringify(result, null, 2)); + } + return result; + }, []); + + const loadRuntimeStatus = useCallback(async (writeOutput = false) => { + const result = await gatewayChannelStatus({ channel: "wechat" }); + const nextStatus = (result.status ?? null) as WechatGatewayStatus | null; + setRuntimeStatus(nextStatus); + setRuntimeError(null); + if (writeOutput) { + setOutput(JSON.stringify(result, null, 2)); + } + return result; + }, []); + + useEffect(() => { + void loadAccounts(false); + }, [loadAccounts]); + + useEffect(() => { + void loadRuntimeStatus(false); + }, [loadRuntimeStatus]); + + useEffect(() => { + let cancelled = false; + + const poll = async () => { + try { + const [nextAccounts, nextStatus] = await Promise.all([ + wechatChannelListAccounts(), + gatewayChannelStatus({ channel: "wechat" }), + ]); + if (cancelled) { + return; + } + setAccounts(nextAccounts); + setAccountsLoaded(true); + setRuntimeStatus((nextStatus.status ?? null) as WechatGatewayStatus | null); + setRuntimeError(null); + } catch (error) { + if (cancelled) { + return; + } + const text = error instanceof Error ? error.message : String(error); + setRuntimeError(text); + } + }; + + void poll(); + const timer = window.setInterval(() => { + void poll(); + }, WECHAT_RUNTIME_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); + + const resolveAccountId = () => { + const normalized = accountId.trim(); + return normalized.length > 0 ? normalized : undefined; + }; + + const resolvePollTimeoutSecs = useCallback(() => { + const parsed = Number.parseInt(pollTimeoutSecs.trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }, [pollTimeoutSecs]); + + const resolveBaseUrl = useCallback(() => { + const normalized = baseUrl.trim(); + return normalized.length > 0 ? normalized : undefined; + }, [baseUrl]); + + const resolveBotType = useCallback(() => { + const normalized = botType.trim(); + return normalized.length > 0 ? normalized : undefined; + }, [botType]); + + const resolveLoginTimeoutMs = useCallback(() => { + const parsed = Number.parseInt(loginTimeoutMs.trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }, [loginTimeoutMs]); + + const clearPendingLoginState = useCallback((nextAccountId?: string) => { + setQrCodeUrl(""); + setLoginSessionKey(""); + autoWaitSessionKeyRef.current = null; + loginAttemptStartedAtRef.current = null; + loginAttemptAccountIdsRef.current = new Set(); + if (nextAccountId?.trim()) { + setAccountId(nextAccountId.trim()); + } + }, []); + + const startLogin = useCallback(async () => { + loginAttemptStartedAtRef.current = Date.now(); + loginAttemptAccountIdsRef.current = new Set( + accounts.map((item) => item.accountId), + ); + const result = await wechatChannelLoginStart({ + baseUrl: resolveBaseUrl(), + botType: resolveBotType(), + sessionKey: loginSessionKey.trim() || undefined, + }); + const qrPayload = result.qrcodeUrl?.trim(); + if (!qrPayload) { + throw new Error(result.message || "微信接口未返回可用二维码"); + } + setLoginSessionKey(result.sessionKey); + setQrCodeUrl(qrPayload); + return result; + }, [accounts, loginSessionKey, resolveBaseUrl, resolveBotType]); + + const finalizeLoginSuccess = useCallback( + async (rawResult: unknown) => { + const result = normalizeWechatLoginWaitResult(rawResult); + if (!result.connected) { + return; + } + + const nextAccountId = result.accountId || accountId.trim() || "default"; + clearPendingLoginState(nextAccountId); + await loadAccounts(false); + await onReloadConfig(); + const startResult = await gatewayChannelStart({ + channel: "wechat", + accountId: nextAccountId, + pollTimeoutSecs: resolvePollTimeoutSecs(), + }); + setOutput( + JSON.stringify( + { + login: rawResult, + start: startResult, + }, + null, + 2, + ), + ); + }, + [ + accountId, + clearPendingLoginState, + loadAccounts, + onReloadConfig, + resolvePollTimeoutSecs, + ], + ); + + const waitForLoginResult = useCallback( + async (sessionKeyOverride?: string) => { + const sessionKey = (sessionKeyOverride || loginSessionKey).trim(); + if (!sessionKey) { + throw new Error("请先生成二维码,或填写已有 session_key"); + } + + return wechatChannelLoginWait({ + sessionKey, + baseUrl: resolveBaseUrl(), + botType: resolveBotType(), + timeoutMs: resolveLoginTimeoutMs(), + accountName: accountName.trim() || undefined, + }); + }, + [ + accountName, + loginSessionKey, + resolveBaseUrl, + resolveBotType, + resolveLoginTimeoutMs, + ], + ); + + const runAction = async ( + action: string, + executor: () => Promise, + successText: string, + afterSuccess?: (result: unknown) => Promise, + ) => { + setBusyAction(action); + setMessage(null); + try { + const result = await executor(); + setOutput(JSON.stringify(result, null, 2)); + if (afterSuccess) { + await afterSuccess(result); + } + setMessage({ type: "success", text: successText }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); + setMessage({ type: "error", text: `执行失败: ${text}` }); + } finally { + setBusyAction(null); + } + }; + + useEffect(() => { + if (!accountsLoaded || accounts.length > 0 || qrCodeUrl || busyAction) { + return; + } + + let cancelled = false; + + const bootstrap = async () => { + setBusyAction("login_start"); + setMessage(null); + try { + const result = await startLogin(); + if (cancelled) { + return; + } + setOutput(JSON.stringify(result, null, 2)); + setMessage({ + type: "success", + text: "已自动生成首张二维码,可直接扫码登录", + }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + if (cancelled) { + return; + } + const text = error instanceof Error ? error.message : String(error); + setOutput( + JSON.stringify( + { action: "login_start", ok: false, error: text }, + null, + 2, + ), + ); + setMessage({ type: "error", text: `二维码生成失败: ${text}` }); + } finally { + if (!cancelled) { + setBusyAction(null); + } + } + }; + + void bootstrap(); + + return () => { + cancelled = true; + }; + }, [accounts.length, accountsLoaded, busyAction, qrCodeUrl, startLogin]); + + useEffect(() => { + const sessionKey = loginSessionKey.trim(); + if (!qrCodeUrl || !sessionKey) { + return; + } + if (autoWaitSessionKeyRef.current === sessionKey) { + return; + } + + autoWaitSessionKeyRef.current = sessionKey; + let cancelled = false; + + const autoWait = async () => { + setBusyAction("login_wait"); + setMessage({ + type: "success", + text: "二维码已生成,正在后台等待扫码确认", + }); + try { + const result = await waitForLoginResult(sessionKey); + if (cancelled) { + return; + } + setOutput(JSON.stringify(result, null, 2)); + await finalizeLoginSuccess(result); + const normalizedResult = normalizeWechatLoginWaitResult(result); + if (normalizedResult.connected) { + setMessage({ + type: "success", + text: "微信登录成功,网关已自动启动", + }); + setTimeout(() => setMessage(null), 2500); + } else { + setMessage({ + type: "error", + text: normalizedResult.message || "微信登录未完成", + }); + } + } catch (error) { + if (cancelled) { + return; + } + const text = error instanceof Error ? error.message : String(error); + setOutput( + JSON.stringify( + { action: "login_wait", ok: false, error: text }, + null, + 2, + ), + ); + setMessage({ type: "error", text: `等待登录失败: ${text}` }); + } finally { + if (!cancelled) { + setBusyAction(null); + } + if (autoWaitSessionKeyRef.current === sessionKey) { + autoWaitSessionKeyRef.current = null; + } + } + }; + + void autoWait(); + + return () => { + cancelled = true; + }; + }, [ + finalizeLoginSuccess, + loginSessionKey, + qrCodeUrl, + waitForLoginResult, + ]); + + useEffect(() => { + const sessionKey = loginSessionKey.trim(); + const loginAttemptStartedAt = loginAttemptStartedAtRef.current; + if (!qrCodeUrl || !sessionKey || !loginAttemptStartedAt) { + return; + } + + const knownAccountIds = loginAttemptAccountIdsRef.current; + const detectedAccountId = accounts.find( + (item) => !knownAccountIds.has(item.accountId), + )?.accountId; + const normalizedTargetAccountId = accountId.trim(); + const hasFreshRuntimeAccount = (runtimeStatus?.accounts ?? []).some( + (item) => { + if (!item.running) { + return false; + } + if ( + normalizedTargetAccountId && + normalizedTargetAccountId !== "default" && + item.accountId !== normalizedTargetAccountId + ) { + return false; + } + if (!item.startedAt) { + return false; + } + const startedAt = Date.parse(item.startedAt); + return Number.isFinite(startedAt) && startedAt >= loginAttemptStartedAt - 5_000; + }, + ); + + if (!detectedAccountId && !hasFreshRuntimeAccount) { + return; + } + + clearPendingLoginState(detectedAccountId); + void onReloadConfig(); + }, [ + accountId, + accounts, + clearPendingLoginState, + loginSessionKey, + onReloadConfig, + qrCodeUrl, + runtimeStatus, + ]); + + const handleRemoveAccount = async (targetAccountId: string) => { + const normalized = targetAccountId.trim(); + if (!normalized) { + return; + } + + const confirmed = window.confirm( + [ + "⚠️ 危险操作检测!", + `操作类型:删除微信账号 ${normalized}${purgeDataOnRemove ? " 并清理本地数据" : ""}`, + `影响范围:将停止该账号的微信网关实例,并从渠道配置中移除该账号${purgeDataOnRemove ? ",同时删除本地缓存与同步状态" : ""}`, + `风险评估:删除后需要重新扫码或手动恢复配置${purgeDataOnRemove ? ",本地数据不可恢复" : ""}`, + "", + "请确认是否继续?", + ].join("\n"), + ); + if (!confirmed) { + return; + } + + await runAction( + "remove_account", + async () => { + await wechatChannelRemoveAccount({ + accountId: normalized, + purgeData: purgeDataOnRemove, + }); + return { + ok: true, + accountId: normalized, + purgeData: purgeDataOnRemove, + }; + }, + "微信账号已删除", + async () => { + await loadAccounts(true); + await onReloadConfig(); + setAccountId((current) => + current.trim() === normalized + ? config.default_account || "default" + : current, + ); + }, + ); + }; + + const busy = busyAction !== null; + + return ( +
+
+

微信 Gateway 运行控制

+

+ 默认保留状态查询、启停与账号清理;扫码和连通性检测已并入上方 IM 配置弹窗。 +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

运行控制

+

+ 账号 ID 留空时按默认配置解析;多账号场景建议显式指定。 +

+
+ +
+ + + +
+ +
+ + + + + +
+
+ +
+ +
+

+ 兼容扫码排障 +

+

+ 只有当上方 IM 配置里的扫码流程异常时,再展开这里做兼容排查。 +

+
+ + › + +
+ +
+
+ + + + +
+ +
+ + +
+ + {qrCodeUrl ? ( +
+
+
+ +
+
+

当前二维码已就绪

+

+ 请使用微信扫码并在手机上确认连接。当前页面会自动等待登录完成并写回配置。 +

+

+ 当前页面会把微信返回的二维码内容本地转码显示,不依赖远程图片加载。 +

+

+ session_key:{loginSessionKey || "未生成"} +

+
+
+
+ ) : ( +
+

当前还没有可扫码二维码

+

+ 请先点击“生成二维码”。如果接口没有返回二维码内容,下面的“最近结果”会直接显示错误原因。 +

+
+ )} +
+
+ +
+
+
+

已配置账号

+

+ 这里展示扫码写入或手工配置后实际可见的微信账号目录。 +

+
+ +
+ +
+ + 运行中账号:{runtimeStatus?.running_accounts ?? 0} + + + 已配置账号:{accounts.length} + + {runtimeError ? ( + + 状态轮询失败:{runtimeError} + + ) : null} +
+ + {accounts.length > 0 ? ( +
+ {accounts.map((item) => { + const runtime = runtimeAccountsById.get(item.accountId); + return ( +
+
+
+
+

+ {item.name || item.accountId} +

+ + {item.accountId} + + + {item.enabled ? "已启用" : "已禁用"} + + + {runtime?.running ? "运行中" : "未运行"} + +
+
+

Token:{item.hasToken ? "已保存" : "未保存"}

+

+ Base URL: + {item.baseUrl || + config.base_url || + DEFAULT_WECHAT_BASE_URL} +

+

+ CDN URL: + {item.cdnBaseUrl || + config.cdn_base_url || + DEFAULT_WECHAT_CDN_BASE_URL} +

+

扫码用户 ID:{item.scannerUserId || "未记录"}

+

+ 最近轮询: + {formatRuntimeTimestamp(runtime?.lastUpdateAt)} +

+

+ 最近消息: + {formatRuntimeTimestamp(runtime?.lastMessageAt)} +

+

+ Sync Buf: + {runtime?.syncBufPresent ? "已存在" : "暂无"} +

+ {runtime?.lastError ? ( +

+ 最近错误:{runtime.lastError} +

+ ) : null} +
+
+ +
+ + +
+
+
+ ); + })} +
+ ) : ( +

+ 还没有微信账号。可以先去上方微信配置扫码接入;只有主流程异常时再用这里的兼容扫码排障。 +

+ )} +
+ + {busyAction && ( +
+ 正在执行:{busyAction} +
+ )} + +
+
最近结果
+
+          {output || "暂无结果"}
+        
+
+
+ ); +} + +// ============================================================================ +// Discord 表单 +// ============================================================================ + +function DiscordForm({ + config, + onChange, +}: { + config: DiscordBotConfig; + onChange: (c: DiscordBotConfig) => void; +}) { + const accountIds = useMemo( + () => Object.keys(config.accounts ?? {}), + [config.accounts], + ); + const [activeAccountId, setActiveAccountId] = useState( + config.default_account || accountIds[0] || "default", + ); + + useEffect(() => { + if (!accountIds.length) { + setActiveAccountId(config.default_account || "default"); + return; + } + if (!accountIds.includes(activeAccountId)) { + setActiveAccountId(config.default_account || accountIds[0]); + } + }, [accountIds, config.default_account, activeAccountId]); + + const patch = (next: Partial) => { + onChange({ ...config, ...next }); + }; + + const patchAccount = ( + accountId: string, + updater: ( + current: NonNullable[string], + ) => NonNullable[string], + ) => { + const baseAccounts = config.accounts ?? {}; + const current = baseAccounts[accountId] ?? { enabled: true }; + patch({ + accounts: { + ...baseAccounts, + [accountId]: updater(current), + }, + }); + }; + + const addAccount = () => { + const baseAccounts = config.accounts ?? {}; + let index = 1; + let accountId = "account_1"; + while (baseAccounts[accountId]) { + index += 1; + accountId = `account_${index}`; + } + const nextAccounts = { + ...baseAccounts, + [accountId]: { + enabled: true, + name: `Discord ${index}`, + }, + }; + patch({ + accounts: nextAccounts, + default_account: config.default_account || accountId, + }); + setActiveAccountId(accountId); + }; + + const removeAccount = (accountId: string) => { + const baseAccounts = { ...(config.accounts ?? {}) }; + delete baseAccounts[accountId]; + const nextIds = Object.keys(baseAccounts); + patch({ + accounts: baseAccounts, + default_account: + config.default_account === accountId + ? nextIds[0] || "default" + : config.default_account, + }); + setActiveAccountId(nextIds[0] || "default"); + }; + + const currentAccount = (config.accounts ?? {})[activeAccountId]; + const intents = config.intents ?? {}; + const actions = config.actions ?? {}; + const threadBindings = config.thread_bindings ?? {}; + const autoPresence = config.auto_presence ?? {}; + const dmPolicy = (config.dm_policy || "pairing").toLowerCase(); + const groupPolicy = (config.group_policy || "allowlist").toLowerCase(); + const streamingMode = (config.streaming || "partial").toLowerCase(); + const replyToMode = (config.reply_to_mode || "off").toLowerCase(); + + return ( +
+
+
+

启用 Discord Bot

+

+ 开启后可通过 Discord Bot 与 AI 对话 +

+
+ +
+ + patch({ bot_token: v })} + placeholder="MTIz..." + hint={ + <> + 从{" "} + + Discord Developer Portal + {" "} + 获取 + + } + /> + + patch({ allowed_server_ids: v })} + placeholder="输入 Discord Server ID" + hint="留空则允许所有服务器" + /> + +
+ + + patch({ default_account: e.target.value || undefined }) + } + placeholder="default" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ +
+
+
+

多账号配置

+

+ 支持账号级 token/模型/服务器范围覆盖 +

+
+ +
+ + {accountIds.length > 0 ? ( + <> +
+ {accountIds.map((accountId) => ( + + ))} +
+ + {currentAccount && ( +
+
+ + +
+ +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + name: e.target.value || undefined, + })) + } + placeholder="运营号 / 机器人 1" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm" + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + default_model: v, + })) + } + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + bot_token: v || undefined, + })) + } + placeholder="MTIz..." + /> + + + patchAccount(activeAccountId, (item) => ({ + ...item, + allowed_server_ids: v, + })) + } + placeholder="输入 Discord Server ID" + /> +
+ )} + + ) : ( +

+ 未配置账号,将使用全局 Bot Token 运行。 +

+ )} +
+ +
+
+ + +
+
+ + +
+
+ + patch({ allow_from: v })} + placeholder="用户 ID 或 *" + /> + + patch({ group_allow_from: v })} + placeholder="用户 ID 或 *" + /> + +
+
+ + +
+
+ + +
+
+ +
+

高级能力开关

+
+ + + + + + +
+
+ + patch({ default_model: v })} + /> +
+ ); +} + +function WechatForm({ + config, + onChange, +}: { + config: WechatBotConfig; + onChange: (c: WechatBotConfig) => void; +}) { + const accountIds = useMemo( + () => Object.keys(config.accounts ?? {}), + [config.accounts], + ); + const [activeAccountId, setActiveAccountId] = useState( + config.default_account || accountIds[0] || "default", + ); + + useEffect(() => { + if (!accountIds.length) { + setActiveAccountId(config.default_account || "default"); + return; + } + if (!accountIds.includes(activeAccountId)) { + setActiveAccountId(config.default_account || accountIds[0]); + } + }, [accountIds, config.default_account, activeAccountId]); + + const patch = (next: Partial) => { + onChange({ ...config, ...next }); + }; + + const patchAccount = ( + accountId: string, + updater: ( + current: NonNullable[string], + ) => NonNullable[string], + ) => { + const baseAccounts = config.accounts ?? {}; + const current = baseAccounts[accountId] ?? { enabled: true }; + patch({ + accounts: { + ...baseAccounts, + [accountId]: updater(current), + }, + }); + }; + + const addAccount = () => { + const baseAccounts = config.accounts ?? {}; + let index = 1; + let accountId = "wechat_1"; + while (baseAccounts[accountId]) { + index += 1; + accountId = `wechat_${index}`; + } + const nextAccounts = { + ...baseAccounts, + [accountId]: { + enabled: true, + name: `微信账号 ${index}`, + }, + }; + patch({ + accounts: nextAccounts, + default_account: config.default_account || accountId, + }); + setActiveAccountId(accountId); + }; + + const removeAccount = (accountId: string) => { + const baseAccounts = { ...(config.accounts ?? {}) }; + delete baseAccounts[accountId]; + const nextIds = Object.keys(baseAccounts); + patch({ + accounts: baseAccounts, + default_account: + config.default_account === accountId + ? nextIds[0] || "default" + : config.default_account, + }); + setActiveAccountId(nextIds[0] || "default"); + }; + + const currentAccount = (config.accounts ?? {})[activeAccountId]; + const dmPolicy = (() => { + const value = (config.dm_policy || "pairing").toLowerCase(); + if ( + value === "pairing" || + value === "allowlist" || + value === "open" || + value === "disabled" + ) { + return value; + } + return "pairing"; + })(); + const groupPolicy = (() => { + const value = (config.group_policy || "allowlist").toLowerCase(); + if (value === "allowlist" || value === "open" || value === "disabled") { + return value; + } + return "allowlist"; + })(); + const streamingMode = (() => { + const value = (config.streaming || "off").toLowerCase(); + if (value === "partial" || value === "block" || value === "off") { + return value; + } + return "off"; + })(); + const replyToMode = (() => { + const value = (config.reply_to_mode || "off").toLowerCase(); + if (value === "first" || value === "all" || value === "off") { + return value; + } + return "off"; + })(); + + return ( +
+
+
+

启用微信 Bot

+

+ 开启后可通过企业微信客服通道接收消息,并转发给 Lime Agent。 +

+
+ +
+ + patch({ bot_token: value })} + placeholder="扫码成功后可自动回填,也支持手工填写" + hint="已有多账号时可留空;旧单账号模式会继续兼容。" + /> + +
+
+ + patch({ base_url: event.target.value })} + placeholder={DEFAULT_WECHAT_BASE_URL} + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + patch({ cdn_base_url: event.target.value })} + placeholder={DEFAULT_WECHAT_CDN_BASE_URL} + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ +
+
+ + + patch({ + scanner_user_id: event.target.value.trim() || undefined, + }) + } + placeholder="扫码绑定后的 user_id" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + + patch({ default_account: event.target.value.trim() || undefined }) + } + placeholder="default" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ +
+
+
+

多账号配置

+

+ 扫码登录成功后账号会自动落到这里,也支持手工补录连接参数。 +

+
+ +
+ + {accountIds.length > 0 ? ( + <> +
+ {accountIds.map((accountId) => ( + + ))} +
+ + {currentAccount && ( +
+
+ + +
+ +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + name: event.target.value || undefined, + })) + } + placeholder="主微信 / 运营号" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm" + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + default_model: value, + })) + } + /> +
+
+ +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + base_url: event.target.value.trim() || undefined, + })) + } + placeholder={DEFAULT_WECHAT_BASE_URL} + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono" + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + cdn_base_url: event.target.value.trim() || undefined, + })) + } + placeholder={DEFAULT_WECHAT_CDN_BASE_URL} + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono" + /> +
+
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + bot_token: value || undefined, + })) + } + placeholder="扫码成功后回填的 token" + /> + +
+ + + patchAccount(activeAccountId, (item) => ({ + ...item, + scanner_user_id: event.target.value.trim() || undefined, + })) + } + placeholder="扫码后的微信 user_id" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono" + /> +
+
+ )} + + ) : ( +

+ 未配置账号时,将回退使用上方的全局兼容参数。 +

+ )} +
+ +
+
+ + +
+
+ + +
+
+ + patch({ allow_from: value })} + placeholder="用户 ID 或 *" + hint="扫码用户通常会自动放行;生产环境建议按 allowlist/pairing 收紧。" + /> + + patch({ group_allow_from: value })} + placeholder="用户 ID 或 *" + /> + +
+
+ + +
+
+ + +
+
+ + patch({ default_model: value })} + /> +
+ ); +} + +// ============================================================================ +// 飞书表单 +// ============================================================================ + +function FeishuForm({ + config, + onChange, +}: { + config: FeishuBotConfig; + onChange: (c: FeishuBotConfig) => void; +}) { + const connectionMode = + (config.connection_mode || "websocket").toLowerCase() === "webhook" + ? "webhook" + : "websocket"; + const dmPolicy = (() => { + const value = (config.dm_policy || "open").toLowerCase(); + if ( + value === "open" || + value === "allowlist" || + value === "pairing" || + value === "disabled" + ) { + return value; + } + return "open"; + })(); + const streamingMode = (config.streaming || "partial").toLowerCase(); + const replyToMode = (config.reply_to_mode || "off").toLowerCase(); + + return ( +
+
+
+

启用飞书 Bot

+

+ 开启后可通过飞书 Bot 与 AI 对话 +

+
+ +
+ +
+ + onChange({ ...config, app_id: e.target.value })} + placeholder="cli_xxxx" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ + onChange({ ...config, app_secret: v })} + placeholder="飞书应用的 App Secret" + /> + +
+ + + onChange({ + ...config, + verification_token: e.target.value || undefined, + }) + } + placeholder="事件订阅验证 Token" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ + onChange({ ...config, encrypt_key: v || undefined })} + placeholder="事件加密密钥" + /> + +
+ + + {connectionMode === "websocket" && ( +

+ 当前版本 WebSocket 模式尚未实装,请使用 Webhook + 模式接入飞书事件回调。 +

+ )} +
+ + {connectionMode === "webhook" && ( +
+
+ + + onChange({ + ...config, + webhook_host: e.target.value.trim() || undefined, + }) + } + placeholder="0.0.0.0" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + { + const value = Number.parseInt(e.target.value, 10); + onChange({ + ...config, + webhook_port: + Number.isFinite(value) && value > 0 ? value : undefined, + }); + }} + placeholder="3000" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + + onChange({ + ...config, + webhook_path: e.target.value.trim() || undefined, + }) + } + placeholder="/feishu/default" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ )} + +
+
+ + +

+ 建议联调使用 open;生产可切换为 allowlist / pairing。 +

+
+ onChange({ ...config, allow_from: v })} + placeholder="输入 open_id / user_id,或 *" + hint="open 策略建议至少包含 *;allowlist/pairing 需填写具体用户。" + /> +
+ +
+
+ + +
+
+ + +
+
+ + onChange({ ...config, default_model: v })} + /> +
+ ); +} + +// ============================================================================ +// 主组件 +// ============================================================================ + +export interface ChannelsDebugWorkbenchProps { + className?: string; + onConfigSaved?: () => void; + initialTab?: "telegram" | "discord" | "feishu" | "wechat"; + initialSubPage?: "overview" | "config" | ChannelSubPage; + initialDebugTab?: "telegram" | "discord" | "feishu" | "wechat"; +} + +function normalizeVisibleDebugTab(tab?: DebugTabKey): VisibleDebugTabKey { + if (tab === "feishu" || tab === "wechat") { + return tab; + } + return "telegram"; +} + +export function ChannelsDebugWorkbench({ + className, + onConfigSaved, + initialSubPage = "logs", + initialDebugTab = "telegram", +}: ChannelsDebugWorkbenchProps) { + const normalizedInitialSubPage: ChannelSubPage = + initialSubPage === "overview" || initialSubPage === "config" + ? "logs" + : initialSubPage; + const [activeSubPage, setActiveSubPage] = + useState(normalizedInitialSubPage); + const [activeDebugTab, setActiveDebugTab] = + useState(normalizeVisibleDebugTab(initialDebugTab)); + const [config, setConfig] = useState(null); + const [channels, setChannels] = useState(DEFAULT_CHANNELS); + const [gateway, setGateway] = useState(DEFAULT_GATEWAY); + const [initialJson, setInitialJson] = useState(""); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const isDirty = useMemo( + () => JSON.stringify({ channels, gateway }) !== initialJson, + [channels, gateway, initialJson], + ); + + const loadConfig = useCallback(async () => { + try { + const c = await getConfig(); + const normalizedChannels = normalizeChannelsConfig(c.channels); + const normalizedGateway = normalizeGatewayConfig(c.gateway); + setConfig(c); + setChannels(normalizedChannels); + setGateway(normalizedGateway); + setInitialJson( + JSON.stringify({ + channels: normalizedChannels, + gateway: normalizedGateway, + }), + ); + } catch (e) { + console.error("加载配置失败", e); + } + }, []); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const handleSave = async () => { + if (!config) return; + setSaving(true); + setMessage(null); + try { + await saveConfig({ ...config, channels, gateway }); + setInitialJson( + JSON.stringify({ + channels, + gateway, + }), + ); + setMessage({ type: "success", text: "高级工具配置已保存" }); + onConfigSaved?.(); + setTimeout(() => setMessage(null), 3000); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + setMessage({ type: "error", text: `保存失败: ${msg}` }); + } + setSaving(false); + }; + + const handleCancel = () => { + if (initialJson) { + const snapshot = JSON.parse(initialJson) as { + channels: ChannelsConfig; + gateway: GatewayConfig; + }; + setChannels(normalizeChannelsConfig(snapshot.channels)); + setGateway(normalizeGatewayConfig(snapshot.gateway)); + } + }; + + if (!config) { + return ; + } + + const SUB_PAGE_LABELS: Record = { + gateway: "网关", + logs: "日志", + }; + + const subPages: Array<{ + key: ChannelSubPage; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + }> = [ + { + key: "gateway", + label: "网关", + description: "公网入口与回调同步", + icon: Network, + }, + { + key: "logs", + label: "日志", + description: "日志与运行排障", + icon: ScrollText, + }, + ]; + + const tunnelEnabled = gateway.tunnel?.enabled === true; + const currentScopeLabel = SUB_PAGE_LABELS[activeSubPage]; + + return ( +
+ {message && ( +
+ + {message.text} +
+ )} + + +
+ {subPages.map((page) => { + const Icon = page.icon; + const isActive = activeSubPage === page.key; + return ( + + ); + })} +
+
+ + {activeSubPage === "gateway" && ( +
+ + {tunnelEnabled ? "隧道已启用" : "隧道未启用"} + + } + > +
+ + +
+
+ + +
+
+

本地入口

+

+ {gateway.tunnel?.local_host || "127.0.0.1"}: + {gateway.tunnel?.local_port ?? 3000} +

+
+
+

+ Tunnel Provider / 模式 +

+

+ {gateway.tunnel?.provider || "cloudflare"} /{" "} + {gateway.tunnel?.mode || "managed"} +

+
+
+

+ 飞书默认账号 +

+

+ {channels.feishu.default_account || "default"} +

+
+
+
+
+ )} + + {activeSubPage === "logs" && ( +
+ +
+ + +
+
+ + + setActiveDebugTab(v as VisibleDebugTabKey)} + className="w-full" + > + + + Telegram + + + 飞书 + + + 微信 + + + + + + + + + + + + + + + + +
+ )} + + {/* 底部固定栏 */} + {isDirty && ( +
+
+ + 未保存的更改 + + {currentScopeLabel} + +
+
+ + +
+
+ )} +
+ ); +} + +const _legacyChannelFormsReservedForReconnect = [ + TelegramForm, + DiscordForm, + WechatForm, + FeishuForm, +]; + +void _legacyChannelFormsReservedForReconnect; + +export default ChannelsDebugWorkbench; diff --git a/src/components/settings-v2/system/channels/index.test.tsx b/src/components/settings-v2/system/channels/index.test.tsx index d441b20d2..c6e6af44d 100644 --- a/src/components/settings-v2/system/channels/index.test.tsx +++ b/src/components/settings-v2/system/channels/index.test.tsx @@ -1,39 +1,20 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockGetConfig, mockSaveConfig } = vi.hoisted(() => ({ - mockGetConfig: vi.fn(), - mockSaveConfig: vi.fn(), -})); - -const { mockUseConfiguredProviders } = vi.hoisted(() => ({ - mockUseConfiguredProviders: vi.fn(), -})); - -vi.mock("@/lib/api/appConfig", () => ({ - getConfig: mockGetConfig, - saveConfig: mockSaveConfig, -})); - -vi.mock("@/hooks/useConfiguredProviders", () => ({ - useConfiguredProviders: mockUseConfiguredProviders, -})); - -vi.mock("./ChannelLogTailPanel", () => ({ - ChannelLogTailPanel: () =>
日志面板占位
, -})); - import { ChannelsSettings } from "."; -interface Mounted { +vi.mock("@/components/channels/ImConfigPage", () => ({ + ImConfigPage: () =>
, +})); + +interface MountedPage { container: HTMLDivElement; root: Root; } -const mounted: Mounted[] = []; +const mountedPages: MountedPage[] = []; -function renderComponent(): HTMLDivElement { +function renderPage() { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -42,169 +23,37 @@ function renderComponent(): HTMLDivElement { root.render(); }); - mounted.push({ container, root }); + mountedPages.push({ container, root }); return container; } -async function flushEffects() { - await act(async () => { - await Promise.resolve(); - }); -} - -async function waitForLoad() { - await flushEffects(); - await flushEffects(); -} - -function findButton(container: HTMLElement, text: string): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")).find((item) => - item.textContent?.includes(text), - ); - if (!button) { - throw new Error(`未找到按钮: ${text}`); - } - return button as HTMLButtonElement; -} - -async function clickButton(button: HTMLButtonElement) { - await act(async () => { - button.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushEffects(); - }); -} - -beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - - vi.clearAllMocks(); - - mockUseConfiguredProviders.mockReturnValue({ - providers: [], - loading: false, - }); - - mockGetConfig.mockResolvedValue({ - channels: { - telegram: { - enabled: false, - bot_token: "123456:telegram-token", - allowed_user_ids: ["10001"], - default_model: "openai/gpt-4.1", - }, - discord: { - enabled: true, - bot_token: "discord-token", - allowed_server_ids: ["guild-1"], - default_model: "claude/claude-sonnet-4", - default_account: "default", - accounts: {}, - dm_policy: "pairing", - allow_from: [], - group_policy: "allowlist", - group_allow_from: [], - streaming: "partial", - reply_to_mode: "off", - }, - feishu: { - enabled: false, - app_id: "cli_test", - app_secret: "secret", - default_model: undefined, - dm_policy: "open", - allow_from: ["*"], - group_policy: "allowlist", - group_allow_from: [], - }, - }, - gateway: { - tunnel: { - enabled: false, - provider: "cloudflare", - mode: "managed", - local_host: "127.0.0.1", - local_port: 3000, - cloudflare: { - tunnel_name: "lime", - dns_name: "bot.example.com", - }, - }, - }, - }); - - mockSaveConfig.mockResolvedValue(undefined); -}); - -afterEach(() => { - while (mounted.length > 0) { - const target = mounted.pop(); - if (!target) { - break; - } - - act(() => { - target.root.unmount(); - }); - target.container.remove(); - } - - vi.clearAllMocks(); -}); - describe("ChannelsSettings", () => { - it("应渲染新的渠道控制总览和工作区切换", async () => { - const container = renderComponent(); - await waitForLoad(); - - const text = container.textContent ?? ""; - expect(text).toContain("CHANNEL CONTROL"); - expect(text).toContain("工作区切换"); - expect(text).toContain("渠道总览"); - expect(text).toContain("推荐顺序"); - expect(text).toContain("Telegram"); - expect(text).toContain("Discord"); - expect(text).toContain("飞书"); + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); }); - it("在渠道配置页修改 Telegram 开关后应支持保存", async () => { - const container = renderComponent(); - await waitForLoad(); + afterEach(() => { + while (mountedPages.length > 0) { + const mounted = mountedPages.pop(); + if (!mounted) { + continue; + } - await clickButton(findButton(container, "渠道配置")); - expect(container.textContent).toContain("Telegram 配置说明"); - - const switches = Array.from( - container.querySelectorAll('button[role="switch"]'), - ); - if (switches.length === 0) { - throw new Error("未找到渠道开关"); + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); } - await clickButton(switches[0]); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); - expect(container.textContent).toContain("未保存的更改"); + it("设置页渠道管理应直接复用 IM 配置主页面", () => { + const container = renderPage(); - await clickButton(findButton(container, "保存")); - - expect(mockSaveConfig).toHaveBeenCalledTimes(1); - expect(mockSaveConfig).toHaveBeenCalledWith( - expect.objectContaining({ - channels: expect.objectContaining({ - telegram: expect.objectContaining({ - enabled: true, - }), - }), - gateway: expect.objectContaining({ - tunnel: expect.objectContaining({ - local_host: "127.0.0.1", - local_port: 3000, - }), - }), - }), - ); + expect( + container.querySelector('[data-testid="im-config-page-proxy"]'), + ).not.toBeNull(); }); }); diff --git a/src/components/settings-v2/system/channels/index.tsx b/src/components/settings-v2/system/channels/index.tsx index 03766fc01..3b34266af 100644 --- a/src/components/settings-v2/system/channels/index.tsx +++ b/src/components/settings-v2/system/channels/index.tsx @@ -1,4731 +1,9 @@ +import { ImConfigPage } from "@/components/channels/ImConfigPage"; + /** - * 渠道管理设置页面 - * - * Telegram / Discord / 飞书 / 微信 Bot 渠道的内联表单配置 + * 设置页里的渠道管理直接复用 IM 配置主页面, + * 避免再维护一套平行的“概览 / 渠道 / 网关 / 日志”工作台。 */ - -import React, { - useState, - useEffect, - useCallback, - useMemo, - useRef, - type ReactNode, -} from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Eye, - EyeOff, - Plus, - X, - Loader2, - Save, - RotateCcw, - AlertCircle, - LayoutDashboard, - SlidersHorizontal, - Network, - ScrollText, - type LucideIcon, -} from "lucide-react"; -import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; -import { - discordChannelProbe, - feishuChannelProbe, - gatewayChannelStart, - gatewayChannelStatus, - gatewayChannelStop, - gatewayTunnelCreate, - gatewayTunnelDetectCloudflared, - gatewayTunnelInstallCloudflared, - gatewayTunnelProbe, - gatewayTunnelRestart, - gatewayTunnelStart, - gatewayTunnelStatus, - gatewayTunnelStop, - gatewayTunnelSyncWebhookUrl, - telegramChannelProbe, - wechatChannelListAccounts, - wechatChannelLoginStart, - wechatChannelLoginWait, - wechatChannelProbe, - wechatChannelRemoveAccount, - type ChannelsConfig, - type DiscordBotConfig, - type FeishuBotConfig, - type GatewayConfig, - type TelegramBotConfig, - type WechatBotConfig, - type WechatConfiguredAccount, - type WechatGatewayAccountStatus, - type WechatGatewayStatus, -} from "@/lib/api/channelsRuntime"; -import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; -import { - filterProviderModelsByCompatibility, - getProviderModelCompatibilityIssue, -} from "@/components/agent/chat/utils/providerModelCompatibility"; -import { ChannelLogTailPanel } from "./ChannelLogTailPanel"; -import { cn } from "@/lib/utils"; -import QRCode from "qrcode"; - -// ============================================================================ -// 默认值 -// ============================================================================ - -const DEFAULT_CHANNELS: ChannelsConfig = { - telegram: { - enabled: false, - bot_token: "", - allowed_user_ids: [], - default_model: undefined, - }, - discord: { - enabled: false, - bot_token: "", - allowed_server_ids: [], - default_model: undefined, - default_account: "default", - accounts: {}, - dm_policy: "pairing", - allow_from: [], - group_policy: "allowlist", - group_allow_from: [], - streaming: "partial", - reply_to_mode: "off", - }, - feishu: { - enabled: false, - app_id: "", - app_secret: "", - default_model: undefined, - dm_policy: "open", - allow_from: ["*"], - group_policy: "allowlist", - group_allow_from: [], - }, - wechat: { - enabled: false, - bot_token: "", - base_url: "", - cdn_base_url: "", - default_model: undefined, - default_account: "default", - accounts: {}, - dm_policy: "pairing", - allow_from: [], - group_policy: "allowlist", - group_allow_from: [], - streaming: "off", - reply_to_mode: "off", - }, -}; - -const DEFAULT_GATEWAY: GatewayConfig = { - tunnel: { - enabled: false, - provider: "cloudflare", - mode: "managed", - local_host: "127.0.0.1", - local_port: 3000, - cloudflare: {}, - }, -}; - -const DEFAULT_WECHAT_BASE_URL = "https://ilinkai.weixin.qq.com"; -const DEFAULT_WECHAT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"; -const DEFAULT_WECHAT_BOT_TYPE = "3"; -const WECHAT_RUNTIME_POLL_INTERVAL_MS = 2000; - -function formatRuntimeTimestamp(timestamp?: string | null): string { - if (!timestamp) { - return "暂无"; - } - - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) { - return timestamp; - } - - return date.toLocaleString(); +export function ChannelsSettings() { + return ; } - -type WechatLoginWaitResultLike = { - connected?: boolean | string | null; - accountId?: string | null; - account_id?: string | null; - message?: string | null; -}; - -function normalizeWechatLoginWaitResult(result: unknown): { - connected: boolean; - accountId?: string; - message: string; -} { - if (!result || typeof result !== "object") { - return { - connected: false, - message: "", - }; - } - - const payload = result as WechatLoginWaitResultLike; - const connected = payload.connected === true || payload.connected === "true"; - const accountId = - (typeof payload.accountId === "string" && payload.accountId.trim()) || - (typeof payload.account_id === "string" && payload.account_id.trim()) || - undefined; - const message = - typeof payload.message === "string" ? payload.message.trim() : ""; - - return { - connected, - accountId, - message, - }; -} - -function normalizeChannelsConfig( - value?: Partial | null, -): ChannelsConfig { - const telegram = value?.telegram; - const discord = value?.discord; - const feishu = value?.feishu; - const wechat = value?.wechat; - - return { - telegram: { - ...DEFAULT_CHANNELS.telegram, - ...telegram, - allowed_user_ids: - telegram?.allowed_user_ids ?? - DEFAULT_CHANNELS.telegram.allowed_user_ids, - }, - discord: { - ...DEFAULT_CHANNELS.discord, - ...discord, - allowed_server_ids: - discord?.allowed_server_ids ?? - DEFAULT_CHANNELS.discord.allowed_server_ids, - accounts: discord?.accounts ?? DEFAULT_CHANNELS.discord.accounts, - allow_from: discord?.allow_from ?? DEFAULT_CHANNELS.discord.allow_from, - group_allow_from: - discord?.group_allow_from ?? DEFAULT_CHANNELS.discord.group_allow_from, - }, - feishu: { - ...DEFAULT_CHANNELS.feishu, - ...feishu, - allow_from: feishu?.allow_from ?? DEFAULT_CHANNELS.feishu.allow_from, - group_allow_from: - feishu?.group_allow_from ?? DEFAULT_CHANNELS.feishu.group_allow_from, - }, - wechat: { - ...DEFAULT_CHANNELS.wechat, - ...wechat, - accounts: wechat?.accounts ?? DEFAULT_CHANNELS.wechat.accounts, - allow_from: wechat?.allow_from ?? DEFAULT_CHANNELS.wechat.allow_from, - group_allow_from: - wechat?.group_allow_from ?? DEFAULT_CHANNELS.wechat.group_allow_from, - }, - }; -} - -function normalizeGatewayConfig(value?: GatewayConfig | null): GatewayConfig { - const tunnel = value?.tunnel; - const defaultTunnel = DEFAULT_GATEWAY.tunnel ?? {}; - - return { - ...DEFAULT_GATEWAY, - ...value, - tunnel: { - ...defaultTunnel, - ...tunnel, - cloudflare: { - ...(defaultTunnel.cloudflare ?? {}), - ...(tunnel?.cloudflare ?? {}), - }, - }, - }; -} - -type TabKey = "telegram" | "discord" | "feishu" | "wechat"; -type ChannelSubPage = "overview" | "config" | "gateway" | "logs"; -type DebugTabKey = "telegram" | "feishu" | "discord" | "wechat"; - -const INPUT_CLASS_NAME = - "w-full rounded-[16px] border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-slate-300 focus:ring-2 focus:ring-slate-200"; -const MONO_INPUT_CLASS_NAME = `${INPUT_CLASS_NAME} font-mono`; -const PANEL_CLASS_NAME = - "space-y-4 rounded-[26px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; -const SOFT_CARD_CLASS_NAME = - "rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4"; -const SECTION_TABS_CLASS_NAME = - "grid w-full max-w-2xl grid-cols-4 rounded-[18px] border border-slate-200 bg-slate-50 p-1"; -const SECTION_TAB_TRIGGER_CLASS_NAME = - "rounded-[14px] px-3 py-2 text-sm font-medium text-slate-600 transition data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm"; - -interface SurfacePanelProps { - icon: LucideIcon; - title: string; - description: string; - aside?: ReactNode; - children: ReactNode; - className?: string; -} - -interface SummaryStatProps { - label: string; - value: string; - description: string; -} - -function SurfacePanel({ - icon: Icon, - title, - description, - aside, - children, - className, -}: SurfacePanelProps) { - return ( -
-
-
-
- - {title} -
-

{description}

-
- {aside ? ( -
{aside}
- ) : null} -
- -
{children}
-
- ); -} - -function SummaryStat({ label, value, description }: SummaryStatProps) { - return ( -
-

- {label} -

-

- {value} -

-

{description}

-
- ); -} - -function LoadingSkeleton() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} - -// ============================================================================ -// 模型选择器子组件 -// ============================================================================ - -function DefaultModelSelect({ - value, - onChange, -}: { - value: string | undefined; - onChange: (v: string | undefined) => void; -}) { - const { providers, loading: providersLoading } = useConfiguredProviders(); - - // 已保存的值如果不兼容,自动回退到 "未指定" - useEffect(() => { - if (!value || providersLoading) return; - const slashIdx = value.indexOf("/"); - if (slashIdx < 0) return; - const providerKey = value.slice(0, slashIdx); - const modelName = value.slice(slashIdx + 1); - const provider = providers.find((p) => p.key === providerKey); - if (!provider) return; - const issue = getProviderModelCompatibilityIssue({ - providerType: provider.type, - configuredProviderType: provider.type, - model: modelName, - }); - if (issue) { - onChange(undefined); - } - }, [value, providers, providersLoading, onChange]); - - return ( -
- - -

- 为此渠道指定默认使用的 AI 模型 -

-
- ); -} - -// ============================================================================ -// 密码输入组件 -// ============================================================================ - -function PasswordInput({ - label, - value, - onChange, - placeholder, - hint, -}: { - label: string; - value: string; - onChange: (v: string) => void; - placeholder?: string; - hint?: React.ReactNode; -}) { - const [show, setShow] = useState(false); - return ( -
- -
- onChange(e.target.value)} - placeholder={placeholder} - className={`${MONO_INPUT_CLASS_NAME} pr-10`} - /> - -
- {hint ? ( -

{hint}

- ) : null} -
- ); -} - -// ============================================================================ -// 字符串列表输入组件 -// ============================================================================ - -function StringListInput({ - label, - values, - onChange, - placeholder, - hint, -}: { - label: string; - values: string[]; - onChange: (v: string[]) => void; - placeholder?: string; - hint?: string; -}) { - const [draft, setDraft] = useState(""); - - const addItem = () => { - const trimmed = draft.trim(); - if (trimmed && !values.includes(trimmed)) { - onChange([...values, trimmed]); - setDraft(""); - } - }; - - return ( -
- -
- setDraft(e.target.value)} - onKeyDown={(e) => - e.key === "Enter" && (e.preventDefault(), addItem()) - } - placeholder={placeholder} - className={`flex-1 ${INPUT_CLASS_NAME}`} - /> - -
- {values.length > 0 && ( -
- {values.map((v) => ( - - {v} - - - ))} -
- )} - {hint ? ( -

{hint}

- ) : null} -
- ); -} - -function ConfigGuideCard({ - title, - steps, - note, -}: { - title: string; - steps: string[]; - note?: string; -}) { - return ( -
-

{title}

-
- {steps.map((step, index) => ( -
- - {index + 1} - -

{step}

-
- ))} -
- {note ? ( -

{note}

- ) : null} -
- ); -} - -function QrCodePreview({ - value, - className, -}: { - value: string; - className?: string; -}) { - const [dataUrl, setDataUrl] = useState(""); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - const trimmed = value.trim(); - - if (!trimmed) { - setDataUrl(""); - setError(null); - return; - } - - setError(null); - void QRCode.toDataURL(trimmed, { - width: 320, - margin: 1, - errorCorrectionLevel: "M", - }) - .then((next: string) => { - if (!cancelled) { - setDataUrl(next); - } - }) - .catch((reason: unknown) => { - if (!cancelled) { - setDataUrl(""); - setError(reason instanceof Error ? reason.message : String(reason)); - } - }); - - return () => { - cancelled = true; - }; - }, [value]); - - if (error) { - return ( -
- 二维码生成失败:{error} -
- ); - } - - if (!dataUrl) { - return ( -
- 正在生成二维码... -
- ); - } - - return ( - 微信扫码二维码 - ); -} - -function GatewayTunnelPanel({ - config, - onChange, - defaultFeishuAccountId, - onReloadConfig, -}: { - config: GatewayConfig; - onChange: (c: GatewayConfig) => void; - defaultFeishuAccountId?: string; - onReloadConfig: () => Promise; -}) { - const tunnel = config.tunnel ?? DEFAULT_GATEWAY.tunnel!; - const cloudflare = tunnel.cloudflare ?? {}; - const [busyAction, setBusyAction] = useState(null); - const [output, setOutput] = useState(""); - const [feishuAccountId, setFeishuAccountId] = useState( - defaultFeishuAccountId ?? "default", - ); - - const busy = busyAction !== null; - - const patchTunnel = ( - patch: Partial>, - ) => { - onChange({ - ...config, - tunnel: { - ...tunnel, - ...patch, - }, - }); - }; - - const patchCloudflare = ( - patch: Partial< - NonNullable["cloudflare"]> - >, - ) => { - patchTunnel({ - cloudflare: { - ...cloudflare, - ...patch, - }, - }); - }; - - const runAction = async ( - action: string, - executor: () => Promise, - ) => { - setBusyAction(action); - try { - const result = await executor(); - setOutput(JSON.stringify(result, null, 2)); - if (action === "create" || action === "sync") { - await onReloadConfig(); - } - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); - } finally { - setBusyAction(null); - } - }; - - return ( -
-
-

Gateway 公共隧道

-

- 全局 webhook 公网入口配置(供 Feishu/Telegram 等渠道复用) -

-
- -
- - - - -
- -
- - - -
- -
- - - -
- - patchCloudflare({ run_token: value || undefined })} - placeholder="cloudflared tunnel run --token ..." - /> - -
- - - patchCloudflare({ - credentials_file: event.target.value || undefined, - }) - } - placeholder="~/.cloudflared/.json" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
- -
- -
- -
- - - - - - - - - -
- - {busyAction && ( -
- 正在执行:{busyAction} -
- )} - -
-
最近结果
-
-          {output || "暂无结果"}
-        
-
-
- ); -} - -// ============================================================================ -// Telegram 表单 -// ============================================================================ - -function TelegramForm({ - config, - onChange, -}: { - config: TelegramBotConfig; - onChange: (c: TelegramBotConfig) => void; -}) { - return ( -
-
-
-

启用 Telegram Bot

-

- 开启后可通过 Telegram Bot 与 AI 对话 -

-
- -
- - onChange({ ...config, bot_token: v })} - placeholder="123456:ABC-DEF..." - hint={ - <> - 从{" "} - - @BotFather - {" "} - 获取 - - } - /> - - onChange({ ...config, allowed_user_ids: v })} - placeholder="输入 Telegram User ID" - hint="留空则允许所有用户" - /> - - onChange({ ...config, default_model: v })} - /> -
- ); -} - -function TelegramGatewayDebugPanel() { - const [busyAction, setBusyAction] = useState(null); - const [accountId, setAccountId] = useState("default"); - const [pollTimeoutSecs, setPollTimeoutSecs] = useState("25"); - const [output, setOutput] = useState(""); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - - const resolveAccountId = () => { - const normalized = accountId.trim(); - return normalized.length > 0 ? normalized : undefined; - }; - - const resolvePollTimeoutSecs = () => { - const parsed = Number.parseInt(pollTimeoutSecs.trim(), 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return undefined; - } - return parsed; - }; - - const runAction = async ( - action: string, - executor: () => Promise, - successText: string, - ) => { - setBusyAction(action); - setMessage(null); - try { - const result = await executor(); - setOutput(JSON.stringify(result, null, 2)); - setMessage({ type: "success", text: successText }); - setTimeout(() => setMessage(null), 2500); - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); - setMessage({ type: "error", text: `执行失败: ${text}` }); - } finally { - setBusyAction(null); - } - }; - - const busy = busyAction !== null; - - return ( -
-
-

Telegram Gateway 调试工具

-

- 用于快速执行启停、重启、状态查询和账号探测 -

-
- - {message && ( -
- {message.text} -
- )} - -
- - -
- -
- - - - - -
- - {busyAction && ( -
- 正在执行:{busyAction} -
- )} - -
-
最近结果
-
-          {output || "暂无结果"}
-        
-
-
- ); -} - -function FeishuGatewayDebugPanel() { - const [busyAction, setBusyAction] = useState(null); - const [accountId, setAccountId] = useState("default"); - const [output, setOutput] = useState(""); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - - const resolveAccountId = () => { - const normalized = accountId.trim(); - return normalized.length > 0 ? normalized : undefined; - }; - - const runAction = async ( - action: string, - executor: () => Promise, - successText: string, - ) => { - setBusyAction(action); - setMessage(null); - try { - const result = await executor(); - setOutput(JSON.stringify(result, null, 2)); - setMessage({ type: "success", text: successText }); - setTimeout(() => setMessage(null), 2500); - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); - setMessage({ type: "error", text: `执行失败: ${text}` }); - } finally { - setBusyAction(null); - } - }; - - const busy = busyAction !== null; - - return ( -
-
-

Feishu Gateway 调试工具

-

- 用于快速执行启停、重启、状态查询和账号探测 -

-
- - {message && ( -
- {message.text} -
- )} - -
- -
- -
- - - - - -
- - {busyAction && ( -
- 正在执行:{busyAction} -
- )} - -
-
最近结果
-
-          {output || "暂无结果"}
-        
-
-
- ); -} - -function DiscordGatewayDebugPanel() { - const [busyAction, setBusyAction] = useState(null); - const [accountId, setAccountId] = useState("default"); - const [output, setOutput] = useState(""); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - - const resolveAccountId = () => { - const normalized = accountId.trim(); - return normalized.length > 0 ? normalized : undefined; - }; - - const runAction = async ( - action: string, - executor: () => Promise, - successText: string, - ) => { - setBusyAction(action); - setMessage(null); - try { - const result = await executor(); - setOutput(JSON.stringify(result, null, 2)); - setMessage({ type: "success", text: successText }); - setTimeout(() => setMessage(null), 2500); - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); - setMessage({ type: "error", text: `执行失败: ${text}` }); - } finally { - setBusyAction(null); - } - }; - - const busy = busyAction !== null; - - return ( -
-
-

Discord Gateway 调试工具

-

- 用于快速执行启停、重启、状态查询和账号探测 -

-
- - {message && ( -
- {message.text} -
- )} - -
- -
- -
- - - - - -
- - {busyAction && ( -
- 正在执行:{busyAction} -
- )} - -
-
最近结果
-
-          {output || "暂无结果"}
-        
-
-
- ); -} - -function WechatGatewayDebugPanel({ - config, - onReloadConfig, -}: { - config: WechatBotConfig; - onReloadConfig: () => Promise; -}) { - const [busyAction, setBusyAction] = useState(null); - const [accountId, setAccountId] = useState( - config.default_account || "default", - ); - const [pollTimeoutSecs, setPollTimeoutSecs] = useState("25"); - const [baseUrl, setBaseUrl] = useState( - config.base_url || DEFAULT_WECHAT_BASE_URL, - ); - const [botType, setBotType] = useState(DEFAULT_WECHAT_BOT_TYPE); - const [loginSessionKey, setLoginSessionKey] = useState(""); - const [loginTimeoutMs, setLoginTimeoutMs] = useState("480000"); - const [accountName, setAccountName] = useState(""); - const [purgeDataOnRemove, setPurgeDataOnRemove] = useState(false); - const [accounts, setAccounts] = useState([]); - const [accountsLoaded, setAccountsLoaded] = useState(false); - const [qrCodeUrl, setQrCodeUrl] = useState(""); - const [runtimeStatus, setRuntimeStatus] = useState( - null, - ); - const [runtimeError, setRuntimeError] = useState(null); - const [output, setOutput] = useState(""); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - const autoWaitSessionKeyRef = useRef(null); - const loginAttemptStartedAtRef = useRef(null); - const loginAttemptAccountIdsRef = useRef>(new Set()); - - const runtimeAccountsById = useMemo(() => { - const map = new Map(); - for (const item of runtimeStatus?.accounts ?? []) { - map.set(item.accountId, item); - } - return map; - }, [runtimeStatus]); - - useEffect(() => { - const configuredIds = accounts.map((item) => item.accountId); - const normalized = accountId.trim(); - - if (!configuredIds.length) { - if (!normalized) { - setAccountId(config.default_account || "default"); - } - return; - } - - const preferred = - (config.default_account && configuredIds.includes(config.default_account) - ? config.default_account - : configuredIds[0]) || "default"; - - if ( - !normalized || - normalized === "default" || - !configuredIds.includes(normalized) - ) { - setAccountId(preferred); - } - }, [accounts, config.default_account, accountId]); - - useEffect(() => { - if (!baseUrl.trim()) { - setBaseUrl(config.base_url || DEFAULT_WECHAT_BASE_URL); - } - }, [config.base_url, baseUrl]); - - const loadAccounts = useCallback(async (writeOutput = false) => { - const result = await wechatChannelListAccounts(); - setAccounts(result); - setAccountsLoaded(true); - if (writeOutput) { - setOutput(JSON.stringify(result, null, 2)); - } - return result; - }, []); - - const loadRuntimeStatus = useCallback(async (writeOutput = false) => { - const result = await gatewayChannelStatus({ channel: "wechat" }); - const nextStatus = (result.status ?? null) as WechatGatewayStatus | null; - setRuntimeStatus(nextStatus); - setRuntimeError(null); - if (writeOutput) { - setOutput(JSON.stringify(result, null, 2)); - } - return result; - }, []); - - useEffect(() => { - void loadAccounts(false); - }, [loadAccounts]); - - useEffect(() => { - void loadRuntimeStatus(false); - }, [loadRuntimeStatus]); - - useEffect(() => { - let cancelled = false; - - const poll = async () => { - try { - const [nextAccounts, nextStatus] = await Promise.all([ - wechatChannelListAccounts(), - gatewayChannelStatus({ channel: "wechat" }), - ]); - if (cancelled) { - return; - } - setAccounts(nextAccounts); - setAccountsLoaded(true); - setRuntimeStatus((nextStatus.status ?? null) as WechatGatewayStatus | null); - setRuntimeError(null); - } catch (error) { - if (cancelled) { - return; - } - const text = error instanceof Error ? error.message : String(error); - setRuntimeError(text); - } - }; - - void poll(); - const timer = window.setInterval(() => { - void poll(); - }, WECHAT_RUNTIME_POLL_INTERVAL_MS); - - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, []); - - const resolveAccountId = () => { - const normalized = accountId.trim(); - return normalized.length > 0 ? normalized : undefined; - }; - - const resolvePollTimeoutSecs = useCallback(() => { - const parsed = Number.parseInt(pollTimeoutSecs.trim(), 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return undefined; - } - return parsed; - }, [pollTimeoutSecs]); - - const resolveBaseUrl = useCallback(() => { - const normalized = baseUrl.trim(); - return normalized.length > 0 ? normalized : undefined; - }, [baseUrl]); - - const resolveBotType = useCallback(() => { - const normalized = botType.trim(); - return normalized.length > 0 ? normalized : undefined; - }, [botType]); - - const resolveLoginTimeoutMs = useCallback(() => { - const parsed = Number.parseInt(loginTimeoutMs.trim(), 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return undefined; - } - return parsed; - }, [loginTimeoutMs]); - - const clearPendingLoginState = useCallback((nextAccountId?: string) => { - setQrCodeUrl(""); - setLoginSessionKey(""); - autoWaitSessionKeyRef.current = null; - loginAttemptStartedAtRef.current = null; - loginAttemptAccountIdsRef.current = new Set(); - if (nextAccountId?.trim()) { - setAccountId(nextAccountId.trim()); - } - }, []); - - const startLogin = useCallback(async () => { - loginAttemptStartedAtRef.current = Date.now(); - loginAttemptAccountIdsRef.current = new Set( - accounts.map((item) => item.accountId), - ); - const result = await wechatChannelLoginStart({ - baseUrl: resolveBaseUrl(), - botType: resolveBotType(), - sessionKey: loginSessionKey.trim() || undefined, - }); - const qrPayload = result.qrcodeUrl?.trim(); - if (!qrPayload) { - throw new Error(result.message || "微信接口未返回可用二维码"); - } - setLoginSessionKey(result.sessionKey); - setQrCodeUrl(qrPayload); - return result; - }, [accounts, loginSessionKey, resolveBaseUrl, resolveBotType]); - - const finalizeLoginSuccess = useCallback( - async (rawResult: unknown) => { - const result = normalizeWechatLoginWaitResult(rawResult); - if (!result.connected) { - return; - } - - const nextAccountId = result.accountId || accountId.trim() || "default"; - clearPendingLoginState(nextAccountId); - await loadAccounts(false); - await onReloadConfig(); - const startResult = await gatewayChannelStart({ - channel: "wechat", - accountId: nextAccountId, - pollTimeoutSecs: resolvePollTimeoutSecs(), - }); - setOutput( - JSON.stringify( - { - login: rawResult, - start: startResult, - }, - null, - 2, - ), - ); - }, - [ - accountId, - clearPendingLoginState, - loadAccounts, - onReloadConfig, - resolvePollTimeoutSecs, - ], - ); - - const waitForLoginResult = useCallback( - async (sessionKeyOverride?: string) => { - const sessionKey = (sessionKeyOverride || loginSessionKey).trim(); - if (!sessionKey) { - throw new Error("请先生成二维码,或填写已有 session_key"); - } - - return wechatChannelLoginWait({ - sessionKey, - baseUrl: resolveBaseUrl(), - botType: resolveBotType(), - timeoutMs: resolveLoginTimeoutMs(), - accountName: accountName.trim() || undefined, - }); - }, - [ - accountName, - loginSessionKey, - resolveBaseUrl, - resolveBotType, - resolveLoginTimeoutMs, - ], - ); - - const runAction = async ( - action: string, - executor: () => Promise, - successText: string, - afterSuccess?: (result: unknown) => Promise, - ) => { - setBusyAction(action); - setMessage(null); - try { - const result = await executor(); - setOutput(JSON.stringify(result, null, 2)); - if (afterSuccess) { - await afterSuccess(result); - } - setMessage({ type: "success", text: successText }); - setTimeout(() => setMessage(null), 2500); - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - setOutput(JSON.stringify({ action, ok: false, error: text }, null, 2)); - setMessage({ type: "error", text: `执行失败: ${text}` }); - } finally { - setBusyAction(null); - } - }; - - useEffect(() => { - if (!accountsLoaded || accounts.length > 0 || qrCodeUrl || busyAction) { - return; - } - - let cancelled = false; - - const bootstrap = async () => { - setBusyAction("login_start"); - setMessage(null); - try { - const result = await startLogin(); - if (cancelled) { - return; - } - setOutput(JSON.stringify(result, null, 2)); - setMessage({ - type: "success", - text: "已自动生成首张二维码,可直接扫码登录", - }); - setTimeout(() => setMessage(null), 2500); - } catch (error) { - if (cancelled) { - return; - } - const text = error instanceof Error ? error.message : String(error); - setOutput( - JSON.stringify( - { action: "login_start", ok: false, error: text }, - null, - 2, - ), - ); - setMessage({ type: "error", text: `二维码生成失败: ${text}` }); - } finally { - if (!cancelled) { - setBusyAction(null); - } - } - }; - - void bootstrap(); - - return () => { - cancelled = true; - }; - }, [accounts.length, accountsLoaded, busyAction, qrCodeUrl, startLogin]); - - useEffect(() => { - const sessionKey = loginSessionKey.trim(); - if (!qrCodeUrl || !sessionKey) { - return; - } - if (autoWaitSessionKeyRef.current === sessionKey) { - return; - } - - autoWaitSessionKeyRef.current = sessionKey; - let cancelled = false; - - const autoWait = async () => { - setBusyAction("login_wait"); - setMessage({ - type: "success", - text: "二维码已生成,正在后台等待扫码确认", - }); - try { - const result = await waitForLoginResult(sessionKey); - if (cancelled) { - return; - } - setOutput(JSON.stringify(result, null, 2)); - await finalizeLoginSuccess(result); - const normalizedResult = normalizeWechatLoginWaitResult(result); - if (normalizedResult.connected) { - setMessage({ - type: "success", - text: "微信登录成功,网关已自动启动", - }); - setTimeout(() => setMessage(null), 2500); - } else { - setMessage({ - type: "error", - text: normalizedResult.message || "微信登录未完成", - }); - } - } catch (error) { - if (cancelled) { - return; - } - const text = error instanceof Error ? error.message : String(error); - setOutput( - JSON.stringify( - { action: "login_wait", ok: false, error: text }, - null, - 2, - ), - ); - setMessage({ type: "error", text: `等待登录失败: ${text}` }); - } finally { - if (!cancelled) { - setBusyAction(null); - } - if (autoWaitSessionKeyRef.current === sessionKey) { - autoWaitSessionKeyRef.current = null; - } - } - }; - - void autoWait(); - - return () => { - cancelled = true; - }; - }, [ - finalizeLoginSuccess, - loginSessionKey, - qrCodeUrl, - waitForLoginResult, - ]); - - useEffect(() => { - const sessionKey = loginSessionKey.trim(); - const loginAttemptStartedAt = loginAttemptStartedAtRef.current; - if (!qrCodeUrl || !sessionKey || !loginAttemptStartedAt) { - return; - } - - const knownAccountIds = loginAttemptAccountIdsRef.current; - const detectedAccountId = accounts.find( - (item) => !knownAccountIds.has(item.accountId), - )?.accountId; - const normalizedTargetAccountId = accountId.trim(); - const hasFreshRuntimeAccount = (runtimeStatus?.accounts ?? []).some( - (item) => { - if (!item.running) { - return false; - } - if ( - normalizedTargetAccountId && - normalizedTargetAccountId !== "default" && - item.accountId !== normalizedTargetAccountId - ) { - return false; - } - if (!item.startedAt) { - return false; - } - const startedAt = Date.parse(item.startedAt); - return Number.isFinite(startedAt) && startedAt >= loginAttemptStartedAt - 5_000; - }, - ); - - if (!detectedAccountId && !hasFreshRuntimeAccount) { - return; - } - - clearPendingLoginState(detectedAccountId); - void onReloadConfig(); - }, [ - accountId, - accounts, - clearPendingLoginState, - loginSessionKey, - onReloadConfig, - qrCodeUrl, - runtimeStatus, - ]); - - const handleRemoveAccount = async (targetAccountId: string) => { - const normalized = targetAccountId.trim(); - if (!normalized) { - return; - } - - const confirmed = window.confirm( - [ - "⚠️ 危险操作检测!", - `操作类型:删除微信账号 ${normalized}${purgeDataOnRemove ? " 并清理本地数据" : ""}`, - `影响范围:将停止该账号的微信网关实例,并从渠道配置中移除该账号${purgeDataOnRemove ? ",同时删除本地缓存与同步状态" : ""}`, - `风险评估:删除后需要重新扫码或手动恢复配置${purgeDataOnRemove ? ",本地数据不可恢复" : ""}`, - "", - "请确认是否继续?", - ].join("\n"), - ); - if (!confirmed) { - return; - } - - await runAction( - "remove_account", - async () => { - await wechatChannelRemoveAccount({ - accountId: normalized, - purgeData: purgeDataOnRemove, - }); - return { - ok: true, - accountId: normalized, - purgeData: purgeDataOnRemove, - }; - }, - "微信账号已删除", - async () => { - await loadAccounts(true); - await onReloadConfig(); - setAccountId((current) => - current.trim() === normalized - ? config.default_account || "default" - : current, - ); - }, - ); - }; - - const busy = busyAction !== null; - - return ( -
-
-

微信 Gateway 调试工具

-

- 支持扫码登录、账号探测、启停控制与已配置账号清理。 -

-
- - {message && ( -
- {message.text} -
- )} - -
-
-

运行控制

-

- 账号 ID 留空时按默认配置解析;多账号场景建议显式指定。 -

-
- -
- - - -
- -
- - - - - - -
-
- -
-
-

扫码登录

-

- 先生成二维码,再等待登录完成。成功后账号会自动写回当前配置。 -

-
- -
- - - - -
- -
- - -
- - {qrCodeUrl ? ( -
-
-
- -
-
-

当前二维码已就绪

-

- 请使用微信扫码并在手机上确认连接。当前页面会自动等待登录完成并写回配置。 -

-

- 当前页面会把微信返回的二维码内容本地转码显示,不依赖远程图片加载。 -

-

- session_key:{loginSessionKey || "未生成"} -

-
-
-
- ) : ( -
-

当前还没有可扫码二维码

-

- 请先点击“生成二维码”。如果接口没有返回二维码内容,下面的“最近结果”会直接显示错误原因。 -

-
- )} -
- -
-
-
-

已配置账号

-

- 这里展示扫码写入或手工配置后实际可见的微信账号目录。 -

-
- -
- -
- - 运行中账号:{runtimeStatus?.running_accounts ?? 0} - - - 已配置账号:{accounts.length} - - {runtimeError ? ( - - 状态轮询失败:{runtimeError} - - ) : null} -
- - {accounts.length > 0 ? ( -
- {accounts.map((item) => { - const runtime = runtimeAccountsById.get(item.accountId); - return ( -
-
-
-
-

- {item.name || item.accountId} -

- - {item.accountId} - - - {item.enabled ? "已启用" : "已禁用"} - - - {runtime?.running ? "运行中" : "未运行"} - -
-
-

Token:{item.hasToken ? "已保存" : "未保存"}

-

- Base URL: - {item.baseUrl || - config.base_url || - DEFAULT_WECHAT_BASE_URL} -

-

- CDN URL: - {item.cdnBaseUrl || - config.cdn_base_url || - DEFAULT_WECHAT_CDN_BASE_URL} -

-

扫码用户 ID:{item.scannerUserId || "未记录"}

-

- 最近轮询: - {formatRuntimeTimestamp(runtime?.lastUpdateAt)} -

-

- 最近消息: - {formatRuntimeTimestamp(runtime?.lastMessageAt)} -

-

- Sync Buf: - {runtime?.syncBufPresent ? "已存在" : "暂无"} -

- {runtime?.lastError ? ( -

- 最近错误:{runtime.lastError} -

- ) : null} -
-
- -
- - -
-
-
- ); - })} -
- ) : ( -

- 还没有微信账号。可以先去“扫码登录”生成二维码,成功后这里会自动出现。 -

- )} -
- - {busyAction && ( -
- 正在执行:{busyAction} -
- )} - -
-
最近结果
-
-          {output || "暂无结果"}
-        
-
-
- ); -} - -// ============================================================================ -// Discord 表单 -// ============================================================================ - -function DiscordForm({ - config, - onChange, -}: { - config: DiscordBotConfig; - onChange: (c: DiscordBotConfig) => void; -}) { - const accountIds = useMemo( - () => Object.keys(config.accounts ?? {}), - [config.accounts], - ); - const [activeAccountId, setActiveAccountId] = useState( - config.default_account || accountIds[0] || "default", - ); - - useEffect(() => { - if (!accountIds.length) { - setActiveAccountId(config.default_account || "default"); - return; - } - if (!accountIds.includes(activeAccountId)) { - setActiveAccountId(config.default_account || accountIds[0]); - } - }, [accountIds, config.default_account, activeAccountId]); - - const patch = (next: Partial) => { - onChange({ ...config, ...next }); - }; - - const patchAccount = ( - accountId: string, - updater: ( - current: NonNullable[string], - ) => NonNullable[string], - ) => { - const baseAccounts = config.accounts ?? {}; - const current = baseAccounts[accountId] ?? { enabled: true }; - patch({ - accounts: { - ...baseAccounts, - [accountId]: updater(current), - }, - }); - }; - - const addAccount = () => { - const baseAccounts = config.accounts ?? {}; - let index = 1; - let accountId = "account_1"; - while (baseAccounts[accountId]) { - index += 1; - accountId = `account_${index}`; - } - const nextAccounts = { - ...baseAccounts, - [accountId]: { - enabled: true, - name: `Discord ${index}`, - }, - }; - patch({ - accounts: nextAccounts, - default_account: config.default_account || accountId, - }); - setActiveAccountId(accountId); - }; - - const removeAccount = (accountId: string) => { - const baseAccounts = { ...(config.accounts ?? {}) }; - delete baseAccounts[accountId]; - const nextIds = Object.keys(baseAccounts); - patch({ - accounts: baseAccounts, - default_account: - config.default_account === accountId - ? nextIds[0] || "default" - : config.default_account, - }); - setActiveAccountId(nextIds[0] || "default"); - }; - - const currentAccount = (config.accounts ?? {})[activeAccountId]; - const intents = config.intents ?? {}; - const actions = config.actions ?? {}; - const threadBindings = config.thread_bindings ?? {}; - const autoPresence = config.auto_presence ?? {}; - const dmPolicy = (config.dm_policy || "pairing").toLowerCase(); - const groupPolicy = (config.group_policy || "allowlist").toLowerCase(); - const streamingMode = (config.streaming || "partial").toLowerCase(); - const replyToMode = (config.reply_to_mode || "off").toLowerCase(); - - return ( -
-
-
-

启用 Discord Bot

-

- 开启后可通过 Discord Bot 与 AI 对话 -

-
- -
- - patch({ bot_token: v })} - placeholder="MTIz..." - hint={ - <> - 从{" "} - - Discord Developer Portal - {" "} - 获取 - - } - /> - - patch({ allowed_server_ids: v })} - placeholder="输入 Discord Server ID" - hint="留空则允许所有服务器" - /> - -
- - - patch({ default_account: e.target.value || undefined }) - } - placeholder="default" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
- -
-
-
-

多账号配置

-

- 支持账号级 token/模型/服务器范围覆盖 -

-
- -
- - {accountIds.length > 0 ? ( - <> -
- {accountIds.map((accountId) => ( - - ))} -
- - {currentAccount && ( -
-
- - -
- -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - name: e.target.value || undefined, - })) - } - placeholder="运营号 / 机器人 1" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm" - /> -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - default_model: v, - })) - } - /> -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - bot_token: v || undefined, - })) - } - placeholder="MTIz..." - /> - - - patchAccount(activeAccountId, (item) => ({ - ...item, - allowed_server_ids: v, - })) - } - placeholder="输入 Discord Server ID" - /> -
- )} - - ) : ( -

- 未配置账号,将使用全局 Bot Token 运行。 -

- )} -
- -
-
- - -
-
- - -
-
- - patch({ allow_from: v })} - placeholder="用户 ID 或 *" - /> - - patch({ group_allow_from: v })} - placeholder="用户 ID 或 *" - /> - -
-
- - -
-
- - -
-
- -
-

高级能力开关

-
- - - - - - -
-
- - patch({ default_model: v })} - /> -
- ); -} - -function WechatForm({ - config, - onChange, -}: { - config: WechatBotConfig; - onChange: (c: WechatBotConfig) => void; -}) { - const accountIds = useMemo( - () => Object.keys(config.accounts ?? {}), - [config.accounts], - ); - const [activeAccountId, setActiveAccountId] = useState( - config.default_account || accountIds[0] || "default", - ); - - useEffect(() => { - if (!accountIds.length) { - setActiveAccountId(config.default_account || "default"); - return; - } - if (!accountIds.includes(activeAccountId)) { - setActiveAccountId(config.default_account || accountIds[0]); - } - }, [accountIds, config.default_account, activeAccountId]); - - const patch = (next: Partial) => { - onChange({ ...config, ...next }); - }; - - const patchAccount = ( - accountId: string, - updater: ( - current: NonNullable[string], - ) => NonNullable[string], - ) => { - const baseAccounts = config.accounts ?? {}; - const current = baseAccounts[accountId] ?? { enabled: true }; - patch({ - accounts: { - ...baseAccounts, - [accountId]: updater(current), - }, - }); - }; - - const addAccount = () => { - const baseAccounts = config.accounts ?? {}; - let index = 1; - let accountId = "wechat_1"; - while (baseAccounts[accountId]) { - index += 1; - accountId = `wechat_${index}`; - } - const nextAccounts = { - ...baseAccounts, - [accountId]: { - enabled: true, - name: `微信账号 ${index}`, - }, - }; - patch({ - accounts: nextAccounts, - default_account: config.default_account || accountId, - }); - setActiveAccountId(accountId); - }; - - const removeAccount = (accountId: string) => { - const baseAccounts = { ...(config.accounts ?? {}) }; - delete baseAccounts[accountId]; - const nextIds = Object.keys(baseAccounts); - patch({ - accounts: baseAccounts, - default_account: - config.default_account === accountId - ? nextIds[0] || "default" - : config.default_account, - }); - setActiveAccountId(nextIds[0] || "default"); - }; - - const currentAccount = (config.accounts ?? {})[activeAccountId]; - const dmPolicy = (() => { - const value = (config.dm_policy || "pairing").toLowerCase(); - if ( - value === "pairing" || - value === "allowlist" || - value === "open" || - value === "disabled" - ) { - return value; - } - return "pairing"; - })(); - const groupPolicy = (() => { - const value = (config.group_policy || "allowlist").toLowerCase(); - if (value === "allowlist" || value === "open" || value === "disabled") { - return value; - } - return "allowlist"; - })(); - const streamingMode = (() => { - const value = (config.streaming || "off").toLowerCase(); - if (value === "partial" || value === "block" || value === "off") { - return value; - } - return "off"; - })(); - const replyToMode = (() => { - const value = (config.reply_to_mode || "off").toLowerCase(); - if (value === "first" || value === "all" || value === "off") { - return value; - } - return "off"; - })(); - - return ( -
-
-
-

启用微信 Bot

-

- 开启后可通过企业微信客服通道接收消息,并转发给 Lime Agent。 -

-
- -
- - patch({ bot_token: value })} - placeholder="扫码成功后可自动回填,也支持手工填写" - hint="已有多账号时可留空;旧单账号模式会继续兼容。" - /> - -
-
- - patch({ base_url: event.target.value })} - placeholder={DEFAULT_WECHAT_BASE_URL} - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- - patch({ cdn_base_url: event.target.value })} - placeholder={DEFAULT_WECHAT_CDN_BASE_URL} - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- -
-
- - - patch({ - scanner_user_id: event.target.value.trim() || undefined, - }) - } - placeholder="扫码绑定后的 user_id" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- - - patch({ default_account: event.target.value.trim() || undefined }) - } - placeholder="default" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- -
-
-
-

多账号配置

-

- 扫码登录成功后账号会自动落到这里,也支持手工补录连接参数。 -

-
- -
- - {accountIds.length > 0 ? ( - <> -
- {accountIds.map((accountId) => ( - - ))} -
- - {currentAccount && ( -
-
- - -
- -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - name: event.target.value || undefined, - })) - } - placeholder="主微信 / 运营号" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm" - /> -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - default_model: value, - })) - } - /> -
-
- -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - base_url: event.target.value.trim() || undefined, - })) - } - placeholder={DEFAULT_WECHAT_BASE_URL} - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono" - /> -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - cdn_base_url: event.target.value.trim() || undefined, - })) - } - placeholder={DEFAULT_WECHAT_CDN_BASE_URL} - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono" - /> -
-
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - bot_token: value || undefined, - })) - } - placeholder="扫码成功后回填的 token" - /> - -
- - - patchAccount(activeAccountId, (item) => ({ - ...item, - scanner_user_id: event.target.value.trim() || undefined, - })) - } - placeholder="扫码后的微信 user_id" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono" - /> -
-
- )} - - ) : ( -

- 未配置账号时,将回退使用上方的全局兼容参数。 -

- )} -
- -
-
- - -
-
- - -
-
- - patch({ allow_from: value })} - placeholder="用户 ID 或 *" - hint="扫码用户通常会自动放行;生产环境建议按 allowlist/pairing 收紧。" - /> - - patch({ group_allow_from: value })} - placeholder="用户 ID 或 *" - /> - -
-
- - -
-
- - -
-
- - patch({ default_model: value })} - /> -
- ); -} - -// ============================================================================ -// 飞书表单 -// ============================================================================ - -function FeishuForm({ - config, - onChange, -}: { - config: FeishuBotConfig; - onChange: (c: FeishuBotConfig) => void; -}) { - const connectionMode = - (config.connection_mode || "websocket").toLowerCase() === "webhook" - ? "webhook" - : "websocket"; - const dmPolicy = (() => { - const value = (config.dm_policy || "open").toLowerCase(); - if ( - value === "open" || - value === "allowlist" || - value === "pairing" || - value === "disabled" - ) { - return value; - } - return "open"; - })(); - const streamingMode = (config.streaming || "partial").toLowerCase(); - const replyToMode = (config.reply_to_mode || "off").toLowerCase(); - - return ( -
-
-
-

启用飞书 Bot

-

- 开启后可通过飞书 Bot 与 AI 对话 -

-
- -
- -
- - onChange({ ...config, app_id: e.target.value })} - placeholder="cli_xxxx" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
- - onChange({ ...config, app_secret: v })} - placeholder="飞书应用的 App Secret" - /> - -
- - - onChange({ - ...config, - verification_token: e.target.value || undefined, - }) - } - placeholder="事件订阅验证 Token" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
- - onChange({ ...config, encrypt_key: v || undefined })} - placeholder="事件加密密钥" - /> - -
- - - {connectionMode === "websocket" && ( -

- 当前版本 WebSocket 模式尚未实装,请使用 Webhook - 模式接入飞书事件回调。 -

- )} -
- - {connectionMode === "webhook" && ( -
-
- - - onChange({ - ...config, - webhook_host: e.target.value.trim() || undefined, - }) - } - placeholder="0.0.0.0" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- - { - const value = Number.parseInt(e.target.value, 10); - onChange({ - ...config, - webhook_port: - Number.isFinite(value) && value > 0 ? value : undefined, - }); - }} - placeholder="3000" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- - - onChange({ - ...config, - webhook_path: e.target.value.trim() || undefined, - }) - } - placeholder="/feishu/default" - className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - /> -
-
- )} - -
-
- - -

- 建议联调使用 open;生产可切换为 allowlist / pairing。 -

-
- onChange({ ...config, allow_from: v })} - placeholder="输入 open_id / user_id,或 *" - hint="open 策略建议至少包含 *;allowlist/pairing 需填写具体用户。" - /> -
- -
-
- - -
-
- - -
-
- - onChange({ ...config, default_model: v })} - /> -
- ); -} - -// ============================================================================ -// 主组件 -// ============================================================================ - -export interface ChannelsSettingsProps { - className?: string; -} - -export function ChannelsSettings({ className }: ChannelsSettingsProps) { - const [activeTab, setActiveTab] = useState("telegram"); - const [activeSubPage, setActiveSubPage] = - useState("overview"); - const [activeDebugTab, setActiveDebugTab] = useState("telegram"); - const [config, setConfig] = useState(null); - const [channels, setChannels] = useState(DEFAULT_CHANNELS); - const [gateway, setGateway] = useState(DEFAULT_GATEWAY); - const [initialJson, setInitialJson] = useState(""); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - - const isDirty = useMemo( - () => JSON.stringify({ channels, gateway }) !== initialJson, - [channels, gateway, initialJson], - ); - - const loadConfig = useCallback(async () => { - try { - const c = await getConfig(); - const normalizedChannels = normalizeChannelsConfig(c.channels); - const normalizedGateway = normalizeGatewayConfig(c.gateway); - setConfig(c); - setChannels(normalizedChannels); - setGateway(normalizedGateway); - setInitialJson( - JSON.stringify({ - channels: normalizedChannels, - gateway: normalizedGateway, - }), - ); - } catch (e) { - console.error("加载配置失败", e); - } - }, []); - - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - const handleSave = async () => { - if (!config) return; - setSaving(true); - setMessage(null); - try { - await saveConfig({ ...config, channels, gateway }); - setInitialJson( - JSON.stringify({ - channels, - gateway, - }), - ); - setMessage({ type: "success", text: "渠道配置已保存" }); - setTimeout(() => setMessage(null), 3000); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - setMessage({ type: "error", text: `保存失败: ${msg}` }); - } - setSaving(false); - }; - - const handleCancel = () => { - if (initialJson) { - const snapshot = JSON.parse(initialJson) as { - channels: ChannelsConfig; - gateway: GatewayConfig; - }; - setChannels(normalizeChannelsConfig(snapshot.channels)); - setGateway(normalizeGatewayConfig(snapshot.gateway)); - } - }; - - if (!config) { - return ; - } - - const TAB_LABELS: Record = { - telegram: "Telegram", - discord: "Discord", - feishu: "飞书", - wechat: "微信", - }; - const SUB_PAGE_LABELS: Record = { - overview: "概览", - config: "渠道配置", - gateway: "网关与隧道", - logs: "日志与调试", - }; - - const channelOverview: Array<{ - key: TabKey; - label: string; - enabled: boolean; - model: string; - }> = [ - { - key: "telegram", - label: "Telegram", - enabled: channels.telegram.enabled, - model: channels.telegram.default_model || "跟随全局默认", - }, - { - key: "discord", - label: "Discord", - enabled: channels.discord.enabled, - model: channels.discord.default_model || "跟随全局默认", - }, - { - key: "feishu", - label: "飞书", - enabled: channels.feishu.enabled, - model: channels.feishu.default_model || "跟随全局默认", - }, - { - key: "wechat", - label: "微信", - enabled: channels.wechat.enabled, - model: channels.wechat.default_model || "跟随全局默认", - }, - ]; - - const subPages: Array<{ - key: ChannelSubPage; - label: string; - description: string; - icon: React.ComponentType<{ className?: string }>; - }> = [ - { - key: "overview", - label: "概览", - description: "查看渠道状态和默认模型", - icon: LayoutDashboard, - }, - { - key: "config", - label: "渠道配置", - description: "维护 Telegram / Discord / 飞书 / 微信基础参数", - icon: SlidersHorizontal, - }, - { - key: "gateway", - label: "网关与隧道", - description: "管理公网入口与回调同步", - icon: Network, - }, - { - key: "logs", - label: "日志与调试", - description: "tail 观察与渠道调试操作", - icon: ScrollText, - }, - ]; - - const enabledCount = channelOverview.filter((item) => item.enabled).length; - const configuredModelCount = channelOverview.filter( - (item) => item.model !== "跟随全局默认", - ).length; - const tunnelEnabled = gateway.tunnel?.enabled === true; - const currentScopeLabel = - activeSubPage === "config" - ? `${SUB_PAGE_LABELS[activeSubPage]} / ${TAB_LABELS[activeTab]}` - : SUB_PAGE_LABELS[activeSubPage]; - - return ( -
- {message && ( -
- - {message.text} -
- )} - -
-
-
- -
-
-
- - CHANNEL CONTROL - -
-

- 把渠道配置、公网入口和运行调试收拢到一个操作台里 -

-

- 这里按“概览、配置、网关、日志”组织渠道工作流,减少在多个零散表单之间来回切换。 - 保存逻辑保持不变,改动后仍然通过底部操作条统一提交。 -

-
- -
- - Telegram / Discord / 飞书 / 微信共用同一页治理流程 - - - 先配渠道,再接公网入口,最后看日志与调试 - - - 当前视图:{currentScopeLabel} - -
-
- -
- - - -
-
- -
-
-
- - {isDirty ? "存在未保存变更" : "配置已同步"} - - - 网关隧道 {tunnelEnabled ? "开启" : "关闭"} - - - 当前子页 {SUB_PAGE_LABELS[activeSubPage]} - -
-

- {isDirty - ? "你已经修改了当前渠道配置,底部操作条会保留“取消”和“保存”。" - : "建议先在概览确认启用状态,再进入配置或网关分区做细节调整。"} -

-
-
-
-
- - -
- {subPages.map((page) => { - const Icon = page.icon; - const isActive = activeSubPage === page.key; - return ( - - ); - })} -
-
- - {activeSubPage === "overview" && ( -
- - 已启用 {enabledCount} / {channelOverview.length} - - } - > -
- {channelOverview.map((item) => ( - - ))} -
-
- -
- -
-
-

- 先完成渠道基础配置 -

-

- 填好 Token、App Secret、白名单和默认模型,确保基础参数完整。 -

-
-
-

- 再检查公网入口 -

-

- 需要外部回调时,再进入“网关与隧道”处理 tunnel、webhook - 和回调同步。 -

-
-
-

- 最后看日志与调试 -

-

- 通过状态查询、探测账号和实时日志判断链路是否打通,而不是盲改表单字段。 -

-
-
-
- - -
-
-
-

- 公网隧道 -

- - {tunnelEnabled ? "已启用" : "未启用"} - -
-
-
-
-

- 未保存更改 -

- - {isDirty ? "待处理" : "无"} - -
-
-
-
-
-
- )} - - {activeSubPage === "config" && ( -
- - 当前渠道:{TAB_LABELS[activeTab]} - - } - > -
- - - setActiveTab(v as TabKey)} - className="w-full" - > - - - Telegram - - - Discord - - - 飞书 - - - 微信 - - - - - - - setChannels({ ...channels, telegram: tg }) - } - /> - - - - - setChannels({ ...channels, discord: dc })} - /> - - - - - setChannels({ ...channels, feishu: fs })} - /> - - - - - - setChannels({ ...channels, wechat: wc })} - /> - - -
-
- -
- -
- {channelOverview.map((item) => ( - - ))} -
-
- - -
-
-

- 默认模型不要留到最后 -

-

- 四个渠道的默认模型建议尽早设置,避免调试时误落到全局默认。 -

-
-
-

- 凭证填写后再切网关 -

-

- 先让 Bot - 能本地正常工作,再处理公网隧道与回调同步,排障路径更短。 -

-
-
-
-
-
- )} - - {activeSubPage === "gateway" && ( -
- - {tunnelEnabled ? "隧道已启用" : "隧道未启用"} - - } - > -
- - -
-
- - -
-
-

本地入口

-

- {gateway.tunnel?.local_host || "127.0.0.1"}: - {gateway.tunnel?.local_port ?? 3000} -

-
-
-

- Tunnel Provider / 模式 -

-

- {gateway.tunnel?.provider || "cloudflare"} /{" "} - {gateway.tunnel?.mode || "managed"} -

-
-
-

- 飞书默认账号 -

-

- {channels.feishu.default_account || "default"} -

-
-
-
-
- )} - - {activeSubPage === "logs" && ( -
- -
- - -
-
- - - setActiveDebugTab(v as DebugTabKey)} - className="w-full" - > - - - Telegram - - - 飞书 - - - Discord - - - 微信 - - - - - - - - - - - - - - - - - - - - -
- )} - - {/* 底部固定栏 */} - {isDirty && ( -
-
- - 未保存的更改 - - {currentScopeLabel} - -
-
- - -
-
- )} -
- ); -} - -export default ChannelsSettings; diff --git a/src/components/settings-v2/system/chrome-relay/index.test.tsx b/src/components/settings-v2/system/chrome-relay/index.test.tsx index 1cf58f3e7..e6e1b63db 100644 --- a/src/components/settings-v2/system/chrome-relay/index.test.tsx +++ b/src/components/settings-v2/system/chrome-relay/index.test.tsx @@ -11,7 +11,9 @@ const { mockInstallBrowserConnectorExtension, mockSetBrowserConnectorEnabled, mockSetSystemConnectorEnabled, + mockSetBrowserActionCapabilityEnabled, mockOpenBrowserExtensionsPage, + mockOpenBrowserRemoteDebuggingPage, mockLaunchBrowserSession, mockOpenBrowserRuntimeDebuggerWindow, mockGetChromeProfileSessions, @@ -29,7 +31,9 @@ const { mockInstallBrowserConnectorExtension: vi.fn(), mockSetBrowserConnectorEnabled: vi.fn(), mockSetSystemConnectorEnabled: vi.fn(), + mockSetBrowserActionCapabilityEnabled: vi.fn(), mockOpenBrowserExtensionsPage: vi.fn(), + mockOpenBrowserRemoteDebuggingPage: vi.fn(), mockLaunchBrowserSession: vi.fn(), mockOpenBrowserRuntimeDebuggerWindow: vi.fn(), mockGetChromeProfileSessions: vi.fn(), @@ -62,7 +66,9 @@ vi.mock("@/lib/webview-api", async () => { installBrowserConnectorExtension: mockInstallBrowserConnectorExtension, setBrowserConnectorEnabled: mockSetBrowserConnectorEnabled, setSystemConnectorEnabled: mockSetSystemConnectorEnabled, + setBrowserActionCapabilityEnabled: mockSetBrowserActionCapabilityEnabled, openBrowserExtensionsPage: mockOpenBrowserExtensionsPage, + openBrowserRemoteDebuggingPage: mockOpenBrowserRemoteDebuggingPage, launchBrowserSession: mockLaunchBrowserSession, openBrowserRuntimeDebuggerWindow: mockOpenBrowserRuntimeDebuggerWindow, getChromeProfileSessions: mockGetChromeProfileSessions, @@ -88,6 +94,36 @@ interface Mounted { const mounted: Mounted[] = []; const mockWriteClipboardText = vi.fn(); +const mockBrowserActionCapabilities = [ + { + key: "read_page", + label: "页面快照", + description: "抓取当前页面快照。", + group: "read", + enabled: true, + }, + { + key: "find", + label: "页面内查找", + description: "在当前页面中查找文本。", + group: "read", + enabled: true, + }, + { + key: "navigate", + label: "导航", + description: "导航到目标地址。", + group: "write", + enabled: true, + }, + { + key: "click", + label: "点击元素", + description: "点击页面元素。", + group: "write", + enabled: true, + }, +] as const; function renderComponent() { const container = document.createElement("div"); @@ -154,6 +190,9 @@ beforeEach(() => { enabled: true, install_root_dir: null, install_dir: null, + browser_action_capabilities: mockBrowserActionCapabilities.map( + (capability) => ({ ...capability }), + ), system_connectors: [ { id: "calendar", @@ -172,6 +211,9 @@ beforeEach(() => { enabled: true, install_root_dir: "/Users/test/connectors", install_dir: "/Users/test/connectors/Lime Browser Connector", + browser_action_capabilities: mockBrowserActionCapabilities.map( + (capability) => ({ ...capability }), + ), system_connectors: [ { id: "calendar", @@ -209,6 +251,9 @@ beforeEach(() => { enabled: false, install_root_dir: null, install_dir: null, + browser_action_capabilities: mockBrowserActionCapabilities.map( + (capability) => ({ ...capability }), + ), system_connectors: [ { id: "calendar", @@ -227,6 +272,57 @@ beforeEach(() => { enabled: true, install_root_dir: null, install_dir: null, + browser_action_capabilities: mockBrowserActionCapabilities.map( + (capability) => ({ ...capability }), + ), + system_connectors: [ + { + id: "calendar", + label: "日历", + description: "读取和管理你的日历事件。", + enabled: true, + available: true, + visible: true, + authorization_status: "authorized", + last_error: null, + capabilities: ["list_events", "create_event", "update_event"], + }, + ], + }); + mockSetBrowserActionCapabilityEnabled.mockResolvedValue({ + enabled: true, + install_root_dir: null, + install_dir: null, + browser_action_capabilities: [ + { + key: "read_page", + label: "页面快照", + description: "抓取当前页面快照。", + group: "read", + enabled: true, + }, + { + key: "find", + label: "页面内查找", + description: "在当前页面中查找文本。", + group: "read", + enabled: false, + }, + { + key: "navigate", + label: "导航", + description: "导航到目标地址。", + group: "write", + enabled: true, + }, + { + key: "click", + label: "点击元素", + description: "点击页面元素。", + group: "write", + enabled: true, + }, + ], system_connectors: [ { id: "calendar", @@ -242,6 +338,7 @@ beforeEach(() => { ], }); mockOpenBrowserExtensionsPage.mockResolvedValue(true); + mockOpenBrowserRemoteDebuggingPage.mockResolvedValue(true); mockLaunchBrowserSession.mockResolvedValue({ profile: { success: true, @@ -423,6 +520,31 @@ describe("ChromeRelaySettings", () => { expect(container.textContent).toContain("已打开独立浏览器调试窗口"); }); + it("应提供扩展与远程调试引导入口", async () => { + const container = renderComponent(); + await flushEffects(); + + expect(container.textContent).toContain("连接方式"); + expect(container.textContent).toContain("浏览器扩展"); + expect(container.textContent).toContain("CDP 直连"); + + const extensionButton = findButton(container, "打开扩展页"); + await act(async () => { + extensionButton.click(); + await flushEffects(); + }); + + expect(mockOpenBrowserExtensionsPage).toHaveBeenCalledTimes(1); + + const remoteDebuggingButton = findButton(container, "打开远程调试页"); + await act(async () => { + remoteDebuggingButton.click(); + await flushEffects(); + }); + + expect(mockOpenBrowserRemoteDebuggingPage).toHaveBeenCalledTimes(1); + }); + it("扩展已连接时应允许断开当前连接", async () => { mockGetChromeBridgeStatus.mockResolvedValueOnce({ observer_count: 1, @@ -447,6 +569,8 @@ describe("ChromeRelaySettings", () => { const container = renderComponent(); await flushEffects(); + expect(container.textContent).toContain("控制已接入 1"); + const button = findButton(container, "断开已连接扩展"); await act(async () => { button.click(); @@ -457,6 +581,79 @@ describe("ChromeRelaySettings", () => { expect(container.textContent).toContain("已断开 1 个扩展观察连接和 1 个控制连接"); }); + it("应按扩展桥接真实能力渲染动作清单", async () => { + mockGetBrowserBackendsStatus.mockResolvedValueOnce({ + policy: { + priority: ["aster_compat", "lime_extension_bridge", "cdp_direct"], + auto_fallback: true, + }, + bridge_observer_count: 1, + bridge_control_count: 1, + running_profile_count: 1, + cdp_alive_profile_count: 1, + aster_native_host_supported: true, + aster_native_host_configured: false, + backends: [ + { + backend: "lime_extension_bridge", + available: true, + capabilities: [ + "navigate", + "read_page", + "get_page_text", + "find", + "form_input", + "tabs_context_mcp", + "open_url", + "click", + "type", + "scroll", + "scroll_page", + "get_page_info", + "refresh_page", + "go_back", + "go_forward", + "switch_tab", + "list_tabs", + ], + }, + ], + }); + + const container = renderComponent(); + await flushEffects(); + + expect(container.textContent).toContain("页面内查找"); + expect(container.textContent).toContain("页面文本"); + expect(container.textContent).toContain("表单输入"); + expect(container.textContent).toContain("返回上一页"); + expect(container.textContent).not.toContain("悬停"); + expect(container.textContent).not.toContain("拖放"); + expect(container.textContent).not.toContain("上传文件"); + expect(container.textContent).not.toContain("处理弹窗"); + }); + + it("应允许切换浏览器动作配置", async () => { + const container = renderComponent(); + await flushEffects(); + + const target = container.querySelector( + 'button[aria-label="切换页面内查找"]', + ); + expect(target).not.toBeNull(); + + await act(async () => { + (target as HTMLButtonElement).click(); + await flushEffects(); + }); + + expect(mockSetBrowserActionCapabilityEnabled).toHaveBeenCalledWith({ + key: "find", + enabled: false, + }); + expect(container.textContent).toContain("页面内查找 已关闭"); + }); + it("系统连接器为空时不应渲染 macOS 连接器卡片", async () => { mockGetBrowserConnectorSettings.mockResolvedValueOnce({ enabled: true, diff --git a/src/components/settings-v2/system/chrome-relay/index.tsx b/src/components/settings-v2/system/chrome-relay/index.tsx index a72372648..41805fe76 100644 --- a/src/components/settings-v2/system/chrome-relay/index.tsx +++ b/src/components/settings-v2/system/chrome-relay/index.tsx @@ -43,12 +43,15 @@ import { installBrowserConnectorExtension, launchBrowserSession, openBrowserExtensionsPage, + openBrowserRemoteDebuggingPage, openBrowserRuntimeDebuggerWindow, openChromeProfileWindow, + setBrowserActionCapabilityEnabled, setBrowserConnectorEnabled, setBrowserConnectorInstallRoot, setBrowserBackendPolicy, setSystemConnectorEnabled, + type BrowserActionCapabilitySnapshot, type BrowserConnectorInstallStatus, type BrowserConnectorSettingsSnapshot, type BrowserBackendPolicy, @@ -102,32 +105,64 @@ const SECTION_TAB_TRIGGER_CLASS_NAME = "rounded-full border px-4 py-2 text-sm font-medium"; const SECTION_TAB_BADGE_CLASS_NAME = "inline-flex min-w-[1.5rem] items-center justify-center rounded-full px-2 py-0.5 text-[11px] font-semibold"; +const REMOTE_DEBUGGING_URL = "chrome://inspect/#remote-debugging"; -const BROWSER_READ_CAPABILITIES = [ - "列出标签页", - "页面快照", - "截图", - "控制台消息", - "网络请求", - "页面错误", - "等待文本", +const DEFAULT_EXTENSION_BRIDGE_CAPABILITIES = [ + "navigate", + "read_page", + "get_page_text", + "find", + "computer", + "form_input", + "tabs_context_mcp", + "open_url", + "click", + "type", + "scroll", + "scroll_page", + "get_page_info", + "refresh_page", + "go_back", + "go_forward", + "switch_tab", + "list_tabs", ]; -const BROWSER_WRITE_CAPABILITIES = [ - "新建标签页", - "点击元素", - "输入文本", - "切换标签页", - "关闭标签页", - "导航", - "按键", - "悬停", - "滚动", - "拖放", - "上传文件", - "执行脚本", - "处理弹窗", -]; +const READ_BROWSER_CAPABILITY_ACTIONS = new Set([ + "tabs_context_mcp", + "list_tabs", + "read_page", + "get_page_text", + "get_page_info", + "find", + "read_console_messages", + "read_network_requests", +]); + +const BROWSER_CAPABILITY_LABELS: Record = { + tabs_context_mcp: "标签页概览", + list_tabs: "列出标签页", + tabs_create_mcp: "新建标签页", + navigate: "导航", + open_url: "打开链接", + read_page: "页面快照", + get_page_text: "页面文本", + get_page_info: "页面信息", + find: "页面内查找", + click: "点击元素", + type: "输入文本", + form_input: "表单输入", + switch_tab: "切换标签页", + scroll: "滚动页面", + scroll_page: "滚动页面", + refresh_page: "刷新页面", + go_back: "返回上一页", + go_forward: "前进到下一页", + javascript: "执行脚本", + javascript_tool: "执行脚本", + read_console_messages: "控制台消息", + read_network_requests: "网络请求", +}; const ENGINE_ORDER: SearchEngine[] = ["google", "xiaohongshu"]; const ENGINE_DEFINITIONS: Record = { @@ -301,6 +336,41 @@ function getSystemConnectorStatusLabel( } } +function resolveBrowserCapabilityLabel(capability: string) { + return BROWSER_CAPABILITY_LABELS[capability.trim().toLowerCase()] ?? null; +} + +function buildBrowserCapabilityGroups(capabilities: string[]) { + const read: string[] = []; + const write: string[] = []; + const seenRead = new Set(); + const seenWrite = new Set(); + + for (const capability of capabilities) { + const normalized = capability.trim().toLowerCase(); + if (!normalized) { + continue; + } + const label = resolveBrowserCapabilityLabel(normalized); + if (!label) { + continue; + } + if (READ_BROWSER_CAPABILITY_ACTIONS.has(normalized)) { + if (!seenRead.has(label)) { + seenRead.add(label); + read.push(label); + } + continue; + } + if (!seenWrite.has(label)) { + seenWrite.add(label); + write.push(label); + } + } + + return { read, write }; +} + export function ChromeRelaySettings() { const [activeEngine, setActiveEngine] = useState("google"); const [activeSectionTab, setActiveSectionTab] = @@ -342,11 +412,17 @@ export function ChromeRelaySettings() { const [installingConnector, setInstallingConnector] = useState(false); const [savingConnectorEnabled, setSavingConnectorEnabled] = useState(false); const [openingExtensionsPage, setOpeningExtensionsPage] = useState(false); + const [openingRemoteDebuggingPage, setOpeningRemoteDebuggingPage] = + useState(false); const [openingInstallDirectory, setOpeningInstallDirectory] = useState(false); const [disconnectingConnector, setDisconnectingConnector] = useState(false); const [updatingSystemConnectorId, setUpdatingSystemConnectorId] = useState< string | null >(null); + const [ + updatingBrowserActionCapabilityKey, + setUpdatingBrowserActionCapabilityKey, + ] = useState(null); const [showAdvancedControls, setShowAdvancedControls] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; @@ -844,6 +920,45 @@ export function ChromeRelaySettings() { } }, [pushMessage]); + const copyPlainText = useCallback( + async (text: string, label: string) => { + try { + if (!navigator.clipboard?.writeText) { + throw new Error("当前环境不支持剪贴板写入"); + } + await navigator.clipboard.writeText(text); + pushMessage({ + type: "success", + text: `${label} 已复制到剪贴板`, + }); + } catch (error) { + pushMessage({ + type: "error", + text: `复制 ${label} 失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + }, + [pushMessage], + ); + + const handleOpenRemoteDebuggingPage = useCallback(async () => { + try { + setOpeningRemoteDebuggingPage(true); + await openBrowserRemoteDebuggingPage(); + } catch (error) { + pushMessage({ + type: "error", + text: `打开 Chrome 远程调试页失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } finally { + setOpeningRemoteDebuggingPage(false); + } + }, [pushMessage]); + const handleDisconnectBrowserConnector = useCallback(async () => { try { setDisconnectingConnector(true); @@ -918,6 +1033,36 @@ export function ChromeRelaySettings() { [pushMessage], ); + const handleSetBrowserActionCapabilityEnabled = useCallback( + async (key: string, enabled: boolean) => { + try { + setUpdatingBrowserActionCapabilityKey(key); + const next = await setBrowserActionCapabilityEnabled({ key, enabled }); + setBrowserConnectorSettings(next); + await refreshBackendStatus(true); + const updatedCapability = next.browser_action_capabilities?.find( + (capability) => capability.key === key, + ); + pushMessage({ + type: "success", + text: updatedCapability + ? `${updatedCapability.label} 已${enabled ? "开启" : "关闭"}` + : `浏览器动作已${enabled ? "开启" : "关闭"}`, + }); + } catch (error) { + pushMessage({ + type: "error", + text: `更新浏览器动作配置失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } finally { + setUpdatingBrowserActionCapabilityKey(null); + } + }, + [pushMessage, refreshBackendStatus], + ); + const copyBridgeConfig = useCallback( async (profileKey: string, label: string) => { if (!bridgeEndpoint) { @@ -1134,6 +1279,23 @@ export function ChromeRelaySettings() { }), [backendsStatus, bridgeStatus], ); + const extensionBridgeCapabilityGroups = useMemo(() => { + const extensionBridgeStatus = backendStatusList.find( + (item) => item.backend === "lime_extension_bridge", + ); + const capabilitySource = + extensionBridgeStatus?.capabilities?.length + ? extensionBridgeStatus.capabilities + : DEFAULT_EXTENSION_BRIDGE_CAPABILITIES; + return buildBrowserCapabilityGroups(capabilitySource); + }, [backendStatusList]); + const browserActionCapabilityGroups = useMemo(() => { + const items = browserConnectorSettings?.browser_action_capabilities ?? []; + return { + read: items.filter((item) => item.group === "read"), + write: items.filter((item) => item.group === "write"), + }; + }, [browserConnectorSettings?.browser_action_capabilities]); const renderProfilePanel = (keyPrefix = "") => ( 0; + const hasCdpDirectAvailable = runtimeSummary.cdpAliveProfiles > 0; const visibleSystemConnectors = (browserConnectorSettings?.system_connectors ?? []) .filter((item) => item.visible !== false); const shouldShowSystemConnectors = visibleSystemConnectors.length > 0; @@ -1959,6 +2123,13 @@ export function ChromeRelaySettings() { {connectorEnabled ? "连接器已开启" : "连接器已关闭"} + 0 ? "success" : "warning"} + > + {runtimeSummary.controlCount > 0 + ? `控制已接入 ${runtimeSummary.controlCount}` + : "等待控制通道"} +
@@ -1975,10 +2146,14 @@ export function ChromeRelaySettings() {

{hasObserverConnected - ? `当前已接入 ${runtimeSummary.observerCount} 个 observer,Lime 可以读取页面信息并下发基础控制命令。` + ? `当前已接入 ${runtimeSummary.observerCount} 个 observer、${runtimeSummary.controlCount} 个 control,Lime 可以复用当前 Chrome 标签页并验证桥接闭环。` : browserConnectorInstallStatus?.message || "先导出浏览器连接器到用户目录,再在 Chrome 扩展页加载已解压目录。"}

+

+ QoderWork 扩展本身负责 `chrome.debugger` / `chrome.tabs` + 中继;下面展示的是 Lime 当前在扩展桥接后端已经封装完成的动作。 +

{hasObserverConnected ? (
{(bridgeStatus?.observers ?? []).map((observer) => ( @@ -1994,7 +2169,7 @@ export function ChromeRelaySettings() { 读取

- {BROWSER_READ_CAPABILITIES.map((capability) => ( + {extensionBridgeCapabilityGroups.read.map((capability) => (
- {BROWSER_WRITE_CAPABILITIES.map((capability) => ( + {extensionBridgeCapabilityGroups.write.map((capability) => (
+ {browserActionCapabilityGroups.read.length > 0 || + browserActionCapabilityGroups.write.length > 0 ? ( +
+
+

+ 动作配置 +

+

+ 关闭某项后,Lime 会在真正发起浏览器动作前直接拦截, + 不再把该动作分发到扩展桥接或 CDP 后端。 +

+
+ +
+ {[ + { + title: "读取权限", + items: browserActionCapabilityGroups.read, + }, + { + title: "写入权限", + items: browserActionCapabilityGroups.write, + }, + ].map((section) => ( +
+

+ {section.title} +

+
+ {section.items.map( + (capability: BrowserActionCapabilitySnapshot) => ( +
+
+

+ {capability.label} +

+

+ {capability.description} +

+
+ + void handleSetBrowserActionCapabilityEnabled( + capability.key, + checked, + ) + } + /> +
+ ), + )} +
+
+ ))} +
+
+ ) : null} + +
+
+

连接方式

+

+ 对齐 Accio 的两条主线说明,但保持 Lime 当前执行边界不变: + 复用已登录 Chrome 优先走扩展桥接,临时调试与人工接管优先走 CDP。 +

+
+ +
+
+
+
+

+ 浏览器扩展 +

+

+ 安装一次,长期自动连接。适合稳定复用当前登录态与真实页面上下文。 +

+
+ + {hasObserverConnected && hasControlConnected + ? "推荐" + : "待完成"} + +
+
+

1. 打开 `chrome://extensions` 并开启开发者模式。

+

2. 加载已解压目录 `Lime Browser Connector`。

+

3. 在扩展弹窗确认 observer / control 都已接入。

+
+
+ + +
+
+ +
+
+
+

+ CDP 直连 +

+

+ 零扩展,适合临时调试、实时画面观察和人工接管。当前 Lime 仍以可复用 CDP 会话为主。 +

+
+ + {hasCdpDirectAvailable ? "已就绪" : "待接入"} + +
+
+

1. 打开 `chrome://inspect/#remote-debugging`。

+

2. 按需开启远程调试,并允许本地调试连接。

+

3. 回到 Lime 使用“打开独立调试窗口”或实时调试面板验证会话。

+
+
+ + +
+
+
+ +
+
+
+

初始设置

+

+ 扩展方式要安装一次;CDP 方式要在 Chrome 中显式开启远程调试。 +

+
+
+

重启后行为

+

+ 扩展会自动重连;CDP 直连通常需要重新确认调试连接或重新建立会话。 +

+
+
+

推荐场景

+

+ 站点技能、资料采集优先扩展;页面调试、人工接管优先 CDP。 +

+
+
+
+
+

安装目录 diff --git a/src/features/browser-runtime/BrowserProfileManager.test.tsx b/src/features/browser-runtime/BrowserProfileManager.test.tsx index 421eb0144..16e70044b 100644 --- a/src/features/browser-runtime/BrowserProfileManager.test.tsx +++ b/src/features/browser-runtime/BrowserProfileManager.test.tsx @@ -454,9 +454,9 @@ describe("BrowserProfileManager", () => { const container = await renderManager({ onMessage }); const toggleTabsButton = Array.from( container.querySelectorAll("button"), - ).find( - (element) => element.textContent?.includes("查看标签页"), - ) as HTMLButtonElement | undefined; + ).find((element) => element.textContent?.includes("查看标签页")) as + | HTMLButtonElement + | undefined; await act(async () => { toggleTabsButton?.click(); @@ -591,22 +591,17 @@ describe("BrowserProfileManager", () => { await Promise.resolve(); }); - expect(mockGetChromeBridgeStatus).toHaveBeenCalled(); - expect(mockBrowserExecuteAction).toHaveBeenCalledWith({ - profile_key: "weibo_attach", - backend: "lime_extension_bridge", - action: "navigate", - args: { - url: "https://weibo.com", - wait_for_page_info: true, - }, - timeout_ms: 30000, + expect(mockLaunchBrowserSession).toHaveBeenCalledWith({ + profile_id: "profile-attach", + environment_preset_id: "env-1", + open_window: false, + stream_mode: "both", }); - expect(mockLaunchBrowserSession).not.toHaveBeenCalled(); + expect(mockBrowserExecuteAction).not.toHaveBeenCalled(); expect(onProfileLaunched).toHaveBeenCalledWith("weibo_attach"); expect(onMessage).toHaveBeenCalledWith({ type: "success", - text: '已附着当前 Chrome:微博附着,并导航到 https://weibo.com。已选择启动环境 "美区桌面",但附着当前 Chrome 模式暂不应用代理、时区、语言、UA 或视口等启动级配置;如需这些能力,请改用托管浏览器模式。', + text: "已附着当前 Chrome:微博附着,并启动实时调试会话(环境:美区桌面)", }); }); @@ -628,6 +623,11 @@ describe("BrowserProfileManager", () => { archived_at: null, }, ]); + mockLaunchBrowserSession.mockRejectedValueOnce( + new Error( + "当前资料使用“附着当前 Chrome”模式,但没有检测到可复用的浏览器会话", + ), + ); const onMessage = vi.fn(); const container = await renderManager({ onMessage }); @@ -641,10 +641,85 @@ describe("BrowserProfileManager", () => { }); expect(mockBrowserExecuteAction).not.toHaveBeenCalled(); - expect(mockLaunchBrowserSession).not.toHaveBeenCalled(); + expect(mockLaunchBrowserSession).toHaveBeenCalled(); expect(onMessage).toHaveBeenCalledWith({ type: "error", text: "启动资料失败: 没有检测到 profile_key=weibo_attach 的当前 Chrome 连接。请先在当前 Chrome 安装并连接 Lime Browser Bridge 扩展。", }); }); + + it("附着当前 Chrome 资料在 runtime attach 失败时应回退到扩展桥接", async () => { + mockListBrowserProfiles.mockResolvedValueOnce([ + { + id: "profile-attach", + profile_key: "weibo_attach", + name: "微博附着", + description: "复用当前 Chrome", + site_scope: "weibo.com", + launch_url: "https://weibo.com", + transport_kind: "existing_session", + profile_dir: "", + managed_profile_dir: null, + created_at: "2026-03-15T00:00:00Z", + updated_at: "2026-03-15T00:00:00Z", + last_used_at: null, + archived_at: null, + }, + ]); + mockLaunchBrowserSession.mockRejectedValueOnce( + new Error( + "当前资料使用“附着当前 Chrome”模式,但没有检测到可复用的浏览器会话", + ), + ); + mockGetChromeBridgeStatus.mockResolvedValue({ + observer_count: 1, + control_count: 0, + pending_command_count: 0, + observers: [ + { + client_id: "observer-1", + profile_key: "weibo_attach", + connected_at: "2026-03-15T00:00:00Z", + user_agent: "Chrome", + last_heartbeat_at: "2026-03-15T00:00:02Z", + last_page_info: null, + }, + ], + controls: [], + pending_commands: [], + }); + const onMessage = vi.fn(); + const onProfileLaunched = vi.fn(); + + const container = await renderManager({ + onMessage, + onProfileLaunched, + }); + const launchButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("附着当前 Chrome"), + ) as HTMLButtonElement | undefined; + + await act(async () => { + launchButton?.click(); + await Promise.resolve(); + }); + + expect(mockLaunchBrowserSession).toHaveBeenCalledWith({ + profile_id: "profile-attach", + environment_preset_id: undefined, + open_window: false, + stream_mode: "both", + }); + expect(mockBrowserExecuteAction).toHaveBeenCalledWith({ + profile_key: "weibo_attach", + backend: "lime_extension_bridge", + action: "navigate", + args: { + url: "https://weibo.com", + wait_for_page_info: true, + }, + timeout_ms: 30000, + }); + expect(onProfileLaunched).toHaveBeenCalledWith("weibo_attach"); + }); }); diff --git a/src/features/browser-runtime/BrowserProfileManager.tsx b/src/features/browser-runtime/BrowserProfileManager.tsx index 845869022..c39476790 100644 --- a/src/features/browser-runtime/BrowserProfileManager.tsx +++ b/src/features/browser-runtime/BrowserProfileManager.tsx @@ -65,13 +65,13 @@ const PROFILE_TRANSPORT_OPTIONS: Array<{ { value: "managed_cdp", label: "托管浏览器", - description: - "由 Lime 启动并管理独立 Chrome 资料,兼容当前实时会话链路。", + description: "由 Lime 启动并管理独立 Chrome 资料,兼容当前实时会话链路。", }, { value: "existing_session", label: "附着当前 Chrome", - description: "复用你已登录的 Chrome,会话附着链路后续单独接入。", + description: + "复用你已登录的 Chrome,优先附着当前会话,不会静默拉起第二个托管浏览器。", }, ]; @@ -91,6 +91,24 @@ function getExistingSessionEnvironmentNotice( return `已选择启动环境 "${presetName}",但附着当前 Chrome 模式暂不应用代理、时区、语言、UA 或视口等启动级配置;如需这些能力,请改用托管浏览器模式。`; } +const EXISTING_SESSION_RUNTIME_FALLBACK_PATTERNS = [ + "附着当前 chrome", + "existing_session", + "可复用的浏览器会话", + "远程调试", + "未检测到可连接的 cdp 调试端口", + "没有可用的 chrome 会话", + "未找到 profile_key", +]; + +function shouldFallbackToExistingSessionBridge(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + const normalized = message.trim().toLowerCase(); + return EXISTING_SESSION_RUNTIME_FALLBACK_PATTERNS.some((pattern) => + normalized.includes(pattern), + ); +} + function toFormState(profile: BrowserProfileRecord): ProfileFormState { return { id: profile.id, @@ -296,6 +314,28 @@ export function BrowserProfileManager(props: BrowserProfileManagerProps) { async (profile: BrowserProfileRecord) => { try { if (profile.transport_kind === "existing_session") { + try { + await browserRuntimeApi.launchBrowserSession({ + profile_id: profile.id, + environment_preset_id: launchEnvironmentPresetId || undefined, + open_window: false, + stream_mode: "both", + }); + await refreshProfiles(showArchived); + onProfileLaunched?.(profile.profile_key); + onMessage?.({ + type: "success", + text: selectedLaunchEnvironmentPreset + ? `已附着当前 Chrome:${profile.name},并启动实时调试会话(环境:${selectedLaunchEnvironmentPreset.name})` + : `已附着当前 Chrome:${profile.name},并启动实时调试会话`, + }); + return; + } catch (error) { + if (!shouldFallbackToExistingSessionBridge(error)) { + throw error; + } + } + await handleAttachExistingSession(profile); return; } @@ -572,9 +612,9 @@ export function BrowserProfileManager(props: BrowserProfileManagerProps) { : null; const pageInfo = transportKind === "existing_session" - ? pageInfoByProfileKey[profile.profile_key] ?? + ? (pageInfoByProfileKey[profile.profile_key] ?? bridgeObserver?.last_page_info ?? - null + null) : null; const currentTabs = tabsByProfileKey[profile.profile_key] ?? []; const isTabPanelOpen = tabPanelsOpen[profile.profile_key] === true; @@ -625,8 +665,7 @@ export function BrowserProfileManager(props: BrowserProfileManagerProps) { 最近使用: {profile.last_used_at || "从未"} {bridgeObserver ? ( - 当前页面:{" "} - {pageInfo?.title || pageInfo?.url || "已连接"} + 当前页面: {pageInfo?.title || pageInfo?.url || "已连接"} ) : null}

diff --git a/src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx b/src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx index 6c8ed6730..bf9475e0f 100644 --- a/src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx +++ b/src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx @@ -45,10 +45,13 @@ const defaultRuntimeState = { streaming: false, refreshingState: false, controlBusy: false, + selectedProfileTransportKind: "managed_cdp", + runtimeConnectionError: null, lifecycleState: null, isHumanControlling: false, isWaitingForHuman: false, isAgentResuming: false, + isExistingSessionProfile: false, canDirectControl: false, refreshTargets: vi.fn(async () => undefined), openSession: vi.fn(async () => undefined), @@ -430,6 +433,74 @@ describe("BrowserRuntimeDebugPanel", () => { expect(container.textContent).toContain("当前窗口标签页"); }); + it("existing_session 正在切到 runtime 会话时不应回退附着面板", async () => { + mockUseBrowserRuntimeDebug.mockReturnValue({ + ...defaultRuntimeState, + selectedProfileKey: "weibo_attach", + selectedSession: { + profile_key: "weibo_attach", + browser_source: "system", + browser_path: "", + profile_dir: "", + remote_debugging_port: 16666, + pid: 0, + started_at: "2026-03-15T00:00:00Z", + last_url: "https://weibo.com/home", + }, + openingSession: true, + selectedProfileTransportKind: "existing_session", + isExistingSessionProfile: true, + }); + mockListBrowserProfiles.mockResolvedValue([ + { + id: "profile-attach", + profile_key: "weibo_attach", + name: "微博附着", + description: "复用当前 Chrome", + site_scope: "weibo.com", + launch_url: "https://weibo.com/home", + transport_kind: "existing_session", + profile_dir: "", + managed_profile_dir: null, + created_at: "2026-03-15T00:00:00Z", + updated_at: "2026-03-15T00:00:00Z", + last_used_at: null, + archived_at: null, + }, + ]); + mockGetChromeBridgeStatus.mockResolvedValue({ + observer_count: 1, + control_count: 0, + pending_command_count: 0, + observers: [ + { + client_id: "observer-1", + profile_key: "weibo_attach", + connected_at: "2026-03-15T00:00:00Z", + user_agent: "Chrome", + last_heartbeat_at: "2026-03-15T00:00:08Z", + last_page_info: { + title: "微博首页", + url: "https://weibo.com/home", + markdown: "# 微博首页", + updated_at: "2026-03-15T00:00:08Z", + }, + }, + ], + controls: [], + pending_commands: [], + }); + + const container = await renderPanel({ + initialProfileKey: "weibo_attach", + initialSessionId: undefined, + }); + + expect(container.textContent).toContain("正在启动 Chrome、连接调试通道"); + expect(container.textContent).not.toContain("当前窗口标签页"); + expect(container.textContent).not.toContain("附着当前 Chrome"); + }); + it("附着模式应支持读取并切换当前 Chrome 标签页", async () => { const onMessage = vi.fn(); mockUseBrowserRuntimeDebug.mockReturnValue({ diff --git a/src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx b/src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx index 45768ffa6..0263cf0b7 100644 --- a/src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx +++ b/src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx @@ -304,13 +304,24 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) { sessionState: runtime.sessionState, onMessage, }); + const preferRuntimeLivePresentation = + runtime.isExistingSessionProfile && + !runtime.runtimeConnectionError && + Boolean( + runtime.sessionState || + runtime.openingSession || + runtime.refreshingState || + runtime.selectedSession, + ); + const showAttachPresentation = + shouldUseAttachPresentation && !preferRuntimeLivePresentation; const currentTitle = runtime.sessionState?.last_page_info?.title || runtime.sessionState?.target_title || attachPageInfo?.title || attachProfile?.name || - (shouldUseAttachPresentation ? "附着当前 Chrome" : "未打开会话"); + (showAttachPresentation ? "附着当前 Chrome" : "未打开会话"); const currentUrl = runtime.sessionState?.last_page_info?.url || runtime.sessionState?.target_url || @@ -319,13 +330,13 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) { ""; const statusInfo = useMemo( () => - shouldUseAttachPresentation + showAttachPresentation ? attachPresentation.statusInfo : resolveSessionStatus(runtime.sessionState), [ attachPresentation.statusInfo, runtime.sessionState, - shouldUseAttachPresentation, + showAttachPresentation, ], ); const hasAttachIntent = Boolean( @@ -358,7 +369,7 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) { refreshingState: runtime.refreshingState, sessionState: runtime.sessionState, }); - const effectiveLiveViewPlaceholder = shouldUseAttachPresentation + const effectiveLiveViewPlaceholder = showAttachPresentation ? attachPresentation.placeholder : liveViewPlaceholder; const visibleAuditLogs = useMemo(() => { @@ -748,7 +759,7 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) { }; if (embedded) { - const embeddedAction = shouldUseAttachPresentation ? ( + const embeddedAction = showAttachPresentation ? ( ) : null} - {shouldUseAttachPresentation ? ( + {showAttachPresentation ? ( @@ -1053,7 +1064,7 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) { {showAdvanced ? (
- {shouldUseAttachPresentation ? ( + {showAttachPresentation ? (
{renderAttachFallbackPanel("max-h-[180px]")}
{renderAuditPanel("max-h-[180px]")}
@@ -1367,13 +1378,13 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) {
{runtime.sessionState?.transport_kind || - (shouldUseAttachPresentation + (showAttachPresentation ? "existing_session" : "cdp_frames")}
- {shouldUseAttachPresentation + {showAttachPresentation ? attachPresentation.liveViewHint : runtime.canDirectControl ? "当前支持点击画面、滚轮滚动,并把文本发送到当前焦点元素。" @@ -1383,7 +1394,7 @@ export function BrowserRuntimeDebugPanel(props: BrowserRuntimeDebugPanelProps) {
- {shouldUseAttachPresentation ? ( + {showAttachPresentation ? ( <> + ); } @@ -73,6 +97,12 @@ describe("useBrowserRuntimeDebug", () => { }); it("浏览器连接被外部关闭后应自动重新拉起并恢复会话", async () => { + mockBrowserRuntimeApi.listBrowserProfiles.mockResolvedValue([ + { + profile_key: "general_browser_assist", + transport_kind: "managed_cdp", + }, + ]); mockBrowserRuntimeApi.supportsNativeEvents.mockReturnValue(true); mockBrowserRuntimeApi.listenBrowserEvent.mockImplementation( (handler: (event: { payload: any }) => void) => { @@ -189,4 +219,186 @@ describe("useBrowserRuntimeDebug", () => { }), ); }); + + it("existing_session 初始资料不应误走 CDP 标签页读取", async () => { + mockBrowserRuntimeApi.listBrowserProfiles.mockResolvedValue([ + { + profile_key: "attached-github", + transport_kind: "existing_session", + }, + ]); + mockBrowserRuntimeApi.supportsNativeEvents.mockReturnValue(true); + mockBrowserRuntimeApi.listenBrowserEvent.mockResolvedValue(() => undefined); + + mountHarness( + HookHarness, + { + sessions: [], + initialProfileKey: "attached-github", + initialSessionId: "", + }, + mountedRoots, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockBrowserRuntimeApi.listCdpTargets).not.toHaveBeenCalled(); + expect(mockBrowserRuntimeApi.openCdpSession).not.toHaveBeenCalled(); + }); + + it("existing_session 自动连接时应优先走 launchBrowserSession", async () => { + mockBrowserRuntimeApi.listBrowserProfiles.mockResolvedValue([ + { + profile_key: "attached-github", + transport_kind: "existing_session", + }, + ]); + mockBrowserRuntimeApi.supportsNativeEvents.mockReturnValue(true); + mockBrowserRuntimeApi.listenBrowserEvent.mockResolvedValue(() => undefined); + mockBrowserRuntimeApi.launchBrowserSession.mockResolvedValue({ + profile: { + success: true, + reused: true, + browser_source: "system", + browser_path: "", + profile_dir: "", + remote_debugging_port: 16666, + pid: 0, + devtools_http_url: "http://127.0.0.1:16666/json/version", + error: null, + }, + session: { + session_id: "session-existing", + profile_key: "attached-github", + target_id: "tab-1", + target_title: "GitHub", + target_url: "https://github.com/", + remote_debugging_port: 16666, + ws_debugger_url: "ws://127.0.0.1:16666/devtools/page/tab-1", + stream_mode: "both", + transport_kind: "cdp_frames", + lifecycle_state: "live", + control_mode: "agent", + created_at: "2026-03-30T10:00:00Z", + connected: true, + }, + }); + mockBrowserRuntimeApi.getBrowserEventBuffer.mockResolvedValue({ + events: [], + next_cursor: 0, + }); + + mountHarness( + HookHarness, + { + sessions: [ + { + profile_key: "attached-github", + last_url: "https://github.com/", + }, + ], + initialProfileKey: "attached-github", + initialSessionId: "", + openOnMount: true, + }, + mountedRoots, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockBrowserRuntimeApi.launchBrowserSession).toHaveBeenCalledWith( + expect.objectContaining({ + profile_key: "attached-github", + url: "https://github.com/", + open_window: false, + stream_mode: "both", + }), + ); + expect(mockBrowserRuntimeApi.openCdpSession).not.toHaveBeenCalled(); + expect(mockBrowserRuntimeApi.listCdpTargets).not.toHaveBeenCalled(); + }); + + it("existing_session 的人工控制应优先走扩展桥接后端", async () => { + mockBrowserRuntimeApi.listBrowserProfiles.mockResolvedValue([ + { + profile_key: "attached-github", + transport_kind: "existing_session", + }, + ]); + mockBrowserRuntimeApi.supportsNativeEvents.mockReturnValue(true); + mockBrowserRuntimeApi.listenBrowserEvent.mockResolvedValue(() => undefined); + mockBrowserRuntimeApi.getBrowserSessionState.mockResolvedValue({ + session_id: "session-existing", + profile_key: "attached-github", + target_id: "tab-1", + target_title: "GitHub", + target_url: "https://github.com/", + remote_debugging_port: 16666, + ws_debugger_url: "ws://127.0.0.1:16666/devtools/page/tab-1", + stream_mode: "both", + transport_kind: "cdp_frames", + lifecycle_state: "human_controlling", + control_mode: "human", + created_at: "2026-03-30T10:00:00Z", + connected: true, + }); + mockBrowserRuntimeApi.getBrowserEventBuffer.mockResolvedValue({ + events: [], + next_cursor: 0, + }); + mockBrowserRuntimeApi.browserExecuteAction.mockResolvedValue({ + success: true, + backend: "lime_extension_bridge", + action: "click", + request_id: "browser-control-1", + attempts: [], + data: {}, + }); + + const { container } = mountHarness( + HookHarness, + { + sessions: [], + initialProfileKey: "attached-github", + }, + mountedRoots, + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const button = container.querySelector( + "[data-testid='runtime-click']", + ) as HTMLButtonElement | null; + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(mockBrowserRuntimeApi.browserExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + profile_key: "attached-github", + backend: "lime_extension_bridge", + action: "click", + args: expect.objectContaining({ + x: 88, + y: 132, + target_id: "tab-1", + }), + }), + ); + }); }); diff --git a/src/features/browser-runtime/useBrowserRuntimeDebug.ts b/src/features/browser-runtime/useBrowserRuntimeDebug.ts index 44f8094c5..453260e1f 100644 --- a/src/features/browser-runtime/useBrowserRuntimeDebug.ts +++ b/src/features/browser-runtime/useBrowserRuntimeDebug.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { BrowserEvent, + BrowserProfileTransportKind, BrowserSessionLifecycleState, BrowserStreamMode, ChromeProfileSessionInfo, @@ -137,6 +138,12 @@ export function useBrowserRuntimeDebug( const [streaming, setStreaming] = useState(false); const [refreshingState, setRefreshingState] = useState(false); const [controlBusy, setControlBusy] = useState(false); + const [selectedProfileTransportKind, setSelectedProfileTransportKind] = + useState(null); + const [loadingProfileTransport, setLoadingProfileTransport] = useState(false); + const [runtimeConnectionError, setRuntimeConnectionError] = useState< + string | null + >(null); const eventCursorRef = useRef(0); const autoAttachedSessionRef = useRef(""); const autoOpenedProfileRef = useRef(""); @@ -192,6 +199,10 @@ export function useBrowserRuntimeDebug( null, [selectedProfileKey, sessions], ); + const runtimeProfileKey = + sessionState?.profile_key || selectedProfileKey || initialProfileKey; + const isExistingSessionProfile = + selectedProfileTransportKind === "existing_session"; const emitMessage = useCallback( (message: StatusMessage) => { @@ -200,6 +211,49 @@ export function useBrowserRuntimeDebug( [onMessage], ); + useEffect(() => { + if (!runtimeProfileKey) { + setSelectedProfileTransportKind(null); + setLoadingProfileTransport(false); + return; + } + + let active = true; + setLoadingProfileTransport(true); + + void browserRuntimeApi + .listBrowserProfiles({ + include_archived: false, + }) + .then((profiles) => { + if (!active) { + return; + } + const matchedProfile = profiles.find( + (profile) => profile.profile_key === runtimeProfileKey, + ); + setSelectedProfileTransportKind( + matchedProfile?.transport_kind ?? "managed_cdp", + ); + }) + .catch(() => { + if (!active) { + return; + } + setSelectedProfileTransportKind(null); + }) + .finally(() => { + if (!active) { + return; + } + setLoadingProfileTransport(false); + }); + + return () => { + active = false; + }; + }, [runtimeProfileKey]); + const applyEvent = useCallback((event: BrowserEvent) => { setSessionState((previous) => reduceSessionStateWithEvent(previous, event)); if (event.type === "frame_chunk") { @@ -299,6 +353,7 @@ export function useBrowserRuntimeDebug( open_window: false, stream_mode: "both", }); + setRuntimeConnectionError(null); setSelectedProfileKey(result.session.profile_key); setSelectedTargetId(result.session.target_id || ""); setSessionState(result.session); @@ -320,6 +375,7 @@ export function useBrowserRuntimeDebug( }); return true; } catch (error) { + setRuntimeConnectionError(normalizeRuntimeErrorMessage(error)); emitMessage({ type: "error", text: `${reason},自动恢复失败: ${normalizeRuntimeErrorMessage(error)}`, @@ -374,6 +430,22 @@ export function useBrowserRuntimeDebug( const refreshTargets = useCallback(async () => { if (!selectedProfileKey) return; + if (loadingProfileTransport) { + return; + } + if (isExistingSessionProfile) { + setTargets([]); + return; + } + const hasManagedSessionContext = Boolean( + sessionState?.session_id || + initialSessionId || + sessions.some((session) => session.profile_key === selectedProfileKey), + ); + if (!hasManagedSessionContext) { + setTargets([]); + return; + } setLoadingTargets(true); try { const nextTargets = @@ -401,9 +473,14 @@ export function useBrowserRuntimeDebug( }, [ emitMessage, handleRecoverableError, + initialSessionId, initialTargetId, + isExistingSessionProfile, + loadingProfileTransport, selectedProfileKey, selectedTargetId, + sessionState?.session_id, + sessions, ]); useEffect(() => { @@ -421,6 +498,7 @@ export function useBrowserRuntimeDebug( setRefreshingState(true); let nextState = await browserRuntimeApi.getBrowserSessionState(initialSessionId); + setRuntimeConnectionError(null); setSessionState(nextState); setSelectedProfileKey(nextState.profile_key); setSelectedTargetId(nextState.target_id || initialTargetId); @@ -431,6 +509,7 @@ export function useBrowserRuntimeDebug( if (await handleRecoverableError(error, "附着浏览器会话失败")) { return; } + setRuntimeConnectionError(normalizeRuntimeErrorMessage(error)); emitMessage({ type: "error", text: `附着浏览器会话失败: ${normalizeRuntimeErrorMessage(error)}`, @@ -452,7 +531,9 @@ export function useBrowserRuntimeDebug( const openSession = useCallback(async () => { if (!selectedProfileKey) return; + if (loadingProfileTransport) return; setOpeningSession(true); + setRuntimeConnectionError(null); try { setConsoleEvents([]); setNetworkEvents([]); @@ -460,12 +541,27 @@ export function useBrowserRuntimeDebug( setLatestFrameMetadata(null); eventCursorRef.current = 0; setEventCursor(0); - let nextState = await browserRuntimeApi.openCdpSession({ - profile_key: selectedProfileKey, - target_id: selectedTargetId || undefined, - }); + let nextState: CdpSessionState; + if (isExistingSessionProfile) { + const result = await browserRuntimeApi.launchBrowserSession({ + profile_key: selectedProfileKey, + url: selectedSession?.last_url || "https://www.google.com/", + target_id: selectedTargetId || undefined, + open_window: false, + stream_mode: "both", + }); + nextState = result.session; + } else { + nextState = await browserRuntimeApi.openCdpSession({ + profile_key: selectedProfileKey, + target_id: selectedTargetId || undefined, + }); + } setSessionState(nextState); + setSelectedProfileKey(nextState.profile_key); + setSelectedTargetId(nextState.target_id || selectedTargetId); nextState = await ensureSessionStream(nextState); + setRuntimeConnectionError(null); await syncBuffer(nextState.session_id); emitMessage({ type: "success", @@ -475,6 +571,7 @@ export function useBrowserRuntimeDebug( if (await handleRecoverableError(error, "打开 CDP 会话失败")) { return; } + setRuntimeConnectionError(normalizeRuntimeErrorMessage(error)); emitMessage({ type: "error", text: `打开 CDP 会话失败: ${normalizeRuntimeErrorMessage(error)}`, @@ -486,15 +583,21 @@ export function useBrowserRuntimeDebug( emitMessage, ensureSessionStream, handleRecoverableError, + loadingProfileTransport, selectedProfileKey, selectedTargetId, syncBuffer, + isExistingSessionProfile, + selectedSession?.last_url, ]); useEffect(() => { if (initialSessionId || sessionState?.session_id || openingSession) { return; } + if (loadingProfileTransport) { + return; + } const profileKey = selectedProfileKey || initialProfileKey; if (!profileKey) { @@ -514,6 +617,7 @@ export function useBrowserRuntimeDebug( }, [ initialProfileKey, initialSessionId, + loadingProfileTransport, openSession, openingSession, selectedProfileKey, @@ -528,6 +632,7 @@ export function useBrowserRuntimeDebug( const nextState = await browserRuntimeApi.getBrowserSessionState( sessionState.session_id, ); + setRuntimeConnectionError(null); setSessionState(nextState); setStreaming(Boolean(nextState.stream_mode)); await syncBuffer(nextState.session_id, eventCursorRef.current); @@ -535,6 +640,7 @@ export function useBrowserRuntimeDebug( if (await handleRecoverableError(error, "刷新浏览器会话失败")) { return; } + setRuntimeConnectionError(normalizeRuntimeErrorMessage(error)); emitMessage({ type: "error", text: `刷新会话状态失败: ${normalizeRuntimeErrorMessage(error)}`, @@ -702,9 +808,12 @@ export function useBrowserRuntimeDebug( } setControlBusy(true); try { + const backend = isExistingSessionProfile + ? "lime_extension_bridge" + : "cdp_direct"; await browserRuntimeApi.browserExecuteAction({ profile_key: sessionState.profile_key, - backend: "cdp_direct", + backend, action, args: { ...args, @@ -727,7 +836,13 @@ export function useBrowserRuntimeDebug( setControlBusy(false); } }, - [emitMessage, handleRecoverableError, sessionState, syncBuffer], + [ + emitMessage, + handleRecoverableError, + isExistingSessionProfile, + sessionState, + syncBuffer, + ], ); const clickAt = useCallback( @@ -855,10 +970,13 @@ export function useBrowserRuntimeDebug( streaming, refreshingState, controlBusy, + selectedProfileTransportKind, + runtimeConnectionError, lifecycleState, isHumanControlling, isWaitingForHuman, isAgentResuming, + isExistingSessionProfile, canDirectControl, refreshTargets, openSession, diff --git a/src/i18n/README.md b/src/i18n/README.md index ee2f8c368..5c18e1292 100644 --- a/src/i18n/README.md +++ b/src/i18n/README.md @@ -341,11 +341,9 @@ function Greeting({ name, date }: Props) { | `selected_total` | 已选择 {selected} / {total} | Selected {selected} of {total} | | `total_records` | 共 {total} 条记录 | Total {total} record(s) | -### API Server +### Local Runtime | Key | Chinese | English | |-----|---------|---------| -| `start_failed` | 启动失败: {error} | Start failed: {error} | -| `stop_failed` | 停止失败: {error} | Stop failed: {error} | | `switched_to` | 已切换到 {provider} | Switched to {provider} | | `switch_failed` | 切换失败: {error} | Switch failed: {error} | | `request_failed` | 请求失败: {error} | Request failed: {error} | diff --git a/src/i18n/patches/en.json b/src/i18n/patches/en.json index 9aad92a41..5c1746bbb 100644 --- a/src/i18n/patches/en.json +++ b/src/i18n/patches/en.json @@ -7,10 +7,6 @@ "深色模式": "Dark Mode", "浅色模式": "Light Mode", "AI Agent": "AI Agent", - "API Server": "API Server", - "团队共享网关": "Team Gateway", - "团队共享网关(内网)": "Team Gateway (LAN)", - "AI Agent + 团队网关": "AI Agent + Team Gateway", "加载侧边栏插件失败:": "Failed to load sidebar plugins:", "// === Settings Page (src/components/settings/SettingsPage.tsx) ===": "", "通用": "General", @@ -61,8 +57,8 @@ "检测中...": "Detecting...", "已安装": "Installed", "未安装": "Not Installed", - "Lime 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。": "Lime is an AI Agent workspace with an optional LAN team gateway. Use it locally, or distribute a unified OpenAI/Anthropic-compatible endpoint to teammates on the same network.", - "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)": "1. Add credentials in Credential Pool (e.g., Kiro credential file or Claude API key); 2. Start directly in AI Agent (recommended); 3. If team sharing is needed, enable sharing in Team Gateway (LAN) and choose a default Provider; 4. Configure API address in LAN tools (e.g., http://localhost:8999).", + "Lime 是一个 AI Agent 工作台,围绕主题工作台、资源沉淀与创作链路组织能力。": "Lime is an AI Agent workspace organized around themed workbenches, asset accumulation, and end-to-end creation flows.", + "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 按主题工作台持续沉淀内容、图片与项目资源": "1. Add credentials in Credential Pool (for example Kiro credential files or a Claude API key); 2. Start directly in AI Agent; 3. Keep accumulating content, images, and project assets through themed workbenches.", "检查更新失败": "Failed to check for updates", "安装程序已启动,应用将自动关闭以完成更新": "Installer launched, app will close to complete update", "下载失败,请手动下载": "Download failed, please download manually", @@ -122,7 +118,7 @@ "等待中": "Pending", "已取消": "Cancelled", "// === Provider Pool Page (src/components/provider-pool/ProviderPoolPage.tsx) ===": "", - "管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 Provider 后自动使用对应凭证": "Manage multiple AI service credentials with automatic load balancing. Credentials are automatically used after selecting default Provider in Team Gateway.", + "管理多个 AI 服务凭证,自动轮询负载均衡。本机默认 Provider 会直接复用这里的凭证来源": "Manage multiple AI service credentials with automatic load balancing. The local default provider reuses credential sources configured here.", "导入配置": "Import Configuration", "从高级设置导入 Private 凭证": "Import Private credentials from advanced settings", "OAuth 凭证": "OAuth Credentials", @@ -156,46 +152,6 @@ "暂无可用凭证,请先在凭证池或 API Key 设置中添加凭证": "No credentials available, please add credentials in Credential Pool or API Key settings first", "当前类型无可用凭证,请先在凭证池中添加": "No credentials available for current type, please add in Credential Pool first", "插件": "Plugin", - "// === API Server Page (src/components/api-server/ApiServerPage.tsx) ===": "", - "Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关": "Agent connects to providers directly by default; enable the gateway only when LAN teammates need access.", - "局域网": "LAN", - "运行中": "Running", - "已停止": "Stopped", - "请求": "Request", - "网关控制": "Gateway Control", - "路由端点": "Route Endpoints", - "网关日志": "Gateway Logs", - "关闭共享": "Stop Sharing", - "开启共享": "Start Sharing", - "共享模式:": "Sharing Mode:", - "仅本机": "Local Only", - "内网共享": "LAN Sharing", - "共享地址:": "Shared Address:", - "请先关闭共享再修改配置": "Stop sharing before changing settings", - "仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放": "Local-only mode allows access from this device only; LAN sharing opens access to devices on the same subnet.", - "当前处于": "Current mode: ", - "模式。修改配置需要先关闭共享": ". Stop sharing before changing settings.", - "处理中...": "Processing...", - "端口:": "Port:", - "默认 Provider": "Default Provider", - "已切换到": "Switched to", - "切换失败:": "Switch failed:", - "共享网关已开启": "Gateway sharing enabled", - "共享网关已关闭": "Gateway sharing disabled", - "开启失败:": "Enable failed:", - "关闭失败:": "Disable failed:", - "网关配置已保存": "Gateway configuration saved", - "网关 API 测试": "Gateway API Testing", - "已切换为仅本机模式": "Switched to local-only mode", - "已切换为内网共享模式": "Switched to LAN sharing mode", - "测试全部": "Test All", - "健康检查": "Health Check", - "模型列表": "Model List", - "未测试": "Not tested", - "测试中...": "Testing...", - "失败": "Failed", - "无响应内容": "No response content", - "请求失败:": "Request failed:", "// === Tools Page (src/components/tools/ToolsPage.tsx) ===": "", "工具箱": "Toolbox", "Lime 提供的实用工具集合": "Collection of utility tools provided by Lime", @@ -837,7 +793,6 @@ "获取模型配置失败": "Failed to get model configuration", "获取": "Get", "模型列表失败": "model list failed", - "// === components\\api-server\\ApiServerPage.test.ts ===": "", "应返回": "Should return", "其他": "Other", "应返回默认模型列表": "Should return default model list", @@ -846,29 +801,6 @@ "应显示": "Should display", "测试端点": "Test endpoint", "不应显示": "Should not display", - "// === components\\api-server\\ApiServerPage.tsx ===": "", - "启动失败": "Failed to start", - "停止失败": "Failed to stop", - "切换失败": "Failed to switch", - "请求失败": "Request failed", - "测试中": "Testing", - "本地代理服务器": "Local proxy server", - "支持": "Support", - "格式": "Format", - "紧凑版": "Compact version", - "处理中": "Processing", - "端口": "Port", - "动态显示有凭证的": "Dynamically show with credentials", - "暂无可用凭证": "No available credentials", - "请先在凭证池或": "Please add credentials in Credential Pool or", - "设置中添加凭证": "add credentials in settings", - "当前选中类型的凭证列表": "Credential list for currently selected type", - "当前类型无可用凭证": "No available credentials for current type", - "请先在凭证池中添加": "Please add in Credential Pool first", - "测试": "Test", - "响应": "Response", - "空响应": "Empty response", - "// === components\\api-server\\EnhancedModelsTab.tsx ===": "", "初始显示": "Initial display", "从未同步": "Never synced", "头部信息": "Header information", @@ -893,12 +825,10 @@ "定价": "Pricing", "取消收藏": "Unfavorite", "复制模型": "Copy model", - "// === components\\api-server\\LogsTab.tsx ===": "", "清空": "Clear All", "暂无日志": "No logs", "软件运行时将显示系统日志": "System logs will be displayed when the software is running", "暂无日志,软件运行时将显示网关与系统日志": "No logs yet. Gateway and system logs will appear while the app is running.", - "// === components\\api-server\\ModelsTab.tsx ===": "", "智谱": "Zhipu", "月之暗面": "Moonshot", "搜索模型": "Search models", @@ -910,17 +840,10 @@ "不同": "Different", "支持的模型不同": "supported models vary", "请确保已配置对应的凭证": "Please ensure corresponding credentials are configured", - "// === components\\api-server\\RoutesTab.tsx ===": "", - "可用路由端点": "Available route endpoints", - "通过不同的": "Through different", - "路径访问不同的": "paths to access different", - "服务器地址": "Server address", "已禁用": "Disabled", "收起": "Collapse", "展开": "Expand", - "端点地址": "Endpoint address", "示例": "Example", - "复制命令": "Copy command", "// === components\\AppSidebar.tsx ===": "", "加载侧边栏插件失败": "Failed to load sidebar plugins", "动态插件入口": "Dynamic plugin entry", @@ -3863,7 +3786,6 @@ "运营与品牌内容团队": "Operations and Brand Content Teams", "需要长期沉淀创作资产的个人与小团队": "Individuals and small teams who need long-term creative asset accumulation", "可选能力": "Optional Capabilities", - "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。": "The Team Gateway exposes OpenAI/Anthropic-compatible endpoints on LAN, enabling shared provider strategies and default models; it is optional for solo creators.", "常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。": "Common credential paths: Kiro `~/.kiro/kiro_creds.json`, Gemini CLI `~/.gemini/oauth_creds.json`, Qwen `~/.qwen-coder/auth.json`.", "本地工具环境(可选)": "Local Tool Environment (Optional)", "Made with love for creators & builders": "Made with love for creators & builders", diff --git a/src/i18n/patches/zh.json b/src/i18n/patches/zh.json index f82c2dd2e..6d5155d88 100644 --- a/src/i18n/patches/zh.json +++ b/src/i18n/patches/zh.json @@ -7,10 +7,6 @@ "深色模式": "深色模式", "浅色模式": "浅色模式", "AI Agent": "AI Agent", - "API Server": "API Server", - "团队共享网关": "团队共享网关", - "团队共享网关(内网)": "团队共享网关(内网)", - "AI Agent + 团队网关": "AI Agent + 团队网关", "加载侧边栏插件失败:": "加载侧边栏插件失败:", "// === Settings Page (src/components/settings/SettingsPage.tsx) ===": "", "通用": "通用", @@ -61,8 +57,8 @@ "检测中...": "检测中...", "已安装": "已安装", "未安装": "未安装", - "Lime 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。": "Lime 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。", - "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)": "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)", + "Lime 是一个 AI Agent 工作台,围绕主题工作台、资源沉淀与创作链路组织能力。": "Lime 是一个 AI Agent 工作台,围绕主题工作台、资源沉淀与创作链路组织能力。", + "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 按主题工作台持续沉淀内容、图片与项目资源": "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 按主题工作台持续沉淀内容、图片与项目资源", "检查更新失败": "检查更新失败", "安装程序已启动,应用将自动关闭以完成更新": "安装程序已启动,应用将自动关闭以完成更新", "下载失败,请手动下载": "下载失败,请手动下载", @@ -96,7 +92,7 @@ "已复制 cURL 命令": "已复制 cURL 命令", "已导出 JSON 文件": "已导出 JSON 文件", "// === Provider Pool Page (src/components/provider-pool/ProviderPoolPage.tsx) ===": "", - "管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 Provider 后自动使用对应凭证": "管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 Provider 后自动使用对应凭证", + "管理多个 AI 服务凭证,自动轮询负载均衡。本机默认 Provider 会直接复用这里的凭证来源": "管理多个 AI 服务凭证,自动轮询负载均衡。本机默认 Provider 会直接复用这里的凭证来源", "导入配置": "导入配置", "从高级设置导入 Private 凭证": "从高级设置导入 Private 凭证", "OAuth 凭证": "OAuth 凭证", @@ -124,52 +120,13 @@ "健康检查未通过": "健康检查未通过", "Token 刷新成功!": "Token 刷新成功!", "编辑失败:": "编辑失败:", + "失败": "失败", "混合": "混合", "使用": "使用", "次": "次", "暂无可用凭证,请先在凭证池或 API Key 设置中添加凭证": "暂无可用凭证,请先在凭证池或 API Key 设置中添加凭证", "当前类型无可用凭证,请先在凭证池中添加": "当前类型无可用凭证,请先在凭证池中添加", "插件": "插件", - "// === API Server Page (src/components/api-server/ApiServerPage.tsx) ===": "", - "Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关": "Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关", - "局域网": "局域网", - "运行中": "运行中", - "已停止": "已停止", - "请求": "请求", - "网关控制": "网关控制", - "路由端点": "路由端点", - "网关日志": "网关日志", - "关闭共享": "关闭共享", - "开启共享": "开启共享", - "共享模式:": "共享模式:", - "仅本机": "仅本机", - "内网共享": "内网共享", - "共享地址:": "共享地址:", - "请先关闭共享再修改配置": "请先关闭共享再修改配置", - "仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放": "仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放", - "当前处于": "当前处于", - "模式。修改配置需要先关闭共享": "模式。修改配置需要先关闭共享", - "处理中...": "处理中...", - "端口:": "端口:", - "默认 Provider": "默认 Provider", - "已切换到": "已切换到", - "切换失败:": "切换失败:", - "共享网关已开启": "共享网关已开启", - "共享网关已关闭": "共享网关已关闭", - "开启失败:": "开启失败:", - "关闭失败:": "关闭失败:", - "网关配置已保存": "网关配置已保存", - "网关 API 测试": "网关 API 测试", - "已切换为仅本机模式": "已切换为仅本机模式", - "已切换为内网共享模式": "已切换为内网共享模式", - "测试全部": "测试全部", - "健康检查": "健康检查", - "模型列表": "模型列表", - "未测试": "未测试", - "测试中...": "测试中...", - "失败": "失败", - "无响应内容": "无响应内容", - "请求失败:": "请求失败:", "// === Tools Page (src/components/tools/ToolsPage.tsx) ===": "", "工具箱": "工具箱", "Lime 提供的实用工具集合": "Lime 提供的实用工具集合", @@ -811,7 +768,6 @@ "获取模型配置失败": "获取模型配置失败", "获取": "获取", "模型列表失败": "模型列表失败", - "// === components\\api-server\\ApiServerPage.test.ts ===": "", "应返回": "应返回", "其他": "其他", "应返回默认模型列表": "应返回默认模型列表", @@ -820,29 +776,6 @@ "应显示": "应显示", "测试端点": "测试端点", "不应显示": "不应显示", - "// === components\\api-server\\ApiServerPage.tsx ===": "", - "启动失败": "启动失败", - "停止失败": "停止失败", - "切换失败": "切换失败", - "请求失败": "请求失败", - "测试中": "测试中", - "本地代理服务器": "本地代理服务器", - "支持": "支持", - "格式": "格式", - "紧凑版": "紧凑版", - "处理中": "处理中", - "端口": "端口", - "动态显示有凭证的": "动态显示有凭证的", - "暂无可用凭证": "暂无可用凭证", - "请先在凭证池或": "请先在凭证池或", - "设置中添加凭证": "设置中添加凭证", - "当前选中类型的凭证列表": "当前选中类型的凭证列表", - "当前类型无可用凭证": "当前类型无可用凭证", - "请先在凭证池中添加": "请先在凭证池中添加", - "测试": "测试", - "响应": "响应", - "空响应": "空响应", - "// === components\\api-server\\EnhancedModelsTab.tsx ===": "", "初始显示": "初始显示", "从未同步": "从未同步", "头部信息": "头部信息", @@ -867,12 +800,10 @@ "定价": "定价", "取消收藏": "取消收藏", "复制模型": "复制模型", - "// === components\\api-server\\LogsTab.tsx ===": "", "清空": "清空", "暂无日志": "暂无日志", "软件运行时将显示系统日志": "软件运行时将显示系统日志", "暂无日志,软件运行时将显示网关与系统日志": "暂无日志,软件运行时将显示网关与系统日志", - "// === components\\api-server\\ModelsTab.tsx ===": "", "智谱": "智谱", "月之暗面": "月之暗面", "搜索模型": "搜索模型", @@ -884,17 +815,10 @@ "不同": "不同", "支持的模型不同": "支持的模型不同", "请确保已配置对应的凭证": "请确保已配置对应的凭证", - "// === components\\api-server\\RoutesTab.tsx ===": "", - "可用路由端点": "可用路由端点", - "通过不同的": "通过不同的", - "路径访问不同的": "路径访问不同的", - "服务器地址": "服务器地址", "已禁用": "已禁用", "收起": "收起", "展开": "展开", - "端点地址": "端点地址", "示例": "示例", - "复制命令": "复制命令", "// === components\\AppSidebar.tsx ===": "", "加载侧边栏插件失败": "加载侧边栏插件失败", "动态插件入口": "动态插件入口", @@ -3843,7 +3767,6 @@ "运营与品牌内容团队": "运营与品牌内容团队", "需要长期沉淀创作资产的个人与小团队": "需要长期沉淀创作资产的个人与小团队", "可选能力": "可选能力", - "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。": "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。", "常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。": "常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。", "本地工具环境(可选)": "本地工具环境(可选)", "Made with love for creators & builders": "Made with love for creators & builders", diff --git a/src/lib/api/serverRuntime.test.ts b/src/lib/api/serverRuntime.test.ts index 6ff55648d..fcb051ac7 100644 --- a/src/lib/api/serverRuntime.test.ts +++ b/src/lib/api/serverRuntime.test.ts @@ -4,10 +4,7 @@ import { exportSupportBundle, getLogStorageDiagnostics, getServerDiagnostics, - getServerStatus, getWindowsStartupDiagnostics, - startServer, - stopServer, } from "./serverRuntime"; vi.mock("@/lib/dev-bridge", () => ({ @@ -19,18 +16,8 @@ describe("serverRuntime API", () => { vi.clearAllMocks(); }); - it("应代理服务控制命令", async () => { - vi.mocked(safeInvoke) - .mockResolvedValueOnce("started") - .mockResolvedValueOnce("stopped"); - - await expect(startServer()).resolves.toBe("started"); - await expect(stopServer()).resolves.toBe("stopped"); - }); - it("应代理诊断类命令", async () => { vi.mocked(safeInvoke) - .mockResolvedValueOnce({ running: true, host: "127.0.0.1", port: 8080 }) .mockResolvedValueOnce({ generated_at: "now", running: true }) .mockResolvedValueOnce({ current_log_exists: true, @@ -44,9 +31,6 @@ describe("serverRuntime API", () => { has_warnings: false, }); - await expect(getServerStatus()).resolves.toEqual( - expect.objectContaining({ running: true }), - ); await expect(getServerDiagnostics()).resolves.toEqual( expect.objectContaining({ running: true }), ); diff --git a/src/lib/api/serverRuntime.ts b/src/lib/api/serverRuntime.ts index e8a1f4386..800d32789 100644 --- a/src/lib/api/serverRuntime.ts +++ b/src/lib/api/serverRuntime.ts @@ -1,21 +1,5 @@ import { safeInvoke } from "@/lib/dev-bridge"; -export interface ServerStatus { - running: boolean; - host: string; - port: number; - requests: number; - uptime_secs: number; - error_rate_1m: number; - p95_latency_ms_1m: number | null; - open_circuit_count: number; - active_requests: number; - capability_routing: CapabilityRoutingMetricsSnapshot; - response_cache: ResponseCacheStats; - request_dedup: RequestDedupStats; - idempotency: IdempotencyStats; -} - export interface CapabilityRoutingMetricsSnapshot { filter_eval_total: number; filter_excluded_total: number; @@ -176,18 +160,6 @@ export interface WindowsStartupDiagnostics { summary_message?: string | null; } -export async function startServer(): Promise { - return safeInvoke("start_server"); -} - -export async function stopServer(): Promise { - return safeInvoke("stop_server"); -} - -export async function getServerStatus(): Promise { - return safeInvoke("get_server_status"); -} - export async function getServerDiagnostics(): Promise { return safeInvoke("get_server_diagnostics"); } diff --git a/src/lib/api/serverTools.test.ts b/src/lib/api/serverTools.test.ts deleted file mode 100644 index 6c3e3fb30..000000000 --- a/src/lib/api/serverTools.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { safeInvoke } from "@/lib/dev-bridge"; -import { getNetworkInfo, testApi } from "./serverTools"; - -vi.mock("@/lib/dev-bridge", () => ({ - safeInvoke: vi.fn(), -})); - -describe("serverTools API", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("应代理测试接口与网络信息命令", async () => { - vi.mocked(safeInvoke) - .mockResolvedValueOnce({ - success: true, - status: 200, - body: "ok", - time_ms: 10, - }) - .mockResolvedValueOnce({ - localhost: "127.0.0.1", - lan_ip: null, - all_ips: [], - }); - - await expect(testApi("GET", "/health", null, false)).resolves.toEqual( - expect.objectContaining({ success: true }), - ); - await expect(getNetworkInfo()).resolves.toEqual( - expect.objectContaining({ localhost: "127.0.0.1" }), - ); - }); -}); diff --git a/src/lib/api/serverTools.ts b/src/lib/api/serverTools.ts deleted file mode 100644 index 707f44181..000000000 --- a/src/lib/api/serverTools.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { safeInvoke } from "@/lib/dev-bridge"; - -export interface TestResult { - success: boolean; - status: number; - body: string; - time_ms: number; - response_headers?: Record; -} - -export interface NetworkInfo { - localhost: string; - lan_ip: string | null; - all_ips: string[]; -} - -export async function testApi( - method: string, - path: string, - body: string | null, - auth: boolean, -): Promise { - return safeInvoke("test_api", { method, path, body, auth }); -} - -export async function getNetworkInfo(): Promise { - return safeInvoke("get_network_info"); -} diff --git a/src/lib/api/serviceSkills.test.ts b/src/lib/api/serviceSkills.test.ts index fcbdc94cb..cd22daf56 100644 --- a/src/lib/api/serviceSkills.test.ts +++ b/src/lib/api/serviceSkills.test.ts @@ -65,35 +65,12 @@ describe("serviceSkills API", () => { expect(catalog.tenantId).toBe("local-seeded"); expect(catalog.items.length).toBeGreaterThan(0); expect( - catalog.items.find((item) => item.id === "github-repo-radar") - ).toEqual( - expect.objectContaining({ - skillType: "site", - outputDestination: - "采集结果会优先写回当前内容;如果当前内容不可用,再沉淀为项目资源。", - triggerHints: expect.arrayContaining(["需要围绕某个技术主题快速找 GitHub 仓库线索时使用。"]), - setupRequirements: expect.arrayContaining(["需要浏览器里已有 GitHub 登录态。"]), - examples: expect.arrayContaining([ - "帮我查一批和 MCP browser automation 相关的 GitHub 仓库。", - ]), - siteCapabilityBinding: expect.objectContaining({ - adapterName: "github/search", - autoRun: true, - requireAttachedSession: true, - saveMode: "current_content", - }), - skillBundle: expect.objectContaining({ - name: "github-repo-radar", - standardCompliance: expect.objectContaining({ - isStandard: true, - }), - metadata: expect.objectContaining({ - Lime_skill_type: "site", - Lime_site_adapter: "github/search", - }), - }), - }), - ); + catalog.items.some( + (item) => + item.defaultExecutorBinding === "browser_assist" || + Boolean(item.siteCapabilityBinding), + ), + ).toBe(false); expect( catalog.items.find((item) => item.id === "carousel-post-replication"), ).toEqual( @@ -169,7 +146,7 @@ describe("serviceSkills API", () => { const catalog = await getServiceSkillCatalog(); expect(catalog.tenantId).toBe("local-seeded"); - expect(catalog.version).toBe("client-seed-2026-03-24"); + expect(catalog.version).toBe("client-seed-2026-03-30"); }); it("当前 OEM 租户不匹配时不应读取其他租户的缓存目录", async () => { @@ -182,14 +159,14 @@ describe("serviceSkills API", () => { const catalog = await getServiceSkillCatalog(); expect(catalog.tenantId).toBe("local-seeded"); - expect(catalog.version).toBe("client-seed-2026-03-24"); + expect(catalog.version).toBe("client-seed-2026-03-30"); }); it("旧的 seeded 本地缓存应自动升级到当前 seeded 目录", async () => { const oldSeeded = getSeededServiceSkillCatalog(); const downgraded = { ...oldSeeded, - items: oldSeeded.items.filter((item) => item.id !== "github-repo-radar"), + items: oldSeeded.items.filter((item) => item.id !== "daily-trend-briefing"), }; window.localStorage.setItem( @@ -199,11 +176,11 @@ describe("serviceSkills API", () => { const catalog = await getServiceSkillCatalog(); - expect(catalog.items.some((item) => item.id === "github-repo-radar")).toBe( + expect(catalog.items.some((item) => item.id === "daily-trend-briefing")).toBe( true, ); const stored = window.localStorage.getItem("lime:service-skill-catalog:v1"); - expect(stored).toContain('"github-repo-radar"'); + expect(stored).toContain('"daily-trend-briefing"'); }); it("目录变更时应广播 catalog change 事件", () => { diff --git a/src/lib/api/serviceSkills.ts b/src/lib/api/serviceSkills.ts index 2055d1bc3..5dbf05daf 100644 --- a/src/lib/api/serviceSkills.ts +++ b/src/lib/api/serviceSkills.ts @@ -157,7 +157,7 @@ interface ServiceSkillCatalogResponseEnvelope { const SERVICE_SKILL_CATALOG_STORAGE_KEY = "lime:service-skill-catalog:v1"; export const SERVICE_SKILL_CATALOG_CHANGED_EVENT = "lime:service-skill-catalog-changed"; -const SEEDED_SERVICE_SKILL_CATALOG_VERSION = "client-seed-2026-03-24"; +const SEEDED_SERVICE_SKILL_CATALOG_VERSION = "client-seed-2026-03-30"; const PLATFORM_OPTIONS: ServiceSkillSlotOption[] = [ { value: "xiaohongshu", label: "小红书" }, @@ -663,68 +663,6 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, ], }, - { - id: "github-repo-radar", - skillKey: "github-repo-radar", - skillType: "site", - title: "GitHub 仓库线索检索", - summary: - "复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。", - category: "情报研究", - outputHint: "仓库列表 + 关键线索", - triggerHints: [ - "需要围绕某个技术主题快速找 GitHub 仓库线索时使用。", - "已经登录 GitHub,希望直接把真实检索结果沉淀到当前内容时使用。", - ], - source: "cloud_catalog", - runnerType: "instant", - defaultExecutorBinding: "browser_assist", - executionLocation: "client_default", - defaultArtifactKind: "analysis", - readinessRequirements: { - requiresBrowser: true, - requiresProject: true, - }, - usageGuidelines: [ - "适合先做一轮主题检索,再回到工作区筛选值得继续跟进的仓库。", - "检索词尽量包含技术栈、业务场景或目标能力,结果会更聚焦。", - ], - setupRequirements: [ - "需要浏览器里已有 GitHub 登录态。", - "建议在目标项目内启动,方便结果优先写回当前内容。", - ], - examples: [ - "帮我查一批和 MCP browser automation 相关的 GitHub 仓库。", - "围绕 AI coding agent 这个主题搜 GitHub 仓库,并沉淀关键线索。", - ], - outputDestination: - "采集结果会优先写回当前内容;如果当前内容不可用,再沉淀为项目资源。", - siteCapabilityBinding: { - adapterName: "github/search", - autoRun: true, - requireAttachedSession: true, - saveMode: "current_content", - slotArgMap: { - repository_query: "query", - }, - fixedArgs: { - limit: 10, - }, - suggestedTitleTemplate: "GitHub 仓库线索 · {{repository_query}}", - }, - themeTarget: "knowledge", - version: SEEDED_SERVICE_SKILL_CATALOG_VERSION, - slotSchema: [ - { - key: "repository_query", - label: "检索主题", - type: "text", - required: true, - placeholder: "例如 MCP agent browser automation", - helpText: "开始后会复用当前浏览器登录态执行 GitHub 仓库搜索。", - }, - ], - }, { id: "daily-trend-briefing", skillKey: "daily-trend-briefing", diff --git a/src/lib/api/skillCatalog.test.ts b/src/lib/api/skillCatalog.test.ts index 88fff0b43..83aa646a7 100644 --- a/src/lib/api/skillCatalog.test.ts +++ b/src/lib/api/skillCatalog.test.ts @@ -1,153 +1,117 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + clearSkillCatalogCache, + getSeededSkillCatalog, + getSkillCatalog, + saveSkillCatalog, + type SkillCatalog, +} from "./skillCatalog"; -const { siteListAdaptersMock } = vi.hoisted(() => ({ - siteListAdaptersMock: vi.fn(), -})); +function buildLegacyCatalogWithSiteEntries(): SkillCatalog { + const seeded = getSeededSkillCatalog(); + const generalSkill = seeded.items[0]!; -vi.mock("../webview-api", () => ({ - siteListAdapters: siteListAdaptersMock, -})); - -import { clearSkillCatalogCache, getSkillCatalog } from "./skillCatalog"; + return { + version: "tenant-2026-03-30", + tenantId: "tenant-demo", + syncedAt: "2026-03-30T12:00:00.000Z", + groups: [ + { + key: "github", + title: "GitHub", + summary: "围绕仓库与 Issue 的只读研究技能。", + sort: 10, + itemCount: 1, + }, + { + key: "general", + title: "通用技能", + summary: "不依赖站点登录态的业务技能。", + sort: 90, + itemCount: 1, + }, + ], + items: [ + { + ...generalSkill, + id: "legacy-site-skill", + title: "旧版 GitHub 站点技能", + skillType: "site", + defaultExecutorBinding: "browser_assist", + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + requireAttachedSession: true, + saveMode: "current_content", + slotArgMap: { + reference_topic: "query", + }, + }, + groupKey: "github", + execution: { + kind: "site_adapter", + siteAdapterBinding: { + adapterName: "github/search", + autoRun: true, + requireAttachedSession: true, + saveMode: "current_content", + slotArgMap: { + reference_topic: "query", + }, + }, + }, + }, + { + ...generalSkill, + id: "tenant-daily-briefing", + title: "租户日报摘要", + summary: "远端同步后的目录项", + groupKey: "general", + execution: { + kind: "agent_turn", + }, + }, + ], + }; +} describe("skillCatalog", () => { beforeEach(() => { window.localStorage.clear(); - siteListAdaptersMock.mockReset(); }); afterEach(() => { window.localStorage.clear(); clearSkillCatalogCache(); - vi.restoreAllMocks(); }); - it("应把运行时站点 adapter 合并为技能目录项,并为新站点补齐分组", async () => { - siteListAdaptersMock.mockResolvedValue([ - { - name: "bilibili/search", - domain: "search.bilibili.com", - description: "按关键词采集 B 站视频搜索结果。", - read_only: true, - capabilities: ["search", "video", "research"], - input_schema: { - type: "object", - properties: { - query: { - type: "string", - description: "搜索关键词", - example: "AI Agent", - }, - limit: { - type: "integer", - description: "返回条目数量上限", - example: 5, - }, - }, - required: ["query"], - additionalProperties: true, - }, - example_args: { - query: "AI Agent", - limit: 5, - }, - example: 'bilibili/search {"query":"AI Agent","limit":5}', - source_kind: "bundled", - source_version: "2026-03-25", - }, - { - name: "github/search", - domain: "github.com", - description: "按关键词采集 GitHub 仓库搜索结果。", - read_only: true, - capabilities: ["search", "repository", "research"], - input_schema: { - type: "object", - properties: { - query: { - type: "string", - description: "搜索关键词", - example: "AI Agent", - }, - }, - required: ["query"], - additionalProperties: true, - }, - example_args: { - query: "AI Agent", - }, - example: 'github/search {"query":"AI Agent"}', - source_kind: "bundled", - source_version: "2026-03-25", - }, - ]); - + it("seeded 目录不应再暴露站点 adapter 或 browser assist 首页入口", async () => { const catalog = await getSkillCatalog(); - const bilibiliSkill = catalog.items.find( - (item) => item.id === "site-adapter-bilibili-search", - ); - expect(bilibiliSkill).toEqual( - expect.objectContaining({ - title: "Bilibili 视频检索", - groupKey: "bilibili", - skillType: "site", - themeTarget: "video", - siteCapabilityBinding: expect.objectContaining({ - adapterName: "bilibili/search", - autoRun: true, - requireAttachedSession: true, - saveMode: "current_content", - }), - execution: expect.objectContaining({ - kind: "site_adapter", - }), - }), - ); - expect(bilibiliSkill?.slotSchema).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "query", - label: "检索词", - required: true, - defaultValue: "AI Agent", - }), - expect.objectContaining({ - key: "limit", - label: "条目上限", - required: false, - defaultValue: "5", - }), - ]), - ); + expect(catalog.groups.map((group) => group.key)).toEqual(["general"]); expect( - catalog.groups.find((group) => group.key === "bilibili"), - ).toEqual( - expect.objectContaining({ - title: "Bilibili", - itemCount: expect.any(Number), - }), - ); - expect( - catalog.items.some((item) => item.id === "site-adapter-github-search"), + catalog.items.some((item) => item.execution.kind === "site_adapter"), ).toBe(false); expect( catalog.items.some( - (item) => item.siteCapabilityBinding?.adapterName === "github/search", + (item) => + item.defaultExecutorBinding === "browser_assist" || + Boolean(item.siteCapabilityBinding), ), - ).toBe(true); + ).toBe(false); }); - it("站点 adapter 读取失败时应回退到原有 seeded 目录", async () => { - siteListAdaptersMock.mockRejectedValue(new Error("bridge unavailable")); + it("读取旧版远端目录时应过滤 site_adapter 和 browser assist 项", async () => { + saveSkillCatalog(buildLegacyCatalogWithSiteEntries(), "bootstrap_sync"); const catalog = await getSkillCatalog(); - expect(catalog.items.some((item) => item.id === "github-repo-radar")).toBe( - true, - ); - expect( - catalog.items.some((item) => item.id === "site-adapter-bilibili-search"), - ).toBe(false); + expect(catalog.items.map((item) => item.id)).toEqual([ + "tenant-daily-briefing", + ]); + expect(catalog.groups.map((group) => group.key)).toEqual(["general"]); + + const stored = window.localStorage.getItem("lime:skill-catalog:v1"); + expect(stored).not.toContain("legacy-site-skill"); }); }); diff --git a/src/lib/api/skillCatalog.ts b/src/lib/api/skillCatalog.ts index 44e8eb8ec..75ab6b35b 100644 --- a/src/lib/api/skillCatalog.ts +++ b/src/lib/api/skillCatalog.ts @@ -8,12 +8,7 @@ import { type ServiceSkillExecutorBinding, type ServiceSkillItem, type ServiceSkillSiteCapabilityBinding, - type ServiceSkillSlotDefinition, } from "./serviceSkills"; -import { - siteListAdapters, - type SiteAdapterDefinition, -} from "../webview-api"; export type SkillCatalogExecutionKind = | "native_skill" @@ -130,25 +125,7 @@ const SEEDED_SKILL_GROUP_PRESETS = [ sort: 90, }, ] as const; -const SEEDED_SKILL_CATALOG_VERSION = "client-seed-skill-catalog-2026-03-29"; -const SITE_ADAPTER_TITLE_OVERRIDES: Record = { - "36kr/newsflash": "36Kr 快讯追踪", - "bilibili/search": "Bilibili 视频检索", - "github/issues": "GitHub Issue 检索", - "github/search": "GitHub 仓库检索", - "linux-do/categories": "Linux.do 分类扫描", - "linux-do/hot": "Linux.do 热门话题", - "smzdm/search": "什么值得买线索检索", - "yahoo-finance/quote": "Yahoo Finance 行情摘要", -}; -const SITE_ADAPTER_ARG_LABEL_OVERRIDES: Record = { - query: "检索词", - limit: "条目上限", - repo: "仓库", - state: "状态", - symbol: "股票代码", - period: "时间周期", -}; +const SEEDED_SKILL_CATALOG_VERSION = "client-seed-skill-catalog-2026-03-30"; const SITE_GROUP_TITLE_OVERRIDES: Record = { github: "GitHub", zhihu: "知乎", @@ -270,14 +247,6 @@ function resolveAdapterGroupKey(adapterName?: string | null): string { return prefix || "general"; } -function normalizeSiteAdapterId(adapterName: string): string { - return `site-adapter-${adapterName - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "")}`; -} - function titleCaseSegment(value: string): string { if (!value) { return value; @@ -300,266 +269,6 @@ function resolveKnownGroupPreset(groupKey: string) { return SEEDED_SKILL_GROUP_PRESETS.find((preset) => preset.key === groupKey); } -function sanitizeSentence(value: string, fallback: string): string { - const normalized = value.trim(); - if (!normalized) { - return fallback; - } - return /[。!?.!?]$/.test(normalized) ? normalized : `${normalized}。`; -} - -function resolveSiteAdapterTitle(adapter: SiteAdapterDefinition): string { - const normalizedName = adapter.name.trim().toLowerCase(); - if (SITE_ADAPTER_TITLE_OVERRIDES[normalizedName]) { - return SITE_ADAPTER_TITLE_OVERRIDES[normalizedName]!; - } - - const groupTitle = resolveGroupTitle(resolveAdapterGroupKey(adapter.name)); - const action = normalizedName.split("/")[1] ?? normalizedName; - return `${groupTitle} ${action - .split(/[-_]/) - .map(titleCaseSegment) - .join(" ")}`; -} - -function resolveSiteAdapterCategory(adapter: SiteAdapterDefinition): string { - const capabilities = adapter.capabilities.map((item) => item.toLowerCase()); - if (capabilities.includes("video")) { - return "视频研究"; - } - if (capabilities.includes("finance") || capabilities.includes("quote")) { - return "财经研究"; - } - if (capabilities.includes("shopping") || capabilities.includes("deals")) { - return "消费研究"; - } - if (capabilities.includes("community") || capabilities.includes("topics")) { - return "社区研究"; - } - return "情报研究"; -} - -function resolveSiteAdapterOutputHint(adapter: SiteAdapterDefinition): string { - const capabilities = adapter.capabilities.map((item) => item.toLowerCase()); - if (capabilities.includes("video")) { - return "视频线索 + 结构化结果"; - } - if (capabilities.includes("finance") || capabilities.includes("quote")) { - return "行情摘要 + 结构化结果"; - } - if (capabilities.includes("newsflash")) { - return "快讯列表 + 结构化结果"; - } - if (capabilities.includes("categories")) { - return "分类列表 + 结构化结果"; - } - if (capabilities.includes("topics") || capabilities.includes("hot")) { - return "热门话题 + 结构化结果"; - } - return "结构化线索"; -} - -function resolveSiteAdapterThemeTarget(adapter: SiteAdapterDefinition): string { - const capabilities = adapter.capabilities.map((item) => item.toLowerCase()); - if (capabilities.includes("video")) { - return "video"; - } - return "knowledge"; -} - -function resolveSiteAdapterAliases(adapter: SiteAdapterDefinition): string[] { - const siteTitle = resolveGroupTitle(resolveAdapterGroupKey(adapter.name)); - return [ - adapter.name, - adapter.domain, - siteTitle, - ...adapter.capabilities, - ].filter((value, index, array) => value && array.indexOf(value) === index); -} - -function resolveSiteAdapterEntryHint(adapter: SiteAdapterDefinition): string { - const siteTitle = resolveGroupTitle(resolveAdapterGroupKey(adapter.name)); - const authHint = normalizeText(adapter.auth_hint); - if (authHint) { - return authHint; - } - return sanitizeSentence( - `复用你当前浏览器里的 ${siteTitle} 上下文,直接执行一轮站点采集并把结果回流到当前工作区`, - `复用你当前浏览器里的 ${siteTitle} 上下文,直接执行一轮站点采集并把结果回流到当前工作区。`, - ); -} - -function resolveSiteAdapterSetupRequirements( - adapter: SiteAdapterDefinition, -): string[] { - const siteTitle = resolveGroupTitle(resolveAdapterGroupKey(adapter.name)); - const requirements = [ - `需要浏览器里已有 ${siteTitle} 可用会话。`, - "建议在目标项目内启动,方便采集结果继续沉淀到当前工作区。", - ]; - const authHint = normalizeText(adapter.auth_hint); - if (authHint) { - requirements.unshift(sanitizeSentence(authHint, authHint)); - } - return requirements.filter( - (value, index, array) => array.indexOf(value) === index, - ); -} - -function resolveSiteAdapterUsageGuidelines( - adapter: SiteAdapterDefinition, -): string[] { - return [ - sanitizeSentence(adapter.description, "适合先采集一轮站点结果。"), - "优先补齐最关键的检索参数,再进入当前工作区继续分析和整理。", - ]; -} - -function resolveSiteAdapterExamples(adapter: SiteAdapterDefinition): string[] { - const example = normalizeText(adapter.example); - return example ? [example] : []; -} - -function resolveSiteAdapterOutputDestination(): string { - return "结果会优先写回当前内容;如果当前内容不可用,再沉淀为项目资源。"; -} - -function resolveSiteAdapterArgLabel(argName: string, description?: string): string { - if (SITE_ADAPTER_ARG_LABEL_OVERRIDES[argName]) { - return SITE_ADAPTER_ARG_LABEL_OVERRIDES[argName]!; - } - const normalizedDescription = normalizeText(description); - if (normalizedDescription && normalizedDescription.length <= 12) { - return normalizedDescription; - } - return argName - .split(/[_-]/) - .map(titleCaseSegment) - .join(" "); -} - -function stringifySiteAdapterDefaultValue(value: unknown): string | undefined { - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - return String(value); - } - return undefined; -} - -function buildSiteAdapterSlotSchema( - adapter: SiteAdapterDefinition, -): ServiceSkillSlotDefinition[] { - const schema = isPlainRecord(adapter.input_schema) ? adapter.input_schema : null; - const properties = isPlainRecord(schema?.properties) ? schema.properties : null; - const requiredSet = new Set( - Array.isArray(schema?.required) - ? schema.required.filter((item): item is string => typeof item === "string") - : [], - ); - const exampleArgs = isPlainRecord(adapter.example_args) ? adapter.example_args : {}; - const keys = properties - ? Object.keys(properties) - : Object.keys(exampleArgs); - - return keys.map((key) => { - const property = properties && isPlainRecord(properties[key]) ? properties[key] : null; - const description = normalizeText(property?.description); - const example = - property?.example !== undefined ? property.example : exampleArgs[key]; - const slotType = - /url|link/i.test(key) || /链接|地址/.test(description ?? "") - ? "url" - : "text"; - - return { - key, - label: resolveSiteAdapterArgLabel(key, description ?? undefined), - type: slotType, - required: requiredSet.has(key), - placeholder: - description ?? `输入${resolveSiteAdapterArgLabel(key, description ?? undefined)}`, - defaultValue: stringifySiteAdapterDefaultValue(example), - helpText: description ?? undefined, - }; - }); -} - -function buildSiteAdapterSlotArgMap( - slotSchema: ServiceSkillSlotDefinition[], -): Record | undefined { - if (slotSchema.length === 0) { - return undefined; - } - - return slotSchema.reduce>((acc, slot) => { - acc[slot.key] = slot.key; - return acc; - }, {}); -} - -function buildSiteAdapterSkillItem( - adapter: SiteAdapterDefinition, -): SkillCatalogItem { - const id = normalizeSiteAdapterId(adapter.name); - const slotSchema = buildSiteAdapterSlotSchema(adapter); - const groupKey = resolveAdapterGroupKey(adapter.name); - const summary = sanitizeSentence(adapter.description, "站点采集技能。"); - - return { - id, - skillKey: id, - skillType: "site", - title: resolveSiteAdapterTitle(adapter), - summary, - entryHint: resolveSiteAdapterEntryHint(adapter), - aliases: resolveSiteAdapterAliases(adapter), - category: resolveSiteAdapterCategory(adapter), - outputHint: resolveSiteAdapterOutputHint(adapter), - triggerHints: [ - sanitizeSentence(adapter.description, "需要站点采集时使用。"), - "适合复用当前浏览器里的真实登录态直接抓一轮结构化结果。", - ], - source: "cloud_catalog", - runnerType: "instant", - defaultExecutorBinding: "browser_assist", - executionLocation: "client_default", - defaultArtifactKind: "analysis", - readinessRequirements: { - requiresBrowser: true, - requiresProject: true, - }, - usageGuidelines: resolveSiteAdapterUsageGuidelines(adapter), - setupRequirements: resolveSiteAdapterSetupRequirements(adapter), - examples: resolveSiteAdapterExamples(adapter), - outputDestination: resolveSiteAdapterOutputDestination(), - siteCapabilityBinding: { - adapterName: adapter.name, - autoRun: true, - requireAttachedSession: true, - saveMode: "current_content", - slotArgMap: buildSiteAdapterSlotArgMap(slotSchema), - }, - slotSchema, - surfaceScopes: ["home", "mention", "workspace"], - themeTarget: resolveSiteAdapterThemeTarget(adapter), - version: adapter.source_version || SEEDED_SKILL_CATALOG_VERSION, - groupKey, - execution: { - kind: "site_adapter", - siteAdapterBinding: { - adapterName: adapter.name, - autoRun: true, - requireAttachedSession: true, - saveMode: "current_content", - slotArgMap: buildSiteAdapterSlotArgMap(slotSchema), - }, - }, - }; -} - function buildFallbackGroupPreset(groupKey: string): Omit { const title = resolveGroupTitle(groupKey); return { @@ -585,7 +294,7 @@ function mergeCatalogGroups( for (const group of currentGroups) { groupsByKey.set(group.key, { ...group, - itemCount: itemCountByGroup[group.key] ?? group.itemCount ?? 0, + itemCount: itemCountByGroup[group.key] ?? 0, }); } @@ -607,40 +316,30 @@ function mergeCatalogGroups( .sort((left, right) => left.sort - right.sort); } -async function mergeRuntimeSiteAdaptersIntoCatalog( - catalog: SkillCatalog, -): Promise { - try { - const adapters = await siteListAdapters(); - const existingAdapterNames = new Set( - catalog.items - .map((item) => normalizeText(item.siteCapabilityBinding?.adapterName)) - .filter((item): item is string => Boolean(item)) - .map((item) => item.toLowerCase()), - ); - - const synthesizedItems = adapters - .filter((adapter) => { - const normalizedName = adapter.name.trim().toLowerCase(); - return !existingAdapterNames.has(normalizedName); - }) - .map((adapter) => buildSiteAdapterSkillItem(adapter)); - - if (synthesizedItems.length === 0) { - return catalog; - } - - const mergedItems = [...catalog.items, ...synthesizedItems]; - const mergedGroups = mergeCatalogGroups(catalog.groups, mergedItems); - - return cloneSkillCatalog({ - ...catalog, - groups: mergedGroups, - items: mergedItems, - }); - } catch { - return catalog; +function shouldExposeSkillCatalogItem(item: SkillCatalogItem): boolean { + if (item.execution.kind === "site_adapter") { + return false; } + + if ( + item.defaultExecutorBinding === "browser_assist" || + item.siteCapabilityBinding + ) { + return false; + } + + return true; +} + +function normalizeSkillCatalog(catalog: SkillCatalog): SkillCatalog { + const filteredItems = catalog.items.filter(shouldExposeSkillCatalogItem); + const normalizedGroups = mergeCatalogGroups(catalog.groups, filteredItems); + + return cloneSkillCatalog({ + ...catalog, + groups: normalizedGroups, + items: filteredItems, + }); } function parseSkillCatalogExecution(value: unknown): SkillCatalogExecution | null { @@ -787,7 +486,7 @@ export function parseSkillCatalog(value: unknown): SkillCatalog | null { items.push(parsed); } - return cloneSkillCatalog({ + return normalizeSkillCatalog({ version, tenantId, syncedAt, @@ -1062,7 +761,7 @@ async function requestRemoteSkillCatalog(): Promise { } export async function getSkillCatalog(): Promise { - const seeded = await mergeRuntimeSiteAdaptersIntoCatalog(getSeededSkillCatalog()); + const seeded = getSeededSkillCatalog(); const cached = readCachedSkillCatalog(); if (cached) { if (!isSeededCatalogCompatibleWithActiveTenant(cached)) { @@ -1074,11 +773,7 @@ export async function getSkillCatalog(): Promise { return seeded; } - const mergedCached = await mergeRuntimeSiteAdaptersIntoCatalog(cached); - if (!isSameSkillCatalog(cached, mergedCached)) { - persistSkillCatalog(mergedCached); - } - return mergedCached; + return cached; } persistSkillCatalog(seeded); @@ -1092,6 +787,5 @@ export async function refreshSkillCatalogFromRemote(): Promise([ "set_browser_connector_install_root_cmd", "set_browser_connector_enabled_cmd", "set_system_connector_enabled_cmd", + "set_browser_action_capability_enabled_cmd", "get_browser_connector_install_status_cmd", "install_browser_connector_extension_cmd", "open_browser_extensions_page_cmd", + "open_browser_remote_debugging_page_cmd", "disconnect_browser_connector_session", "launch_browser_session", "launch_browser_profile_runtime_assist_cmd", diff --git a/src/lib/governance/legacySurfaceCatalog.json b/src/lib/governance/legacySurfaceCatalog.json index 773ea1669..5ec4683ac 100644 --- a/src/lib/governance/legacySurfaceCatalog.json +++ b/src/lib/governance/legacySurfaceCatalog.json @@ -172,6 +172,13 @@ ], "allowedPaths": [] }, + { + "id": "settings-api-server-page-entry", + "classification": "dead-candidate", + "description": "已删除的共享网关设置页入口", + "targets": ["src/components/api-server/ApiServerPage.tsx"], + "allowedPaths": [] + }, { "id": "team-subagent-scheduler-hook", "classification": "compat", @@ -195,6 +202,35 @@ } ], "commands": [ + { + "id": "shared-gateway-control-commands", + "classification": "dead-candidate", + "description": "已下线的共享网关桌面命令边界", + "commands": [ + "start_server", + "stop_server", + "get_server_status", + "get_available_routes", + "get_route_curl_examples", + "test_api", + "get_network_info" + ], + "allowedPaths": [] + }, + { + "id": "shared-gateway-tray-sync-commands", + "classification": "dead-candidate", + "description": "已下线的共享网关托盘同步命令边界", + "commands": [ + "sync_tray_state", + "update_tray_server_status", + "update_tray_credential_status", + "get_tray_state", + "refresh_tray_menu", + "refresh_tray_with_stats" + ], + "allowedPaths": [] + }, { "id": "agent-create-session-compat-command", "classification": "dead-candidate", @@ -680,6 +716,32 @@ "patterns": ["select_credential_with_fallback_legacy"], "allowedPaths": [] }, + { + "id": "rust-shared-gateway-route-list-http-surface", + "classification": "dead-candidate", + "description": "已下线的共享网关 /v1/routes 与 route_model 兼容面", + "patterns": [ + "\"/v1/routes\"", + "async fn list_routes(", + "get_available_routes(", + "models::route_model", + "RouteListResponse" + ], + "allowedPaths": [] + }, + { + "id": "rust-shared-gateway-selector-http-routes", + "classification": "dead-candidate", + "description": "已下线的共享网关 selector HTTP 路由兼容面", + "patterns": [ + "\"/{selector}/v1/messages\"", + "\"/{selector}/v1/chat/completions\"", + "anthropic_messages_with_selector(", + "chat_completions_with_selector(", + "selector '{selector}'" + ], + "allowedPaths": [] + }, { "id": "rust-memory-legacy-command-shells", "classification": "dead-candidate", diff --git a/src/lib/navigation/sidebarNav.ts b/src/lib/navigation/sidebarNav.ts index b95636568..fee2150fa 100644 --- a/src/lib/navigation/sidebarNav.ts +++ b/src/lib/navigation/sidebarNav.ts @@ -4,6 +4,7 @@ import { CalendarRange, Compass, Image, + LayoutGrid, Library, MessageSquare, Palette, @@ -31,12 +32,41 @@ export interface SidebarNavItemDefinition { id: string; label: string; icon: LucideIcon; - page: Page; + page?: Page; params?: PageParams; isActive?: (currentPage: Page, currentParams?: PageParams) => boolean; configurable?: boolean; + children?: SidebarNavItemDefinition[]; + defaultExpanded?: boolean; } +const CAPABILITY_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ + { + id: "skills", + label: "技能", + icon: Sparkles, + page: "skills", + isActive: (currentPage) => currentPage === "skills", + configurable: false, + }, + { + id: "automation", + label: "自动化", + icon: CalendarRange, + page: "automation", + isActive: (currentPage) => currentPage === "automation", + configurable: false, + }, + { + id: "channels", + label: "IM 配置", + icon: MessageSquare, + page: "channels", + isActive: (currentPage) => currentPage === "channels", + configurable: false, + }, +]; + export const MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ { id: "home-general", @@ -59,12 +89,12 @@ export const MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ (currentParams as AgentPageParams | undefined)?.agentEntry !== "new-task", }, { - id: "skills", - label: "技能", - icon: Sparkles, - page: "skills", - isActive: (currentPage) => currentPage === "skills", + id: "capabilities", + label: "能力", + icon: LayoutGrid, configurable: false, + children: CAPABILITY_SIDEBAR_NAV_ITEMS, + defaultExpanded: true, }, { id: "video", @@ -75,12 +105,6 @@ export const MAIN_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"), }, { id: "image-gen", label: "插图", icon: Image, page: "image-gen" }, - { - id: "automation", - label: "自动化", - icon: CalendarRange, - page: "automation", - }, { id: "terminal", label: "终端", icon: Terminal, page: "terminal" }, { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, ]; @@ -155,7 +179,6 @@ export const DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS = [ "claw", "video", "image-gen", - "automation", "openclaw", "resources", "style-library", diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index d116dc608..f13e5b3f1 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -85,6 +85,13 @@ type MockBrowserConnectorSettings = { enabled: boolean; install_root_dir: string | null; install_dir: string | null; + browser_action_capabilities: Array<{ + key: string; + label: string; + description: string; + group: string; + enabled: boolean; + }>; system_connectors: Array<{ id: string; label: string; @@ -109,6 +116,149 @@ type MockBrowserConnectorInstallStatus = { message: string | null; }; +const DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES = [ + { + key: "tabs_context_mcp", + label: "标签页概览", + description: "读取当前已附着标签页的上下文摘要。", + group: "read", + enabled: true, + }, + { + key: "list_tabs", + label: "列出标签页", + description: "列出当前浏览器标签页。", + group: "read", + enabled: true, + }, + { + key: "tabs_create_mcp", + label: "新建标签页", + description: "创建新的浏览器标签页。", + group: "write", + enabled: true, + }, + { + key: "read_page", + label: "页面快照", + description: "抓取当前页面快照。", + group: "read", + enabled: true, + }, + { + key: "get_page_text", + label: "页面文本", + description: "读取当前页面文本内容。", + group: "read", + enabled: true, + }, + { + key: "get_page_info", + label: "页面信息", + description: "读取页面标题、URL 与快照信息。", + group: "read", + enabled: true, + }, + { + key: "find", + label: "页面内查找", + description: "在当前页面中查找文本。", + group: "read", + enabled: true, + }, + { + key: "read_console_messages", + label: "控制台消息", + description: "读取浏览器控制台消息。", + group: "read", + enabled: true, + }, + { + key: "read_network_requests", + label: "网络请求", + description: "读取页面网络请求记录。", + group: "read", + enabled: true, + }, + { + key: "navigate", + label: "导航", + description: "导航到目标地址。", + group: "write", + enabled: true, + }, + { + key: "open_url", + label: "打开链接", + description: "直接打开目标链接。", + group: "write", + enabled: true, + }, + { + key: "click", + label: "点击元素", + description: "点击页面元素。", + group: "write", + enabled: true, + }, + { + key: "type", + label: "输入文本", + description: "向当前页面输入文本。", + group: "write", + enabled: true, + }, + { + key: "form_input", + label: "表单输入", + description: "按字段填写页面表单。", + group: "write", + enabled: true, + }, + { + key: "switch_tab", + label: "切换标签页", + description: "切换当前操作标签页。", + group: "write", + enabled: true, + }, + { + key: "scroll_page", + label: "滚动页面", + description: "滚动当前页面或容器。", + group: "write", + enabled: true, + }, + { + key: "refresh_page", + label: "刷新页面", + description: "刷新当前页面。", + group: "write", + enabled: true, + }, + { + key: "go_back", + label: "返回上一页", + description: "返回上一页。", + group: "write", + enabled: true, + }, + { + key: "go_forward", + label: "前进到下一页", + description: "前进到下一页。", + group: "write", + enabled: true, + }, + { + key: "javascript", + label: "执行脚本", + description: "在当前页面执行脚本。", + group: "write", + enabled: true, + }, +] as const; + const mockBrowserProfiles: MockBrowserProfileRecord[] = [ { id: "browser-profile-general", @@ -155,6 +305,9 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = { enabled: true, install_root_dir: "/mock/path/to/connectors", install_dir: "/mock/path/to/connectors/Lime Browser Connector", + browser_action_capabilities: DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES.map( + (capability) => ({ ...capability }), + ), system_connectors: [ { id: "reminders", @@ -165,11 +318,7 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = { visible: true, authorization_status: "not_determined", last_error: null, - capabilities: [ - "list_reminders", - "create_reminder", - "update_reminder", - ], + capabilities: ["list_reminders", "create_reminder", "update_reminder"], }, { id: "calendar", @@ -218,6 +367,102 @@ let mockBrowserConnectorSettings: MockBrowserConnectorSettings = { ], }; +function normalizeMockBrowserActionCapabilityKey(key: string) { + if (key === "scroll") { + return "scroll_page"; + } + if (key === "javascript_tool") { + return "javascript"; + } + return key; +} + +function filterMockBrowserBackendCapabilities(capabilities: string[]) { + const enabledCapabilities = new Set( + mockBrowserConnectorSettings.browser_action_capabilities + .filter((capability) => capability.enabled) + .map((capability) => capability.key), + ); + return capabilities.filter((capability) => { + const normalized = normalizeMockBrowserActionCapabilityKey(capability); + return ( + !DEFAULT_MOCK_BROWSER_ACTION_CAPABILITIES.some( + (item) => item.key === normalized, + ) || enabledCapabilities.has(normalized) + ); + }); +} + +function buildMockBrowserBackendsStatus() { + return { + policy: { + priority: ["aster_compat", "lime_extension_bridge", "cdp_direct"], + auto_fallback: true, + }, + bridge_observer_count: 1, + bridge_control_count: 0, + running_profile_count: 1, + cdp_alive_profile_count: 1, + aster_native_host_supported: true, + aster_native_host_configured: false, + backends: [ + { + backend: "aster_compat", + available: true, + capabilities: filterMockBrowserBackendCapabilities([ + "navigate", + "read_page", + "tabs_context_mcp", + "list_tabs", + ]), + }, + { + backend: "lime_extension_bridge", + available: true, + capabilities: filterMockBrowserBackendCapabilities([ + "navigate", + "read_page", + "get_page_text", + "find", + "form_input", + "tabs_context_mcp", + "open_url", + "click", + "type", + "scroll", + "scroll_page", + "get_page_info", + "refresh_page", + "go_back", + "go_forward", + "switch_tab", + "list_tabs", + ]), + }, + { + backend: "cdp_direct", + available: true, + capabilities: filterMockBrowserBackendCapabilities([ + "tabs_context_mcp", + "navigate", + "read_page", + "get_page_text", + "find", + "click", + "type", + "scroll_page", + "get_page_info", + "read_console_messages", + "read_network_requests", + "javascript", + ]), + }, + ], + }; +} + +let mockBrowserBackendsStatus = buildMockBrowserBackendsStatus(); + let mockBrowserConnectorInstallStatus: MockBrowserConnectorInstallStatus = { status: "not_installed", install_root_dir: "/mock/path/to/connectors", @@ -510,11 +755,6 @@ function buildMockBrowserSessionLaunchResponse(request: any) { const currentTime = new Date().toISOString(); if (profile) { - if (profile.transport_kind === "existing_session") { - throw new Error( - "当前资料使用“附着当前 Chrome”模式,运行时附着链路尚未接入;请先改用“托管浏览器”模式启动", - ); - } profile.last_used_at = currentTime; profile.updated_at = currentTime; } @@ -523,14 +763,18 @@ function buildMockBrowserSessionLaunchResponse(request: any) { environmentPreset.updated_at = currentTime; } + const isExistingSession = profile?.transport_kind === "existing_session"; + return upsertMockBrowserSessionState({ profile: { success: true, - reused: false, + reused: isExistingSession, browser_source: "system", browser_path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - profile_dir: `/tmp/lime/chrome_profiles/${profileKey}`, + profile_dir: isExistingSession + ? "" + : `/tmp/lime/chrome_profiles/${profileKey}`, remote_debugging_port: 13001, pid: 12345, devtools_http_url: "http://127.0.0.1:13001/json/version", @@ -1083,7 +1327,7 @@ const defaultMocks: Record = { }, content_creator: { schema_version: 1, - enabled_themes: ["social-media"], + enabled_themes: [], media_defaults: {}, }, navigation: { @@ -1249,56 +1493,6 @@ const defaultMocks: Record = { message: "Provider 配置已同步到浏览器 mock 环境。", }), - // 服务器相关 - get_server_status: () => ({ - running: false, - host: "127.0.0.1", - port: 8787, - requests: 0, - uptime_secs: 0, - error_rate_1m: 0, - p95_latency_ms_1m: null, - open_circuit_count: 0, - active_requests: 0, - capability_routing: { - filter_eval_total: 0, - filter_excluded_total: 0, - filter_excluded_tools_total: 0, - filter_excluded_vision_total: 0, - filter_excluded_context_total: 0, - provider_fallback_total: 0, - model_fallback_total: 0, - all_candidates_excluded_total: 0, - }, - response_cache: { - size: 0, - hits: 0, - misses: 0, - evictions: 0, - }, - request_dedup: { - inflight_size: 0, - completed_size: 0, - check_new_total: 0, - check_in_progress_total: 0, - check_completed_total: 0, - wait_success_total: 0, - wait_timeout_total: 0, - wait_no_result_total: 0, - complete_total: 0, - remove_total: 0, - }, - idempotency: { - entries_size: 0, - in_progress_size: 0, - completed_size: 0, - check_new_total: 0, - check_in_progress_total: 0, - check_completed_total: 0, - complete_total: 0, - remove_total: 0, - }, - }), get_server_diagnostics: () => ({ generated_at: new Date().toISOString(), running: false, @@ -1382,55 +1576,6 @@ const defaultMocks: Record = { replay_rate_percent: 0, }, }), - check_server_status: () => ({ - running: false, - host: "127.0.0.1", - port: 8787, - requests: 0, - uptime_secs: 0, - error_rate_1m: 0, - p95_latency_ms_1m: null, - open_circuit_count: 0, - active_requests: 0, - capability_routing: { - filter_eval_total: 0, - filter_excluded_total: 0, - filter_excluded_tools_total: 0, - filter_excluded_vision_total: 0, - filter_excluded_context_total: 0, - provider_fallback_total: 0, - model_fallback_total: 0, - all_candidates_excluded_total: 0, - }, - response_cache: { - size: 0, - hits: 0, - misses: 0, - evictions: 0, - }, - request_dedup: { - inflight_size: 0, - completed_size: 0, - check_new_total: 0, - check_in_progress_total: 0, - check_completed_total: 0, - wait_success_total: 0, - wait_timeout_total: 0, - wait_no_result_total: 0, - complete_total: 0, - remove_total: 0, - }, - idempotency: { - entries_size: 0, - in_progress_size: 0, - completed_size: 0, - check_new_total: 0, - check_in_progress_total: 0, - check_completed_total: 0, - complete_total: 0, - remove_total: 0, - }, - }), get_log_storage_diagnostics: () => ({ log_directory: "/tmp/lime/logs", current_log_path: "/tmp/lime/logs/lime.log", @@ -1440,15 +1585,6 @@ const defaultMocks: Record = { related_log_files: [], raw_response_files: [], }), - start_server: () => "Server started (mock)", - stop_server: () => "Server stopped (mock)", - - // 网络相关 - get_network_info: () => ({ - localhost: "127.0.0.1", - lan_ip: "192.168.1.100", - all_ips: ["127.0.0.1", "192.168.1.100"], - }), list_browser_environment_presets_cmd: (args: any) => { const includeArchived = Boolean(args?.request?.include_archived); return mockBrowserEnvironmentPresets.filter( @@ -1727,7 +1863,8 @@ const defaultMocks: Record = { adapter: adapter.name, domain: adapter.domain, profile_key: requestedProfileKey || "attached-site-session", - target_id: requestedTargetId || String(matchingTab?.id ?? "mock-target-1"), + target_id: + requestedTargetId || String(matchingTab?.id ?? "mock-target-1"), message: `已检测到 ${adapter.domain} 的真实浏览器页面,Claw 可以直接复用当前会话执行。`, }; } @@ -1831,7 +1968,9 @@ const defaultMocks: Record = { return { ok: false, adapter: adapterName || "github/search", - domain: adapterName.startsWith("zhihu") ? "www.zhihu.com" : "github.com", + domain: adapterName.startsWith("zhihu") + ? "www.zhihu.com" + : "github.com", profile_key: request.profile_key ?? "general_browser_assist", entry_url: "https://example.com/mock-site", error_code: "attached_session_required", @@ -2032,14 +2171,36 @@ const defaultMocks: Record = { }; return mockBrowserConnectorSettings; }, - get_browser_connector_install_status_cmd: () => mockBrowserConnectorInstallStatus, + set_browser_action_capability_enabled_cmd: (args: any) => { + const request = args?.request ?? {}; + const targetKey = normalizeMockBrowserActionCapabilityKey( + String(request.key ?? ""), + ); + mockBrowserConnectorSettings = { + ...mockBrowserConnectorSettings, + browser_action_capabilities: + mockBrowserConnectorSettings.browser_action_capabilities.map( + (capability) => + capability.key === targetKey + ? { + ...capability, + enabled: request.enabled !== false, + } + : capability, + ), + }; + mockBrowserBackendsStatus = buildMockBrowserBackendsStatus(); + return mockBrowserConnectorSettings; + }, + get_browser_connector_install_status_cmd: () => + mockBrowserConnectorInstallStatus, install_browser_connector_extension_cmd: (args: any) => { const installRootDir = typeof args?.request?.install_root_dir === "string" && args.request.install_root_dir.trim() ? args.request.install_root_dir.trim() - : mockBrowserConnectorSettings.install_root_dir ?? - "/mock/path/to/connectors"; + : (mockBrowserConnectorSettings.install_root_dir ?? + "/mock/path/to/connectors"); const installDir = `${installRootDir}/Lime Browser Connector`; mockBrowserConnectorSettings = { ...mockBrowserConnectorSettings, @@ -2065,6 +2226,7 @@ const defaultMocks: Record = { }; }, open_browser_extensions_page_cmd: () => true, + open_browser_remote_debugging_page_cmd: () => true, chrome_bridge_execute_command: (args: any) => ({ success: true, request_id: `mock-${Date.now()}`, @@ -2095,47 +2257,7 @@ const defaultMocks: Record = { ], auto_fallback: args?.policy?.auto_fallback ?? true, }), - get_browser_backends_status: () => ({ - policy: { - priority: ["aster_compat", "lime_extension_bridge", "cdp_direct"], - auto_fallback: true, - }, - bridge_observer_count: 1, - bridge_control_count: 0, - running_profile_count: 1, - cdp_alive_profile_count: 1, - aster_native_host_supported: true, - aster_native_host_configured: false, - backends: [ - { - backend: "aster_compat", - available: true, - capabilities: [ - "navigate", - "read_page", - "tabs_context_mcp", - "list_tabs", - ], - }, - { - backend: "lime_extension_bridge", - available: true, - capabilities: [ - "open_url", - "click", - "type", - "get_page_info", - "switch_tab", - "list_tabs", - ], - }, - { - backend: "cdp_direct", - available: true, - capabilities: ["tabs_context_mcp", "navigate", "read_page"], - }, - ], - }), + get_browser_backends_status: () => mockBrowserBackendsStatus, list_cdp_targets: () => [ { id: "mock-target-1", @@ -2531,21 +2653,16 @@ const defaultMocks: Record = { default_decision_status: "pending_review", decision: { decision_status: - request?.decision_status || - request?.decisionStatus || - "pending_review", + request?.decision_status || request?.decisionStatus || "pending_review", decision_summary: request?.decision_summary || request?.decisionSummary || "", chosen_fix_strategy: request?.chosen_fix_strategy || request?.chosenFixStrategy || "", risk_level: request?.risk_level || request?.riskLevel || "unknown", risk_tags: request?.risk_tags || request?.riskTags || [], - human_reviewer: - request?.human_reviewer || request?.humanReviewer || "", + human_reviewer: request?.human_reviewer || request?.humanReviewer || "", reviewed_at: - request?.reviewed_at || - request?.reviewedAt || - "2026-03-27T00:07:00Z", + request?.reviewed_at || request?.reviewedAt || "2026-03-27T00:07:00Z", followup_actions: request?.followup_actions || request?.followupActions || [], regression_requirements: @@ -3217,10 +3334,6 @@ const defaultMocks: Record = { get_token_stats_by_model: () => ({ stats: [] }), get_token_stats_by_day: () => ({ stats: [] }), - // Routes 相关 - get_available_routes: () => ({ routes: [] }), - get_route_curl_examples: () => ({ examples: [] }), - // Prompts 相关 get_prompts: () => [], upsert_prompt: () => ({ success: true }), @@ -3329,23 +3442,6 @@ const defaultMocks: Record = { clear_logs: () => ({}), clear_diagnostic_log_history: () => ({}), - // Test 相关 - test_api: () => ({ - success: true, - status: 200, - body: "", - time_ms: 0, - response_headers: { - "x-lime-request-id": "mock-request-id", - "x-lime-cache": "store", - "x-lime-dedup": "new", - "x-lime-idempotency": "new", - "x-lime-requested-provider": "openai", - "x-lime-effective-provider": "openai", - "x-lime-model": "gpt-4o-mini", - }, - }), - // Kiro Credentials 相关 get_kiro_credentials: () => ({ loaded: false }), refresh_kiro_token: () => ({ success: true }), diff --git a/src/lib/webview-api.ts b/src/lib/webview-api.ts index 4cc340c6b..6f24e68c1 100644 --- a/src/lib/webview-api.ts +++ b/src/lib/webview-api.ts @@ -308,11 +308,20 @@ export interface SystemConnectorSnapshot { capabilities: string[]; } +export interface BrowserActionCapabilitySnapshot { + key: string; + label: string; + description: string; + group: string; + enabled: boolean; +} + export interface BrowserConnectorSettingsSnapshot { enabled: boolean; install_root_dir?: string | null; install_dir?: string | null; system_connectors: SystemConnectorSnapshot[]; + browser_action_capabilities?: BrowserActionCapabilitySnapshot[]; } export interface BrowserConnectorInstallStatus { @@ -730,6 +739,7 @@ export interface LaunchBrowserSessionRequest { environment?: BrowserEnvironmentLaunchConfig; target_id?: string; open_window?: boolean; + headless?: boolean; stream_mode?: BrowserStreamMode; } @@ -739,6 +749,7 @@ export interface LaunchBrowserProfileRuntimeAssistRequest { environment_preset_id?: string; target_id?: string; open_window?: boolean; + headless?: boolean; stream_mode?: BrowserStreamMode; } @@ -749,6 +760,7 @@ export interface LaunchBrowserRuntimeAssistRequest { environment?: BrowserEnvironmentLaunchConfig; target_id?: string; open_window?: boolean; + headless?: boolean; stream_mode?: BrowserStreamMode; } @@ -944,6 +956,18 @@ export async function setSystemConnectorEnabled(request: { ); } +export async function setBrowserActionCapabilityEnabled(request: { + key: string; + enabled: boolean; +}): Promise { + return safeInvoke( + "set_browser_action_capability_enabled_cmd", + { + request, + }, + ); +} + export async function getBrowserConnectorInstallStatus(): Promise { return safeInvoke( "get_browser_connector_install_status_cmd", @@ -965,6 +989,10 @@ export async function openBrowserExtensionsPage(): Promise { return safeInvoke("open_browser_extensions_page_cmd"); } +export async function openBrowserRemoteDebuggingPage(): Promise { + return safeInvoke("open_browser_remote_debugging_page_cmd"); +} + /** * 通过 ChromeBridge 发送测试命令 */ @@ -1116,6 +1144,7 @@ export async function launchBrowserProfileRuntimeAssist( environment_preset_id: request.environment_preset_id, target_id: request.target_id, open_window: request.open_window, + headless: request.headless, stream_mode: request.stream_mode, }); } @@ -1130,6 +1159,7 @@ export async function launchBrowserRuntimeAssist( environment: request.environment, target_id: request.target_id, open_window: request.open_window, + headless: request.headless, stream_mode: request.stream_mode, }); } diff --git a/src/lib/workspace/navigation.test.ts b/src/lib/workspace/navigation.test.ts index cab7c207a..1bda56115 100644 --- a/src/lib/workspace/navigation.test.ts +++ b/src/lib/workspace/navigation.test.ts @@ -78,6 +78,17 @@ describe("buildClawAgentParams", () => { expect(params.lockTheme).toBe(false); }); + it("应允许显式锁定主题,避免被项目工作区主题覆盖", () => { + const params = buildClawAgentParams({ + theme: "general", + lockTheme: true, + }); + + expect(params.agentEntry).toBe("claw"); + expect(params.theme).toBe("general"); + expect(params.lockTheme).toBe(true); + }); + it("应透传首页壳提交后的首条上下文参数", () => { const params = buildClawAgentParams({ projectId: "proj-3", diff --git a/src/lib/workspace/navigation.ts b/src/lib/workspace/navigation.ts index 0b4f66da6..a10048230 100644 --- a/src/lib/workspace/navigation.ts +++ b/src/lib/workspace/navigation.ts @@ -21,7 +21,7 @@ export function buildClawAgentParams( agentEntry: "claw", immersiveHome: overrides.immersiveHome ?? false, theme: overrides.theme ?? "general", - lockTheme: false, + lockTheme: overrides.lockTheme ?? false, }; } diff --git a/src/types/page.ts b/src/types/page.ts index 40e363249..044edc11d 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -73,6 +73,7 @@ export type Page = | ThemeWorkspacePage | "image-gen" | "automation" + | "channels" | "resources" | "browser-runtime" | "tools" @@ -135,6 +136,7 @@ export interface AgentPageParams { projectId?: string; contentId?: string; initialRequestMetadata?: Record; + initialAutoSendRequestMetadata?: Record; autoRunInitialPromptOnMount?: boolean; /** Agent 入口模式:新建任务或任务中心 */ agentEntry?: "new-task" | "claw"; @@ -246,6 +248,8 @@ export interface AgentSiteSkillLaunchParams { profileKey?: string; targetId?: string; requireAttachedSession?: boolean; + preferredBackend?: "lime_extension_bridge" | "cdp_direct"; + autoLaunch?: boolean; saveTitle?: string; skillTitle?: string; } diff --git a/src/types/settings.ts b/src/types/settings.ts index 8493268df..75f97f2a7 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -36,7 +36,6 @@ export enum SettingsTabs { MediaServices = "media-services", // 系统 - ApiServer = "api-server", McpServer = "mcp-server", Channels = "channels", WebSearch = "web-search", @@ -86,9 +85,7 @@ export const SETTINGS_GROUPS: Record = { SettingsTabs.MediaServices, ], [SettingsGroupKey.System]: [ - SettingsTabs.ApiServer, SettingsTabs.McpServer, - SettingsTabs.Channels, SettingsTabs.WebSearch, SettingsTabs.Environment, SettingsTabs.ChromeRelay,