mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 04:00:09 +00:00
Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff61f4b3fa | |||
| 192111840c | |||
| 837a3daa58 | |||
| 5f6f053039 | |||
| 775be47513 | |||
| 2f265a9307 | |||
| 0fa2e2349c | |||
| 930344ae23 | |||
| 538195dfb4 | |||
| b3d2d2fdbd | |||
| cce14911d1 | |||
| ddb5794826 | |||
| f685d5c217 | |||
| 7eee016abe | |||
| 36cc836f2b | |||
| 1c24b9e677 | |||
| a22ea78460 | |||
| b50acaca40 | |||
| d3faa70c94 | |||
| 8cd03c8013 | |||
| 8a6545f799 | |||
| de9f7e092a | |||
| 6770d8f321 | |||
| b01e4dc257 | |||
| 0e346c5b72 | |||
| 55452cdf42 | |||
| 94bd7b2f6b | |||
| b09d744231 | |||
| 5fe9afc681 | |||
| 857cf9582a | |||
| acd3da8059 | |||
| 7cad53d878 | |||
| a0fac0b700 | |||
| a35877f676 | |||
| d15651bbec | |||
| fd985d0b69 | |||
| cec72199bb | |||
| 1a340deb75 | |||
| eb1ba56024 | |||
| 902eb9f863 | |||
| a41fd95eb5 | |||
| a27ea18dfb | |||
| 875e2ffb87 | |||
| 6953f188c1 | |||
| 063c0b7a21 | |||
| 16b932278e | |||
| 97111fc99d | |||
| 219f44c6e8 | |||
| 99ec113e75 | |||
| bf294e2df9 | |||
| 066c77fad7 | |||
| 8c40ff90ea | |||
| 029d442992 | |||
| 422ccc9f58 | |||
| 83b8aa5a04 | |||
| e37cca70c5 | |||
| c056760414 | |||
| aca724c430 | |||
| b45cb41d4b | |||
| 736eb570af | |||
| af785466d1 | |||
| 869f10a44c | |||
| 7e78453ae3 | |||
| 874cf39ef3 | |||
| d3b6f74672 | |||
| f8142de9a2 | |||
| b22ac0f266 | |||
| b358b0b2d1 | |||
| 9fb3038615 | |||
| d96912dae7 | |||
| 56cbf7a3f3 | |||
| 3680e5efe6 | |||
| e78cbaf945 | |||
| 3859b7ca51 | |||
| 9a4c8d5590 | |||
| 1466d6eb51 | |||
| ba358bf3fc | |||
| 516a2651f4 | |||
| 0911c2a94c | |||
| fc088773bd | |||
| 1698b7e77d | |||
| b8c4df5a13 | |||
| 7b7690fbb6 | |||
| c7976ce7f7 | |||
| 45e07a9584 | |||
| 55623c5661 | |||
| 95c27bd748 | |||
| 67cd059340 | |||
| c261c06098 | |||
| 2b2abca0ae | |||
| 2eb860b59d | |||
| 3b3632b419 | |||
| b68760d0ca | |||
| 71dd287001 | |||
| e87eb8c033 | |||
| 63ced8167d | |||
| 7cf5616638 | |||
| 621b36e752 | |||
| c38e6db65c | |||
| 3740791573 | |||
| 61f4bda987 | |||
| 3bcf6a8d72 | |||
| 7144e9de28 | |||
| 0195f42daa | |||
| 2a66071210 | |||
| f9b611bc69 | |||
| 29623c4ab6 | |||
| d2d3888f43 | |||
| e7524c4f1a | |||
| 632c1e6c49 | |||
| d3973a5cc0 | |||
| 6ab1fb2a77 | |||
| 6a7a20176a | |||
| a91385aabc | |||
| 1285f601df | |||
| e5c9a1a054 | |||
| 03c79bfb62 | |||
| cf16737668 | |||
| d6dae46261 | |||
| 48ac76815d | |||
| d35ee849dd | |||
| 391b16e082 | |||
| 97ea30e48b | |||
| fd0d208152 | |||
| 500a02bd88 | |||
| 8ddd8e2cff | |||
| 62187d55c5 | |||
| c68eb07a91 | |||
| 2dc812ac97 | |||
| c21076eec4 | |||
| b3a31ec2ee | |||
| c9505f7ea2 | |||
| c6d3633337 | |||
| ae4145ba12 | |||
| 8a2d05d64e | |||
| d359a83ade | |||
| 519e755aff | |||
| 652005ed21 | |||
| 27f97b2e52 | |||
| 6f42386345 | |||
| 1792752231 | |||
| 46818e9571 | |||
| d6b5e81a57 | |||
| e5666882d4 | |||
| 469a8e6661 | |||
| 7798e4b0b5 | |||
| 654035e7b0 | |||
| eb39f193c9 | |||
| 7e514ac3e3 | |||
| f3f2bda880 | |||
| 6434ee9a5d | |||
| b52ff52949 | |||
| 4766bb3eb3 | |||
| 7ab111fcc5 | |||
| 6281ca4228 | |||
| 73fa3b1689 | |||
| 04e9f7fcea | |||
| 1cc92db5e2 | |||
| 2d088ca6e2 | |||
| 43b0b5e854 | |||
| 0e46085176 | |||
| e50e6859e7 | |||
| 70097ad315 | |||
| 929d23a94e | |||
| ad75e25443 | |||
| 93492382ca | |||
| 4ea80c2915 | |||
| f94f941fe8 | |||
| fbc42b725e | |||
| f94e4f46a4 | |||
| 6478c6012f | |||
| ff259bdc51 | |||
| 7b61b9526f | |||
| 8c4fbf4a81 | |||
| d91132c155 | |||
| b8a03bdc08 | |||
| 8385a7c447 | |||
| c814c566d4 | |||
| 5e03311d21 | |||
| 03f99bfeeb | |||
| 224079b420 | |||
| 081a0886aa | |||
| d9eba30519 | |||
| a47d29b0bb | |||
| 3864a1eaab | |||
| 8ca3f9a372 | |||
| a2d91b205e | |||
| a35c55c57b | |||
| 625cf80b84 | |||
| d02df7b897 | |||
| 19b11f05be | |||
| 59d2915bf9 | |||
| 17506e30ee | |||
| 1a48642a2d | |||
| 205b9de5c6 | |||
| 20a631a637 | |||
| ba6980ffe9 | |||
| 55b4842f00 | |||
| 6e6970f1b2 | |||
| da7e18281d | |||
| 7083ab4ef5 | |||
| 3dae46911b | |||
| 36d0994ec2 | |||
| 516c04797d | |||
| f3cf7f4aed | |||
| df8111aca0 | |||
| 566b261a12 | |||
| e00c299d1c | |||
| e0d20e86fc | |||
| b5871d327a | |||
| 875c9b49eb | |||
| 1914ae6d43 | |||
| ffd66d5465 | |||
| d00770a956 | |||
| 20267fc77c | |||
| 4630785870 | |||
| 5b7611615e | |||
| ec547a3b57 | |||
| 36c4be46f0 | |||
| 7b136a210f | |||
| 9075d5dfd3 | |||
| 1c429f8d28 | |||
| ac250b9897 | |||
| e8b7fe14e1 | |||
| 79cf5febed | |||
| 4b6b341951 | |||
| 44892960e0 | |||
| dc86f38dc1 | |||
| 3e43683132 | |||
| b125565597 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: add-provider-doc
|
||||
description: Guide for adding new AI provider documentation. Use when adding documentation for a new AI provider (like OpenAI, Anthropic, etc.), including usage docs, environment variables, Docker config, and image resources. Triggers on provider documentation tasks.
|
||||
description: Add documentation for a new AI provider — usage docs, env vars, Docker config, image resources.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[provider-name]'
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: add-setting-env
|
||||
description: Guide for adding environment variables to configure user settings. Use when implementing server-side environment variables that control default values for user settings. Triggers on env var configuration or setting default value tasks.
|
||||
description: Add server-side environment variables that control default values for user settings.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[setting-name]'
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ In `NODE_ENV=development`, `AgentRuntimeService.executeStep()` automatically rec
|
||||
|
||||
**Data flow**: executeStep loop -> build `StepPresentationData` -> write partial snapshot to disk -> on completion, finalize to `.agent-tracing/{timestamp}_{traceId}.json`
|
||||
|
||||
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor emits a `context_engine_result` event after `serverMessagesEngine()` processes messages. This event carries the full `contextEngineInput` (DB messages, systemRole, model, knowledge, tools, userMemory, etc.) and the processed `output` messages (the final LLM payload).
|
||||
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor calls `ctx.tracingContextEngine(input, output)` after `serverMessagesEngine()` processes messages. `AgentRuntimeService.executeStep` buffers the call per step and forwards it to `OperationTraceRecorder.appendStep` as the typed `contextEngine` field. CE flows through this side channel rather than the `events` array so its heavy payload (agentDocuments, systemRole, …) never enters the Redis state pipeline (LOBE-9110).
|
||||
|
||||
## Package Location
|
||||
|
||||
@@ -199,9 +199,10 @@ interface StepSnapshot {
|
||||
messages?: any[]; // DB messages before step
|
||||
context?: { phase: string; payload?: unknown; stepContext?: unknown };
|
||||
events?: Array<{ type: string; [key: string]: unknown }>;
|
||||
// context_engine_result event contains:
|
||||
// input: full contextEngineInput (messages, systemRole, model, knowledge, tools, userMemory, ...)
|
||||
// output: processed messages array (final LLM payload)
|
||||
contextEngine?: {
|
||||
input?: unknown; // contextEngineInput minus messages + toolsConfig (reconstructible from baseline)
|
||||
output?: unknown; // processed messages array (final LLM payload)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -216,5 +217,5 @@ When using `--messages`, the output shows three sections (if context engine data
|
||||
## Integration Points
|
||||
|
||||
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
|
||||
- **Context engine event**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, emits `context_engine_result` event
|
||||
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
|
||||
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
|
||||
|
||||
@@ -18,6 +18,28 @@ The two reference tools to read end-to-end:
|
||||
|
||||
---
|
||||
|
||||
## Tool Render 设计原则(中文草案)
|
||||
|
||||
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
|
||||
|
||||
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
|
||||
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
|
||||
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。
|
||||
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
|
||||
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
|
||||
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
|
||||
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
|
||||
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
|
||||
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
|
||||
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
|
||||
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
|
||||
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
|
||||
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox`、`createStaticStyles` 和 `cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
|
||||
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
|
||||
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
|
||||
|
||||
---
|
||||
|
||||
## 0. Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
@@ -179,6 +201,7 @@ export default SearchInspector;
|
||||
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
|
||||
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
|
||||
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
|
||||
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
|
||||
|
||||
### Inspector registry — `client/Inspector/index.ts`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: cli
|
||||
description: LobeHub CLI (@lobehub/cli) development guide. Use when working on CLI commands, adding new subcommands, fixing CLI bugs, or understanding CLI architecture. Triggers on CLI development, command implementation, or `lh` command questions.
|
||||
description: LobeHub CLI (@lobehub/cli) development guide — commands, subcommands, architecture.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: desktop
|
||||
description: Electron desktop development guide. Use when implementing desktop features, IPC handlers, controllers, preload scripts, window management, menu configuration, or Electron-specific functionality. Triggers on desktop app development, Electron IPC, or desktop local tools implementation.
|
||||
description: Electron desktop development guide — IPC handlers, controllers, preload scripts, window/menu management.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# LobeHub gateway streaming + tab-switch test harness
|
||||
|
||||
Captures store + DOM state at 200ms intervals so we can prove or disprove
|
||||
claims like "切回 tab 后消息回到了很早以前". Built for gateway-mode chat but
|
||||
works for any LobeHub streaming session.
|
||||
|
||||
## Files
|
||||
|
||||
`scripts/agent-gateway/`
|
||||
|
||||
| File | Role |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| `probe.js` | Injects a 200ms sampler + `__PROBE_EVENT` marker + `__switchTab` |
|
||||
| `probe-dump.js` | Stops the sampler and returns `{events, samples}` as JSON string |
|
||||
| `tab-switch.js` | Runs N round-trip switches between two tabs, marks each step |
|
||||
| `analyze.mjs` | Node post-processor: timeline + regression detection |
|
||||
|
||||
## Standard workflow
|
||||
|
||||
```bash
|
||||
# 1. Start Electron with CDP
|
||||
./.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
|
||||
# 2. Navigate to a chat, switch runtime to Cloud Sandbox (gateway mode)
|
||||
|
||||
# 3. Install the probe + helpers
|
||||
agent-browser --cdp 9222 eval --stdin \
|
||||
< .agents/skills/local-testing/scripts/agent-gateway/probe.js
|
||||
|
||||
# 4. Send a tool-call message — manually or via type+press
|
||||
agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"
|
||||
|
||||
# 5. Run the multi-switch driver (auto-picks active tab as BACK and the
|
||||
# rightmost inactive tab as AWAY — edit ROUND_TRIPS / DWELL_MS in the
|
||||
# file if you want different timing)
|
||||
agent-browser --cdp 9222 eval --stdin \
|
||||
< .agents/skills/local-testing/scripts/agent-gateway/tab-switch.js
|
||||
|
||||
# 6. Wait for streaming to finish, then dump
|
||||
agent-browser --cdp 9222 eval --stdin \
|
||||
< .agents/skills/local-testing/scripts/agent-gateway/probe-dump.js \
|
||||
> /tmp/probe.json
|
||||
|
||||
# 7. Analyze
|
||||
node .agents/skills/local-testing/scripts/agent-gateway/analyze.mjs /tmp/probe.json
|
||||
```
|
||||
|
||||
The analyzer prints three sections: EVENTS, TIMELINE, REGRESSIONS. If
|
||||
REGRESSIONS is non-empty it means content/reasoning/childN dropped on the
|
||||
same topic — the symptom users describe.
|
||||
|
||||
## What the probe tracks (and why)
|
||||
|
||||
`chat.messagesMap` only stores the top-level `assistantGroup` shell. The
|
||||
actual streamed content, reasoning, and tool calls live in
|
||||
`assistantGroup.children: AssistantContentBlock[]`. Any probe that only
|
||||
reads `m.content` / `m.reasoning` will see zeros throughout streaming and
|
||||
miss everything that matters. probe.js walks both levels and sums:
|
||||
|
||||
- `cT` total content length
|
||||
- `rT` total reasoning length
|
||||
- `toolT` total tool-call count
|
||||
- `childN` number of content blocks
|
||||
|
||||
Plus DOM-side signals (`domLen`, search/crawl indicator counts) so you can
|
||||
tell store-side regressions apart from render-side regressions.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Optimistic new-topic state.** Before the first chunk lands, messages
|
||||
live under the `<scope>_new` key with `tmp_*` ids and no `topicId` field.
|
||||
probe.js falls back to those when `activeTopicId` is null.
|
||||
- **Reasoning resets to 0 are not bugs.** When the assistant finishes
|
||||
thinking and starts tool-use or text, the streaming reasoning buffer
|
||||
empties and the finalised reasoning gets sealed into a completed block.
|
||||
Filter these out manually if needed.
|
||||
- **DOM length jitters by a handful of chars** because counters like "(10)"
|
||||
in tool-call labels change as results arrive. analyze.mjs only flags
|
||||
`domLen` drops greater than 100 chars to ignore that noise.
|
||||
- **Never identify tabs by innerText.** The active tab's text embeds a
|
||||
` · <agent name>` suffix, so a search like `'LobeHub Growth'` matches the
|
||||
active tab when the active agent happens to be LobeHub Growth — and you
|
||||
end up clicking the tab you're already on. probe.js uses the stable
|
||||
`data-contextmenu-trigger` attribute (a React `useId()` value that's set
|
||||
per-tab and survives focus changes) plus `data-active="true"` to mark
|
||||
the active one. Helpers exposed:
|
||||
`__listTabs()` / `__clickTabByKey(key)` / `__clickTabByIndex(i)` /
|
||||
`__activeTabKey()`.
|
||||
- **`tab-switch.js` fires-and-forgets.** The IIFE kicks off an async loop
|
||||
and returns immediately so the agent-browser CLI eval doesn't blow past
|
||||
its default 25 s timeout. Wait on the `SWITCH_LOOP_DONE` event marker
|
||||
before dumping. Re-running while a loop is in flight is refused — the
|
||||
chaotic data from overlapping runs is not worth debugging.
|
||||
@@ -0,0 +1,243 @@
|
||||
// Analyzer for probe-events dumps. Reads a JSON file produced by `run.ts dump`
|
||||
// and prints a layered breakdown:
|
||||
//
|
||||
// 1. STREAM EVENTS — every non-chunk WS/SSE event in receipt order
|
||||
// 2. CHUNKS SUMMARY — collapsed per-step chunk counts (otherwise floods)
|
||||
// 3. ACTION CALLS — replaceMessages / refreshMessages / MARK:* with stack
|
||||
// 4. CORRELATION — calls ↔ nearest stream event within ±300ms
|
||||
// 5. PER-KEY ASSISTANT GROWTH — for each messagesMap key, when the leading
|
||||
// assistant message's cLen / rLen actually moves (this is what reveals
|
||||
// "chunks arrived but the message never grew" regressions)
|
||||
// 6. ROLLBACKS — msgN / childN / role drops in the active-topic timeline
|
||||
//
|
||||
// Usage:
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import type {
|
||||
ProbeActionCall,
|
||||
ProbeDump,
|
||||
ProbeMessageSummary,
|
||||
ProbeStreamEvent,
|
||||
ProbeTimelineSample,
|
||||
} from './types';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('usage: bun run analyze-events.ts <dump.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
// agent-browser eval --stdin wraps return values in quotes when the value is
|
||||
// a string — so the JSON file may be double-encoded depending on how it was
|
||||
// captured. Handle both.
|
||||
const parsedOnce = JSON.parse(raw) as ProbeDump | string;
|
||||
const dump: ProbeDump = typeof parsedOnce === 'string' ? JSON.parse(parsedOnce) : parsedOnce;
|
||||
|
||||
const { streamEvents = [], actionCalls = [], timeline = [] } = dump;
|
||||
|
||||
const pad = (v: unknown, n: number) => String(v).padStart(n);
|
||||
|
||||
// ── META ───────────────────────────────────────────────────────────
|
||||
console.log('=== META ===');
|
||||
console.log(` events: ${streamEvents.length}`);
|
||||
console.log(` calls: ${actionCalls.length}`);
|
||||
console.log(` timeline: ${timeline.length}`);
|
||||
|
||||
// ── 1. STREAM EVENTS (non-chunk) ───────────────────────────────────
|
||||
const nonChunkEvents = streamEvents.filter((e) => e.type !== 'stream_chunk');
|
||||
const chunkEvents = streamEvents.filter((e) => e.type === 'stream_chunk');
|
||||
|
||||
console.log(
|
||||
`\n=== STREAM EVENTS (${nonChunkEvents.length} non-chunk + ${chunkEvents.length} chunks elided) ===`,
|
||||
);
|
||||
for (const e of nonChunkEvents) {
|
||||
const dataStr = e.dataKeys?.length ? ` [${e.dataKeys.join(',')}]` : '';
|
||||
const data = e.data as Record<string, unknown> | undefined;
|
||||
const uiHint = data?.uiMessagesPreview
|
||||
? ` uiPreview=${JSON.stringify(data.uiMessagesPreview)}`
|
||||
: data?.uiMessagesTotal
|
||||
? ` uiTotal=${data.uiMessagesTotal}`
|
||||
: '';
|
||||
const phaseHint = data?.phase ? ` phase=${data.phase}` : '';
|
||||
const extra = e.serverType ? ` serverType=${e.serverType}` : '';
|
||||
console.log(
|
||||
` t=${pad(e.t, 7)} [${(e.transport ?? '?').padEnd(3)}] step=${pad(e.stepIndex ?? '-', 2)} ` +
|
||||
`type=${(e.type ?? '').padEnd(22)} op=${e.opIdTail ?? '-'}${phaseHint}${uiHint}${extra}${dataStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. CHUNK SUMMARY ───────────────────────────────────────────────
|
||||
console.log('\n=== CHUNKS SUMMARY (per step / chunkType) ===');
|
||||
const chunkBuckets = new Map<string, { count: number; firstT: number; lastT: number }>();
|
||||
for (const c of chunkEvents) {
|
||||
const data = c.data as Record<string, unknown> | undefined;
|
||||
const ct = (data?.chunkType as string | undefined) ?? '?';
|
||||
const key = `step=${c.stepIndex ?? '-'} chunkType=${ct.padEnd(8)} op=${c.opIdTail}`;
|
||||
const slot = chunkBuckets.get(key);
|
||||
if (slot) {
|
||||
slot.count += 1;
|
||||
slot.lastT = c.t;
|
||||
} else {
|
||||
chunkBuckets.set(key, { count: 1, firstT: c.t, lastT: c.t });
|
||||
}
|
||||
}
|
||||
for (const [k, v] of chunkBuckets) {
|
||||
console.log(` ${k} count=${pad(v.count, 4)} t=${pad(v.firstT, 7)}..${pad(v.lastT, 7)}`);
|
||||
}
|
||||
|
||||
// ── 3. ACTION CALLS ───────────────────────────────────────────────
|
||||
console.log('\n=== ACTION CALLS (replace/refresh/MARK) ===');
|
||||
for (const c of actionCalls) {
|
||||
if (c.name?.startsWith('MARK:')) {
|
||||
console.log(` t=${pad(c.t, 7)} ${c.name}`);
|
||||
continue;
|
||||
}
|
||||
const snapshot = (c.args as any)?.snapshot as
|
||||
| Array<{ id: string; role: string; cLen: number; rLen: number }>
|
||||
| undefined;
|
||||
const snapStr = snapshot?.length
|
||||
? ' snapshot=' + snapshot.map((m) => `${m.id}:${m.role}/c${m.cLen}/r${m.rLen}`).join(' | ')
|
||||
: '';
|
||||
const summary =
|
||||
c.name === 'replaceMessages'
|
||||
? `count=${c.args?.count} action=${(c.args?.params as any)?.action ?? '-'}${snapStr}`
|
||||
: c.name === 'refreshMessages'
|
||||
? `ctx=${JSON.stringify(c.args?.context)}`
|
||||
: c.error
|
||||
? `error=${c.error}`
|
||||
: '';
|
||||
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(20)} ${summary}`);
|
||||
if (c.stack) {
|
||||
const frames = c.stack
|
||||
.split(' ← ')
|
||||
.filter((f) => !!f && !f.includes('Object.<anonymous>'))
|
||||
.slice(0, 3);
|
||||
for (const f of frames) console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. CORRELATION ────────────────────────────────────────────────
|
||||
function nearestEventForCall(
|
||||
call: ProbeActionCall,
|
||||
windowMs = 300,
|
||||
): { event: ProbeStreamEvent; delta: number } | null {
|
||||
let best: ProbeStreamEvent | null = null;
|
||||
let bestDelta = Infinity;
|
||||
for (const e of streamEvents) {
|
||||
const d = Math.abs(e.t - call.t);
|
||||
if (d < bestDelta && d <= windowMs) {
|
||||
bestDelta = d;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
return best ? { event: best, delta: bestDelta } : null;
|
||||
}
|
||||
|
||||
console.log('\n=== CORRELATION (replace/refresh ↔ nearest event within ±300ms) ===');
|
||||
for (const c of actionCalls) {
|
||||
if (c.name !== 'refreshMessages' && c.name !== 'replaceMessages') continue;
|
||||
const hit = nearestEventForCall(c);
|
||||
if (hit) {
|
||||
const phase = (hit.event.data as Record<string, unknown> | undefined)?.phase;
|
||||
console.log(
|
||||
` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← Δ${pad(hit.delta, 4)}ms ${hit.event.type}` +
|
||||
(phase ? ` phase=${phase}` : ''),
|
||||
);
|
||||
} else {
|
||||
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← (no event nearby — external trigger)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. PER-KEY ASSISTANT GROWTH ───────────────────────────────────
|
||||
// For each messagesMap key, find the trailing assistant message and report
|
||||
// the points in time where its cLen / rLen actually changed. If the timeline
|
||||
// shows chunks arriving but the assistant cLen never moves, that's the
|
||||
// signature of "dispatch queue blocked / messageId mismatch".
|
||||
console.log('\n=== PER-KEY ASSISTANT GROWTH ===');
|
||||
const keysEverSeen = new Set<string>();
|
||||
for (const s of timeline) for (const k of Object.keys(s.byKey ?? {})) keysEverSeen.add(k);
|
||||
|
||||
for (const key of keysEverSeen) {
|
||||
console.log(`\n key=${key}`);
|
||||
let lastSig: string | null = null;
|
||||
for (const s of timeline) {
|
||||
const slot = s.byKey?.[key];
|
||||
if (!slot) continue;
|
||||
const last = slot.msgs.at(-1) as ProbeMessageSummary | undefined;
|
||||
if (!last) continue;
|
||||
const sig = `${last.id}|c${last.cLen}|r${last.rLen}|n${slot.n}`;
|
||||
if (sig === lastSig) continue;
|
||||
lastSig = sig;
|
||||
console.log(
|
||||
` t=${pad(s.t, 7)} msgN=${pad(slot.n, 3)} ` +
|
||||
`lastAssistant=${last.id} cLen=${pad(last.cLen, 5)} rLen=${pad(last.rLen, 5)}` +
|
||||
` runOps=${s.runOps}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. ROLLBACKS (active-topic msgN / childN / role drops) ─────────
|
||||
console.log('\n=== ROLLBACKS (active-topic msgN / childN / role drops) ===');
|
||||
let prev: ProbeTimelineSample | null = null;
|
||||
const rollbacks: Array<{ t: number; topic: string | null; drops: string[] }> = [];
|
||||
|
||||
const flatten = (s: ProbeTimelineSample) => {
|
||||
if (!s.activeTopic) return [];
|
||||
return Object.entries(s.byKey ?? {})
|
||||
.filter(([k]) => k.includes(s.activeTopic!))
|
||||
.flatMap(([, v]) => v.msgs);
|
||||
};
|
||||
|
||||
for (const s of timeline) {
|
||||
if (s.err) {
|
||||
prev = null;
|
||||
continue;
|
||||
}
|
||||
if (!prev || prev.activeTopic !== s.activeTopic) {
|
||||
prev = s;
|
||||
continue;
|
||||
}
|
||||
const prevMsgs = flatten(prev);
|
||||
const curMsgs = flatten(s);
|
||||
const drops: string[] = [];
|
||||
|
||||
if (curMsgs.length < prevMsgs.length) drops.push(`msgN ${prevMsgs.length}→${curMsgs.length}`);
|
||||
|
||||
let prevChild = 0;
|
||||
let curChild = 0;
|
||||
for (const m of prevMsgs) prevChild += m.chN ?? 0;
|
||||
for (const m of curMsgs) curChild += m.chN ?? 0;
|
||||
if (curChild < prevChild) drops.push(`childN ${prevChild}→${curChild}`);
|
||||
|
||||
const prevById = new Map(prevMsgs.map((m) => [m.id, m]));
|
||||
for (const m of curMsgs) {
|
||||
const pr = prevById.get(m.id);
|
||||
if (!pr) continue;
|
||||
if (m.cLen < pr.cLen) drops.push(`cLen[${m.id}] ${pr.cLen}→${m.cLen}`);
|
||||
if (m.rLen < pr.rLen) drops.push(`rLen[${m.id}] ${pr.rLen}→${m.rLen}`);
|
||||
}
|
||||
|
||||
if (drops.length) rollbacks.push({ t: s.t, topic: s.activeTopic, drops });
|
||||
prev = s;
|
||||
}
|
||||
|
||||
if (rollbacks.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const r of rollbacks) {
|
||||
const nearEvent = streamEvents
|
||||
.filter((e) => Math.abs(e.t - r.t) <= 300)
|
||||
.map((e) => `${e.type}${(e.data as any)?.phase ? ':' + (e.data as any).phase : ''}`);
|
||||
const nearCall = actionCalls
|
||||
.filter((c) => Math.abs(c.t - r.t) <= 300 && !c.name?.startsWith('MARK:'))
|
||||
.map((c) => c.name);
|
||||
console.log(
|
||||
` t=${pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}` +
|
||||
(nearEvent.length ? ` near-event:[${nearEvent.join(',')}]` : '') +
|
||||
(nearCall.length ? ` near-call:[${nearCall.join(',')}]` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// Analyze a probe dump captured by probe.js + probe-dump.js.
|
||||
//
|
||||
// node analyze.mjs /tmp/probe.json
|
||||
//
|
||||
// Prints:
|
||||
// 1. EVENTS — user-action markers with their relative timestamps
|
||||
// 2. TIMELINE — periodic samples (~1 per second + event-adjacent samples)
|
||||
// showing every interesting field; columns:
|
||||
// t(ms) | runOps | msgN | childN | content | reasoning | tools | domLen | search | crawl | topic | event
|
||||
// 3. REGRESSIONS — every place a tracked counter *dropped* on the same
|
||||
// topic between adjacent samples. A "true" UI rollback shows up as a
|
||||
// drop in content/reasoning/tools/childN/domLen without a topic change.
|
||||
//
|
||||
// Whitelisted transitions (not flagged):
|
||||
// - topic change → all drops expected (focus moved away)
|
||||
// - reasoning length 0 after content starts → reasoning gets sealed into a
|
||||
// completed sub-block; the parent's running reasoning resets to ''.
|
||||
// - msgN drop when topic transitions from `_new` placeholder to a real id.
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('usage: node analyze.mjs <probe.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
// probe-dump.js wraps the payload in JSON.stringify so agent-browser returns
|
||||
// it as a single quoted string. Unwrap.
|
||||
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
const { events, samples } = data;
|
||||
|
||||
const fmt = {
|
||||
pad(v, n) {
|
||||
return String(v).padStart(n);
|
||||
},
|
||||
};
|
||||
|
||||
console.log('=== EVENTS ===');
|
||||
for (const e of events) console.log(` t=${fmt.pad(e.t, 7)} ${e.name}`);
|
||||
|
||||
console.log(
|
||||
'\n=== TIMELINE (~1s cadence, plus event-adjacent samples) ===\n' +
|
||||
' t(ms) runOps msgN childN content reasoning tools domLen search crawl topic event',
|
||||
);
|
||||
|
||||
let lastSampledAt = -1e9;
|
||||
const eventBuckets = events.map((e) => e.t);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = samples[i];
|
||||
const nearEvent = eventBuckets.some((et) => Math.abs(et - s.t) < 110);
|
||||
if (!nearEvent && s.t - lastSampledAt < 1000) continue;
|
||||
lastSampledAt = s.t;
|
||||
|
||||
const ev = events.find((e) => Math.abs(e.t - s.t) < 110);
|
||||
const evMarker = ev ? ` ◀ ${ev.name}` : '';
|
||||
const topicSuffix = s.topicId ? s.topicId.slice(-6) : '(none)';
|
||||
const search = s.ind?.search ?? 0;
|
||||
const crawl = s.ind?.crawl ?? 0;
|
||||
console.log(
|
||||
` ${fmt.pad(s.t, 6)} ` +
|
||||
`${fmt.pad(s.runOps, 6)} ` +
|
||||
`${fmt.pad(s.msgN, 4)} ` +
|
||||
`${fmt.pad(s.childN ?? 0, 5)} ` +
|
||||
`${fmt.pad(s.cT ?? 0, 8)} ` +
|
||||
`${fmt.pad(s.rT ?? 0, 9)} ` +
|
||||
`${fmt.pad(s.toolT ?? 0, 5)} ` +
|
||||
`${fmt.pad(s.domLen ?? 0, 7)} ` +
|
||||
`${fmt.pad(search, 6)} ` +
|
||||
`${fmt.pad(crawl, 5)} ` +
|
||||
`${topicSuffix.padEnd(8)}${evMarker}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== REGRESSIONS (same topic, value dropped) ===');
|
||||
const regressions = [];
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const prev = samples[i - 1];
|
||||
const cur = samples[i];
|
||||
if (!cur.topicId || prev.topicId !== cur.topicId) continue;
|
||||
|
||||
const drops = [];
|
||||
if (cur.msgN < prev.msgN) drops.push(`msgN: ${prev.msgN}→${cur.msgN}`);
|
||||
if ((cur.childN ?? 0) < (prev.childN ?? 0)) drops.push(`childN: ${prev.childN}→${cur.childN}`);
|
||||
if ((cur.cT ?? 0) < (prev.cT ?? 0)) drops.push(`content: ${prev.cT}→${cur.cT}`);
|
||||
if ((cur.rT ?? 0) < (prev.rT ?? 0)) drops.push(`reasoning: ${prev.rT}→${cur.rT}`);
|
||||
if ((cur.toolT ?? 0) < (prev.toolT ?? 0)) drops.push(`tools: ${prev.toolT}→${cur.toolT}`);
|
||||
// domLen jitters by a few chars from counter labels — only flag big drops.
|
||||
if ((cur.domLen ?? 0) < (prev.domLen ?? 0) - 100) {
|
||||
drops.push(`domLen: ${prev.domLen}→${cur.domLen}`);
|
||||
}
|
||||
if (drops.length === 0) continue;
|
||||
|
||||
const nearbyEv = events.filter((e) => Math.abs(e.t - cur.t) < 600).map((e) => e.name);
|
||||
regressions.push({ t: cur.t, topic: cur.topicId.slice(-6), drops, nearbyEv });
|
||||
}
|
||||
|
||||
if (regressions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const r of regressions) {
|
||||
const evStr = r.nearbyEv.length ? ` near:[${r.nearbyEv.join(',')}]` : '';
|
||||
console.log(` t=${fmt.pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}${evStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== SUMMARY ===`);
|
||||
console.log(` samples: ${samples.length}`);
|
||||
console.log(` events: ${events.length}`);
|
||||
console.log(` regressions: ${regressions.length}`);
|
||||
if (samples.length) {
|
||||
const last = samples.at(-1);
|
||||
console.log(
|
||||
` final: msgN=${last.msgN} childN=${last.childN ?? 0} content=${last.cT ?? 0} ` +
|
||||
`reasoning=${last.rT ?? 0} tools=${last.toolT ?? 0} runOps=${last.runOps}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Stop the probe and serialize collected data.
|
||||
//
|
||||
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
|
||||
//
|
||||
// The whole thing is wrapped in a JSON.stringify so agent-browser returns it
|
||||
// as a single quoted string — the analyzer double-parses to handle that.
|
||||
|
||||
(function () {
|
||||
if (window.__PROBE_TIMER) {
|
||||
clearInterval(window.__PROBE_TIMER);
|
||||
window.__PROBE_TIMER = null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
events: window.__PROBE_EVENTS || [],
|
||||
samples: window.__PROBE_SAMPLES || [],
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
// Stops the events-probe timeline timer and stashes the full capture as a
|
||||
// JSON string on `window.__PROBE_LAST_DUMP_JSON`. `run.ts` wraps the bundle
|
||||
// in an IIFE that returns that global, which `agent-browser eval` prints to
|
||||
// stdout — the runner then persists it under `.agent-gateway/`.
|
||||
|
||||
import type { ProbeDump } from './types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_LAST_DUMP_JSON?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const w = window;
|
||||
|
||||
if (w.__PROBE_TIMELINE_TIMER) {
|
||||
clearInterval(w.__PROBE_TIMELINE_TIMER);
|
||||
w.__PROBE_TIMELINE_TIMER = null;
|
||||
}
|
||||
|
||||
const mutations = w.__PROBE_MUTATIONS ?? [];
|
||||
|
||||
const dump: ProbeDump & { mutations: typeof mutations } = {
|
||||
meta: {
|
||||
t0: w.__PROBE_T0 ?? 0,
|
||||
collectedAt: Date.now(),
|
||||
sampleCount: (w.__PROBE_MSG_TIMELINE ?? []).length,
|
||||
eventCount: (w.__PROBE_STREAM_EVENTS ?? []).length,
|
||||
callCount: (w.__PROBE_ACTION_CALLS ?? []).length,
|
||||
},
|
||||
streamEvents: w.__PROBE_STREAM_EVENTS ?? [],
|
||||
actionCalls: w.__PROBE_ACTION_CALLS ?? [],
|
||||
timeline: w.__PROBE_MSG_TIMELINE ?? [],
|
||||
mutations,
|
||||
};
|
||||
|
||||
w.__PROBE_LAST_DUMP_JSON = JSON.stringify(dump);
|
||||
@@ -0,0 +1,637 @@
|
||||
// LobeHub gateway raw-event-stream probe.
|
||||
//
|
||||
// Gateway-mode chats subscribe via WebSocket — NOT via the `/api/agent/stream`
|
||||
// SSE endpoint (that one belongs to the direct/client durable-agent runtime).
|
||||
// `AgentStreamClient` (`packages/agent-gateway-client/src/client.ts`) opens
|
||||
// `new WebSocket('wss://.../ws?operationId=...')`, then parses JSON frames in
|
||||
// its `onmessage` handler and re-emits `agent_event.event` objects to the
|
||||
// chat store.
|
||||
//
|
||||
// To capture the RAW gateway events before the store touches them, we wrap
|
||||
// `window.WebSocket` so that for any socket whose URL contains `operationId=`
|
||||
// we intercept the `onmessage` handler / `addEventListener('message')` and
|
||||
// log every `agent_event` frame.
|
||||
//
|
||||
// We *also* keep the `window.fetch` hook for `/api/agent/stream` so this
|
||||
// probe still works for direct-mode runs — but gateway-mode events come
|
||||
// through the WebSocket path.
|
||||
//
|
||||
// Buffers (read via `dump`):
|
||||
// __PROBE_STREAM_EVENTS — raw events parsed off the wire
|
||||
// __PROBE_ACTION_CALLS — replaceMessages / refreshMessages calls (best-effort)
|
||||
// __PROBE_MSG_TIMELINE — 200ms snapshots of every messagesMap key
|
||||
|
||||
import type {
|
||||
ProbeActionCall,
|
||||
ProbeMessageSummary,
|
||||
ProbeStreamEvent,
|
||||
ProbeTimelineSample,
|
||||
} from './types';
|
||||
|
||||
// Bundled by esbuild as an IIFE. Top-level code runs once on injection.
|
||||
|
||||
const w = window;
|
||||
|
||||
// ── Buffers ─────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_MUTATIONS?: Array<{
|
||||
t: number;
|
||||
key: string;
|
||||
n: number;
|
||||
last?: { id: string; role: string; cLen: number; rLen: number; updatedAt?: unknown };
|
||||
prevLast?: { id: string; role: string; cLen: number; rLen: number };
|
||||
delta?: string;
|
||||
}>;
|
||||
__PROBE_STORE_UNSUB?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const events: ProbeStreamEvent[] = (w.__PROBE_STREAM_EVENTS ??= []);
|
||||
const calls: ProbeActionCall[] = (w.__PROBE_ACTION_CALLS ??= []);
|
||||
const timeline: ProbeTimelineSample[] = (w.__PROBE_MSG_TIMELINE ??= []);
|
||||
const mutations = (w.__PROBE_MUTATIONS ??= []);
|
||||
events.length = 0;
|
||||
calls.length = 0;
|
||||
timeline.length = 0;
|
||||
mutations.length = 0;
|
||||
|
||||
const t0 = Date.now();
|
||||
w.__PROBE_T0 = t0;
|
||||
const now = (): number => Date.now() - t0;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function summarizeData(data: unknown): Record<string, unknown> | unknown {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
const src = data as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(src)) {
|
||||
const v = src[k];
|
||||
if (v == null) {
|
||||
out[k] = v;
|
||||
} else if (Array.isArray(v)) {
|
||||
out[k] = `Array(${v.length})`;
|
||||
if (k === 'uiMessages') {
|
||||
out.uiMessagesPreview = v.slice(0, 5).map((m: any) => ({
|
||||
id: (m.id ?? '').slice(-8),
|
||||
role: m.role,
|
||||
cLen: (m.content ?? '').length,
|
||||
children: (m.children ?? []).length,
|
||||
tools: (m.tools ?? []).length,
|
||||
reasoning: (m.reasoning?.content ?? '').length,
|
||||
}));
|
||||
out.uiMessagesTotal = v.length;
|
||||
}
|
||||
} else if (typeof v === 'object') {
|
||||
const obj = v as Record<string, unknown>;
|
||||
out[k] =
|
||||
'Object{' +
|
||||
Object.keys(obj)
|
||||
.slice(0, 6)
|
||||
.map((kk) => kk + (typeof obj[kk] === 'string' ? `=${(obj[kk] as string).length}ch` : ''))
|
||||
.join(',') +
|
||||
'}';
|
||||
} else if (typeof v === 'string') {
|
||||
out[k] = v.length > 100 ? v.slice(0, 100) + `…(${v.length})` : v;
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeMessages(msgs: any[]): ProbeMessageSummary[] {
|
||||
return (msgs ?? []).slice(0, 80).map((m) => ({
|
||||
id: (m.id ?? '').slice(-8),
|
||||
role: m.role,
|
||||
cLen: (m.content ?? '').length,
|
||||
rLen: (m.reasoning?.content ?? '').length,
|
||||
tools: (m.tools ?? []).length,
|
||||
chN: (m.children ?? []).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function shortStack(): string {
|
||||
const raw = new Error('probe-stack').stack ?? '';
|
||||
return raw
|
||||
.split('\n')
|
||||
.slice(3)
|
||||
.filter((l) => !l.includes('probe-events') && !l.includes('node_modules'))
|
||||
.map((l) => l.trim().replace(/^at\s+/, ''))
|
||||
.slice(0, 6)
|
||||
.join(' ← ');
|
||||
}
|
||||
|
||||
function recordAgentEvent(args: {
|
||||
transport: 'ws' | 'sse';
|
||||
opId: string | null;
|
||||
agentEvent: any;
|
||||
eventId?: string | null;
|
||||
rawLen?: number;
|
||||
}): void {
|
||||
const { transport, opId, agentEvent, eventId, rawLen } = args;
|
||||
if (!agentEvent || typeof agentEvent !== 'object') return;
|
||||
events.push({
|
||||
t: now(),
|
||||
transport,
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
eventId: eventId ?? null,
|
||||
type: agentEvent.type,
|
||||
stepIndex: agentEvent.stepIndex,
|
||||
dataKeys: agentEvent.data ? Object.keys(agentEvent.data) : [],
|
||||
data: summarizeData(agentEvent.data) as Record<string, unknown>,
|
||||
rawLen,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 1. Patch window.WebSocket for gateway WS events ────────────────
|
||||
|
||||
if (!w.__PROBE_ORIG_WEBSOCKET) w.__PROBE_ORIG_WEBSOCKET = w.WebSocket;
|
||||
const OrigWS = w.__PROBE_ORIG_WEBSOCKET;
|
||||
|
||||
function extractOpIdFromWsUrl(url: string | URL): string | null {
|
||||
const m = String(url ?? '').match(/operationId=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function isGatewayWs(url: string | URL): boolean {
|
||||
return String(url ?? '').includes('operationId=');
|
||||
}
|
||||
|
||||
function handleWsFrame(rawData: unknown, opId: string | null): void {
|
||||
const rawLen = typeof rawData === 'string' ? rawData.length : -1;
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = typeof rawData === 'string' ? JSON.parse(rawData) : null;
|
||||
} catch {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_PARSE_ERROR_',
|
||||
raw: typeof rawData === 'string' && rawData.length < 400 ? rawData : '(non-string or large)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
if (parsed.type === 'agent_event') {
|
||||
recordAgentEvent({
|
||||
transport: 'ws',
|
||||
opId,
|
||||
agentEvent: parsed.event,
|
||||
eventId: parsed.id,
|
||||
rawLen,
|
||||
});
|
||||
} else {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_SERVER_MSG_',
|
||||
serverType: parsed.type,
|
||||
rawLen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the constructor. Instance `constructor` will still reflect OrigWS
|
||||
// (we share prototypes), so use the `_WS_OPEN_` sentinel events to confirm
|
||||
// the patch is firing.
|
||||
function PatchedWebSocket(this: WebSocket, url: string | URL, protocols?: string | string[]) {
|
||||
const ws: WebSocket = protocols == null ? new OrigWS(url) : new OrigWS(url, protocols);
|
||||
const opId = extractOpIdFromWsUrl(url);
|
||||
if (!isGatewayWs(url)) return ws;
|
||||
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_WS_OPEN_',
|
||||
url: String(url),
|
||||
});
|
||||
|
||||
// One observer listener that always fires, regardless of how the consumer
|
||||
// (AgentStreamClient uses `ws.onmessage = …`) subscribes.
|
||||
ws.addEventListener('message', (e) => {
|
||||
try {
|
||||
handleWsFrame((e as MessageEvent).data, opId);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_WS_CLOSE_',
|
||||
});
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Preserve prototype + static fields so `instanceof WebSocket` and
|
||||
// `WebSocket.OPEN` constants still work.
|
||||
(PatchedWebSocket as unknown as { prototype: WebSocket }).prototype = OrigWS.prototype;
|
||||
for (const k of Object.keys(OrigWS) as Array<keyof typeof OrigWS>) {
|
||||
try {
|
||||
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
|
||||
} catch {
|
||||
/* readonly */
|
||||
}
|
||||
}
|
||||
(['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const).forEach((k) => {
|
||||
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
|
||||
});
|
||||
w.WebSocket = PatchedWebSocket as unknown as typeof WebSocket;
|
||||
|
||||
// ── 2. Patch window.fetch for `/api/agent/stream` (direct-mode SSE) ─
|
||||
|
||||
if (!w.__PROBE_ORIG_FETCH) w.__PROBE_ORIG_FETCH = w.fetch.bind(w);
|
||||
const origFetch = w.__PROBE_ORIG_FETCH;
|
||||
|
||||
function isAgentStreamUrl(input: RequestInfo | URL): boolean {
|
||||
let url = '';
|
||||
if (typeof input === 'string') url = input;
|
||||
else if (input instanceof URL) url = input.toString();
|
||||
else if (input && typeof (input as Request).url === 'string') url = (input as Request).url;
|
||||
return url.includes('/api/agent/stream');
|
||||
}
|
||||
|
||||
function extractOpIdFromHttpUrl(input: RequestInfo | URL): string | null {
|
||||
const url = typeof input === 'string' ? input : (input as Request | URL).toString();
|
||||
const m = url.match(/operationId=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function pushFromSSEFrame(rawFrame: string, opId: string | null): void {
|
||||
const lines = rawFrame.split('\n');
|
||||
let dataJson = '';
|
||||
let evtName = 'message';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) evtName = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataJson += line.slice(5).trim();
|
||||
}
|
||||
if (!dataJson) return;
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(dataJson);
|
||||
} catch {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'sse',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_PARSE_ERROR_',
|
||||
sseEvent: evtName,
|
||||
raw: dataJson.length > 400 ? dataJson.slice(0, 400) + '…' : dataJson,
|
||||
});
|
||||
return;
|
||||
}
|
||||
recordAgentEvent({
|
||||
transport: 'sse',
|
||||
opId,
|
||||
agentEvent: parsed,
|
||||
eventId: null,
|
||||
rawLen: dataJson.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function teeAndDrain(response: Response, opId: string | null): Promise<Response> {
|
||||
if (!response.body) return response;
|
||||
const [a, b] = response.body.tee();
|
||||
|
||||
void (async () => {
|
||||
const reader = b.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
|
||||
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||
const frame = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
if (frame.trim()) pushFromSSEFrame(frame, opId);
|
||||
}
|
||||
}
|
||||
if (buf.trim()) pushFromSSEFrame(buf, opId);
|
||||
} catch (e: any) {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'sse',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_TEE_ERROR_',
|
||||
message: String(e?.message ?? e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(a, {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
w.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const response = await origFetch(input as any, init);
|
||||
if (!isAgentStreamUrl(input)) return response;
|
||||
const opId = extractOpIdFromHttpUrl(input);
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input.split('?')[0]
|
||||
: (input as Request | URL).toString().split('?')[0];
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'sse',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_CONNECTED_',
|
||||
url,
|
||||
status: response.status,
|
||||
});
|
||||
return teeAndDrain(response, opId);
|
||||
} as typeof fetch;
|
||||
|
||||
// ── 3. Wrap store actions (best-effort for "who called replace") ────
|
||||
|
||||
// Side-global stash for the original chat-store actions. Re-installs ALWAYS
|
||||
// rewrap from the originals so updates to the probe body take effect
|
||||
// without a page reload — using only a `__probeWrapped` flag on the chat
|
||||
// state object would freeze the first-installed wrapper across re-installs.
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_ORIG_REFRESH_MESSAGES?: any;
|
||||
__PROBE_ORIG_REPLACE_MESSAGES?: any;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = w.__LOBE_STORES?.chat?.();
|
||||
if (chat) {
|
||||
// First-time install: cache the originals. Re-install: restore from
|
||||
// the cached originals before wrapping again.
|
||||
if (!w.__PROBE_ORIG_REFRESH_MESSAGES) w.__PROBE_ORIG_REFRESH_MESSAGES = chat.refreshMessages;
|
||||
if (!w.__PROBE_ORIG_REPLACE_MESSAGES) w.__PROBE_ORIG_REPLACE_MESSAGES = chat.replaceMessages;
|
||||
const origRefresh = w.__PROBE_ORIG_REFRESH_MESSAGES;
|
||||
const origReplace = w.__PROBE_ORIG_REPLACE_MESSAGES;
|
||||
chat.refreshMessages = origRefresh;
|
||||
chat.replaceMessages = origReplace;
|
||||
|
||||
chat.refreshMessages = async function probeRefresh(this: unknown, ...args: any[]) {
|
||||
calls.push({
|
||||
t: now(),
|
||||
name: 'refreshMessages',
|
||||
args: { context: args[0] ?? null },
|
||||
stack: shortStack(),
|
||||
});
|
||||
return origRefresh.apply(this, args);
|
||||
};
|
||||
chat.replaceMessages = function probeReplace(this: unknown, ...args: any[]) {
|
||||
const msgs = (args[0] as any[]) ?? [];
|
||||
const snapshot = msgs.slice(-2).map((m) => ({
|
||||
id: (m.id ?? '').slice(-8),
|
||||
role: m.role,
|
||||
cLen: (m.content ?? '').length,
|
||||
rLen: (m.reasoning?.content ?? '').length,
|
||||
updatedAt: m.updatedAt,
|
||||
}));
|
||||
calls.push({
|
||||
t: now(),
|
||||
name: 'replaceMessages',
|
||||
args: { count: msgs.length, params: args[1] ?? null, snapshot } as any,
|
||||
stack: shortStack(),
|
||||
});
|
||||
|
||||
// Pair the call with a mutation row so the analyzer can build a
|
||||
// single ordered timeline across replaceMessages + dispatchMessage.
|
||||
const stackTop = shortStack().split(' ← ')[0]?.slice(0, 80);
|
||||
const last = msgs.at(-1);
|
||||
const lastSum = last
|
||||
? {
|
||||
id: (last.id ?? '').slice(-8),
|
||||
role: last.role,
|
||||
cLen: (last.content ?? '').length,
|
||||
rLen: (last.reasoning?.content ?? '').length,
|
||||
updatedAt: last.updatedAt,
|
||||
}
|
||||
: undefined;
|
||||
const params: any = args[1] ?? {};
|
||||
const ctxKey = params.context
|
||||
? `main_${params.context.agentId ?? '?'}_${
|
||||
params.context.topicId ? 'tpc_' + params.context.topicId : 'new'
|
||||
}`.replace('main_tpc_', 'main_') // crude key inference
|
||||
: '(no-ctx)';
|
||||
mutations.push({
|
||||
t: now(),
|
||||
key: ctxKey,
|
||||
n: msgs.length,
|
||||
last: lastSum,
|
||||
delta: `replaceMessages(action=${params.action ?? '-'}) src=${stackTop ?? '-'}`,
|
||||
});
|
||||
|
||||
return origReplace.apply(this, args);
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
calls.push({ t: now(), name: '_WRAP_ERROR_', error: String(e?.message ?? e) });
|
||||
}
|
||||
|
||||
// ── 3.5. Mutation log — wrap the TWO ChatStore writers (replaceMessages,
|
||||
// internal_dispatchMessage) to record EVERY dbMessagesMap[key] reference
|
||||
// change with a one-line "before/after last assistant message" delta. This
|
||||
// reveals dispatchMessage-driven collapses that the replaceMessages wrap
|
||||
// alone cannot see.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_ORIG_DISPATCH_MESSAGE?: any;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = w.__LOBE_STORES?.chat?.();
|
||||
if (chat?.internal_dispatchMessage) {
|
||||
if (!w.__PROBE_ORIG_DISPATCH_MESSAGE)
|
||||
w.__PROBE_ORIG_DISPATCH_MESSAGE = chat.internal_dispatchMessage;
|
||||
const origDispatch = w.__PROBE_ORIG_DISPATCH_MESSAGE;
|
||||
chat.internal_dispatchMessage = origDispatch;
|
||||
|
||||
chat.internal_dispatchMessage = function probeDispatch(this: unknown, payload: any, ctx?: any) {
|
||||
// Snapshot BEFORE — read the would-be target key + last message.
|
||||
const before = (() => {
|
||||
try {
|
||||
const state = w.__LOBE_STORES?.chat?.();
|
||||
if (!state) return null;
|
||||
// Replicate state.internal_getConversationContext logic enough to
|
||||
// resolve a key — but most callers pass operationId on ctx, and
|
||||
// operationId-keyed lookup needs store internals. Easiest: snapshot
|
||||
// ALL keys' last-assistant cLen and compare BEFORE vs AFTER below.
|
||||
const map = state.dbMessagesMap ?? {};
|
||||
const out: Record<string, any> = {};
|
||||
for (const k of Object.keys(map)) {
|
||||
const last = (map[k] ?? []).at(-1);
|
||||
out[k] = last
|
||||
? {
|
||||
id: (last.id ?? '').slice(-8),
|
||||
cLen: (last.content ?? '').length,
|
||||
rLen: (last.reasoning?.content ?? '').length,
|
||||
n: map[k].length,
|
||||
}
|
||||
: { n: 0 };
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const result = origDispatch.apply(this, [payload, ctx]);
|
||||
|
||||
// Snapshot AFTER — find which key(s) actually changed.
|
||||
try {
|
||||
const state = w.__LOBE_STORES?.chat?.();
|
||||
if (state && before) {
|
||||
const map = state.dbMessagesMap ?? {};
|
||||
for (const k of Object.keys(map)) {
|
||||
const last = (map[k] ?? []).at(-1);
|
||||
const beforeSnap = before[k];
|
||||
const afterSnap = last
|
||||
? {
|
||||
id: (last.id ?? '').slice(-8),
|
||||
cLen: (last.content ?? '').length,
|
||||
rLen: (last.reasoning?.content ?? '').length,
|
||||
n: map[k].length,
|
||||
}
|
||||
: { n: 0 };
|
||||
const changed =
|
||||
!beforeSnap ||
|
||||
beforeSnap.n !== afterSnap.n ||
|
||||
beforeSnap.id !== (afterSnap as any).id ||
|
||||
beforeSnap.cLen !== (afterSnap as any).cLen ||
|
||||
beforeSnap.rLen !== (afterSnap as any).rLen;
|
||||
if (!changed) continue;
|
||||
let delta = '';
|
||||
if (beforeSnap?.id !== undefined && beforeSnap.id !== (afterSnap as any).id)
|
||||
delta += `id:${beforeSnap.id}→${(afterSnap as any).id};`;
|
||||
if (
|
||||
beforeSnap?.cLen !== undefined &&
|
||||
(afterSnap as any).cLen !== undefined &&
|
||||
(afterSnap as any).cLen < beforeSnap.cLen
|
||||
)
|
||||
delta += `cLen↓${beforeSnap.cLen}→${(afterSnap as any).cLen};`;
|
||||
if (
|
||||
beforeSnap?.rLen !== undefined &&
|
||||
(afterSnap as any).rLen !== undefined &&
|
||||
(afterSnap as any).rLen < beforeSnap.rLen
|
||||
)
|
||||
delta += `rLen↓${beforeSnap.rLen}→${(afterSnap as any).rLen};`;
|
||||
if (beforeSnap?.n !== undefined && afterSnap.n < beforeSnap.n)
|
||||
delta += `n↓${beforeSnap.n}→${afterSnap.n};`;
|
||||
mutations.push({
|
||||
t: now(),
|
||||
key: k,
|
||||
n: afterSnap.n,
|
||||
last: (afterSnap as any).id ? (afterSnap as any) : undefined,
|
||||
prevLast: beforeSnap?.id ? beforeSnap : undefined,
|
||||
delta: delta || `dispatch:${payload?.type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
mutations.push({
|
||||
t: now(),
|
||||
key: '_DISPATCH_PROBE_ERROR_',
|
||||
n: -1,
|
||||
delta: String(e?.message ?? e),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
calls.push({ t: now(), name: '_DISPATCH_WRAP_ERROR_', error: String(e?.message ?? e) });
|
||||
}
|
||||
|
||||
// ── 4. Periodic per-key timeline snapshots ─────────────────────────
|
||||
|
||||
function captureTimeline(): void {
|
||||
try {
|
||||
const c = w.__LOBE_STORES?.chat?.();
|
||||
if (!c) return;
|
||||
const msgsMap = (c.messagesMap ?? {}) as Record<string, any[]>;
|
||||
const dbMap = (c.dbMessagesMap ?? {}) as Record<string, any[]>;
|
||||
const byKey: ProbeTimelineSample['byKey'] = {};
|
||||
for (const k of Object.keys(msgsMap)) {
|
||||
const display = msgsMap[k] ?? [];
|
||||
const db = dbMap[k] ?? [];
|
||||
if (display.length === 0 && db.length === 0) continue;
|
||||
byKey[k] = {
|
||||
n: display.length,
|
||||
dbN: db.length,
|
||||
msgs: summarizeMessages(display),
|
||||
};
|
||||
}
|
||||
const ops = Object.values((c.operations ?? {}) as Record<string, any>);
|
||||
timeline.push({
|
||||
t: now(),
|
||||
activeTopic: ((c.activeTopicId as string | null) ?? '').slice(-10) || null,
|
||||
keys: Object.keys(byKey),
|
||||
byKey,
|
||||
runOps: ops.filter((o: any) => o.status === 'running').length,
|
||||
});
|
||||
} catch (e: any) {
|
||||
timeline.push({
|
||||
t: now(),
|
||||
activeTopic: null,
|
||||
keys: [],
|
||||
byKey: {},
|
||||
runOps: 0,
|
||||
err: e?.message ?? String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
captureTimeline();
|
||||
if (w.__PROBE_TIMELINE_TIMER) clearInterval(w.__PROBE_TIMELINE_TIMER);
|
||||
w.__PROBE_TIMELINE_TIMER = setInterval(captureTimeline, 200);
|
||||
|
||||
// ── 5. Tab-switch helpers ──────────────────────────────────────────
|
||||
|
||||
function listTopBarTabs(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]',
|
||||
),
|
||||
).filter((t) => t.getBoundingClientRect().top < 30);
|
||||
}
|
||||
|
||||
w.__listTabs = () =>
|
||||
listTopBarTabs().map((t, i) => ({
|
||||
i,
|
||||
key: t.getAttribute('data-contextmenu-trigger'),
|
||||
active: t.getAttribute('data-active') === 'true',
|
||||
title: (t.innerText ?? '').slice(0, 60),
|
||||
}));
|
||||
|
||||
w.__clickTabByKey = (key: string) => {
|
||||
const tab = listTopBarTabs().find((t) => t.getAttribute('data-contextmenu-trigger') === key);
|
||||
if (!tab) return 'not found: ' + key;
|
||||
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
|
||||
tab.click();
|
||||
return 'clicked key=' + key;
|
||||
};
|
||||
|
||||
w.__PROBE_EVENT = (name: string) => {
|
||||
calls.push({ t: now(), name: 'MARK:' + name });
|
||||
};
|
||||
|
||||
// `run.ts` wraps the bundle in an IIFE and appends a `return <confirmation>`
|
||||
// after the bundle body — agent-browser then prints the confirmation back to
|
||||
// the operator. Nothing to do here at the end of the module body.
|
||||
@@ -0,0 +1,204 @@
|
||||
// LobeHub chat streaming time-series probe.
|
||||
//
|
||||
// Inject into the renderer (via agent-browser eval) to record store + DOM
|
||||
// snapshots every 200ms during a streaming session. Designed to surface
|
||||
// "UI rolled back to an earlier state" symptoms — especially around
|
||||
// gateway-mode tab switches that happen while the assistant is still writing.
|
||||
//
|
||||
// Usage:
|
||||
// agent-browser --cdp 9222 eval --stdin < probe.js
|
||||
// # ...do test interactions, call window.__PROBE_EVENT('LABEL') to mark moments...
|
||||
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
|
||||
// node analyze.mjs /tmp/probe.json
|
||||
//
|
||||
// What it captures per sample:
|
||||
// - activeTopicId
|
||||
// - msgN: top-level messages in chat.messagesMap for this topic
|
||||
// - childN: total assistantGroup.children blocks across all msgs (THIS is
|
||||
// where streaming content actually lives — top-level assistantGroup stays empty)
|
||||
// - cT / rT / toolT: totals across messages AND their children
|
||||
// (content, reasoning, tool-call count)
|
||||
// - perMsg: per-message breakdown so regressions can be located precisely
|
||||
// - runOps: number of running operations (execServerAgentRuntime etc.)
|
||||
// - domLen: total innerText length of the rendered chat list area
|
||||
// - ind: visible UI indicators (Search pages, Crawled pages, Deeply Thought, Sending)
|
||||
//
|
||||
// Event markers: window.__PROBE_EVENT('NAME') records {t, name} into
|
||||
// __PROBE_EVENTS, used by the analyzer to align state changes with
|
||||
// user-driven actions (SENT, AWAY_1, BACK_1, ...).
|
||||
|
||||
(function () {
|
||||
if (window.__PROBE_TIMER) clearInterval(window.__PROBE_TIMER);
|
||||
window.__PROBE_SAMPLES = [];
|
||||
window.__PROBE_EVENTS = [];
|
||||
const t0 = Date.now();
|
||||
|
||||
function snapshot() {
|
||||
try {
|
||||
const chat = window.__LOBE_STORES.chat();
|
||||
const topicId = chat.activeTopicId;
|
||||
const idTail = topicId ? topicId.replace('tpc_', '') : null;
|
||||
const keys = Object.keys(chat.messagesMap || {});
|
||||
|
||||
// Collect messages for the active topic. Before a topic is committed,
|
||||
// optimistic messages live under the `<agentScope>_new` key — fall
|
||||
// back to those when no topic is active yet.
|
||||
let msgs = [];
|
||||
if (idTail) {
|
||||
keys.forEach((k) => {
|
||||
if (k.includes(idTail)) msgs = msgs.concat(chat.messagesMap[k] || []);
|
||||
});
|
||||
} else {
|
||||
keys
|
||||
.filter((k) => k.endsWith('_new'))
|
||||
.forEach((k) => {
|
||||
msgs = msgs.concat(chat.messagesMap[k] || []);
|
||||
});
|
||||
}
|
||||
|
||||
// Walk top-level + assistantGroup.children. children carry the actual
|
||||
// streamed content / reasoning / tool calls; the parent assistantGroup
|
||||
// remains a placeholder (cLen=0, rLen=0) for its whole lifetime.
|
||||
let totalContent = 0;
|
||||
let totalReason = 0;
|
||||
let totalTools = 0;
|
||||
let childCount = 0;
|
||||
const perMsg = msgs.map((m) => {
|
||||
const cLen = (m.content || '').length;
|
||||
const rLen = ((m.reasoning && m.reasoning.content) || '').length;
|
||||
const tools = (m.tools || []).length;
|
||||
totalContent += cLen;
|
||||
totalReason += rLen;
|
||||
totalTools += tools;
|
||||
|
||||
const children = m.children || [];
|
||||
let chC = 0;
|
||||
let chR = 0;
|
||||
let chT = 0;
|
||||
children.forEach((c) => {
|
||||
chC += (c.content || '').length;
|
||||
chR += ((c.reasoning && c.reasoning.content) || '').length;
|
||||
chT += (c.tools || []).length;
|
||||
});
|
||||
totalContent += chC;
|
||||
totalReason += chR;
|
||||
totalTools += chT;
|
||||
childCount += children.length;
|
||||
|
||||
return {
|
||||
id: (m.id || '').slice(-8),
|
||||
role: m.role,
|
||||
cLen,
|
||||
rLen,
|
||||
tools,
|
||||
chCount: children.length,
|
||||
chC,
|
||||
chR,
|
||||
chT,
|
||||
};
|
||||
});
|
||||
|
||||
const ops = Object.values(chat.operations || {});
|
||||
const runningOps = ops.filter((o) => o.status === 'running');
|
||||
|
||||
// DOM probe: total rendered text in the chat scroll area (proxy for
|
||||
// "how much is actually visible to the user").
|
||||
const convScroll =
|
||||
document.querySelector(
|
||||
'[data-chat-list], [class*="ChatList"], [class*="ConversationList"]',
|
||||
) ||
|
||||
document.querySelector('main [class*="scroll"]') ||
|
||||
document.querySelector('main');
|
||||
const domTxt = convScroll ? convScroll.innerText || '' : '';
|
||||
|
||||
const bodyTxt = document.body.innerText || '';
|
||||
const searchMatches = (bodyTxt.match(/Search pages?:|Searched the web/g) || []).length;
|
||||
const crawlMatches = (bodyTxt.match(/Crawl(ed|ing) pages?/g) || []).length;
|
||||
|
||||
window.__PROBE_SAMPLES.push({
|
||||
t: Date.now() - t0,
|
||||
topicId,
|
||||
msgN: msgs.length,
|
||||
childN: childCount,
|
||||
cT: totalContent,
|
||||
rT: totalReason,
|
||||
toolT: totalTools,
|
||||
perMsg,
|
||||
runOps: runningOps.length,
|
||||
runOpTypes: runningOps.map((o) => o.type),
|
||||
domLen: domTxt.length,
|
||||
ind: {
|
||||
search: searchMatches,
|
||||
crawl: crawlMatches,
|
||||
sending: bodyTxt.includes('Sending message'),
|
||||
deeplyThinking: bodyTxt.includes('Deeply Thinking'),
|
||||
deeplyThought: bodyTxt.includes('Deeply Thought'),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
window.__PROBE_SAMPLES.push({ t: Date.now() - t0, err: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
snapshot();
|
||||
window.__PROBE_TIMER = setInterval(snapshot, 200);
|
||||
window.__PROBE_EVENT = function (name) {
|
||||
window.__PROBE_EVENTS.push({ t: Date.now() - t0, name });
|
||||
};
|
||||
|
||||
// Tab-switch helpers installed alongside the probe.
|
||||
//
|
||||
// The Electron tab bar mounts each tab as a div with data-insp-path
|
||||
// ending in `TabItem.tsx:...`. The active tab is marked with
|
||||
// data-active="true". DO NOT search by innerText — the active tab's text
|
||||
// includes a ` · <agent name>` suffix that produces false matches when
|
||||
// your search string happens to overlap with the agent name.
|
||||
function listTabs() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]'),
|
||||
).filter((t) => t.getBoundingClientRect().top < 30);
|
||||
}
|
||||
function tabKey(el) {
|
||||
// Stable for the tab's lifetime; survives focus changes.
|
||||
return el.getAttribute('data-contextmenu-trigger');
|
||||
}
|
||||
function findActiveTab() {
|
||||
return listTabs().find((t) => t.getAttribute('data-active') === 'true') || null;
|
||||
}
|
||||
|
||||
// Click by stable key captured earlier (preferred for round-trips).
|
||||
window.__clickTabByKey = function (key) {
|
||||
const tab = listTabs().find((t) => tabKey(t) === key);
|
||||
if (!tab) return 'not found: key=' + key;
|
||||
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
|
||||
tab.click();
|
||||
return 'clicked key=' + key;
|
||||
};
|
||||
|
||||
// Click by index in the tab strip (0-based, left-to-right).
|
||||
window.__clickTabByIndex = function (i) {
|
||||
const tabs = listTabs();
|
||||
if (i < 0 || i >= tabs.length) return 'index out of range: ' + i + '/' + tabs.length;
|
||||
const t = tabs[i];
|
||||
if (t.getAttribute('data-active') === 'true') return 'already active: i=' + i;
|
||||
t.click();
|
||||
return 'clicked i=' + i + ' key=' + tabKey(t);
|
||||
};
|
||||
|
||||
// Snapshot all tabs in order: [{key, active, title (first 60 chars of innerText)}]
|
||||
window.__listTabs = function () {
|
||||
return listTabs().map((t, i) => ({
|
||||
i,
|
||||
key: tabKey(t),
|
||||
active: t.getAttribute('data-active') === 'true',
|
||||
title: (t.innerText || '').slice(0, 60),
|
||||
}));
|
||||
};
|
||||
|
||||
window.__activeTabKey = function () {
|
||||
const a = findActiveTab();
|
||||
return a ? tabKey(a) : null;
|
||||
};
|
||||
|
||||
return 'probe installed';
|
||||
})();
|
||||
@@ -0,0 +1,211 @@
|
||||
// CLI for the agent-gateway probe.
|
||||
//
|
||||
// Bundles the TS probes with esbuild, pipes them into `agent-browser eval`,
|
||||
// and persists dumps under `.agent-gateway/` (gitignored) for later use as
|
||||
// streaming-replay test fixtures.
|
||||
//
|
||||
// Commands:
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts install
|
||||
// Bundle probe-events.ts and inject into the CDP-attached browser.
|
||||
// Re-installing clears all buffers and re-patches WebSocket / fetch.
|
||||
//
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts dump [name]
|
||||
// Stop the timeline timer, fetch the capture as JSON, write it to
|
||||
// `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`. `name` defaults to
|
||||
// `dump`. Prints the absolute path written.
|
||||
//
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts analyze [path]
|
||||
// Run analyze-events.ts on the dump. `path` defaults to the most
|
||||
// recently modified file in `.agent-gateway/`.
|
||||
//
|
||||
// Optional flags:
|
||||
// --cdp <port> CDP port (default 9222)
|
||||
// --browser <bin> agent-browser binary (default 'agent-browser')
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
// .agents/skills/local-testing/scripts/agent-gateway/ → 5 levels up
|
||||
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../../../../..');
|
||||
const DUMP_DIR = path.join(PROJECT_ROOT, '.agent-gateway');
|
||||
|
||||
interface Flags {
|
||||
browser: string;
|
||||
cdp: string;
|
||||
positional: string[];
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): Flags {
|
||||
const out: Flags = { cdp: '9222', browser: 'agent-browser', positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--cdp') out.cdp = argv[++i] ?? out.cdp;
|
||||
else if (a === '--browser') out.browser = argv[++i] ?? out.browser;
|
||||
else out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function bundle(entry: string): Promise<string> {
|
||||
// Bun.build is built into the Bun runtime — no external dep needed.
|
||||
const r = await Bun.build({
|
||||
entrypoints: [path.join(SCRIPT_DIR, entry)],
|
||||
target: 'browser',
|
||||
format: 'esm',
|
||||
minify: false,
|
||||
});
|
||||
if (!r.success) {
|
||||
const msgs = r.logs.map((l) => `${l.level}: ${l.message}`).join('\n');
|
||||
throw new Error(`bundle failed for ${entry}:\n${msgs}`);
|
||||
}
|
||||
return await r.outputs[0].text();
|
||||
}
|
||||
|
||||
function wrapIife(body: string, returnExpr: string): string {
|
||||
// Wrap as an IIFE that swallows the bundled top-level (top-level `const`
|
||||
// declarations get scoped to the IIFE, so re-injection doesn't conflict)
|
||||
// and returns the configured expression — which `agent-browser eval`
|
||||
// captures and prints to stdout.
|
||||
return `(() => {\n${body}\n;return ${returnExpr};\n})()`;
|
||||
}
|
||||
|
||||
function runAgentBrowserEval(flags: Flags, script: string): Promise<string> {
|
||||
return new Promise((resolveP, rejectP) => {
|
||||
const child = spawn(flags.browser, ['--cdp', flags.cdp, 'eval', '--stdin'], {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', rejectP);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolveP(stdout);
|
||||
else rejectP(new Error(`agent-browser exited ${code}`));
|
||||
});
|
||||
child.stdin.write(script);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// agent-browser prints eval results as JSON (string values are quoted).
|
||||
function unquoteAgentBrowserResult(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
try {
|
||||
return JSON.parse(trimmed) as string;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isoStamp(): string {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
||||
}
|
||||
|
||||
function ensureDumpDir(): void {
|
||||
mkdirSync(DUMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function latestDump(): string | null {
|
||||
ensureDumpDir();
|
||||
const entries = readdirSync(DUMP_DIR)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.map((f) => ({ f, mtime: statSync(path.join(DUMP_DIR, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
return entries[0] ? path.join(DUMP_DIR, entries[0].f) : null;
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────
|
||||
|
||||
async function cmdInstall(flags: Flags): Promise<void> {
|
||||
const body = await bundle('probe-events.ts');
|
||||
const installMsg = JSON.stringify(
|
||||
'events probe installed: WebSocket+fetch interception. ' +
|
||||
'WS captures operationId= sockets (gateway), fetch captures /api/agent/stream (direct).',
|
||||
);
|
||||
const script = wrapIife(body, installMsg);
|
||||
const out = await runAgentBrowserEval(flags, script);
|
||||
console.log(unquoteAgentBrowserResult(out));
|
||||
}
|
||||
|
||||
async function cmdDump(flags: Flags): Promise<void> {
|
||||
const name = flags.positional[1] ?? 'dump';
|
||||
const body = await bundle('probe-dump.ts');
|
||||
const script = wrapIife(body, 'window.__PROBE_LAST_DUMP_JSON');
|
||||
const raw = await runAgentBrowserEval(flags, script);
|
||||
const json = unquoteAgentBrowserResult(raw);
|
||||
ensureDumpDir();
|
||||
const filename = `${name}-${isoStamp()}.json`;
|
||||
const dumpPath = path.join(DUMP_DIR, filename);
|
||||
writeFileSync(dumpPath, json, 'utf8');
|
||||
// Validate by parsing the meta header so we error early on bad capture
|
||||
try {
|
||||
const parsed = JSON.parse(json) as {
|
||||
meta?: { eventCount?: number; callCount?: number; sampleCount?: number };
|
||||
};
|
||||
const meta = parsed.meta ?? {};
|
||||
console.log(
|
||||
`wrote ${dumpPath} (${json.length} bytes events=${meta.eventCount ?? '?'} ` +
|
||||
`calls=${meta.callCount ?? '?'} samples=${meta.sampleCount ?? '?'})`,
|
||||
);
|
||||
} catch {
|
||||
console.log(`wrote ${dumpPath} (${json.length} bytes — JSON.parse failed; see file)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdAnalyze(flags: Flags): Promise<void> {
|
||||
const target = flags.positional[1] ?? latestDump();
|
||||
if (!target) {
|
||||
console.error('no dump file found. run `dump` first or pass a path.');
|
||||
process.exit(1);
|
||||
}
|
||||
const child = spawn('bun', ['run', path.join(SCRIPT_DIR, 'analyze-events.ts'), target], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
await new Promise<void>((resolveP, rejectP) => {
|
||||
child.on('error', rejectP);
|
||||
child.on('close', (code) => (code === 0 ? resolveP() : rejectP(new Error(`exit ${code}`))));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────
|
||||
|
||||
const flags = parseFlags(process.argv.slice(2));
|
||||
const cmd = flags.positional[0];
|
||||
|
||||
const usage = `usage:
|
||||
bun run run.ts install [--cdp 9222]
|
||||
bun run run.ts dump [name] [--cdp 9222]
|
||||
bun run run.ts analyze [path]
|
||||
`;
|
||||
|
||||
if (!cmd) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (cmd === 'install') await cmdInstall(flags);
|
||||
else if (cmd === 'dump') await cmdDump(flags);
|
||||
else if (cmd === 'analyze') await cmdAnalyze(flags);
|
||||
else {
|
||||
console.error(`unknown command: ${cmd}\n\n${usage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e?.stack ?? e);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Run N round-trip tab switches with event markers timed against the probe.
|
||||
//
|
||||
// agent-browser --cdp 9222 eval --stdin < tab-switch.js
|
||||
//
|
||||
// Captures the currently-active tab as the BACK target and the rightmost
|
||||
// inactive tab as the AWAY target. Both are addressed by their stable
|
||||
// data-contextmenu-trigger key (NOT by visible title — the active tab's
|
||||
// innerText embeds a ` · <agent name>` suffix that breaks text matching).
|
||||
//
|
||||
// Fires the loop in the background and returns immediately so the
|
||||
// agent-browser eval doesn't have to await the full ROUND_TRIPS × DWELL_MS
|
||||
// duration. Wait on the `SWITCH_LOOP_DONE` event before dumping.
|
||||
//
|
||||
// Refuses to launch if a previous loop is still in flight.
|
||||
//
|
||||
// Requires probe.js to have been installed first (provides
|
||||
// window.__PROBE_EVENT / __listTabs / __clickTabByKey / __activeTabKey).
|
||||
|
||||
(function () {
|
||||
const ROUND_TRIPS = 4;
|
||||
const DWELL_MS = 10_000;
|
||||
|
||||
if (!window.__PROBE_EVENT || !window.__listTabs || !window.__clickTabByKey) {
|
||||
return 'probe not installed — eval probe.js first';
|
||||
}
|
||||
if (window.__SWITCH_LOOP_RUNNING) {
|
||||
return 'switch loop already running — wait for SWITCH_LOOP_DONE first';
|
||||
}
|
||||
|
||||
const tabs = window.__listTabs();
|
||||
const activeTab = tabs.find((t) => t.active);
|
||||
if (!activeTab) return 'no active tab — abort';
|
||||
|
||||
// Pick the first inactive tab as AWAY target. With multiple inactive tabs
|
||||
// you'll usually want the one that's stable across the test — feel free
|
||||
// to swap to tabs[tabs.length-1] if you want the rightmost.
|
||||
const inactives = tabs.filter((t) => !t.active);
|
||||
if (inactives.length === 0) return 'no inactive tab to switch to — abort';
|
||||
const awayTab = inactives.at(-1); // rightmost inactive
|
||||
|
||||
const BACK_KEY = activeTab.key;
|
||||
const AWAY_KEY = awayTab.key;
|
||||
|
||||
window.__SWITCH_LOOP_RUNNING = true;
|
||||
window.__PROBE_EVENT('SWITCH_LOOP_CONFIG:back=' + BACK_KEY + ',away=' + AWAY_KEY);
|
||||
|
||||
(async function () {
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
try {
|
||||
window.__PROBE_EVENT('SWITCH_LOOP_START');
|
||||
for (let i = 1; i <= ROUND_TRIPS; i++) {
|
||||
window.__PROBE_EVENT('AWAY_' + i);
|
||||
const awayResult = window.__clickTabByKey(AWAY_KEY);
|
||||
window.__PROBE_EVENT('AWAY_' + i + '_RES:' + awayResult.slice(0, 50));
|
||||
await sleep(DWELL_MS);
|
||||
|
||||
window.__PROBE_EVENT('BACK_' + i);
|
||||
const backResult = window.__clickTabByKey(BACK_KEY);
|
||||
window.__PROBE_EVENT('BACK_' + i + '_RES:' + backResult.slice(0, 50));
|
||||
await sleep(DWELL_MS);
|
||||
}
|
||||
window.__PROBE_EVENT('SWITCH_LOOP_DONE');
|
||||
} finally {
|
||||
window.__SWITCH_LOOP_RUNNING = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return 'switch loop kicked off (BACK=' + BACK_KEY + ', AWAY=' + AWAY_KEY + ')';
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
// Shared types between the in-browser probe and the Node-side analyzer.
|
||||
// Kept tiny on purpose — anything the analyzer can re-derive is left off.
|
||||
|
||||
export interface ProbeStreamEvent {
|
||||
/** Summarized payload — long strings truncated, arrays printed as Array(N) */
|
||||
data?: Record<string, unknown>;
|
||||
/** Keys present on the event's `data` payload — useful at a glance */
|
||||
dataKeys?: string[];
|
||||
/** ServerMessage.id — gateway WS frames carry an event-id we may resume from */
|
||||
eventId?: string | null;
|
||||
message?: string;
|
||||
/** Last 10 chars of the operationId (full id is excessively long) */
|
||||
opIdTail: string;
|
||||
raw?: string;
|
||||
/** Raw frame byte length, when applicable */
|
||||
rawLen?: number;
|
||||
/** For non-agent_event server frames (auth_success, heartbeat_ack, …) */
|
||||
serverType?: string;
|
||||
sseEvent?: string;
|
||||
status?: number;
|
||||
stepIndex?: number;
|
||||
/** Milliseconds since the probe's t0 (install time). */
|
||||
t: number;
|
||||
/** 'ws' for gateway WebSocket frames, 'sse' for direct /api/agent/stream */
|
||||
transport: 'ws' | 'sse';
|
||||
/** Either the AgentStreamEvent.type, or a probe sentinel like `_WS_OPEN_` */
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ProbeActionCall {
|
||||
args?: {
|
||||
count?: number;
|
||||
context?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
error?: string;
|
||||
/** `replaceMessages` / `refreshMessages` / `MARK:<label>` / `_WRAP_ERROR_` */
|
||||
name: string;
|
||||
stack?: string;
|
||||
t: number;
|
||||
}
|
||||
|
||||
export interface ProbeMessageSummary {
|
||||
/** children.length */
|
||||
chN: number;
|
||||
/** content.length */
|
||||
cLen: number;
|
||||
/** Last 8 chars of the message id */
|
||||
id: string;
|
||||
/** reasoning.content.length */
|
||||
rLen: number;
|
||||
role: string;
|
||||
/** tools.length */
|
||||
tools: number;
|
||||
}
|
||||
|
||||
export interface ProbeTimelineSample {
|
||||
/** Last 10 chars of activeTopicId, or null */
|
||||
activeTopic: string | null;
|
||||
/** Per-key breakdown: display count, db count, message summaries */
|
||||
byKey: Record<
|
||||
string,
|
||||
{
|
||||
n: number;
|
||||
dbN: number;
|
||||
msgs: ProbeMessageSummary[];
|
||||
}
|
||||
>;
|
||||
err?: string;
|
||||
/** All messagesMap keys that have content at this moment */
|
||||
keys: string[];
|
||||
/** Number of operations in 'running' status */
|
||||
runOps: number;
|
||||
t: number;
|
||||
}
|
||||
|
||||
export interface ProbeDumpMeta {
|
||||
callCount: number;
|
||||
/** Date.now() at dump call */
|
||||
collectedAt: number;
|
||||
eventCount: number;
|
||||
sampleCount: number;
|
||||
/** Date.now() at probe install */
|
||||
t0: number;
|
||||
}
|
||||
|
||||
export interface ProbeDump {
|
||||
actionCalls: ProbeActionCall[];
|
||||
meta: ProbeDumpMeta;
|
||||
streamEvents: ProbeStreamEvent[];
|
||||
timeline: ProbeTimelineSample[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Globals the probe attaches to `window`. Keeps `as any` casts at the boundary
|
||||
* instead of sprinkling them through the probe body.
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
__clickTabByKey?: (key: string) => string;
|
||||
__listTabs?: () => Array<{ i: number; key: string | null; active: boolean; title: string }>;
|
||||
__LOBE_STORES?: Record<string, () => any>;
|
||||
__PROBE_ACTION_CALLS?: ProbeActionCall[];
|
||||
__PROBE_EVENT?: (label: string) => void;
|
||||
__PROBE_MSG_TIMELINE?: ProbeTimelineSample[];
|
||||
__PROBE_ORIG_FETCH?: typeof fetch;
|
||||
__PROBE_ORIG_WEBSOCKET?: typeof WebSocket;
|
||||
__PROBE_STREAM_EVENTS?: ProbeStreamEvent[];
|
||||
__PROBE_T0?: number;
|
||||
__PROBE_TIMELINE_TIMER?: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ user-invocable: false
|
||||
|
||||
# LobeHub Project Overview
|
||||
|
||||
> The directory listings below are a **curated map of key locations**, not an
|
||||
> exhaustive tree. `packages/`, `src/store/`, route groups etc. grow over time —
|
||||
> run `ls` against the real directory for the current set.
|
||||
|
||||
## Project Description
|
||||
|
||||
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
|
||||
@@ -14,7 +18,7 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
|
||||
- Web desktop/mobile
|
||||
- Desktop (Electron)
|
||||
- Mobile app (React Native) - coming soon
|
||||
- Mobile app (React Native) — **separate repo, already launched** (not in this monorepo)
|
||||
|
||||
**Logo emoji:** 🤯
|
||||
|
||||
@@ -39,147 +43,92 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
| Database | Neon PostgreSQL + Drizzle ORM |
|
||||
| Testing | Vitest |
|
||||
|
||||
## Complete Project Structure
|
||||
> Exact versions live in the root `package.json` — check there, not here.
|
||||
|
||||
Monorepo using `@lobechat/` namespace for workspace packages.
|
||||
## Monorepo Layout
|
||||
|
||||
This is a monorepo extending the open-source `lobehub` submodule. Two repos:
|
||||
|
||||
- **cloud repo root** — `src/` and `packages/business/` (`config`, `const`, `model-runtime`) hold cloud-only SaaS code that overrides/extends the submodule. See `AGENTS.md` for the override mechanism.
|
||||
- **`lobehub/` submodule** — the open-source product core.
|
||||
|
||||
### `lobehub/` submodule — key directories
|
||||
|
||||
```
|
||||
lobehub/
|
||||
├── apps/
|
||||
│ └── desktop/ # Electron desktop app
|
||||
├── docs/
|
||||
│ ├── changelog/
|
||||
│ ├── development/
|
||||
│ ├── self-hosting/
|
||||
│ └── usage/
|
||||
├── locales/
|
||||
│ ├── en-US/
|
||||
│ └── zh-CN/
|
||||
├── packages/
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ ├── builtin-agents/
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── business/ # Cloud-only business logic
|
||||
│ │ ├── config/
|
||||
│ │ ├── const/
|
||||
│ │ └── model-runtime/
|
||||
│ ├── config/
|
||||
│ ├── const/
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── docs/ # changelog, development, self-hosting, usage
|
||||
├── locales/ # en-US, zh-CN, ...
|
||||
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ ├── agent-signal/ # Agent Signal pipeline
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── builtin-tools/ # Builtin tool registries
|
||||
│ ├── context-engine/
|
||||
│ ├── conversation-flow/
|
||||
│ ├── database/
|
||||
│ │ └── src/
|
||||
│ │ ├── models/
|
||||
│ │ ├── schemas/
|
||||
│ │ └── repositories/
|
||||
│ ├── desktop-bridge/
|
||||
│ ├── edge-config/
|
||||
│ ├── editor-runtime/
|
||||
│ ├── electron-client-ipc/
|
||||
│ ├── electron-server-ipc/
|
||||
│ ├── fetch-sse/
|
||||
│ ├── file-loaders/
|
||||
│ ├── memory-user-memory/
|
||||
│ ├── model-bank/
|
||||
│ ├── model-runtime/
|
||||
│ │ └── src/
|
||||
│ │ ├── core/
|
||||
│ │ └── providers/
|
||||
│ ├── observability-otel/
|
||||
│ ├── prompts/
|
||||
│ ├── python-interpreter/
|
||||
│ ├── ssrf-safe-fetch/
|
||||
│ ├── types/
|
||||
│ ├── utils/
|
||||
│ └── web-crawler/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── (backend)/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ ├── f/
|
||||
│ │ │ ├── market/
|
||||
│ │ │ ├── middleware/
|
||||
│ │ │ ├── oidc/
|
||||
│ │ │ ├── trpc/
|
||||
│ │ │ └── webapi/
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/
|
||||
│ │ └── (auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/
|
||||
│ │ ├── (mobile)/
|
||||
│ │ ├── (desktop)/
|
||||
│ │ ├── onboarding/
|
||||
│ │ └── share/
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ └── router/
|
||||
│ ├── business/ # Cloud-only (client/server)
|
||||
│ │ ├── client/
|
||||
│ │ ├── locales/
|
||||
│ │ └── server/
|
||||
│ ├── components/
|
||||
│ ├── config/
|
||||
│ ├── const/
|
||||
│ ├── envs/
|
||||
│ ├── features/
|
||||
│ ├── helpers/
|
||||
│ ├── hooks/
|
||||
│ ├── layout/
|
||||
│ │ ├── AuthProvider/
|
||||
│ │ └── GlobalProvider/
|
||||
│ ├── libs/
|
||||
│ │ ├── better-auth/
|
||||
│ │ ├── oidc-provider/
|
||||
│ │ └── trpc/
|
||||
│ ├── locales/
|
||||
│ │ └── default/
|
||||
│ ├── server/
|
||||
│ │ ├── featureFlags/
|
||||
│ │ ├── globalConfig/
|
||||
│ │ ├── modules/
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── async/
|
||||
│ │ │ ├── lambda/
|
||||
│ │ │ ├── mobile/
|
||||
│ │ │ └── tools/
|
||||
│ │ └── services/
|
||||
│ ├── services/
|
||||
│ ├── store/
|
||||
│ │ ├── agent/
|
||||
│ │ ├── chat/
|
||||
│ │ └── user/
|
||||
│ ├── styles/
|
||||
│ ├── tools/
|
||||
│ ├── database/ # src/{models,schemas,repositories}
|
||||
│ ├── model-bank/ # Model definitions & provider cards
|
||||
│ ├── model-runtime/ # src/{core,providers}
|
||||
│ ├── types/
|
||||
│ └── utils/
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
└── src/
|
||||
├── app/
|
||||
│ ├── (backend)/ # api, f, market, middleware, oidc, trpc, webapi
|
||||
│ ├── spa/ # SPA HTML template service
|
||||
│ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
├── routes/ # SPA page segments (thin — delegate to features/)
|
||||
│ └── (main)/ (mobile)/ (desktop)/ (popup)/ onboarding/ share/
|
||||
├── spa/ # SPA entries + router config
|
||||
│ ├── entry.{web,mobile,desktop,popup}.tsx
|
||||
│ └── router/
|
||||
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
|
||||
├── features/ # Domain business components
|
||||
├── store/ # ~28 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services
|
||||
└── ... # components, hooks, layout, libs, locales, services, types, utils
|
||||
```
|
||||
|
||||
### cloud repo — key directories
|
||||
|
||||
```
|
||||
(cloud root)
|
||||
├── packages/business/ # Cloud overrides: config, const, model-runtime
|
||||
├── src/
|
||||
│ ├── business/ # Cloud impls of submodule stubs (client/server/locales)
|
||||
│ ├── routes/ # Cloud-only route groups: (cloud)/, embed/
|
||||
│ ├── store/ # Cloud-only stores (e.g. subscription/)
|
||||
│ ├── server/ # Cloud routers & services (billing, budget, risk control...)
|
||||
│ └── app/(backend)/cron/ # Vercel cron routes (schedules declared in root vercel.ts)
|
||||
└── vercel.ts # Cron schedule declarations
|
||||
```
|
||||
|
||||
> File search rule: a path like `@/store/x` resolves cloud `src/store/x` first, then
|
||||
> `lobehub/packages/store/src/x`, then `lobehub/src/store/x`. Cloud override wins.
|
||||
|
||||
## Architecture Map
|
||||
|
||||
| Layer | Location |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
|
||||
| Cloud-only | `src/business/*`, `packages/business/*` |
|
||||
| Layer | Location |
|
||||
| ---------------- | ---------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
|
||||
| Cloud-only | `src/business/*`, `packages/business/*` (cloud repo) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
|
||||
@@ -1,95 +1,96 @@
|
||||
---
|
||||
name: react
|
||||
description: "LobeHub React/SPA component conventions: antd-style with `createStaticStyles` + `cssVar.*` (prefer zero-runtime over `createStyles` + `token`), `@lobehub/ui/base-ui` primitives before `@lobehub/ui` before antd, `Flexbox`/`Center` for layouts, react-router-dom navigation, and the `.desktop.tsx` sync rule. Use when writing or editing any `.tsx` under `src/**`, picking a styling helper, choosing a component (Select/Modal/Drawer/Button/Tooltip), wiring routes in `desktopRouter.config.tsx`/`.desktop.tsx`, or adding a `Link`/`useNavigate` call in the SPA. Triggers on `createStyles`/`createStaticStyles`, `cssVar`, `@lobehub/ui`, `antd-style`, `Flexbox`, `useNavigate`, `react-router-dom`, `Link`, 'new component', 'add a page', 'edit a layout', 'desktopRouter', 'componentMap.desktop'."
|
||||
description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# React Component Writing Guide
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
||||
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
|
||||
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
|
||||
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
|
||||
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
|
||||
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
|
||||
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
|
||||
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
|
||||
- Only implement a custom component as a last resort — never reach for antd directly
|
||||
- Use selectors to access zustand store data
|
||||
## Styling
|
||||
|
||||
## @lobehub/ui Components
|
||||
| Scenario | Approach |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Most cases | `createStaticStyles` + `cssVar.*` (zero-runtime, module-level) |
|
||||
| Simple one-off | Inline `style` attribute |
|
||||
| Truly dynamic (JS color fns like `readableColor`/`chroma`) | `createStyles` + `token` — **last resort** |
|
||||
|
||||
If unsure about component usage, search existing code in this project. Most components extend antd with additional props.
|
||||
## Component Priority
|
||||
|
||||
Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
|
||||
1. **`src/components`** — project-specific reusable components
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
|
||||
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
|
||||
4. **Custom implementation** — last resort; never reach for antd directly
|
||||
|
||||
**Common Components:**
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
|
||||
|
||||
- General: ActionIcon, ActionIconGroup, Block, Button, Icon
|
||||
- Data Display: Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip
|
||||
- Data Entry: CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select
|
||||
- Feedback: Alert, Drawer, Modal
|
||||
- Layout: Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow
|
||||
- Navigation: Burger, Dropdown, Menu, SideNav, Tabs
|
||||
### Common @lobehub/ui Components
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
|
||||
| Feedback | Alert, Drawer, Modal |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
|
||||
|
||||
## Layout
|
||||
|
||||
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
|
||||
|
||||
- Use `gap` instead of `margin` for spacing between flex children
|
||||
- Use `flex={1}` to fill available space
|
||||
- Nest Flexbox for complex layouts; set `overflow: 'auto'` for scrollable regions
|
||||
|
||||
## Navigation
|
||||
|
||||
**For SPA pages, use `react-router-dom`, NOT `next/link`.**
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
|
||||
// ✅ Correct
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
```
|
||||
|
||||
Access navigate from stores: `useGlobalStore.getState().navigate?.('/settings');`
|
||||
|
||||
## Desktop File Sync Rule
|
||||
|
||||
Files with a `.desktop.ts(x)` variant must be edited **in sync**. Drift causes blank pages in Electron.
|
||||
|
||||
| Base file (web) | Desktop file (Electron) |
|
||||
| -------------------------- | ---------------------------------- |
|
||||
| `desktopRouter.config.tsx` | `desktopRouter.config.desktop.tsx` |
|
||||
| `componentMap.ts` | `componentMap.desktop.ts` |
|
||||
|
||||
**After editing any `.ts`/`.tsx`:** glob for `<filename>.desktop.{ts,tsx}` in the same directory. If found, apply the equivalent sync-import change.
|
||||
|
||||
## Routing Architecture
|
||||
|
||||
Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | ---------- | -------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA | `desktopRouter.config.tsx` + `.desktop.tsx` (pair) |
|
||||
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
|
||||
|
||||
### Key Files
|
||||
|
||||
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
|
||||
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
|
||||
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
|
||||
- Router utilities: `src/utils/router.tsx`
|
||||
|
||||
### `.desktop.{ts,tsx}` File Sync Rule
|
||||
|
||||
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
|
||||
|
||||
Known pairs that must stay in sync:
|
||||
|
||||
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
|
||||
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
|
||||
|
||||
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
|
||||
|
||||
### Router Utilities
|
||||
Router utilities:
|
||||
|
||||
```tsx
|
||||
import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
|
||||
|
||||
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
|
||||
element: redirectElement('/settings/profile');
|
||||
errorElement: <ErrorBoundary />;
|
||||
```
|
||||
|
||||
### Navigation
|
||||
## Common Mistakes
|
||||
|
||||
**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`.
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
<Link href="/">Home</Link>;
|
||||
|
||||
// ✅ Correct
|
||||
import { Link } from 'react-router-dom';
|
||||
<Link to="/">Home</Link>;
|
||||
|
||||
// In components
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const navigate = useNavigate();
|
||||
navigate('/chat');
|
||||
|
||||
// From stores
|
||||
const navigate = useGlobalStore.getState().navigate;
|
||||
navigate?.('/settings');
|
||||
```
|
||||
| Mistake | Fix |
|
||||
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
|
||||
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
|
||||
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
|
||||
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
|
||||
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
|
||||
|
||||
@@ -48,6 +48,7 @@ user-invocable: false
|
||||
- Replace magic numbers/strings with well-named constants
|
||||
- Defer formatting to tooling
|
||||
- Prefer **named exports** over `export default` — keeps refactor renames and IDE auto-import in sync, and avoids the `default` re-naming drift you get with `import Foo from './foo'`. Reserve `export default` for files where the framework requires it (Next.js page/route/layout, React.lazy targets, config files like `vitest.config.ts`)
|
||||
- Before adding local helpers for common guards/parsing/normalization (record checks, string extraction, empty-string handling, timing helpers, JSON-safe utilities, etc.), search `packages/utils` first. If the helper already exists or clearly belongs there, import it from `@lobechat/utils` (or the relevant `@lobechat/utils/*` subpath) instead of duplicating tiny helpers across feature files.
|
||||
|
||||
## UI and Theming
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: version-release
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
|
||||
description: 'Version release workflow — release process and GitHub Release notes (not docs/changelog pages).'
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[minor|patch] [version?]'
|
||||
---
|
||||
|
||||
+148
-188
@@ -1,6 +1,10 @@
|
||||
# Issue Triage Guide
|
||||
|
||||
This guide is used for batch triaging GitHub issues - analyzing issues and applying appropriate labels.
|
||||
This guide is used for triaging GitHub issues — analyzing issues and applying only the most essential business-domain labels.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Each issue should have 1-3 labels that describe its core business domain.** Do NOT apply redundant labels that can be inferred from other labels. Less is more.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -20,23 +24,76 @@ For each issue number, run:
|
||||
gh issue view [ISSUE_NUMBER] --json number,title,body,labels,comments
|
||||
```
|
||||
|
||||
### Step 3: Analyze and Select Labels
|
||||
### Step 3: Select Labels (1-3 per issue)
|
||||
|
||||
Extract information from the issue template and content:
|
||||
Only apply labels from these THREE categories:
|
||||
|
||||
#### Template Fields Mapping
|
||||
#### Category 1: Technology Carrier
|
||||
|
||||
- 📦 Platform field → `platform:web/desktop/mobile`
|
||||
- 💻 Operating System → `os:windows/macos/linux/ios`
|
||||
- 🌐 Browser → `device:pc/mobile`
|
||||
- 📦 Deployment mode → `deployment:server/client/pglite`
|
||||
- Platform (hosting) → `hosting:cloud/self-host/vercel/zeabur/railway`
|
||||
The runtime environment or technology wrapper where the issue occurs:
|
||||
|
||||
#### Provider Detection
|
||||
| Label | When to apply |
|
||||
|-------|--------------|
|
||||
| `electron` | Desktop/Electron-specific issues. This REPLACES `platform:desktop`, `os:*`, `deployment:*`, `hosting:*` — do NOT add those. |
|
||||
| `pwa` | PWA/mobile-app-specific issues |
|
||||
| `docker` | Docker-specific deployment issues |
|
||||
|
||||
**IMPORTANT**: Always check issue title and body for provider mentions!
|
||||
**Rule**: If `electron` is applied, do NOT add `platform:desktop`, `os:*`, `deployment:*`, or `hosting:*`. The `electron` label already implies all of these.
|
||||
|
||||
**Official Providers** (check for these keywords in title/body):
|
||||
#### Category 2: Feature / Component
|
||||
|
||||
The functional area affected. Select the 1-2 MOST relevant:
|
||||
|
||||
Core Features:
|
||||
|
||||
- `feature:agent` - Agent/Assistant functionality
|
||||
- `feature:topic` - Topic/Conversation management
|
||||
- `feature:marketplace` - Agent/plugin marketplace
|
||||
- `feature:settings` - Settings and configuration
|
||||
|
||||
Content & Knowledge:
|
||||
|
||||
- `feature:editor` - Lobe Editor / rich text / markdown rendering
|
||||
- `feature:markdown` - Markdown rendering (if separate from editor)
|
||||
- `feature:files` - File upload/management
|
||||
- `feature:knowledge-base` - Knowledge base and RAG
|
||||
- `feature:export` - Export functionality
|
||||
|
||||
Model Capabilities:
|
||||
|
||||
- `feature:tool` - Tool calling and function execution
|
||||
- `feature:streaming` - Streaming responses
|
||||
- `feature:vision` - Vision/multimodal capabilities
|
||||
- `feature:image` - AI image generation
|
||||
- `feature:tts` - Text-to-speech
|
||||
|
||||
Technical:
|
||||
|
||||
- `feature:api` - Backend API
|
||||
- `feature:auth` - Authentication/authorization
|
||||
- `feature:sync` - Cloud sync functionality
|
||||
- `feature:search` - Search functionality
|
||||
- `feature:mcp` - MCP integration
|
||||
- `feature:thread` - Thread/Subtopic functionality
|
||||
|
||||
Collaboration:
|
||||
|
||||
- `feature:group-chat` - Group chat functionality
|
||||
- `feature:memory` - Memory feature
|
||||
- `feature:team-workspace` - Team workspace
|
||||
- `feature:im-integration` - IM and bot integration
|
||||
|
||||
Other:
|
||||
|
||||
- `feature:schedule-task` - Scheduled task functionality
|
||||
|
||||
**Rule**: Pick only the 1-2 most specific feature labels. Don't stack multiple features unless the issue genuinely spans multiple areas.
|
||||
|
||||
#### Category 3: Model Provider
|
||||
|
||||
Only when the issue is SPECIFICALLY about a provider's behavior:
|
||||
|
||||
**Official Providers** (check title and body for these keywords):
|
||||
|
||||
- `openai`, `gpt` → `provider:openai`
|
||||
- `gemini` → `provider:gemini`
|
||||
@@ -57,197 +114,100 @@ Extract information from the issue template and content:
|
||||
**Third-party Aggregation Providers**:
|
||||
|
||||
- `aihubmix`, `AIHubMix`, `AIHUBMIX` → `provider:aihubmix`
|
||||
- Check environment variables like `AIHUBMIX_*` in issue body
|
||||
- `zenmux` → `provider:zenmux`
|
||||
|
||||
**Multiple Providers**: If issue mentions multiple providers, add ALL applicable provider labels.
|
||||
**Rule**: Only add a provider label if the issue is specifically about that provider's behavior (e.g., "Gemini returns error X"). Do NOT add provider labels just because the issue template mentions a provider.
|
||||
|
||||
### Label Categories
|
||||
|
||||
#### a) Issue Type (select ONE if applicable)
|
||||
|
||||
- `💄 Design` - UI/UX design issues
|
||||
- `📝 Documentation` - Documentation improvements
|
||||
- `⚡️ Performance` - Performance optimization
|
||||
|
||||
#### b) Priority (select ONE if applicable)
|
||||
|
||||
- `priority:high` - Critical issues, data loss, security, maintainer mentions "urgent"/"serious"/"critical"
|
||||
- `priority:medium` - Important issues affecting multiple users, significant functionality impact
|
||||
- `priority:low` - Nice to have, minor issues, edge cases
|
||||
|
||||
**Priority Guidelines**:
|
||||
|
||||
- Set `priority:high` for: data loss, authentication failures, deployment blockers, critical bugs
|
||||
- Set `priority:medium` for: feature bugs affecting multiple users, workflow issues
|
||||
- Set `priority:low` for: cosmetic issues, feature requests, configuration questions
|
||||
|
||||
#### c) Platform (select ALL applicable)
|
||||
|
||||
- `platform:web`
|
||||
- `platform:desktop`
|
||||
- `platform:mobile`
|
||||
|
||||
#### d) Device (for platform:web, select ONE)
|
||||
|
||||
- `device:pc`
|
||||
- `device:mobile`
|
||||
|
||||
#### e) Operating System (select ALL applicable)
|
||||
|
||||
- `os:windows`
|
||||
- `os:macos`
|
||||
- `os:linux`
|
||||
- `os:ios`
|
||||
- `os:android`
|
||||
|
||||
#### f) Hosting Platform (select ONE)
|
||||
|
||||
- `hosting:cloud` - Official LobeHub Cloud
|
||||
- `hosting:self-host` - Self-hosted deployment
|
||||
- `hosting:vercel` - Vercel deployment
|
||||
- `hosting:zeabur` - Zeabur deployment
|
||||
- `hosting:railway` - Railway deployment
|
||||
|
||||
#### g) Deployment Mode (select ONE if mentioned)
|
||||
|
||||
- `deployment:server` - Server-side database mode
|
||||
- `deployment:client` - Client-side database mode
|
||||
- `deployment:pglite` - PGLite mode
|
||||
|
||||
**Additional deployment tags**:
|
||||
|
||||
- `docker` - If using Docker deployment
|
||||
- `electron` - If desktop/Electron specific
|
||||
|
||||
#### h) Model Provider (select ALL applicable)
|
||||
|
||||
See "Provider Detection" section above for complete list.
|
||||
|
||||
**IMPORTANT**: Always scan issue title and body for provider keywords!
|
||||
|
||||
#### i) Feature/Component (select ALL applicable)
|
||||
|
||||
Core Features:
|
||||
|
||||
- `feature:settings` - Settings and configuration
|
||||
- `feature:agent` - Agent/Assistant functionality
|
||||
- `feature:topic` - Topic/Conversation management
|
||||
- `feature:marketplace` - Agent marketplace
|
||||
|
||||
File & Knowledge:
|
||||
|
||||
- `feature:files` - File upload/management
|
||||
- `feature:knowledge-base` - Knowledge base and RAG
|
||||
- `feature:export` - Export functionality
|
||||
|
||||
Model Capabilities:
|
||||
|
||||
- `feature:streaming` - Streaming responses
|
||||
- `feature:tool` - Tool calling
|
||||
- `feature:vision` - Vision/multimodal capabilities
|
||||
- `feature:image` - AI image generation
|
||||
- `feature:dalle` - DALL-E specific
|
||||
- `feature:tts` - Text-to-speech
|
||||
|
||||
Technical:
|
||||
|
||||
- `feature:api` - Backend API
|
||||
- `feature:auth` - Authentication/authorization
|
||||
- `feature:sync` - Cloud sync functionality
|
||||
- `feature:search` - Search functionality
|
||||
- `feature:mcp` - MCP integration
|
||||
- `feature:editor` - Lobe Editor
|
||||
- `feature:markdown` - Markdown rendering
|
||||
- `feature:thread` - Thread/Subtopic functionality
|
||||
|
||||
Collaboration:
|
||||
|
||||
- `feature:group-chat` - Group chat functionality
|
||||
- `feature:memory` - Memory feature
|
||||
- `feature:team-workspace` - Team workspace
|
||||
|
||||
#### j) Workflow/Status
|
||||
#### Special Labels (use sparingly)
|
||||
|
||||
- `i18n` - Internationalization / translation issues
|
||||
- `Duplicate` - Only if duplicate of an OPEN issue (mention issue number)
|
||||
- `needs-reproduction` - Cannot reproduce, needs more information
|
||||
- `good-first-issue` - Good for first-time contributors
|
||||
- `🤔 Need Reproduce` - Needs reproduction steps
|
||||
- `good-first-issue` - Good for first-time contributors
|
||||
|
||||
### Step 4: Apply Labels
|
||||
|
||||
Add labels (comma-separated, no spaces after commas):
|
||||
|
||||
```bash
|
||||
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2,label3"
|
||||
```
|
||||
|
||||
Remove "unconfirm" label if adding other labels:
|
||||
|
||||
```bash
|
||||
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2"
|
||||
gh issue edit [ISSUE_NUMBER] --remove-label "unconfirm"
|
||||
```
|
||||
|
||||
**Important**: Combine both commands when possible for efficiency.
|
||||
|
||||
### Step 5: Log Summary
|
||||
|
||||
For each issue, provide reasoning (2-4 sentences):
|
||||
For each issue, provide a brief reasoning (1-2 sentences) explaining why each label was chosen.
|
||||
|
||||
- Labels applied and why
|
||||
- Key factors from issue template/comments
|
||||
- Provider detection reasoning (if applicable)
|
||||
## What NOT to Label
|
||||
|
||||
These categories are INTENTIONALLY OMITTED — do NOT apply them:
|
||||
|
||||
| Do NOT apply | Reason |
|
||||
|-------------|--------|
|
||||
| `platform:web`, `platform:desktop`, `platform:mobile` | Inferred from `electron`/`pwa` or issue context |
|
||||
| `os:windows`, `os:macos`, `os:linux`, `os:ios`, `os:android` | Low triage value; inferred from `electron` |
|
||||
| `device:pc`, `device:mobile` | Redundant with platform |
|
||||
| `hosting:cloud`, `hosting:self-host`, `hosting:vercel`, etc. | Low triage value unless deployment-specific |
|
||||
| `deployment:server`, `deployment:client`, `deployment:pglite` | Low triage value; inferred from `electron` |
|
||||
| `priority:high`, `priority:medium`, `priority:low` | Maintainers judge priority themselves |
|
||||
| `🐛 Bug`, `💄 Design`, `📝 Documentation`, `⚡️ Performance` | Issue type is already indicated by GitHub issue template |
|
||||
| `Inactive` | Handled separately; do NOT add during triage |
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Electron desktop bug
|
||||
|
||||
**Issue**: "Connection failure when executing tasks on macOS desktop app"
|
||||
|
||||
**Analysis**: Desktop Electron app issue with task scheduling.
|
||||
|
||||
**Labels**: `electron,feature:schedule-task`
|
||||
|
||||
**Why**: `electron` covers the desktop platform. `feature:schedule-task` identifies the affected feature. No need for `platform:desktop`, `os:macos`, `hosting:cloud`, `priority:*`, or `Bug`.
|
||||
|
||||
### Example 2: Provider-specific issue
|
||||
|
||||
**Issue**: "Gemini tool calling returns empty response on desktop"
|
||||
|
||||
**Analysis**: Desktop app issue, but the core problem is Gemini provider behavior with tool calling.
|
||||
|
||||
**Labels**: `electron,provider:gemini`
|
||||
|
||||
**Why**: `electron` for the desktop context. `provider:gemini` because the issue is about Gemini's behavior. The tool calling aspect is secondary — the provider is the key domain.
|
||||
|
||||
### Example 3: Feature-specific issue
|
||||
|
||||
**Issue**: "Underscore auto-escaped in markdown editor"
|
||||
|
||||
**Analysis**: Markdown rendering bug in the editor component.
|
||||
|
||||
**Labels**: `feature:markdown`
|
||||
|
||||
**Why**: Single label is sufficient — the issue is purely about markdown rendering. No need for platform, OS, or priority labels.
|
||||
|
||||
### Example 4: Web-only feature request
|
||||
|
||||
**Issue**: "Add search functionality to plugin marketplace"
|
||||
|
||||
**Analysis**: Feature request for marketplace search. Web platform, no specific provider.
|
||||
|
||||
**Labels**: `feature:marketplace,feature:search`
|
||||
|
||||
**Why**: Two feature labels capture the core domain. No platform label needed — it's a web app by default.
|
||||
|
||||
### Example 5: Ollama self-hosted issue
|
||||
|
||||
**Issue**: "Ollama model not loading on self-hosted Docker deployment"
|
||||
|
||||
**Analysis**: Provider-specific issue with Ollama on Docker.
|
||||
|
||||
**Labels**: `docker,provider:ollama`
|
||||
|
||||
**Why**: `docker` for the deployment context, `provider:ollama` for the model provider. No need for `hosting:self-host` or `platform:*`.
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Read Carefully**: Read issue template fields AND issue body/title for complete context
|
||||
2. **Provider Detection**: ALWAYS check title and body for provider keywords (including aihubmix, etc.)
|
||||
3. **Multiple Categories**: Use ALL applicable labels from different categories
|
||||
4. **Label Prefixes**: Always use proper prefixes (`feature:`, `provider:`, `os:`, `platform:`, etc.)
|
||||
5. **Maintainer Comments**: Check maintainer comments for priority/status hints
|
||||
6. **No Comments**: Only apply labels, DO NOT post comments to issues
|
||||
7. **Batch Efficiency**: Process issues in parallel when possible
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Provider in Environment Variables
|
||||
|
||||
If issue body contains `AIHUBMIX_*`, add `provider:aihubmix`
|
||||
|
||||
### Multiple Provider Issues
|
||||
|
||||
If comparing providers (e.g., "works with OpenAI but not Gemini"), add both provider labels
|
||||
|
||||
### Desktop Issues
|
||||
|
||||
Desktop issues often need: `platform:desktop`, `electron`, specific `os:*`, and `deployment:client` or `deployment:server`
|
||||
|
||||
### Knowledge Base Issues
|
||||
|
||||
Usually need: `feature:knowledge-base`, often with `feature:files`, may need `provider:*` for embedding models
|
||||
|
||||
### Tool Calling Issues
|
||||
|
||||
Usually need: `feature:tool`, specific `provider:*`, may need `feature:mcp` if MCP-related
|
||||
|
||||
### Streaming Issues
|
||||
|
||||
Usually need: `feature:streaming`, specific `provider:*`, check for timeout/performance issues
|
||||
|
||||
## Example Triage
|
||||
|
||||
**Issue #8850**: "aihubmix 的优惠 app 没有生效"
|
||||
|
||||
**Analysis**:
|
||||
|
||||
- Title contains "aihubmix" → `provider:aihubmix`
|
||||
- Template shows: Windows, Chrome, Docker, Client mode
|
||||
- About API discount codes not working
|
||||
|
||||
**Labels Applied**:
|
||||
|
||||
```bash
|
||||
gh issue edit 8850 --add-label "provider:aihubmix,platform:web,os:windows,deployment:client,hosting:self-host,docker"
|
||||
gh issue edit 8850 --remove-label "unconfirm"
|
||||
```
|
||||
|
||||
**Reasoning**: AIHubMix provider discount feature not working. Client mode deployment on Windows with Docker. Provider detection from title keyword "aihubmix".
|
||||
1. **1-3 labels per issue** — Never exceed 3 labels. If you find yourself adding more, you're being too granular.
|
||||
2. **`electron` replaces all platform/OS/deployment labels** — Never combine `electron` with `platform:desktop`, `os:*`, `deployment:*`, or `hosting:*`.
|
||||
3. **Provider only when relevant** — Only add `provider:*` if the issue is specifically about that provider's behavior.
|
||||
4. **No priority, no type** — Do NOT add `priority:*`, `🐛 Bug`, `💄 Design`, etc. Maintainers handle these.
|
||||
5. **No comments** — Only apply labels. Do NOT post comments to issues.
|
||||
6. **Remove `unconfirm`** — Always remove the `unconfirm` label when applying triage labels.
|
||||
|
||||
@@ -77,6 +77,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# use a proxy to connect to the Anthropic API
|
||||
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
|
||||
|
||||
# Anthropic SDK client timeout in milliseconds
|
||||
# ANTHROPIC_CLIENT_TIMEOUT=295000
|
||||
|
||||
# ## Google AI ####
|
||||
|
||||
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
[ "${HUSKY-}" = "0" ] && exit 0
|
||||
|
||||
export PATH="node_modules/.bin:$PATH"
|
||||
|
||||
BRANCH=$(git branch --show-current)
|
||||
if [ "$BRANCH" = "dev" ] || [ "$BRANCH" = "main" ]; then
|
||||
npm run type-check
|
||||
fi
|
||||
npx --no-install lint-staged
|
||||
|
||||
lint-staged
|
||||
@@ -10,6 +10,10 @@ inputs:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
bun-version:
|
||||
description: Bun version
|
||||
required: false
|
||||
default: '1.3.2'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,6 +25,8 @@ runs:
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ inputs.bun-version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -21,6 +21,46 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Remind contributors when a non-release PR targets `main`.
|
||||
# Day-to-day PRs should target `canary`; `main` is reserved for releases
|
||||
# (see .agents/skills/version-release/SKILL.md). Allowed exceptions:
|
||||
# - PR title matches `🚀 release: v{x.y.z}` (minor release)
|
||||
# - head branch matches `hotfix/*` or `release/*` (patch release)
|
||||
- name: Remind contributor if base branch is not canary
|
||||
if: github.event.action == 'opened' && github.event.pull_request.base.ref == 'main'
|
||||
env:
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
if [[ "$HEAD_REF" == hotfix/* ]] || [[ "$HEAD_REF" == release/* ]]; then
|
||||
echo "✅ Release/hotfix branch ($HEAD_REF) -> main is allowed"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$PR_TITLE" =~ ^🚀[[:space:]]+release: ]]; then
|
||||
echo "✅ Release-titled PR -> main is allowed"
|
||||
exit 0
|
||||
fi
|
||||
echo "⚠️ Non-release PR targets main; posting reminder comment."
|
||||
gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF'
|
||||
👋 Thanks for your contribution!
|
||||
|
||||
This PR currently targets the **`main`** branch, but `main` is reserved for release PRs only. Day-to-day development (features, fixes, refactors, docs, etc.) should target the **`canary`** branch.
|
||||
|
||||
### How to fix
|
||||
|
||||
On the PR page, click **Edit** next to the title, then change the base branch from `main` to `canary`.
|
||||
|
||||
### When targeting `main` is allowed
|
||||
|
||||
- PR title starts with `🚀 release: v{x.y.z}` (minor release)
|
||||
- Head branch matches `hotfix/*` or `release/*` (patch release)
|
||||
|
||||
If your PR fits one of these cases, please ignore this message.
|
||||
EOF
|
||||
)"
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
|
||||
@@ -59,7 +59,14 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
@@ -16,14 +16,14 @@ permissions:
|
||||
jobs:
|
||||
run:
|
||||
permissions:
|
||||
issues: write # for actions-cool/issues-helper to update issues
|
||||
pull-requests: write # for actions-cool/issues-helper to update PRs
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Auto Comment on Issues Closed
|
||||
uses: wow-actions/auto-comment@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN}}
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
issuesClosed: |
|
||||
✅ @{{ author }}
|
||||
|
||||
@@ -51,11 +51,4 @@ jobs:
|
||||
The growth of project is inseparable from user feedback and contribution, thanks for your contribution! If you are interesting with the lobehub developer community, please join our [discord](https://discord.com/invite/AYFPHvv2jT) and then dm @arvinxx or @canisminor1990. They will invite you to our private developer channel. We are talking about the lobe-chat development or sharing ai newsletter around the world.
|
||||
emoji: 'hooray'
|
||||
pr-emoji: '+1, heart'
|
||||
- name: Remove inactive
|
||||
if: github.event.issue.state == 'open' && github.actor == github.event.issue.user.login
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'remove-labels'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
labels: 'Inactive'
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Issue Close Require
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
issue-check-inactive:
|
||||
permissions:
|
||||
issues: write # for actions-cool/issues-helper to update issues
|
||||
pull-requests: write # for actions-cool/issues-helper to update PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: check-inactive
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'check-inactive'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
inactive-label: 'Inactive'
|
||||
inactive-day: 60
|
||||
|
||||
issue-close-require:
|
||||
permissions:
|
||||
issues: write # for actions-cool/issues-helper to update issues
|
||||
pull-requests: write # for actions-cool/issues-helper to update PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: need reproduce
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
labels: '✅ Fixed'
|
||||
inactive-day: 3
|
||||
body: |
|
||||
👋 @{{ author }}
|
||||
<br/>
|
||||
Since the issue was labeled with `✅ Fixed`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
|
||||
- name: need reproduce
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
labels: '🤔 Need Reproduce'
|
||||
inactive-day: 3
|
||||
body: |
|
||||
👋 @{{ author }}
|
||||
<br/>
|
||||
Since the issue was labeled with `🤔 Need Reproduce`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
|
||||
- name: need reproduce
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
labels: "🙅🏻♀️ WON'T DO"
|
||||
inactive-day: 3
|
||||
body: |
|
||||
👋 @{{ github.event.issue.user.login }}
|
||||
<br/>
|
||||
Since the issue was labeled with `🙅🏻♀️ WON'T DO`, and no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Clean issue notice
|
||||
uses: actions-cool/issues-helper@v3
|
||||
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
labels: '🚨 Sync Fail'
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Sync check
|
||||
if: failure()
|
||||
uses: actions-cool/issues-helper@v3
|
||||
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
|
||||
with:
|
||||
actions: 'create-issue'
|
||||
title: '🚨 同步失败 | Sync Fail'
|
||||
|
||||
@@ -35,7 +35,15 @@ jobs:
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -101,7 +109,15 @@ jobs:
|
||||
name: Test App (shard ${{ matrix.shard }}/3)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -128,7 +144,15 @@ jobs:
|
||||
name: Merge and Upload App Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -161,7 +185,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -207,7 +239,15 @@ jobs:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
+5
-1
@@ -28,6 +28,9 @@ prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Agent-gateway probe captures (local debugging dumps)
|
||||
.agent-gateway/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
@@ -96,7 +99,7 @@ sitemap*.xml
|
||||
robots.txt
|
||||
|
||||
# Git hooks
|
||||
.husky/prepare-commit-msg
|
||||
.githooks/prepare-commit-msg
|
||||
|
||||
# Documents and media
|
||||
*.pdf
|
||||
@@ -106,6 +109,7 @@ vertex-ai-key.json
|
||||
|
||||
# Agent tracing snapshots
|
||||
.agent-tracing/
|
||||
.llm-generation-tracing/
|
||||
|
||||
# AI coding tools
|
||||
.local/
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
.history
|
||||
.temp
|
||||
.env.local
|
||||
.husky
|
||||
.githooks
|
||||
.npmrc
|
||||
.gitkeep
|
||||
venv
|
||||
@@ -59,4 +59,4 @@ Dockerfile*
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
.next
|
||||
.next
|
||||
|
||||
+236
@@ -2,6 +2,242 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
|
||||
|
||||
<sup>Released on **2026-05-18**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **pricing**: restore DeepSeek models to official pricing.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **conversation**: animate only the last markdown block + drop clearMessages hotkey.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **pricing**: restore DeepSeek models to official pricing, closes [#14911](https://github.com/lobehub/lobe-chat/issues/14911) ([e566688](https://github.com/lobehub/lobe-chat/commit/e566688))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **conversation**: animate only the last markdown block + drop clearMessages hotkey, closes [#14906](https://github.com/lobehub/lobe-chat/issues/14906) ([469a8e6](https://github.com/lobehub/lobe-chat/commit/469a8e6))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.58](https://github.com/lobehub/lobe-chat/compare/v2.1.57...v2.1.58)
|
||||
|
||||
<sup>Released on **2026-05-13**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **agent-runtime**: persist agent operations to `agent_operations` table.
|
||||
- **misc**: support slack mpim and fix discord dm problem.
|
||||
- **database**: add `agent_operations` table.
|
||||
- **markdown**: user_feedback card + task card polish + Run now context menu.
|
||||
- **documents**: add optimistic create/delete and inline rename for document tree.
|
||||
- **devtools**: add dev-only feature flag override panel.
|
||||
- **misc**: add service model assignments settings.
|
||||
- **misc**: inline skill auth in recommended task templates.
|
||||
- **activator**: require activation reason.
|
||||
- **agent-signal,server,prompts**: consolidate in self-review implemented.
|
||||
- **hetero-agent**: support AskUserQuestion tools for claude code.
|
||||
- **bot**: gate device tools by sender identity.
|
||||
- **misc**: add user activity business hook.
|
||||
- **misc**: add Gemini 3.1 Flash-Lite provider cards.
|
||||
- **misc**: home daily brief with linkable welcome + paired input hint.
|
||||
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions.
|
||||
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations.
|
||||
- **misc**: migrate Notion to LobeHub Market.
|
||||
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **hetero-agent**: wire AskUserBridge response events to renderer.
|
||||
- **home**: blank user bubble when sending the placeholder hint.
|
||||
- **conversation**: prevent synthetic scroll from shrinking spacer.
|
||||
- **task-card**: localize task card date independent of dayjs global locale.
|
||||
- **web-crawler**: cap response body size to prevent serverless OOM.
|
||||
- **desktop**: focus onboarding auth success state.
|
||||
- **misc**: Docs image.
|
||||
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…).
|
||||
- **misc**: update Task page placeholder copy.
|
||||
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`.
|
||||
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths.
|
||||
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit.
|
||||
- **tasks**: scheduler, hotkey, comment & TodoList polish.
|
||||
- **cli**: remove stale cron entry from generated man page.
|
||||
- **misc**: sidebar add agent.
|
||||
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.
|
||||
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion.
|
||||
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls.
|
||||
- **misc**: reject inactive OIDC access.
|
||||
- **misc**: drop unreachable aihubmix empty-apiKey test.
|
||||
- **aihubmix**: use full models endpoint to return complete model list.
|
||||
- **onboarding**: skip marketplace on early exit, drop CJK in prompts.
|
||||
- **model-runtime**: enrich stream parse errors with provider/model context.
|
||||
- **home**: strip markdown links from daily-brief input placeholder.
|
||||
- **misc**: consume visual content parts in server runtime.
|
||||
- **misc**: store onboarding interests as keys.
|
||||
- **hetero-agent**: sync new-step assistant across replicas.
|
||||
- **misc**: remove the old cron job from lobehub.
|
||||
- **misc**: refresh content baseline from DB on every ingest call.
|
||||
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline.
|
||||
- **local-system**: guard readFile against binary blobs and oversized output.
|
||||
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer.
|
||||
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash.
|
||||
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail.
|
||||
- **gemini**: handle zero cachedContentTokenCount in usage conversion.
|
||||
- **misc**: first inject the cloudecc runtime session should use the existingStatus.
|
||||
- **misc**: slack connect error & slash commands.
|
||||
- **misc**: polish task agent manager.
|
||||
- **agent-runtime**: recover malformed tool_call names instead of finishing silently.
|
||||
- **misc**: remove signin captcha flow.
|
||||
- **misc**: add temporary email auth error locale.
|
||||
- **misc**: add bot callback service.
|
||||
- **misc**: sanitize sensitive comments and examples from production JS bundle.
|
||||
- **misc**: multiple account link.
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component.
|
||||
- **misc**: polish desktop header icons, sidebar density, and task menus.
|
||||
- **review-panel**: hover revert button to discard per-file working-tree changes.
|
||||
- **misc**: standardize header action icon sizes.
|
||||
- **tool**: add word wrap toggle to tool arguments display.
|
||||
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation.
|
||||
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace.
|
||||
- **misc**: add `reasoning_effort` support for Grok 4.3.
|
||||
- **misc**: increase chat topic title length.
|
||||
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher.
|
||||
- **chat-input**: show skeleton in action bar while config is loading.
|
||||
- **home**: add Recommendations module with hetero agent action library.
|
||||
- **copyable-label**: wrap long tool-call params instead of truncating.
|
||||
- **misc**: format tool execution time as Xmin Ys instead of X.Y min.
|
||||
- **misc**: Add new DeepSeek-V4 models.
|
||||
- **topic**: add copy session ID to topic dropdown menu.
|
||||
- **misc**: use visible divider between queued messages.
|
||||
- **intervention**: polish confirmation bar layout.
|
||||
- **settings**: remove image avatar from lab input markdown rendering item.
|
||||
- **task**: activity card stop run + register /tasks in SPA proxy.
|
||||
- **misc**: update auth captcha retry copy.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **agent-runtime**: persist agent operations to `agent_operations` table, closes [#14736](https://github.com/lobehub/lobe-chat/issues/14736) ([a772341](https://github.com/lobehub/lobe-chat/commit/a772341))
|
||||
- **misc**: support slack mpim and fix discord dm problem, closes [#14733](https://github.com/lobehub/lobe-chat/issues/14733) ([729265a](https://github.com/lobehub/lobe-chat/commit/729265a))
|
||||
- **database**: add `agent_operations` table, closes [#14416](https://github.com/lobehub/lobe-chat/issues/14416) ([cb8b616](https://github.com/lobehub/lobe-chat/commit/cb8b616))
|
||||
- **markdown**: user_feedback card + task card polish + Run now context menu, closes [#14727](https://github.com/lobehub/lobe-chat/issues/14727) ([79152fa](https://github.com/lobehub/lobe-chat/commit/79152fa))
|
||||
- **documents**: add optimistic create/delete and inline rename for document tree, closes [#14714](https://github.com/lobehub/lobe-chat/issues/14714) ([0007984](https://github.com/lobehub/lobe-chat/commit/0007984))
|
||||
- **devtools**: add dev-only feature flag override panel, closes [#14565](https://github.com/lobehub/lobe-chat/issues/14565) ([18b1c25](https://github.com/lobehub/lobe-chat/commit/18b1c25))
|
||||
- **misc**: add service model assignments settings, closes [#14712](https://github.com/lobehub/lobe-chat/issues/14712) ([eb924ec](https://github.com/lobehub/lobe-chat/commit/eb924ec))
|
||||
- **misc**: inline skill auth in recommended task templates, closes [#14676](https://github.com/lobehub/lobe-chat/issues/14676) ([4490e3e](https://github.com/lobehub/lobe-chat/commit/4490e3e))
|
||||
- **activator**: require activation reason, closes [#14597](https://github.com/lobehub/lobe-chat/issues/14597) ([5f14b7e](https://github.com/lobehub/lobe-chat/commit/5f14b7e))
|
||||
- **agent-signal,server,prompts**: consolidate in self-review implemented, closes [#14657](https://github.com/lobehub/lobe-chat/issues/14657) ([1374fd2](https://github.com/lobehub/lobe-chat/commit/1374fd2))
|
||||
- **hetero-agent**: support AskUserQuestion tools for claude code, closes [#14639](https://github.com/lobehub/lobe-chat/issues/14639) ([49c3d7e](https://github.com/lobehub/lobe-chat/commit/49c3d7e))
|
||||
- **bot**: gate device tools by sender identity, closes [#14634](https://github.com/lobehub/lobe-chat/issues/14634) ([3c81011](https://github.com/lobehub/lobe-chat/commit/3c81011))
|
||||
- **misc**: add user activity business hook, closes [#14601](https://github.com/lobehub/lobe-chat/issues/14601) ([521566b](https://github.com/lobehub/lobe-chat/commit/521566b))
|
||||
- **misc**: add Gemini 3.1 Flash-Lite provider cards, closes [#14604](https://github.com/lobehub/lobe-chat/issues/14604) ([9b032f0](https://github.com/lobehub/lobe-chat/commit/9b032f0))
|
||||
- **misc**: home daily brief with linkable welcome + paired input hint, closes [#14589](https://github.com/lobehub/lobe-chat/issues/14589) ([12e37f1](https://github.com/lobehub/lobe-chat/commit/12e37f1))
|
||||
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions, closes [#14583](https://github.com/lobehub/lobe-chat/issues/14583) ([b7a5020](https://github.com/lobehub/lobe-chat/commit/b7a5020))
|
||||
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations, closes [#14586](https://github.com/lobehub/lobe-chat/issues/14586) ([d2c379c](https://github.com/lobehub/lobe-chat/commit/d2c379c))
|
||||
- **misc**: migrate Notion to LobeHub Market, closes [#14578](https://github.com/lobehub/lobe-chat/issues/14578) ([f1f2e58](https://github.com/lobehub/lobe-chat/commit/f1f2e58))
|
||||
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context, closes [#14568](https://github.com/lobehub/lobe-chat/issues/14568) ([7792f63](https://github.com/lobehub/lobe-chat/commit/7792f63))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **hetero-agent**: wire AskUserBridge response events to renderer, closes [#14732](https://github.com/lobehub/lobe-chat/issues/14732) ([5174c13](https://github.com/lobehub/lobe-chat/commit/5174c13))
|
||||
- **home**: blank user bubble when sending the placeholder hint, closes [#14678](https://github.com/lobehub/lobe-chat/issues/14678) ([fc275ca](https://github.com/lobehub/lobe-chat/commit/fc275ca))
|
||||
- **conversation**: prevent synthetic scroll from shrinking spacer, closes [#14584](https://github.com/lobehub/lobe-chat/issues/14584) ([217afcf](https://github.com/lobehub/lobe-chat/commit/217afcf))
|
||||
- **task-card**: localize task card date independent of dayjs global locale, closes [#14730](https://github.com/lobehub/lobe-chat/issues/14730) ([df0e635](https://github.com/lobehub/lobe-chat/commit/df0e635))
|
||||
- **web-crawler**: cap response body size to prevent serverless OOM, closes [#14660](https://github.com/lobehub/lobe-chat/issues/14660) ([2202189](https://github.com/lobehub/lobe-chat/commit/2202189))
|
||||
- **desktop**: focus onboarding auth success state, closes [#14694](https://github.com/lobehub/lobe-chat/issues/14694) ([4e4294f](https://github.com/lobehub/lobe-chat/commit/4e4294f))
|
||||
- **misc**: Docs image, closes [#14726](https://github.com/lobehub/lobe-chat/issues/14726) ([3a4bd4a](https://github.com/lobehub/lobe-chat/commit/3a4bd4a))
|
||||
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…), closes [#14720](https://github.com/lobehub/lobe-chat/issues/14720) ([a40fe91](https://github.com/lobehub/lobe-chat/commit/a40fe91))
|
||||
- **misc**: update Task page placeholder copy, closes [#14704](https://github.com/lobehub/lobe-chat/issues/14704) ([eea742f](https://github.com/lobehub/lobe-chat/commit/eea742f))
|
||||
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`, closes [#14713](https://github.com/lobehub/lobe-chat/issues/14713) ([5ff4590](https://github.com/lobehub/lobe-chat/commit/5ff4590))
|
||||
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths, closes [#14695](https://github.com/lobehub/lobe-chat/issues/14695) ([51cefe0](https://github.com/lobehub/lobe-chat/commit/51cefe0))
|
||||
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit, closes [#14711](https://github.com/lobehub/lobe-chat/issues/14711) ([948e48b](https://github.com/lobehub/lobe-chat/commit/948e48b))
|
||||
- **tasks**: scheduler, hotkey, comment & TodoList polish, closes [#14707](https://github.com/lobehub/lobe-chat/issues/14707) ([1ae774d](https://github.com/lobehub/lobe-chat/commit/1ae774d))
|
||||
- **cli**: remove stale cron entry from generated man page, closes [#14709](https://github.com/lobehub/lobe-chat/issues/14709) ([94e4ea6](https://github.com/lobehub/lobe-chat/commit/94e4ea6))
|
||||
- **misc**: sidebar add agent, closes [#14693](https://github.com/lobehub/lobe-chat/issues/14693) ([fdedc96](https://github.com/lobehub/lobe-chat/commit/fdedc96))
|
||||
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop, closes [#185](https://github.com/lobehub/lobe-chat/issues/185), closes [#14689](https://github.com/lobehub/lobe-chat/issues/14689) ([7349ad0](https://github.com/lobehub/lobe-chat/commit/7349ad0))
|
||||
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion, closes [#14681](https://github.com/lobehub/lobe-chat/issues/14681) ([744059c](https://github.com/lobehub/lobe-chat/commit/744059c))
|
||||
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls, closes [#14663](https://github.com/lobehub/lobe-chat/issues/14663) ([dfe1932](https://github.com/lobehub/lobe-chat/commit/dfe1932))
|
||||
- **misc**: reject inactive OIDC access, closes [#14674](https://github.com/lobehub/lobe-chat/issues/14674) ([b79c5d8](https://github.com/lobehub/lobe-chat/commit/b79c5d8))
|
||||
- **misc**: drop unreachable aihubmix empty-apiKey test, closes [#14669](https://github.com/lobehub/lobe-chat/issues/14669) ([b0ee35d](https://github.com/lobehub/lobe-chat/commit/b0ee35d))
|
||||
- **aihubmix**: use full models endpoint to return complete model list, closes [#14511](https://github.com/lobehub/lobe-chat/issues/14511) ([f4de472](https://github.com/lobehub/lobe-chat/commit/f4de472))
|
||||
- **onboarding**: skip marketplace on early exit, drop CJK in prompts, closes [#14598](https://github.com/lobehub/lobe-chat/issues/14598) ([a9eb904](https://github.com/lobehub/lobe-chat/commit/a9eb904))
|
||||
- **model-runtime**: enrich stream parse errors with provider/model context, closes [#14636](https://github.com/lobehub/lobe-chat/issues/14636) ([7daed90](https://github.com/lobehub/lobe-chat/commit/7daed90))
|
||||
- **home**: strip markdown links from daily-brief input placeholder, closes [#14635](https://github.com/lobehub/lobe-chat/issues/14635) ([0babdcf](https://github.com/lobehub/lobe-chat/commit/0babdcf))
|
||||
- **misc**: consume visual content parts in server runtime, closes [#14637](https://github.com/lobehub/lobe-chat/issues/14637) ([d445a89](https://github.com/lobehub/lobe-chat/commit/d445a89))
|
||||
- **misc**: store onboarding interests as keys, closes [#14624](https://github.com/lobehub/lobe-chat/issues/14624) ([9982de3](https://github.com/lobehub/lobe-chat/commit/9982de3))
|
||||
- **hetero-agent**: sync new-step assistant across replicas, closes [#14631](https://github.com/lobehub/lobe-chat/issues/14631) ([7675bd9](https://github.com/lobehub/lobe-chat/commit/7675bd9))
|
||||
- **misc**: remove the old cron job from lobehub, closes [#14630](https://github.com/lobehub/lobe-chat/issues/14630) ([457d112](https://github.com/lobehub/lobe-chat/commit/457d112))
|
||||
- **misc**: refresh content baseline from DB on every ingest call, closes [#14603](https://github.com/lobehub/lobe-chat/issues/14603) ([6595961](https://github.com/lobehub/lobe-chat/commit/6595961))
|
||||
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline, closes [#14629](https://github.com/lobehub/lobe-chat/issues/14629) ([ae8f9cf](https://github.com/lobehub/lobe-chat/commit/ae8f9cf))
|
||||
- **local-system**: guard readFile against binary blobs and oversized output, closes [#14602](https://github.com/lobehub/lobe-chat/issues/14602) ([96165e4](https://github.com/lobehub/lobe-chat/commit/96165e4))
|
||||
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer, closes [#14590](https://github.com/lobehub/lobe-chat/issues/14590) ([38b793f](https://github.com/lobehub/lobe-chat/commit/38b793f))
|
||||
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash, closes [#14606](https://github.com/lobehub/lobe-chat/issues/14606) ([11ec59b](https://github.com/lobehub/lobe-chat/commit/11ec59b))
|
||||
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail, closes [#14596](https://github.com/lobehub/lobe-chat/issues/14596) ([4bfd434](https://github.com/lobehub/lobe-chat/commit/4bfd434))
|
||||
- **gemini**: handle zero cachedContentTokenCount in usage conversion, closes [#14567](https://github.com/lobehub/lobe-chat/issues/14567) ([307cd8e](https://github.com/lobehub/lobe-chat/commit/307cd8e))
|
||||
- **misc**: first inject the cloudecc runtime session should use the existingStatus, closes [#14592](https://github.com/lobehub/lobe-chat/issues/14592) ([09c66ff](https://github.com/lobehub/lobe-chat/commit/09c66ff))
|
||||
- **misc**: slack connect error & slash commands, closes [#14591](https://github.com/lobehub/lobe-chat/issues/14591) ([8274be0](https://github.com/lobehub/lobe-chat/commit/8274be0))
|
||||
- **misc**: polish task agent manager, closes [#14569](https://github.com/lobehub/lobe-chat/issues/14569) ([a02ecbc](https://github.com/lobehub/lobe-chat/commit/a02ecbc))
|
||||
- **agent-runtime**: recover malformed tool_call names instead of finishing silently, closes [#14577](https://github.com/lobehub/lobe-chat/issues/14577) ([5f8ec8b](https://github.com/lobehub/lobe-chat/commit/5f8ec8b))
|
||||
- **misc**: remove signin captcha flow, closes [#14573](https://github.com/lobehub/lobe-chat/issues/14573) ([181b7eb](https://github.com/lobehub/lobe-chat/commit/181b7eb))
|
||||
- **misc**: add temporary email auth error locale, closes [#14564](https://github.com/lobehub/lobe-chat/issues/14564) ([2bdd901](https://github.com/lobehub/lobe-chat/commit/2bdd901))
|
||||
- **misc**: add bot callback service, closes [#14570](https://github.com/lobehub/lobe-chat/issues/14570) ([e4b5e52](https://github.com/lobehub/lobe-chat/commit/e4b5e52))
|
||||
- **misc**: sanitize sensitive comments and examples from production JS bundle, closes [#14557](https://github.com/lobehub/lobe-chat/issues/14557) ([1a6e07b](https://github.com/lobehub/lobe-chat/commit/1a6e07b))
|
||||
- **misc**: multiple account link, closes [#14562](https://github.com/lobehub/lobe-chat/issues/14562) ([760a342](https://github.com/lobehub/lobe-chat/commit/760a342))
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component, closes [#14703](https://github.com/lobehub/lobe-chat/issues/14703) ([266d102](https://github.com/lobehub/lobe-chat/commit/266d102))
|
||||
- **misc**: polish desktop header icons, sidebar density, and task menus, closes [#14724](https://github.com/lobehub/lobe-chat/issues/14724) ([e56edab](https://github.com/lobehub/lobe-chat/commit/e56edab))
|
||||
- **review-panel**: hover revert button to discard per-file working-tree changes, closes [#14716](https://github.com/lobehub/lobe-chat/issues/14716) ([846e648](https://github.com/lobehub/lobe-chat/commit/846e648))
|
||||
- **misc**: standardize header action icon sizes, closes [#14717](https://github.com/lobehub/lobe-chat/issues/14717) ([ca9a781](https://github.com/lobehub/lobe-chat/commit/ca9a781))
|
||||
- **tool**: add word wrap toggle to tool arguments display, closes [#14706](https://github.com/lobehub/lobe-chat/issues/14706) ([bfa2850](https://github.com/lobehub/lobe-chat/commit/bfa2850))
|
||||
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation, closes [#14692](https://github.com/lobehub/lobe-chat/issues/14692) ([877052f](https://github.com/lobehub/lobe-chat/commit/877052f))
|
||||
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace, closes [#14667](https://github.com/lobehub/lobe-chat/issues/14667) ([f591f7a](https://github.com/lobehub/lobe-chat/commit/f591f7a))
|
||||
- **misc**: add `reasoning_effort` support for Grok 4.3, closes [#14642](https://github.com/lobehub/lobe-chat/issues/14642) ([a1fac45](https://github.com/lobehub/lobe-chat/commit/a1fac45))
|
||||
- **misc**: increase chat topic title length, closes [#14659](https://github.com/lobehub/lobe-chat/issues/14659) ([e0ead0c](https://github.com/lobehub/lobe-chat/commit/e0ead0c))
|
||||
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher, closes [#14658](https://github.com/lobehub/lobe-chat/issues/14658) ([31e9130](https://github.com/lobehub/lobe-chat/commit/31e9130))
|
||||
- **chat-input**: show skeleton in action bar while config is loading, closes [#14656](https://github.com/lobehub/lobe-chat/issues/14656) ([84b802c](https://github.com/lobehub/lobe-chat/commit/84b802c))
|
||||
- **home**: add Recommendations module with hetero agent action library, closes [#14645](https://github.com/lobehub/lobe-chat/issues/14645) ([e261a6f](https://github.com/lobehub/lobe-chat/commit/e261a6f))
|
||||
- **copyable-label**: wrap long tool-call params instead of truncating, closes [#14640](https://github.com/lobehub/lobe-chat/issues/14640) ([60a127b](https://github.com/lobehub/lobe-chat/commit/60a127b))
|
||||
- **misc**: format tool execution time as Xmin Ys instead of X.Y min, closes [#14641](https://github.com/lobehub/lobe-chat/issues/14641) ([b85a1ad](https://github.com/lobehub/lobe-chat/commit/b85a1ad))
|
||||
- **misc**: Add new DeepSeek-V4 models, closes [#14110](https://github.com/lobehub/lobe-chat/issues/14110) ([867e22a](https://github.com/lobehub/lobe-chat/commit/867e22a))
|
||||
- **topic**: add copy session ID to topic dropdown menu, closes [#14595](https://github.com/lobehub/lobe-chat/issues/14595) ([a275009](https://github.com/lobehub/lobe-chat/commit/a275009))
|
||||
- **misc**: use visible divider between queued messages, closes [#14593](https://github.com/lobehub/lobe-chat/issues/14593) ([909b1ec](https://github.com/lobehub/lobe-chat/commit/909b1ec))
|
||||
- **intervention**: polish confirmation bar layout, closes [#14587](https://github.com/lobehub/lobe-chat/issues/14587) ([5c11130](https://github.com/lobehub/lobe-chat/commit/5c11130))
|
||||
- **settings**: remove image avatar from lab input markdown rendering item, closes [#14582](https://github.com/lobehub/lobe-chat/issues/14582) ([d73de25](https://github.com/lobehub/lobe-chat/commit/d73de25))
|
||||
- **task**: activity card stop run + register /tasks in SPA proxy, closes [#14559](https://github.com/lobehub/lobe-chat/issues/14559) ([a7cc553](https://github.com/lobehub/lobe-chat/commit/a7cc553))
|
||||
- **misc**: update auth captcha retry copy, closes [#14561](https://github.com/lobehub/lobe-chat/issues/14561) ([c208723](https://github.com/lobehub/lobe-chat/commit/c208723))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
|
||||
|
||||
<sup>Released on **2026-05-09**</sup>
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ ENV \
|
||||
# AiHubMix
|
||||
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_CLIENT_TIMEOUT="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
# Amazon Bedrock
|
||||
ENABLED_AWS_BEDROCK="" AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
# Azure OpenAI
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
# LobeHub
|
||||
|
||||
LobeHub is the ultimate space for work and life: <br/>
|
||||
to find, build, and collaborate with agent teammates that grow with you.<br/>
|
||||
We’re building the world’s largest human–agent co-evolving network.
|
||||
LobeHub organizes your agents into 7×24 operation.
|
||||
|
||||
It hires, schedules, reports on your entire AI team.
|
||||
|
||||
You stay in charge — without staying online.
|
||||
|
||||
**English** · [简体中文](./README.zh-CN.md) · [Official Site][official-site] · [Changelog][changelog] · [Documents][docs] · [Blog][blog] · [Feedback][github-issues-link]
|
||||
|
||||
@@ -25,7 +27,6 @@ We’re building the world’s largest human–agent co-evolving network.
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
[![][github-license-shield]][github-license-link]<br>
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
**Share LobeHub Repository**
|
||||
|
||||
@@ -37,9 +38,9 @@ We’re building the world’s largest human–agent co-evolving network.
|
||||
[![][share-mastodon-shield]][share-mastodon-link]
|
||||
[![][share-linkedin-shield]][share-linkedin-link]
|
||||
|
||||
<sup>Agent teammates that grow with you</sup>
|
||||
<sup>Your Chief Agent Operator</sup>
|
||||
|
||||
[![][github-trending-shield]][github-trending-url]
|
||||
<a href="https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-lobehub-2" target="_blank" rel="noopener noreferrer"><img alt="LobeHub - Your Chief Agent Operator for multi-agent work | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1147569&theme=light&period=daily&t=1779247564355"></a> <a href="https://trendshift.io/repositories/19224" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19224" alt="lobehub%2Flobehub | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://vercel.com/oss)
|
||||
|
||||
@@ -52,30 +53,10 @@ We’re building the world’s largest human–agent co-evolving network.
|
||||
|
||||
- [👋🏻 Getting Started & Join Our Community](#-getting-started--join-our-community)
|
||||
- [✨ Features](#-features)
|
||||
- [Operator: Agents as the Unit of Work](#operator-agents-as-the-unit-of-work)
|
||||
- [Create: Agents as the Unit of Work](#create-agents-as-the-unit-of-work)
|
||||
- [Collaborate: Scale New Forms of Collaboration Networks](#collaborate-scale-new-forms-of-collaboration-networks)
|
||||
- [Evolve: Co-evolution of Humans and Agents](#evolve-co-evolution-of-humans-and-agents)
|
||||
- [MCP Plugin One-Click Installation](#mcp-plugin-one-click-installation)
|
||||
- [MCP Marketplace](#mcp-marketplace)
|
||||
- [Desktop App](#desktop-app)
|
||||
- [Smart Internet Search](#smart-internet-search)
|
||||
- [Chain of Thought](#chain-of-thought)
|
||||
- [Branching Conversations](#branching-conversations)
|
||||
- [Artifacts Support](#artifacts-support)
|
||||
- [File Upload /Knowledge Base](#file-upload-knowledge-base)
|
||||
- [Multi-Model Service Provider Support](#multi-model-service-provider-support)
|
||||
- [Local Large Language Model (LLM) Support](#local-large-language-model-llm-support)
|
||||
- [Model Visual Recognition](#model-visual-recognition)
|
||||
- [TTS & STT Voice Conversation](#tts--stt-voice-conversation)
|
||||
- [Text to Image Generation](#text-to-image-generation)
|
||||
- [Plugin System (Function Calling)](#plugin-system-function-calling)
|
||||
- [Agent Market (GPTs)](#agent-market-gpts)
|
||||
- [Support Local / Remote Database](#support-local--remote-database)
|
||||
- [Support Multi-User Management](#support-multi-user-management)
|
||||
- [Progressive Web App (PWA)](#progressive-web-app-pwa)
|
||||
- [Mobile Device Adaptation](#mobile-device-adaptation)
|
||||
- [Custom Themes](#custom-themes)
|
||||
- [`*` What's more](#-whats-more)
|
||||
- [🛳 Self Hosting](#-self-hosting)
|
||||
- [`A` Deploying with Vercel, Zeabur , Sealos or Alibaba Cloud](#a-deploying-with-vercel-zeabur--sealos-or-alibaba-cloud)
|
||||
- [`B` Deploying with Docker](#b-deploying-with-docker)
|
||||
@@ -95,7 +76,7 @@ We’re building the world’s largest human–agent co-evolving network.
|
||||
|
||||
<br/>
|
||||
|
||||
<https://github.com/user-attachments/assets/6710ad97-03d0-4175-bd75-adff9b55eca2>
|
||||
<https://github.com/user-attachments/assets/0a33365f-b786-48b5-9ed6-f8af7927bccb>
|
||||
|
||||
## 👋🏻 Getting Started & Join Our Community
|
||||
|
||||
@@ -104,9 +85,9 @@ By adopting the Bootstrapping approach, we aim to provide developers and users w
|
||||
|
||||
Whether for users or professional developers, LobeHub will be your AI Agent playground. Please be aware that LobeHub is currently under active development, and feedback is welcome for any [issues][issues-link] encountered.
|
||||
|
||||
| [](https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | We are live on Product Hunt! We are thrilled to bring LobeHub to the world. If you believe in a future where humans and agents co-evolve, please support our journey. |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [![][discord-shield-badge]][discord-link] | Join our Discord community! This is where you can connect with developers and other enthusiastic users of LobeHub. |
|
||||
| [](https://www.producthunt.com/products/lobehub?launch=lobehub-2&embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | We are live on Product Hunt! We are thrilled to bring LobeHub to the world. If you believe in a future where humans and agents co-evolve, please support our journey. |
|
||||
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [![][discord-shield-badge]][discord-link] | Join our Discord community! This is where you can connect with developers and other enthusiastic users of LobeHub. |
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
@@ -130,7 +111,26 @@ Today’s agents are one-off, task-driven tools. They lack context, live in isol
|
||||
|
||||
LobeHub is a work-and-lifestyle space to find, build, and collaborate with agent teammates that grow with you. In LobeHub, we treat **Agents as the unit of work**, providing an infrastructure where humans and agents co-evolve.
|
||||
|
||||

|
||||

|
||||
|
||||
### Operator: Agents as the Unit of Work
|
||||
|
||||
Hires, schedules, and reports on your entire AI team.
|
||||
|
||||
- **More productivity. Fewer tools**: Bring all your agents under one roof.
|
||||
- **IM Gateway**: Agents where you already chat.
|
||||
|
||||

|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
### Create: Agents as the Unit of Work
|
||||
|
||||
@@ -139,6 +139,8 @@ Building a personalized AI team starts with the **Agent Builder**. You can descr
|
||||
- **Unified Intelligence**: Seamlessly access any model and any modality—all under your control.
|
||||
- **10,000+ Skills**: Connect your agents to the skills you use every day with a library of over 10,000 tools and MCP-compatible plugins.
|
||||
|
||||

|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
<div align="right">
|
||||
@@ -158,6 +160,8 @@ LobeHub introduces **Agent Groups**, allowing you to work with agents like real
|
||||
- **Project**: Organize work by project to keep everything structured and easy to track.
|
||||
- **Workspace**: A shared space for teams to collaborate with agents, ensuring clear ownership and visibility across the organization.
|
||||
|
||||

|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
<div align="right">
|
||||
@@ -175,113 +179,7 @@ The best AI is one that understands you deeply. LobeHub features **Personal Memo
|
||||
- **Continual Learning**: Your agents learn from how you work, adapting their behavior to act at the right moment.
|
||||
- **White-Box Memory**: We believe in transparency. Your agents use structured, editable memory, giving you full control over what they remember.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>More Features</summary>
|
||||
|
||||
![][image-feat-mcp]
|
||||
|
||||
### MCP Plugin One-Click Installation
|
||||
|
||||
**Seamlessly Connect Your AI to the World**
|
||||
|
||||
Unlock the full potential of your AI by enabling smooth, secure, and dynamic interactions with external tools, data sources, and services. LobeHub's MCP (Model Context Protocol) plugin system breaks down the barriers between your AI and the digital ecosystem, allowing for unprecedented connectivity and functionality.
|
||||
|
||||
Transform your conversations into powerful workflows by connecting to databases, APIs, file systems, and more. Experience the freedom of AI that truly understands and interacts with your world.
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
![][image-feat-mcp-market]
|
||||
|
||||
### MCP Marketplace
|
||||
|
||||
**Discover, Connect, Extend**
|
||||
|
||||
Browse a growing library of MCP plugins to expand your AI's capabilities and streamline your workflows effortlessly. Visit [lobehub.com/mcp](https://lobehub.com/mcp) to explore the MCP Marketplace, which offers a curated collection of integrations that enhance your AI's ability to work with various tools and services.
|
||||
|
||||
From productivity tools to development environments, discover new ways to extend your AI's reach and effectiveness. Connect with the community and find the perfect plugins for your specific needs.
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
![][image-feat-desktop]
|
||||
|
||||
### Desktop App
|
||||
|
||||
**Peak Performance, Zero Distractions**
|
||||
|
||||
Get the full LobeHub experience without browser limitations—comprehensive, focused, and always ready to go. Our desktop application provides a dedicated environment for your AI interactions, ensuring optimal performance and minimal distractions.
|
||||
|
||||
Experience faster response times, better resource management, and a more stable connection to your AI assistant. The desktop app is designed for users who demand the best performance from their AI tools.
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
![][image-feat-web-search]
|
||||
|
||||
### Smart Internet Search
|
||||
|
||||
**Online Knowledge On Demand**
|
||||
|
||||
With real-time internet access, your AI keeps up with the world—news, data, trends, and more. Stay informed and get the most current information available, enabling your AI to provide accurate and up-to-date responses.
|
||||
|
||||
Access live information, verify facts, and explore current events without leaving your conversation. Your AI becomes a gateway to the world's knowledge, always current and comprehensive.
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-cot]][docs-feat-cot]
|
||||
|
||||
### [Chain of Thought][docs-feat-cot]
|
||||
|
||||
Experience AI reasoning like never before. Watch as complex problems unfold step by step through our innovative Chain of Thought (CoT) visualization. This breakthrough feature provides unprecedented transparency into AI's decision-making process, allowing you to observe how conclusions are reached in real-time.
|
||||
|
||||
By breaking down complex reasoning into clear, logical steps, you can better understand and validate the AI's problem-solving approach. Whether you're debugging, learning, or simply curious about AI reasoning, CoT visualization transforms abstract thinking into an engaging, interactive experience.
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-branch]][docs-feat-branch]
|
||||
|
||||
### [Branching Conversations][docs-feat-branch]
|
||||
|
||||
Introducing a more natural and flexible way to chat with AI. With Branch Conversations, your discussions can flow in multiple directions, just like human conversations do. Create new conversation branches from any message, giving you the freedom to explore different paths while preserving the original context.
|
||||
|
||||
Choose between two powerful modes:
|
||||
|
||||
- **Continuation Mode:** Seamlessly extend your current discussion while maintaining valuable context
|
||||
- **Standalone Mode:** Start fresh with a new topic based on any previous message
|
||||
|
||||
This groundbreaking feature transforms linear conversations into dynamic, tree-like structures, enabling deeper exploration of ideas and more productive interactions.
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-artifacts]][docs-feat-artifacts]
|
||||
|
||||
### [Artifacts Support][docs-feat-artifacts]
|
||||
|
||||
Experience the power of Claude Artifacts, now integrated into LobeHub. This revolutionary feature expands the boundaries of AI-human interaction, enabling real-time creation and visualization of diverse content formats.
|
||||
|
||||
Create and visualize with unprecedented flexibility:
|
||||
|
||||
- Generate and display dynamic SVG graphics
|
||||
- Build and render interactive HTML pages in real-time
|
||||
- Produce professional documents in multiple formats
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-knowledgebase]][docs-feat-knowledgebase]
|
||||
|
||||
### [File Upload /Knowledge Base][docs-feat-knowledgebase]
|
||||
|
||||
LobeHub supports file upload and knowledge base functionality. You can upload various types of files including documents, images, audio, and video, as well as create knowledge bases, making it convenient for users to manage and search for files. Additionally, you can utilize files and knowledge base features during conversations, enabling a richer dialogue experience.
|
||||
|
||||
<https://github.com/user-attachments/assets/faa8cf67-e743-4590-8bf6-ebf6ccc34175>
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> Learn more on [📘 LobeHub Knowledge Base Launch — From Now On, Every Step Counts](https://lobehub.com/blog/knowledge-base)
|
||||

|
||||
|
||||
<div align="right">
|
||||
|
||||
@@ -289,277 +187,6 @@ LobeHub supports file upload and knowledge base functionality. You can upload va
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-privoder]][docs-feat-provider]
|
||||
|
||||
### [Multi-Model Service Provider Support][docs-feat-provider]
|
||||
|
||||
In the continuous development of LobeHub, we deeply understand the importance of diversity in model service providers for meeting the needs of the community when providing AI conversation services. Therefore, we have expanded our support to multiple model service providers, rather than being limited to a single one, in order to offer users a more diverse and rich selection of conversations.
|
||||
|
||||
In this way, LobeHub can more flexibly adapt to the needs of different users, while also providing developers with a wider range of choices.
|
||||
|
||||
#### Supported Model Service Providers
|
||||
|
||||
We have implemented support for the following model service providers:
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
<details><summary><kbd>See more providers (+-10)</kbd></summary>
|
||||
|
||||
</details>
|
||||
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
At the same time, we are also planning to support more model service providers. If you would like LobeHub to support your favorite service provider, feel free to join our [💬 community discussion](https://github.com/lobehub/lobehub/discussions/1284).
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-local]][docs-feat-local]
|
||||
|
||||
### [Local Large Language Model (LLM) Support][docs-feat-local]
|
||||
|
||||
To meet the specific needs of users, LobeHub also supports the use of local models based on [Ollama](https://ollama.ai), allowing users to flexibly use their own or third-party models.
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> Learn more about [📘 Using Ollama in LobeHub][docs-usage-ollama] by checking it out.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-vision]][docs-feat-vision]
|
||||
|
||||
### [Model Visual Recognition][docs-feat-vision]
|
||||
|
||||
LobeHub now supports OpenAI's latest [`gpt-4-vision`](https://platform.openai.com/docs/guides/vision) model with visual recognition capabilities,
|
||||
a multimodal intelligence that can perceive visuals. Users can easily upload or drag and drop images into the dialogue box,
|
||||
and the agent will be able to recognize the content of the images and engage in intelligent conversation based on this,
|
||||
creating smarter and more diversified chat scenarios.
|
||||
|
||||
This feature opens up new interactive methods, allowing communication to transcend text and include a wealth of visual elements.
|
||||
Whether it's sharing images in daily use or interpreting images within specific industries, the agent provides an outstanding conversational experience.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-tts]][docs-feat-tts]
|
||||
|
||||
### [TTS & STT Voice Conversation][docs-feat-tts]
|
||||
|
||||
LobeHub supports Text-to-Speech (TTS) and Speech-to-Text (STT) technologies, enabling our application to convert text messages into clear voice outputs,
|
||||
allowing users to interact with our conversational agent as if they were talking to a real person. Users can choose from a variety of voices to pair with the agent.
|
||||
|
||||
Moreover, TTS offers an excellent solution for those who prefer auditory learning or desire to receive information while busy.
|
||||
In LobeHub, we have meticulously selected a range of high-quality voice options (OpenAI Audio, Microsoft Edge Speech) to meet the needs of users from different regions and cultural backgrounds.
|
||||
Users can choose the voice that suits their personal preferences or specific scenarios, resulting in a personalized communication experience.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-t2i]][docs-feat-t2i]
|
||||
|
||||
### [Text to Image Generation][docs-feat-t2i]
|
||||
|
||||
With support for the latest text-to-image generation technology, LobeHub now allows users to invoke image creation tools directly within conversations with the agent. By leveraging the capabilities of AI tools such as [`DALL-E 3`](https://openai.com/dall-e-3), [`MidJourney`](https://www.midjourney.com/), and [`Pollinations`](https://pollinations.ai/), the agents are now equipped to transform your ideas into images.
|
||||
|
||||
This enables a more private and immersive creative process, allowing for the seamless integration of visual storytelling into your personal dialogue with the agent.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-plugin]][docs-feat-plugin]
|
||||
|
||||
### [Plugin System (Function Calling)][docs-feat-plugin]
|
||||
|
||||
The plugin ecosystem of LobeHub is an important extension of its core functionality, greatly enhancing the practicality and flexibility of the LobeHub assistant.
|
||||
|
||||
<video controls src="https://github.com/lobehub/lobehub/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2" muted="false"></video>
|
||||
|
||||
By utilizing plugins, LobeHub assistants can obtain and process real-time information, such as searching for web information and providing users with instant and relevant news.
|
||||
|
||||
In addition, these plugins are not limited to news aggregation, but can also extend to other practical functions, such as quickly searching documents, generating images, obtaining data from various platforms like Bilibili, Steam, and interacting with various third-party services.
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> Learn more about [📘 Plugin Usage][docs-usage-plugin] by checking it out.
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| Recent Submits | Description |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Shopping tools](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2026-01-12**</sup> | Search for products on eBay & AliExpress, find eBay events & coupons. Get prompt examples.<br/>`shopping` `e-bay` `ali-express` `coupons` |
|
||||
| [SEO Assistant](https://lobechat.com/discover/plugin/seo_assistant)<br/><sup>By **webfx** on **2026-01-12**</sup> | The SEO Assistant can generate search engine keyword information in order to aid the creation of content.<br/>`seo` `keyword` |
|
||||
| [Video Captions](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | Convert Youtube links into transcribed text, enable asking questions, create chapters, and summarize its content.<br/>`video-to-text` `youtube` |
|
||||
| [WeatherGPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | Get current weather information for a specific location.<br/>`weather` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**40**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-agent]][docs-feat-agent]
|
||||
|
||||
### [Agent Market (GPTs)][docs-feat-agent]
|
||||
|
||||
In LobeHub Agent Marketplace, creators can discover a vibrant and innovative community that brings together a multitude of well-designed agents,
|
||||
which not only play an important role in work scenarios but also offer great convenience in learning processes.
|
||||
Our marketplace is not just a showcase platform but also a collaborative space. Here, everyone can contribute their wisdom and share the agents they have developed.
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> By [🤖/🏪 Submit Agents][submit-agents-link], you can easily submit your agent creations to our platform.
|
||||
> Importantly, LobeHub has established a sophisticated automated internationalization (i18n) workflow,
|
||||
> capable of seamlessly translating your agent into multiple language versions.
|
||||
> This means that no matter what language your users speak, they can experience your agent without barriers.
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> We welcome all users to join this growing ecosystem and participate in the iteration and optimization of agents.
|
||||
> Together, we can create more interesting, practical, and innovative agents, further enriching the diversity and practicality of the agent offerings.
|
||||
|
||||
<!-- AGENT LIST -->
|
||||
|
||||
| Recent Submits | Description |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Turtle Soup Host](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | A turtle soup host needs to provide the scenario, the complete story (truth of the event), and the key point (the condition for guessing correctly).<br/>`turtle-soup` `reasoning` `interaction` `puzzle` `role-playing` |
|
||||
| [Academic Writing Assistant](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | Expert in academic research paper writing and formal documentation<br/>`academic-writing` `research` `formal-style` |
|
||||
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
|
||||
| [Minecraft Senior Developer](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | Expert in advanced Java development and Minecraft mod and server plugin development<br/>`development` `programming` `minecraft` `java` |
|
||||
|
||||
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
|
||||
|
||||
<!-- AGENT LIST -->
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-database]][docs-feat-database]
|
||||
|
||||
### [Support Local / Remote Database][docs-feat-database]
|
||||
|
||||
LobeHub supports the use of both server-side and local databases. Depending on your needs, you can choose the appropriate deployment solution:
|
||||
|
||||
- **Local database**: suitable for users who want more control over their data and privacy protection. LobeHub uses CRDT (Conflict-Free Replicated Data Type) technology to achieve multi-device synchronization. This is an experimental feature aimed at providing a seamless data synchronization experience.
|
||||
- **Server-side database**: suitable for users who want a more convenient user experience. LobeHub supports PostgreSQL as a server-side database. For detailed documentation on how to configure the server-side database, please visit [Configure Server-side Database](https://lobehub.com/docs/self-hosting/advanced/server-database).
|
||||
|
||||
Regardless of which database you choose, LobeHub can provide you with an excellent user experience.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-auth]][docs-feat-auth]
|
||||
|
||||
### [Support Multi-User Management][docs-feat-auth]
|
||||
|
||||
LobeHub supports multi-user management and provides flexible user authentication solutions:
|
||||
|
||||
- **Better Auth**: LobeHub integrates `Better Auth`, a modern and flexible authentication library that supports multiple authentication methods, including OAuth, email login, credential login, magic links, and more. With `Better Auth`, you can easily implement user registration, login, session management, social login, multi-factor authentication (MFA), and other functions to ensure the security and privacy of user data.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-pwa]][docs-feat-pwa]
|
||||
|
||||
### [Progressive Web App (PWA)][docs-feat-pwa]
|
||||
|
||||
We deeply understand the importance of providing a seamless experience for users in today's multi-device environment.
|
||||
Therefore, we have adopted Progressive Web Application ([PWA](https://support.google.com/chrome/answer/9658361)) technology,
|
||||
a modern web technology that elevates web applications to an experience close to that of native apps.
|
||||
|
||||
Through PWA, LobeHub can offer a highly optimized user experience on both desktop and mobile devices while maintaining high-performance characteristics.
|
||||
Visually and in terms of feel, we have also meticulously designed the interface to ensure it is indistinguishable from native apps,
|
||||
providing smooth animations, responsive layouts, and adapting to different device screen resolutions.
|
||||
|
||||
> \[!NOTE]
|
||||
>
|
||||
> If you are unfamiliar with the installation process of PWA, you can add LobeHub as your desktop application (also applicable to mobile devices) by following these steps:
|
||||
>
|
||||
> - Launch the Chrome or Edge browser on your computer.
|
||||
> - Visit the LobeHub webpage.
|
||||
> - In the upper right corner of the address bar, click on the <kbd>Install</kbd> icon.
|
||||
> - Follow the instructions on the screen to complete the PWA Installation.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-mobile]][docs-feat-mobile]
|
||||
|
||||
### [Mobile Device Adaptation][docs-feat-mobile]
|
||||
|
||||
We have carried out a series of optimization designs for mobile devices to enhance the user's mobile experience. Currently, we are iterating on the mobile user experience to achieve smoother and more intuitive interactions. If you have any suggestions or ideas, we welcome you to provide feedback through GitHub Issues or Pull Requests.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-theme]][docs-feat-theme]
|
||||
|
||||
### [Custom Themes][docs-feat-theme]
|
||||
|
||||
As a design-engineering-oriented application, LobeHub places great emphasis on users' personalized experiences,
|
||||
hence introducing flexible and diverse theme modes, including a light mode for daytime and a dark mode for nighttime.
|
||||
Beyond switching theme modes, a range of color customization options allow users to adjust the application's theme colors according to their preferences.
|
||||
Whether it's a desire for a sober dark blue, a lively peach pink, or a professional gray-white, users can find their style of color choices in LobeHub.
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> The default configuration can intelligently recognize the user's system color mode and automatically switch themes to ensure a consistent visual experience with the operating system.
|
||||
> For users who like to manually control details, LobeHub also offers intuitive setting options and a choice between chat bubble mode and document mode for conversation scenarios.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### `*` What's more
|
||||
|
||||
Beside these features, LobeHub also have much better basic technique underground:
|
||||
|
||||
- [x] 💨 **Quick Deployment**: Using the Vercel platform or docker image, you can deploy with just one click and complete the process within 1 minute without any complex configuration.
|
||||
- [x] 🌐 **Custom Domain**: If users have their own domain, they can bind it to the platform for quick access to the dialogue agent from anywhere.
|
||||
- [x] 🔒 **Privacy Protection**: All data is stored locally in the user's browser, ensuring user privacy.
|
||||
- [x] 💎 **Exquisite UI Design**: With a carefully designed interface, it offers an elegant appearance and smooth interaction. It supports light and dark themes and is mobile-friendly. PWA support provides a more native-like experience.
|
||||
- [x] 🗣️ **Smooth Conversation Experience**: Fluid responses ensure a smooth conversation experience. It fully supports Markdown rendering, including code highlighting, LaTex formulas, Mermaid flowcharts, and more.
|
||||
|
||||
</details>
|
||||
|
||||
> ✨ more features will be added when LobeHub evolve.
|
||||
|
||||
<div align="right">
|
||||
@@ -855,28 +482,10 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[docs-dev-guide]: https://lobehub.com/docs/development/start
|
||||
[docs-docker]: https://lobehub.com/docs/self-hosting/server-database/docker-compose
|
||||
[docs-env-var]: https://lobehub.com/docs/self-hosting/environment-variables
|
||||
[docs-feat-agent]: https://lobehub.com/docs/usage/features/agent-market
|
||||
[docs-feat-artifacts]: https://lobehub.com/docs/usage/features/artifacts
|
||||
[docs-feat-auth]: https://lobehub.com/docs/usage/features/auth
|
||||
[docs-feat-branch]: https://lobehub.com/docs/usage/features/branching-conversations
|
||||
[docs-feat-cot]: https://lobehub.com/docs/usage/features/cot
|
||||
[docs-feat-database]: https://lobehub.com/docs/usage/features/database
|
||||
[docs-feat-knowledgebase]: https://lobehub.com/blog/knowledge-base
|
||||
[docs-feat-local]: https://lobehub.com/docs/usage/features/local-llm
|
||||
[docs-feat-mobile]: https://lobehub.com/docs/usage/features/mobile
|
||||
[docs-feat-plugin]: https://lobehub.com/docs/usage/features/plugin-system
|
||||
[docs-feat-provider]: https://lobehub.com/docs/usage/features/multi-ai-providers
|
||||
[docs-feat-pwa]: https://lobehub.com/docs/usage/features/pwa
|
||||
[docs-feat-t2i]: https://lobehub.com/docs/usage/features/text-to-image
|
||||
[docs-feat-theme]: https://lobehub.com/docs/usage/features/theme
|
||||
[docs-feat-tts]: https://lobehub.com/docs/usage/features/tts
|
||||
[docs-feat-vision]: https://lobehub.com/docs/usage/features/vision
|
||||
[docs-function-call]: https://lobehub.com/blog/openai-function-call
|
||||
[docs-plugin-dev]: https://lobehub.com/docs/usage/plugins/development
|
||||
[docs-self-hosting]: https://lobehub.com/docs/self-hosting/start
|
||||
[docs-upstream-sync]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
|
||||
[docs-usage-ollama]: https://lobehub.com/docs/usage/providers/ollama
|
||||
[docs-usage-plugin]: https://lobehub.com/docs/usage/plugins/basic
|
||||
[fossa-license-link]: https://app.fossa.com/projects/git%2Bgithub.com%2Flobehub%2Flobehub
|
||||
[fossa-license-shield]: https://app.fossa.com/api/projects/git%2Bgithub.com%2Flobehub%2Flobehub.svg?type=large
|
||||
[github-action-release-link]: https://github.com/actions/workflows/lobehub/lobehub/release.yml
|
||||
@@ -898,29 +507,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobehub?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobehub/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobehub?color=ffcb47&labelColor=black&style=flat-square
|
||||
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
|
||||
[github-trending-url]: https://trendshift.io/repositories/2256
|
||||
[image-banner]: https://github.com/user-attachments/assets/0fe626a3-0ddc-4f67-b595-3c5b3f1701e0
|
||||
[image-feat-agent]: https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f
|
||||
[image-feat-artifacts]: https://github.com/user-attachments/assets/7f95fad6-b210-4e6e-84a0-7f39e96f3a00
|
||||
[image-feat-auth]: https://github.com/user-attachments/assets/80bb232e-19d1-4f97-98d6-e291f3585e6d
|
||||
[image-feat-branch]: https://github.com/user-attachments/assets/92f72082-02bd-4835-9c54-b089aad7fd41
|
||||
[image-feat-cot]: https://github.com/user-attachments/assets/f74f1139-d115-4e9c-8c43-040a53797a5e
|
||||
[image-feat-database]: https://github.com/user-attachments/assets/f1697c8b-d1fb-4dac-ba05-153c6295d91d
|
||||
[image-feat-desktop]: https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96
|
||||
[image-feat-knowledgebase]: https://github.com/user-attachments/assets/7da7a3b2-92fd-4630-9f4e-8560c74955ae
|
||||
[image-feat-local]: https://github.com/user-attachments/assets/1239da50-d832-4632-a7ef-bd754c0f3850
|
||||
[image-feat-mcp]: https://github.com/user-attachments/assets/1be85d36-3975-4413-931f-27e05e440995
|
||||
[image-feat-mcp-market]: https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0
|
||||
[image-feat-mobile]: https://github.com/user-attachments/assets/32cf43c4-96bd-4a4c-bfb6-59acde6fe380
|
||||
[image-feat-plugin]: https://github.com/user-attachments/assets/66a891ac-01b6-4e3f-b978-2eb07b489b1b
|
||||
[image-feat-privoder]: https://github.com/user-attachments/assets/e553e407-42de-4919-977d-7dbfcf44a821
|
||||
[image-feat-pwa]: https://github.com/user-attachments/assets/9647f70f-b71b-43b6-9564-7cdd12d1c24d
|
||||
[image-feat-t2i]: https://github.com/user-attachments/assets/708274a7-2458-494b-a6ec-b73dfa1fa7c2
|
||||
[image-feat-theme]: https://github.com/user-attachments/assets/b47c39f1-806f-492b-8fcb-b0fa973937c1
|
||||
[image-feat-tts]: https://github.com/user-attachments/assets/50189597-2cc3-4002-b4c8-756a52ad5c0a
|
||||
[image-feat-vision]: https://github.com/user-attachments/assets/18574a1f-46c2-4cbc-af2c-35a86e128a07
|
||||
[image-feat-web-search]: https://github.com/user-attachments/assets/cfdc48ac-b5f8-4a00-acee-db8f2eba09ad
|
||||
[image-banner]: https://github.com/user-attachments/assets/5f78ae58-ed4f-4d38-8037-96109fbba58c
|
||||
[image-star]: https://github.com/user-attachments/assets/3216e25b-186f-4a54-9cb4-2f124aec0471
|
||||
[issues-link]: https://img.shields.io/github/issues/lobehub/lobehub.svg?style=flat
|
||||
[lobe-chat-plugins]: https://github.com/lobehub/lobe-chat-plugins
|
||||
@@ -958,8 +545,6 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
|
||||
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2CopenAI&text=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20-%20An%20open-source%2C%20extensible%20%28Function%20Calling%29%2C%20high-performance%20chatbot%20framework.%20It%20supports%20one-click%20free%20deployment%20of%20your%20private%20ChatGPT%2FLLM%20web%20application.&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobehub
|
||||
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
|
||||
[sponsor-link]: https://opencollective.com/lobehub 'Become ❤️ LobeHub Sponsor'
|
||||
[sponsor-shield]: https://img.shields.io/badge/-Sponsor%20LobeHub-f04f88?logo=opencollective&logoColor=white&style=flat-square
|
||||
[submit-agents-link]: https://github.com/lobehub/lobe-chat-agents
|
||||
[submit-agents-shield]: https://img.shields.io/badge/🤖/🏪_submit_agent-%E2%86%92-c4f042?labelColor=black&style=for-the-badge
|
||||
[submit-plugin-link]: https://github.com/lobehub/lobe-chat-plugins
|
||||
|
||||
+39
-429
@@ -4,8 +4,11 @@
|
||||
|
||||
# LobeHub
|
||||
|
||||
LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您一起成长的 Agent 队友协作。<br/>
|
||||
在 LobeHub 中,我们将 **Agent 视为工作单元**,提供一个让人类与 Agent 共同进化的基础设施。
|
||||
LobeHub 帮你把专属 Agent 组织成 7×24 不打烊的高效队伍:
|
||||
|
||||
自动为你招募适配的 AI 队友、调度任务排班、汇总生成工作报告,
|
||||
|
||||
你始终掌控全局,从此不用再时刻在线盯守,真正解放自己的时间。
|
||||
|
||||
[English](./README.md) · **简体中文** · [官网][official-site] · [更新日志][changelog] · [文档][docs] · [博客][blog] · [反馈问题][github-issues-link]
|
||||
|
||||
@@ -24,7 +27,6 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
[![][github-license-shield]][github-license-link]<br>
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
**分享 LobeHub 给你的好友**
|
||||
|
||||
@@ -35,9 +37,9 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
[![][share-weibo-shield]][share-weibo-link]
|
||||
[![][share-mastodon-shield]][share-mastodon-link]
|
||||
|
||||
<sup>Agent teammates that grow with you</sup>
|
||||
<sup>你的首席 Agent 运营官</sup>
|
||||
|
||||
[![][github-trending-shield]][github-trending-url]
|
||||
<a href="https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-lobehub-2" target="_blank" rel="noopener noreferrer"><img alt="LobeHub - Your Chief Agent Operator for multi-agent work | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1147569&theme=light&period=daily&t=1779247564355"></a> <a href="https://trendshift.io/repositories/19224" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19224" alt="lobehub%2Flobehub | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
[![][github-hello-shield]][github-hello-url]
|
||||
|
||||
</div>
|
||||
@@ -49,30 +51,10 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
|
||||
- [👋🏻 开始使用 & 交流](#-开始使用--交流)
|
||||
- [✨ 特性一览](#-特性一览)
|
||||
- [运营:你制定策略,我们负责运行 Agent。](#运营你制定策略我们负责运行-agent)
|
||||
- [创建:以 Agent 为工作单元](#创建以-agent-为工作单元)
|
||||
- [协作:扩展新型协作网络](#协作扩展新型协作网络)
|
||||
- [进化:人类与 Agent 的共生进化](#进化人类与-agent-的共生进化)
|
||||
- [MCP](#mcp)
|
||||
- [发现、连接、扩展](#发现连接扩展)
|
||||
- [巅峰性能,零干扰](#巅峰性能零干扰)
|
||||
- [在线知识,按需获取](#在线知识按需获取)
|
||||
- [思维链 (CoT)](#思维链-cot)
|
||||
- [分支对话](#分支对话)
|
||||
- [支持白板 (Artifacts)](#支持白板-artifacts)
|
||||
- [文件上传 / 知识库](#文件上传--知识库)
|
||||
- [多模型服务商支持](#多模型服务商支持)
|
||||
- [支持本地大语言模型 (LLM)](#支持本地大语言模型-llm)
|
||||
- [模型视觉识别 (Model Visual)](#模型视觉识别-model-visual)
|
||||
- [TTS & STT 语音会话](#tts--stt-语音会话)
|
||||
- [Text to Image 文生图](#text-to-image-文生图)
|
||||
- [插件系统 (Tools Calling)](#插件系统-tools-calling)
|
||||
- [助手市场 (GPTs)](#助手市场-gpts)
|
||||
- [支持本地 / 远程数据库](#支持本地--远程数据库)
|
||||
- [支持多用户管理](#支持多用户管理)
|
||||
- [渐进式 Web 应用 (PWA)](#渐进式-web-应用-pwa)
|
||||
- [移动设备适配](#移动设备适配)
|
||||
- [自定义主题](#自定义主题)
|
||||
- [`*` 更多特性](#-更多特性)
|
||||
- [🛳 开箱即用](#-开箱即用)
|
||||
- [`A` 使用 Vercel、Zeabur 、Sealos 或 阿里云计算巢 部署](#a-使用-vercelzeabur-sealos-或-阿里云计算巢-部署)
|
||||
- [`B` 使用 Docker 部署](#b-使用-docker-部署)
|
||||
@@ -93,7 +75,7 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
|
||||
<br/>
|
||||
|
||||
<https://github.com/user-attachments/assets/6710ad97-03d0-4175-bd75-adff9b55eca2>
|
||||
<https://github.com/user-attachments/assets/0a33365f-b786-48b5-9ed6-f8af7927bccb>
|
||||
|
||||
## 👋🏻 开始使用 & 交流
|
||||
|
||||
@@ -102,9 +84,9 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
|
||||
不论普通用户与专业开发者,LobeHub 旨在成为所有人的 AI Agent 实验场。LobeHub 目前正在积极开发中,有任何需求或者问题,欢迎提交 [issues][issues-link]
|
||||
|
||||
| [](https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | 我们已在 Product Hunt 上线!我们很高兴将 LobeHub 推向世界。如果您相信人类与 Agent 共同进化的未来,请支持我们的旅程。 |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------- |
|
||||
| [![][discord-shield-badge]][discord-link] | 加入我们的 Discord 社区!这是你可以与开发者和其他 LobeHub 热衷用户交流的地方 |
|
||||
| [](https://www.producthunt.com/products/lobehub?launch=lobehub-2&embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | 我们已在 Product Hunt 上线!我们很高兴将 LobeHub 推向世界。如果您相信人类与 Agent 共同进化的未来,请支持我们的旅程。 |
|
||||
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------- |
|
||||
| [![][discord-shield-badge]][discord-link] | 加入我们的 Discord 社区!这是你可以与开发者和其他 LobeHub 热衷用户交流的地方 |
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
@@ -127,7 +109,26 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
|
||||
LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您一起成长的 Agent 队友协作。在 LobeHub 中,我们将 **Agent 视为工作单元**,提供一个让人类与 Agent 共同进化的基础设施。
|
||||
|
||||

|
||||

|
||||
|
||||
### 运营:你制定策略,我们负责运行 Agent。
|
||||
|
||||
雇用、排程并汇报你整个 AI 团队的工作
|
||||
|
||||
- **更高生产力,更少工具**:将你所有的 Agent 集中在一个平台。
|
||||
- **IM 网关**: Agent 连接到您每天使用的技能。
|
||||
|
||||

|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
### 创建:以 Agent 为工作单元
|
||||
|
||||
@@ -136,6 +137,8 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
- **统一智能**:无缝访问任何模型与任何模态 —— 全部由您掌控。
|
||||
- **1 万 + 技能**:通过超过 10,000 个工具和与 MCP 兼容的插件,将 Agent 连接到您每天使用的技能。
|
||||
|
||||

|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
<div align="right">
|
||||
@@ -155,6 +158,8 @@ LobeHub 引入了 **Agent Groups**,让您可以像对待真实队友一样与
|
||||
- **项目(Project)**:按项目组织工作,保持一切结构化且易于跟踪。
|
||||
- **工作区(Workspace)**:供团队与 Agent 协作的共享空间,确保明确的所有权和组织内的可见性。
|
||||
|
||||

|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
<div align="right">
|
||||
@@ -172,105 +177,7 @@ LobeHub 引入了 **Agent Groups**,让您可以像对待真实队友一样与
|
||||
- **持续学习**:您的 Agent 会从您的工作方式中学习,调整其行为以在恰当时刻采取行动。
|
||||
- **白盒记忆**:我们相信透明性。您的 Agent 使用结构化、可编辑的记忆,让您完全掌控它们记住的内容。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>更多特性</summary>
|
||||
|
||||
[](https://lobehub.com/mcp)
|
||||
|
||||
### MCP
|
||||
|
||||
通过启用与外部工具、数据源和服务的平滑、安全和动态交互,释放你的 AI 的全部潜力。基于 MCP(模型上下文协议)的插件系统打破了 AI 与数字生态系统之间的壁垒,实现了前所未有的连接性和功能性。
|
||||
|
||||
将对话转化为强大的工作流程,连接数据库、API、文件系统等。体验真正理解并与你的世界互动的 AI Agent。
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
![][image-feat-mcp-market]
|
||||
|
||||
### 发现、连接、扩展
|
||||
|
||||
浏览不断增长的 MCP 插件库,轻松扩展你的 AI 能力并简化工作流程。访问 [lobehub.com/mcp](https://lobehub.com/mcp) 探索 MCP 市场,提供精选的集成集合,增强你的 AI 与各种工具和服务协作的能力。
|
||||
|
||||
从生产力工具到开发环境,发现扩展 AI 覆盖范围和效率的新方式。与社区连接,找到满足特定需求的完美插件。
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
![][image-feat-desktop]
|
||||
|
||||
### 巅峰性能,零干扰
|
||||
|
||||
获得完整的 LobeHub 体验,摆脱浏览器限制 —— 轻量级、专注且随时就绪。我们的桌面应用程序为你的 AI 交互提供专用环境,确保最佳性能和最小干扰。
|
||||
|
||||
体验更快的响应时间、更好的资源管理和与 AI 助手的更稳定连接。桌面应用专为要求 AI 工具最佳性能的用户设计。
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
![][image-feat-web-search]
|
||||
|
||||
### 在线知识,按需获取
|
||||
|
||||
通过实时联网访问,你的 AI 与世界保持同步 —— 新闻、数据、趋势等。保持信息更新,获取最新可用信息,使你的 AI 能够提供准确和最新的回复。
|
||||
|
||||
访问实时信息,验证事实,探索当前事件,无需离开对话。你的 AI 成为通向世界知识的门户,始终保持最新和全面。
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-cot]][docs-feat-cot]
|
||||
|
||||
### [思维链 (CoT)][docs-feat-cot]
|
||||
|
||||
体验前所未有的 AI 推理过程。通过创新的思维链(CoT)可视化功能,您可以实时观察复杂问题是如何一步步被解析的。这项突破性的功能为 AI 的决策过程提供了前所未有的透明度,让您能够清晰地了解结论是如何得出的。
|
||||
|
||||
通过将复杂的推理过程分解为清晰的逻辑步骤,您可以更好地理解和验证 AI 的解题思路。无论您是在调试问题、学习知识,还是单纯对 AI 推理感兴趣,思维链可视化都能将抽象思维转化为一种引人入胜的互动体验。
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-branch]][docs-feat-branch]
|
||||
|
||||
### [分支对话][docs-feat-branch]
|
||||
|
||||
为您带来更自然、更灵活的 AI 对话方式。通过分支对话功能,您的讨论可以像人类对话一样自然延伸。在任意消息处创建新的对话分支,让您在保留原有上下文的同时,自由探索不同的对话方向。
|
||||
|
||||
两种强大模式任您选择:
|
||||
|
||||
- **延续模式**:无缝延展当前讨论,保持宝贵的对话上下文
|
||||
- **独立模式**:基于任意历史消息,开启全新话题探讨
|
||||
|
||||
这项突破性功能将线性对话转变为动态的树状结构,让您能够更深入地探索想法,实现更高效的互动体验。
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-artifacts]][docs-feat-artifacts]
|
||||
|
||||
### [支持白板 (Artifacts)][docs-feat-artifacts]
|
||||
|
||||
体验集成于 LobeHub 的 Claude Artifacts 能力。这项革命性功能突破了 AI 人机交互的边界,让您能够实时创建和可视化各种格式的内容。
|
||||
|
||||
以前所未有的灵活度进行创作与可视化:
|
||||
|
||||
- 生成并展示动态 SVG 图形
|
||||
- 实时构建与渲染交互式 HTML 页面
|
||||
- 输出多种格式的专业文档
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
[![][image-feat-knowledgebase]][docs-feat-knowledgebase]
|
||||
|
||||
### [文件上传 / 知识库][docs-feat-knowledgebase]
|
||||
|
||||
LobeHub 支持文件上传与知识库功能,你可以上传文件、图片、音频、视频等多种类型的文件,以及创建知识库,方便用户管理和查找文件。同时在对话中使用文件和知识库功能,实现更加丰富的对话体验。
|
||||
|
||||
<https://github.com/user-attachments/assets/faa8cf67-e743-4590-8bf6-ebf6ccc34175>
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> 查阅 [📘 LobeHub 知识库上线 —— 此刻起,跬步千里](https://lobehub.com/zh/blog/knowledge-base) 了解详情。
|
||||

|
||||
|
||||
<div align="right">
|
||||
|
||||
@@ -278,262 +185,6 @@ LobeHub 支持文件上传与知识库功能,你可以上传文件、图片、
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-privoder]][docs-feat-provider]
|
||||
|
||||
### [多模型服务商支持][docs-feat-provider]
|
||||
|
||||
在 LobeHub 的不断发展过程中,我们深刻理解到在提供 AI 会话服务时模型服务商的多样性对于满足社区需求的重要性。因此,我们不再局限于单一的模型服务商,而是拓展了对多种模型服务商的支持,以便为用户提供更为丰富和多样化的会话选择。
|
||||
|
||||
通过这种方式,LobeHub 能够更灵活地适应不同用户的需求,同时也为开发者提供了更为广泛的选择空间。
|
||||
|
||||
#### 已支持的模型服务商
|
||||
|
||||
我们已经实现了对以下模型服务商的支持:
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
<details><summary><kbd>See more providers (+-10)</kbd></summary>
|
||||
|
||||
</details>
|
||||
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
同时,我们也在计划支持更多的模型服务商,以进一步丰富我们的服务商库。如果你希望让 LobeHub 支持你喜爱的服务商,欢迎加入我们的 [💬 社区讨论](https://github.com/lobehub/lobehub/discussions/6157)。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-local]][docs-feat-local]
|
||||
|
||||
### [支持本地大语言模型 (LLM)][docs-feat-local]
|
||||
|
||||
为了满足特定用户的需求,LobeHub 还基于 [Ollama](https://ollama.ai) 支持了本地模型的使用,让用户能够更灵活地使用自己的或第三方的模型。
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> 查阅 [📘 在 LobeHub 中使用 Ollama][docs-usage-ollama] 获得更多信息
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-vision]][docs-feat-vision]
|
||||
|
||||
### [模型视觉识别 (Model Visual)][docs-feat-vision]
|
||||
|
||||
LobeHub 已经支持 OpenAI 最新的 [`gpt-4-vision`](https://platform.openai.com/docs/guides/vision) 支持视觉识别的模型,这是一个具备视觉识别能力的多模态应用。
|
||||
用户可以轻松上传图片或者拖拽图片到对话框中,助手将能够识别图片内容,并在此基础上进行智能对话,构建更智能、更多元化的聊天场景。
|
||||
|
||||
这一特性打开了新的互动方式,使得交流不再局限于文字,而是可以涵盖丰富的视觉元素。无论是日常使用中的图片分享,还是在特定行业内的图像解读,助手都能提供出色的对话体验。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-tts]][docs-feat-tts]
|
||||
|
||||
### [TTS & STT 语音会话][docs-feat-tts]
|
||||
|
||||
LobeHub 支持文字转语音(Text-to-Speech,TTS)和语音转文字(Speech-to-Text,STT)技术,这使得我们的应用能够将文本信息转化为清晰的语音输出,用户可以像与真人交谈一样与我们的对话助手进行交流。
|
||||
用户可以从多种声音中选择,给助手搭配合适的音源。 同时,对于那些倾向于听觉学习或者想要在忙碌中获取信息的用户来说,TTS 提供了一个极佳的解决方案。
|
||||
|
||||
在 LobeHub 中,我们精心挑选了一系列高品质的声音选项 (OpenAI Audio, Microsoft Edge Speech),以满足不同地域和文化背景用户的需求。用户可以根据个人喜好或者特定场景来选择合适的语音,从而获得个性化的交流体验。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-t2i]][docs-feat-t2i]
|
||||
|
||||
### [Text to Image 文生图][docs-feat-t2i]
|
||||
|
||||
支持最新的文本到图片生成技术,LobeHub 现在能够让用户在与助手对话中直接调用文生图工具进行创作。
|
||||
通过利用 [`DALL-E 3`](https://openai.com/dall-e-3)、[`MidJourney`](https://www.midjourney.com/) 和 [`Pollinations`](https://pollinations.ai/) 等 AI 工具的能力, 助手们现在可以将你的想法转化为图像。
|
||||
同时可以更私密和沉浸式地完成你的创作过程。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-plugin]][docs-feat-plugin]
|
||||
|
||||
### [插件系统 (Tools Calling)][docs-feat-plugin]
|
||||
|
||||
LobeHub 的插件生态系统是其核心功能的重要扩展,它极大地增强了 ChatGPT 的实用性和灵活性。
|
||||
|
||||
<video controls src="https://github.com/lobehub/lobehub/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2" muted="false"></video>
|
||||
|
||||
通过利用插件,ChatGPT 能够实现实时信息的获取和处理,例如自动获取最新新闻头条,为用户提供即时且相关的资讯。
|
||||
|
||||
此外,这些插件不仅局限于新闻聚合,还可以扩展到其他实用的功能,如快速检索文档、生成图象、获取电商平台数据,以及其他各式各样的第三方服务。
|
||||
|
||||
> 通过文档了解更多 [📘 插件使用][docs-usage-plugin]
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [购物工具](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2026-01-12**</sup> | 在 eBay 和 AliExpress 上搜索产品,查找 eBay 活动和优惠券。获取快速示例。<br/>`购物` `e-bay` `ali-express` `优惠券` |
|
||||
| [SEO 助手](https://lobechat.com/discover/plugin/seo_assistant)<br/><sup>By **webfx** on **2026-01-12**</sup> | SEO 助手可以生成搜索引擎关键词信息,以帮助创建内容。<br/>`seo` `关键词` |
|
||||
| [视频字幕](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | 将 Youtube 链接转换为转录文本,使其能够提问,创建章节,并总结其内容。<br/>`视频转文字` `you-tube` |
|
||||
| [天气 GPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | 获取特定位置的当前天气信息。<br/>`天气` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**40**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-agent]][docs-feat-agent]
|
||||
|
||||
### [助手市场 (GPTs)][docs-feat-agent]
|
||||
|
||||
在 LobeHub 的助手市场中,创作者们可以发现一个充满活力和创新的社区,它汇聚了众多精心设计的助手,这些助手不仅在工作场景中发挥着重要作用,也在学习过程中提供了极大的便利。
|
||||
我们的市场不仅是一个展示平台,更是一个协作的空间。在这里,每个人都可以贡献自己的智慧,分享个人开发的助手。
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> 通过 [🤖/🏪 提交助手][submit-agents-link] ,你可以轻松地将你的助手作品提交到我们的平台。我们特别强调的是,LobeHub 建立了一套精密的自动化国际化(i18n)工作流程, 它的强大之处在于能够无缝地将你的助手转化为多种语言版本。
|
||||
> 这意味着,不论你的用户使用何种语言,他们都能无障碍地体验到你的助手。
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> 我欢迎所有用户加入这个不断成长的生态系统,共同参与到助手的迭代与优化中来。共同创造出更多有趣、实用且具有创新性的助手,进一步丰富助手的多样性和实用性。
|
||||
|
||||
<!-- AGENT LIST -->
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| [海龟汤主持人](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | 一个海龟汤主持人,需要自己提供汤面,汤底与关键点(猜中的判定条件)。<br/>`海龟汤` `推理` `互动` `谜题` `角色扮演` |
|
||||
| [学术写作助手](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | 专业的学术研究论文写作和正式文档编写专家<br/>`学术写作` `研究` `正式风格` |
|
||||
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
|
||||
| [Minecraft 资深开发者](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | 擅长高级 Java 开发及 Minecraft 开发<br/>`开发` `编程` `minecraft` `java` |
|
||||
|
||||
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
|
||||
|
||||
<!-- AGENT LIST -->
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-database]][docs-feat-database]
|
||||
|
||||
### [支持本地 / 远程数据库][docs-feat-database]
|
||||
|
||||
LobeHub 支持同时使用服务端数据库和本地数据库。根据您的需求,您可以选择合适的部署方案:
|
||||
|
||||
- 本地数据库:适合希望对数据有更多掌控感和隐私保护的用户。LobeHub 采用了 CRDT (Conflict-Free Replicated Data Type) 技术,实现了多端同步功能。这是一项实验性功能,旨在提供无缝的数据同步体验。
|
||||
- 服务端数据库:适合希望更便捷使用体验的用户。LobeHub 支持 PostgreSQL 作为服务端数据库。关于如何配置服务端数据库的详细文档,请前往 [配置服务端数据库](https://lobehub.com/zh/docs/self-hosting/advanced/server-database)。
|
||||
|
||||
无论您选择哪种数据库,LobeHub 都能为您提供卓越的用户体验。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-auth]][docs-feat-auth]
|
||||
|
||||
### [支持多用户管理][docs-feat-auth]
|
||||
|
||||
LobeHub 支持多用户管理,提供了灵活的用户认证方案:
|
||||
|
||||
- **Better Auth**:LobeHub 集成了 `Better Auth`,一个现代化且灵活的身份验证库,支持多种身份验证方式,包括 OAuth、邮件登录、凭证登录、魔法链接等。通过 `Better Auth`,您可以轻松实现用户的注册、登录、会话管理、社交登录、多因素认证 (MFA) 等功能,确保用户数据的安全性和隐私性。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-pwa]][docs-feat-pwa]
|
||||
|
||||
### [渐进式 Web 应用 (PWA)][docs-feat-pwa]
|
||||
|
||||
我们深知在当今多设备环境下为用户提供无缝体验的重要性。为此,我们采用了渐进式 Web 应用 [PWA](https://support.google.com/chrome/answer/9658361) 技术,
|
||||
这是一种能够将网页应用提升至接近原生应用体验的现代 Web 技术。通过 PWA,LobeHub 能够在桌面和移动设备上提供高度优化的用户体验,同时保持轻量级和高性能的特点。
|
||||
在视觉和感觉上,我们也经过精心设计,以确保它的界面与原生应用无差别,提供流畅的动画、响应式布局和适配不同设备的屏幕分辨率。
|
||||
|
||||
> \[!NOTE]
|
||||
>
|
||||
> 若您未熟悉 PWA 的安装过程,您可以按照以下步骤将 LobeHub 添加为您的桌面应用(也适用于移动设备):
|
||||
>
|
||||
> - 在电脑上运行 Chrome 或 Edge 浏览器 .
|
||||
> - 访问 LobeHub 网页 .
|
||||
> - 在地址栏的右上角,单击 <kbd>安装</kbd> 图标 .
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-mobile]][docs-feat-mobile]
|
||||
|
||||
### [移动设备适配][docs-feat-mobile]
|
||||
|
||||
针对移动设备进行了一系列的优化设计,以提升用户的移动体验。目前,我们正在对移动端的用户体验进行版本迭代,以实现更加流畅和直观的交互。如果您有任何建议或想法,我们非常欢迎您通过 GitHub Issues 或者 Pull Requests 提供反馈。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
[![][image-feat-theme]][docs-feat-theme]
|
||||
|
||||
### [自定义主题][docs-feat-theme]
|
||||
|
||||
作为设计工程师出身,LobeHub 在界面设计上充分考虑用户的个性化体验,因此引入了灵活多变的主题模式,其中包括日间的亮色模式和夜间的深色模式。
|
||||
除了主题模式的切换,还提供了一系列的颜色定制选项,允许用户根据自己的喜好来调整应用的主题色彩。无论是想要沉稳的深蓝,还是希望活泼的桃粉,或者是专业的灰白,用户都能够在 LobeHub 中找到匹配自己风格的颜色选择。
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> 默认配置能够智能地识别用户系统的颜色模式,自动进行主题切换,以确保应用界面与操作系统保持一致的视觉体验。对于喜欢手动调控细节的用户,LobeHub 同样提供了直观的设置选项,针对聊天场景也提供了对话气泡模式和文档模式的选择。
|
||||
|
||||
<div align="right">
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
### `*` 更多特性
|
||||
|
||||
除了上述功能特性以外,LobeHub 所具有的设计和技术能力将为你带来更多使用保障:
|
||||
|
||||
- [x] 💎 **精致 UI 设计**:经过精心设计的界面,具有优雅的外观和流畅的交互效果,支持亮暗色主题,适配移动端。支持 PWA,提供更加接近原生应用的体验。
|
||||
- [x] 🗣️ **流畅的对话体验**:流式响应带来流畅的对话体验,并且支持完整的 Markdown 渲染,包括代码高亮、LaTex 公式、Mermaid 流程图等。
|
||||
- [x] 💨 **快速部署**:使用 Vercel 平台或者我们的 Docker 镜像,只需点击一键部署按钮,即可在 1 分钟内完成部署,无需复杂的配置过程。
|
||||
- [x] 🔒 **隐私安全**:所有数据保存在用户浏览器本地,保证用户的隐私安全。
|
||||
- [x] 🌐 **自定义域名**:如果用户拥有自己的域名,可以将其绑定到平台上,方便在任何地方快速访问对话助手。
|
||||
|
||||
</details>
|
||||
|
||||
> ✨ 随着产品迭代持续更新,我们将会带来更多更多令人激动的功能!
|
||||
|
||||
<div align="right">
|
||||
@@ -867,28 +518,10 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[docs-dev-guide]: https://lobehub.com/docs/development/start
|
||||
[docs-docker]: https://lobehub.com/zh/docs/self-hosting/server-database/docker-compose
|
||||
[docs-env-var]: https://lobehub.com/docs/self-hosting/environment-variables
|
||||
[docs-feat-agent]: https://lobehub.com/docs/usage/features/agent-market
|
||||
[docs-feat-artifacts]: https://lobehub.com/docs/usage/features/artifacts
|
||||
[docs-feat-auth]: https://lobehub.com/docs/usage/features/auth
|
||||
[docs-feat-branch]: https://lobehub.com/docs/usage/features/branching-conversations
|
||||
[docs-feat-cot]: https://lobehub.com/docs/usage/features/cot
|
||||
[docs-feat-database]: https://lobehub.com/docs/usage/features/database
|
||||
[docs-feat-knowledgebase]: https://lobehub.com/blog/knowledge-base
|
||||
[docs-feat-local]: https://lobehub.com/docs/usage/features/local-llm
|
||||
[docs-feat-mobile]: https://lobehub.com/docs/usage/features/mobile
|
||||
[docs-feat-plugin]: https://lobehub.com/docs/usage/features/plugin-system
|
||||
[docs-feat-provider]: https://lobehub.com/docs/usage/features/multi-ai-providers
|
||||
[docs-feat-pwa]: https://lobehub.com/docs/usage/features/pwa
|
||||
[docs-feat-t2i]: https://lobehub.com/docs/usage/features/text-to-image
|
||||
[docs-feat-theme]: https://lobehub.com/docs/usage/features/theme
|
||||
[docs-feat-tts]: https://lobehub.com/docs/usage/features/tts
|
||||
[docs-feat-vision]: https://lobehub.com/docs/usage/features/vision
|
||||
[docs-function-call]: https://lobehub.com/zh/blog/openai-function-call
|
||||
[docs-plugin-dev]: https://lobehub.com/docs/usage/plugins/development
|
||||
[docs-self-hosting]: https://lobehub.com/docs/self-hosting/start
|
||||
[docs-upstream-sync]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
|
||||
[docs-usage-ollama]: https://lobehub.com/docs/usage/providers/ollama
|
||||
[docs-usage-plugin]: https://lobehub.com/docs/usage/plugins/basic
|
||||
[fossa-license-link]: https://app.fossa.com/projects/git%2Bgithub.com%2Flobehub%2Flobehub
|
||||
[fossa-license-shield]: https://app.fossa.com/api/projects/git%2Bgithub.com%2Flobehub%2Flobehub.svg?type=large
|
||||
[github-action-release-link]: https://github.com/lobehub/lobehub/actions/workflows/release.yml
|
||||
@@ -911,29 +544,8 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[github-releasedate-link]: https://github.com/lobehub/lobehub/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobehub?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobehub/stargazers
|
||||
[github-stars-shield]: https://github.com/user-attachments/assets/3216e25b-186f-4a54-9cb4-2f124aec0471
|
||||
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
|
||||
[github-trending-url]: https://trendshift.io/repositories/2256
|
||||
[image-banner]: https://github.com/user-attachments/assets/0fe626a3-0ddc-4f67-b595-3c5b3f1701e0
|
||||
[image-feat-agent]: https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f
|
||||
[image-feat-artifacts]: https://github.com/user-attachments/assets/7f95fad6-b210-4e6e-84a0-7f39e96f3a00
|
||||
[image-feat-auth]: https://github.com/user-attachments/assets/80bb232e-19d1-4f97-98d6-e291f3585e6d
|
||||
[image-feat-branch]: https://github.com/user-attachments/assets/92f72082-02bd-4835-9c54-b089aad7fd41
|
||||
[image-feat-cot]: https://github.com/user-attachments/assets/f74f1139-d115-4e9c-8c43-040a53797a5e
|
||||
[image-feat-database]: https://github.com/user-attachments/assets/f1697c8b-d1fb-4dac-ba05-153c6295d91d
|
||||
[image-feat-desktop]: https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96
|
||||
[image-feat-knowledgebase]: https://github.com/user-attachments/assets/7da7a3b2-92fd-4630-9f4e-8560c74955ae
|
||||
[image-feat-local]: https://github.com/user-attachments/assets/1239da50-d832-4632-a7ef-bd754c0f3850
|
||||
[image-feat-mcp-market]: https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0
|
||||
[image-feat-mobile]: https://github.com/user-attachments/assets/32cf43c4-96bd-4a4c-bfb6-59acde6fe380
|
||||
[image-feat-plugin]: https://github.com/user-attachments/assets/66a891ac-01b6-4e3f-b978-2eb07b489b1b
|
||||
[image-feat-privoder]: https://github.com/user-attachments/assets/e553e407-42de-4919-977d-7dbfcf44a821
|
||||
[image-feat-pwa]: https://github.com/user-attachments/assets/9647f70f-b71b-43b6-9564-7cdd12d1c24d
|
||||
[image-feat-t2i]: https://github.com/user-attachments/assets/708274a7-2458-494b-a6ec-b73dfa1fa7c2
|
||||
[image-feat-theme]: https://github.com/user-attachments/assets/b47c39f1-806f-492b-8fcb-b0fa973937c1
|
||||
[image-feat-tts]: https://github.com/user-attachments/assets/50189597-2cc3-4002-b4c8-756a52ad5c0a
|
||||
[image-feat-vision]: https://github.com/user-attachments/assets/18574a1f-46c2-4cbc-af2c-35a86e128a07
|
||||
[image-feat-web-search]: https://github.com/user-attachments/assets/cfdc48ac-b5f8-4a00-acee-db8f2eba09ad
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobehub?color=ffcb47&labelColor=black&style=flat-square
|
||||
[image-banner]: https://github.com/user-attachments/assets/5f78ae58-ed4f-4d38-8037-96109fbba58c
|
||||
[image-star]: https://github.com/user-attachments/assets/c3b482e7-cef5-4e94-bef9-226900ecfaab
|
||||
[issues-link]: https://img.shields.io/github/issues/lobehub/lobehub.svg?style=flat
|
||||
[lobe-chat-plugins]: https://github.com/lobehub/lobe-chat-plugins
|
||||
@@ -969,8 +581,6 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
|
||||
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2CopenAI&text=%E6%8E%A8%E8%8D%90%E4%B8%80%E4%B8%AA%20GitHub%20%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE%20%F0%9F%A4%AF%20LobeHub%20-%20%E5%BC%80%E6%BA%90%E7%9A%84%E3%80%81%E5%8F%AF%E6%89%A9%E5%B1%95%E7%9A%84%EF%BC%88Function%20Calling%EF%BC%89%E9%AB%98%E6%80%A7%E8%83%BD%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA%E6%A1%86%E6%9E%B6%E3%80%82%0A%E5%AE%83%E6%94%AF%E6%8C%81%E4%B8%80%E9%94%AE%E5%85%8D%E8%B4%B9%E9%83%A8%E7%BD%B2%E7%A7%81%E4%BA%BA%20ChatGPT%2FLLM%20%E7%BD%91%E9%A1%B5%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobehub
|
||||
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
|
||||
[sponsor-link]: https://opencollective.com/lobehub 'Become ❤ LobeHub Sponsor'
|
||||
[sponsor-shield]: https://img.shields.io/badge/-Sponsor%20LobeHub-f04f88?logo=opencollective&logoColor=white&style=flat-square
|
||||
[submit-agents-link]: https://github.com/lobehub/lobe-chat-agents
|
||||
[submit-agents-shield]: https://img.shields.io/badge/🤖/🏪_submit_agent-%E2%86%92-c4f042?labelColor=black&style=for-the-badge
|
||||
[submit-plugin-link]: https://github.com/lobehub/lobe-chat-plugins
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.15" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.20" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.20",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
import { registerBotMessengersCommands } from './botMessengers';
|
||||
|
||||
// ── Access policy helpers ──────────────────────────────
|
||||
|
||||
@@ -269,6 +270,204 @@ function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Watch keywords subcommand factory ──────────────────
|
||||
|
||||
interface WatchKeywordEntry {
|
||||
instruction?: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise `settings.watchKeywords` into the canonical
|
||||
* `{keyword, instruction?}[]` shape. Mirrors `extractWatchKeywordEntries`
|
||||
* in `src/server/services/bot/platforms/const.ts` so the CLI accepts the
|
||||
* same legacy on-disk shapes (`string`, `string[]`, `{keyword, …}[]`)
|
||||
* the runtime is forgiving about — including the rare comma/whitespace
|
||||
* separated string from a hand-pasted upgrade.
|
||||
*/
|
||||
function normalizeWatchKeywords(raw: unknown): WatchKeywordEntry[] {
|
||||
const push = (out: Map<string, WatchKeywordEntry>, keyword: unknown, instruction?: unknown) => {
|
||||
if (typeof keyword !== 'string') return;
|
||||
const normalised = keyword.trim().toLowerCase();
|
||||
if (!normalised) return;
|
||||
const trimmedInstruction =
|
||||
typeof instruction === 'string' && instruction.trim() ? instruction.trim() : undefined;
|
||||
const existing = out.get(normalised);
|
||||
if (!existing) {
|
||||
out.set(normalised, { instruction: trimmedInstruction, keyword: normalised });
|
||||
return;
|
||||
}
|
||||
if (!existing.instruction && trimmedInstruction) existing.instruction = trimmedInstruction;
|
||||
};
|
||||
const collected = new Map<string, WatchKeywordEntry>();
|
||||
if (typeof raw === 'string') {
|
||||
for (const piece of raw.split(/[\s,]+/)) push(collected, piece);
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === 'string') {
|
||||
push(collected, entry);
|
||||
continue;
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'keyword' in entry) {
|
||||
const obj = entry as { instruction?: unknown; keyword?: unknown };
|
||||
push(collected, obj.keyword, obj.instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...collected.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `list / add / remove / clear` subcommand group around
|
||||
* `settings.watchKeywords`. Shape differs from the user/channel allowlists
|
||||
* (`{keyword, instruction?}` vs `{id, name?}`), so we duplicate the
|
||||
* scaffolding instead of squeezing both shapes through one factory — the
|
||||
* help text, column headers, and `--instruction` flag are all keyword-
|
||||
* specific and would just bloat the unified version.
|
||||
*/
|
||||
function registerWatchKeywordsCommand(bot: Command) {
|
||||
const group = bot
|
||||
.command('watch-keywords')
|
||||
.description(
|
||||
'Manage watch keywords (non-mention channel triggers; the optional instruction is prepended to the user message before being sent to the AI)',
|
||||
);
|
||||
|
||||
const readEntries = (bot: any): WatchKeywordEntry[] =>
|
||||
normalizeWatchKeywords((bot.settings as Record<string, unknown> | null)?.watchKeywords);
|
||||
|
||||
const buildPayload = (bot: any, nextEntries: WatchKeywordEntry[]) => ({
|
||||
id: bot.id,
|
||||
settings: {
|
||||
...(bot.settings as Record<string, unknown>),
|
||||
watchKeywords: nextEntries,
|
||||
},
|
||||
});
|
||||
|
||||
group
|
||||
.command('list <botId>')
|
||||
.description('List watch-keyword entries')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (options.json) {
|
||||
outputJson(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`${pc.dim('No watch-keyword entries.')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(
|
||||
entries.map((e) => [e.keyword, e.instruction ?? pc.dim('-')]),
|
||||
['KEYWORD', 'INSTRUCTION'],
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('add <botId> <keyword>')
|
||||
.description('Add a watch keyword (with optional instruction prefix)')
|
||||
.option(
|
||||
'--instruction <text>',
|
||||
'Prompt prepended to the user message when this keyword fires (omit for "just wake the bot")',
|
||||
)
|
||||
.action(async (botId: string, keyword: string, options: { instruction?: string }) => {
|
||||
const trimmedKeyword = keyword.trim().toLowerCase();
|
||||
if (!trimmedKeyword) {
|
||||
log.error('Keyword cannot be empty.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedInstruction = options.instruction?.trim();
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
const existing = entries.find((e) => e.keyword === trimmedKeyword);
|
||||
if (existing) {
|
||||
// Upsert instruction on duplicate keyword — operators commonly
|
||||
// re-run `add` to tweak the prompt without remembering to remove first.
|
||||
if (trimmedInstruction && existing.instruction !== trimmedInstruction) {
|
||||
existing.instruction = trimmedInstruction;
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, entries) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Updated instruction for ${pc.bold(trimmedKeyword)} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info(`${trimmedKeyword} is already on watchKeywords — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = [
|
||||
...entries,
|
||||
trimmedInstruction
|
||||
? { instruction: trimmedInstruction, keyword: trimmedKeyword }
|
||||
: { keyword: trimmedKeyword },
|
||||
];
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(trimmedKeyword)}${trimmedInstruction ? ' (with instruction)' : ''} to watchKeywords (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('remove <botId> <keyword>')
|
||||
.description('Remove a watch keyword')
|
||||
.action(async (botId: string, keyword: string) => {
|
||||
const trimmedKeyword = keyword.trim().toLowerCase();
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
const next = entries.filter((e) => e.keyword !== trimmedKeyword);
|
||||
|
||||
if (next.length === entries.length) {
|
||||
log.info(`${trimmedKeyword} is not on watchKeywords — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed ${pc.bold(trimmedKeyword)} from watchKeywords (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('clear <botId>')
|
||||
.description('Clear all watch keywords')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (botId: string, options: { yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.length === 0) {
|
||||
log.info('watchKeywords is already empty — nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Clear all ${entries.length} watch-keyword entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
|
||||
console.log(`${pc.green('✓')} Cleared watchKeywords on bot ${pc.bold(botId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
@@ -277,6 +476,9 @@ export function registerBotCommand(program: Command) {
|
||||
// Register message subcommand group
|
||||
registerBotMessageCommands(bot);
|
||||
|
||||
// Register messengers subcommand group (System Bot installations + account links)
|
||||
registerBotMessengersCommands(bot);
|
||||
|
||||
// ── platforms ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
@@ -608,6 +810,10 @@ export function registerBotCommand(program: Command) {
|
||||
name: 'group-allowlist',
|
||||
});
|
||||
|
||||
// ── watch-keywords () ────────────────────────
|
||||
|
||||
registerWatchKeywordsCommand(bot);
|
||||
|
||||
// ── remove ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
botMessage: {
|
||||
replyToThread: { mutate: vi.fn() },
|
||||
sendDirectMessage: { mutate: vi.fn() },
|
||||
sendMessage: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('bot message send --attachment', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockTrpcClient.botMessage.sendMessage.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.sendMessage.mutate.mockResolvedValue({ messageId: 'm-1' });
|
||||
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({
|
||||
channelId: 'dm-1',
|
||||
messageId: 'm-dm-1',
|
||||
});
|
||||
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-tr-1' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const bot = program.command('bot');
|
||||
registerBotMessageCommands(bot);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('passes a remote URL through as fetchUrl', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'send',
|
||||
'bot-1',
|
||||
'--target',
|
||||
'ch-1',
|
||||
'--message',
|
||||
'hi',
|
||||
'--attachment',
|
||||
'https://cdn.example.com/foo.png',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.botMessage.sendMessage.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fetchUrl: 'https://cdn.example.com/foo.png',
|
||||
mimeType: 'image/png',
|
||||
name: 'foo.png',
|
||||
type: 'image',
|
||||
}),
|
||||
],
|
||||
botId: 'bot-1',
|
||||
channelId: 'ch-1',
|
||||
content: 'hi',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('base64-encodes a local file path', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'lh-cli-attach-'));
|
||||
const filePath = path.join(dir, 'tiny.txt');
|
||||
await writeFile(filePath, 'hello');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'send',
|
||||
'bot-1',
|
||||
'--target',
|
||||
'ch-1',
|
||||
'--message',
|
||||
'm',
|
||||
'--attachment',
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
|
||||
expect(call.attachments).toHaveLength(1);
|
||||
expect(call.attachments[0]).toMatchObject({
|
||||
mimeType: 'text/plain',
|
||||
name: 'tiny.txt',
|
||||
type: 'file',
|
||||
});
|
||||
expect(call.attachments[0].data).toBe(Buffer.from('hello').toString('base64'));
|
||||
expect(call.attachments[0].fetchUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts multiple --attachment flags', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'send',
|
||||
'bot-1',
|
||||
'--target',
|
||||
'ch-1',
|
||||
'--message',
|
||||
'm',
|
||||
'--attachment',
|
||||
'https://cdn.example.com/a.png',
|
||||
'--attachment',
|
||||
'https://cdn.example.com/b.pdf',
|
||||
]);
|
||||
|
||||
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
|
||||
expect(call.attachments).toHaveLength(2);
|
||||
expect(call.attachments[0]).toMatchObject({ type: 'image', name: 'a.png' });
|
||||
expect(call.attachments[1]).toMatchObject({ type: 'file', name: 'b.pdf' });
|
||||
});
|
||||
|
||||
it('omits attachments field when no flag is given', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'send',
|
||||
'bot-1',
|
||||
'--target',
|
||||
'ch-1',
|
||||
'--message',
|
||||
'm',
|
||||
]);
|
||||
|
||||
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
|
||||
expect(call.attachments).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot message dm --attachment', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({
|
||||
channelId: 'dm-1',
|
||||
messageId: 'm-dm-1',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const bot = program.command('bot');
|
||||
registerBotMessageCommands(bot);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('sends a DM with a remote-URL attachment', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'dm',
|
||||
'bot-1',
|
||||
'--user-id',
|
||||
'u-1',
|
||||
'--message',
|
||||
'hi',
|
||||
'--attachment',
|
||||
'https://cdn.example.com/foo.png',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.botMessage.sendDirectMessage.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fetchUrl: 'https://cdn.example.com/foo.png',
|
||||
type: 'image',
|
||||
}),
|
||||
],
|
||||
botId: 'bot-1',
|
||||
content: 'hi',
|
||||
userId: 'u-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits attachments when no flag is given', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'dm',
|
||||
'bot-1',
|
||||
'--user-id',
|
||||
'u-1',
|
||||
'--message',
|
||||
'plain',
|
||||
]);
|
||||
const call = mockTrpcClient.botMessage.sendDirectMessage.mutate.mock.calls[0][0];
|
||||
expect(call.attachments).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot message thread reply --attachment', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-tr-1' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const bot = program.command('bot');
|
||||
registerBotMessageCommands(bot);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('replies to a thread with attachments', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'thread',
|
||||
'reply',
|
||||
'bot-1',
|
||||
'--thread-id',
|
||||
'th-1',
|
||||
'--message',
|
||||
'reply',
|
||||
'--attachment',
|
||||
'https://cdn.example.com/a.png',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.botMessage.replyToThread.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fetchUrl: 'https://cdn.example.com/a.png',
|
||||
type: 'image',
|
||||
}),
|
||||
],
|
||||
botId: 'bot-1',
|
||||
content: 'reply',
|
||||
threadId: 'th-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot message send via System Bot messenger install (@id)', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockTrpcClient.botMessage.sendMessage.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.sendMessage.mutate.mockResolvedValue({ messageId: 'm-mi-1' });
|
||||
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({ messageId: 'm-mi-2' });
|
||||
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
|
||||
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-mi-3' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const bot = program.command('bot');
|
||||
registerBotMessageCommands(bot);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('@-prefixed positional arg routes to messengerInstallationId on send', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'send',
|
||||
'@inst_abc',
|
||||
'--target',
|
||||
'C1',
|
||||
'--message',
|
||||
'hi',
|
||||
]);
|
||||
|
||||
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
|
||||
expect(call.messengerInstallationId).toBe('inst_abc');
|
||||
expect(call.botId).toBeUndefined();
|
||||
expect(call.channelId).toBe('C1');
|
||||
});
|
||||
|
||||
it('@-prefixed routes on dm', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'dm',
|
||||
'@inst_xyz',
|
||||
'--user-id',
|
||||
'U1',
|
||||
'--message',
|
||||
'hi',
|
||||
]);
|
||||
const call = mockTrpcClient.botMessage.sendDirectMessage.mutate.mock.calls[0][0];
|
||||
expect(call.messengerInstallationId).toBe('inst_xyz');
|
||||
expect(call.botId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('@-prefixed routes on thread reply', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'thread',
|
||||
'reply',
|
||||
'@inst_thr',
|
||||
'--thread-id',
|
||||
'T1',
|
||||
'--message',
|
||||
'r',
|
||||
]);
|
||||
const call = mockTrpcClient.botMessage.replyToThread.mutate.mock.calls[0][0];
|
||||
expect(call.messengerInstallationId).toBe('inst_thr');
|
||||
});
|
||||
|
||||
it('plain (non-@) positional stays as botId', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'message',
|
||||
'send',
|
||||
'uuid-bot-id',
|
||||
'--target',
|
||||
'C1',
|
||||
'--message',
|
||||
'hi',
|
||||
]);
|
||||
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
|
||||
expect(call.botId).toBe('uuid-bot-id');
|
||||
expect(call.messengerInstallationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
@@ -6,6 +9,111 @@ import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
type AttachmentInput = {
|
||||
data?: string;
|
||||
fetchUrl?: string;
|
||||
mimeType?: string;
|
||||
name?: string;
|
||||
type: 'image' | 'file' | 'video' | 'audio';
|
||||
};
|
||||
|
||||
const MIME_EXT_MAP: Record<string, string> = {
|
||||
'.bmp': 'image/bmp',
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
const inferMime = (path: string): string | undefined => MIME_EXT_MAP[extname(path).toLowerCase()];
|
||||
|
||||
const inferAttachmentType = (mimeType?: string): AttachmentInput['type'] => {
|
||||
if (!mimeType) return 'file';
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('video/')) return 'video';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
return 'file';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a list of `--attachment` flag values into `AttachmentInput[]`. Each
|
||||
* entry is either a URL or a local file path. Returns `undefined` when no
|
||||
* flags were passed so callers can omit the field on the wire entirely (the
|
||||
* TRPC schema treats absent vs empty differently). Bails the process on
|
||||
* load failures — a silently-dropped attachment would be worse than a
|
||||
* loud error here.
|
||||
*/
|
||||
const resolveAttachmentFlags = async (flags: string[]): Promise<AttachmentInput[] | undefined> => {
|
||||
if (flags.length === 0) return undefined;
|
||||
const out: AttachmentInput[] = [];
|
||||
for (const raw of flags) {
|
||||
try {
|
||||
out.push(await parseAttachmentArg(raw));
|
||||
} catch (error) {
|
||||
log.error(`Failed to load attachment "${raw}": ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a single `--attachment <value>` argument. Accepted forms:
|
||||
* - `https://…` / `http://…` → fetchUrl, type inferred from extension
|
||||
* - any other string → treated as a local file path;
|
||||
* bytes are read + base64-encoded
|
||||
*/
|
||||
const parseAttachmentArg = async (raw: string): Promise<AttachmentInput> => {
|
||||
if (/^https?:\/\//.test(raw)) {
|
||||
const pathname = new URL(raw).pathname;
|
||||
const mimeType = inferMime(pathname);
|
||||
return {
|
||||
fetchUrl: raw,
|
||||
mimeType,
|
||||
name: basename(pathname) || undefined,
|
||||
type: inferAttachmentType(mimeType),
|
||||
};
|
||||
}
|
||||
const bytes = await readFile(raw);
|
||||
const mimeType = inferMime(raw);
|
||||
return {
|
||||
data: bytes.toString('base64'),
|
||||
mimeType,
|
||||
name: basename(raw),
|
||||
type: inferAttachmentType(mimeType),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the `<botIdOrAtKey>` positional argument into a `{ botId? |
|
||||
* messengerInstallationId? }` shape that matches the TRPC send procedures'
|
||||
* `exactly-one-of` constraint.
|
||||
*
|
||||
* Convention: a value prefixed with `@` is treated as a System Bot
|
||||
* messenger installation id (e.g. `@inst_abc123`); anything else is a
|
||||
* per-agent bot id. The `@` was chosen because `agent_bot_providers`.id is
|
||||
* always a UUID — no UUID starts with `@`, so the prefix unambiguously
|
||||
* disambiguates without breaking the existing UUID-only call sites.
|
||||
*/
|
||||
const resolveSendTargetArg = (
|
||||
value: string,
|
||||
): { botId?: string; messengerInstallationId?: string } => {
|
||||
if (value.startsWith('@')) {
|
||||
return { messengerInstallationId: value.slice(1) };
|
||||
}
|
||||
return { botId: value };
|
||||
};
|
||||
|
||||
export function registerBotMessageCommands(bot: Command) {
|
||||
const message = bot
|
||||
.command('message')
|
||||
@@ -14,20 +122,40 @@ export function registerBotMessageCommands(bot: Command) {
|
||||
// ── send ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('send <botId>')
|
||||
.description('Send a message to a channel')
|
||||
.command('send <botIdOrAtKey>')
|
||||
.description(
|
||||
'Send a message to a channel. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
|
||||
'to send through a System Bot messenger installation (see `lh bot messengers list`).',
|
||||
)
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.requiredOption('--message <text>', 'Message content')
|
||||
.option(
|
||||
'--attachment <pathOrUrl>',
|
||||
'Attach a file by local path or remote URL (repeatable). ' +
|
||||
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
|
||||
collectOptions,
|
||||
[],
|
||||
)
|
||||
.option('--reply-to <messageId>', 'Reply to a specific message')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { json?: boolean; message: string; replyTo?: string; target: string },
|
||||
botIdOrAtKey: string,
|
||||
options: {
|
||||
attachment: string[];
|
||||
json?: boolean;
|
||||
message: string;
|
||||
replyTo?: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const attachments = await resolveAttachmentFlags(options.attachment);
|
||||
const target = resolveSendTargetArg(botIdOrAtKey);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.sendMessage.mutate({
|
||||
botId,
|
||||
...target,
|
||||
attachments,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
replyTo: options.replyTo,
|
||||
@@ -39,8 +167,56 @@ export function registerBotMessageCommands(bot: Command) {
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
|
||||
console.log(
|
||||
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`,
|
||||
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── dm (direct message) ─────────────────────────────────
|
||||
|
||||
message
|
||||
.command('dm <botIdOrAtKey>')
|
||||
.description(
|
||||
'Send a direct message to a platform user. Pass a per-agent bot id, or ' +
|
||||
'"@<messenger-install-id>" for a System Bot install.',
|
||||
)
|
||||
.requiredOption('--user-id <id>', 'Target user ID on the platform')
|
||||
.requiredOption('--message <text>', 'Message content')
|
||||
.option(
|
||||
'--attachment <pathOrUrl>',
|
||||
'Attach a file by local path or remote URL (repeatable). ' +
|
||||
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
|
||||
collectOptions,
|
||||
[],
|
||||
)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botIdOrAtKey: string,
|
||||
options: { attachment: string[]; json?: boolean; message: string; userId: string },
|
||||
) => {
|
||||
const attachments = await resolveAttachmentFlags(options.attachment);
|
||||
const target = resolveSendTargetArg(botIdOrAtKey);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.sendDirectMessage.mutate({
|
||||
...target,
|
||||
attachments,
|
||||
content: options.message,
|
||||
userId: options.userId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
|
||||
console.log(
|
||||
`${pc.green('✓')} DM sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -450,21 +626,43 @@ export function registerBotMessageCommands(bot: Command) {
|
||||
});
|
||||
|
||||
thread
|
||||
.command('reply <botId>')
|
||||
.description('Reply to a thread')
|
||||
.command('reply <botIdOrAtKey>')
|
||||
.description(
|
||||
'Reply to a thread. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
|
||||
'for a System Bot install.',
|
||||
)
|
||||
.requiredOption('--thread-id <id>', 'Thread ID')
|
||||
.requiredOption('--message <text>', 'Reply content')
|
||||
.action(async (botId: string, options: { message: string; threadId: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.replyToThread.mutate({
|
||||
botId,
|
||||
content: options.message,
|
||||
threadId: options.threadId,
|
||||
});
|
||||
.option(
|
||||
'--attachment <pathOrUrl>',
|
||||
'Attach a file by local path or remote URL (repeatable). ' +
|
||||
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
|
||||
collectOptions,
|
||||
[],
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
botIdOrAtKey: string,
|
||||
options: { attachment: string[]; message: string; threadId: string },
|
||||
) => {
|
||||
const attachments = await resolveAttachmentFlags(options.attachment);
|
||||
const target = resolveSendTargetArg(botIdOrAtKey);
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`);
|
||||
});
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.replyToThread.mutate({
|
||||
...target,
|
||||
attachments,
|
||||
content: options.message,
|
||||
threadId: options.threadId,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
|
||||
console.log(
|
||||
`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── channel (subcommand group) ──────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type * as FormatModule from '../utils/format';
|
||||
import { registerBotMessengersCommands } from './botMessengers';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
messenger: {
|
||||
availablePlatforms: { query: vi.fn() },
|
||||
getMyLink: { query: vi.fn() },
|
||||
listMyInstallations: { query: vi.fn() },
|
||||
listMyLinks: { query: vi.fn() },
|
||||
setActiveAgent: { mutate: vi.fn() },
|
||||
unlink: { mutate: vi.fn() },
|
||||
uninstallInstallation: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
// `confirm` always answers yes — we test uninstall/unlink under the explicit
|
||||
// `--yes` flag too, but for the prompt path we want a deterministic answer.
|
||||
vi.mock('../utils/format', async () => {
|
||||
const actual = await vi.importActual<typeof FormatModule>('../utils/format');
|
||||
return { ...actual, confirm: vi.fn(async () => true) };
|
||||
});
|
||||
|
||||
describe('bot messengers', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const fn of [
|
||||
mockTrpcClient.messenger.availablePlatforms.query,
|
||||
mockTrpcClient.messenger.getMyLink.query,
|
||||
mockTrpcClient.messenger.listMyInstallations.query,
|
||||
mockTrpcClient.messenger.listMyLinks.query,
|
||||
mockTrpcClient.messenger.setActiveAgent.mutate,
|
||||
mockTrpcClient.messenger.unlink.mutate,
|
||||
mockTrpcClient.messenger.uninstallInstallation.mutate,
|
||||
]) {
|
||||
fn.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const bot = program.command('bot');
|
||||
registerBotMessengersCommands(bot);
|
||||
return program;
|
||||
}
|
||||
|
||||
function renderedOutput(): string {
|
||||
return consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
}
|
||||
|
||||
// ── installations ──────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('renders the installation table with SEND ARG hint', async () => {
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
|
||||
{
|
||||
applicationId: 'A1',
|
||||
id: 'inst_abc',
|
||||
installedAt: '2026-01-15T00:00:00Z',
|
||||
platform: 'slack',
|
||||
tenantId: 'T1',
|
||||
tenantName: 'Acme Corp',
|
||||
},
|
||||
]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list']);
|
||||
const out = renderedOutput();
|
||||
expect(mockTrpcClient.messenger.listMyInstallations.query).toHaveBeenCalled();
|
||||
expect(out).toContain('inst_abc');
|
||||
expect(out).toContain('Acme Corp');
|
||||
// The hint should explain how to use the id with the send commands
|
||||
expect(out).toContain('@<INSTALLATION ID>');
|
||||
});
|
||||
|
||||
it('reports empty state with install guidance', async () => {
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list']);
|
||||
const out = renderedOutput();
|
||||
expect(out).toContain('No System Bot installations connected.');
|
||||
expect(out).toContain('Settings → Messenger');
|
||||
});
|
||||
|
||||
it('--json passes through the payload', async () => {
|
||||
const payload = [{ id: 'inst_only', platform: 'discord', tenantId: 'g1' }];
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce(payload);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list', '--json']);
|
||||
expect(renderedOutput()).toContain('"id": "inst_only"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
it('prints details for a matching install', async () => {
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
|
||||
{
|
||||
applicationId: 'A1',
|
||||
id: 'inst_match',
|
||||
installedAt: '2026-01-15T00:00:00Z',
|
||||
platform: 'slack',
|
||||
scope: 'chat:write,users:read',
|
||||
tenantId: 'T1',
|
||||
tenantName: 'Acme Corp',
|
||||
},
|
||||
]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'view', 'inst_match']);
|
||||
const out = renderedOutput();
|
||||
expect(out).toContain('inst_match');
|
||||
expect(out).toContain('slack');
|
||||
expect(out).toContain('Acme Corp');
|
||||
expect(out).toContain('chat:write,users:read');
|
||||
});
|
||||
|
||||
it('exits non-zero when install missing', async () => {
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'view',
|
||||
'inst_missing',
|
||||
]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('--json missing install emits JSON null + exit 1 (scriptable)', async () => {
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'view',
|
||||
'inst_missing',
|
||||
'--json',
|
||||
]);
|
||||
// No human-readable error log; the JSON-pipe consumer gets `null`.
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain('null');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
it('--yes skips confirm and calls the mutation', async () => {
|
||||
mockTrpcClient.messenger.uninstallInstallation.mutate.mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'uninstall',
|
||||
'inst_abc',
|
||||
'--yes',
|
||||
]);
|
||||
expect(mockTrpcClient.messenger.uninstallInstallation.mutate).toHaveBeenCalledWith({
|
||||
installationId: 'inst_abc',
|
||||
});
|
||||
expect(renderedOutput()).toContain('revoked');
|
||||
});
|
||||
|
||||
it('confirms before calling when --yes is omitted', async () => {
|
||||
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
|
||||
{ id: 'inst_abc', platform: 'slack', tenantId: 'T1', tenantName: 'Acme' },
|
||||
]);
|
||||
mockTrpcClient.messenger.uninstallInstallation.mutate.mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'uninstall',
|
||||
'inst_abc',
|
||||
]);
|
||||
// Mocked confirm returns true → mutation still fires
|
||||
expect(mockTrpcClient.messenger.uninstallInstallation.mutate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('platforms', () => {
|
||||
it('renders the platforms table', async () => {
|
||||
mockTrpcClient.messenger.availablePlatforms.query.mockResolvedValueOnce([
|
||||
{ appId: 'A123', id: 'slack', name: 'Slack' },
|
||||
{ botUsername: 'lobehub_bot', id: 'telegram', name: 'Telegram' },
|
||||
]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'platforms']);
|
||||
const out = renderedOutput();
|
||||
expect(out).toContain('slack');
|
||||
expect(out).toContain('A123');
|
||||
expect(out).toContain('lobehub_bot');
|
||||
});
|
||||
|
||||
it('handles empty platform list gracefully', async () => {
|
||||
mockTrpcClient.messenger.availablePlatforms.query.mockResolvedValueOnce([]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'platforms']);
|
||||
expect(renderedOutput()).toContain('No System Bot platforms');
|
||||
});
|
||||
});
|
||||
|
||||
// ── links ──────────────────────────────────────────────
|
||||
|
||||
describe('links list', () => {
|
||||
it('renders the links table', async () => {
|
||||
mockTrpcClient.messenger.listMyLinks.query.mockResolvedValueOnce([
|
||||
{
|
||||
activeAgentId: 'agent_1',
|
||||
platform: 'slack',
|
||||
platformUserId: 'U1',
|
||||
platformUsername: 'alice',
|
||||
tenantId: 'T1',
|
||||
},
|
||||
]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'links', 'list']);
|
||||
const out = renderedOutput();
|
||||
expect(out).toContain('agent_1');
|
||||
expect(out).toContain('alice');
|
||||
});
|
||||
|
||||
it('reports empty state', async () => {
|
||||
mockTrpcClient.messenger.listMyLinks.query.mockResolvedValueOnce([]);
|
||||
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'links', 'list']);
|
||||
expect(renderedOutput()).toContain('No account links yet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('links view', () => {
|
||||
it('shows the link detail', async () => {
|
||||
mockTrpcClient.messenger.getMyLink.query.mockResolvedValueOnce({
|
||||
activeAgentId: 'agent_2',
|
||||
platform: 'slack',
|
||||
platformUserId: 'U2',
|
||||
platformUsername: 'bob',
|
||||
tenantId: 'T2',
|
||||
});
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'links',
|
||||
'view',
|
||||
'slack',
|
||||
'--tenant',
|
||||
'T2',
|
||||
]);
|
||||
expect(mockTrpcClient.messenger.getMyLink.query).toHaveBeenCalledWith({
|
||||
platform: 'slack',
|
||||
tenantId: 'T2',
|
||||
});
|
||||
expect(renderedOutput()).toContain('agent_2');
|
||||
});
|
||||
|
||||
it('exits non-zero on missing link', async () => {
|
||||
mockTrpcClient.messenger.getMyLink.query.mockResolvedValueOnce(null);
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'links',
|
||||
'view',
|
||||
'discord',
|
||||
]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('links set-agent', () => {
|
||||
it('passes agentId through to setActiveAgent', async () => {
|
||||
mockTrpcClient.messenger.setActiveAgent.mutate.mockResolvedValueOnce({ success: true });
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'links',
|
||||
'set-agent',
|
||||
'slack',
|
||||
'--agent',
|
||||
'agent_xyz',
|
||||
'--tenant',
|
||||
'T1',
|
||||
]);
|
||||
expect(mockTrpcClient.messenger.setActiveAgent.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'agent_xyz',
|
||||
platform: 'slack',
|
||||
tenantId: 'T1',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the agent when --agent none is passed', async () => {
|
||||
mockTrpcClient.messenger.setActiveAgent.mutate.mockResolvedValueOnce({ success: true });
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'links',
|
||||
'set-agent',
|
||||
'telegram',
|
||||
'--agent',
|
||||
'none',
|
||||
]);
|
||||
expect(mockTrpcClient.messenger.setActiveAgent.mutate).toHaveBeenCalledWith({
|
||||
agentId: null,
|
||||
platform: 'telegram',
|
||||
tenantId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('links unlink', () => {
|
||||
it('passes platform + tenant to the unlink mutation with --yes', async () => {
|
||||
mockTrpcClient.messenger.unlink.mutate.mockResolvedValueOnce({ success: true });
|
||||
await createProgram().parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'bot',
|
||||
'messengers',
|
||||
'links',
|
||||
'unlink',
|
||||
'slack',
|
||||
'--tenant',
|
||||
'T1',
|
||||
'--yes',
|
||||
]);
|
||||
expect(mockTrpcClient.messenger.unlink.mutate).toHaveBeenCalledWith({
|
||||
platform: 'slack',
|
||||
tenantId: 'T1',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* `lh bot messengers ...` — manages the user's System Bot installations
|
||||
* (Slack workspaces, Discord guilds, Telegram), distinct from per-agent bots.
|
||||
*
|
||||
* Mirrors `bot ...` (per-agent CRUD) and `bot message ...` (send/read), but
|
||||
* operates on `messenger_installations` (workspace-scoped) and
|
||||
* `messenger_account_links` (per-user routing). Subcommands talk directly to
|
||||
* `lambdaClient.messenger.*` — there's no shared CLI-side service layer for
|
||||
* this domain yet.
|
||||
*
|
||||
* **uninstall vs unlink** (recurring confusion — surface in command help):
|
||||
* - `uninstall <installationId>` revokes the install for the **whole
|
||||
* workspace**. Other users in that workspace can no longer use the bot.
|
||||
* - `links unlink <platform>` only removes the **current user's** account
|
||||
* binding. Workspace stays installed; colleagues are unaffected.
|
||||
*/
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
|
||||
const PLATFORMS = ['telegram', 'slack', 'discord'] as const;
|
||||
type MessengerPlatform = (typeof PLATFORMS)[number];
|
||||
|
||||
const validatePlatform = (value: string): MessengerPlatform => {
|
||||
if (!(PLATFORMS as readonly string[]).includes(value)) {
|
||||
throw new Error(`Unknown messenger platform: ${value}. Valid values: ${PLATFORMS.join(', ')}.`);
|
||||
}
|
||||
return value as MessengerPlatform;
|
||||
};
|
||||
|
||||
export function registerBotMessengersCommands(bot: Command) {
|
||||
const messengers = bot
|
||||
.command('messengers')
|
||||
.description(
|
||||
'Manage System Bot messenger installations (Slack workspaces, Discord guilds, Telegram) ' +
|
||||
'and per-user account links',
|
||||
);
|
||||
|
||||
// ── installations ──────────────────────────────────────
|
||||
|
||||
messengers
|
||||
.command('list')
|
||||
.description('List all System Bot installations the current user has connected.')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const installations = await client.messenger.listMyInstallations.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(installations);
|
||||
return;
|
||||
}
|
||||
|
||||
if (installations.length === 0) {
|
||||
console.log('No System Bot installations connected.');
|
||||
console.log(
|
||||
`\nRun ${pc.dim('lh bot messengers platforms')} to see what's available, then install via ` +
|
||||
`${pc.dim('Settings → Messenger')} (OAuth requires a browser).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = installations.map((i: any) => [
|
||||
i.id || '',
|
||||
i.platform || '',
|
||||
i.tenantName || i.tenantId || '(global)',
|
||||
i.applicationId || '',
|
||||
i.installedAt ? new Date(i.installedAt).toISOString().slice(0, 10) : '',
|
||||
]);
|
||||
printTable(rows, ['INSTALLATION ID', 'PLATFORM', 'TENANT', 'APP ID', 'INSTALLED']);
|
||||
console.log(
|
||||
`\nUse ${pc.dim('@<INSTALLATION ID>')} as the positional argument on ` +
|
||||
`${pc.dim('lh bot message send/dm/thread reply')} to route through a System Bot install.`,
|
||||
);
|
||||
});
|
||||
|
||||
messengers
|
||||
.command('view <installationId>')
|
||||
.description('Show detail for one installation.')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (installationId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const installations = (await client.messenger.listMyInstallations.query()) as any[];
|
||||
const install = installations.find((i) => i.id === installationId);
|
||||
|
||||
if (!install) {
|
||||
// Under `--json`, scripts need a parseable output even on miss —
|
||||
// emit `null` to stdout (rather than a human-readable error), then
|
||||
// exit non-zero so error handling still works in pipelines.
|
||||
if (options.json) {
|
||||
outputJson(null);
|
||||
} else {
|
||||
console.error(pc.red(`Installation not found: ${installationId}`));
|
||||
}
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
outputJson(install);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.bold('Installation')} ${pc.dim(install.id)}`);
|
||||
console.log(` Platform: ${install.platform}`);
|
||||
console.log(` Tenant: ${install.tenantName || install.tenantId || '(global)'}`);
|
||||
if (install.tenantId && install.tenantName) {
|
||||
console.log(` Tenant ID: ${install.tenantId}`);
|
||||
}
|
||||
console.log(` Application ID: ${install.applicationId}`);
|
||||
if (install.scope) console.log(` OAuth Scope: ${install.scope}`);
|
||||
if (install.installedAt) {
|
||||
console.log(` Installed: ${new Date(install.installedAt).toISOString()}`);
|
||||
}
|
||||
if (install.enterpriseId) {
|
||||
console.log(` Enterprise ID: ${install.enterpriseId}`);
|
||||
}
|
||||
if (install.isEnterpriseInstall) {
|
||||
console.log(` Enterprise: yes`);
|
||||
}
|
||||
});
|
||||
|
||||
messengers
|
||||
.command('uninstall <installationId>')
|
||||
.description(
|
||||
'Revoke a workspace install. AFFECTS EVERY USER IN THAT WORKSPACE — for Slack this freezes ' +
|
||||
'the bot; for Discord it removes the audit entry (a guild admin must remove the bot ' +
|
||||
'separately). To disconnect only your own account, use `bot messengers links unlink`.',
|
||||
)
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (installationId: string, options: { yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
if (!options.yes) {
|
||||
const installations = (await client.messenger.listMyInstallations.query()) as any[];
|
||||
const install = installations.find((i) => i.id === installationId);
|
||||
const label = install
|
||||
? `${install.platform} (${install.tenantName || install.tenantId || 'global'})`
|
||||
: installationId;
|
||||
const ok = await confirm(
|
||||
`${pc.yellow('⚠')} Uninstall ${pc.bold(label)} — this revokes the install for the whole workspace. Continue?`,
|
||||
);
|
||||
if (!ok) {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.messenger.uninstallInstallation.mutate({ installationId });
|
||||
console.log(`${pc.green('✓')} Installation ${pc.dim(installationId)} revoked.`);
|
||||
});
|
||||
|
||||
messengers
|
||||
.command('platforms')
|
||||
.description('List the platforms available for System Bot OAuth install.')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platforms = await client.messenger.availablePlatforms.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(platforms);
|
||||
return;
|
||||
}
|
||||
|
||||
if (platforms.length === 0) {
|
||||
console.log('No System Bot platforms are configured on this deployment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = platforms.map((p: any) => [
|
||||
p.id || '',
|
||||
p.name || '',
|
||||
p.appId || '',
|
||||
p.botUsername || '',
|
||||
]);
|
||||
printTable(rows, ['ID', 'NAME', 'APP ID', 'BOT USERNAME']);
|
||||
console.log(
|
||||
`\nInstalls are initiated via ${pc.dim('Settings → Messenger')} in the web UI ` +
|
||||
'(OAuth needs a browser).',
|
||||
);
|
||||
});
|
||||
|
||||
// ── account links ──────────────────────────────────────
|
||||
|
||||
const links = messengers
|
||||
.command('links')
|
||||
.description('Manage per-user account links — routing of inbound IM to your agents');
|
||||
|
||||
links
|
||||
.command('list')
|
||||
.description('List all your account links across platforms and tenants.')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const linkRows = await client.messenger.listMyLinks.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(linkRows);
|
||||
return;
|
||||
}
|
||||
|
||||
if (linkRows.length === 0) {
|
||||
console.log('No account links yet. Complete verify-im on a platform first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = linkRows.map((l: any) => [
|
||||
l.platform || '',
|
||||
l.tenantId || '(global)',
|
||||
l.activeAgentId || pc.dim('(unset)'),
|
||||
l.platformUsername || l.platformUserId || '',
|
||||
]);
|
||||
printTable(rows, ['PLATFORM', 'TENANT', 'ACTIVE AGENT', 'PLATFORM USER']);
|
||||
});
|
||||
|
||||
links
|
||||
.command('view <platform>')
|
||||
.description('Show one account link.')
|
||||
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (platform: string, options: { json?: boolean; tenant?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platformValidated = validatePlatform(platform);
|
||||
const link = await client.messenger.getMyLink.query({
|
||||
platform: platformValidated,
|
||||
tenantId: options.tenant,
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
console.error(
|
||||
pc.red(
|
||||
`No link found for ${platform}${options.tenant ? ` (tenant ${options.tenant})` : ''}`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
outputJson(link);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.bold('Link')} ${pc.dim(link.platform)}`);
|
||||
if (link.tenantId) console.log(` Tenant ID: ${link.tenantId}`);
|
||||
console.log(` Platform User ID: ${link.platformUserId}`);
|
||||
if (link.platformUsername) {
|
||||
console.log(` Platform User: ${link.platformUsername}`);
|
||||
}
|
||||
console.log(` Active Agent: ${link.activeAgentId ?? pc.dim('(unset)')}`);
|
||||
});
|
||||
|
||||
links
|
||||
.command('set-agent <platform>')
|
||||
.description('Change which agent receives inbound IM on a platform link.')
|
||||
.requiredOption('--agent <id>', 'Agent id to route to, or "none" to clear the active agent.')
|
||||
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
|
||||
.action(async (platform: string, options: { agent: string; tenant?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platformValidated = validatePlatform(platform);
|
||||
const agentId = options.agent === 'none' ? null : options.agent;
|
||||
|
||||
await client.messenger.setActiveAgent.mutate({
|
||||
agentId,
|
||||
platform: platformValidated,
|
||||
tenantId: options.tenant,
|
||||
});
|
||||
|
||||
const scope = options.tenant ? ` (tenant ${options.tenant})` : '';
|
||||
const target = agentId === null ? 'cleared' : `set to agent ${pc.dim(agentId)}`;
|
||||
console.log(`${pc.green('✓')} Active agent for ${platform}${scope} ${target}.`);
|
||||
});
|
||||
|
||||
links
|
||||
.command('unlink <platform>')
|
||||
.description(
|
||||
'Remove your account link for a platform. Workspace install is unaffected — colleagues ' +
|
||||
'can still use the bot.',
|
||||
)
|
||||
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (platform: string, options: { tenant?: string; yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platformValidated = validatePlatform(platform);
|
||||
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(
|
||||
`Unlink your account from ${pc.bold(platform)}${options.tenant ? ` (tenant ${options.tenant})` : ''}?`,
|
||||
);
|
||||
if (!ok) {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.messenger.unlink.mutate({
|
||||
platform: platformValidated,
|
||||
tenantId: options.tenant,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Unlinked.`);
|
||||
});
|
||||
}
|
||||
@@ -341,4 +341,199 @@ describe('hetero exec command', () => {
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('--resume auto-retry on session-not-found', () => {
|
||||
it('retries without --resume when the error stream event indicates the session is gone', async () => {
|
||||
// First spawn: exits non-zero, emits a resume-not-found error event
|
||||
const resumeNotFoundEvent = {
|
||||
data: {
|
||||
error: 'No conversation found with session ID cc-stale',
|
||||
message: 'No conversation found with session ID cc-stale',
|
||||
},
|
||||
operationId: 'op-r1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'error',
|
||||
};
|
||||
mockSpawnAgent
|
||||
.mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
|
||||
// Second spawn: succeeds
|
||||
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'do the thing',
|
||||
'--resume',
|
||||
'cc-stale',
|
||||
'--operation-id',
|
||||
'op-r1',
|
||||
]);
|
||||
|
||||
// Two spawns: first with --resume, retry without
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawnAgent.mock.calls[0][0]).toMatchObject({ resumeSessionId: 'cc-stale' });
|
||||
expect(mockSpawnAgent.mock.calls[1][0]).not.toHaveProperty('resumeSessionId');
|
||||
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
|
||||
|
||||
// Final exit code comes from the retry (0 → success)
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('retries without --resume when stderr contains a session-not-found message', async () => {
|
||||
// First spawn: exits non-zero with no events, but stderr has the pattern
|
||||
mockSpawnAgent
|
||||
.mockReturnValueOnce(
|
||||
createFakeHandle({
|
||||
exitCode: 1,
|
||||
stderrChunks: ['Error: No conversation found with session ID xyz\n'],
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'continue',
|
||||
'--resume',
|
||||
'xyz',
|
||||
'--operation-id',
|
||||
'op-r2',
|
||||
]);
|
||||
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('retries without --resume when the error indicates context overflow', async () => {
|
||||
const contextOverflowEvent = {
|
||||
data: {
|
||||
error: 'prompt is too long: 215168 tokens > 200000 maximum',
|
||||
message: 'prompt is too long: 215168 tokens > 200000 maximum',
|
||||
},
|
||||
operationId: 'op-ctx',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'error',
|
||||
};
|
||||
mockSpawnAgent
|
||||
.mockReturnValueOnce(createFakeHandle({ events: [contextOverflowEvent], exitCode: 1 }))
|
||||
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'next question',
|
||||
'--resume',
|
||||
'cc-longctx',
|
||||
'--operation-id',
|
||||
'op-ctx',
|
||||
]);
|
||||
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawnAgent.mock.calls[0][0]).toMatchObject({ resumeSessionId: 'cc-longctx' });
|
||||
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('does NOT retry on a non-resume error exit', async () => {
|
||||
// Exit code 1 but no resume-related error message
|
||||
mockSpawnAgent.mockReturnValueOnce(
|
||||
createFakeHandle({ exitCode: 1, stderrChunks: ['rate limit exceeded\n'] }),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--resume',
|
||||
'cc-valid',
|
||||
]);
|
||||
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does NOT retry when --resume is not provided', async () => {
|
||||
const errorEvent = {
|
||||
data: { error: 'No conversation found', message: 'No conversation found' },
|
||||
operationId: 'op-nr',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'error',
|
||||
};
|
||||
mockSpawnAgent.mockReturnValueOnce(createFakeHandle({ events: [errorEvent], exitCode: 1 }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'fresh run',
|
||||
'--operation-id',
|
||||
'op-nr',
|
||||
]);
|
||||
|
||||
// No --resume → no interception → no retry
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does NOT suppress the resume-error event from JSONL output', async () => {
|
||||
const resumeNotFoundEvent = {
|
||||
data: {
|
||||
error: 'No conversation found with session ID old',
|
||||
message: 'No conversation found with session ID old',
|
||||
},
|
||||
operationId: 'op-jsonl',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'error',
|
||||
};
|
||||
mockSpawnAgent
|
||||
.mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
|
||||
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'do thing',
|
||||
'--resume',
|
||||
'old',
|
||||
'--render',
|
||||
'jsonl',
|
||||
]);
|
||||
|
||||
// The error event is still emitted to JSONL (for observability) even
|
||||
// though it was withheld from the ingester.
|
||||
const lines = stdoutSpy.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((s): s is string => typeof s === 'string');
|
||||
const errorLine = lines.find((l) => {
|
||||
try {
|
||||
return JSON.parse(l).type === 'error';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
expect(errorLine).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+239
-84
@@ -17,6 +17,40 @@ import { TrpcIngestSink } from '../utils/TrpcIngestSink';
|
||||
|
||||
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
|
||||
|
||||
/**
|
||||
* Patterns that indicate a `--resume <sessionId>` run should be retried
|
||||
* without `--resume`. Two classes of failure:
|
||||
*
|
||||
* 1. Session file missing (sandbox recycled): the container is ephemeral
|
||||
* (~1 h idle TTL), so a new sandbox has an empty `~/.claude/projects/`
|
||||
* and the stored session id is stale.
|
||||
*
|
||||
* 2. Context overflow (long conversation): the resumed session carries all
|
||||
* accumulated history; when the combined token count exceeds the model's
|
||||
* context window the API rejects the request immediately after CC
|
||||
* initialises. Starting fresh (no `--resume`) drops the old history and
|
||||
* lets CC respond to the new prompt alone.
|
||||
*
|
||||
* Checked against:
|
||||
* - `error` stream events emitted by the CC adapter from CC's result event
|
||||
* - Accumulated stderr output (fallback when CC exits without a result event)
|
||||
*/
|
||||
const RESUME_RETRY_PATTERNS = [
|
||||
// Session file missing — sandbox was recycled
|
||||
/no conversation found/i,
|
||||
/session.*not found/i,
|
||||
/conversation.*not found/i,
|
||||
/resume.*not found/i,
|
||||
// Context overflow — API rejected the resumed session's accumulated history
|
||||
/prompt.*too long/i,
|
||||
/context.*too long/i,
|
||||
/context window.*exceed/i,
|
||||
/maximum.*context.*length/i,
|
||||
] as const;
|
||||
|
||||
const looksLikeNeedsRetryWithoutResume = (text: string): boolean =>
|
||||
RESUME_RETRY_PATTERNS.some((p) => p.test(text));
|
||||
|
||||
interface ExecOptions {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
@@ -218,95 +252,204 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
const ingester = new BatchIngester(sink);
|
||||
|
||||
// `spawnAgent` is async and can reject DURING image normalization — fetch
|
||||
// failures, missing local --image paths, decode errors. Surface those as a
|
||||
// clean error + exit code instead of an unhandled promise rejection / stack
|
||||
// trace, mirroring the validation try/catch above.
|
||||
let handle: Awaited<ReturnType<typeof spawnAgent>>;
|
||||
try {
|
||||
handle = await spawnAgent({
|
||||
/**
|
||||
* Spawn one agent process and stream all its events into `ingester`.
|
||||
*
|
||||
* When `interceptResumeErrors` is true, any `error`-type event whose
|
||||
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
|
||||
* ingester and signals a retry instead. This keeps the server's
|
||||
* operation state clean: no terminal error event is pushed, so the
|
||||
* retry's events land on the same operationId without confusing the
|
||||
* renderer.
|
||||
*
|
||||
* Returns:
|
||||
* code / signal — child exit info
|
||||
* sessionId — CC session id from `system.init` (undefined on resume failure)
|
||||
* ingestError — true when a batch could not be flushed after retries
|
||||
* resumeNotFound — true when a resume-not-found error was intercepted
|
||||
* stderrContent — accumulated stderr (only when interceptResumeErrors=true)
|
||||
*/
|
||||
const runOneAgent = async (
|
||||
spawnOpts: Parameters<typeof spawnAgent>[0],
|
||||
interceptResumeErrors: boolean,
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
ingestError: boolean;
|
||||
resumeNotFound: boolean;
|
||||
sessionId: string | undefined;
|
||||
signal: NodeJS.Signals | null;
|
||||
stderrContent: string;
|
||||
}> => {
|
||||
// `spawnAgent` is async and can reject DURING image normalization — fetch
|
||||
// failures, missing local --image paths, decode errors.
|
||||
let handle: Awaited<ReturnType<typeof spawnAgent>>;
|
||||
try {
|
||||
handle = await spawnAgent(spawnOpts);
|
||||
} catch (err) {
|
||||
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Always collect stderr — used for resume-error detection AND for
|
||||
// surfacing a meaningful error message to the server when CC fails
|
||||
// without emitting a structured error event. Cap at 8 KB so the
|
||||
// collector doesn't grow unboundedly on a chatty run.
|
||||
// Always pipe to process.stderr too so users see auth prompts / warnings.
|
||||
const STDERR_CAP = 8 * 1024;
|
||||
let stderrContent = '';
|
||||
handle.stderr.on('data', (chunk: Buffer) => {
|
||||
if (stderrContent.length < STDERR_CAP) {
|
||||
stderrContent += chunk.toString();
|
||||
}
|
||||
});
|
||||
handle.stderr.pipe(process.stderr);
|
||||
|
||||
// Ctrl-C → SIGINT to the child's process group.
|
||||
// Repeated Ctrl-C escalates to SIGKILL.
|
||||
let interrupted = false;
|
||||
const onSigint = async () => {
|
||||
if (interrupted) {
|
||||
handle.kill('SIGKILL');
|
||||
return;
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort; process is exiting anyway
|
||||
}
|
||||
}
|
||||
};
|
||||
const onSigterm = async () => {
|
||||
handle.kill('SIGTERM');
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', onSigint);
|
||||
process.on('SIGTERM', onSigterm);
|
||||
|
||||
// Stream events. Each event is optionally written as JSONL and pushed
|
||||
// into the ingester. When intercepting resume errors, a matching
|
||||
// `error` event is withheld from the ingester and flags a retry instead.
|
||||
let resumeNotFound = false;
|
||||
const ingestError = false;
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
if (interceptResumeErrors && event.type === 'error') {
|
||||
const data = event.data as Record<string, unknown> | undefined;
|
||||
const msg = String(data?.message ?? data?.error ?? '');
|
||||
if (looksLikeNeedsRetryWithoutResume(msg)) {
|
||||
resumeNotFound = true;
|
||||
// Emit to JSONL for observability but do NOT push to ingester —
|
||||
// we are about to retry; the server must not see a terminal error.
|
||||
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
ingester.push(event);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Stream error from agent process:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({
|
||||
error: { message: String(err), type: 'stream_error' },
|
||||
result: 'error',
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.off('SIGINT', onSigint);
|
||||
process.off('SIGTERM', onSigterm);
|
||||
}
|
||||
|
||||
const { code, signal } = await handle.exit;
|
||||
|
||||
// Fallback stderr detection: CC may exit non-zero without emitting a
|
||||
// result event (e.g. it writes to stderr and quits immediately).
|
||||
if (
|
||||
interceptResumeErrors &&
|
||||
!resumeNotFound &&
|
||||
code !== 0 &&
|
||||
looksLikeNeedsRetryWithoutResume(stderrContent)
|
||||
) {
|
||||
resumeNotFound = true;
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
ingestError,
|
||||
resumeNotFound,
|
||||
sessionId: handle.sessionId,
|
||||
signal,
|
||||
stderrContent,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── First run (with --resume if provided) ───────────────────────────────
|
||||
|
||||
const interceptResume = !!options.resume;
|
||||
const first = await runOneAgent(
|
||||
{
|
||||
agentType: options.type,
|
||||
command: options.command,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
operationId,
|
||||
prompt: resolved.prompt,
|
||||
resumeSessionId: options.resume,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
},
|
||||
interceptResume,
|
||||
);
|
||||
|
||||
// ─── Auto-retry without --resume when the session cannot be used ─────────
|
||||
//
|
||||
// Two classes of failure detected via `RESUME_RETRY_PATTERNS`:
|
||||
// A. Sandbox recycled: container is ephemeral (~1 h idle TTL); new sandbox
|
||||
// has no CC session files so `--resume <staleId>` is rejected with a
|
||||
// "no conversation found" error.
|
||||
// B. Context overflow: the resumed session carries accumulated history that
|
||||
// pushes the combined token count past the model limit; the API rejects
|
||||
// the call with a "prompt is too long" error.
|
||||
//
|
||||
// In both cases we transparently restart CC without `--resume` so it starts a
|
||||
// fresh session. The server's `heteroSessionId` is updated with the new id,
|
||||
// breaking the stale-session loop.
|
||||
let result = first;
|
||||
if (first.resumeNotFound) {
|
||||
log.info('Resume failed (session not found or context overflow) — retrying without --resume');
|
||||
result = await runOneAgent(
|
||||
{
|
||||
agentType: options.type,
|
||||
command: options.command,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
operationId,
|
||||
prompt: resolved.prompt,
|
||||
// No resumeSessionId — start fresh
|
||||
},
|
||||
false, // no need to intercept resume errors on a fresh run
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the child's stderr to ours so users see CLI errors / warnings
|
||||
// (auth prompts, missing-binary errors, etc.) in the terminal.
|
||||
handle.stderr.pipe(process.stderr);
|
||||
// ─── Drain + finish ───────────────────────────────────────────────────────
|
||||
|
||||
// Ctrl-C → SIGINT to the child's process group so the spawned CLI gets a
|
||||
// chance to clean up. Repeated Ctrl-C escalates to SIGKILL via the
|
||||
// standard "double-tap" pattern most CLIs implement themselves.
|
||||
// In server-ingest mode, drain the ingester and call heteroFinish before
|
||||
// exiting so the server knows the operation was cancelled.
|
||||
let interrupted = false;
|
||||
const onSigint = async () => {
|
||||
if (interrupted) {
|
||||
handle.kill('SIGKILL');
|
||||
return;
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort; process is exiting anyway
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', onSigint);
|
||||
process.on('SIGTERM', async () => {
|
||||
handle.kill('SIGTERM');
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stream events. Each event is optionally written as JSONL and always
|
||||
// pushed into the ingester (which batches and sends to the server).
|
||||
let ingestError = false;
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
if (emitJsonl) {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
ingester.push(event);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Stream error from agent process:', err instanceof Error ? err.message : String(err));
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({
|
||||
result: 'error',
|
||||
error: { message: String(err), type: 'stream_error' },
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.off('SIGINT', onSigint);
|
||||
}
|
||||
|
||||
// Pass the child's exit code through. In server-ingest mode, drain the
|
||||
// ingester and call heteroFinish before exiting.
|
||||
const { code, signal } = await handle.exit;
|
||||
const { code, signal, sessionId } = result;
|
||||
|
||||
if (serverIngest) {
|
||||
try {
|
||||
@@ -316,21 +459,33 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
'Failed to flush events to server:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
ingestError = true;
|
||||
result = { ...result, ingestError: true };
|
||||
}
|
||||
|
||||
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
|
||||
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
|
||||
|
||||
// When the run failed, pass stderr as the error detail so the server can
|
||||
// surface a useful message instead of the generic "Agent execution failed"
|
||||
// fallback. Trim to the last 1 KB — the tail is most informative and
|
||||
// keeps the tRPC payload small.
|
||||
const stderrTail = result.stderrContent.trim();
|
||||
const finishError =
|
||||
!exitedClean && stderrTail
|
||||
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await sink.finish({
|
||||
error: finishError,
|
||||
result: exitedClean ? 'success' : 'error',
|
||||
sessionId: handle.sessionId,
|
||||
sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to send heteroFinish:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== null) process.exit(ingestError ? 1 : code);
|
||||
if (code !== null) process.exit(result.ingestError ? 1 : code);
|
||||
if (signal === 'SIGINT') process.exit(130);
|
||||
if (signal === 'SIGTERM') process.exit(143);
|
||||
if (signal === 'SIGKILL') process.exit(137);
|
||||
|
||||
@@ -12,16 +12,38 @@ export function registerNotifyCommand(program: Command) {
|
||||
.requiredOption('-c, --content <content>', 'Message content')
|
||||
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
|
||||
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
|
||||
.option(
|
||||
'--role <role>',
|
||||
'Message role: user (default, triggers agent reply) | assistant (writes directly as agent message)',
|
||||
'user',
|
||||
)
|
||||
.option(
|
||||
'--message-id <messageId>',
|
||||
'When --role assistant: update an existing message instead of creating a new one (keeps a single bubble)',
|
||||
)
|
||||
.option(
|
||||
'--continue',
|
||||
'When --role assistant: trigger a follow-up agent turn after writing the message',
|
||||
)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
content: string;
|
||||
continue?: boolean;
|
||||
json?: boolean;
|
||||
messageId?: string;
|
||||
role?: 'assistant' | 'user';
|
||||
threadId?: string;
|
||||
topic: string;
|
||||
}) => {
|
||||
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
|
||||
log.debug(
|
||||
'notify: topic=%s, agentId=%s, role=%s, messageId=%s',
|
||||
options.topic,
|
||||
options.agentId,
|
||||
options.role,
|
||||
options.messageId,
|
||||
);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
@@ -29,6 +51,9 @@ export function registerNotifyCommand(program: Command) {
|
||||
const result = await client.agentNotify.notify.mutate({
|
||||
agentId: options.agentId,
|
||||
content: options.content,
|
||||
continue: options.continue,
|
||||
messageId: options.messageId,
|
||||
role: options.role,
|
||||
threadId: options.threadId,
|
||||
topicId: options.topic,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface TaskEntry {
|
||||
agentId?: string;
|
||||
agentType: 'hermes' | 'openclaw';
|
||||
operationId: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
function getRegistryPath(): string {
|
||||
return path.join(os.homedir(), '.lobehub', 'task-registry.json');
|
||||
}
|
||||
|
||||
function readRegistry(): Record<string, TaskEntry> {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getRegistryPath(), 'utf8')) as Record<string, TaskEntry>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRegistry(entries: Record<string, TaskEntry>): void {
|
||||
const dir = path.dirname(getRegistryPath());
|
||||
fs.mkdirSync(dir, { mode: 0o700, recursive: true });
|
||||
fs.writeFileSync(getRegistryPath(), JSON.stringify(entries, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function saveTask(entry: TaskEntry): void {
|
||||
const registry = readRegistry();
|
||||
registry[entry.taskId] = entry;
|
||||
writeRegistry(registry);
|
||||
}
|
||||
|
||||
export function getTask(taskId: string): TaskEntry | undefined {
|
||||
return readRegistry()[taskId];
|
||||
}
|
||||
|
||||
export function removeTask(taskId: string): void {
|
||||
const registry = readRegistry();
|
||||
delete registry[taskId];
|
||||
writeRegistry(registry);
|
||||
}
|
||||
|
||||
export function listTasks(): TaskEntry[] {
|
||||
return Object.values(readRegistry());
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import { createProgram } from './program';
|
||||
import { log } from './utils/logger';
|
||||
|
||||
createProgram().parse(process.argv, { from: 'node' });
|
||||
void createProgram()
|
||||
.parseAsync(process.argv, { from: 'node' })
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { removeTask, saveTask } from '../../daemon/taskRegistry';
|
||||
import { runHeteroTask } from '../heteroTask';
|
||||
|
||||
// ─── Mocks ───
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
// task registry — use real implementation backed by a temporary in-memory map
|
||||
const taskStore: Record<string, any> = {};
|
||||
vi.mock('../../daemon/taskRegistry', () => ({
|
||||
getTask: vi.fn((id: string) => taskStore[id]),
|
||||
listTasks: vi.fn(() => Object.values(taskStore)),
|
||||
removeTask: vi.fn((id: string) => {
|
||||
delete taskStore[id];
|
||||
}),
|
||||
saveTask: vi.fn((entry: any) => {
|
||||
taskStore[entry.taskId] = entry;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
getTrpcClient: vi.fn().mockResolvedValue({
|
||||
agentNotify: {
|
||||
notify: { mutate: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function makeMockChild(pid = 9999) {
|
||||
const listeners: Record<string, Array<(...a: any[]) => void>> = {};
|
||||
return {
|
||||
on: vi.fn((event: string, cb: (...a: any[]) => void) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
}),
|
||||
pid,
|
||||
unref: vi.fn(),
|
||||
_emit: (event: string, ...args: any[]) => listeners[event]?.forEach((cb) => cb(...args)),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
describe('runHeteroTask (openclaw)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear task store
|
||||
for (const key of Object.keys(taskStore)) delete taskStore[key];
|
||||
execFileSyncMock.mockReturnValue('/usr/local/bin/lh\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('always injects buildNotifyProtocol into the prompt regardless of session history', async () => {
|
||||
const child = makeMockChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'what time is it',
|
||||
taskId: 'task-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
|
||||
const msgIdx = spawnArgs.indexOf('--message');
|
||||
const messageArg = spawnArgs[msgIdx + 1];
|
||||
|
||||
expect(messageArg).toContain('what time is it');
|
||||
expect(messageArg).toContain('lh notify');
|
||||
expect(messageArg).toContain('MSG_ID');
|
||||
});
|
||||
|
||||
it('always injects protocol even on the second turn of the same session', async () => {
|
||||
const child1 = makeMockChild(1111);
|
||||
const child2 = makeMockChild(2222);
|
||||
spawnMock.mockReturnValueOnce(child1).mockReturnValueOnce(child2);
|
||||
|
||||
// First turn
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'hello',
|
||||
taskId: 'task-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
// Simulate process exit so task is removed
|
||||
child1._emit('close', 0, null);
|
||||
|
||||
// Second turn (same topicId)
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-2',
|
||||
prompt: 'follow up',
|
||||
taskId: 'task-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
for (const call of spawnMock.mock.calls) {
|
||||
const args = call[1] as string[];
|
||||
const msg = args[args.indexOf('--message') + 1];
|
||||
expect(msg).toContain('lh notify');
|
||||
}
|
||||
});
|
||||
|
||||
it('kills an existing concurrent process for the same topicId before spawning', async () => {
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const child1 = makeMockChild(1111);
|
||||
spawnMock.mockReturnValueOnce(child1);
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'msg1',
|
||||
taskId: 'task-1',
|
||||
topicId: 'topic-same',
|
||||
});
|
||||
// task-1 is still "running" (close not fired)
|
||||
|
||||
const child2 = makeMockChild(2222);
|
||||
spawnMock.mockReturnValueOnce(child2);
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-2',
|
||||
prompt: 'msg2',
|
||||
taskId: 'task-2',
|
||||
topicId: 'topic-same',
|
||||
});
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(1111, 'SIGTERM');
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not kill processes for a different topicId', async () => {
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const child1 = makeMockChild(3333);
|
||||
spawnMock.mockReturnValueOnce(child1);
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'a',
|
||||
taskId: 'task-a',
|
||||
topicId: 'topic-A',
|
||||
});
|
||||
|
||||
const child2 = makeMockChild(4444);
|
||||
spawnMock.mockReturnValueOnce(child2);
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-2',
|
||||
prompt: 'b',
|
||||
taskId: 'task-b',
|
||||
topicId: 'topic-B',
|
||||
});
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves task entry with correct fields after spawn', async () => {
|
||||
const child = makeMockChild(5555);
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
await runHeteroTask({
|
||||
agentId: 'agent-1',
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-x',
|
||||
prompt: 'test',
|
||||
taskId: 'task-x',
|
||||
topicId: 'topic-x',
|
||||
});
|
||||
|
||||
expect(saveTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentType: 'openclaw',
|
||||
pid: 5555,
|
||||
taskId: 'task-x',
|
||||
topicId: 'topic-x',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes --session-id and --agent args to openclaw', async () => {
|
||||
const child = makeMockChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'hello',
|
||||
taskId: 'task-1',
|
||||
topicId: 'my-topic-id',
|
||||
});
|
||||
|
||||
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
|
||||
expect(spawnArgs).toContain('--session-id');
|
||||
expect(spawnArgs[spawnArgs.indexOf('--session-id') + 1]).toBe('my-topic-id');
|
||||
expect(spawnArgs).toContain('--agent');
|
||||
expect(spawnArgs).toContain('--local');
|
||||
});
|
||||
|
||||
it('removes task and ignores already-exited process when killing concurrent task', async () => {
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw new Error('No such process');
|
||||
});
|
||||
|
||||
const child1 = makeMockChild(7777);
|
||||
spawnMock.mockReturnValueOnce(child1);
|
||||
await runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'msg1',
|
||||
taskId: 'task-1',
|
||||
topicId: 'topic-gone',
|
||||
});
|
||||
|
||||
const child2 = makeMockChild(8888);
|
||||
spawnMock.mockReturnValueOnce(child2);
|
||||
// Should not throw even though kill fails
|
||||
await expect(
|
||||
runHeteroTask({
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-2',
|
||||
prompt: 'msg2',
|
||||
taskId: 'task-2',
|
||||
topicId: 'topic-gone',
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(removeTask).toHaveBeenCalledWith('task-1');
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { getHermesPort } from './heteroTask';
|
||||
|
||||
export interface CheckPlatformCapabilityParams {
|
||||
platform: 'hermes' | 'openclaw';
|
||||
}
|
||||
|
||||
export interface CheckPlatformCapabilityResult {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a specific agent platform is available on this device.
|
||||
* Dispatched by the server via `device.checkCapability` tRPC procedure.
|
||||
*
|
||||
* - openclaw: runs `openclaw --version` and parses the output
|
||||
* - hermes: hits the gateway health endpoint on the configured port
|
||||
*/
|
||||
export async function checkPlatformCapability(
|
||||
params: CheckPlatformCapabilityParams,
|
||||
): Promise<CheckPlatformCapabilityResult> {
|
||||
const { platform } = params;
|
||||
|
||||
if (platform === 'openclaw') {
|
||||
try {
|
||||
const output = execFileSync('openclaw', ['--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
// output is typically "openclaw x.y.z"
|
||||
const version = output.split(/\s+/).at(-1);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : 'openclaw not found or failed to run',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const body = (await res.json()) as { version?: string };
|
||||
version = body.version;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
return { available: true, version };
|
||||
}
|
||||
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { available: false, reason: `Unknown platform: ${platform as string}` };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
export interface GetAgentProfileParams {
|
||||
/** Agent ID to query (openclaw only). Defaults to the default agent. */
|
||||
agentId?: string;
|
||||
platform: RemoteHeterogeneousAgentType;
|
||||
}
|
||||
|
||||
export interface AgentProfileResult {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Files to look for a description (tried in order)
|
||||
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
|
||||
|
||||
/**
|
||||
* Try to extract a description from the workspace identity file.
|
||||
* Looks for Creature / Vibe / Description fields in IDENTITY.md or SOUL.md.
|
||||
*/
|
||||
function readDescriptionFromWorkspace(workspacePath: string): string | undefined {
|
||||
for (const filename of IDENTITY_FILES) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
if (!match) continue;
|
||||
|
||||
const value = match[1].trim();
|
||||
// Skip unfilled template placeholders like _(pick something)_ or (TBD)
|
||||
if (/^[_*((].*[))*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenClawAgentEntry {
|
||||
id: string;
|
||||
identityEmoji?: string;
|
||||
identityName?: string;
|
||||
isDefault?: boolean;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
function getOpenClawProfile(agentId?: string): AgentProfileResult {
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
let agents: OpenClawAgentEntry[];
|
||||
try {
|
||||
agents = JSON.parse(output) as OpenClawAgentEntry[];
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
const agent = agentId
|
||||
? agents.find((a) => a.id === agentId)
|
||||
: (agents.find((a) => a.isDefault) ?? agents[0]);
|
||||
|
||||
if (!agent) return {};
|
||||
|
||||
const title = agent.identityName || undefined;
|
||||
const avatar = agent.identityEmoji || '🦞'; // OpenClaw brand mascot as default
|
||||
|
||||
// Description is not exposed by the CLI — read from the workspace IDENTITY.md
|
||||
const description = agent.workspace ? readDescriptionFromWorkspace(agent.workspace) : undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the agent profile (title, avatar, description) from the platform
|
||||
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
|
||||
*
|
||||
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
|
||||
* IDENTITY.md for description fallback
|
||||
* - hermes: not yet implemented — returns empty profile
|
||||
*/
|
||||
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
|
||||
const { platform, agentId } = params;
|
||||
|
||||
if (platform === 'openclaw') {
|
||||
return getOpenClawProfile(agentId);
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
// Profile fetch not yet implemented for Hermes — return empty
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
|
||||
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
|
||||
function resolveLhPath(): string {
|
||||
try {
|
||||
return execFileSync('which', ['lh'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return 'lh';
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunHeteroTaskParams {
|
||||
agentId?: string;
|
||||
agentType: RemoteHeterogeneousAgentType;
|
||||
cwd?: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
export interface CancelHeteroTaskParams {
|
||||
signal?: 'SIGINT' | 'SIGKILL' | 'SIGTERM';
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function getHermesPort(): number {
|
||||
const env = process.env.HERMES_GATEWAY_PORT;
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_HERMES_PORT;
|
||||
}
|
||||
|
||||
async function isHermesGatewayRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startHermesGateway(port: number): Promise<void> {
|
||||
const child = spawn('hermes', ['gateway', 'start'], {
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
if (await isHermesGatewayRunning(port)) return;
|
||||
}
|
||||
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
|
||||
}
|
||||
|
||||
async function sendAutoNotify(
|
||||
topicId: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
await client.agentNotify.notify.mutate({
|
||||
agentId,
|
||||
content: text,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to send auto-notify:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal remote hetero task completion to the server so it can publish
|
||||
* `agent_runtime_end` to the gateway WS and close the frontend subscription.
|
||||
* Called on clean process exit (code=0, no signal) — error exits go through
|
||||
* `sendAutoNotify` which writes an error message AND triggers completion via
|
||||
* the `done` flag.
|
||||
*/
|
||||
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
await client.agentNotify.notify.mutate({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to send done signal:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notify protocol injected into the first message of a new hetero-agent session.
|
||||
* Tells the agent how to push updates back to the LobeHub user via `lh notify`.
|
||||
*/
|
||||
function buildNotifyProtocol(lhPath: string, topicId: string): string {
|
||||
return (
|
||||
`## Context: This task was dispatched by LobeHub\n\n` +
|
||||
`This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.\n\n` +
|
||||
`**When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer. Think of it as speaking directly to the user in the chat window.\n\n` +
|
||||
`**What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps. The user cares about outcomes and insights, not your step-by-step mechanics.\n\n` +
|
||||
`## Sending messages back to the user\n\n` +
|
||||
`Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n` +
|
||||
`**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n` +
|
||||
`\`\`\`\n` +
|
||||
`MSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n` +
|
||||
`\`\`\`\n\n` +
|
||||
`**Step 2 — Update the same bubble as you make progress**:\n` +
|
||||
`\`\`\`\n` +
|
||||
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n` +
|
||||
`\`\`\`\n\n` +
|
||||
`**Step 3 — Replace with your complete, final response when done**:\n` +
|
||||
`\`\`\`\n` +
|
||||
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n` +
|
||||
`\`\`\`\n\n` +
|
||||
`Rules:\n` +
|
||||
`- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n` +
|
||||
`- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n` +
|
||||
`- Write what matters to the user — not implementation steps or internal tool calls.\n` +
|
||||
`- Call notify at least once when the task is done, even if there were no intermediate updates.`
|
||||
);
|
||||
}
|
||||
|
||||
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
|
||||
const workDir = cwd || process.cwd();
|
||||
const lhPath = resolveLhPath();
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
// openclaw agent --local is one-shot: each invocation processes one message and exits.
|
||||
// The --session-id links turns into the same conversation history on disk.
|
||||
// Requires the `openclaw` binary to be on PATH with Node >=22.19.
|
||||
const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? 'main';
|
||||
|
||||
// Always inject the notify protocol so openclaw knows how to report results
|
||||
// back to the LobeHub UI — even if the previous turn failed and the session
|
||||
// history was not cleanly committed.
|
||||
const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
|
||||
|
||||
// Kill any existing openclaw process for this topicId before spawning a new one.
|
||||
// openclaw serialises session writes; a concurrent process holding the session
|
||||
// lock will cause the new one to exit with code 1.
|
||||
for (const existing of listTasks()) {
|
||||
if (existing.topicId === topicId && existing.agentType === 'openclaw') {
|
||||
try {
|
||||
process.kill(existing.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
removeTask(existing.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
'openclaw',
|
||||
[
|
||||
'agent',
|
||||
'--agent',
|
||||
openclawAgent,
|
||||
'--session-id',
|
||||
topicId,
|
||||
'--message',
|
||||
enrichedPrompt,
|
||||
'--local',
|
||||
],
|
||||
{
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('Failed to get PID for openclaw process');
|
||||
}
|
||||
child.unref();
|
||||
|
||||
saveTask({
|
||||
agentId,
|
||||
agentType,
|
||||
operationId,
|
||||
pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
});
|
||||
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
|
||||
|
||||
// On exit: notify the server so it can close the frontend gateway WS subscription.
|
||||
// - Abnormal exit (signal or non-zero code): write an error message bubble.
|
||||
// - Clean exit (code=0, no signal): openclaw already sent its final message via
|
||||
// `lh notify`; just send a done signal to publish `agent_runtime_end`.
|
||||
child.on('close', (code, signal) => {
|
||||
removeTask(taskId);
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
// Send error message first, THEN signal done (sequential).
|
||||
// Fire-and-forget both, but ensure done is always sent even if notify fails.
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
} else {
|
||||
// Clean exit — openclaw already sent its final message; just signal done.
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
|
||||
if (!(await isHermesGatewayRunning(port))) {
|
||||
log.info(`Hermes gateway not running on port ${port}, starting...`);
|
||||
await startHermesGateway(port);
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
// pid is 0 for Hermes — the gateway is long-lived and cancellation uses
|
||||
// the HTTP /stop API rather than direct signal delivery.
|
||||
saveTask({
|
||||
agentId,
|
||||
agentType,
|
||||
operationId,
|
||||
pid: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
});
|
||||
log.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
|
||||
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType as string}`);
|
||||
}
|
||||
|
||||
export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<string> {
|
||||
const { signal = 'SIGINT', taskId } = params;
|
||||
const entry = getTask(taskId);
|
||||
|
||||
if (!entry) {
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
if (entry.agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/stop`, {
|
||||
body: JSON.stringify({ operationId: entry.operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
removeTask(taskId);
|
||||
await sendAutoNotify(entry.topicId, taskId, 'Task cancelled', entry.agentId);
|
||||
return JSON.stringify({ taskId });
|
||||
}
|
||||
|
||||
// OpenClaw: kill by PID and let the child's close handler send the notify.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch (err) {
|
||||
// Process already exited — exit handler won't fire; clean up manually.
|
||||
log.warn(
|
||||
`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
removeTask(taskId);
|
||||
await sendAutoNotify(
|
||||
entry.topicId,
|
||||
taskId,
|
||||
'Task already completed or cancelled',
|
||||
entry.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify({ pid: entry.pid, signal, taskId });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from '../utils/logger';
|
||||
import { checkPlatformCapability } from './checkPlatformCapability';
|
||||
import {
|
||||
editLocalFile,
|
||||
globLocalFiles,
|
||||
@@ -8,9 +9,14 @@ import {
|
||||
searchLocalFiles,
|
||||
writeLocalFile,
|
||||
} from './file';
|
||||
import { getAgentProfile } from './getAgentProfile';
|
||||
import { cancelHeteroTask, runHeteroTask } from './heteroTask';
|
||||
import { getCommandOutput, killCommand, runCommand } from './shell';
|
||||
|
||||
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
cancelHeteroTask,
|
||||
checkPlatformCapability,
|
||||
getAgentProfile,
|
||||
editFile: editLocalFile,
|
||||
getCommandOutput,
|
||||
globFiles: globLocalFiles,
|
||||
@@ -19,6 +25,7 @@ const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
listFiles: listLocalFiles,
|
||||
readFile: readLocalFile,
|
||||
runCommand,
|
||||
runHeteroTask,
|
||||
searchFiles: searchLocalFiles,
|
||||
writeFile: writeLocalFile,
|
||||
|
||||
|
||||
@@ -427,6 +427,35 @@ describe('streamAgentEventsViaWebSocket', () => {
|
||||
).rejects.toThrow('Gateway auth failed');
|
||||
});
|
||||
|
||||
it('should reject when websocket onerror fires', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
capturedWs!.onerror?.({ message: 'socket exploded', type: 'error' });
|
||||
|
||||
await expect(promise).rejects.toThrow('Agent gateway WebSocket failed: [object Object]');
|
||||
});
|
||||
|
||||
it('should reject when websocket closes before completion', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
capturedWs!.readyState = MockWebSocket.CLOSED;
|
||||
capturedWs!.onclose?.({ code: 1011, reason: 'gateway shutdown', type: 'close' });
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
'Agent gateway WebSocket closed before completion: [object Object]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve on session_complete', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
|
||||
@@ -187,6 +187,7 @@ export async function streamAgentEventsViaWebSocket(
|
||||
const ctx = createRenderContext();
|
||||
let lastEventId = '';
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let isSettled = false;
|
||||
let jsonPrinted = false;
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -243,6 +244,8 @@ export async function streamAgentEventsViaWebSocket(
|
||||
} else if (!streamOpts.json) {
|
||||
renderEnd(agentEvent);
|
||||
}
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
@@ -266,6 +269,8 @@ export async function streamAgentEventsViaWebSocket(
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
@@ -273,16 +278,25 @@ export async function streamAgentEventsViaWebSocket(
|
||||
|
||||
ws.onerror = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (isSettled) return;
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
resolve();
|
||||
isSettled = true;
|
||||
reject(new Error(`Agent gateway WebSocket failed: ${String(err)}`));
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (isSettled) return;
|
||||
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
isSettled = true;
|
||||
reject(new Error(`Agent gateway WebSocket closed before completion: ${String(event)}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import {
|
||||
copyExternalRuntimeModulesToSource,
|
||||
getExternalRuntimeModulesFilesConfig,
|
||||
} from './external-runtime-deps.config.mjs';
|
||||
import {
|
||||
copyNativeModules,
|
||||
copyNativeModulesToSource,
|
||||
@@ -106,6 +110,7 @@ const config = {
|
||||
*/
|
||||
beforePack: async () => {
|
||||
await copyNativeModulesToSource();
|
||||
await copyExternalRuntimeModulesToSource();
|
||||
|
||||
console.info('📦 Downloading agent-browser binary...');
|
||||
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
|
||||
@@ -251,6 +256,8 @@ const config = {
|
||||
'!node_modules',
|
||||
// Then explicitly include native modules using object form (handles pnpm symlinks)
|
||||
...getNativeModulesFilesConfig(),
|
||||
// Include non-native runtime modules that are intentionally externalized from Vite.
|
||||
...getExternalRuntimeModulesFilesConfig(),
|
||||
],
|
||||
generateUpdatesFilesForAllChannels: true,
|
||||
linux: {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
sharedRendererPlugins,
|
||||
sharedRollupOutput,
|
||||
} from '../../plugins/vite/sharedRendererConfig';
|
||||
import { getExternalDependencies } from './native-deps.config.mjs';
|
||||
import { externalRuntimeModules } from './external-runtime-deps.config.mjs';
|
||||
import { getNativeExternalDependencies } from './native-deps.config.mjs';
|
||||
|
||||
/**
|
||||
* Force `base: '/'` in renderer config. The `electron-vite` preset
|
||||
@@ -99,7 +100,11 @@ const desktopPackageJson = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
const electronRuntimeExternals = ['electron'];
|
||||
const mainProcessRuntimeExternals = [...electronRuntimeExternals, 'node-mac-permissions'];
|
||||
const mainProcessRuntimeExternals = [
|
||||
...electronRuntimeExternals,
|
||||
...externalRuntimeModules,
|
||||
'node-mac-permissions',
|
||||
];
|
||||
|
||||
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
|
||||
|
||||
@@ -113,17 +118,45 @@ export default defineConfig({
|
||||
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
|
||||
external: [
|
||||
...mainProcessRuntimeExternals,
|
||||
...getExternalDependencies(),
|
||||
...getNativeExternalDependencies(),
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
],
|
||||
output: {
|
||||
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
|
||||
// Prevent shared deps from being bundled into index.js to avoid side-effect pollution.
|
||||
// Pattern: when a module is imported by both the main bundle (statically) and a
|
||||
// dynamic-import chunk (lazy loader), rolldown places it in main and makes the
|
||||
// chunk back-reference `require("./index.js")`. Electron's main entry isn't in
|
||||
// Node's CJS cache, so that require recompiles `index.js` from scratch — which
|
||||
// re-runs `new App()` at top-level and triggers `protocol.registerSchemesAsPrivileged`
|
||||
// *after* the app is ready → throw.
|
||||
//
|
||||
// Same root cause as the original `debug` regression fixed in #11827. Isolate
|
||||
// each shared module into its own vendor chunk so both ends reference the vendor
|
||||
// chunk instead of back-referencing main.
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/debug')) {
|
||||
return 'vendor-debug';
|
||||
}
|
||||
|
||||
// Small text/binary detection utilities in file-loaders/utils. Imported by
|
||||
// main (via `sniffBinaryFile`) and potentially by lazy loader chunks.
|
||||
// Explicitly enumerated to avoid catching `parser-utils.ts`, which pulls in
|
||||
// xmldom / yauzl / concat-stream — those belong in docx/pptx loader chunks.
|
||||
if (
|
||||
/packages\/file-loaders\/src\/utils\/(?:detectUtf16|isBinaryContent|isTextReadableFile)\.ts$/.test(
|
||||
id,
|
||||
)
|
||||
) {
|
||||
return 'vendor-file-loaders-utils';
|
||||
}
|
||||
|
||||
// jszip — imported by main (via some static path) AND by the docx loader chunk.
|
||||
// Without this, reading a .docx file throws the protocol re-init error.
|
||||
if (id.includes('node_modules/jszip')) {
|
||||
return 'vendor-jszip';
|
||||
}
|
||||
|
||||
// Split i18n json resources by namespace (ns), not by locale.
|
||||
// Example: ".../resources/locales/zh-CN/common.json?import" -> "locales-common"
|
||||
const normalizedId = id.replaceAll('\\', '/').split('?')[0];
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
copyModulesToSource,
|
||||
getDependenciesForModules,
|
||||
getModuleFilesConfig,
|
||||
} from './module-deps.config.mjs';
|
||||
|
||||
/**
|
||||
* Non-native modules intentionally externalized from the main-process bundle.
|
||||
*
|
||||
* These modules are not native dependencies. They stay external because their
|
||||
* process-level side effects must be owned by one Node runtime module instance.
|
||||
*/
|
||||
export const externalRuntimeModules = ['electron-log'];
|
||||
|
||||
/**
|
||||
* Get all dependencies for runtime external modules.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAllExternalRuntimeDependencies() {
|
||||
return getDependenciesForModules(externalRuntimeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate files config objects for non-native runtime external modules.
|
||||
* @returns {Array<{from: string, to: string, filter: string[]}>}
|
||||
*/
|
||||
export function getExternalRuntimeModulesFilesConfig() {
|
||||
return getModuleFilesConfig(externalRuntimeModules);
|
||||
}
|
||||
|
||||
export async function copyExternalRuntimeModulesToSource() {
|
||||
await copyModulesToSource(externalRuntimeModules, 'runtime external module');
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable no-console */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourceNodeModules = path.join(__dirname, 'node_modules');
|
||||
|
||||
/**
|
||||
* Recursively resolve all dependencies of a module.
|
||||
* @param {string} moduleName - The module to resolve
|
||||
* @param {Set<string>} visited - Set of already visited modules
|
||||
* @param {string} nodeModulesPath - Path to node_modules directory
|
||||
* @returns {Set<string>} Set of all dependencies
|
||||
*/
|
||||
function resolveDependencies(moduleName, visited = new Set(), nodeModulesPath = sourceNodeModules) {
|
||||
if (visited.has(moduleName)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
// Always add the module name first. Workspace and optional platform modules
|
||||
// may not be materialized locally, but they still need stable package rules.
|
||||
visited.add(moduleName);
|
||||
|
||||
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const dependencies = packageJson.dependencies || {};
|
||||
const optionalDependencies = packageJson.optionalDependencies || {};
|
||||
|
||||
for (const dep of Object.keys(dependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
|
||||
for (const dep of Object.keys(optionalDependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable package.json files; electron-builder will surface any
|
||||
// actual missing runtime dependency during packaging or startup.
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transitive dependencies for a set of top-level modules.
|
||||
* @param {string[]} modules
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getDependenciesForModules(modules) {
|
||||
const allDeps = new Set();
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const deps = resolveDependencies(moduleName);
|
||||
for (const dep of deps) {
|
||||
allDeps.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return [...allDeps];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate glob patterns for electron-builder files config.
|
||||
* @param {string[]} modules
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getModuleFilesPatterns(modules) {
|
||||
return getDependenciesForModules(modules).map((dep) => `node_modules/${dep}/**/*`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate object-form electron-builder files config.
|
||||
* Object form is required because pnpm symlinks are resolved before packaging.
|
||||
* @param {string[]} modules
|
||||
* @returns {Array<{from: string, to: string, filter: string[]}>}
|
||||
*/
|
||||
export function getModuleFilesConfig(modules) {
|
||||
return getDependenciesForModules(modules).map((dep) => ({
|
||||
filter: ['**/*'],
|
||||
from: `node_modules/${dep}`,
|
||||
to: `node_modules/${dep}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy module symlinks in source node_modules to real directories so
|
||||
* electron-builder can include them via file rules.
|
||||
* @param {string[]} modules
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function copyModulesToSource(modules, label) {
|
||||
const deps = getDependenciesForModules(modules);
|
||||
|
||||
console.log(`📦 Resolving ${deps.length} ${label} symlinks for packaging...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const modulePath = path.join(sourceNodeModules, dep);
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(modulePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
const realPath = await fs.promises.realpath(modulePath);
|
||||
console.log(` 📎 ${dep} (resolving symlink)`);
|
||||
|
||||
await fs.promises.rm(modulePath, { force: true, recursive: true });
|
||||
await fs.promises.mkdir(path.dirname(modulePath), { recursive: true });
|
||||
await copyDir(realPath, modulePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${label} symlinks resolved`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy modules to a destination node_modules directory, resolving symlinks.
|
||||
* @param {string[]} modules
|
||||
* @param {string} destNodeModules
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function copyModulesToDirectory(modules, destNodeModules, label) {
|
||||
const deps = getDependenciesForModules(modules);
|
||||
|
||||
console.log(`📦 Copying ${deps.length} ${label} to unpacked directory...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const sourcePath = path.join(sourceNodeModules, dep);
|
||||
const destPath = path.join(destNodeModules, dep);
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(sourcePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
const realPath = await fs.promises.realpath(sourcePath);
|
||||
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyDir(realPath, destPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
console.log(` 📁 ${dep}`);
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyDir(sourcePath, destPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${label} copied successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory.
|
||||
* @param {string} src
|
||||
* @param {string} dest
|
||||
*/
|
||||
async function copyDir(src, dest) {
|
||||
await fs.promises.mkdir(dest, { recursive: true });
|
||||
const entries = await fs.promises.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(srcPath, destPath);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
const realPath = await fs.promises.realpath(srcPath);
|
||||
const realStat = await fs.promises.stat(realPath);
|
||||
if (realStat.isDirectory()) {
|
||||
await copyDir(realPath, destPath);
|
||||
} else {
|
||||
await fs.promises.copyFile(realPath, destPath);
|
||||
}
|
||||
} else {
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Native dependencies configuration for Electron build
|
||||
*
|
||||
@@ -9,12 +8,15 @@
|
||||
*
|
||||
* This module automatically resolves the full dependency tree.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
import {
|
||||
copyModulesToDirectory,
|
||||
copyModulesToSource,
|
||||
getDependenciesForModules,
|
||||
getModuleFilesConfig,
|
||||
getModuleFilesPatterns,
|
||||
} from './module-deps.config.mjs';
|
||||
|
||||
/**
|
||||
* Get the current target platform
|
||||
@@ -40,78 +42,20 @@ export const nativeModules = [
|
||||
'node-screenshots',
|
||||
];
|
||||
|
||||
/**
|
||||
* Recursively resolve all dependencies of a module
|
||||
* @param {string} moduleName - The module to resolve
|
||||
* @param {Set<string>} visited - Set of already visited modules (to avoid cycles)
|
||||
* @param {string} nodeModulesPath - Path to node_modules directory
|
||||
* @returns {Set<string>} Set of all dependencies
|
||||
*/
|
||||
function resolveDependencies(
|
||||
moduleName,
|
||||
visited = new Set(),
|
||||
nodeModulesPath = path.join(__dirname, 'node_modules'),
|
||||
) {
|
||||
if (visited.has(moduleName)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
// Always add the module name first (important for workspace dependencies
|
||||
// that may not be in local node_modules but are declared in nativeModules)
|
||||
visited.add(moduleName);
|
||||
|
||||
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
|
||||
|
||||
// If module doesn't exist locally, still keep it in visited but skip dependency resolution
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const dependencies = packageJson.dependencies || {};
|
||||
const optionalDependencies = packageJson.optionalDependencies || {};
|
||||
|
||||
// Resolve regular dependencies
|
||||
for (const dep of Object.keys(dependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
|
||||
// Also resolve optional dependencies (important for native modules like @napi-rs/canvas
|
||||
// which have platform-specific binaries in optional deps)
|
||||
for (const dep of Object.keys(optionalDependencies)) {
|
||||
resolveDependencies(dep, visited, nodeModulesPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading package.json
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for all native modules (including transitive dependencies)
|
||||
* @returns {string[]} Array of all dependency names
|
||||
*/
|
||||
export function getAllDependencies() {
|
||||
const allDeps = new Set();
|
||||
|
||||
for (const nativeModule of nativeModules) {
|
||||
const deps = resolveDependencies(nativeModule);
|
||||
for (const dep of deps) {
|
||||
allDeps.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return [...allDeps];
|
||||
export function getAllNativeDependencies() {
|
||||
return getDependenciesForModules(nativeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate glob patterns for electron-builder files config
|
||||
* @returns {string[]} Array of glob patterns
|
||||
*/
|
||||
export function getFilesPatterns() {
|
||||
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
|
||||
export function getNativeModuleFilesPatterns() {
|
||||
return getModuleFilesPatterns(nativeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,11 +64,7 @@ export function getFilesPatterns() {
|
||||
* @returns {Array<{from: string, to: string, filter: string[]}>}
|
||||
*/
|
||||
export function getNativeModulesFilesConfig() {
|
||||
return getAllDependencies().map((dep) => ({
|
||||
filter: ['**/*'],
|
||||
from: `node_modules/${dep}`,
|
||||
to: `node_modules/${dep}`,
|
||||
}));
|
||||
return getModuleFilesConfig(nativeModules);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,15 +72,15 @@ export function getNativeModulesFilesConfig() {
|
||||
* @returns {string[]} Array of glob patterns
|
||||
*/
|
||||
export function getAsarUnpackPatterns() {
|
||||
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
|
||||
return getNativeModuleFilesPatterns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of native dependencies for Vite external config
|
||||
* @returns {string[]} Array of dependency names
|
||||
*/
|
||||
export function getExternalDependencies() {
|
||||
return getAllDependencies();
|
||||
export function getNativeExternalDependencies() {
|
||||
return getAllNativeDependencies();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,39 +89,7 @@ export function getExternalDependencies() {
|
||||
* included in the asar archive (electron-builder glob doesn't follow symlinks).
|
||||
*/
|
||||
export async function copyNativeModulesToSource() {
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
const deps = getAllDependencies();
|
||||
const sourceNodeModules = path.join(__dirname, 'node_modules');
|
||||
|
||||
console.log(`📦 Resolving ${deps.length} native module symlinks for packaging...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const modulePath = path.join(sourceNodeModules, dep);
|
||||
|
||||
try {
|
||||
const stat = await fsPromises.lstat(modulePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Resolve the symlink to get the real path
|
||||
const realPath = await fsPromises.realpath(modulePath);
|
||||
console.log(` 📎 ${dep} (resolving symlink)`);
|
||||
|
||||
// Remove the symlink
|
||||
await fsPromises.rm(modulePath, { force: true, recursive: true });
|
||||
|
||||
// Create parent directory if needed (for scoped packages like @napi-rs)
|
||||
await fsPromises.mkdir(path.dirname(modulePath), { recursive: true });
|
||||
|
||||
// Copy the actual directory content in place of the symlink
|
||||
await copyDir(realPath, modulePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Module might not exist (optional dependency for different platform)
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Native module symlinks resolved`);
|
||||
await copyModulesToSource(nativeModules, 'native module');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,72 +98,5 @@ export async function copyNativeModulesToSource() {
|
||||
* @param {string} destNodeModules - Destination node_modules path
|
||||
*/
|
||||
export async function copyNativeModules(destNodeModules) {
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
const deps = getAllDependencies();
|
||||
const sourceNodeModules = path.join(__dirname, 'node_modules');
|
||||
|
||||
console.log(`📦 Copying ${deps.length} native modules to unpacked directory...`);
|
||||
|
||||
for (const dep of deps) {
|
||||
const sourcePath = path.join(sourceNodeModules, dep);
|
||||
const destPath = path.join(destNodeModules, dep);
|
||||
|
||||
try {
|
||||
// Check if source exists (might be a symlink)
|
||||
const stat = await fsPromises.lstat(sourcePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Resolve the symlink to get the real path
|
||||
const realPath = await fsPromises.realpath(sourcePath);
|
||||
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
|
||||
|
||||
// Create destination directory
|
||||
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
|
||||
// Copy the actual directory content (not the symlink)
|
||||
await copyDir(realPath, destPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
console.log(` 📁 ${dep}`);
|
||||
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyDir(sourcePath, destPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Module might not exist (optional dependency for different platform)
|
||||
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Native modules copied successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
*/
|
||||
async function copyDir(src, dest) {
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
|
||||
await fsPromises.mkdir(dest, { recursive: true });
|
||||
const entries = await fsPromises.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(srcPath, destPath);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
// For symlinks within the module, resolve and copy the actual file
|
||||
const realPath = await fsPromises.realpath(srcPath);
|
||||
const realStat = await fsPromises.stat(realPath);
|
||||
if (realStat.isDirectory()) {
|
||||
await copyDir(realPath, destPath);
|
||||
} else {
|
||||
await fsPromises.copyFile(realPath, destPath);
|
||||
}
|
||||
} else {
|
||||
await fsPromises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
await copyModulesToDirectory(nativeModules, destNodeModules, 'native modules');
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"dependencies": {
|
||||
"@lobehub/fluent-emoji": "^4.1.0",
|
||||
"@napi-rs/canvas": "^0.1.70",
|
||||
"electron-log": "^5.4.3",
|
||||
"get-windows": "^9.3.0",
|
||||
"node-screenshots": "^0.2.8"
|
||||
},
|
||||
@@ -63,14 +64,12 @@
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
"@types/async-retry": "^1.4.9",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251210.1",
|
||||
"@vanilla-extract/css": "^1.17.4",
|
||||
"@vanilla-extract/vite-plugin": "^5.1.0",
|
||||
"async-retry": "^1.3.3",
|
||||
"consola": "^3.4.2",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
@@ -79,7 +78,6 @@
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
@@ -109,7 +107,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^8.0.9",
|
||||
"vite": "8.0.14",
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
|
||||
|
||||
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
|
||||
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
|
||||
|
||||
@@ -35,7 +35,9 @@ export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
gatewayEnabled: true,
|
||||
gatewayUrl: 'https://device-gateway.lobehub.com',
|
||||
locale: 'auto',
|
||||
localFileWorkspaceRoots: [],
|
||||
networkProxy: defaultProxySettings,
|
||||
pendingRestoreRoute: '',
|
||||
shortcuts: DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
|
||||
storagePath: appStorageDir,
|
||||
themeMode: 'system',
|
||||
|
||||
@@ -21,7 +21,12 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
const MAX_POLL_TIME = 2 * 60 * 1000; // 2 minutes (reduced from 5 minutes for better UX)
|
||||
const POLL_INTERVAL = 3000; // 3 seconds
|
||||
const TOKEN_REFRESH_DEBOUNCE = 5 * 60 * 1000; // 5 minutes - debounce interval to prevent excessive refreshes on rapid app restarts
|
||||
|
||||
// Refresh the access token only once it is within this window of its expiry. Kept
|
||||
// small (minutes) on purpose: a buffer that is large relative to the server's
|
||||
// access-token lifetime makes the token look "expiring soon" right after login,
|
||||
// refreshing on every launch/activation and churning refresh-token rotations.
|
||||
const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
@@ -292,8 +297,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// Check if token is expiring soon (refresh 5 minutes in advance)
|
||||
if (!this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
if (!this.remoteServerConfigCtr.isTokenExpiringSoon(TOKEN_REFRESH_BUFFER)) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
@@ -683,7 +687,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Initialize auto-refresh functionality
|
||||
* Checks for valid token at app startup and starts auto-refresh timer if token exists
|
||||
* Proactively refreshes token on every startup (with 5-minute debounce to prevent rapid restart issues)
|
||||
* Proactively refreshes the token only when it is expired or near expiry
|
||||
*/
|
||||
private async initializeAutoRefresh() {
|
||||
try {
|
||||
@@ -711,26 +715,18 @@ export default class AuthCtr extends ControllerModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Check if token has already expired
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
await this.performProactiveRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Proactively refresh token if it hasn't been refreshed in the last 6 hours
|
||||
// This ensures token validity even if the server has revoked it
|
||||
if (this.shouldProactivelyRefresh()) {
|
||||
logger.info('Token refresh interval exceeded, proactively refreshing token on startup');
|
||||
// Refresh proactively only when the token is actually near expiry. The access
|
||||
// token is long-lived; refreshing on every launch just multiplies refresh-token
|
||||
// rotations — and the chance of a lost-response logout — for no benefit.
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon(TOKEN_REFRESH_BUFFER)) {
|
||||
logger.info('Token is expired or expiring soon, refreshing on startup');
|
||||
await this.performProactiveRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start auto-refresh timer
|
||||
logger.info(
|
||||
`Token is valid and recently refreshed, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
);
|
||||
this.startAutoRefresh();
|
||||
} catch (error) {
|
||||
@@ -738,36 +734,6 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token should be proactively refreshed
|
||||
* Returns true if the token hasn't been refreshed recently (within debounce interval)
|
||||
* This ensures we refresh on every app launch while preventing excessive refreshes on rapid restarts
|
||||
*/
|
||||
private shouldProactivelyRefresh(): boolean {
|
||||
const lastRefreshAt = this.remoteServerConfigCtr.getLastTokenRefreshAt();
|
||||
|
||||
// If never refreshed, should refresh
|
||||
if (!lastRefreshAt) {
|
||||
logger.debug('No last refresh time found, should proactively refresh');
|
||||
return true;
|
||||
}
|
||||
|
||||
const timeSinceLastRefresh = Date.now() - lastRefreshAt;
|
||||
const shouldRefresh = timeSinceLastRefresh >= TOKEN_REFRESH_DEBOUNCE;
|
||||
|
||||
if (shouldRefresh) {
|
||||
logger.debug(
|
||||
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, exceeds ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, within ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold, skipping refresh`,
|
||||
);
|
||||
}
|
||||
|
||||
return shouldRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform proactive token refresh (used on startup and app activation)
|
||||
*/
|
||||
@@ -796,7 +762,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
|
||||
/**
|
||||
* Handle app activation event (e.g., Mac dock click, window focus)
|
||||
* Proactively refresh token if needed (respects 6-hour interval)
|
||||
* Proactively refresh token if it is expired or near expiry
|
||||
*/
|
||||
async onAppActivate(): Promise<void> {
|
||||
logger.debug('App activated, checking if token refresh is needed');
|
||||
@@ -817,12 +783,12 @@ export default class AuthCtr extends ControllerModule {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only refresh if interval has passed
|
||||
if (this.shouldProactivelyRefresh()) {
|
||||
logger.info('Token refresh interval exceeded on app activation, refreshing token');
|
||||
// Refresh only when the token is actually near expiry (see initializeAutoRefresh).
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon(TOKEN_REFRESH_BUFFER)) {
|
||||
logger.info('Token is expiring soon on app activation, refreshing token');
|
||||
await this.performProactiveRefresh();
|
||||
} else {
|
||||
logger.debug('Token was recently refreshed, skipping activation refresh');
|
||||
logger.debug('Token is still valid, skipping activation refresh');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during app activation refresh check:', error);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
|
||||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
|
||||
@@ -9,6 +13,48 @@ import LocalFileCtr from './LocalFileCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
* Tells the agent binary how to push results back to the LobeHub chat UI via `lh notify`.
|
||||
* Ported directly from apps/cli/src/tools/heteroTask.ts so desktop and CLI stay in sync.
|
||||
*/
|
||||
function buildNotifyProtocol(lhPath: string, topicId: string): string {
|
||||
return (
|
||||
`## Context: This task was dispatched by LobeHub\n\n` +
|
||||
`This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.\n\n` +
|
||||
`**When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer.\n\n` +
|
||||
`**What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps.\n\n` +
|
||||
`## Sending messages back to the user\n\n` +
|
||||
`Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n` +
|
||||
`**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n` +
|
||||
`\`\`\`\n` +
|
||||
`MSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n` +
|
||||
`\`\`\`\n\n` +
|
||||
`**Step 2 — Update the same bubble as you make progress**:\n` +
|
||||
`\`\`\`\n` +
|
||||
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n` +
|
||||
`\`\`\`\n\n` +
|
||||
`**Step 3 — Replace with your complete, final response when done**:\n` +
|
||||
`\`\`\`\n` +
|
||||
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n` +
|
||||
`\`\`\`\n\n` +
|
||||
`Rules:\n` +
|
||||
`- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n` +
|
||||
`- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n` +
|
||||
`- Call notify at least once when the task is done, even if there were no intermediate updates.`
|
||||
);
|
||||
}
|
||||
|
||||
interface PlatformTaskEntry {
|
||||
agentId?: string;
|
||||
agentType: string;
|
||||
operationId: string;
|
||||
pid: number;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionCtr
|
||||
*
|
||||
@@ -17,6 +63,9 @@ import ShellCommandCtr from './ShellCommandCtr';
|
||||
export default class GatewayConnectionCtr extends ControllerModule {
|
||||
static override readonly groupName = 'gatewayConnection';
|
||||
|
||||
/** In-memory registry for running platform agent tasks (openclaw / hermes). */
|
||||
private readonly platformTasks = new Map<string, PlatformTaskEntry>();
|
||||
|
||||
// ─── Service Accessor ───
|
||||
|
||||
private get service() {
|
||||
@@ -123,32 +172,25 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
request: AgentRunRequestMessage,
|
||||
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
|
||||
try {
|
||||
const ctr = this.heterogeneousAgentCtr;
|
||||
const serverUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
|
||||
if (!serverUrl) {
|
||||
return { reason: 'Remote server URL not configured', status: 'rejected' };
|
||||
}
|
||||
|
||||
// Create a session for the hetero agent.
|
||||
const { sessionId } = await ctr.startSession({
|
||||
// Fire-and-forget: lh hetero exec handles spawn -> adapt ->
|
||||
// BatchIngester -> heteroIngest/heteroFinish -> server -> Gateway -> clients.
|
||||
// Same command as spawnHeteroSandbox() on the server side.
|
||||
this.heterogeneousAgentCtr.spawnLhHeteroExec({
|
||||
agentType: request.agentType,
|
||||
args: [],
|
||||
command: request.agentType === 'codex' ? 'codex' : 'claude',
|
||||
cwd: request.cwd,
|
||||
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
|
||||
env: { LOBEHUB_JWT: request.jwt },
|
||||
jwt: request.jwt,
|
||||
operationId: request.operationId,
|
||||
prompt: request.prompt,
|
||||
resumeSessionId: request.resumeSessionId,
|
||||
serverUrl,
|
||||
topicId: request.topicId,
|
||||
});
|
||||
|
||||
// Fire-and-forget: sendPrompt runs the CLI until completion.
|
||||
ctr
|
||||
.sendPrompt({
|
||||
operationId: request.operationId,
|
||||
prompt: request.prompt,
|
||||
sessionId,
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// Errors are surfaced via heteroFinish on the server side.
|
||||
// Log locally for desktop debugging only.
|
||||
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
|
||||
});
|
||||
|
||||
return { status: 'accepted' };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
@@ -192,6 +234,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: searchFiles,
|
||||
writeLocalFile: writeFile,
|
||||
|
||||
// Platform agent capability probing
|
||||
checkPlatformCapability: () => this.checkPlatformCapability(args),
|
||||
getAgentProfile: () => this.getAgentProfile(args),
|
||||
|
||||
// Platform agent task execution (openclaw / hermes)
|
||||
cancelHeteroTask: () => this.cancelHeteroTask(args),
|
||||
runHeteroTask: () => this.runHeteroTask(args),
|
||||
};
|
||||
|
||||
const handler = methodMap[apiName];
|
||||
@@ -203,4 +253,331 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
|
||||
return handler();
|
||||
}
|
||||
|
||||
// ─── Platform Capability Probing ───
|
||||
|
||||
private async checkPlatformCapability(args: {
|
||||
platform: string;
|
||||
}): Promise<{ available: boolean; reason?: string; version?: string }> {
|
||||
const { platform } = args;
|
||||
|
||||
const binaryMap: Record<string, string> = {
|
||||
hermes: 'hermes',
|
||||
openclaw: 'openclaw',
|
||||
};
|
||||
|
||||
const binary = binaryMap[platform];
|
||||
if (!binary) {
|
||||
return { available: false, reason: `Unknown platform: ${platform}` };
|
||||
}
|
||||
|
||||
const whichCmd = process.platform === 'win32' ? `where ${binary}` : `which ${binary}`;
|
||||
|
||||
try {
|
||||
execSync(whichCmd, { stdio: 'pipe' });
|
||||
} catch {
|
||||
return { available: false, reason: `${platform} is not installed on this device` };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = execSync(`${binary} --version`, {
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return { available: true, version: raw };
|
||||
} catch {
|
||||
return { available: true };
|
||||
}
|
||||
}
|
||||
|
||||
private async getAgentProfile(args: { agentId?: string; platform: string }): Promise<{
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}> {
|
||||
const { platform, agentId } = args;
|
||||
|
||||
if (platform === 'openclaw') {
|
||||
return this.getOpenClawProfile(agentId);
|
||||
}
|
||||
|
||||
// hermes and unknown platforms: not yet implemented
|
||||
return {};
|
||||
}
|
||||
|
||||
private getOpenClawProfile(agentId?: string): {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
} {
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
let agents: Array<{
|
||||
id: string;
|
||||
identityEmoji?: string;
|
||||
identityName?: string;
|
||||
isDefault?: boolean;
|
||||
workspace?: string;
|
||||
}>;
|
||||
try {
|
||||
agents = JSON.parse(output) as typeof agents;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
const agent = agentId
|
||||
? agents.find((a) => a.id === agentId)
|
||||
: (agents.find((a) => a.isDefault) ?? agents[0]);
|
||||
|
||||
if (!agent) return {};
|
||||
|
||||
const title = agent.identityName || undefined;
|
||||
const avatar = agent.identityEmoji || '🦞';
|
||||
const description = agent.workspace
|
||||
? this.readDescriptionFromWorkspace(agent.workspace)
|
||||
: undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
private readDescriptionFromWorkspace(workspacePath: string): string | undefined {
|
||||
for (const filename of ['IDENTITY.md', 'SOUL.md']) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
if (!match) continue;
|
||||
|
||||
const value = match[1].trim();
|
||||
if (/^[_*((].*[))*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Platform Agent Task Execution ───
|
||||
//
|
||||
// Ported from apps/cli/src/tools/heteroTask.ts so that devices connected via
|
||||
// the desktop gateway can execute openclaw/hermes tasks without requiring `lh connect`.
|
||||
|
||||
private async runHeteroTask(args: {
|
||||
agentId?: string;
|
||||
agentType: string;
|
||||
cwd?: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
taskId: string;
|
||||
topicId: string;
|
||||
}): Promise<string> {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
const lhPath = this.resolveLhPath();
|
||||
const openclawAgent = process.env['OPENCLAW_AGENT_ID'] ?? 'main';
|
||||
|
||||
// Always inject the notify protocol so openclaw knows how to report results
|
||||
// back to the LobeHub UI — even if the previous turn failed and the session
|
||||
// history was not cleanly committed.
|
||||
const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
|
||||
|
||||
// Kill any existing openclaw process for this topicId before spawning a new one.
|
||||
// openclaw serialises session writes; a concurrent process holding the session
|
||||
// lock will cause the new one to exit with code 1.
|
||||
for (const [existingTaskId, entry] of this.platformTasks) {
|
||||
if (entry.topicId === topicId && entry.agentType === 'openclaw') {
|
||||
try {
|
||||
process.kill(entry.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
this.platformTasks.delete(existingTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
'openclaw',
|
||||
[
|
||||
'agent',
|
||||
'--agent',
|
||||
openclawAgent,
|
||||
'--session-id',
|
||||
topicId,
|
||||
'--message',
|
||||
enrichedPrompt,
|
||||
'--local',
|
||||
],
|
||||
{ cwd: workDir, detached: true, env: { ...process.env }, stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for openclaw process');
|
||||
child.unref();
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
this.platformTasks.delete(taskId);
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
|
||||
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
);
|
||||
} else {
|
||||
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
if (!(await this.isHermesRunning(port))) {
|
||||
await this.startHermesGateway(port);
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid: 0, topicId });
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType}`);
|
||||
}
|
||||
|
||||
private async cancelHeteroTask(args: { signal?: string; taskId: string }): Promise<string> {
|
||||
const { signal = 'SIGINT', taskId } = args;
|
||||
const entry = this.platformTasks.get(taskId);
|
||||
|
||||
if (!entry) {
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
if (entry.agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/stop`, {
|
||||
body: JSON.stringify({ operationId: entry.operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
// Hermes gateway may already have stopped; ignore
|
||||
}
|
||||
this.platformTasks.delete(taskId);
|
||||
await this.sendNotify({
|
||||
agentId: entry.agentId,
|
||||
content: 'Task cancelled',
|
||||
role: 'assistant',
|
||||
topicId: entry.topicId,
|
||||
});
|
||||
return JSON.stringify({ taskId });
|
||||
}
|
||||
|
||||
// openclaw: kill by PID; the close handler sends the done signal.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch {
|
||||
this.platformTasks.delete(taskId);
|
||||
await this.sendNotify({
|
||||
agentId: entry.agentId,
|
||||
content: 'Task already completed or cancelled',
|
||||
role: 'assistant',
|
||||
topicId: entry.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ pid: entry.pid, signal, taskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notify message to the server so the frontend receives agent output or
|
||||
* a completion signal. Uses the tRPC agentNotify.notify endpoint directly —
|
||||
* this is the desktop counterpart to `lh notify` used by the CLI path.
|
||||
*/
|
||||
private async sendNotify(params: {
|
||||
agentId?: string;
|
||||
content: string;
|
||||
done?: boolean;
|
||||
role: string;
|
||||
topicId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const [serverUrl, token] = await Promise.all([
|
||||
this.remoteServerConfigCtr.getRemoteServerUrl(),
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/agentNotify.notify`, {
|
||||
body: JSON.stringify({ json: params }),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: openclaw's own `lh notify` calls are the primary channel.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Platform Agent Helpers ───
|
||||
|
||||
private resolveLhPath(): string {
|
||||
try {
|
||||
return execFileSync('which', ['lh'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return 'lh';
|
||||
}
|
||||
}
|
||||
|
||||
private getHermesPort(): number {
|
||||
const env = process.env['HERMES_GATEWAY_PORT'];
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_HERMES_PORT;
|
||||
}
|
||||
|
||||
private async isHermesRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
return (await fetch(`http://localhost:${port}/health`)).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async startHermesGateway(port: number): Promise<void> {
|
||||
const child = spawn('hermes', ['gateway', 'start'], {
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
if (await this.isHermesRunning(port)) return;
|
||||
}
|
||||
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
GitWorkingTreePatch,
|
||||
GitWorkingTreePatches,
|
||||
GitWorkingTreeStatus,
|
||||
SubmoduleWorkingTreePatches,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectRepoType, resolveGitDir } from '@/utils/git';
|
||||
@@ -739,9 +740,73 @@ export default class GitController extends ControllerModule {
|
||||
*
|
||||
* Per-file patches are capped at 256 KB; oversized or binary entries get an
|
||||
* empty `patch` string and a flag the renderer can use for a placeholder.
|
||||
*
|
||||
* Dirty submodules are detected via `git submodule status` and surfaced as
|
||||
* grouped `submodules[]` entries — their internal patches live under each
|
||||
* group, not in the parent's flat `patches` list. Nested submodules are not
|
||||
* traversed (phase 1).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
|
||||
return this.collectWorkingTreePatches(dirPath, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* List paths of initialized submodules registered in `dirPath`. Uninitialized
|
||||
* entries (`-` prefix in `git submodule status`) are skipped — there's no
|
||||
* working tree to inspect for those. Failures (no submodules, shell errors)
|
||||
* return an empty set so callers gracefully fall back to the flat layout.
|
||||
*
|
||||
* Only direct submodules are listed; nested submodules would need
|
||||
* `--recursive` plus a tree-aware renderer we don't have in phase 1.
|
||||
*/
|
||||
private async listSubmodulePaths(dirPath: string): Promise<Set<string>> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['submodule', 'status'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const paths = new Set<string>();
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (line.length < 2) continue;
|
||||
// Status char: ' ' (clean), '+' (modified content), '-' (uninit), 'U' (conflict).
|
||||
if (line[0] === '-') continue;
|
||||
// Format: "<status><sha> <path>[ (<describe>)]". Parse via string ops
|
||||
// rather than a single regex — combining `\s+` separators with a
|
||||
// greedy/lazy path capture trips eslint's ReDoS rule.
|
||||
const rest = line.slice(1);
|
||||
const firstSpace = rest.indexOf(' ');
|
||||
if (firstSpace < 0) continue;
|
||||
const sha = rest.slice(0, firstSpace);
|
||||
if (!/^[\da-f]{7,40}$/.test(sha)) continue;
|
||||
let path = rest.slice(firstSpace + 1);
|
||||
// Drop the trailing ` (<describe>)` suffix when present.
|
||||
if (path.endsWith(')')) {
|
||||
const describeStart = path.lastIndexOf(' (');
|
||||
if (describeStart > 0) path = path.slice(0, describeStart);
|
||||
}
|
||||
if (path) paths.add(path);
|
||||
}
|
||||
return paths;
|
||||
} catch (error: any) {
|
||||
logger.debug('[listSubmodulePaths] failed', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared implementation for working-tree patch collection. The IPC entry
|
||||
* passes `recurseSubmodules: true`; recursive calls into each submodule pass
|
||||
* `false` to avoid traversing nested submodules (phase 1).
|
||||
*/
|
||||
private async collectWorkingTreePatches(
|
||||
dirPath: string,
|
||||
recurseSubmodules: boolean,
|
||||
): Promise<GitWorkingTreePatches> {
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -751,10 +816,19 @@ export default class GitController extends ControllerModule {
|
||||
status: GitFileDiffStatus;
|
||||
}
|
||||
|
||||
// Step 0 — when recursion is enabled, learn which paths in the parent's
|
||||
// status are submodule roots. Their internal diffs are collected separately
|
||||
// (see Step 4) so we filter them out of the parent's flat patch list.
|
||||
const submodulePaths = recurseSubmodules
|
||||
? await this.listSubmodulePaths(dirPath)
|
||||
: new Set<string>();
|
||||
|
||||
// Step 1 — classify every dirty path. Mirrors getGitWorkingTreeFiles but
|
||||
// also distinguishes untracked (`??`) from staged-add (`A`) so we can pick
|
||||
// the right path (git diff vs raw read) per entry.
|
||||
// the right path (git diff vs raw read) per entry. Submodule entries are
|
||||
// siphoned into `submoduleDirtyEntries` for separate recursion in Step 4.
|
||||
const entries: Entry[] = [];
|
||||
const submoduleDirtyEntries: Entry[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
@@ -772,20 +846,27 @@ export default class GitController extends ControllerModule {
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (!filePath) continue;
|
||||
let parsed: Entry | null = null;
|
||||
if (x === '?' && y === '?') {
|
||||
entries.push({ filePath, isUntracked: true, status: 'added' });
|
||||
parsed = { filePath, isUntracked: true, status: 'added' };
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'deleted' });
|
||||
parsed = { filePath, isUntracked: false, status: 'deleted' };
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'added' });
|
||||
parsed = { filePath, isUntracked: false, status: 'added' };
|
||||
} else {
|
||||
entries.push({ filePath, isUntracked: false, status: 'modified' });
|
||||
parsed = { filePath, isUntracked: false, status: 'modified' };
|
||||
}
|
||||
if (!parsed) continue;
|
||||
if (submodulePaths.has(filePath)) {
|
||||
submoduleDirtyEntries.push(parsed);
|
||||
} else {
|
||||
entries.push(parsed);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] status failed', {
|
||||
logger.warn('[collectWorkingTreePatches] status failed', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
@@ -818,7 +899,7 @@ export default class GitController extends ControllerModule {
|
||||
30_000,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] bulk diff failed; per-file fallback', {
|
||||
logger.warn('[collectWorkingTreePatches] bulk diff failed; per-file fallback', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
@@ -855,7 +936,33 @@ export default class GitController extends ControllerModule {
|
||||
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
|
||||
allPatches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { patches: allPatches };
|
||||
// Step 4 — for each dirty submodule, recurse for its own patches + branch.
|
||||
// We only descend one level (`recurseSubmodules: false` on the inner call)
|
||||
// because phase 1's UI groups direct children; nested submodules would
|
||||
// need a tree view we don't have yet. Empty groups (pointer-only bumps)
|
||||
// are kept so the user still sees the submodule surfaced in the panel.
|
||||
let submodules: SubmoduleWorkingTreePatches[] | undefined;
|
||||
if (submoduleDirtyEntries.length > 0) {
|
||||
submodules = await Promise.all(
|
||||
submoduleDirtyEntries.map(async (entry) => {
|
||||
const absolutePath = path.resolve(dirPath, entry.filePath);
|
||||
const [sub, branchInfo] = await Promise.all([
|
||||
this.collectWorkingTreePatches(absolutePath, false),
|
||||
this.getGitBranch(absolutePath),
|
||||
]);
|
||||
return {
|
||||
absolutePath,
|
||||
branch: branchInfo.branch,
|
||||
detached: branchInfo.detached,
|
||||
name: path.basename(entry.filePath),
|
||||
patches: sub.patches,
|
||||
relativePath: entry.filePath,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { patches: allPatches, submodules };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -876,7 +983,23 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranchDiff(payload: GetGitBranchDiffPayload): Promise<GitBranchDiffPatches> {
|
||||
const { path: dirPath, baseRef: baseRefOverride } = payload;
|
||||
return this.collectBranchDiff(payload.path, payload.baseRef, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared implementation for branch-diff collection. The IPC entry passes
|
||||
* `recurseSubmodules: true`; recursive calls into each submodule pass
|
||||
* `false` to avoid traversing nested submodules (phase 1). Each submodule's
|
||||
* base ref is resolved independently — we don't try to derive it from the
|
||||
* parent's base because (a) the parent's submodule pointer may not exist
|
||||
* as a branch ref inside the submodule and (b) "this submodule's branch
|
||||
* vs its own remote default" is what users typically want.
|
||||
*/
|
||||
private async collectBranchDiff(
|
||||
dirPath: string,
|
||||
baseRefOverride: string | undefined,
|
||||
recurseSubmodules: boolean,
|
||||
): Promise<GitBranchDiffPatches> {
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -930,7 +1053,7 @@ export default class GitController extends ControllerModule {
|
||||
30_000,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitBranchDiff] diff failed', {
|
||||
logger.warn('[collectBranchDiff] diff failed', {
|
||||
baseRef,
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
@@ -938,9 +1061,20 @@ export default class GitController extends ControllerModule {
|
||||
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
|
||||
}
|
||||
|
||||
// Step 4 — split + classify per-file from the diff preamble alone.
|
||||
// Step 4 — split per-file. When submodule recursion is enabled, peel out
|
||||
// any pointer-bump entries (block path matches a registered submodule)
|
||||
// into `pointerBumpPaths`; we'll surface those groups unconditionally in
|
||||
// Step 5 even if the submodule's own branch is clean.
|
||||
const submodulePaths = recurseSubmodules
|
||||
? await this.listSubmodulePaths(dirPath)
|
||||
: new Set<string>();
|
||||
const patches: GitWorkingTreePatch[] = [];
|
||||
const pointerBumpPaths = new Set<string>();
|
||||
for (const block of splitBulkDiff(bulkDiff)) {
|
||||
if (submodulePaths.has(block.path)) {
|
||||
pointerBumpPaths.add(block.path);
|
||||
continue;
|
||||
}
|
||||
const status = detectDiffBlockStatus(block.patch);
|
||||
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
|
||||
}
|
||||
@@ -948,7 +1082,42 @@ export default class GitController extends ControllerModule {
|
||||
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
|
||||
patches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { baseRef, headRef, patches };
|
||||
// Step 5 — recurse for EVERY registered submodule (not just those with
|
||||
// pointer-bumps) so we also surface submodules whose own branch diverges
|
||||
// from its own origin/HEAD even when the parent's pointer is unchanged.
|
||||
// Single-level only (`recurseSubmodules: false` on the inner call). A
|
||||
// group is kept when EITHER its pointer changed in the parent OR its own
|
||||
// branch diff has at least one patch; submodules that are clean on both
|
||||
// axes are dropped to keep the panel quiet. Submodule count is expected
|
||||
// to be small (single digits in practice), so per-submodule fetch + diff
|
||||
// in parallel is acceptable.
|
||||
let submodules: SubmoduleWorkingTreePatches[] | undefined;
|
||||
if (submodulePaths.size > 0) {
|
||||
const candidates = await Promise.all(
|
||||
Array.from(submodulePaths).map(async (relativePath) => {
|
||||
const absolutePath = path.resolve(dirPath, relativePath);
|
||||
const [sub, branchInfo] = await Promise.all([
|
||||
this.collectBranchDiff(absolutePath, undefined, false),
|
||||
this.getGitBranch(absolutePath),
|
||||
]);
|
||||
return {
|
||||
group: {
|
||||
absolutePath,
|
||||
branch: branchInfo.branch,
|
||||
detached: branchInfo.detached,
|
||||
name: path.basename(relativePath),
|
||||
patches: sub.patches,
|
||||
relativePath,
|
||||
},
|
||||
keep: pointerBumpPaths.has(relativePath) || sub.patches.length > 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const filtered = candidates.filter((c) => c.keep).map((c) => c.group);
|
||||
if (filtered.length > 0) submodules = filtered;
|
||||
}
|
||||
|
||||
return { baseRef, headRef, patches, submodules };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,11 +24,15 @@ import {
|
||||
buildAgentInput,
|
||||
materializeImageToPath,
|
||||
normalizeImage,
|
||||
resolveCliSpawnPlan,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
|
||||
import type {
|
||||
HeterogeneousAgentBuildPlan,
|
||||
HeterogeneousAgentImageAttachment,
|
||||
} from '@/modules/heterogeneousAgent/types';
|
||||
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
|
||||
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -601,7 +605,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AskUserQuestion MCP server (LOBE-8725) ───
|
||||
// ─── AskUserQuestion MCP server () ───
|
||||
|
||||
/**
|
||||
* Lazy single-instance MCP server for CC's AskUserQuestion replacement.
|
||||
@@ -647,7 +651,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
// `alwaysLoad: true` is the undocumented CC flag that promotes our
|
||||
// server's tool out of the deferred set so the model calls it directly
|
||||
// (no ToolSearch hop). See LOBE-8725 spike notes — falls back to the
|
||||
// (no ToolSearch hop). See spike notes — falls back to the
|
||||
// 2-hop ToolSearch path if a future CC drops the flag, no breakage.
|
||||
const config = {
|
||||
mcpServers: {
|
||||
@@ -868,169 +872,210 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
|
||||
|
||||
logger.info(
|
||||
'Spawning agent:',
|
||||
resolvedCliSpawnPlan.command,
|
||||
resolvedCliSpawnPlan.args.join(' '),
|
||||
`(cwd: ${cwd})`,
|
||||
);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
|
||||
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
|
||||
// the claude binary can leave bash/grep/etc. tool children running and
|
||||
// the CLI hung waiting on them. Windows has different semantics — use
|
||||
// taskkill /T /F there; no detached flag needed.
|
||||
// Forward the user's proxy settings to the CLI. The main-process undici
|
||||
// dispatcher doesn't reach child processes — they need env vars.
|
||||
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
|
||||
|
||||
const spawnOptions = {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
env: { ...process.env, ...proxyEnv, ...session.env },
|
||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
|
||||
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
|
||||
// the claude binary can leave bash/grep/etc. tool children running and
|
||||
// the CLI hung waiting on them. Windows has different semantics — use
|
||||
// taskkill /T /F there; no detached flag needed.
|
||||
// Forward the user's proxy settings to the CLI. The main-process undici
|
||||
// dispatcher doesn't reach child processes — they need env vars.
|
||||
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
|
||||
|
||||
const proc = spawn(session.command, cliArgs, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
env: { ...process.env, ...proxyEnv, ...session.env },
|
||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||
const proc = spawn(resolvedCliSpawnPlan.command, resolvedCliSpawnPlan.args, spawnOptions);
|
||||
this.handleSpawnedAgentProcess({
|
||||
intervention,
|
||||
params,
|
||||
proc,
|
||||
reject,
|
||||
resolve,
|
||||
session,
|
||||
traceSession,
|
||||
useStdin,
|
||||
spawnPlan,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
session.process = proc;
|
||||
|
||||
// Producer-side conversion (V3 contract): JSONL framing + adapter +
|
||||
// toStreamEvent all run inside the shared pipeline, so renderer + future
|
||||
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
|
||||
// no per-consumer adapter. The pipeline auto-wires the Codex
|
||||
// file-change line-stat tracker when `agentType === 'codex'`, so this
|
||||
// controller stays agent-agnostic.
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
operationId: params.operationId,
|
||||
private handleSpawnedAgentProcess({
|
||||
intervention,
|
||||
params,
|
||||
proc,
|
||||
reject,
|
||||
resolve,
|
||||
session,
|
||||
spawnPlan,
|
||||
traceSession,
|
||||
useStdin,
|
||||
}: {
|
||||
intervention?: Awaited<ReturnType<HeterogeneousAgentCtr['setupInterventionForOp']>>;
|
||||
params: SendPromptParams;
|
||||
proc: ChildProcess;
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: () => void;
|
||||
session: AgentSession;
|
||||
spawnPlan: HeterogeneousAgentBuildPlan;
|
||||
traceSession: CliTraceSession | undefined;
|
||||
useStdin: boolean;
|
||||
}) {
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
const events = await produce();
|
||||
// Adapter-extracted CC/Codex session id powers `--resume` on the
|
||||
// next prompt; surface it through the existing `getSessionInfo`
|
||||
// IPC by mirroring the freshest value onto the session record.
|
||||
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
|
||||
session.agentSessionId = pipeline.sessionId;
|
||||
}
|
||||
for (const event of events) {
|
||||
this.broadcast('heteroAgentEvent', {
|
||||
event,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast agent stream batch:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Stream stdout events through the producer pipeline.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastPipelineBatch(() => pipeline.push(chunk));
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastPipelineBatch(() => pipeline.flush());
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
});
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
session.process = proc;
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
});
|
||||
// Producer-side conversion (V3 contract): JSONL framing + adapter +
|
||||
// toStreamEvent all run inside the shared pipeline, so renderer + future
|
||||
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
|
||||
// no per-consumer adapter. The pipeline auto-wires the Codex
|
||||
// file-change line-stat tracker when `agentType === 'codex'`, so this
|
||||
// controller stays agent-agnostic.
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
|
||||
// child_process docs note "stdio streams might still be open" at exit
|
||||
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
|
||||
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
|
||||
// THEN wait for the queue itself to settle. Without this two-step
|
||||
// gate, trailing flushed events (final synthesized tool_end /
|
||||
// tool_result) would race against — and lose to — the
|
||||
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
|
||||
// persistence to finalize on incomplete state.
|
||||
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
|
||||
/* end / close / error are all "done"; we still want to settle. */
|
||||
});
|
||||
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
|
||||
// op. Pending MCP handlers get a `session_ended` cancellation so
|
||||
// they return cleanly even if CC was killed mid-tool-call.
|
||||
if (intervention) {
|
||||
await intervention.cleanup().catch((err) => {
|
||||
logger.warn('AskUserQuestion cleanup error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
const events = await produce();
|
||||
// Adapter-extracted CC/Codex session id powers `--resume` on the
|
||||
// next prompt; surface it through the existing `getSessionInfo`
|
||||
// IPC by mirroring the freshest value onto the session record.
|
||||
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
|
||||
session.agentSessionId = pipeline.sessionId;
|
||||
}
|
||||
for (const event of events) {
|
||||
this.broadcast('heteroAgentEvent', {
|
||||
event,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast agent stream batch:', error);
|
||||
});
|
||||
};
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
// Stream stdout events through the producer pipeline.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastPipelineBatch(() => pipeline.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastPipelineBatch(() => pipeline.flush());
|
||||
});
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Capture stderr
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
|
||||
// child_process docs note "stdio streams might still be open" at exit
|
||||
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
|
||||
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
|
||||
// THEN wait for the queue itself to settle. Without this two-step
|
||||
// gate, trailing flushed events (final synthesized tool_end /
|
||||
// tool_result) would race against — and lose to — the
|
||||
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
|
||||
// persistence to finalize on incomplete state.
|
||||
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
|
||||
/* end / close / error are all "done"; we still want to settle. */
|
||||
});
|
||||
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
|
||||
// op. Pending MCP handlers get a `session_ended` cancellation so
|
||||
// they return cleanly even if CC was killed mid-tool-call.
|
||||
if (intervention) {
|
||||
await intervention.cleanup().catch((err) => {
|
||||
logger.warn('AskUserQuestion cleanup error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1206,4 +1251,69 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
process.on('SIGTERM', onSignal);
|
||||
process.on('SIGINT', onSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `lh hetero exec` for gateway-driven agent runs.
|
||||
* The `lh` CLI handles everything downstream — no local
|
||||
* AgentStreamPipeline or IPC broadcast needed. Mirrors
|
||||
* `spawnHeteroSandbox()` on the server side.
|
||||
*/
|
||||
spawnLhHeteroExec(params: {
|
||||
agentType: string;
|
||||
cwd?: string;
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
serverUrl: string;
|
||||
topicId: string;
|
||||
}): void {
|
||||
const { agentType, cwd, jwt, operationId, prompt, resumeSessionId, serverUrl, topicId } =
|
||||
params;
|
||||
const workDir = cwd ?? process.cwd();
|
||||
|
||||
const args = [
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
agentType,
|
||||
'--operation-id',
|
||||
operationId,
|
||||
'--topic',
|
||||
topicId,
|
||||
'--render',
|
||||
'none',
|
||||
'--input-json',
|
||||
'-',
|
||||
'--cwd',
|
||||
workDir,
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...buildProxyEnv(this.app.storeManager.get('networkProxy')),
|
||||
LOBEHUB_JWT: jwt,
|
||||
LOBEHUB_SERVER: serverUrl,
|
||||
};
|
||||
|
||||
logger.info('spawnLhHeteroExec: type=%s op=%s topic=%s', agentType, operationId, topicId);
|
||||
|
||||
const child = spawn('lh', args, {
|
||||
cwd: workDir,
|
||||
env,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(prompt));
|
||||
child.stdin.end();
|
||||
|
||||
child.on('error', (err) => {
|
||||
logger.error('spawnLhHeteroExec: spawn failed — %s', err.message);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
logger.info('spawnLhHeteroExec: exited — op=%s code=%s signal=%s', operationId, code, signal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
type GrepContentParams,
|
||||
type GrepContentResult,
|
||||
type ListLocalFileParams,
|
||||
type ListProjectSkillsParams,
|
||||
type ListProjectSkillsResult,
|
||||
type LocalFilePreviewUrlParams,
|
||||
type LocalFilePreviewUrlResult,
|
||||
type LocalMoveFilesResultItem,
|
||||
type LocalReadFileParams,
|
||||
type LocalReadFileResult,
|
||||
@@ -39,17 +43,18 @@ import {
|
||||
import {
|
||||
editLocalFile,
|
||||
expandTilde,
|
||||
type FileResult,
|
||||
listLocalFiles,
|
||||
moveLocalFiles,
|
||||
readLocalFile,
|
||||
renameLocalFile,
|
||||
type SearchOptions,
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { execa } from 'execa';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
import ContentSearchService from '@/services/contentSearchSrv';
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -118,6 +123,62 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
// Cap recursion to guard against pathological directory trees.
|
||||
const MAX_SKILL_FILE_COUNT = 1000;
|
||||
|
||||
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
|
||||
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
|
||||
const current = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(toPosixRelativePath(path.relative(dir, full)));
|
||||
if (results.length >= MAX_SKILL_FILE_COUNT) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort();
|
||||
};
|
||||
|
||||
// Parse a minimal YAML frontmatter block for SKILL.md files.
|
||||
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
|
||||
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
|
||||
const match = raw.match(SKILL_FRONTMATTER_RE);
|
||||
if (!match) return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
if (!key || key.startsWith('#')) continue;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if (value.startsWith('|') || value.startsWith('>')) continue;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
@@ -370,6 +431,28 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreviewUrl({
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
|
||||
try {
|
||||
const url = await this.app.localFileProtocolManager.createPreviewUrl({
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
return { error: 'File is outside the approved workspace', success: false };
|
||||
}
|
||||
|
||||
return { success: true, url };
|
||||
} catch (error) {
|
||||
logger.error('Failed to create local file preview URL:', error);
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handlePrepareSkillDirectory({
|
||||
forceRefresh,
|
||||
@@ -532,6 +615,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
requestedScope,
|
||||
root,
|
||||
});
|
||||
await this.approveProjectRootForPreview(root);
|
||||
|
||||
return {
|
||||
entries,
|
||||
@@ -560,6 +644,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
engine: fallback.engine,
|
||||
requestedScope,
|
||||
});
|
||||
await this.approveProjectRootForPreview(requestedScope);
|
||||
|
||||
return {
|
||||
entries,
|
||||
@@ -570,6 +655,61 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan agent skill directories under the project root and return parsed
|
||||
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
|
||||
* to surface skills available in the current project.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
for (const source of sources) {
|
||||
const dir = path.join(root, source);
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const skills = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(dir, entry.name);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
try {
|
||||
const raw = await readFile(skillFile, 'utf8');
|
||||
const fields = parseSkillFrontmatter(raw);
|
||||
const files = await listSkillFilesRecursive(skillDir);
|
||||
return {
|
||||
description: fields.description || undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
name: fields.name || entry.name,
|
||||
path: skillFile,
|
||||
skillDir,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (skills.length > 0) {
|
||||
await this.approveProjectRootForPreview(root);
|
||||
return { root, skills, source };
|
||||
}
|
||||
} catch {
|
||||
// Directory does not exist or is not readable; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return { root, skills: [], source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@@ -641,4 +781,12 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
logger.debug(`Editing file ${params.file_path}`, { replace_all: params.replace_all });
|
||||
return editLocalFile(params);
|
||||
}
|
||||
|
||||
private async approveProjectRootForPreview(root: string) {
|
||||
try {
|
||||
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve project preview root ${root}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
DetectAppsResult,
|
||||
OpenInAppParams,
|
||||
OpenInAppResult,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { getCachedDetection } from '@/modules/openInApp/cache';
|
||||
import { detectApp } from '@/modules/openInApp/detectors';
|
||||
import { launchApp } from '@/modules/openInApp/launchers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:OpenInAppCtr');
|
||||
|
||||
export default class OpenInAppCtr extends ControllerModule {
|
||||
static override readonly groupName = 'openInApp';
|
||||
|
||||
@IpcMethod()
|
||||
async detectApps(): Promise<DetectAppsResult> {
|
||||
const apps = await getCachedDetection();
|
||||
return { apps };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openInApp({ appId, path }: OpenInAppParams): Promise<OpenInAppResult> {
|
||||
// Re-validate installation status before launching: per spec, the main
|
||||
// process must reject if the app disappeared between probe and launch.
|
||||
const installed = await detectApp(appId, process.platform);
|
||||
if (!installed) {
|
||||
logger.warn(`openInApp: ${appId} reported not installed`);
|
||||
return { error: `${appId} is not installed`, success: false };
|
||||
}
|
||||
|
||||
const result = await launchApp(appId, path, process.platform);
|
||||
if (result.success) {
|
||||
logger.info(`openInApp: launched ${appId} with path ${path}`);
|
||||
} else {
|
||||
logger.error(`openInApp: launch failed for ${appId}: ${result.error}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import querystring from 'node:querystring';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import retry from 'async-retry';
|
||||
import { safeStorage, session as electronSession } from 'electron';
|
||||
|
||||
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
|
||||
@@ -378,10 +377,8 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token with retry mechanism
|
||||
* Use stored refresh token to obtain a new access token
|
||||
* Handles concurrent requests by returning the existing refresh promise if one is in progress.
|
||||
* Retries up to 3 times with exponential backoff for transient errors.
|
||||
* Refresh the access token using the stored refresh token (single attempt).
|
||||
* Concurrent callers share the in-progress refresh promise.
|
||||
*/
|
||||
async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
|
||||
// If a refresh is already in progress, return the existing promise
|
||||
@@ -390,60 +387,18 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
// Start a new refresh operation with retry
|
||||
logger.info('Initiating new token refresh operation with retry.');
|
||||
this.refreshPromise = this.performTokenRefreshWithRetry();
|
||||
logger.info('Initiating new token refresh operation.');
|
||||
|
||||
// Return the promise so callers can wait
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs token refresh with retry mechanism
|
||||
* Uses exponential backoff: 1s, 2s, 4s
|
||||
*/
|
||||
private async performTokenRefreshWithRetry(): Promise<{ error?: string; success: boolean }> {
|
||||
try {
|
||||
return await retry(
|
||||
async (bail, attemptNumber) => {
|
||||
logger.debug(`Token refresh attempt ${attemptNumber}/3`);
|
||||
|
||||
const result = await this.performTokenRefresh();
|
||||
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if error is non-retryable
|
||||
if (this.isNonRetryableError(result.error)) {
|
||||
logger.warn(`Non-retryable error encountered: ${result.error}`);
|
||||
// Use bail to stop retrying immediately
|
||||
bail(new Error(result.error));
|
||||
return result; // This won't be reached, but TypeScript needs it
|
||||
}
|
||||
|
||||
// Throw error to trigger retry for transient errors
|
||||
throw new Error(result.error);
|
||||
},
|
||||
{
|
||||
factor: 2, // Exponential backoff factor
|
||||
maxTimeout: 4000, // Max wait time between retries: 4s
|
||||
minTimeout: 1000, // Min wait time between retries: 1s
|
||||
onRetry: (err: Error, attempt: number) => {
|
||||
logger.info(`Token refresh retry ${attempt}/3: ${err.message}`);
|
||||
},
|
||||
retries: 3, // Total retry attempts
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Token refresh failed after all retries:', errorMessage);
|
||||
return { error: errorMessage, success: false };
|
||||
} finally {
|
||||
// Ensure the promise reference is cleared once the operation completes
|
||||
// No retry: with refresh token rotation the server consumes the old token as soon
|
||||
// as the request lands. Resending it (e.g. after a lost response) triggers reuse
|
||||
// detection — invalid_grant + revocation of the whole grant — which logs the user
|
||||
// out. Transient failures are recovered by the next refresh cycle instead.
|
||||
this.refreshPromise = this.performTokenRefresh().finally(() => {
|
||||
logger.debug('Clearing the refresh promise reference.');
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -186,6 +186,19 @@ export default class SystemController extends ControllerModule {
|
||||
const folderPath = result.filePaths[0];
|
||||
const repoType = await detectRepoType(folderPath);
|
||||
|
||||
try {
|
||||
const approvedRoot = await this.app.localFileProtocolManager.approveWorkspaceRoot(folderPath);
|
||||
|
||||
if (approvedRoot) {
|
||||
const storedRoots = this.app.storeManager.get('localFileWorkspaceRoots', []);
|
||||
if (!storedRoots.includes(approvedRoot)) {
|
||||
this.app.storeManager.set('localFileWorkspaceRoots', [approvedRoot, ...storedRoots]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve local file workspace root ${folderPath}:`, error);
|
||||
}
|
||||
|
||||
return { path: folderPath, repoType };
|
||||
}
|
||||
|
||||
|
||||
@@ -721,10 +721,7 @@ describe('AuthCtr', () => {
|
||||
});
|
||||
|
||||
describe('Proactive Token Refresh', () => {
|
||||
const FIVE_MINUTES = 5 * 60 * 1000; // Debounce interval
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks for proactive refresh tests
|
||||
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerConfig).mockResolvedValue({
|
||||
active: true,
|
||||
remoteServerUrl: 'https://lobehub-cloud.com',
|
||||
@@ -733,22 +730,16 @@ describe('AuthCtr', () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(true);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue('mock-access-token');
|
||||
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
|
||||
Date.now() + 3600000, // Token valid for 1 hour
|
||||
Date.now() + 7 * 24 * 60 * 60 * 1000, // Token valid for 7 days
|
||||
);
|
||||
// Reset getLastTokenRefreshAt to a recent value by default
|
||||
// Individual tests will override this as needed
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(Date.now());
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ success: true });
|
||||
vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('onAppActivate', () => {
|
||||
it('should refresh token when last refresh was more than 5 minutes ago', async () => {
|
||||
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
it('should refresh token when it is expiring soon', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
@@ -756,33 +747,27 @@ describe('AuthCtr', () => {
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('tokenRefreshed');
|
||||
});
|
||||
|
||||
it('should NOT refresh token when last refresh was within 5 minutes', async () => {
|
||||
// Last refresh was 2 minutes ago (within 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
it('should NOT refresh token when it is not expiring soon', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh token when lastRefreshAt is undefined (never refreshed)', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(undefined);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
it('should check expiry with a small buffer, not the 24h default', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
const [buffer] = vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mock.calls[0];
|
||||
expect(buffer).toBeGreaterThan(0);
|
||||
expect(buffer).toBeLessThanOrEqual(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('should skip refresh when remote server is not active', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(false);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
@@ -791,19 +776,15 @@ describe('AuthCtr', () => {
|
||||
|
||||
it('should skip refresh when no access token exists', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle refresh failure with non-retryable error', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
it('should clear tokens and require re-auth on non-retryable error', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
error: 'invalid_grant',
|
||||
success: false,
|
||||
@@ -819,10 +800,8 @@ describe('AuthCtr', () => {
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
|
||||
it('should handle refresh failure with transient error (start auto-refresh)', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
it('should preserve tokens on transient error', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
error: 'network_error',
|
||||
success: false,
|
||||
@@ -831,90 +810,49 @@ describe('AuthCtr', () => {
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
// Should not clear tokens for transient errors
|
||||
expect(mockRemoteServerConfigCtr.clearTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady (initializeAutoRefresh)', () => {
|
||||
it('should proactively refresh token on startup when debounce interval exceeded', async () => {
|
||||
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
it('should proactively refresh on startup when token is expiring soon', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT refresh on startup when token was recently refreshed (within debounce)', async () => {
|
||||
// Last refresh was 2 minutes ago (within 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
it('should NOT refresh on startup when token is not expiring soon', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh on startup when token is expired regardless of last refresh time', async () => {
|
||||
// Token expired 1 hour ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
|
||||
Date.now() - 60 * 60 * 1000,
|
||||
);
|
||||
// Last refresh was 2 minutes ago (within debounce, but token is expired)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
it('should check expiry with a small buffer, not the 24h default', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
const [buffer] = vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mock.calls[0];
|
||||
expect(buffer).toBeGreaterThan(0);
|
||||
expect(buffer).toBeLessThanOrEqual(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh debounce boundary tests', () => {
|
||||
it('should NOT refresh at exactly 5 minutes minus 1 second', async () => {
|
||||
// Last refresh was 4 minutes 59 seconds ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - (FIVE_MINUTES - 1000),
|
||||
);
|
||||
it('should skip initialization when no access token exists', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null);
|
||||
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
authCtr.afterAppReady();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh at exactly 5 minutes', async () => {
|
||||
// Last refresh was exactly 5 minutes ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - FIVE_MINUTES,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { execSync as ExecSyncType } from 'node:child_process';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
|
||||
import GatewayConnectionCtr from '../GatewayConnectionCtr';
|
||||
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from '../ShellCommandCtr';
|
||||
@@ -31,6 +34,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
sendToolCallResponse = vi.fn();
|
||||
sendAgentRunAck = vi.fn();
|
||||
|
||||
constructor(options: any) {
|
||||
super();
|
||||
@@ -71,6 +75,22 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
this.emit('error', new Error(message));
|
||||
}
|
||||
|
||||
simulateAgentRunRequest(
|
||||
agentType: string,
|
||||
operationId = 'op-1',
|
||||
prompt = 'hello',
|
||||
jwt = 'mock-jwt',
|
||||
) {
|
||||
this.emit('agent_run_request', {
|
||||
agentType,
|
||||
jwt,
|
||||
operationId,
|
||||
prompt,
|
||||
topicId: 'topic-1',
|
||||
type: 'agent_run_request',
|
||||
});
|
||||
}
|
||||
|
||||
simulateReconnecting(delay: number) {
|
||||
this.connectionStatus = 'reconnecting';
|
||||
this.emit('status_changed', 'reconnecting');
|
||||
@@ -89,6 +109,10 @@ vi.mock('electron', () => ({
|
||||
getPath: vi.fn((name: string) => `/mock/${name}`),
|
||||
},
|
||||
ipcMain: { handle: ipcMainHandleMock },
|
||||
powerSaveBlocker: {
|
||||
start: vi.fn(() => 1),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
@@ -119,6 +143,15 @@ vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'mock-device-uuid'),
|
||||
}));
|
||||
|
||||
const execSyncMock = vi.hoisted(() => vi.fn());
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<{ execSync: typeof ExecSyncType }>();
|
||||
return { ...actual, execFileSync: execFileSyncMock, execSync: execSyncMock, spawn: spawnMock };
|
||||
});
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: { hostname: vi.fn(() => 'mock-hostname') },
|
||||
}));
|
||||
@@ -127,6 +160,13 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
GatewayClient: MockGatewayClient,
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('fast-glob', () => ({ default: vi.fn().mockResolvedValue([]) }));
|
||||
vi.mock('fflate', () => ({ unzipSync: vi.fn() }));
|
||||
|
||||
// ─── Mock Controllers ───
|
||||
|
||||
const mockLocalFileCtr = {
|
||||
@@ -158,8 +198,15 @@ const mockShellCommandCtr = {
|
||||
handleRunCommand: vi.fn().mockResolvedValue({ success: true, stdout: '' }),
|
||||
} as unknown as ShellCommandCtr;
|
||||
|
||||
const mockHeterogeneousAgentCtr = {
|
||||
sendPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
spawnLhHeteroExec: vi.fn(),
|
||||
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
|
||||
} as unknown as HeterogeneousAgentCtr;
|
||||
|
||||
const mockRemoteServerConfigCtr = {
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
} as unknown as RemoteServerConfigCtr;
|
||||
@@ -174,6 +221,7 @@ const mockApp = {
|
||||
if (Cls === RemoteServerConfigCtr) return mockRemoteServerConfigCtr;
|
||||
if (Cls === LocalFileCtr) return mockLocalFileCtr;
|
||||
if (Cls === ShellCommandCtr) return mockShellCommandCtr;
|
||||
if (Cls === HeterogeneousAgentCtr) return mockHeterogeneousAgentCtr;
|
||||
return null;
|
||||
}),
|
||||
getService: vi.fn((Cls) => {
|
||||
@@ -573,6 +621,346 @@ describe('GatewayConnectionCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agent Run Routing ───
|
||||
|
||||
describe('agent run routing', () => {
|
||||
async function connectAndOpen() {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockClear();
|
||||
});
|
||||
|
||||
it.each(['openclaw', 'hermes', 'codex', 'claude-code'] as const)(
|
||||
'forwards agentType "%s" to spawnLhHeteroExec',
|
||||
async (agentType) => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest(agentType);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentType }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('sends accepted ack and spawns lh hetero exec', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-xyz');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
|
||||
operationId: 'op-xyz',
|
||||
status: 'accepted',
|
||||
});
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentType: 'openclaw',
|
||||
jwt: 'mock-jwt',
|
||||
operationId: 'op-xyz',
|
||||
prompt: 'hello',
|
||||
serverUrl: 'https://server.example.com',
|
||||
topicId: 'topic-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends rejected ack when remote server URL is not configured', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerUrl).mockResolvedValueOnce('');
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-fail');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
|
||||
operationId: 'op-fail',
|
||||
reason: 'Remote server URL not configured',
|
||||
status: 'rejected',
|
||||
});
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends rejected ack when spawnLhHeteroExec throws', async () => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockImplementationOnce(() => {
|
||||
throw new Error('binary not found');
|
||||
});
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-fail');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
|
||||
operationId: 'op-fail',
|
||||
reason: 'binary not found',
|
||||
status: 'rejected',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── runHeteroTask ───
|
||||
|
||||
describe('runHeteroTask', () => {
|
||||
/** Creates a minimal mock child process returned by spawn(). */
|
||||
function makeMockChild(pid = 9999) {
|
||||
const listeners: Record<string, Array<(...a: any[]) => void>> = {};
|
||||
return {
|
||||
on: vi.fn((event: string, cb: (...a: any[]) => void) => {
|
||||
listeners[event] = listeners[event] ?? [];
|
||||
listeners[event].push(cb);
|
||||
}),
|
||||
pid,
|
||||
unref: vi.fn(),
|
||||
_emit: (event: string, ...args: any[]) => listeners[event]?.forEach((cb) => cb(...args)),
|
||||
};
|
||||
}
|
||||
|
||||
async function connectAndOpen() {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileSyncMock.mockReturnValue('/usr/local/bin/lh\n');
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
it('always injects buildNotifyProtocol into the prompt', async () => {
|
||||
const child = makeMockChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'runHeteroTask',
|
||||
{
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'hello',
|
||||
taskId: 'task-1',
|
||||
topicId: 'topic-1',
|
||||
},
|
||||
'req-run',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
|
||||
const messageArg = spawnArgs[spawnArgs.indexOf('--message') + 1];
|
||||
expect(messageArg).toContain('hello');
|
||||
expect(messageArg).toContain('lh notify');
|
||||
});
|
||||
|
||||
it('kills an existing concurrent openclaw process for the same topicId before spawning', async () => {
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
// First task
|
||||
const child1 = makeMockChild(1111);
|
||||
spawnMock.mockReturnValueOnce(child1);
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'runHeteroTask',
|
||||
{
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'msg1',
|
||||
taskId: 'task-1',
|
||||
topicId: 'topic-same',
|
||||
},
|
||||
'req-1',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Second task for same topicId — should kill task-1's pid first
|
||||
const child2 = makeMockChild(2222);
|
||||
spawnMock.mockReturnValueOnce(child2);
|
||||
client.simulateToolCallRequest(
|
||||
'runHeteroTask',
|
||||
{
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-2',
|
||||
prompt: 'msg2',
|
||||
taskId: 'task-2',
|
||||
topicId: 'topic-same',
|
||||
},
|
||||
'req-2',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(1111, 'SIGTERM');
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not kill processes for a different topicId', async () => {
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const child1 = makeMockChild(3333);
|
||||
spawnMock.mockReturnValueOnce(child1);
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'runHeteroTask',
|
||||
{
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-1',
|
||||
prompt: 'a',
|
||||
taskId: 'task-a',
|
||||
topicId: 'topic-A',
|
||||
},
|
||||
'req-a',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const child2 = makeMockChild(4444);
|
||||
spawnMock.mockReturnValueOnce(child2);
|
||||
client.simulateToolCallRequest(
|
||||
'runHeteroTask',
|
||||
{
|
||||
agentType: 'openclaw',
|
||||
operationId: 'op-2',
|
||||
prompt: 'b',
|
||||
taskId: 'task-b',
|
||||
topicId: 'topic-B',
|
||||
},
|
||||
'req-b',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Platform Capability Probing ───
|
||||
|
||||
describe('platform capability probing', () => {
|
||||
async function connectAndOpen() {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execSyncMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns available:true with version when binary is installed', async () => {
|
||||
execSyncMock.mockImplementation((cmd: string) => {
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where '))
|
||||
return '/usr/local/bin/openclaw\n';
|
||||
if (cmd.includes('--version')) return 'openclaw 1.2.3\n';
|
||||
return '';
|
||||
});
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'checkPlatformCapability',
|
||||
{ platform: 'openclaw' },
|
||||
'req-cap',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-cap',
|
||||
result: {
|
||||
content: JSON.stringify({ available: true, version: 'openclaw 1.2.3' }),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns available:true without version when --version command fails', async () => {
|
||||
execSyncMock.mockImplementation((cmd: string) => {
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where '))
|
||||
return '/usr/local/bin/openclaw\n';
|
||||
throw new Error('version command failed');
|
||||
});
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'checkPlatformCapability',
|
||||
{ platform: 'openclaw' },
|
||||
'req-cap-nover',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-cap-nover',
|
||||
result: {
|
||||
content: JSON.stringify({ available: true }),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns available:false when binary is not installed', async () => {
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error('command not found');
|
||||
});
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'checkPlatformCapability',
|
||||
{ platform: 'openclaw' },
|
||||
'req-missing',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-missing',
|
||||
result: {
|
||||
content: JSON.stringify({
|
||||
available: false,
|
||||
reason: 'openclaw is not installed on this device',
|
||||
}),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns available:false for unknown platform', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest(
|
||||
'checkPlatformCapability',
|
||||
{ platform: 'unknownBot' },
|
||||
'req-unknown-plat',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-unknown-plat',
|
||||
result: {
|
||||
content: JSON.stringify({ available: false, reason: 'Unknown platform: unknownBot' }),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('getAgentProfile returns empty object', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateToolCallRequest('getAgentProfile', { platform: 'openclaw' }, 'req-profile');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-profile',
|
||||
result: {
|
||||
content: JSON.stringify({}),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IPC Methods ───
|
||||
|
||||
describe('getConnectionStatus', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof os>('node:os');
|
||||
return { ...actual, platform: vi.fn(() => 'linux') };
|
||||
});
|
||||
|
||||
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
|
||||
|
||||
const { mockGetAllWindows } = vi.hoisted(() => ({
|
||||
@@ -111,7 +116,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
let appStoragePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
appStoragePath = await mkdtemp(path.join(tmpdir(), 'lobehub-hetero-'));
|
||||
appStoragePath = await mkdtemp(path.join(os.tmpdir(), 'lobehub-hetero-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -712,7 +717,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
* `stdout.on('end')` handler can schedule `pipeline.flush()` onto the
|
||||
* broadcast queue), then drain the queue, then broadcast complete.
|
||||
*/
|
||||
describe('exit-before-end ordering (LOBE-8516 phase 0 race)', () => {
|
||||
describe('exit-before-end ordering (phase 0 race)', () => {
|
||||
let broadcasts: Array<{ channel: string; data: any }>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -803,7 +808,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-quit cleanup of AskUserQuestion temp configs (LOBE-8725)', () => {
|
||||
describe('app-quit cleanup of AskUserQuestion temp configs ()', () => {
|
||||
// The async exit-handler cleanup races Electron's main-process teardown
|
||||
// and used to leak `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` on
|
||||
// every quit. The controller now unlinks pending intervention temp
|
||||
@@ -817,7 +822,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
* it like a real pending intervention and tries to unlink it.
|
||||
*/
|
||||
const seedPendingIntervention = async (ctr: HeterogeneousAgentCtr, opId: string) => {
|
||||
const tmpConfigPath = path.join(tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
|
||||
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
|
||||
await writeFile(tmpConfigPath, '{"mcpServers":{}}');
|
||||
const slot = {
|
||||
bridge: {} as any,
|
||||
|
||||
@@ -84,6 +84,12 @@ const mockContentSearchService = {
|
||||
checkToolAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLocalFileProtocolManager = {
|
||||
approveIndexedProjectRoot: vi.fn(),
|
||||
approveProjectRootFromScope: vi.fn(),
|
||||
createPreviewUrl: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock makeSureDirExist
|
||||
vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: vi.fn(),
|
||||
@@ -98,6 +104,7 @@ const mockApp = {
|
||||
}
|
||||
return mockSearchService;
|
||||
}),
|
||||
localFileProtocolManager: mockLocalFileProtocolManager,
|
||||
toolDetectorManager: {
|
||||
getBestTool: vi.fn(() => null), // No external tools available, use Node.js fallback
|
||||
},
|
||||
@@ -180,6 +187,42 @@ describe('LocalFileCtr', () => {
|
||||
// they exercise real fs + file-loaders without fighting the heavy mocks
|
||||
// this suite needs for execa-driven tools, electron, and the like.
|
||||
|
||||
describe('getLocalFilePreviewUrl', () => {
|
||||
it('should return a main-issued preview URL for an approved workspace file', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
|
||||
'localfile://file/workspace/app.ts?token=abc',
|
||||
);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
path: '/workspace/app.ts',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/workspace/app.ts?token=abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject preview URL creation outside an approved workspace', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(null);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
path: '/Users/alice/.ssh/id_rsa',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'File is outside the approved workspace',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
it('should write file successfully', async () => {
|
||||
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { DetectedApp, OpenInAppResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import OpenInAppCtr from '../OpenInAppCtr';
|
||||
|
||||
const { getCachedDetectionMock, detectAppMock, launchAppMock, ipcHandlers, ipcMainHandleMock } =
|
||||
vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return {
|
||||
detectAppMock: vi.fn(),
|
||||
getCachedDetectionMock: vi.fn(),
|
||||
ipcHandlers: handlers,
|
||||
ipcMainHandleMock: handle,
|
||||
launchAppMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(
|
||||
channel: string,
|
||||
payload?: any,
|
||||
context?: Partial<IpcContext>,
|
||||
): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = {
|
||||
sender: context?.sender ?? ({ id: 'test' } as any),
|
||||
};
|
||||
|
||||
if (payload === undefined) {
|
||||
return handler(fakeEvent);
|
||||
}
|
||||
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/cache', () => ({
|
||||
getCachedDetection: getCachedDetectionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/detectors', () => ({
|
||||
detectApp: detectAppMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/launchers', () => ({
|
||||
launchApp: launchAppMock,
|
||||
}));
|
||||
|
||||
const mockApp = {} as unknown as App;
|
||||
|
||||
describe('OpenInAppCtr', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
new OpenInAppCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('detectApps', () => {
|
||||
it('should call getCachedDetection and return the apps list', async () => {
|
||||
const apps: DetectedApp[] = [
|
||||
{ displayName: 'Visual Studio Code', id: 'vscode', installed: true },
|
||||
{ displayName: 'Cursor', id: 'cursor', installed: false },
|
||||
];
|
||||
getCachedDetectionMock.mockResolvedValue(apps);
|
||||
|
||||
const result = await invokeIpc('openInApp.detectApps');
|
||||
|
||||
expect(getCachedDetectionMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ apps });
|
||||
});
|
||||
});
|
||||
|
||||
describe('openInApp', () => {
|
||||
it('should launch the app when installed', async () => {
|
||||
detectAppMock.mockResolvedValue(true);
|
||||
const launchResult: OpenInAppResult = { success: true };
|
||||
launchAppMock.mockResolvedValue(launchResult);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'vscode',
|
||||
path: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
|
||||
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/project', process.platform);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should not launch and return error when app is not installed', async () => {
|
||||
detectAppMock.mockResolvedValue(false);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'cursor',
|
||||
path: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('cursor', process.platform);
|
||||
expect(launchAppMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: 'cursor is not installed',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through launch errors when launchApp fails', async () => {
|
||||
detectAppMock.mockResolvedValue(true);
|
||||
const launchResult: OpenInAppResult = {
|
||||
error: 'Path not found: /tmp/missing',
|
||||
success: false,
|
||||
};
|
||||
launchAppMock.mockResolvedValue(launchResult);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'vscode',
|
||||
path: '/tmp/missing',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
|
||||
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/missing', process.platform);
|
||||
expect(result).toEqual(launchResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -535,6 +535,7 @@ describe('RemoteServerConfigCtr', () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Token refresh failed');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle missing tokens in response', async () => {
|
||||
@@ -618,7 +619,7 @@ describe('RemoteServerConfigCtr', () => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle network errors with retry', async () => {
|
||||
it('should not retry after a network error', async () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
||||
@@ -644,9 +645,8 @@ describe('RemoteServerConfigCtr', () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Network error');
|
||||
// With retry mechanism, fetch should be called 4 times (1 initial + 3 retries)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||
}, 15000);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import McpInstallCtr from './McpInstallCtr';
|
||||
import MenuController from './MenuCtr';
|
||||
import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
import NotificationCtr from './NotificationCtr';
|
||||
import OpenInAppCtr from './OpenInAppCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ScreenCaptureCtr from './ScreenCaptureCtr';
|
||||
@@ -37,6 +38,7 @@ export const controllerIpcConstructors = [
|
||||
MenuController,
|
||||
NetworkProxyCtr,
|
||||
NotificationCtr,
|
||||
OpenInAppCtr,
|
||||
RemoteServerConfigCtr,
|
||||
RemoteServerSyncCtr,
|
||||
ScreenCaptureCtr,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { createLogger } from '@/utils/logger';
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
|
||||
import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
@@ -62,6 +63,7 @@ export class App {
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
localFileProtocolManager: LocalFileProtocolManager;
|
||||
toolDetectorManager: ToolDetectorManager;
|
||||
screenCaptureManager: ScreenCaptureManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
@@ -102,6 +104,10 @@ export class App {
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererUrlManager = new RendererUrlManager();
|
||||
this.localFileProtocolManager = new LocalFileProtocolManager();
|
||||
void this.localFileProtocolManager.approveWorkspaceRoots(
|
||||
this.storeManager.get('localFileWorkspaceRoots', []),
|
||||
);
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
@@ -114,6 +120,7 @@ export class App {
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererUrlManager.protocolScheme,
|
||||
this.localFileProtocolManager.protocolScheme,
|
||||
]);
|
||||
|
||||
// load controllers
|
||||
@@ -152,6 +159,10 @@ export class App {
|
||||
// should register before app ready
|
||||
this.rendererUrlManager.configureRendererLoader();
|
||||
|
||||
// Serves arbitrary local files (e.g. project file previews) via
|
||||
// `localfile://` to the renderer. Active in both dev and prod.
|
||||
this.localFileProtocolManager.registerHandler();
|
||||
|
||||
// initialize protocol handlers
|
||||
this.protocolManager.initialize();
|
||||
|
||||
|
||||
@@ -115,9 +115,9 @@ vi.mock('../infrastructure/I18nManager', () => ({
|
||||
|
||||
vi.mock('../infrastructure/StoreManager', () => ({
|
||||
StoreManager: vi.fn().mockImplementation(() => ({
|
||||
get: vi.fn((key) => {
|
||||
if (key === 'storagePath') return '/mock/storage/path';
|
||||
return undefined;
|
||||
get: vi.fn((_key, defaultValue) => {
|
||||
if (_key === 'storagePath') return '/mock/storage/path';
|
||||
return defaultValue;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
})),
|
||||
|
||||
@@ -256,6 +256,26 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a route captured before an update restart. The captured route is
|
||||
* cleared before any navigation decision so a subsequent normal launch never
|
||||
* restores a stale route.
|
||||
*/
|
||||
private consumePendingRestoreRoute(): string {
|
||||
const pendingRestoreRoute = this.app.storeManager.get('pendingRestoreRoute', '');
|
||||
if (pendingRestoreRoute) this.app.storeManager.set('pendingRestoreRoute', '');
|
||||
return pendingRestoreRoute;
|
||||
}
|
||||
|
||||
private resolveMainWindowInitialPath(
|
||||
isOnboardingCompleted: boolean,
|
||||
pendingRestoreRoute: string,
|
||||
): string {
|
||||
if (!isOnboardingCompleted) return '/desktop-onboarding';
|
||||
if (pendingRestoreRoute) return pendingRestoreRoute;
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all browsers when app starts up
|
||||
*/
|
||||
@@ -271,7 +291,11 @@ export class BrowserManager {
|
||||
|
||||
// Dynamically determine initial path for main window
|
||||
if (browser.identifier === BrowsersIdentifiers.app) {
|
||||
const initialPath = isOnboardingCompleted ? '/' : '/desktop-onboarding';
|
||||
const pendingRestoreRoute = this.consumePendingRestoreRoute();
|
||||
const initialPath = this.resolveMainWindowInitialPath(
|
||||
isOnboardingCompleted,
|
||||
pendingRestoreRoute,
|
||||
);
|
||||
browser = {
|
||||
...browser,
|
||||
keepAlive: isLinux ? false : browser.keepAlive,
|
||||
|
||||
@@ -110,6 +110,13 @@ describe('BrowserManager', () => {
|
||||
getController: vi.fn().mockReturnValue({
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
storeManager: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'pendingRestoreRoute') return '';
|
||||
return '';
|
||||
}),
|
||||
set: vi.fn(),
|
||||
},
|
||||
} as unknown as AppCore;
|
||||
|
||||
manager = new BrowserManager(mockApp);
|
||||
@@ -266,6 +273,43 @@ describe('BrowserManager', () => {
|
||||
expect(manager.browsers.has('app')).toBe(true);
|
||||
expect(manager.browsers.has('settings')).toBe(false);
|
||||
});
|
||||
|
||||
it('restores a captured route as the main window initial path', async () => {
|
||||
(mockApp.storeManager.get as any).mockImplementation((key: string) => {
|
||||
if (key === 'pendingRestoreRoute') return '/agent/abc';
|
||||
return '';
|
||||
});
|
||||
|
||||
await manager.initializeBrowsers();
|
||||
|
||||
expect(manager.browsers.get('app')?.options.path).toBe('/agent/abc');
|
||||
});
|
||||
|
||||
it('clears the captured route after consuming it', async () => {
|
||||
(mockApp.storeManager.get as any).mockImplementation((key: string) => {
|
||||
if (key === 'pendingRestoreRoute') return '/agent/abc';
|
||||
return '';
|
||||
});
|
||||
|
||||
await manager.initializeBrowsers();
|
||||
|
||||
expect(mockApp.storeManager.set).toHaveBeenCalledWith('pendingRestoreRoute', '');
|
||||
});
|
||||
|
||||
it('ignores the captured route when onboarding is not completed', async () => {
|
||||
(mockApp.storeManager.get as any).mockImplementation((key: string) => {
|
||||
if (key === 'pendingRestoreRoute') return '/agent/abc';
|
||||
return '';
|
||||
});
|
||||
(mockApp.getController as any).mockReturnValue({
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(false),
|
||||
});
|
||||
|
||||
await manager.initializeBrowsers();
|
||||
|
||||
expect(manager.browsers.get('app')?.options.path).toBe('/desktop-onboarding');
|
||||
expect(mockApp.storeManager.set).toHaveBeenCalledWith('pendingRestoreRoute', '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastToAllWindows', () => {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, protocol } from 'electron';
|
||||
|
||||
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { getExportMimeType } from '../../utils/mime';
|
||||
|
||||
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: false,
|
||||
bypassCSP: false,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true,
|
||||
} as const;
|
||||
|
||||
const logger = createLogger('core:LocalFileProtocolManager');
|
||||
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const EXTRA_MIME_TYPES: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
const getMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
|
||||
};
|
||||
|
||||
const normalizeAbsolutePath = (filePath: string): string | null => {
|
||||
const normalized = path.normalize(filePath);
|
||||
return path.isAbsolute(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
const isPathWithinRoot = (targetPath: string, rootPath: string): boolean => {
|
||||
const relative = path.relative(rootPath, targetPath);
|
||||
return (
|
||||
relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
};
|
||||
|
||||
const buildLocalFileUrl = (absolutePath: string, token: string): string => {
|
||||
const forwardSlashed = absolutePath.replaceAll('\\', '/');
|
||||
const stripped = forwardSlashed.startsWith('/') ? forwardSlashed.slice(1) : forwardSlashed;
|
||||
const encoded = stripped.split('/').map(encodeURIComponent).join('/');
|
||||
const url = new URL(`${LOCAL_FILE_PROTOCOL_SCHEME}://${LOCAL_FILE_PROTOCOL_HOST}/${encoded}`);
|
||||
url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
interface PreviewTokenRecord {
|
||||
expiresAt: number;
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom `localfile://` protocol for project file previews.
|
||||
*
|
||||
* URL shape: `localfile://file/<percent-encoded-absolute-path>?token=<main-issued-token>`
|
||||
* - host is fixed to `file` so the scheme behaves as `standard`
|
||||
* - the absolute path is encoded in the URL pathname
|
||||
* - every request must carry a short-lived token minted by the main process
|
||||
*
|
||||
* Examples:
|
||||
* localfile://file//Users/alice/project/cat.png?token=...
|
||||
* localfile://file/C:/Users/alice/project/cat.png?token=...
|
||||
*/
|
||||
export class LocalFileProtocolManager {
|
||||
private readonly approvedWorkspaceRoots = new Set<string>();
|
||||
|
||||
private readonly indexedProjectRoots = new Set<string>();
|
||||
|
||||
private handlerRegistered = false;
|
||||
|
||||
private readonly previewTokens = new Map<string, PreviewTokenRecord>();
|
||||
|
||||
get protocolScheme() {
|
||||
return {
|
||||
privileges: LOCAL_FILE_PROTOCOL_PRIVILEGES,
|
||||
scheme: LOCAL_FILE_PROTOCOL_SCHEME,
|
||||
};
|
||||
}
|
||||
|
||||
registerHandler() {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
const register = () => {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
protocol.handle(LOCAL_FILE_PROTOCOL_SCHEME, async (request) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.hostname !== LOCAL_FILE_PROTOCOL_HOST) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const resolvedPath = this.resolveFilePath(url.pathname);
|
||||
if (!resolvedPath) {
|
||||
return new Response('Invalid path', { status: 400 });
|
||||
}
|
||||
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
if (!this.hasPreviewToken(token)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const realResolvedPath = normalizeAbsolutePath(await realpath(resolvedPath));
|
||||
if (!realResolvedPath || !this.verifyPreviewToken(token, realResolvedPath)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const fileStat = await stat(realResolvedPath);
|
||||
if (!fileStat.isFile()) {
|
||||
return new Response('Not a file', { status: 404 });
|
||||
}
|
||||
|
||||
const buffer = await readFile(realResolvedPath);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', getMimeType(realResolvedPath));
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
// Local files are immutable from the renderer's perspective for a
|
||||
// single preview session; allow short-lived caching to avoid
|
||||
// re-reading large images during scrolling/refresh.
|
||||
headers.set('Cache-Control', 'private, max-age=60');
|
||||
|
||||
return new Response(buffer, { headers, status: 200 });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
logger.error(`Failed to serve localfile request ${request.url}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
this.handlerRegistered = true;
|
||||
logger.debug(`Registered ${LOCAL_FILE_PROTOCOL_SCHEME}:// handler`);
|
||||
};
|
||||
|
||||
if (app.isReady()) {
|
||||
register();
|
||||
} else {
|
||||
app.whenReady().then(register);
|
||||
}
|
||||
}
|
||||
|
||||
async approveWorkspaceRoot(rootPath: string): Promise<string | null> {
|
||||
const normalizedRoot = normalizeAbsolutePath(rootPath);
|
||||
if (!normalizedRoot) return null;
|
||||
|
||||
const realRoot = normalizeAbsolutePath(await realpath(normalizedRoot));
|
||||
if (!realRoot) return null;
|
||||
|
||||
this.approvedWorkspaceRoots.add(realRoot);
|
||||
return realRoot;
|
||||
}
|
||||
|
||||
async approveWorkspaceRoots(rootPaths: string[] = []): Promise<string[]> {
|
||||
const approvedRoots = await Promise.allSettled(
|
||||
rootPaths.map((rootPath) => this.approveWorkspaceRoot(rootPath)),
|
||||
);
|
||||
|
||||
return approvedRoots
|
||||
.map((result) => (result.status === 'fulfilled' ? result.value : null))
|
||||
.filter((rootPath): rootPath is string => !!rootPath);
|
||||
}
|
||||
|
||||
async approveProjectRootFromScope({
|
||||
projectRoot,
|
||||
requestedScope,
|
||||
}: {
|
||||
projectRoot: string;
|
||||
requestedScope: string;
|
||||
}): Promise<string | null> {
|
||||
const [realProjectRoot, realRequestedScope] = await Promise.all([
|
||||
realpath(projectRoot),
|
||||
realpath(requestedScope),
|
||||
]);
|
||||
const normalizedProjectRoot = normalizeAbsolutePath(realProjectRoot);
|
||||
const normalizedRequestedScope = normalizeAbsolutePath(realRequestedScope);
|
||||
if (!normalizedProjectRoot || !normalizedRequestedScope) return null;
|
||||
|
||||
const scopeIsApproved = [...this.approvedWorkspaceRoots].some(
|
||||
(approvedRoot) =>
|
||||
normalizedRequestedScope === approvedRoot ||
|
||||
isPathWithinRoot(normalizedRequestedScope, approvedRoot),
|
||||
);
|
||||
if (!scopeIsApproved) return null;
|
||||
|
||||
this.approvedWorkspaceRoots.add(normalizedProjectRoot);
|
||||
return normalizedProjectRoot;
|
||||
}
|
||||
|
||||
async approveIndexedProjectRoot(projectRoot: string): Promise<string | null> {
|
||||
const normalizedProjectRoot = normalizeAbsolutePath(projectRoot);
|
||||
if (!normalizedProjectRoot) return null;
|
||||
|
||||
const realProjectRoot = normalizeAbsolutePath(await realpath(normalizedProjectRoot));
|
||||
if (!realProjectRoot) return null;
|
||||
|
||||
this.indexedProjectRoots.add(realProjectRoot);
|
||||
return realProjectRoot;
|
||||
}
|
||||
|
||||
async createPreviewUrl({
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot);
|
||||
if (!normalizedFilePath || !normalizedWorkspaceRoot) return null;
|
||||
|
||||
const [realFilePath, realWorkspaceRoot] = await Promise.all([
|
||||
realpath(normalizedFilePath),
|
||||
realpath(normalizedWorkspaceRoot),
|
||||
]);
|
||||
const normalizedRealFilePath = normalizeAbsolutePath(realFilePath);
|
||||
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
|
||||
|
||||
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
|
||||
if (
|
||||
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
|
||||
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
|
||||
const token = randomUUID();
|
||||
this.previewTokens.set(token, {
|
||||
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
|
||||
realPath: normalizedRealFilePath,
|
||||
});
|
||||
|
||||
return buildLocalFileUrl(normalizedFilePath, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the URL pathname back into an absolute filesystem path.
|
||||
*
|
||||
* Pathname examples produced by `new URL('localfile://file//abs/path')`:
|
||||
* posix: `//abs/path` -> `/abs/path`
|
||||
* windows: `/C:/abs/path` -> `C:/abs/path`
|
||||
*
|
||||
* Returns null when the path is non-absolute or escapes via segments we
|
||||
* cannot safely normalize (defense-in-depth, not a sandbox).
|
||||
*/
|
||||
private resolveFilePath(pathname: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip the single leading slash inserted by URL parsing on standard
|
||||
// schemes; what remains should already be an absolute filesystem path.
|
||||
let candidate = decoded.startsWith('/') ? decoded.slice(1) : decoded;
|
||||
if (!candidate) return null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// posix-style absolute path won't have a drive letter; treat as invalid
|
||||
// on Windows.
|
||||
candidate = candidate.replaceAll('/', '\\');
|
||||
} else if (!candidate.startsWith('/')) {
|
||||
// We expect an absolute POSIX path: `localfile://file//abs/path` yields
|
||||
// pathname `//abs/path` -> after stripping one slash -> `/abs/path`.
|
||||
candidate = `/${candidate}`;
|
||||
}
|
||||
|
||||
const normalized = path.normalize(candidate);
|
||||
if (!path.isAbsolute(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private cleanupExpiredTokens() {
|
||||
const now = Date.now();
|
||||
for (const [token, record] of this.previewTokens) {
|
||||
if (record.expiresAt <= now) {
|
||||
this.previewTokens.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hasPreviewToken(token: string): boolean {
|
||||
const record = this.previewTokens.get(token);
|
||||
if (!record) return false;
|
||||
|
||||
if (record.expiresAt <= Date.now()) {
|
||||
this.previewTokens.delete(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private verifyPreviewToken(token: string, realResolvedPath: string): boolean {
|
||||
const record = this.previewTokens.get(token);
|
||||
if (!record) return false;
|
||||
|
||||
return record.realPath === realResolvedPath;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { autoUpdater } from 'electron-updater';
|
||||
import { isDev, isWindows } from '@/const/env';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import { UPDATE_CHANNEL, UPDATE_SERVER_URL, updaterConfig } from '@/modules/updater/configs';
|
||||
import { extractRestoreRoute } from '@/modules/updater/utils';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App as AppCore } from '../App';
|
||||
@@ -239,12 +240,29 @@ export class UpdaterManager {
|
||||
}
|
||||
};
|
||||
|
||||
private captureRestoreRoute = () => {
|
||||
try {
|
||||
const url = this.mainWindow.webContents?.getURL();
|
||||
if (!url) return;
|
||||
|
||||
const route = extractRestoreRoute(url);
|
||||
if (!route) return;
|
||||
|
||||
this.app.storeManager.set('pendingRestoreRoute', route);
|
||||
logger.info(`Captured route for restore after update restart: ${route}`);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to capture route for restore after update restart:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Install update immediately
|
||||
*/
|
||||
public installNow = () => {
|
||||
logger.info('Installing update now...');
|
||||
|
||||
this.captureRestoreRoute();
|
||||
|
||||
this.app.isQuiting = true;
|
||||
|
||||
logger.info('Closing all windows before update installation...');
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LocalFileProtocolManager } from '../LocalFileProtocolManager';
|
||||
|
||||
const { mockApp, mockProtocol, mockReadFile, mockRealpath, mockStat, protocolHandlerRef } =
|
||||
vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
mockRealpath: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
protocol: mockProtocol,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
realpath: mockRealpath,
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LocalFileProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
mockRealpath.mockImplementation(async (filePath: string) => filePath);
|
||||
mockStat.mockImplementation(async () => ({ isFile: () => true, size: 1024 }));
|
||||
mockReadFile.mockImplementation(async () => Buffer.from('image-bytes'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
it('exposes scheme metadata for registerSchemesAsPrivileged', () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
expect(manager.protocolScheme).toEqual({
|
||||
privileges: expect.objectContaining({
|
||||
bypassCSP: false,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
}),
|
||||
scheme: 'localfile',
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a POSIX absolute path with the correct mime type', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/Pictures/cat.png',
|
||||
workspaceRoot: '/Users/alice',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
expect(mockProtocol.handle).toHaveBeenCalledWith('localfile', expect.any(Function));
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png');
|
||||
expect(response.headers.get('Content-Length')).toBe('11'); // 'image-bytes'.length
|
||||
});
|
||||
|
||||
it('serves source files as text through the localfile protocol', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/project/App.tsx',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded characters in the path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/My Pictures/图 #.png',
|
||||
workspaceRoot: '/Users/alice',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/My Pictures/图 #.png');
|
||||
});
|
||||
|
||||
it('rejects requests to a different host', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://other/Users/alice/cat.png',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the path is a directory', async () => {
|
||||
mockStat.mockImplementation(async () => ({ isFile: () => false, size: 0 }));
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/Users/alice');
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/folder',
|
||||
workspaceRoot: '/Users/alice',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps ENOENT errors to a 404 response', async () => {
|
||||
mockStat.mockImplementation(async () => {
|
||||
const err: NodeJS.ErrnoException = new Error('no such file');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
await manager.approveWorkspaceRoot('/');
|
||||
const handler = protocolHandlerRef.current;
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/nonexistent.png',
|
||||
workspaceRoot: '/',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects direct localfile requests without a main-issued preview token', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/.ssh/id_rsa',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects forged preview tokens before resolving the requested path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/.ssh/id_rsa?token=forged',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockRealpath).not.toHaveBeenCalled();
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mint preview URLs outside an approved workspace root', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project');
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/.ssh/id_rsa',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('can approve a project root derived from an already approved nested scope', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
|
||||
await manager.approveProjectRootFromScope({
|
||||
projectRoot: '/Users/alice/project',
|
||||
requestedScope: '/Users/alice/project/packages/app',
|
||||
});
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/project/root.ts',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
expect(url).toContain('token=');
|
||||
});
|
||||
|
||||
it('can mint preview URLs for roots produced by the main-process project index', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
filePath: '/Users/alice/project/App.tsx',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
if (!url) throw new Error('Expected local file preview URL');
|
||||
|
||||
expect(url).toContain('token=');
|
||||
});
|
||||
|
||||
it('defers registration until app ready when not yet ready', async () => {
|
||||
mockApp.isReady.mockReturnValue(false);
|
||||
let resolveReady: () => void = () => undefined;
|
||||
mockApp.whenReady.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
|
||||
expect(mockProtocol.handle).not.toHaveBeenCalled();
|
||||
resolveReady();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(mockProtocol.handle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -361,6 +361,51 @@ describe('UpdaterManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureRestoreRoute', () => {
|
||||
const callCapture = () => (updaterManager as any).captureRestoreRoute();
|
||||
|
||||
it('stores the derived route from the main window URL', () => {
|
||||
(mockApp.browserManager.getMainWindow as any).mockReturnValue({
|
||||
webContents: { getURL: () => 'app://renderer/agent/abc' },
|
||||
});
|
||||
|
||||
callCapture();
|
||||
|
||||
expect(mockApp.storeManager.set).toHaveBeenCalledWith('pendingRestoreRoute', '/agent/abc');
|
||||
});
|
||||
|
||||
it('stores nothing when the URL is not a restorable route', () => {
|
||||
(mockApp.browserManager.getMainWindow as any).mockReturnValue({
|
||||
webContents: { getURL: () => 'app://renderer/' },
|
||||
});
|
||||
|
||||
callCapture();
|
||||
|
||||
expect(mockApp.storeManager.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores nothing when there is no webContents', () => {
|
||||
(mockApp.browserManager.getMainWindow as any).mockReturnValue({ webContents: null });
|
||||
|
||||
callCapture();
|
||||
|
||||
expect(mockApp.storeManager.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when reading the URL fails', () => {
|
||||
(mockApp.browserManager.getMainWindow as any).mockReturnValue({
|
||||
webContents: {
|
||||
getURL: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => callCapture()).not.toThrow();
|
||||
expect(mockApp.storeManager.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installLater', () => {
|
||||
it('should set autoInstallOnAppQuit to true', () => {
|
||||
updaterManager.installLater();
|
||||
|
||||
@@ -95,6 +95,11 @@ const createMockApp = () => {
|
||||
}),
|
||||
},
|
||||
browserManager: {
|
||||
getMainWindow: vi.fn(() => ({
|
||||
broadcast: vi.fn(),
|
||||
loadUrl: vi.fn(),
|
||||
show: vi.fn(),
|
||||
})),
|
||||
showMainWindow: vi.fn(),
|
||||
retrieveByIdentifier: vi.fn(() => ({
|
||||
show: vi.fn(),
|
||||
@@ -223,6 +228,9 @@ describe('LinuxMenu', () => {
|
||||
|
||||
describe('menu item click handlers', () => {
|
||||
it('should handle preferences click', () => {
|
||||
const mainWindow = { broadcast: vi.fn(), loadUrl: vi.fn(), show: vi.fn() };
|
||||
(mockApp.browserManager.getMainWindow as any).mockReturnValue(mainWindow);
|
||||
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
@@ -231,7 +239,9 @@ describe('LinuxMenu', () => {
|
||||
|
||||
expect(preferencesItem).toBeDefined();
|
||||
preferencesItem.click();
|
||||
expect(mockApp.browserManager.retrieveByIdentifier).toHaveBeenCalledWith('settings');
|
||||
expect(mockApp.browserManager.getMainWindow).toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalled();
|
||||
expect(mainWindow.broadcast).toHaveBeenCalledWith('navigate', { path: '/settings' });
|
||||
});
|
||||
|
||||
it('should handle check for updates click', () => {
|
||||
|
||||
@@ -103,7 +103,11 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
},
|
||||
{
|
||||
@@ -465,7 +469,11 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
||||
@@ -205,6 +205,9 @@ describe('WindowsMenu', () => {
|
||||
|
||||
describe('menu item click handlers', () => {
|
||||
it('should handle preferences click', () => {
|
||||
const mainWindow = { broadcast: vi.fn(), loadUrl: vi.fn(), show: vi.fn() };
|
||||
(mockApp.browserManager.getMainWindow as any).mockReturnValue(mainWindow);
|
||||
|
||||
windowsMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
@@ -213,7 +216,9 @@ describe('WindowsMenu', () => {
|
||||
|
||||
expect(preferencesItem).toBeDefined();
|
||||
preferencesItem.click();
|
||||
expect(mockApp.browserManager.retrieveByIdentifier).toHaveBeenCalledWith('settings');
|
||||
expect(mockApp.browserManager.getMainWindow).toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalled();
|
||||
expect(mainWindow.broadcast).toHaveBeenCalledWith('navigate', { path: '/settings' });
|
||||
});
|
||||
|
||||
it('should handle check for updates click', () => {
|
||||
|
||||
@@ -102,7 +102,11 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
},
|
||||
this.getUpdateMenuItem(t),
|
||||
@@ -472,7 +476,11 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { FileResult, SearchOptions } from '../types';
|
||||
import type { UnixSearchTool } from './unix';
|
||||
import { UnixFileSearch } from './unix';
|
||||
|
||||
const logger = createLogger('module:FileSearch:linux');
|
||||
|
||||
/**
|
||||
* Linux file search implementation
|
||||
* Uses fd > find > fast-glob fallback strategy
|
||||
*/
|
||||
export class LinuxSearchServiceImpl extends UnixFileSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestUnixTool();
|
||||
logger.info(`Using file search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithUnixTool(this.currentTool as UnixSearchTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available (always true for Linux)
|
||||
*/
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
// At minimum, fast-glob is always available
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* Linux doesn't have a system-wide search index like Spotlight
|
||||
* @returns Promise indicating operation result (always false for Linux)
|
||||
*/
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
logger.warn('updateSearchIndex is not supported on Linux (no system-wide index)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { platform } from 'node:os';
|
||||
|
||||
import type { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
import { LinuxSearchServiceImpl } from './impl/linux';
|
||||
import { MacOSSearchServiceImpl } from './impl/macOS';
|
||||
import { WindowsSearchServiceImpl } from './impl/windows';
|
||||
|
||||
export { BaseFileSearch } from './base';
|
||||
export type { FileResult, SearchOptions } from './types';
|
||||
|
||||
export const createFileSearchModule = (toolDetectorManager?: ToolDetectorManager) => {
|
||||
const currentPlatform = platform();
|
||||
|
||||
switch (currentPlatform) {
|
||||
case 'darwin': {
|
||||
return new MacOSSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
case 'win32': {
|
||||
return new WindowsSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
case 'linux': {
|
||||
return new LinuxSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
default: {
|
||||
// Fallback to Linux implementation (uses fast-glob, no external dependencies)
|
||||
console.warn(`Unsupported platform: ${currentPlatform}, using Linux fallback`);
|
||||
return new LinuxSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
export interface FileResult {
|
||||
contentType?: string;
|
||||
createdTime: Date;
|
||||
// Search engine used to find this file (e.g., 'mdfind', 'fd', 'find', 'fast-glob')
|
||||
engine?: string;
|
||||
isDirectory: boolean;
|
||||
lastAccessTime: Date;
|
||||
// Spotlight specific metadata
|
||||
metadata?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
modifiedTime: Date;
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
// Directory options
|
||||
// Content options
|
||||
contentContains?: string;
|
||||
// Created after specific date
|
||||
createdAfter?: Date;
|
||||
|
||||
// Created before specific date
|
||||
createdBefore?: Date;
|
||||
// Whether to return detailed results
|
||||
detailed?: boolean;
|
||||
|
||||
// Limit search to specific directories
|
||||
exclude?: string[]; // Files containing specific content
|
||||
|
||||
// File type options
|
||||
fileTypes?: string[];
|
||||
|
||||
// Basic options
|
||||
keywords: string;
|
||||
limit?: number;
|
||||
// Created before specific date
|
||||
// Advanced options
|
||||
liveUpdate?: boolean;
|
||||
// File type filters, like "public.image", "public.movie"
|
||||
// Time options
|
||||
modifiedAfter?: Date;
|
||||
|
||||
// Modified after specific date
|
||||
modifiedBefore?: Date;
|
||||
// Path options
|
||||
onlyIn?: string; // Whether to return detailed metadata
|
||||
sortBy?: 'name' | 'date' | 'size'; // Result sorting
|
||||
sortDirection?: 'asc' | 'desc'; // Sort direction
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
|
||||
args: [
|
||||
...DESKTOP_CLAUDE_CODE_ARGS,
|
||||
// Wire the controller-managed temp mcp.json (AskUserQuestion server,
|
||||
// see LOBE-8725) when present. Path-based config is required — CC
|
||||
// see ) when present. Path-based config is required — CC
|
||||
// does not accept inline JSON for `--mcp-config`.
|
||||
...(mcpConfigPath ? ['--mcp-config', mcpConfigPath] : []),
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearDetectionCache, getCachedDetection } from '../cache';
|
||||
import { detectAllApps } from '../detectors';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../detectors', () => ({
|
||||
detectAllApps: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedDetectAll = vi.mocked(detectAllApps);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDetectionCache();
|
||||
});
|
||||
|
||||
describe('getCachedDetection', () => {
|
||||
it('invokes detection on first call', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
|
||||
const result = await getCachedDetection('darwin');
|
||||
|
||||
expect(result).toEqual([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('concurrent callers share a single inflight promise', async () => {
|
||||
let resolveFn: (value: any) => void = () => {};
|
||||
const inflight = new Promise<any>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
mockedDetectAll.mockReturnValueOnce(inflight);
|
||||
|
||||
const p1 = getCachedDetection('darwin');
|
||||
const p2 = getCachedDetection('darwin');
|
||||
const p3 = getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFn([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
|
||||
const results = await Promise.all([p1, p2, p3]);
|
||||
|
||||
// all three share the same resolved value
|
||||
expect(results[0]).toBe(results[1]);
|
||||
expect(results[1]).toBe(results[2]);
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('subsequent serial calls reuse the cached promise', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
|
||||
await getCachedDetection('darwin');
|
||||
await getCachedDetection('darwin');
|
||||
await getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-invokes detection after clearDetectionCache', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
await getCachedDetection('darwin');
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
clearDetectionCache();
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: false },
|
||||
]);
|
||||
await getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { detectAllApps, detectApp } from '../detectors';
|
||||
import { extractAllIcons } from '../iconExtractor';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:child_process - execFile is wrapped via promisify, so the mock must
|
||||
// expose execFile as the underlying callback-style function we can drive.
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the icon extractor — detection tests should not depend on real icon
|
||||
// extraction. The default returns an empty Map (no icons) which leaves the
|
||||
// `icon` field absent from all detection results.
|
||||
vi.mock('../iconExtractor', () => ({
|
||||
extractAllIcons: vi.fn(async () => new Map<string, string>()),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
interface ExecOutcome {
|
||||
code: number;
|
||||
error?: NodeJS.ErrnoException;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
const respondExec = (outcome: ExecOutcome) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (outcome.code === 0) {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
} else {
|
||||
const err: NodeJS.ErrnoException & { stderr?: string } =
|
||||
outcome.error ?? new Error('exec failed');
|
||||
err.stderr = outcome.stderr ?? '';
|
||||
(err as any).code = outcome.code;
|
||||
callback(err, '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectApp', () => {
|
||||
describe('appBundle strategy', () => {
|
||||
it('returns true when fs.access resolves for any path', async () => {
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing'));
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await detectApp('terminal', 'darwin');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedAccess).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns false when all paths reject', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await detectApp('vscode', 'darwin');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandV strategy', () => {
|
||||
it('returns true on exit 0', async () => {
|
||||
respondExec({ code: 0, stdout: '/usr/bin/zed' });
|
||||
|
||||
const result = await detectApp('zed', 'linux');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'/bin/sh',
|
||||
['-c', 'command -v "zed"'],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'not found' });
|
||||
|
||||
const result = await detectApp('zed', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unsafe binary names without spawning a shell', async () => {
|
||||
// We monkey-patch a registry entry transiently to inject a malicious binary.
|
||||
const registry = await import('../registry');
|
||||
const originalGhostty = registry.APP_REGISTRY.ghostty.detect.linux;
|
||||
registry.APP_REGISTRY.ghostty.detect.linux = {
|
||||
binary: 'foo; rm -rf /',
|
||||
type: 'commandV',
|
||||
};
|
||||
|
||||
const result = await detectApp('ghostty', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
|
||||
registry.APP_REGISTRY.ghostty.detect.linux = originalGhostty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('registryAppPaths strategy', () => {
|
||||
it('returns true on exit 0', async () => {
|
||||
respondExec({ code: 0, stdout: 'C:\\Program Files\\code.exe' });
|
||||
|
||||
const result = await detectApp('vscode', 'win32');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'where',
|
||||
['Code.exe'],
|
||||
{ windowsHide: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'not found' });
|
||||
|
||||
const result = await detectApp('vscode', 'win32');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when platform has no detect entry for the app', async () => {
|
||||
const result = await detectApp('xcode', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true for ALWAYS_INSTALLED entries without probing', async () => {
|
||||
const darwinFinder = await detectApp('finder', 'darwin');
|
||||
const win32Explorer = await detectApp('explorer', 'win32');
|
||||
const linuxFiles = await detectApp('files', 'linux');
|
||||
|
||||
expect(darwinFinder).toBe(true);
|
||||
expect(win32Explorer).toBe(true);
|
||||
expect(linuxFiles).toBe(true);
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAllApps', () => {
|
||||
it('returns one entry per AppId regardless of platform', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('linux');
|
||||
|
||||
const registry = await import('../registry');
|
||||
expect(apps.length).toBe(Object.keys(registry.APP_REGISTRY).length);
|
||||
// every entry has the three required fields
|
||||
for (const app of apps) {
|
||||
expect(app).toEqual(
|
||||
expect.objectContaining({
|
||||
displayName: expect.any(String),
|
||||
id: expect.any(String),
|
||||
installed: expect.any(Boolean),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks unsupported-on-platform apps as not installed', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('linux');
|
||||
|
||||
const xcode = apps.find((a) => a.id === 'xcode');
|
||||
expect(xcode?.installed).toBe(false);
|
||||
});
|
||||
|
||||
it('marks ALWAYS_INSTALLED platform file manager as installed without probes', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('darwin');
|
||||
|
||||
const finder = apps.find((a) => a.id === 'finder');
|
||||
expect(finder?.installed).toBe(true);
|
||||
});
|
||||
|
||||
it('merges extracted icons onto installed apps only', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
vi.mocked(extractAllIcons).mockResolvedValueOnce(
|
||||
new Map([['finder', 'data:image/png;base64,FAKE']]),
|
||||
);
|
||||
|
||||
const apps = await detectAllApps('darwin');
|
||||
|
||||
const finder = apps.find((a) => a.id === 'finder');
|
||||
expect(finder?.icon).toBe('data:image/png;base64,FAKE');
|
||||
|
||||
// not-installed apps must not have an icon field
|
||||
const xcode = apps.find((a) => a.id === 'xcode');
|
||||
expect(xcode?.installed).toBe(false);
|
||||
expect(xcode?.icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes only installed AppIds to extractAllIcons', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
vi.mocked(extractAllIcons).mockResolvedValueOnce(new Map());
|
||||
|
||||
await detectAllApps('darwin');
|
||||
|
||||
expect(extractAllIcons).toHaveBeenCalledTimes(1);
|
||||
const [ids, platform] = vi.mocked(extractAllIcons).mock.calls[0];
|
||||
expect(platform).toBe('darwin');
|
||||
// only finder is ALWAYS_INSTALLED on darwin; all others fail probes
|
||||
expect(ids).toEqual(['finder']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { __resetForTest, extractAllIcons, extractAppIcon } from '../iconExtractor';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
mkdtemp: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedMkdtemp = vi.mocked(mkdtemp);
|
||||
const mockedReadFile = vi.mocked(readFile);
|
||||
const mockedUnlink = vi.mocked(unlink);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* Drives the next execFile call. The promisified callback signature is
|
||||
* `(error, stdout, stderr)`; non-error responses resolve with stdout.
|
||||
*/
|
||||
const respondExec = (
|
||||
match: { args?: string[]; binary: string },
|
||||
outcome: { error?: Error; stderr?: string; stdout?: string },
|
||||
) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (_file !== match.binary) {
|
||||
callback(new Error(`unexpected binary: ${_file}`), '', '');
|
||||
return undefined as any;
|
||||
}
|
||||
if (match.args && JSON.stringify(_args) !== JSON.stringify(match.args)) {
|
||||
callback(new Error(`unexpected args: ${JSON.stringify(_args)}`), '', '');
|
||||
return undefined as any;
|
||||
}
|
||||
if (outcome.error) {
|
||||
callback(outcome.error, '', outcome.stderr ?? '');
|
||||
} else {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Shorthand: tools-available probe passes (which plutil + which sips both 0).
|
||||
const respondToolsAvailable = () => {
|
||||
// /usr/bin/which plutil
|
||||
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/plutil\n' });
|
||||
// /usr/bin/which sips
|
||||
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/sips\n' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedAccess.mockReset();
|
||||
mockedMkdtemp.mockReset();
|
||||
mockedReadFile.mockReset();
|
||||
mockedUnlink.mockReset();
|
||||
mockedExecFile.mockReset();
|
||||
mockedUnlink.mockResolvedValue(undefined);
|
||||
__resetForTest();
|
||||
});
|
||||
|
||||
describe('extractAppIcon', () => {
|
||||
it('returns a data URL when plutil + sips succeed on darwin', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
// plutil CFBundleIconFile lookup
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // .icns exists
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
// sips conversion
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBe(
|
||||
`data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('appends .icns suffix when CFBundleIconFile has no extension', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
|
||||
mockedAccess.mockImplementationOnce(async (p: any) => {
|
||||
// .icns existence check — verify suffix appended
|
||||
if (typeof p === 'string' && p.endsWith('Terminal.icns')) return undefined;
|
||||
throw new Error('wrong path: ' + String(p));
|
||||
});
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50]));
|
||||
|
||||
const result = await extractAppIcon('terminal', 'darwin');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.startsWith('data:image/png;base64,')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the next path when the first bundle does not exist', async () => {
|
||||
respondToolsAvailable();
|
||||
// terminal has two candidate paths; first fails, second succeeds.
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing'));
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0xff]));
|
||||
|
||||
const result = await extractAppIcon('terminal', 'darwin');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no bundle path exists', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when plutil cannot read CFBundleIconFile', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { error: new Error('plutil: not found') });
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the resolved .icns is missing', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing icns')); // .icns missing
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when sips fails', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { error: new Error('sips error') });
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the produced PNG is empty', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.alloc(0));
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when registry has no darwin entry for the app', async () => {
|
||||
respondToolsAvailable();
|
||||
const result = await extractAppIcon('explorer', 'darwin');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined on win32 (extractor is macOS-only)', async () => {
|
||||
const result = await extractAppIcon('vscode', 'win32');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined on linux (extractor is macOS-only)', async () => {
|
||||
const result = await extractAppIcon('vscode', 'linux');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAllIcons', () => {
|
||||
it('returns a map of only AppIds with successfully extracted icons', async () => {
|
||||
respondToolsAvailable();
|
||||
|
||||
// vscode succeeds
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // .icns
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from('vscode'));
|
||||
|
||||
// cursor fails at bundle access (try all paths fail)
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
// xcode succeeds — reset access for it
|
||||
// (subsequent calls to mockedAccess will keep returning rejection)
|
||||
// So this test exercises: success, fail-no-bundle.
|
||||
|
||||
const map = await extractAllIcons(['vscode', 'cursor'], 'darwin');
|
||||
|
||||
expect(map.has('vscode')).toBe(true);
|
||||
expect(map.has('cursor')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty map when input list is empty', async () => {
|
||||
const map = await extractAllIcons([], 'darwin');
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not throw when extraction errors', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { error: new Error('boom') });
|
||||
|
||||
const map = await extractAllIcons(['vscode'], 'darwin');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips all when tools are unavailable', async () => {
|
||||
// /usr/bin/which plutil fails
|
||||
respondExec({ binary: '/usr/bin/which' }, { error: new Error('not found') });
|
||||
|
||||
const map = await extractAllIcons(['vscode', 'terminal'], 'darwin');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { launchApp } from '../launchers';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedShell = vi.mocked(shell);
|
||||
|
||||
type LastCall = { file: string; args: string[] };
|
||||
|
||||
const captureExec = (): LastCall => {
|
||||
expect(mockedExecFile).toHaveBeenCalled();
|
||||
const [file, args] = mockedExecFile.mock.calls[0];
|
||||
return { args: args as string[], file: file as string };
|
||||
};
|
||||
|
||||
interface ExecOutcome {
|
||||
code: number;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
const respondExec = (outcome: ExecOutcome) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (outcome.code === 0) {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
} else {
|
||||
const err: NodeJS.ErrnoException & { stderr?: string } = new Error('exec failed');
|
||||
err.stderr = outcome.stderr ?? '';
|
||||
(err as any).code = outcome.code;
|
||||
callback(err, '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedAccess.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('launchApp - path validation', () => {
|
||||
it('rejects relative paths', async () => {
|
||||
const result = await launchApp('vscode', 'relative/path', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Path must be absolute');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects paths that do not exist', async () => {
|
||||
mockedAccess.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
const result = await launchApp('vscode', '/missing', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Path not found: /missing');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when app is not available on platform', async () => {
|
||||
const result = await launchApp('xcode', '/some/path', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Xcode');
|
||||
expect(result.error).toContain('not available on this platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - macOpenA strategy', () => {
|
||||
it('spawns open -a <appName> <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('open');
|
||||
expect(call.args).toEqual(['-a', 'Visual Studio Code', '/work/dir']);
|
||||
});
|
||||
|
||||
it('returns stderr substring on failure', async () => {
|
||||
respondExec({ code: 1, stderr: ' cannot open Cursor.app ' });
|
||||
|
||||
const result = await launchApp('cursor', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('cannot open Cursor.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - macOpen strategy', () => {
|
||||
it('spawns open <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('finder', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('open');
|
||||
expect(call.args).toEqual(['/work/dir']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - exec strategy', () => {
|
||||
it('spawns <binary> <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('code');
|
||||
expect(call.args).toEqual(['/work/dir']);
|
||||
});
|
||||
|
||||
it('appends registry-provided args before path', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
args: ['--new-window'],
|
||||
binary: 'code',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.args).toEqual(['--new-window', '/work/dir']);
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('rejects suspicious binary names', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: 'rm; ls',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid binary name');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('rejects binary names with spaces', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: 'foo bar',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid binary name');
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('accepts absolute-path binary names', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: '/usr/local/bin/code',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('/usr/local/bin/code');
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('returns stderr substring on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'command not found' });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('command not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - shellOpenPath strategy', () => {
|
||||
it('delegates to shell.openPath', async () => {
|
||||
mockedShell.openPath.mockResolvedValueOnce('');
|
||||
|
||||
const result = await launchApp('explorer', '/abs/work-dir', 'win32');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedShell.openPath).toHaveBeenCalledWith('/abs/work-dir');
|
||||
});
|
||||
|
||||
it('returns error string from shell.openPath as error', async () => {
|
||||
mockedShell.openPath.mockResolvedValueOnce('cannot open');
|
||||
|
||||
const result = await launchApp('files', '/some/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('cannot open');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DetectedApp } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectAllApps } from './detectors';
|
||||
|
||||
let cachedPromise: Promise<DetectedApp[]> | null = null;
|
||||
|
||||
export const getCachedDetection = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
if (!cachedPromise) {
|
||||
cachedPromise = detectAllApps(platform);
|
||||
}
|
||||
return cachedPromise;
|
||||
};
|
||||
|
||||
export const clearDetectionCache = (): void => {
|
||||
cachedPromise = null;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { extractAllIcons } from './iconExtractor';
|
||||
import type { DetectStrategy } from './registry';
|
||||
import { ALWAYS_INSTALLED, APP_REGISTRY } from './registry';
|
||||
|
||||
// Icon extraction shells out to plutil + sips on macOS (see iconExtractor.ts)
|
||||
// so Electron itself cannot crash on `app.getFileIcon` regressions. Renderer
|
||||
// falls back to lucide if extraction returns undefined.
|
||||
|
||||
const logger = createLogger('modules:openInApp:detectors');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
|
||||
|
||||
const probeAppBundle = async (paths: string[]): Promise<boolean> => {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const probeCommandV = async (binary: string): Promise<boolean> => {
|
||||
if (!SAFE_BINARY_REGEX.test(binary)) {
|
||||
logger.debug(`rejecting unsafe binary name for commandV: ${binary}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`commandV probe failed for ${binary}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const probeRegistryAppPaths = async (exeName: string): Promise<boolean> => {
|
||||
try {
|
||||
await execFileAsync('where', [exeName], { windowsHide: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`where probe failed for ${exeName}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const runDetectStrategy = (strategy: DetectStrategy): Promise<boolean> => {
|
||||
switch (strategy.type) {
|
||||
case 'appBundle': {
|
||||
return probeAppBundle(strategy.paths);
|
||||
}
|
||||
case 'commandV': {
|
||||
return probeCommandV(strategy.binary);
|
||||
}
|
||||
case 'registryAppPaths': {
|
||||
return probeRegistryAppPaths(strategy.exeName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const detectApp = async (id: OpenInAppId, platform: NodeJS.Platform): Promise<boolean> => {
|
||||
if (ALWAYS_INSTALLED[platform] === id) {
|
||||
return true;
|
||||
}
|
||||
const descriptor = APP_REGISTRY[id];
|
||||
const strategy = descriptor?.detect[platform];
|
||||
if (!strategy) {
|
||||
return false;
|
||||
}
|
||||
return runDetectStrategy(strategy);
|
||||
};
|
||||
|
||||
export const detectAllApps = async (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
const entries = Object.entries(APP_REGISTRY) as Array<
|
||||
[OpenInAppId, (typeof APP_REGISTRY)[OpenInAppId]]
|
||||
>;
|
||||
const installedFlags = await Promise.all(entries.map(([id]) => detectApp(id, platform)));
|
||||
|
||||
// Extract icons for installed apps only. Extraction shells out to plutil +
|
||||
// sips (see iconExtractor.ts) so it cannot crash the renderer; failures
|
||||
// resolve to undefined and the renderer falls back to lucide icons.
|
||||
const installedIds = entries.filter((_entry, i) => installedFlags[i]).map(([id]) => id);
|
||||
const icons = await extractAllIcons(installedIds, platform);
|
||||
|
||||
return entries.map(([id, descriptor], i) => {
|
||||
const installed = installedFlags[i];
|
||||
const icon = installed ? icons.get(id) : undefined;
|
||||
return {
|
||||
displayName: descriptor.displayName,
|
||||
id,
|
||||
installed,
|
||||
...(icon ? { icon } : {}),
|
||||
} satisfies DetectedApp;
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user