mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a823410bba | |||
| fbda59ff28 | |||
| e4d5017e76 | |||
| 27121a6f1a | |||
| 373b5e90b2 | |||
| 3caa3efb18 | |||
| c27b62e10c | |||
| a9d74bb143 | |||
| e1fe37933d | |||
| 1c3e973bab | |||
| 22c264bb77 | |||
| 1736faf3af | |||
| 6c58af9c84 | |||
| 0139c054a2 | |||
| 063fa61c49 | |||
| dc3186a990 | |||
| 50d7b126c8 | |||
| 42487663b9 | |||
| 94c7fa4d76 | |||
| 2461709de4 | |||
| 5609b6313b | |||
| 53e13ea3b1 | |||
| 21aceb6fee | |||
| 2657b667be | |||
| f042dd352e | |||
| 15cb3be9cc | |||
| 65113ca2a7 | |||
| 2194b23390 | |||
| 234c87dd9d | |||
| 9945cecf87 | |||
| 671b2527b8 | |||
| 6d94635631 | |||
| 109545c3b1 | |||
| 47daf09be1 | |||
| 41172a6740 | |||
| caa7905be2 | |||
| a7f38114d5 | |||
| 1b74566b4c | |||
| 1024ee961b | |||
| 980c2e74d8 | |||
| 84598524df | |||
| 5e2ef88c13 | |||
| 403de538d6 | |||
| 8949e89535 | |||
| 8aa075cd80 | |||
| 9cc5f9e1a0 | |||
| bcf97d9487 | |||
| 3e4b81d2cc | |||
| 651d1a203a | |||
| 4c29515e4c | |||
| b4b1205ee9 | |||
| 8c0e66b633 | |||
| 1ae8498fc7 | |||
| c4b147554b | |||
| 5fb1f339a7 | |||
| 81fc1aaf7f | |||
| b14f1dba5c | |||
| 1d2b32bafc | |||
| 347e2eec0c | |||
| e8275a93ff | |||
| 49d191d2a7 | |||
| 35052416cc | |||
| 0c5ccc8770 | |||
| c8ff3ac43d | |||
| 718096e306 | |||
| f0eded2941 | |||
| 1f6d350dca | |||
| 5eee6d21e3 | |||
| bcc31ca331 | |||
| 72d34046c0 | |||
| 60f08f58e4 | |||
| 202f062a0d | |||
| be81c35e94 | |||
| 14357a3f51 | |||
| 0561a1d7eb | |||
| 3e0a396968 | |||
| 5f27cd8f26 | |||
| 1c80146a07 | |||
| 1d4d5c1c73 | |||
| d45257615a | |||
| b3cbc9a710 | |||
| e295f80235 | |||
| 5cd02b937b | |||
| cce2741de3 | |||
| 362d137a2b | |||
| 6859ee2374 | |||
| d6e641b790 | |||
| 2ee53bcd60 | |||
| 8b96d14347 | |||
| 248d6ecf76 | |||
| d4e8d6df6e | |||
| 4c6a3999c1 | |||
| 506b96af64 | |||
| 924ae8bf1f | |||
| 302755057e | |||
| eea9464b04 | |||
| 82cc885394 | |||
| e4ad195df9 | |||
| 47b6f3503a | |||
| bb4924fc5b | |||
| 46f884d5ed | |||
| 0fcc21895e | |||
| 3c52998157 | |||
| 8d4c48749f | |||
| 26aa28c263 | |||
| f3d5d03cf5 | |||
| d71686ba88 | |||
| f16c280e93 | |||
| be62847e00 | |||
| a8faccff66 | |||
| 63d8e07453 | |||
| 44e69af6cc | |||
| eedf46a11d | |||
| ff61f4b3fa | |||
| 192111840c | |||
| 837a3daa58 | |||
| 5f6f053039 | |||
| 775be47513 | |||
| 2f265a9307 | |||
| 0fa2e2349c | |||
| 930344ae23 | |||
| 538195dfb4 | |||
| 694a25822f |
@@ -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()`
|
||||
|
||||
@@ -23,7 +23,7 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**:
|
||||
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
|
||||
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
|
||||
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui/](references/ui/README.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
|
||||
|
||||
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
|
||||
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui/](ui/README.md).
|
||||
For where files live and how registries work, see [architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
@@ -156,7 +156,7 @@ export const TaskManifest: BuiltinToolManifest = {
|
||||
executors: ['client', 'server'],
|
||||
|
||||
/* Default human intervention policy for all APIs that don't specify one.
|
||||
Pair with an Intervention component (see ui.md). */
|
||||
Pair with an Intervention component (see ui/intervention.md). */
|
||||
humanIntervention: 'never' | 'always' | { /* extended config */ },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
# Tool UI Surfaces
|
||||
|
||||
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
|
||||
|
||||
| Surface | Required? | When the chat shows it | Registered in |
|
||||
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
|
||||
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
|
||||
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
|
||||
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
|
||||
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
|
||||
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
|
||||
|
||||
The two reference tools to read end-to-end:
|
||||
|
||||
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
|
||||
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### 0.1 Use `'use client'` at the top of every component file
|
||||
|
||||
Tool surfaces are leaves in the chat tree and must not block server rendering.
|
||||
|
||||
### 0.2 Prefer `createStaticStyles + cssVar.*`
|
||||
|
||||
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
|
||||
|
||||
```tsx
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
```
|
||||
|
||||
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
|
||||
|
||||
### 0.3 Use `@lobehub/ui`, not raw `antd`
|
||||
|
||||
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
|
||||
|
||||
### 0.4 Always `memo` and set `displayName`
|
||||
|
||||
```tsx
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args /* … */ }) => {
|
||||
/* … */
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
|
||||
|
||||
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
|
||||
|
||||
### 0.6 Pull strings from `t('plugin')`
|
||||
|
||||
```tsx
|
||||
const { t } = useTranslation('plugin');
|
||||
t('builtins.<identifier>.apiName.<api>');
|
||||
```
|
||||
|
||||
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
|
||||
|
||||
### 0.7 Read store state from `@/store/chat`, not props
|
||||
|
||||
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inspector — Header Chip (required)
|
||||
|
||||
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
|
||||
|
||||
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
|
||||
|
||||
### Props (`BuiltinInspectorProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
apiName: string;
|
||||
args: Arguments; // final args (only after the assistant stops streaming)
|
||||
identifier: string;
|
||||
isArgumentsStreaming?: boolean; // args still arriving
|
||||
isLoading?: boolean; // args complete, executor running
|
||||
partialArgs?: Arguments; // partial JSON during streaming
|
||||
pluginState?: State; // executor's `state` after success
|
||||
result?: { content: string | null; error?: any };
|
||||
}
|
||||
```
|
||||
|
||||
### State machine
|
||||
|
||||
| Phase | What's available | What to show |
|
||||
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
|
||||
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
|
||||
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
|
||||
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
|
||||
|
||||
### Canonical example — Search
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const query = args?.query || partialArgs?.query || '';
|
||||
const resultCount = pluginState?.results?.length ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
if (isArgumentsStreaming && !query) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
|
||||
{query && <span className={highlightTextStyles.primary}>{query}</span>}
|
||||
{!isLoading &&
|
||||
!isArgumentsStreaming &&
|
||||
pluginState?.results &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
|
||||
({t('builtins.lobe-web-browsing.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### Inspector rules
|
||||
|
||||
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
|
||||
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
|
||||
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
|
||||
- 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`
|
||||
|
||||
```ts
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import { CreateTaskInspector } from './CreateTask';
|
||||
import { ListTasksInspector } from './ListTasks';
|
||||
/* … */
|
||||
|
||||
export const TaskInspectors: Record<string, BuiltinInspector> = {
|
||||
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
|
||||
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
|
||||
/* one entry per ApiName */
|
||||
};
|
||||
|
||||
export { CreateTaskInspector } from './CreateTask';
|
||||
export { ListTasksInspector } from './ListTasks';
|
||||
/* re-export each */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Render — Rich Result Card (optional)
|
||||
|
||||
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
|
||||
|
||||
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
|
||||
|
||||
### Props (`BuiltinRenderProps<Args, State, Content>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
|
||||
apiName?: string;
|
||||
args: Arguments; // final params from the LLM
|
||||
content: Content; // executor's content string (or parsed)
|
||||
identifier?: string;
|
||||
messageId: string; // for store lookups
|
||||
pluginError?: any; // from BuiltinToolResult.error
|
||||
pluginState?: State; // executor's state
|
||||
toolCallId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Two patterns
|
||||
|
||||
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
|
||||
|
||||
```tsx
|
||||
// client/Render/CrawlSinglePage.tsx
|
||||
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageContent from './PageContent';
|
||||
|
||||
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
|
||||
({ messageId, pluginState, args }) => (
|
||||
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
|
||||
),
|
||||
);
|
||||
export default CrawlSinglePage;
|
||||
```
|
||||
|
||||
**Pattern B — Folder with subcomponents** (web-browsing Search):
|
||||
|
||||
```
|
||||
client/Render/Search/
|
||||
├── index.tsx # composes the subcomponents, handles error states
|
||||
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
|
||||
├── SearchQuery.tsx # editable query header
|
||||
└── SearchResult.tsx # result list
|
||||
```
|
||||
|
||||
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
|
||||
|
||||
### Error handling in Render
|
||||
|
||||
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
|
||||
|
||||
```tsx
|
||||
if (pluginError) {
|
||||
if (pluginError?.type === 'PluginSettingsInvalid') {
|
||||
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
title={pluginError?.message}
|
||||
type="error"
|
||||
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Render rules
|
||||
|
||||
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
|
||||
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
|
||||
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
|
||||
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
### Render registry — `client/Render/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import CreateTaskRender from './CreateTask';
|
||||
import RunTasksRender from './RunTasks';
|
||||
|
||||
export const TaskRenders: Record<string, BuiltinRender> = {
|
||||
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
|
||||
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
|
||||
/* only the APIs with rich result UI — others fall back to text content */
|
||||
};
|
||||
|
||||
export { default as CreateTaskRender } from './CreateTask';
|
||||
export { default as RunTasksRender } from './RunTasks';
|
||||
```
|
||||
|
||||
### Render display control (rare)
|
||||
|
||||
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Placeholder — Skeleton Between Args and Result (optional)
|
||||
|
||||
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
|
||||
|
||||
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
|
||||
|
||||
### Props (`BuiltinPlaceholderProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
|
||||
apiName: string;
|
||||
args?: T;
|
||||
identifier: string;
|
||||
}
|
||||
```
|
||||
|
||||
No `pluginState` — Placeholder lives entirely in the "executing" gap.
|
||||
|
||||
### Canonical example — Search Placeholder
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStyles } from '@/styles';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
query: cx(
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
shinyTextStyles.shinyText,
|
||||
),
|
||||
}));
|
||||
|
||||
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
|
||||
const { query } = args || {};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
|
||||
<Flexbox horizontal align="center" className={styles.query} gap={8}>
|
||||
<Icon icon={SearchIcon} />
|
||||
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
|
||||
</Flexbox>
|
||||
<Skeleton.Block active style={{ height: 20, width: 40 }} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={12}>
|
||||
{[1, 2, 3, 4, 5].map((id) => (
|
||||
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Placeholder rules
|
||||
|
||||
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
|
||||
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
|
||||
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
|
||||
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
|
||||
|
||||
### Placeholder registry — `client/Placeholder/index.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import CrawlMultiPages from './CrawlMultiPages';
|
||||
import CrawlSinglePage from './CrawlSinglePage';
|
||||
import { Search } from './Search';
|
||||
|
||||
export const WebBrowsingPlaceholders = {
|
||||
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
|
||||
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
|
||||
[WebBrowsingApiName.search]: Search,
|
||||
};
|
||||
|
||||
export { CrawlMultiPages, CrawlSinglePage, Search };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Streaming — Live Output During Execution (optional)
|
||||
|
||||
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
|
||||
|
||||
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
|
||||
|
||||
### Props (`BuiltinStreamingProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinStreamingProps<Arguments = any> {
|
||||
apiName: string;
|
||||
args: Arguments;
|
||||
identifier: string;
|
||||
messageId: string; // use to fetch the streaming buffer from store
|
||||
toolCallId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
|
||||
|
||||
### Canonical example — RunCommandStreaming
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface RunCommandParams {
|
||||
command?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
|
||||
const { command } = args || {};
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
animated
|
||||
wrap
|
||||
language="sh"
|
||||
showLanguage={false}
|
||||
style={{ padding: '4px 8px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{command}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
RunCommandStreaming.displayName = 'RunCommandStreaming';
|
||||
```
|
||||
|
||||
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
|
||||
|
||||
```tsx
|
||||
const buffer = useChatStore((state) =>
|
||||
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
|
||||
);
|
||||
```
|
||||
|
||||
### Streaming rules
|
||||
|
||||
- Render `null` until you have something to display (avoids flash).
|
||||
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
|
||||
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
|
||||
|
||||
### Streaming registry — `client/Streaming/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import { RunCommandStreaming } from './RunCommand';
|
||||
import { WriteFileStreaming } from './WriteFile';
|
||||
|
||||
export const LocalSystemStreamings = {
|
||||
[LocalSystemApiName.runCommand]: RunCommandStreaming,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Intervention — Approval / Edit-Before-Run (optional)
|
||||
|
||||
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
|
||||
|
||||
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
|
||||
|
||||
### Props (`BuiltinInterventionProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInterventionProps<Arguments = any> {
|
||||
apiName?: string;
|
||||
args: Arguments;
|
||||
identifier?: string;
|
||||
interactionMode?: 'approval' | 'custom';
|
||||
messageId: string;
|
||||
|
||||
/** Called when the user edits the args; the approve action awaits this. */
|
||||
onArgsChange?: (args: Arguments) => void | Promise<void>;
|
||||
|
||||
/** Called on approve / skip / cancel. */
|
||||
onInteractionAction?: (
|
||||
action:
|
||||
| { type: 'submit'; payload: Record<string, unknown> }
|
||||
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
|
||||
| { type: 'cancel'; payload?: Record<string, unknown> },
|
||||
) => Promise<void>;
|
||||
|
||||
/** Register a callback to flush pending saves before approval. Returns cleanup. */
|
||||
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — RunCommand Intervention
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BuiltinInterventionProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
|
||||
const { description, command, timeout } = args;
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal justify="space-between">
|
||||
{description && <Text>{description}</Text>}
|
||||
{timeout && (
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
timeout: {formatTimeout(timeout)}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{command && (
|
||||
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
|
||||
{command}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
export default RunCommand;
|
||||
```
|
||||
|
||||
### Intervention rules
|
||||
|
||||
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
|
||||
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
|
||||
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
|
||||
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
|
||||
|
||||
### Intervention registry — `client/Intervention/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import EditLocalFile from './EditLocalFile';
|
||||
import RunCommand from './RunCommand';
|
||||
import WriteFile from './WriteFile';
|
||||
/* … */
|
||||
|
||||
export const LocalSystemInterventions = {
|
||||
[LocalSystemApiName.editLocalFile]: EditLocalFile,
|
||||
[LocalSystemApiName.runCommand]: RunCommand,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFile,
|
||||
/* one entry per API that needs approval */
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Portal — Full-Screen Detail View (optional)
|
||||
|
||||
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
|
||||
|
||||
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
|
||||
|
||||
### Props (`BuiltinPortalProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
|
||||
apiName?: string;
|
||||
arguments: Arguments;
|
||||
identifier: string;
|
||||
messageId: string;
|
||||
state: State;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — Web-Browsing Portal
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import PageContent from './PageContent';
|
||||
import PageContents from './PageContents';
|
||||
import Search from './Search';
|
||||
|
||||
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
|
||||
switch (apiName) {
|
||||
case WebBrowsingApiName.search:
|
||||
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
|
||||
|
||||
case WebBrowsingApiName.crawlSinglePage: {
|
||||
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
|
||||
return <PageContent messageId={messageId} result={result} />;
|
||||
}
|
||||
|
||||
case WebBrowsingApiName.crawlMultiPages:
|
||||
return (
|
||||
<PageContents
|
||||
messageId={messageId}
|
||||
results={(state as CrawlPluginState).results}
|
||||
urls={args.urls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export default Portal;
|
||||
```
|
||||
|
||||
### Portal rules
|
||||
|
||||
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
|
||||
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
|
||||
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
|
||||
|
||||
### Portal registry — `packages/builtin-tools/src/portals.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { type BuiltinPortal } from '@lobechat/types';
|
||||
|
||||
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `client/components/` — Shared Subcomponents
|
||||
|
||||
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
|
||||
|
||||
Examples from `web-browsing/src/client/components/`:
|
||||
|
||||
- `CategoryAvatar.tsx` — search category icon
|
||||
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
|
||||
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
|
||||
|
||||
Examples from `local-system/src/client/components/`:
|
||||
|
||||
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
|
||||
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
|
||||
|
||||
### Rules
|
||||
|
||||
- Live under `client/components/`, exported via `client/components/index.ts`.
|
||||
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
|
||||
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
|
||||
|
||||
---
|
||||
|
||||
## 8. `client/index.ts` — Package Public API
|
||||
|
||||
Re-exports everything the registries need plus useful types/manifest:
|
||||
|
||||
```ts
|
||||
// Inspector — required
|
||||
export { TaskInspectors } from './Inspector';
|
||||
|
||||
// Render — only if any API has one
|
||||
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
|
||||
|
||||
// Placeholder / Streaming / Intervention — only if used
|
||||
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
|
||||
export { LocalSystemStreamings } from './Streaming';
|
||||
export { LocalSystemInterventions } from './Intervention';
|
||||
|
||||
// Portal — single export per tool
|
||||
export { default as WebBrowsingPortal } from './Portal';
|
||||
|
||||
// Reusable components if other packages need them
|
||||
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
|
||||
|
||||
// Re-export manifest, identifier, types for convenience
|
||||
export { TaskManifest, TaskIdentifier } from '../manifest';
|
||||
export * from '../types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Diagnostic Quick-Lookup
|
||||
|
||||
| Symptom | Surface to check | | |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
|
||||
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
|
||||
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
|
||||
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
|
||||
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
|
||||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
|
||||
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
|
||||
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
|
||||
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
|
||||
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
|
||||
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Tool UI Surfaces
|
||||
|
||||
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
|
||||
|
||||
| Surface | Required? | When the chat shows it | Registered in |
|
||||
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
|
||||
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
|
||||
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
|
||||
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
|
||||
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
|
||||
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
|
||||
|
||||
The two reference tools to read end-to-end:
|
||||
|
||||
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
|
||||
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
|
||||
|
||||
---
|
||||
|
||||
## Files in this folder
|
||||
|
||||
Read **principles** and **shared-rules** first — they apply to every surface. Then jump to the surface you're building.
|
||||
|
||||
| File | What it covers |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [principles.md](principles.md) | Design principles — when each surface exists and how far to take it |
|
||||
| [shared-rules.md](shared-rules.md) | Cross-surface rules: component skeleton, styling, single-layer surfaces |
|
||||
| [inspector.md](inspector.md) | Inspector — header chip (required) |
|
||||
| [render.md](render.md) | Render — rich result card |
|
||||
| [placeholder.md](placeholder.md) | Placeholder — skeleton between args and result |
|
||||
| [streaming.md](streaming.md) | Streaming — live output during execution |
|
||||
| [intervention.md](intervention.md) | Intervention — approval / edit-before-run |
|
||||
| [portal.md](portal.md) | Portal — full-screen detail view |
|
||||
| [composition.md](composition.md) | Shared subcomponents (`client/components/`) + package public API |
|
||||
| [diagnostics.md](diagnostics.md) | Symptom → surface quick-lookup |
|
||||
@@ -0,0 +1,51 @@
|
||||
# Composition — Shared Components & Package API
|
||||
|
||||
## `client/components/` — Shared Subcomponents
|
||||
|
||||
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
|
||||
|
||||
Examples from `web-browsing/src/client/components/`:
|
||||
|
||||
- `CategoryAvatar.tsx` — search category icon
|
||||
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
|
||||
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
|
||||
|
||||
Examples from `local-system/src/client/components/`:
|
||||
|
||||
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
|
||||
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
|
||||
|
||||
### Rules
|
||||
|
||||
- Live under `client/components/`, exported via `client/components/index.ts`.
|
||||
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
|
||||
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
|
||||
|
||||
---
|
||||
|
||||
## `client/index.ts` — Package Public API
|
||||
|
||||
Re-exports everything the registries need plus useful types/manifest:
|
||||
|
||||
```ts
|
||||
// Inspector — required
|
||||
export { TaskInspectors } from './Inspector';
|
||||
|
||||
// Render — only if any API has one
|
||||
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
|
||||
|
||||
// Placeholder / Streaming / Intervention — only if used
|
||||
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
|
||||
export { LocalSystemStreamings } from './Streaming';
|
||||
export { LocalSystemInterventions } from './Intervention';
|
||||
|
||||
// Portal — single export per tool
|
||||
export { default as WebBrowsingPortal } from './Portal';
|
||||
|
||||
// Reusable components if other packages need them
|
||||
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
|
||||
|
||||
// Re-export manifest, identifier, types for convenience
|
||||
export { TaskManifest, TaskIdentifier } from '../manifest';
|
||||
export * from '../types';
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# Diagnostic Quick-Lookup
|
||||
|
||||
| Symptom | Surface to check |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry |
|
||||
| Header shows the API name but no chips | Inspector missing `args?.X \|\| partialArgs?.X` fallback |
|
||||
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` |
|
||||
| Empty result card under header | Render returned `<div />` instead of `null` when no data |
|
||||
| Render looks "complex" / card-in-card | Filled container (`colorFillQuaternary`) wrapping more filled boxes — flatten to single-layer, see [shared-rules.md](shared-rules.md) |
|
||||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions |
|
||||
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry |
|
||||
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` |
|
||||
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName |
|
||||
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) |
|
||||
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` |
|
||||
@@ -0,0 +1,118 @@
|
||||
# Inspector — Header Chip (required)
|
||||
|
||||
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
|
||||
|
||||
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
|
||||
|
||||
## Props (`BuiltinInspectorProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
apiName: string;
|
||||
args: Arguments; // final args (only after the assistant stops streaming)
|
||||
identifier: string;
|
||||
isArgumentsStreaming?: boolean; // args still arriving
|
||||
isLoading?: boolean; // args complete, executor running
|
||||
partialArgs?: Arguments; // partial JSON during streaming
|
||||
pluginState?: State; // executor's `state` after success
|
||||
result?: { content: string | null; error?: any };
|
||||
}
|
||||
```
|
||||
|
||||
## State machine
|
||||
|
||||
| Phase | What's available | What to show |
|
||||
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
|
||||
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
|
||||
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
|
||||
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
|
||||
|
||||
## Canonical example — Search
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const query = args?.query || partialArgs?.query || '';
|
||||
const resultCount = pluginState?.results?.length ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
if (isArgumentsStreaming && !query) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
|
||||
{query && <span className={highlightTextStyles.primary}>{query}</span>}
|
||||
{!isLoading &&
|
||||
!isArgumentsStreaming &&
|
||||
pluginState?.results &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
|
||||
({t('builtins.lobe-web-browsing.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
## Inspector rules
|
||||
|
||||
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
|
||||
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
|
||||
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
|
||||
- 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`
|
||||
|
||||
```ts
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import { CreateTaskInspector } from './CreateTask';
|
||||
import { ListTasksInspector } from './ListTasks';
|
||||
/* … */
|
||||
|
||||
export const TaskInspectors: Record<string, BuiltinInspector> = {
|
||||
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
|
||||
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
|
||||
/* one entry per ApiName */
|
||||
};
|
||||
|
||||
export { CreateTaskInspector } from './CreateTask';
|
||||
export { ListTasksInspector } from './ListTasks';
|
||||
/* re-export each */
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Intervention — Approval / Edit-Before-Run (optional)
|
||||
|
||||
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
|
||||
|
||||
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
|
||||
|
||||
## Props (`BuiltinInterventionProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInterventionProps<Arguments = any> {
|
||||
apiName?: string;
|
||||
args: Arguments;
|
||||
identifier?: string;
|
||||
interactionMode?: 'approval' | 'custom';
|
||||
messageId: string;
|
||||
|
||||
/** Called when the user edits the args; the approve action awaits this. */
|
||||
onArgsChange?: (args: Arguments) => void | Promise<void>;
|
||||
|
||||
/** Called on approve / skip / cancel. */
|
||||
onInteractionAction?: (
|
||||
action:
|
||||
| { type: 'submit'; payload: Record<string, unknown> }
|
||||
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
|
||||
| { type: 'cancel'; payload?: Record<string, unknown> },
|
||||
) => Promise<void>;
|
||||
|
||||
/** Register a callback to flush pending saves before approval. Returns cleanup. */
|
||||
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Canonical example — RunCommand Intervention
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BuiltinInterventionProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
|
||||
const { description, command, timeout } = args;
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal justify="space-between">
|
||||
{description && <Text>{description}</Text>}
|
||||
{timeout && (
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
timeout: {formatTimeout(timeout)}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{command && (
|
||||
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
|
||||
{command}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
export default RunCommand;
|
||||
```
|
||||
|
||||
## Intervention rules
|
||||
|
||||
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
|
||||
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
|
||||
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
|
||||
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
|
||||
|
||||
## Intervention registry — `client/Intervention/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import EditLocalFile from './EditLocalFile';
|
||||
import RunCommand from './RunCommand';
|
||||
import WriteFile from './WriteFile';
|
||||
/* … */
|
||||
|
||||
export const LocalSystemInterventions = {
|
||||
[LocalSystemApiName.editLocalFile]: EditLocalFile,
|
||||
[LocalSystemApiName.runCommand]: RunCommand,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFile,
|
||||
/* one entry per API that needs approval */
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Placeholder — Skeleton Between Args and Result (optional)
|
||||
|
||||
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
|
||||
|
||||
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
|
||||
|
||||
## Props (`BuiltinPlaceholderProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
|
||||
apiName: string;
|
||||
args?: T;
|
||||
identifier: string;
|
||||
}
|
||||
```
|
||||
|
||||
No `pluginState` — Placeholder lives entirely in the "executing" gap.
|
||||
|
||||
## Canonical example — Search Placeholder
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStyles } from '@/styles';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
query: cx(
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
shinyTextStyles.shinyText,
|
||||
),
|
||||
}));
|
||||
|
||||
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
|
||||
const { query } = args || {};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
|
||||
<Flexbox horizontal align="center" className={styles.query} gap={8}>
|
||||
<Icon icon={SearchIcon} />
|
||||
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
|
||||
</Flexbox>
|
||||
<Skeleton.Block active style={{ height: 20, width: 40 }} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={12}>
|
||||
{[1, 2, 3, 4, 5].map((id) => (
|
||||
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Placeholder rules
|
||||
|
||||
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
|
||||
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
|
||||
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
|
||||
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
|
||||
|
||||
## Placeholder registry — `client/Placeholder/index.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import CrawlMultiPages from './CrawlMultiPages';
|
||||
import CrawlSinglePage from './CrawlSinglePage';
|
||||
import { Search } from './Search';
|
||||
|
||||
export const WebBrowsingPlaceholders = {
|
||||
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
|
||||
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
|
||||
[WebBrowsingApiName.search]: Search,
|
||||
};
|
||||
|
||||
export { CrawlMultiPages, CrawlSinglePage, Search };
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Portal — Full-Screen Detail View (optional)
|
||||
|
||||
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
|
||||
|
||||
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
|
||||
|
||||
## Props (`BuiltinPortalProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
|
||||
apiName?: string;
|
||||
arguments: Arguments;
|
||||
identifier: string;
|
||||
messageId: string;
|
||||
state: State;
|
||||
}
|
||||
```
|
||||
|
||||
## Canonical example — Web-Browsing Portal
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import PageContent from './PageContent';
|
||||
import PageContents from './PageContents';
|
||||
import Search from './Search';
|
||||
|
||||
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
|
||||
switch (apiName) {
|
||||
case WebBrowsingApiName.search:
|
||||
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
|
||||
|
||||
case WebBrowsingApiName.crawlSinglePage: {
|
||||
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
|
||||
return <PageContent messageId={messageId} result={result} />;
|
||||
}
|
||||
|
||||
case WebBrowsingApiName.crawlMultiPages:
|
||||
return (
|
||||
<PageContents
|
||||
messageId={messageId}
|
||||
results={(state as CrawlPluginState).results}
|
||||
urls={args.urls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export default Portal;
|
||||
```
|
||||
|
||||
## Portal rules
|
||||
|
||||
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
|
||||
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
|
||||
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
|
||||
|
||||
## Portal registry — `packages/builtin-tools/src/portals.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { type BuiltinPortal } from '@lobechat/types';
|
||||
|
||||
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。具体的样式约定见 [shared-rules.md](shared-rules.md)。
|
||||
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
|
||||
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
|
||||
@@ -0,0 +1,101 @@
|
||||
# Render — Rich Result Card (optional)
|
||||
|
||||
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
|
||||
|
||||
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
|
||||
|
||||
## Props (`BuiltinRenderProps<Args, State, Content>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
|
||||
apiName?: string;
|
||||
args: Arguments; // final params from the LLM
|
||||
content: Content; // executor's content string (or parsed)
|
||||
identifier?: string;
|
||||
messageId: string; // for store lookups
|
||||
pluginError?: any; // from BuiltinToolResult.error
|
||||
pluginState?: State; // executor's state
|
||||
toolCallId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Two patterns
|
||||
|
||||
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
|
||||
|
||||
```tsx
|
||||
// client/Render/CrawlSinglePage.tsx
|
||||
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageContent from './PageContent';
|
||||
|
||||
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
|
||||
({ messageId, pluginState, args }) => (
|
||||
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
|
||||
),
|
||||
);
|
||||
export default CrawlSinglePage;
|
||||
```
|
||||
|
||||
**Pattern B — Folder with subcomponents** (web-browsing Search):
|
||||
|
||||
```
|
||||
client/Render/Search/
|
||||
├── index.tsx # composes the subcomponents, handles error states
|
||||
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
|
||||
├── SearchQuery.tsx # editable query header
|
||||
└── SearchResult.tsx # result list
|
||||
```
|
||||
|
||||
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
|
||||
|
||||
## Error handling in Render
|
||||
|
||||
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
|
||||
|
||||
```tsx
|
||||
if (pluginError) {
|
||||
if (pluginError?.type === 'PluginSettingsInvalid') {
|
||||
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
title={pluginError?.message}
|
||||
type="error"
|
||||
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Render rules
|
||||
|
||||
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
|
||||
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
|
||||
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
|
||||
- **Keep the Render single-layer** — the tool card is already your surface, so don't open with your own filled container and then nest more filled boxes inside it. See [shared-rules.md](shared-rules.md) → "Stay single-layer".
|
||||
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
## Render registry — `client/Render/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import CreateTaskRender from './CreateTask';
|
||||
import RunTasksRender from './RunTasks';
|
||||
|
||||
export const TaskRenders: Record<string, BuiltinRender> = {
|
||||
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
|
||||
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
|
||||
/* only the APIs with rich result UI — others fall back to text content */
|
||||
};
|
||||
|
||||
export { default as CreateTaskRender } from './CreateTask';
|
||||
export { default as RunTasksRender } from './RunTasks';
|
||||
```
|
||||
|
||||
## Render display control (rare)
|
||||
|
||||
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
|
||||
## The component skeleton
|
||||
|
||||
Every surface file is the same shape, so internalize it once instead of re-deriving it per rule. The skeleton below bakes in five mechanical conventions — copy it and fill the body:
|
||||
|
||||
```tsx
|
||||
'use client'; // (a) leaves of the chat tree must not block server rendering
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// (b) type with BuiltinXProps<Args, State> — never widen to `any`.
|
||||
// Args = the JSON Schema params, State = the executor's `state` field;
|
||||
// they should match <Name>Params / <Name>State from types.ts.
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, pluginState }) => {
|
||||
const { t } = useTranslation('plugin'); // (c) all strings from the `plugin` namespace
|
||||
|
||||
// (d) cross-cutting state (loading, streaming buffer) comes from the store,
|
||||
// not props — props only carry args/state/messageId.
|
||||
// const buffer = useChatStore((s) => chatToolSelectors.streamingBuffer(messageId)(s));
|
||||
|
||||
return <span>{t('builtins.<identifier>.apiName.search')}</span>;
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector'; // (e) always memo + displayName
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
- **(c)** Default an Inspector to `t('builtins.<identifier>.apiName.<api>')` so the row is non-empty while args stream in.
|
||||
- **(d)** Read the store via Zustand selectors inside the component; see [streaming.md](streaming.md) for the buffer selector.
|
||||
|
||||
## Styling: `createStaticStyles + cssVar.*`, `@lobehub/ui` over `antd`
|
||||
|
||||
Zero-runtime CSS-in-JS — styles compile once and read CSS variables at runtime:
|
||||
|
||||
```tsx
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
```
|
||||
|
||||
- Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
|
||||
- Components come from `@lobehub/ui` (`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton`), not raw `antd`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
- Note: `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. For that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
|
||||
|
||||
## Stay single-layer — don't nest filled cards
|
||||
|
||||
The framework already wraps every Render / Intervention in a tool card, so that card **is** your surface. A Render that opens with its own `background: ${cssVar.colorFillQuaternary}` container is already one card deep; put another filled box inside it (`colorBgContainer` / `colorFillTertiary`) and you get the card-in-card look that reads as "complex" — two or three stacked fills for what is really a flat list of fields.
|
||||
|
||||
- **The outermost wrapper carries no fill.** Use a flat container with only `padding-block: 4px` for breathing room; let the tool card provide the card. (See `Agent/index.tsx`'s `container`.)
|
||||
- **At most one filled box, and only to delineate real content** — a Markdown preview, a diff, a code/result block. Labels, key–value fields, question/answer text, chips: render flat on the surface, separated by spacing or a hairline divider (`height: 1px; background: ${cssVar.colorFillSecondary}`), not by wrapping each in its own box.
|
||||
- **A box on a flat surface needs a visible fill.** Once the outer fill is gone, an inner `colorBgContainer` box can vanish against the tool card (same color). Use `colorFillTertiary` for the one content box so it still reads as delineated.
|
||||
- Don't wrap a single value in a box just to give it padding — that's the redundant-nesting smell (a `detailCard` around a `value` box around one string).
|
||||
|
||||
```tsx
|
||||
// ❌ card-in-card: filled container wrapping a filled preview box
|
||||
container: css`
|
||||
padding: 12px;
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
previewBox: css`
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
|
||||
// ✅ single-layer: flat container, one visible content box
|
||||
container: css`
|
||||
padding-block: 4px;
|
||||
`,
|
||||
previewBox: css`
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
```
|
||||
|
||||
For the common "icon + file/title header, then one content box" shape, reuse `ToolResultCard` from `@lobechat/shared-tool-ui/components` instead of rebuilding it — it's already single-layer (flat wrapper, one `colorFillTertiary` content box) and is what CC `Read` / `Grep` / `Glob` / `Write` / `WebSearch` / `WebFetch` render through.
|
||||
|
||||
The exception is a deliberate **panel** pattern — an `<Block variant="outlined">` with a header bar + list rows (CC `TodoWrite` / `Task`). There the single outlined block is the panel and the header fill is a header bar, not a nested card. One structured panel is fine; stacked decorative fills are not.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Streaming — Live Output During Execution (optional)
|
||||
|
||||
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
|
||||
|
||||
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
|
||||
|
||||
## Props (`BuiltinStreamingProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinStreamingProps<Arguments = any> {
|
||||
apiName: string;
|
||||
args: Arguments;
|
||||
identifier: string;
|
||||
messageId: string; // use to fetch the streaming buffer from store
|
||||
toolCallId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
|
||||
|
||||
## Canonical example — RunCommandStreaming
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface RunCommandParams {
|
||||
command?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
|
||||
const { command } = args || {};
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
animated
|
||||
wrap
|
||||
language="sh"
|
||||
showLanguage={false}
|
||||
style={{ padding: '4px 8px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{command}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
RunCommandStreaming.displayName = 'RunCommandStreaming';
|
||||
```
|
||||
|
||||
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
|
||||
|
||||
```tsx
|
||||
const buffer = useChatStore((state) =>
|
||||
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
|
||||
);
|
||||
```
|
||||
|
||||
## Streaming rules
|
||||
|
||||
- Render `null` until you have something to display (avoids flash).
|
||||
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
|
||||
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
|
||||
|
||||
## Streaming registry — `client/Streaming/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import { RunCommandStreaming } from './RunCommand';
|
||||
import { WriteFileStreaming } from './WriteFile';
|
||||
|
||||
export const LocalSystemStreamings = {
|
||||
[LocalSystemApiName.runCommand]: RunCommandStreaming,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
|
||||
};
|
||||
```
|
||||
@@ -1,13 +1,6 @@
|
||||
---
|
||||
name: chat-sdk
|
||||
description: >
|
||||
Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
|
||||
(1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot,
|
||||
(2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming,
|
||||
(3) Set up webhook handlers for chat platforms,
|
||||
(4) Send interactive cards or stream AI responses to chat platforms.
|
||||
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
|
||||
building bots that work across multiple chat platforms.
|
||||
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: data-fetching
|
||||
description: Data fetching architecture guide using Service layer + Zustand Store + SWR. Use when implementing data fetching, creating services, working with store hooks, or migrating from useEffect. Triggers on data loading, API calls, service creation, or store data fetching tasks.
|
||||
name: data-fetching-architecture
|
||||
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
|
||||
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
|
||||
---
|
||||
|
||||
# Docs Changelog Writing Guide
|
||||
|
||||
@@ -397,35 +397,60 @@ The pattern is the same for every platform:
|
||||
|
||||
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
|
||||
|
||||
| Platform | Reference | Quick switcher |
|
||||
| ------------- | -------------------------------------------------- | -------------- |
|
||||
| Discord | [references/discord.md](./references/discord.md) | `Cmd+K` |
|
||||
| Slack | [references/slack.md](./references/slack.md) | `Cmd+K` |
|
||||
| Telegram | [references/telegram.md](./references/telegram.md) | `Cmd+F` |
|
||||
| WeChat / 微信 | [references/wechat.md](./references/wechat.md) | `Cmd+F` |
|
||||
| Lark / 飞书 | [references/lark.md](./references/lark.md) | `Cmd+K` |
|
||||
| QQ | [references/qq.md](./references/qq.md) | `Cmd+F` |
|
||||
Each channel has its own folder under `bot/<channel>/` containing an `index.md`
|
||||
(activation, navigation, send-message, and verification snippets specific to
|
||||
that app) and its test script:
|
||||
|
||||
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [references/osascript-common.md](./references/osascript-common.md). Read this first if you're new to osascript automation.
|
||||
| Platform | Reference | Quick switcher |
|
||||
| ------------- | ------------------------------------------------ | -------------- |
|
||||
| Discord | [bot/discord/index.md](./bot/discord/index.md) | `Cmd+K` |
|
||||
| Slack | [bot/slack/index.md](./bot/slack/index.md) | `Cmd+K` |
|
||||
| Telegram | [bot/telegram/index.md](./bot/telegram/index.md) | `Cmd+F` |
|
||||
| WeChat / 微信 | [bot/wechat/index.md](./bot/wechat/index.md) | `Cmd+F` |
|
||||
| Lark / 飞书 | [bot/lark/index.md](./bot/lark/index.md) | `Cmd+K` |
|
||||
| QQ | [bot/qq/index.md](./bot/qq/index.md) | `Cmd+F` |
|
||||
|
||||
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [bot/osascript-common.md](./bot/osascript-common.md). Read this first if you're new to osascript automation.
|
||||
|
||||
## Bridge-based channels (no native app)
|
||||
|
||||
Some channels have no native app to drive with osascript — they connect through
|
||||
a local bridge inside the Desktop app. These are tested with agent-browser
|
||||
(IPC + UI) plus the bridge's own HTTP/REST endpoints, not osascript:
|
||||
|
||||
| Channel | Reference | What it drives |
|
||||
| -------- | ------------------------------------------------ | -------------------------------------------------------- |
|
||||
| iMessage | [bot/imessage/index.md](./bot/imessage/index.md) | `imessageBridge.*` IPC + local bridge + BlueBubbles REST |
|
||||
|
||||
For iMessage there is a one-shot regression script — see `test-imessage-bridge.sh` below.
|
||||
|
||||
---
|
||||
|
||||
# Scripts
|
||||
|
||||
Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
|
||||
**App / recording scripts** in `.agents/skills/local-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart) |
|
||||
| `capture-app-window.sh` | Capture screenshot of a specific app window |
|
||||
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
|
||||
| `record-app-screen.sh` | Record app screen (video + screenshots, start/stop) |
|
||||
| `test-discord-bot.sh` | Send message to Discord bot via osascript |
|
||||
| `test-slack-bot.sh` | Send message to Slack bot via osascript |
|
||||
| `test-telegram-bot.sh` | Send message to Telegram bot via osascript |
|
||||
| `test-wechat-bot.sh` | Send message to WeChat bot via osascript |
|
||||
| `test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
|
||||
| `test-qq-bot.sh` | Send message to QQ bot via osascript |
|
||||
|
||||
**Bot scripts** live under `.agents/skills/local-testing/bot/`, one folder per
|
||||
channel (alongside that channel's `index.md`). The shared
|
||||
`capture-app-window.sh` sits at the `bot/` root:
|
||||
|
||||
| Script | Usage |
|
||||
| ---------------------------------- | ------------------------------------------------------------------- |
|
||||
| `capture-app-window.sh` | Capture screenshot of a specific app window (used by bot tests) |
|
||||
| `discord/test-discord-bot.sh` | Send message to Discord bot via osascript |
|
||||
| `slack/test-slack-bot.sh` | Send message to Slack bot via osascript |
|
||||
| `telegram/test-telegram-bot.sh` | Send message to Telegram bot via osascript |
|
||||
| `wechat/test-wechat-bot.sh` | Send message to WeChat bot via osascript |
|
||||
| `lark/test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
|
||||
| `qq/test-qq-bot.sh` | Send message to QQ bot via osascript |
|
||||
| `imessage/test-imessage-bridge.sh` | Regression-test the iMessage BlueBubbles bridge (IPC + HTTP) |
|
||||
| `imessage/send-imessage-test.sh` | Send one real iMessage (desktop → BB → iMessage) and verify it sent |
|
||||
|
||||
### Window Screenshot Utility
|
||||
|
||||
@@ -433,9 +458,9 @@ Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
|
||||
|
||||
```bash
|
||||
# Standalone usage
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Discord" /tmp/discord.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Slack" /tmp/slack.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "WeChat" /tmp/wechat.png
|
||||
./.agents/skills/local-testing/bot/capture-app-window.sh "Discord" /tmp/discord.png
|
||||
./.agents/skills/local-testing/bot/capture-app-window.sh "Slack" /tmp/slack.png
|
||||
./.agents/skills/local-testing/bot/capture-app-window.sh "WeChat" /tmp/wechat.png
|
||||
```
|
||||
|
||||
All bot test scripts use this utility automatically for their screenshots.
|
||||
@@ -452,32 +477,48 @@ Examples:
|
||||
|
||||
```bash
|
||||
# Discord — test a bot in #bot-testing channel
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
|
||||
# Slack — test a bot in #bot-testing channel
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
|
||||
# Telegram — test a bot by username
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
|
||||
# WeChat — test a bot or send to a contact
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
|
||||
# Lark/飞书 — test a bot in a group chat
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
|
||||
# QQ — test a bot in a group or direct chat
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
|
||||
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
|
||||
```
|
||||
|
||||
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
|
||||
|
||||
### iMessage bridge regression script
|
||||
|
||||
`test-imessage-bridge.sh` does **not** follow the osascript bot interface — it
|
||||
drives the Desktop bridge's IPC + HTTP layers and asserts the result, then
|
||||
self-cleans. Needs BlueBubbles running and Electron up with CDP.
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
|
||||
# defaults: bb_url=http://127.0.0.1:1234 cdp_port=9222 — exit 0 = all green
|
||||
```
|
||||
|
||||
It guards the connect/configure flow (testConfig happy + reject paths, first-time
|
||||
`upsertConfig` save, bridge running + webhook registered, local-server secret
|
||||
enforcement). See [bot/imessage/index.md](./bot/imessage/index.md)
|
||||
for the full manual UI flow and known bugs.
|
||||
|
||||
---
|
||||
|
||||
# Screen Recording
|
||||
@@ -517,4 +558,4 @@ Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/`
|
||||
|
||||
### osascript
|
||||
|
||||
See [references/osascript-common.md](./references/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
|
||||
See [bot/osascript-common.md](./bot/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**App name:** `Discord` | **Process name:** `Discord`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
See [osascript-common.md](../osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
@@ -92,6 +92,6 @@ echo "Screenshot saved to /tmp/discord-test-result.png"
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
```
|
||||
+1
-1
@@ -60,5 +60,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -0,0 +1,232 @@
|
||||
# iMessage Desktop bridge regression test
|
||||
|
||||
The iMessage channel is different from the other bot platforms: there is **no
|
||||
native app to drive with osascript**. Instead the Desktop app runs a local
|
||||
**BlueBubbles bridge** — a small HTTP server in the Electron main process that
|
||||
registers a webhook on a local [BlueBubbles](https://bluebubbles.app/) server,
|
||||
receives iMessage events, and forwards them to LobeHub Cloud.
|
||||
|
||||
So the test surface is three layers:
|
||||
|
||||
1. **Electron main IPC** — `imessageBridge.*` handlers (`getStatus`,
|
||||
`testConfig`, `upsertConfig`, `removeConfig`, `start`, `stop`)
|
||||
2. **Local bridge HTTP server** — `http://127.0.0.1:<port>/webhooks/bluebubbles/<appId>?secret=<secret>`
|
||||
3. **BlueBubbles REST API** — `http://127.0.0.1:1234/api/v1/*` (webhook + server/info)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running **BlueBubbles server** (macOS, default `http://127.0.0.1:1234`) with
|
||||
a known password. Sanity check:
|
||||
```bash
|
||||
curl -sS -m4 -o /dev/null -w '%{http_code}\n' \
|
||||
"http://127.0.0.1:1234/api/v1/server/info?password=<PW>" # expect 200
|
||||
```
|
||||
- **Electron dev running with CDP**: `./.agents/skills/local-testing/scripts/electron-dev.sh start`
|
||||
- The **iMessage Desktop branch** checked out (the `imessageBridge` IPC group
|
||||
and `@lobechat/chat-adapter-imessage` must be compiled into the main bundle).
|
||||
Run `pnpm install --ignore-scripts` at the repo root **and** in `apps/desktop/`
|
||||
after switching branches — the new workspace package must be linked or the
|
||||
main build fails to resolve `@lobechat/chat-adapter-imessage`.
|
||||
|
||||
## Fast path: automated script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
|
||||
```
|
||||
|
||||
Asserts the whole flow and self-cleans (unique `applicationId` per run, removes
|
||||
its bridge config + BlueBubbles webhook on exit). Exit 0 = all green. It covers:
|
||||
|
||||
- BlueBubbles reachable + password valid; Electron CDP reachable; IPC available
|
||||
- `testConfig` happy path → success
|
||||
- `testConfig` wrong password → rejected; unreachable URL → rejected
|
||||
- `upsertConfig` **first-time save → success** (Bug #1 regression guard, below)
|
||||
- `getStatus` → `running:true`, config persisted, password redacted (`blueBubblesPasswordSet`)
|
||||
- BlueBubbles webhook actually registered for the appId
|
||||
- Local bridge HTTP server: wrong secret → 401; valid secret → past auth
|
||||
|
||||
The password is passed as argv (visible in `ps`) — local dev only, don't use a
|
||||
real secret on a shared machine.
|
||||
|
||||
## Layer 1 — IPC probes (no UI)
|
||||
|
||||
The renderer exposes the main-process handlers via `window.electronAPI.invoke`.
|
||||
This is the quickest way to exercise the bridge without clicking:
|
||||
|
||||
```bash
|
||||
# baseline
|
||||
agent-browser --cdp 9222 eval \
|
||||
"(async()=>JSON.stringify(await window.electronAPI.invoke('imessageBridge.getStatus',{})))()"
|
||||
|
||||
# test a connection (note: password as a JS string)
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(async function () {
|
||||
try {
|
||||
var r = await window.electronAPI.invoke('imessageBridge.testConfig', {
|
||||
applicationId: 'probe',
|
||||
blueBubblesServerUrl: 'http://127.0.0.1:1234',
|
||||
blueBubblesPassword: 'PASTE_PW',
|
||||
enabled: true,
|
||||
webhookSecret: 'probe-secret',
|
||||
});
|
||||
return JSON.stringify(r); // { success: true }
|
||||
} catch (e) { return 'ERR: ' + (e.message || e); }
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
`upsertConfig` persists to the Electron store, starts the local HTTP server, and
|
||||
registers the BlueBubbles webhook. `removeConfig` + `stop` reverse it.
|
||||
|
||||
## Layer 2 — full UI flow (agent-browser)
|
||||
|
||||
The bridge settings only render in Desktop (`isDesktop` guard) under the agent's
|
||||
**Channel → iMessage** screen. The platform tile only appears as a real (non
|
||||
"Coming Soon") entry once the server registers `imessage` **and** the frontend
|
||||
drops it from `COMING_SOON_PLATFORMS` (`src/routes/(main)/agent/channel/const.ts`).
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 open "http://localhost:5173/agent/<aid>/channel"
|
||||
agent-browser --cdp 9222 wait --load networkidle && agent-browser --cdp 9222 wait 1500
|
||||
|
||||
# confirm the remote backend lists imessage (it must be registered + deployed)
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(async function(){
|
||||
var url='lobe-backend://lobe/trpc/lambda/agentBotProvider.listPlatforms?input='+encodeURIComponent('{"json":null,"meta":{"values":["undefined"],"v":1}}');
|
||||
var d=await (await fetch(url,{credentials:'include'})).json();
|
||||
var p=d.result?.data?.json||d;
|
||||
return JSON.stringify(p.map(function(x){return x.id;}));
|
||||
})()
|
||||
EVALEOF
|
||||
|
||||
# click the iMessage tile, then fill the form by ref
|
||||
agent-browser --cdp 9222 eval "(()=>{var b=[...document.querySelectorAll('aside button')].find(x=>/imessage/i.test(x.textContent));b&&b.click();})()"
|
||||
agent-browser --cdp 9222 wait 1500
|
||||
agent-browser --cdp 9222 snapshot -i | grep -iE "127.0.0.1:1234|Application ID|Webhook Secret|Test BlueBubbles|Save Bridge"
|
||||
```
|
||||
|
||||
Field refs (from the snapshot): Application ID, Webhook Secret, BlueBubbles
|
||||
Server URL (`placeholder="http://127.0.0.1:1234"`), and a **nested** textbox right
|
||||
under the URL one is the BlueBubbles Password. Fill with `fill` (real input
|
||||
events — `eval`-setting React inputs won't fire onChange), click **Test
|
||||
BlueBubbles**, then **Save Bridge**. Read the antd toast immediately (it
|
||||
auto-dismisses):
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval \
|
||||
"JSON.stringify([...new Set([...document.querySelectorAll('.ant-message-custom-content')].map(n=>n.textContent.trim()))])"
|
||||
# Test → "BlueBubbles connection passed"
|
||||
# Save → "iMessage Desktop bridge saved"
|
||||
```
|
||||
|
||||
Verify the end state via BlueBubbles + IPC:
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:1234/api/v1/webhook?password=<PW>" # webhook for the appId present
|
||||
agent-browser --cdp 9222 eval "(async()=>JSON.stringify(await window.electronAPI.invoke('imessageBridge.getStatus',{})))()"
|
||||
# running:true, serverUrl: http://127.0.0.1:33270, configs[].blueBubblesPasswordSet:true
|
||||
```
|
||||
|
||||
Cleanup: `removeConfig` + `stop` via IPC, then `DELETE /api/v1/webhook/<id>` on
|
||||
BlueBubbles.
|
||||
|
||||
## Outbound send test (desktop → BlueBubbles → iMessage)
|
||||
|
||||
Verifies the leg the bridge uses to _reply_: `BlueBubblesApiClient.sendText`
|
||||
→ `POST /api/v1/message/text`. Run the helper against your own number:
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/bot/imessage/send-imessage-test.sh '<bb_password>' '+<E164>' # e.g. +15551234567
|
||||
```
|
||||
|
||||
**Gotcha that bites everyone:** with `method=apple-script` and a _new_
|
||||
conversation, the HTTP POST often **times out** even though the message is
|
||||
sent. Never judge success by the HTTP response. Instead poll
|
||||
`POST /api/v1/message/query` and read the matching `isFromMe:true` row's
|
||||
`error` field:
|
||||
|
||||
- `error: 0` (or null) → sent OK
|
||||
- non-zero `error` → real send failure
|
||||
|
||||
The script does exactly this: fires the send, ignores the timeout, then matches
|
||||
its marker text in the message store and asserts `error == 0`.
|
||||
|
||||
Two more notes:
|
||||
|
||||
- Use a full E.164 handle (`iMessage;-;+<countrycode><number>`) or an Apple ID
|
||||
email. Looking the chat up by guid afterwards may 404 if BB filed the message
|
||||
under a differently-formatted guid — that's a lookup quirk, not a send failure.
|
||||
- Sending to _your own_ number round-trips: BB records both the outgoing
|
||||
(`fromMe:true`) and an incoming copy (`fromMe:false`).
|
||||
|
||||
## Inbound e2e test (iMessage → cloud agent → reply)
|
||||
|
||||
Full inbound chain: a message arrives → BlueBubbles fires its `new-message`
|
||||
webhook → local bridge (`:33270`) → `forwardWebhook` POSTs to
|
||||
`<remote>/api/agent/webhooks/imessage/<appId>?secret=…` → cloud agent → reply
|
||||
flows back via Device Gateway → BB `sendText`.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- A cloud bot provider for the same `applicationId` exists and is **connected**
|
||||
(Save Configuration + the device gateway connected — a _disconnected_ gateway
|
||||
yields `DEVICE_NOT_FOUND` on connect and blocks the reply leg).
|
||||
- The `imessage` Labs toggle is on (otherwise the channel is gated to "Coming
|
||||
Soon"), and `webhookSecret` matches on both ends (auto-generated on save).
|
||||
|
||||
Two ways to drive it:
|
||||
|
||||
1. **Second device / Apple ID (recommended).** Have _another_ Apple ID message
|
||||
the BB-hosted number (e.g. "please reply pong"). The bot replies; you see it
|
||||
on the other device. **No loop risk** — the reply goes to the other party,
|
||||
not back to itself.
|
||||
2. **Send to your own number (quick, loop-aware).** `sendText` to the hosted
|
||||
number; the loopback _incoming_ copy (`isFromMe:false`) triggers the bot.
|
||||
Watch the reply land in `message/query` as a `fromMe:true` row.
|
||||
|
||||
**Loop guard — why a self-send doesn't spin forever:** the Chat SDK adapter
|
||||
drops any `isFromMe` message before dispatch
|
||||
(`packages/chat-adapter-imessage/src/adapter.ts`: `if (message.isFromMe) return`).
|
||||
The bot's own reply (`isFromMe:true`) is never re-processed, so in the normal
|
||||
case (someone else → bot → reply to them) there is no loop. The self-send case
|
||||
is a **test-only edge**: the bot's reply also round-trips to your number, and
|
||||
only the adapter's `isFromMe` check stops a second pass. Keep the prompt
|
||||
conversational (so the bot doesn't keep finding something to answer), and
|
||||
**turn the `imessage` lab off / remove the config when done** — never leave a
|
||||
self-send bot running unattended.
|
||||
|
||||
Watch the chain live:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/electron-dev.log | grep -iE "imessage|bridge|forward|Message API"
|
||||
# the agent reply shows up as a fromMe:true row with the bot's text:
|
||||
curl -sS -X POST "http://127.0.0.1:1234/api/v1/message/query?password=<PW>" \
|
||||
-H 'Content-Type: application/json' -d '{"limit":5,"sort":"DESC"}'
|
||||
```
|
||||
|
||||
`startTyping` will log a Private-API error unless BlueBubbles has the Private
|
||||
API helper set up (needs a jailbroken / SIP-disabled Mac) — it's logged and
|
||||
ignored; text replies still work.
|
||||
|
||||
## Known bugs / gotchas
|
||||
|
||||
- **Bug #1 — first-time save (fixed; guarded by the script).** BlueBubbles'
|
||||
`GET /api/v1/webhook?url=<unregistered>` returns **HTTP 500**
|
||||
(`Cannot read properties of null (reading 'events')`). The bridge must list
|
||||
**all** webhooks and match client-side, never pass the `?url=` filter. If you
|
||||
see `upsertConfig` fail with "An unhandled error has occurred!" originating in
|
||||
`listWebhooks`, this regressed.
|
||||
- **Save leaves a half-state on webhook failure.** `upsertConfig` writes the
|
||||
config + starts the HTTP server _before_ registering the webhook, so a webhook
|
||||
failure still reports `running:true` with the config persisted but no
|
||||
BlueBubbles webhook. Always assert the BlueBubbles webhook list, not just IPC
|
||||
status.
|
||||
- **Unknown appId / forward failure → 500.** Posting to the local bridge for an
|
||||
unknown appId, or when no cloud bot is bound, returns 500 (BlueBubbles retries
|
||||
on 5xx). Auth (wrong secret → 401) is enforced before that.
|
||||
- **Backend deploy lag.** Desktop dev proxies tRPC through `lobe-backend://` to
|
||||
the _remote_ server. iMessage only appears in `listPlatforms` once the server
|
||||
registration is deployed there, regardless of local branch.
|
||||
- **Restart to load main-process fixes.** Editing `imessageBridgeSrv.ts` /
|
||||
`@lobechat/chat-adapter-imessage` needs `electron-dev.sh restart` — main isn't
|
||||
hot-replaced. On restart, enabled configs auto-register their webhook again.
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# send-imessage-test.sh — Verify the outbound leg: desktop → BlueBubbles → iMessage
|
||||
#
|
||||
# Sends one real iMessage via the same REST call the Desktop bridge uses
|
||||
# (`POST /api/v1/message/text`, which BlueBubblesApiClient.sendText wraps) and
|
||||
# confirms it actually went out.
|
||||
#
|
||||
# KEY GOTCHA: with method=apple-script and a NEW conversation, the HTTP request
|
||||
# often TIMES OUT even though the message is sent. Do NOT treat the timeout as a
|
||||
# failure — instead poll `POST /api/v1/message/query` and check the message's
|
||||
# `error` field (0 = sent OK). This script does that for you.
|
||||
#
|
||||
# This sends a REAL message, so it has side effects. Target your own number.
|
||||
#
|
||||
# Usage:
|
||||
# ./send-imessage-test.sh <bb_password> <target_e164> [message] [bb_url]
|
||||
#
|
||||
# Example (send to your own phone, E.164 with country code):
|
||||
# ./send-imessage-test.sh 'my-bb-pass' '+15551234567'
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BB_PASS="${1:?Usage: $0 <bb_password> <target_e164(+countrycode)> [message] [bb_url]}"
|
||||
TARGET="${2:?Need a target handle in E.164, e.g. +15551234567 (or an Apple ID email)}"
|
||||
MARKER="lobe-imsg-test-$(date +%s)"
|
||||
MESSAGE="${3:-[${MARKER}] desktop bridge → BlueBubbles → iMessage outbound check}"
|
||||
BB_URL="${4:-http://127.0.0.1:1234}"
|
||||
|
||||
CHAT_GUID="iMessage;-;${TARGET}"
|
||||
|
||||
echo "[send-test] target=${TARGET} marker=${MARKER}"
|
||||
|
||||
# 1) Fire the send. apple-script on a new chat may hang the HTTP response, so we
|
||||
# cap it short and ignore a timeout — step 2 is the source of truth.
|
||||
python3 - "$BB_PASS" "$BB_URL" "$CHAT_GUID" "$MESSAGE" <<'PY' || true
|
||||
import json,sys,urllib.request,urllib.parse,uuid
|
||||
pw,base,guid,msg=sys.argv[1:5]
|
||||
url=base+"/api/v1/message/text?password="+urllib.parse.quote(pw)
|
||||
body={"chatGuid":guid,"message":msg,"method":"apple-script","tempGuid":str(uuid.uuid4())}
|
||||
req=urllib.request.Request(url,data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type":"application/json"},method="POST")
|
||||
try:
|
||||
r=urllib.request.urlopen(req,timeout=8)
|
||||
print("[send-test] HTTP",r.status,"(immediate response)")
|
||||
except urllib.error.HTTPError as e:
|
||||
print("[send-test] HTTP",e.code,e.read().decode()[:200])
|
||||
except Exception as e:
|
||||
print("[send-test] HTTP request returned no body (likely apple-script delay):",type(e).__name__)
|
||||
PY
|
||||
|
||||
# 2) Source of truth: find our marker in the message store and read its error.
|
||||
echo "[send-test] verifying via message/query (the HTTP timeout above is expected)…"
|
||||
sleep 3
|
||||
python3 - "$BB_PASS" "$BB_URL" "$MARKER" <<'PY'
|
||||
import json,sys,time,urllib.request,urllib.parse
|
||||
pw,base,marker=sys.argv[1:4]
|
||||
url=base+"/api/v1/message/query?password="+urllib.parse.quote(pw)
|
||||
def query():
|
||||
body={"limit":15,"offset":0,"with":["chats"],"sort":"DESC"}
|
||||
req=urllib.request.Request(url,data=json.dumps(body).encode(),
|
||||
headers={"Content-Type":"application/json"},method="POST")
|
||||
return json.load(urllib.request.urlopen(req,timeout=12)).get("data") or []
|
||||
hit=None
|
||||
for _ in range(5):
|
||||
for m in query():
|
||||
if marker in (m.get("text") or "") and m.get("isFromMe"):
|
||||
hit=m; break
|
||||
if hit: break
|
||||
time.sleep(2)
|
||||
if not hit:
|
||||
print("[send-test] ✗ outbound message not found in BB store — send likely failed")
|
||||
sys.exit(1)
|
||||
err=hit.get("error")
|
||||
if err in (0,None):
|
||||
print("[send-test] ✓ outbound message sent (fromMe=True, error=%s)"%err)
|
||||
print("[send-test] → confirm it arrived in the Messages app on the target device")
|
||||
else:
|
||||
print("[send-test] ✗ BlueBubbles reported send error=%s"%err)
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-imessage-bridge.sh — Regression test for the iMessage Desktop bridge
|
||||
#
|
||||
# Drives the Electron main-process `imessageBridge.*` IPC handlers plus the
|
||||
# local bridge HTTP server and the BlueBubbles server, asserting the full
|
||||
# connect/configure flow. Use it to regression-test PR work on the iMessage
|
||||
# channel (BlueBubbles bridge) without clicking through the UI every time.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. BlueBubbles server running and reachable (default http://127.0.0.1:1234)
|
||||
# 2. Electron dev running with CDP — `electron-dev.sh start`
|
||||
# 3. `agent-browser` on PATH, connected to the same CDP port
|
||||
#
|
||||
# Usage:
|
||||
# ./test-imessage-bridge.sh <bluebubbles_password> [bb_url] [cdp_port]
|
||||
#
|
||||
# Example:
|
||||
# ./test-imessage-bridge.sh 'my-bb-password'
|
||||
# ./test-imessage-bridge.sh 'my-bb-password' http://127.0.0.1:1234 9222
|
||||
#
|
||||
# Notes:
|
||||
# - The password is passed as an argv, so it is visible in `ps`. This is a
|
||||
# local dev tool; do not run it on shared machines with a real secret.
|
||||
# - It uses a unique applicationId per run (imsg-regression-$$) and cleans up
|
||||
# its own bridge config + BlueBubbles webhook on exit, so it is safe to
|
||||
# re-run and does not disturb real configs.
|
||||
set -euo pipefail
|
||||
|
||||
BB_PASS="${1:?Usage: $0 <bluebubbles_password> [bb_url] [cdp_port]}"
|
||||
BB_URL="${2:-http://127.0.0.1:1234}"
|
||||
CDP_PORT="${3:-9222}"
|
||||
|
||||
APP_ID="imsg-regression-$$"
|
||||
SECRET="regression-secret-$$"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# ── Output helpers ───────────────────────────────────────────────────
|
||||
ok() { echo " ✓ $1"; PASS=$((PASS + 1)); }
|
||||
bad() { echo " ✗ $1 — $2"; FAIL=$((FAIL + 1)); }
|
||||
note() { echo "[imsg-test] $1"; }
|
||||
|
||||
# ── BlueBubbles REST helpers ─────────────────────────────────────────
|
||||
bb_get_webhooks() {
|
||||
curl -sS -m 8 "${BB_URL}/api/v1/webhook?password=${BB_PASS}"
|
||||
}
|
||||
|
||||
# Delete every webhook whose URL mentions our APP_ID (cleanup is idempotent).
|
||||
bb_cleanup_webhooks() {
|
||||
local ids
|
||||
ids=$(bb_get_webhooks | python3 -c '
|
||||
import json,sys
|
||||
try: d=json.load(sys.stdin)
|
||||
except Exception: sys.exit(0)
|
||||
for w in (d.get("data") or []):
|
||||
if "'"$APP_ID"'" in (w.get("url") or ""): print(w["id"])
|
||||
' 2>/dev/null || true)
|
||||
for id in $ids; do
|
||||
curl -sS -m 8 -X DELETE "${BB_URL}/api/v1/webhook/${id}?password=${BB_PASS}" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
# ── IPC helper (drives the Electron renderer's electronAPI bridge) ───
|
||||
# Runs a JS snippet that returns a string token; prints the raw token.
|
||||
# The BlueBubbles password is base64-injected (atob) so special chars in the
|
||||
# secret never need shell/JS quoting.
|
||||
ipc_eval() {
|
||||
local js="$1"
|
||||
agent-browser --cdp "$CDP_PORT" eval -b "$(printf '%s' "$js" | base64)" 2>/dev/null
|
||||
}
|
||||
|
||||
PASS_B64=$(printf '%s' "$BB_PASS" | base64)
|
||||
|
||||
# Emit an inline JS object literal for the bridge config. $1 overrides the
|
||||
# password expression (defaults to atob of the real password); pass a JS string
|
||||
# literal like "'wrong'" to test the rejection path.
|
||||
ipc_config_js() {
|
||||
local pwexpr="${1:-atob('${PASS_B64}')}"
|
||||
printf "{applicationId:'%s',blueBubblesServerUrl:'%s',blueBubblesPassword:%s,enabled:true,webhookSecret:'%s'}" \
|
||||
"$APP_ID" "$BB_URL" "$pwexpr" "$SECRET"
|
||||
}
|
||||
|
||||
# ── Preflight ────────────────────────────────────────────────────────
|
||||
note "BlueBubbles: ${BB_URL} CDP: ${CDP_PORT} appId: ${APP_ID}"
|
||||
|
||||
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' \
|
||||
"${BB_URL}/api/v1/server/info?password=${BB_PASS}" || echo 000)
|
||||
if [ "$code" = "200" ]; then ok "BlueBubbles reachable + password valid"; else
|
||||
bad "BlueBubbles preflight" "HTTP $code (is BlueBubbles running on ${BB_URL}?)"
|
||||
echo "Aborting — fix BlueBubbles first."; exit 1
|
||||
fi
|
||||
|
||||
if ! curl -sf --max-time 3 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
bad "Electron CDP preflight" "CDP ${CDP_PORT} unreachable — run electron-dev.sh start"
|
||||
echo "Aborting."; exit 1
|
||||
fi
|
||||
ok "Electron CDP reachable"
|
||||
|
||||
# Bridge must expose the IPC group (built from this branch's code).
|
||||
probe=$(ipc_eval "(async()=>{try{var s=await window.electronAPI.invoke('imessageBridge.getStatus',{});return 'OK:'+JSON.stringify(s);}catch(e){return 'ERR:'+(e.message||e);}})()")
|
||||
case "$probe" in
|
||||
*OK:*) ok "imessageBridge IPC available" ;;
|
||||
*) bad "imessageBridge IPC" "got: $probe (is the iMessage Desktop branch checked out?)"; echo "Aborting."; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Start clean: remove any leftover config for this appId + BB webhooks.
|
||||
ipc_eval "(async()=>{try{await window.electronAPI.invoke('imessageBridge.removeConfig',{applicationId:'${APP_ID}'});}catch(e){}return 'done';})()" >/dev/null
|
||||
bb_cleanup_webhooks
|
||||
|
||||
# ── testConfig: happy path ───────────────────────────────────────────
|
||||
r=$(ipc_eval "(async()=>{try{var c=$(ipc_config_js);var x=await window.electronAPI.invoke('imessageBridge.testConfig',c);return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
|
||||
case "$r" in
|
||||
*OK:*success*true*) ok "testConfig with valid password → success" ;;
|
||||
*) bad "testConfig (valid)" "got: $r" ;;
|
||||
esac
|
||||
|
||||
# ── testConfig: wrong password rejects ───────────────────────────────
|
||||
r=$(ipc_eval "(async()=>{try{var c=$(ipc_config_js "'definitely-wrong-password'");var x=await window.electronAPI.invoke('imessageBridge.testConfig',c);return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
|
||||
case "$r" in
|
||||
*ERR:*) ok "testConfig with wrong password → rejected" ;;
|
||||
*) bad "testConfig (wrong password)" "expected rejection, got: $r" ;;
|
||||
esac
|
||||
|
||||
# ── testConfig: unreachable URL rejects ──────────────────────────────
|
||||
r=$(ipc_eval "(async()=>{try{var x=await window.electronAPI.invoke('imessageBridge.testConfig',{applicationId:'${APP_ID}',blueBubblesServerUrl:'http://127.0.0.1:65530',blueBubblesPassword:atob('${PASS_B64}'),enabled:true,webhookSecret:'${SECRET}'});return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
|
||||
case "$r" in
|
||||
*ERR:*) ok "testConfig with unreachable URL → rejected" ;;
|
||||
*) bad "testConfig (unreachable)" "expected rejection, got: $r" ;;
|
||||
esac
|
||||
|
||||
# ── upsertConfig: FIRST-TIME registration (Bug #1 regression guard) ──
|
||||
# BlueBubbles' GET /webhook?url=<unregistered> returns HTTP 500. The bridge
|
||||
# must list ALL webhooks and match client-side, otherwise this first save
|
||||
# fails. This assertion guards that fix.
|
||||
r=$(ipc_eval "(async()=>{try{var c=$(ipc_config_js);var x=await window.electronAPI.invoke('imessageBridge.upsertConfig',c);return 'OK:'+JSON.stringify(x);}catch(e){return 'ERR:'+(e.message||e);}})()")
|
||||
case "$r" in
|
||||
*OK:*success*true*) ok "upsertConfig first-time save → success (Bug #1 guard)" ;;
|
||||
*) bad "upsertConfig (first-time)" "got: $r" ;;
|
||||
esac
|
||||
|
||||
# ── getStatus: bridge running + config persisted ─────────────────────
|
||||
# Return a quote-free token so grep isn't tripped up by agent-browser's
|
||||
# JSON-string escaping of the eval result.
|
||||
r=$(ipc_eval "(async()=>{var s=await window.electronAPI.invoke('imessageBridge.getStatus',{});var c=(s.configs||[]).find(function(x){return x.applicationId==='${APP_ID}';});return 'RUN='+(s.running?'Y':'N')+' CFG='+(c?'Y':'N')+' PW='+((c&&c.blueBubblesPasswordSet)?'Y':'N');})()")
|
||||
echo "$r" | grep -q 'RUN=Y' && ok "bridge running" || bad "bridge running" "got: $r"
|
||||
echo "$r" | grep -q 'CFG=Y' && ok "config persisted" || bad "config persisted" "got: $r"
|
||||
echo "$r" | grep -q 'PW=Y' && ok "password stored (redacted in status)" || bad "password stored" "got: $r"
|
||||
|
||||
# ── BlueBubbles webhook actually registered ──────────────────────────
|
||||
if bb_get_webhooks | grep -q "${APP_ID}"; then
|
||||
ok "BlueBubbles webhook registered for appId"
|
||||
else
|
||||
bad "BlueBubbles webhook" "no webhook URL containing ${APP_ID}"
|
||||
fi
|
||||
|
||||
# ── Local bridge HTTP server: secret enforcement ─────────────────────
|
||||
BRIDGE_URL=$(ipc_eval "(async()=>{var s=await window.electronAPI.invoke('imessageBridge.getStatus',{});return s.serverUrl||'';})()" | tr -d '"')
|
||||
if [ -n "$BRIDGE_URL" ]; then
|
||||
# wrong secret → 401
|
||||
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${BRIDGE_URL}/webhooks/bluebubbles/${APP_ID}?secret=WRONG" \
|
||||
-d '{"type":"new-message","data":{"guid":"x"}}' || echo 000)
|
||||
[ "$code" = "401" ] && ok "local bridge rejects wrong secret (401)" || bad "local bridge wrong secret" "expected 401, got $code"
|
||||
|
||||
# right secret → passes auth (reaches forward; without a bound cloud bot it
|
||||
# returns 5xx — that's fine, we're only asserting auth + routing here)
|
||||
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
"${BRIDGE_URL}/webhooks/bluebubbles/${APP_ID}?secret=${SECRET}" \
|
||||
-d '{"type":"new-message","data":{"guid":"x","text":"hi"}}' || echo 000)
|
||||
[ "$code" != "401" ] && ok "local bridge accepts valid secret (HTTP $code, past auth)" || bad "local bridge valid secret" "got 401 with correct secret"
|
||||
else
|
||||
bad "local bridge URL" "getStatus returned no serverUrl"
|
||||
fi
|
||||
|
||||
# ── Cleanup ──────────────────────────────────────────────────────────
|
||||
ipc_eval "(async()=>{try{await window.electronAPI.invoke('imessageBridge.removeConfig',{applicationId:'${APP_ID}'});await window.electronAPI.invoke('imessageBridge.stop',{});}catch(e){}return 'cleaned';})()" >/dev/null
|
||||
bb_cleanup_webhooks
|
||||
note "cleaned up config + BlueBubbles webhook for ${APP_ID}"
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[imsg-test] PASS=${PASS} FAIL=${FAIL}"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
See [osascript-common.md](../osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
@@ -56,6 +56,6 @@ screencapture /tmp/lark-bot-response.png
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
```
|
||||
+1
-1
@@ -80,5 +80,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**App name:** `QQ` | **Process name:** `QQ`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
See [osascript-common.md](../osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
@@ -57,6 +57,6 @@ screencapture /tmp/qq-bot-response.png
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
|
||||
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
|
||||
```
|
||||
+1
-1
@@ -72,5 +72,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**App name:** `Slack` | **Process name:** `Slack`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
See [osascript-common.md](../osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
@@ -68,6 +68,6 @@ screencapture /tmp/slack-bot-response.png
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
```
|
||||
+1
-1
@@ -60,5 +60,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**App name:** `Telegram` | **Process name:** `Telegram`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
See [osascript-common.md](../osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
@@ -75,6 +75,6 @@ curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | j
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
```
|
||||
+1
-1
@@ -75,5 +75,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
See [osascript-common.md](../osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
@@ -76,6 +76,6 @@ screencapture /tmp/wechat-bot-response.png
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
```
|
||||
+1
-1
@@ -81,5 +81,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
|
||||
description: "Create a PR for the current branch (targets `canary` by default), including splitting one cross-layer branch into ordered stacked PRs so a lower layer (db / shared package / server TRPC) merges before its callers (desktop / CLI / UI). Use when the user asks to create / submit a PR, or to split a branch because clients call a server contract that isn't on the trunk yet. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', 'split this PR', 'stacked PR', 'backend should merge first', '提 PR', '提个 PR', '新建 PR', '拆 PR', '后端先合', '分层合并'."
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
@@ -71,3 +71,82 @@ Use `.github/PULL_REQUEST_TEMPLATE.md` as the body structure. Key sections:
|
||||
|
||||
- **Language**: All PR content must be in English
|
||||
- If a PR already exists for the branch, inform the user instead of creating a duplicate
|
||||
|
||||
---
|
||||
|
||||
# Stacked PRs (cross-layer feature)
|
||||
|
||||
The steps above create **one** PR for the current branch. When a single branch lands across layers — `packages/database` schema/model → a shared `packages/*` lib → `src/server` TRPC → `apps/desktop` + `apps/cli` callers → `src/features` UI — shipping it as one PR can't merge safely: the clients call an endpoint that doesn't exist on the trunk until the same PR merges, so any partial/rollback or independent review breaks. Split it into **ordered PRs**, lower layer first.
|
||||
|
||||
## The ordering rule
|
||||
|
||||
A PR may only merge **after** every layer it calls is already on the trunk.
|
||||
|
||||
- The **server contract** (new TRPC procedure, changed return shape, new table/model) merges first.
|
||||
- The **callers** (desktop, CLI, UI) merge after — they invoke that contract.
|
||||
- Tie-break with one question: _"if this merged alone to `canary` right now, would it build and behave?"_ If no, it belongs in a later PR.
|
||||
|
||||
## Which file goes in which PR
|
||||
|
||||
The non-obvious calls:
|
||||
|
||||
- **Frontend that adapts to a contract change goes WITH the server PR.** If you widen a TRPC return shape (e.g. `listDevices` now returns `platform: string | null`), the component consuming it must change in the _same_ PR — otherwise the server PR breaks the build on its own. Contract + its in-repo consumers ship together.
|
||||
- **A new shared package goes with its consumer**, not the server, unless the server imports it too. A `@lobechat/*` package imported only by desktop/CLI ships in the client PR. Don't carry an unused package in the lower PR.
|
||||
- **Workspace dep declarations** (`package.json` `workspace:*`, `pnpm-workspace.yaml`) travel with the code that imports the package.
|
||||
|
||||
## The git recipe — split an existing full branch
|
||||
|
||||
Starting point: one branch (`feat/x`) with a single commit `<FULL>` containing everything, already pushed (so it's also safe on the remote).
|
||||
|
||||
```bash
|
||||
# 1. Safety nets — make the full work unloseable before rewriting anything
|
||||
git branch backup/x-full <FULL> # local ref to the full commit
|
||||
git branch feat/x-clients <FULL> # the higher-layer branch starts here
|
||||
|
||||
# 2. Rewrite the lower-layer branch to lower-layer files only
|
||||
git checkout feat/x # this becomes the SERVER PR
|
||||
git reset --hard origin/canary
|
||||
git checkout <FULL> -- <server/db files…> # stages just those paths
|
||||
git commit -m "✨ feat(...): <server half>"
|
||||
git push --force-with-lease origin feat/x # never --force; never push to canary
|
||||
|
||||
# 3. Build the higher-layer branch STACKED on the lower branch
|
||||
git checkout feat/x-clients
|
||||
git reset --hard feat/x # base = the just-rewritten server HEAD
|
||||
git checkout backup/x-full -- <client/ui files…> # only the remaining paths
|
||||
git commit -m "✨ feat(...): <client half>"
|
||||
git push -u origin feat/x-clients
|
||||
```
|
||||
|
||||
Then open the higher PR **based on the lower branch**, not the trunk:
|
||||
|
||||
```bash
|
||||
gh pr create --base feat/x --head feat/x-clients --title "…" --body "…"
|
||||
```
|
||||
|
||||
`--base feat/x` keeps the diff client-only (no server files leak in) and makes it physically impossible to merge the clients before the server. **After the server PR merges to `canary`, retarget the client PR's base to `canary`** (GitHub usually auto-retargets when the base branch merges; note it in the PR body so a human confirms).
|
||||
|
||||
## Verify the dependency actually holds
|
||||
|
||||
The whole point is the higher layer needs the lower one. Prove it: on the stacked higher branch, type-check the caller and confirm the symbol the lower layer introduced resolves.
|
||||
|
||||
```bash
|
||||
cd apps/cli && bun run type-check 2>&1 | grep -iE "connect\.ts|device\.register"
|
||||
# empty (re: your change) = the stacked base supplies device.register ✓
|
||||
```
|
||||
|
||||
Filter to your touched files — this repo's standalone type-check emits pre-existing env noise (`__ELECTRON__`, `@/types/llm`, unbuilt `@lobechat/types`) that isn't yours.
|
||||
|
||||
## PR + Linear bookkeeping
|
||||
|
||||
- **Each PR closes only its own layer's issues.** Server PR: `Closes LOBE-<server>`. Client PR: `Closes LOBE-<pkg> / <desktop> / <cli>`. Don't let one PR's body claim another layer's issue.
|
||||
- Both PRs are `Part of LOBE-<parent>`.
|
||||
- On PR creation, move each closed sub-issue to **In Review** (not Done) and add a completion comment — see the `linear` skill.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Never push to `canary`.** A split branch cut with `git checkout -b feat/x origin/canary` _tracks_ `origin/canary`, so a bare `git push` targets canary. Always `git push origin feat/x` with the explicit branch name.
|
||||
- **`--force-with-lease`, not `--force`** when rewriting the lower branch — it aborts if the remote moved under you.
|
||||
- **Back up before `reset --hard`.** Step 1's `backup/x-full` + the pushed remote branch mean the full commit is referenced by ≥3 refs before you rewrite anything. Verify with `git branch --contains <FULL>`.
|
||||
- **Lockfiles:** this monorepo commits no root `pnpm-lock.yaml`, so a new `workspace:*` dep needs no lockfile churn. In a repo that _does_ commit one, regenerate it on each branch after the split.
|
||||
- **Don't over-split.** Two PRs (contract / callers) is usually enough. A UI page that only reads an existing endpoint can be its own later PR, but don't fragment a single layer across PRs for its own sake.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: project-overview
|
||||
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
|
||||
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -13,11 +13,12 @@ user-invocable: false
|
||||
## Project Description
|
||||
|
||||
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
|
||||
This repo is the **open-source root** (`github.com/lobehub/lobehub`, package `@lobehub/lobehub`).
|
||||
|
||||
**Supported platforms:**
|
||||
|
||||
- Web desktop/mobile
|
||||
- Desktop (Electron)
|
||||
- Desktop (Electron) — `apps/desktop`
|
||||
- Mobile app (React Native) — **separate repo, already launched** (not in this monorepo)
|
||||
|
||||
**Logo emoji:** 🤯
|
||||
@@ -47,30 +48,28 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
|
||||
## 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
|
||||
Flat layout — `apps/`, `packages/`, and `src/` all sit at the repo root. No
|
||||
git submodules.
|
||||
|
||||
```
|
||||
lobehub/
|
||||
(repo root)
|
||||
├── apps/
|
||||
│ ├── 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
|
||||
│ ├── 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 core
|
||||
│ ├── agent-signal/ # Agent Signal pipeline
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── builtin-tools/ # Builtin tool registries
|
||||
│ ├── agent-tracing/ # Tracing / snapshots
|
||||
│ ├── builtin-tool-*/ # Per-tool packages (calculator, web-browsing, claude-code, ...)
|
||||
│ ├── builtin-tools/ # Central registries that compose builtin-tool-*
|
||||
│ ├── context-engine/
|
||||
│ ├── database/ # src/{models,schemas,repositories}
|
||||
│ ├── model-bank/ # Model definitions & provider cards
|
||||
│ ├── model-runtime/ # src/{core,providers}
|
||||
│ ├── business/ # Open-source stubs (config, const, model-bank, model-runtime) — overridden by cloud
|
||||
│ ├── types/
|
||||
│ └── utils/
|
||||
└── src/
|
||||
@@ -83,55 +82,54 @@ lobehub/
|
||||
├── spa/ # SPA entries + router config
|
||||
│ ├── entry.{web,mobile,desktop,popup}.tsx
|
||||
│ └── router/
|
||||
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
|
||||
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
|
||||
├── features/ # Domain business components
|
||||
├── store/ # ~28 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services
|
||||
├── store/ # ~30 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
|
||||
└── ... # 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/*` (cloud repo) |
|
||||
| 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 | `packages/builtin-tool-*`, `packages/builtin-tools` |
|
||||
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
React UI → Store Actions → Client Service → TRPC Lambda → Server Services → DB Model → PostgreSQL
|
||||
```
|
||||
|
||||
## Note: Relationship to the Cloud Repo
|
||||
|
||||
This open-source repo is consumed by a **separate, private cloud (SaaS) repo**
|
||||
as a git submodule mounted at `lobehub/`. The cloud repo provides:
|
||||
|
||||
- **`src/business/{client,server}`** and **`packages/business/*`** implementations
|
||||
that override the stubs shipped here.
|
||||
- Cloud-only routes (e.g. `(cloud)/`, `embed/`), cloud-only stores (e.g.
|
||||
`subscription/`), cloud-only TRPC routers (billing, budget, risk control, …),
|
||||
and Vercel cron routes under `src/app/(backend)/cron/`.
|
||||
- File-resolution order in cloud: `@/store/x` → cloud `src/store/x` first, then
|
||||
`lobehub/packages/store/src/x`, then `lobehub/src/store/x`. **Cloud override wins.**
|
||||
|
||||
When working in this repo alone, ignore the cloud layer — the stubs in
|
||||
`src/business/` and `packages/business/` are the source of truth here.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: react
|
||||
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.'
|
||||
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -17,22 +17,41 @@ user-invocable: false
|
||||
## Component Priority
|
||||
|
||||
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
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives. **If the component lives here, use it. Do NOT import the same-named root export.**
|
||||
3. **`@lobehub/ui`** — higher-level / antd-wrapping components (only when no base-ui equivalent)
|
||||
4. **antd** — only when neither base-ui nor `@lobehub/ui` root provides it
|
||||
5. **Custom implementation** — true last resort
|
||||
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs` and `node_modules/@lobehub/ui/es/base-ui/`.
|
||||
|
||||
### Common @lobehub/ui Components
|
||||
### `@lobehub/ui/base-ui` — always prefer for these
|
||||
|
||||
| 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 |
|
||||
| Component | Import |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| `Select` (+ `SelectProps`, `SelectOption`) | `import { Select } from '@lobehub/ui/base-ui';` |
|
||||
| `Modal` (imperative API) | `import { createModal, confirmModal, useModalContext, type ModalInstance } from '@lobehub/ui/base-ui';` |
|
||||
| `DropdownMenu` | `import { DropdownMenu } from '@lobehub/ui/base-ui';` |
|
||||
| `ContextMenu` | `import { ContextMenu } from '@lobehub/ui/base-ui';` |
|
||||
| `Popover` | `import { Popover } from '@lobehub/ui/base-ui';` |
|
||||
| `ScrollArea` | `import { ScrollArea } from '@lobehub/ui/base-ui';` |
|
||||
| `Switch` | `import { Switch } from '@lobehub/ui/base-ui';` |
|
||||
| `Toast` | `import { Toast } from '@lobehub/ui/base-ui';` |
|
||||
| `FloatingSheet` | `import { FloatingSheet } from '@lobehub/ui/base-ui';` |
|
||||
|
||||
For Modal specifically, see the dedicated **modal** skill — use the imperative `createModal({ content: … })` pattern over the legacy `<Modal open … />` declarative pattern. base-ui has its own `ModalHost` already mounted in `SPAGlobalProvider`.
|
||||
|
||||
> Common slip: `import { Select } from '@lobehub/ui'` looks fine but it's the antd-backed Select. Use base-ui Select. Same for `Modal`, `DropdownMenu`, etc.
|
||||
|
||||
### `@lobehub/ui` root — use when base-ui has no equivalent
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, Input, InputPassword, SearchBar, TextArea |
|
||||
| Feedback | Alert, Drawer |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Menu, SideNav, Tabs |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -85,12 +104,15 @@ errorElement: <ErrorBoundary />;
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| 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 |
|
||||
| Mistake | Fix |
|
||||
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
|
||||
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
|
||||
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
|
||||
| `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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: skills-audit
|
||||
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[--verbose | --apply]'
|
||||
---
|
||||
|
||||
# Skills Audit
|
||||
|
||||
Periodic review of the project-local skill set under `.agents/skills/`. The goal is to catch drift before the catalog becomes confusing — too many skills, overlapping triggers, descriptions that no longer match the body, references to skills that were renamed/deleted.
|
||||
|
||||
**Recommended cadence:** weekly, or after any week where >1 skill was added/renamed.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1 — Inventory
|
||||
|
||||
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
|
||||
|
||||
```bash
|
||||
find .agents/skills -name SKILL.md | wc -l # total count
|
||||
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
|
||||
```
|
||||
|
||||
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
|
||||
|
||||
### 2 — Pull frontmatter for all skills
|
||||
|
||||
```bash
|
||||
# Extract name + description for each SKILL.md
|
||||
for f in .agents/skills/*/SKILL.md; do
|
||||
echo "=== $(basename $(dirname $f)) ==="
|
||||
awk '/^---$/{c++; next} c==1' "$f" | head -20
|
||||
done
|
||||
```
|
||||
|
||||
Read the description block of every skill. The body can stay unread unless step 4 flags it.
|
||||
|
||||
### 3 — Detect overlap / redundancy
|
||||
|
||||
For each pair within the same domain, ask:
|
||||
|
||||
- **Same description**? → likely duplicate (one is probably a stale rename leftover, or a global-vs-local collision).
|
||||
- **Trigger keywords substantially overlap**? → either merge, OR tighten one description so the model can choose unambiguously.
|
||||
- **One skill's body says "see also: foo"**? → confirm `foo` still exists, AND confirm the cross-reference is still meaningful (the referenced skill may have absorbed the referrer's concerns).
|
||||
- **Skill duplicates content from `AGENTS.md`**? → fold into AGENTS.md or slim the skill to just the delta.
|
||||
|
||||
Common false positives (do NOT merge):
|
||||
|
||||
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
|
||||
- `microcopy` vs `i18n` — content vs mechanics.
|
||||
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
|
||||
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
|
||||
|
||||
### 4 — Description format consistency
|
||||
|
||||
Apply the **standard template**:
|
||||
|
||||
```
|
||||
{Topic + key conventions or scope}. Use when {scenarios — verbs + nouns}. Triggers on {`code-symbols`, 'natural phrases', '中文'}.
|
||||
```
|
||||
|
||||
Skills with `disable-model-invocation: true` (user-invoked only, slash commands) don't need `Triggers on` — they're never auto-routed.
|
||||
|
||||
Flag descriptions that:
|
||||
|
||||
- ❌ Have NO `Use when` clause (model can't decide when to load it).
|
||||
- ❌ Have NO `Triggers on` clause (and aren't `disable-model-invocation`).
|
||||
- ❌ Use weird formats (numbered lists `(1)(2)(3)`, `Triggers:` colon instead of `Triggers on`, `MUST use when ...` as opening word).
|
||||
- ❌ Are dramatically terse for a 200+ line body, or dramatically verbose for a 60-line body.
|
||||
- ❌ Reference deleted/renamed skills.
|
||||
|
||||
### 5 — Stale-skill check
|
||||
|
||||
For narrow domain skills (e.g. `response-compliance`, one-off CLI workflows):
|
||||
|
||||
```bash
|
||||
# Confirm the referenced code surface still exists
|
||||
rg -l "response-compliance|openresponses" packages/ src/ # adjust per skill
|
||||
git log --since="3 months ago" -- .agents/skills/ < skill > /SKILL.md # is it being maintained?
|
||||
```
|
||||
|
||||
If the underlying surface is gone and the skill hasn't been edited in 3+ months → flag for archival.
|
||||
|
||||
### 6 — Cross-reference integrity
|
||||
|
||||
Any skill body mentioning another skill by name:
|
||||
|
||||
```bash
|
||||
# Scan all skill bodies for skill-name references
|
||||
rg -o '`[a-z][a-z0-9-]+`' .agents/skills/*/SKILL.md | grep -v ':\s*$' | sort -u
|
||||
```
|
||||
|
||||
For each name extracted, confirm `.agents/skills/<name>/SKILL.md` exists. Broken references happen after renames — fix them in the same audit pass.
|
||||
|
||||
### 7 — Output report
|
||||
|
||||
Produce a markdown summary back to the user with the same structure as the original audit (this skill was created during one):
|
||||
|
||||
```markdown
|
||||
## 📊 Inventory
|
||||
|
||||
{count, domain breakdown}
|
||||
|
||||
## 🎯 Recommendations
|
||||
|
||||
### 🔴 High confidence
|
||||
|
||||
- {action} — {reason}
|
||||
|
||||
### 🟡 Medium confidence
|
||||
|
||||
- {action} — {reason needs verification}
|
||||
|
||||
### 🟢 Low confidence / no-op
|
||||
|
||||
- {item considered but skipping because ...}
|
||||
|
||||
## 📋 Suggested order
|
||||
|
||||
{table of actions with risk + LOC estimate}
|
||||
```
|
||||
|
||||
End by asking the user which actions to apply — do NOT auto-apply unless the user passed `--apply` and even then confirm destructive deletes individually.
|
||||
|
||||
## Output rules
|
||||
|
||||
- Be specific. "Skill X overlaps with Y" is useless without naming the overlapping triggers.
|
||||
- Cite line numbers when flagging description / body issues.
|
||||
- Don't recommend merges unless the call sites would actually load the merged skill in the same context.
|
||||
- Don't recommend deletes for skills that haven't been touched recently — "unused" can mean "stable", not "dead".
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ Don't rename skill directories without checking for cross-references AND user memory entries that name the old slug.
|
||||
- ❌ Don't normalize a description by removing trigger keywords just to fit the template — the keywords are the routing signal.
|
||||
- ❌ Don't fold a heavy 200+ line skill into another just because they share a domain — large skills get loaded selectively and merging makes everything load.
|
||||
- ❌ Don't propose `.agents/skills/INDEX.md` or `<domain>-<skill>` prefix renames unless the user explicitly asks — costs > benefits for cosmetic reorgs.
|
||||
|
||||
## Related history
|
||||
|
||||
- First audit: `chore/skills-audit` branch (2026-05-25) — deleted `source-command-dedupe`, renamed `data-fetching` → `data-fetching-architecture`, normalized 9 descriptions, created this skill.
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: 'source-command-dedupe'
|
||||
description: 'Find duplicate GitHub issues'
|
||||
---
|
||||
|
||||
# source-command-dedupe
|
||||
|
||||
Use this skill when the user asks to run the migrated source command `dedupe`.
|
||||
|
||||
## Command Template
|
||||
|
||||
Find up to 3 likely duplicate issues for a given GitHub issue.
|
||||
|
||||
To do this, follow these steps precisely:
|
||||
|
||||
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
|
||||
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
|
||||
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
|
||||
|
||||
Notes (be sure to tell this to your agents, too):
|
||||
|
||||
- Use `gh` to interact with Github, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
|
||||
|
||||
---
|
||||
|
||||
Found 3 possible duplicate issues:
|
||||
|
||||
1. <link to issue>
|
||||
2. <link to issue>
|
||||
3. <link to issue>
|
||||
|
||||
This issue will be automatically closed as a duplicate in 3 days.
|
||||
|
||||
- If your issue is a duplicate, please close it and 👍 the existing issue instead
|
||||
- To prevent auto-closure, add a comment or 👎 this comment
|
||||
|
||||
> 🤖 Generated with Codex
|
||||
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spa-routes
|
||||
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
|
||||
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together — `desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, any colocated `<name>.desktop.{ts,tsx}` variant (e.g. settings `componentMap.ts` × `componentMap.desktop.ts`, page-level `index.tsx` × `index.desktop.tsx`), or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `componentMap.desktop`, `index.desktop.tsx`, `.desktop.tsx` variant, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -94,6 +94,27 @@ Anything that changes the tree (new segment, renamed `path`, moved layout, new c
|
||||
|
||||
---
|
||||
|
||||
## 3b. Other `.desktop.{ts,tsx}` variants inside `src/routes/`
|
||||
|
||||
The router pair is **not** the only `.desktop` variant pattern in this repo. Some route trees colocate a `<name>.desktop.{ts,tsx}` next to its base `<name>.{ts,tsx}` — Vite's resolver swaps in the `.desktop` file for Electron builds. Same drift risk as the router pair: editing only one side can break Electron silently.
|
||||
|
||||
Known variants today:
|
||||
|
||||
| Base file (web) | Desktop file (Electron) | Purpose |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` | Settings tab → component map. Web uses dynamic `import()`; desktop uses sync imports. `componentMap.sync.test.ts` enforces identical keys. |
|
||||
| `src/routes/(main)/agent/index.tsx` | `src/routes/(main)/agent/index.desktop.tsx` | Page entry. Desktop variant overrides the web page wholesale (e.g. extra popup guards). |
|
||||
| `src/routes/(main)/group/index.tsx` | `src/routes/(main)/group/index.desktop.tsx` | Same pattern as agent. |
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. After editing **any** `.ts`/`.tsx` under `src/routes/`, glob the same directory for a `<filename>.desktop.{ts,tsx}` sibling. If one exists, apply the equivalent change there in the same commit.
|
||||
2. When adding a new SettingsTab, register it in **both** `componentMap.ts` (with `dynamic(...)`) and `componentMap.desktop.ts` (with a sync `import`). `componentMap.sync.test.ts` will fail the build otherwise.
|
||||
3. When adding a new desktop-only page wholesale-override, prefer a single base file with platform-aware code over introducing a new `.desktop.tsx` variant — only add a new variant when the two trees genuinely diverge (different store wiring, different popup guards, etc.).
|
||||
4. When deleting, remove **both** files together.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to Divide Files (route vs feature)
|
||||
|
||||
| Question | Put in `src/routes/` | Put in `src/features/` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: store-data-structures
|
||||
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
|
||||
description: "Zustand store data-shape patterns for LobeHub — List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -310,5 +310,5 @@ export interface BenchmarkListItem {
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `data-fetching` — how to fetch and update this data
|
||||
- `data-fetching-architecture` — how to fetch and update this data
|
||||
- `zustand` — general Zustand patterns
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: upstash-workflow
|
||||
description: 'Upstash Workflow implementation guide. Use when creating async workflows with QStash, implementing fan-out patterns, or building 3-layer workflow architecture (process → paginate → execute).'
|
||||
description: "Upstash Workflow + QStash implementation guide for LobeHub — 3-layer architecture (process → paginate → execute), fan-out patterns. Use when creating an async workflow, implementing fan-out (paginate → execute), or wiring `serve()` + `context.run` / `context.call` steps. Triggers on `serve()`, `context.run`, `context.call`, `context.sleep`, `qstash`, 'async workflow', 'fan-out workflow', 'QStash workflow'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ runs:
|
||||
|
||||
# 1. 上传安装包到版本目录
|
||||
echo "📦 Uploading release files to s3://$S3_BUCKET/$CHANNEL/$VERSION/"
|
||||
for file in release/*.dmg release/*.zip release/*.exe release/*.AppImage release/*.deb release/*.rpm release/*.snap release/*.tar.gz; do
|
||||
for file in release/*.dmg release/*.zip release/*.exe release/*.AppImage release/*.deb release/*.rpm release/*.snap release/*.tar.gz release/*.blockmap; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
echo " ↗️ $filename"
|
||||
|
||||
@@ -28,6 +28,9 @@ prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Agent-gateway probe captures (local debugging dumps)
|
||||
.agent-gateway/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
|
||||
@@ -7,6 +7,7 @@ Guidelines for using AI coding agents in this LobeHub repository.
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- **Component priority**: `@lobehub/ui/base-ui` (headless primitives) **first**, then `@lobehub/ui` root, then antd as last resort. When the component exists in base-ui, use it — never reach for the root or antd counterpart. Base-ui covers `Select`, `Modal` / `createModal` / `confirmModal`, `DropdownMenu`, `ContextMenu`, `Popover`, `ScrollArea`, `Switch`, `Toast`, `FloatingSheet`. Prefer `@lobehub/ui/base-ui` for new code and migrate root-package call sites opportunistically.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
@@ -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.20" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.22" "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.20",
|
||||
"version": "0.0.22",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
@@ -44,7 +45,7 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/types'
|
||||
|
||||
@@ -70,6 +70,26 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Lambda tRPC client from an already-resolved auth context, without
|
||||
* re-running credential discovery. Use this when the caller already holds a
|
||||
* token (e.g. `lh connect --token <jwt>`) — `getTrpcClient` would re-resolve
|
||||
* via env/stored creds and `process.exit(1)` when none exist, which would
|
||||
* abort an otherwise-valid explicit-token session.
|
||||
*/
|
||||
export function createLambdaClient(auth: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
}): TrpcClient {
|
||||
const headers =
|
||||
auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token };
|
||||
|
||||
return createTRPCClient<LambdaRouter>({
|
||||
links: [httpLink({ headers, transformer: superjson, url: `${auth.serverUrl}/trpc/lambda` })],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getToolsTrpcClient(): Promise<ToolsTrpcClient> {
|
||||
if (_toolsClient) return _toolsClient;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../auth/resolveToken', () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadOrCreateConnectionId: vi.fn().mockReturnValue('test-connection-id'),
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
normalizeUrl: vi.fn((url?: string) => (url ? url.replace(/\/$/, '') : undefined)),
|
||||
saveSettings: vi.fn(),
|
||||
|
||||
@@ -8,8 +8,11 @@ import type {
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { IdentitySource } from '@lobechat/device-identity';
|
||||
import { deriveDeviceId } from '@lobechat/device-identity';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { createLambdaClient } from '../api/client';
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
@@ -25,7 +28,7 @@ import {
|
||||
stopDaemon,
|
||||
writeStatus,
|
||||
} from '../daemon/manager';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { executeToolCall } from '../tools';
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
@@ -192,8 +195,24 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
|
||||
|
||||
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
|
||||
// user pin a VM to a fixed identity); otherwise derive from the machine id so
|
||||
// the same machine + user maps to one device across reconnects.
|
||||
const identity: { deviceId: string; identitySource: IdentitySource } | undefined =
|
||||
options.deviceId
|
||||
? { deviceId: options.deviceId, identitySource: 'fallback' }
|
||||
: auth.userId
|
||||
? deriveDeviceId(auth.userId)
|
||||
: undefined;
|
||||
|
||||
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
|
||||
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
|
||||
const channel = process.env.LOBEHUB_CLI_CHANNEL || 'cli';
|
||||
|
||||
const client = new GatewayClient({
|
||||
deviceId: options.deviceId,
|
||||
channel,
|
||||
connectionId: loadOrCreateConnectionId(),
|
||||
deviceId: identity?.deviceId ?? options.deviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
logger: isDaemonChild ? createDaemonLogger() : log,
|
||||
serverUrl: auth.serverUrl,
|
||||
@@ -248,14 +267,14 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
// Handle tool call requests
|
||||
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
|
||||
const { requestId, toolCall } = request;
|
||||
const { requestId, timeout, toolCall } = request;
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
|
||||
} else {
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
|
||||
}
|
||||
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
|
||||
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
|
||||
@@ -386,6 +405,25 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Register this device in the server registry before opening the WS, so the
|
||||
// row exists by the time the gateway reports it online. Best-effort: a
|
||||
// failure must not block the connection.
|
||||
if (identity) {
|
||||
try {
|
||||
// Reuse the already-resolved auth (respects `--token` mode) instead of
|
||||
// getTrpcClient(), which re-discovers creds and exits when none are found.
|
||||
const trpc = createLambdaClient(auth);
|
||||
await trpc.device.register.mutate({
|
||||
deviceId: identity.deviceId,
|
||||
hostname: os.hostname(),
|
||||
identitySource: identity.identitySource,
|
||||
platform: process.platform,
|
||||
});
|
||||
} catch (err) {
|
||||
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Connect
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ import { registerHeteroCommand } from './hetero';
|
||||
const { mockSpawnAgent } = vi.hoisted(() => ({
|
||||
mockSpawnAgent: vi.fn(),
|
||||
}));
|
||||
const { mockGetTrpcClient, mockHeteroFinishMutate, mockHeteroIngestMutate } = vi.hoisted(() => ({
|
||||
mockGetTrpcClient: vi.fn(),
|
||||
mockHeteroFinishMutate: vi.fn(),
|
||||
mockHeteroIngestMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
|
||||
spawnAgent: mockSpawnAgent,
|
||||
}));
|
||||
|
||||
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(),
|
||||
@@ -77,6 +86,17 @@ describe('hetero exec command', () => {
|
||||
}) as any);
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockSpawnAgent.mockReset();
|
||||
mockHeteroIngestMutate.mockReset();
|
||||
mockHeteroFinishMutate.mockReset();
|
||||
mockGetTrpcClient.mockReset();
|
||||
mockHeteroIngestMutate.mockResolvedValue({ ack: true });
|
||||
mockHeteroFinishMutate.mockResolvedValue({ ack: true });
|
||||
mockGetTrpcClient.mockResolvedValue({
|
||||
aiAgent: {
|
||||
heteroFinish: { mutate: mockHeteroFinishMutate },
|
||||
heteroIngest: { mutate: mockHeteroIngestMutate },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -536,4 +556,93 @@ describe('hetero exec command', () => {
|
||||
expect(errorLine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends full text snapshots before tools and waits for finish until all server ingests ack', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
|
||||
const first = events[0];
|
||||
callOrder.push(`ingest:${first.type}:${first.data?.chunkType ?? 'terminal'}`);
|
||||
return { ack: true };
|
||||
});
|
||||
mockHeteroFinishMutate.mockImplementation(async () => {
|
||||
callOrder.push('finish');
|
||||
return { ack: true };
|
||||
});
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hello ' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { chunkType: 'text', content: 'world' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'Bash',
|
||||
arguments: '{"cmd":"ls"}',
|
||||
id: 'tc-1',
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
},
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { reason: 'success' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 4,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--topic',
|
||||
'topic-1',
|
||||
'--operation-id',
|
||||
'op-server',
|
||||
'--render',
|
||||
'none',
|
||||
]);
|
||||
|
||||
expect(mockHeteroIngestMutate).toHaveBeenCalledTimes(3);
|
||||
expect(mockHeteroIngestMutate.mock.calls[0][0].events[0].data).toMatchObject({
|
||||
chunkType: 'text',
|
||||
content: 'hello world',
|
||||
snapshotMode: 'replace',
|
||||
snapshotSeq: 1,
|
||||
});
|
||||
expect(callOrder).toEqual([
|
||||
'ingest:stream_chunk:text',
|
||||
'ingest:stream_chunk:tools_calling',
|
||||
'ingest:agent_runtime_end:terminal',
|
||||
'finish',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+103
-16
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -6,12 +7,12 @@ import type {
|
||||
AgentContentBlock,
|
||||
AgentImageSource,
|
||||
AgentPromptInput,
|
||||
AgentStreamEvent,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
|
||||
import { log } from '../utils/logger';
|
||||
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
|
||||
|
||||
@@ -200,6 +201,85 @@ const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
|
||||
return buildPromptFromText(raw, images);
|
||||
};
|
||||
|
||||
class SerialServerIngester {
|
||||
private accumulatedText = '';
|
||||
private fatalError: Error | null = null;
|
||||
private inflight: Promise<void> = Promise.resolve();
|
||||
private nextSnapshotSeq = 0;
|
||||
private pendingTextEvent: AgentStreamEvent | undefined;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly sink: TrpcIngestSink,
|
||||
private readonly snapshotFlushMs = 200,
|
||||
) {}
|
||||
|
||||
push(event: AgentStreamEvent): void {
|
||||
if (this.fatalError) return;
|
||||
|
||||
if (
|
||||
event.type === 'stream_chunk' &&
|
||||
event.data?.chunkType === 'text' &&
|
||||
typeof event.data?.content === 'string'
|
||||
) {
|
||||
this.accumulatedText += event.data.content;
|
||||
this.pendingTextEvent = event;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.queuePendingTextSnapshot();
|
||||
}, this.snapshotFlushMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queuePendingTextSnapshot();
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([event]);
|
||||
});
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
this.queuePendingTextSnapshot();
|
||||
try {
|
||||
await this.inflight;
|
||||
} catch {
|
||||
// `fatalError` is re-thrown below.
|
||||
}
|
||||
if (this.fatalError) throw this.fatalError;
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>) {
|
||||
this.inflight = this.inflight.then(task).catch((err) => {
|
||||
this.fatalError = err instanceof Error ? err : new Error(String(err));
|
||||
throw this.fatalError;
|
||||
});
|
||||
}
|
||||
|
||||
private queuePendingTextSnapshot() {
|
||||
if (!this.pendingTextEvent || this.fatalError) return;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
const baseEvent = this.pendingTextEvent;
|
||||
this.pendingTextEvent = undefined;
|
||||
const snapshotEvent: AgentStreamEvent = {
|
||||
...baseEvent,
|
||||
data: {
|
||||
...baseEvent.data,
|
||||
content: this.accumulatedText,
|
||||
snapshotMode: 'replace',
|
||||
snapshotSeq: ++this.nextSnapshotSeq,
|
||||
},
|
||||
};
|
||||
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([snapshotEvent]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
@@ -243,17 +323,22 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
|
||||
// JWT injected by the server) for authentication.
|
||||
const agentType = options.type as 'claude-code' | 'codex';
|
||||
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
|
||||
let sink: TrpcIngestSink | undefined;
|
||||
let serverIngester: SerialServerIngester | undefined;
|
||||
if (serverIngest) {
|
||||
const client = await getTrpcClient();
|
||||
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
|
||||
} else {
|
||||
sink = new NoopIngestSink();
|
||||
sink = new TrpcIngestSink(
|
||||
client,
|
||||
agentType,
|
||||
operationId,
|
||||
options.topic!,
|
||||
process.env.LOBEHUB_ASSISTANT_MESSAGE_ID,
|
||||
);
|
||||
serverIngester = new SerialServerIngester(sink);
|
||||
}
|
||||
const ingester = new BatchIngester(sink);
|
||||
|
||||
/**
|
||||
* Spawn one agent process and stream all its events into `ingester`.
|
||||
* Spawn one agent process and stream all its events into the server ingester.
|
||||
*
|
||||
* When `interceptResumeErrors` is true, any `error`-type event whose
|
||||
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
|
||||
@@ -297,6 +382,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// Always pipe to process.stderr too so users see auth prompts / warnings.
|
||||
const STDERR_CAP = 8 * 1024;
|
||||
let stderrContent = '';
|
||||
const stderrEnded = once(handle.stderr, 'end').then(() => undefined);
|
||||
handle.stderr.on('data', (chunk: Buffer) => {
|
||||
if (stderrContent.length < STDERR_CAP) {
|
||||
stderrContent += chunk.toString();
|
||||
@@ -314,9 +400,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort; process is exiting anyway
|
||||
@@ -325,9 +411,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
};
|
||||
const onSigterm = async () => {
|
||||
handle.kill('SIGTERM');
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort
|
||||
@@ -356,16 +442,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
}
|
||||
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
ingester.push(event);
|
||||
serverIngester?.push(event);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Stream error from agent process:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({
|
||||
error: { message: String(err), type: 'stream_error' },
|
||||
result: 'error',
|
||||
@@ -381,6 +467,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
|
||||
const { code, signal } = await handle.exit;
|
||||
await stderrEnded;
|
||||
|
||||
// Fallback stderr detection: CC may exit non-zero without emitting a
|
||||
// result event (e.g. it writes to stderr and quits immediately).
|
||||
@@ -451,9 +538,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const { code, signal, sessionId } = result;
|
||||
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Failed to flush events to server:',
|
||||
|
||||
@@ -6,9 +6,13 @@ import { registerTopicCommand } from './topic';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
message: {
|
||||
getMessages: { query: vi.fn() },
|
||||
},
|
||||
topic: {
|
||||
batchDelete: { mutate: vi.fn() },
|
||||
createTopic: { mutate: vi.fn() },
|
||||
getTopicDetail: { query: vi.fn() },
|
||||
getTopics: { query: vi.fn() },
|
||||
recentTopics: { query: vi.fn() },
|
||||
removeTopic: { mutate: vi.fn() },
|
||||
@@ -41,6 +45,18 @@ describe('topic command', () => {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.message)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
// Default stub for getTopicDetail
|
||||
mockTrpcClient.topic.getTopicDetail.query.mockResolvedValue({
|
||||
favorite: false,
|
||||
id: 't1',
|
||||
title: 'Test Topic',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -203,4 +219,130 @@ describe('topic command', () => {
|
||||
expect(mockTrpcClient.topic.recentTopics.query).toHaveBeenCalledWith({ limit: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
it('should display topic metadata and messages', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([
|
||||
{ content: 'Hello world', id: 'm1', role: 'user' },
|
||||
{ content: 'Hi there', id: 'm2', role: 'assistant' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1']);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopicDetail.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 't1' }),
|
||||
);
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ topicId: 't1' }),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip message query entirely when --no-messages flag is set', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1', '--no-messages']);
|
||||
|
||||
// getTopicDetail is still called (for metadata)
|
||||
expect(mockTrpcClient.topic.getTopicDetail.query).toHaveBeenCalled();
|
||||
// but getMessages must NOT be called
|
||||
expect(mockTrpcClient.message.getMessages.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should output json when --json flag is set', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([
|
||||
{ content: 'Hello', id: 'm1', role: 'user' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1', '--json']);
|
||||
|
||||
const calls = consoleSpy.mock.calls.flat().join('');
|
||||
const parsed = JSON.parse(calls);
|
||||
expect(parsed.topic.id).toBe('t1');
|
||||
expect(parsed.messages).toHaveLength(1);
|
||||
expect(parsed.messages[0]).toHaveProperty('role', 'user');
|
||||
expect(parsed.messages[0]).toHaveProperty('content', 'Hello');
|
||||
});
|
||||
|
||||
it('should output json with empty messages for --no-messages --json', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1', '--no-messages', '--json']);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).not.toHaveBeenCalled();
|
||||
const calls = consoleSpy.mock.calls.flat().join('');
|
||||
const parsed = JSON.parse(calls);
|
||||
expect(parsed.topic.id).toBe('t1');
|
||||
expect(parsed.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should respect -L for message page size', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1', '-L', '10']);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 10, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should slice messages with --from and --to', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([
|
||||
{ content: 'msg1', id: 'm1', role: 'user' },
|
||||
{ content: 'msg2', id: 'm2', role: 'assistant' },
|
||||
{ content: 'msg3', id: 'm3', role: 'user' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1', '--from', '2', '--to', '3']);
|
||||
|
||||
// Should print only m2 and m3 (index 1 and 2)
|
||||
const output = consoleSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('msg2');
|
||||
expect(output).toContain('msg3');
|
||||
expect(output).not.toContain('msg1');
|
||||
});
|
||||
|
||||
it('should render tool calls inline', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([
|
||||
{
|
||||
content: "I'll search for that.",
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
function: { arguments: '{"query":"lobehub"}', name: 'web_search' },
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ content: 'search results...', id: 'm2', role: 'tool' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1']);
|
||||
|
||||
const output = consoleSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('web_search');
|
||||
expect(output).toContain('lobehub');
|
||||
});
|
||||
|
||||
it('should render threaded messages with indentation', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([
|
||||
{ content: 'Parent message', id: 'm1', parentId: null, role: 'user' },
|
||||
{ content: 'Thread reply', id: 'm2', parentId: 'm1', role: 'assistant' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'view', 't1']);
|
||||
|
||||
const output = consoleSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('Parent message');
|
||||
expect(output).toContain('Thread reply');
|
||||
// thread reply should appear after parent (basic ordering check)
|
||||
expect(output.indexOf('Thread reply')).toBeGreaterThan(output.indexOf('Parent message'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,4 +332,170 @@ export function registerTopicCommand(program: Command) {
|
||||
|
||||
printTable(rows, ['ID', 'TITLE', 'UPDATED']);
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
topic
|
||||
.command('view <id>')
|
||||
.description('View topic details and its messages')
|
||||
.option('-L, --limit <n>', 'Max messages to fetch per page', '50')
|
||||
.option('--from <n>', 'Show messages starting from this index (1-based)', '1')
|
||||
.option('--to <n>', 'Show messages up to this index (inclusive)')
|
||||
.option('--no-messages', 'Skip messages, show topic metadata only')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
from?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
messages?: boolean;
|
||||
to?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── 1. Fetch topic detail (single query by id) ──
|
||||
const topicDetail = await client.topic.getTopicDetail.query({ id } as any);
|
||||
|
||||
// ── 2. Fetch messages only when needed ──
|
||||
if (options.messages === false) {
|
||||
// --no-messages: skip message query entirely
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ messages: [], topic: topicDetail ?? { id } }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log('');
|
||||
console.log(
|
||||
`${pc.bold('Topic:')} ${pc.cyan((topicDetail as any)?.title ?? id)} ${pc.dim(`(${id})`)}`,
|
||||
);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
const msgLimit = Number.parseInt(options.limit || '50', 10);
|
||||
const msgResult = await client.message.getMessages.query({
|
||||
pageSize: msgLimit,
|
||||
topicId: id,
|
||||
} as any);
|
||||
const allMessages: any[] = Array.isArray(msgResult)
|
||||
? msgResult
|
||||
: ((msgResult as any).items ?? []);
|
||||
|
||||
// Apply --from / --to slicing (1-based)
|
||||
const fromIdx = Math.max(1, Number.parseInt(options.from || '1', 10)) - 1;
|
||||
const toIdx = options.to ? Number.parseInt(options.to, 10) : allMessages.length;
|
||||
const messages = allMessages.slice(fromIdx, toIdx);
|
||||
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
messages: messages.map((m: any) => ({
|
||||
content: m.content ?? null,
|
||||
createdAt: m.createdAt ?? null,
|
||||
id: m.id,
|
||||
parentId: m.parentId ?? null,
|
||||
role: m.role,
|
||||
threadId: m.threadId ?? null,
|
||||
tools: m.tools ?? null,
|
||||
})),
|
||||
topic: { id },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Header ──
|
||||
const t = topicDetail as any;
|
||||
console.log('');
|
||||
console.log(`${pc.bold('Topic:')} ${pc.cyan(t?.title ?? id)} ${pc.dim(`(${id})`)}`);
|
||||
if (t?.favorite) console.log(`${pc.bold('Favorite:')} ★`);
|
||||
if (t?.updatedAt) console.log(`${pc.bold('Updated:')} ${timeAgo(t.updatedAt)}`);
|
||||
if (t?.status) console.log(`${pc.bold('Status:')} ${t.status}`);
|
||||
if (t?.model) console.log(`${pc.bold('Model:')} ${t.model}${t.provider ? ` (${t.provider})` : ''}`);
|
||||
console.log('');
|
||||
|
||||
// ── Messages ──
|
||||
if (messages.length === 0) {
|
||||
console.log(pc.dim(' (no messages)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build parentId → children map for thread display
|
||||
const childrenOf = new Map<string | null, any[]>();
|
||||
for (const m of messages) {
|
||||
const key = m.parentId ?? null;
|
||||
if (!childrenOf.has(key)) childrenOf.set(key, []);
|
||||
childrenOf.get(key)!.push(m);
|
||||
}
|
||||
|
||||
const printMessage = (m: any, depth: number) => {
|
||||
const indent = ' '.repeat(depth + 1);
|
||||
const roleLabel =
|
||||
m.role === 'user'
|
||||
? pc.green('user ')
|
||||
: m.role === 'tool'
|
||||
? pc.yellow('tool ')
|
||||
: pc.blue('assistant');
|
||||
const threadMark = depth > 0 ? pc.dim('↳ ') : '';
|
||||
|
||||
// Full content (no truncation)
|
||||
const content = (m.content || '').trim();
|
||||
if (content) {
|
||||
console.log(`${indent}${threadMark}${roleLabel} ${content}`);
|
||||
}
|
||||
|
||||
// Tool calls (assistant requesting tools)
|
||||
if (m.tools && Array.isArray(m.tools) && m.tools.length > 0) {
|
||||
for (const tool of m.tools) {
|
||||
const toolName = tool.function?.name ?? tool.id ?? 'unknown';
|
||||
const toolArgs = tool.function?.arguments
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tool.function.arguments), null, 2)
|
||||
.split('\n')
|
||||
.map((l: string) => `${indent} ${l}`)
|
||||
.join('\n');
|
||||
} catch {
|
||||
return `${indent} ${tool.function.arguments}`;
|
||||
}
|
||||
})()
|
||||
: '';
|
||||
console.log(`${indent} ${pc.yellow('⚙')} ${pc.bold(toolName)}`);
|
||||
if (toolArgs) console.log(toolArgs);
|
||||
}
|
||||
}
|
||||
|
||||
// Render thread children recursively
|
||||
const children = childrenOf.get(m.id) ?? [];
|
||||
for (const child of children) {
|
||||
printMessage(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Print only top-level messages (parentId === null/undefined, or parentId not in current page)
|
||||
const msgIds = new Set(messages.map((m: any) => m.id));
|
||||
const topLevel = messages.filter(
|
||||
(m: any) => !m.parentId || !msgIds.has(m.parentId),
|
||||
);
|
||||
|
||||
for (const m of topLevel) {
|
||||
printMessage(m, 0);
|
||||
}
|
||||
|
||||
if (allMessages.length > msgLimit) {
|
||||
console.log('');
|
||||
console.log(
|
||||
pc.dim(
|
||||
` … total ${allMessages.length} messages, showing ${fromIdx + 1}–${Math.min(toIdx, allMessages.length)}. Use -L / --from / --to to paginate.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { loadSettings, normalizeUrl, resolveServerUrl, saveSettings } from './index';
|
||||
import {
|
||||
loadOrCreateConnectionId,
|
||||
loadSettings,
|
||||
normalizeUrl,
|
||||
resolveServerUrl,
|
||||
saveSettings,
|
||||
} from './index';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-settings');
|
||||
const settingsDir = path.join(tmpDir, '.lobehub');
|
||||
@@ -91,4 +97,22 @@ describe('settings', () => {
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://app.lobehub.com');
|
||||
});
|
||||
|
||||
it('should create a connectionId once and reuse it across calls', () => {
|
||||
const first = loadOrCreateConnectionId();
|
||||
expect(first).toMatch(/[\da-f-]{36}/);
|
||||
|
||||
// Persisted in its own file, independent of settings.json.
|
||||
expect(fs.existsSync(path.join(settingsDir, 'connection-id'))).toBe(true);
|
||||
expect(loadOrCreateConnectionId()).toBe(first);
|
||||
});
|
||||
|
||||
it('should keep the connectionId even when settings.json is cleared', () => {
|
||||
const id = loadOrCreateConnectionId();
|
||||
// Clearing official-server settings unlinks settings.json — connectionId must survive.
|
||||
saveSettings({ serverUrl: 'https://app.lobehub.com/' });
|
||||
|
||||
expect(fs.existsSync(settingsFile)).toBe(false);
|
||||
expect(loadOrCreateConnectionId()).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -14,6 +15,9 @@ export interface StoredSettings {
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
// Kept in its own file rather than settings.json, which is unlinked whenever
|
||||
// all server/gateway URLs are default — the connectionId must persist regardless.
|
||||
const CONNECTION_ID_FILE = path.join(SETTINGS_DIR, 'connection-id');
|
||||
|
||||
export function normalizeUrl(url: string | undefined): string | undefined {
|
||||
return url ? url.replace(/\/$/, '') : undefined;
|
||||
@@ -54,6 +58,31 @@ export function saveSettings(settings: StoredSettings): void {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(normalized, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-install connection routing key for `lh connect`. Decoupled from
|
||||
* the (machine-derived, shared-across-clients) deviceId so the gateway only
|
||||
* replaces this install's own stale socket — a co-running desktop app on the
|
||||
* same machine keeps its connection. Persisted under the CLI home dir, so a
|
||||
* separate `LOBEHUB_CLI_HOME` (e.g. a dev build) naturally gets its own id.
|
||||
*/
|
||||
export function loadOrCreateConnectionId(): string {
|
||||
try {
|
||||
const existing = fs.readFileSync(CONNECTION_ID_FILE, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// not yet created
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(SETTINGS_DIR, { mode: 0o700, recursive: true });
|
||||
fs.writeFileSync(CONNECTION_ID_FILE, id, { mode: 0o600 });
|
||||
} catch {
|
||||
// best-effort: an unwritable home dir just means a fresh id per run
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function loadSettings(): StoredSettings | null {
|
||||
if (!fs.existsSync(SETTINGS_FILE)) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { getHermesPort } from './heteroTask';
|
||||
|
||||
export interface CheckPlatformCapabilityParams {
|
||||
platform: 'hermes' | 'openclaw';
|
||||
}
|
||||
@@ -42,26 +40,19 @@ export async function checkPlatformCapability(
|
||||
}
|
||||
|
||||
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}` };
|
||||
const output = execFileSync('hermes', ['--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
// output is typically "Hermes Agent vX.Y.Z (...)"
|
||||
const versionMatch = output.match(/v(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : output.split(/\s+/).at(-1);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
|
||||
reason: err instanceof Error ? err.message : 'hermes not found or failed to run',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
@@ -80,13 +81,92 @@ function getOpenClawProfile(agentId?: string): AgentProfileResult {
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the active Hermes profile name from `hermes profile list` output.
|
||||
* The active profile is marked with ◆ in the first column.
|
||||
*/
|
||||
function getActiveHermesProfileName(): string | undefined {
|
||||
try {
|
||||
const output = execFileSync('hermes', ['profile', 'list'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const match = output.match(/◆(\S+)/);
|
||||
return match?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the filesystem path of a Hermes profile from `hermes profile show <name>`.
|
||||
*/
|
||||
function getHermesProfilePath(profileName: string): string | undefined {
|
||||
try {
|
||||
const output = execFileSync('hermes', ['profile', 'show', profileName], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const match = output.match(/^Path:\s+(.+)/m);
|
||||
const raw = match?.[1]?.trim();
|
||||
// Expand leading `~` — Node does not auto-expand home-dir shorthands.
|
||||
return raw?.replace(/^~(?=\/|$)/, os.homedir());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a one-line description from a Hermes SOUL.md file.
|
||||
* Strips HTML comments and Markdown headings, then returns the first
|
||||
* non-empty line of actual content.
|
||||
*/
|
||||
function readHermesSoulDescription(soulPath: string): string | undefined {
|
||||
try {
|
||||
const content = fs.readFileSync(soulPath, 'utf8');
|
||||
// Loop until stable to handle any malformed/nested comment sequences.
|
||||
let stripped = content;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
|
||||
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
|
||||
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
|
||||
} while (stripped !== previous);
|
||||
const line = stripped
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0);
|
||||
return line || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getHermesProfile(): AgentProfileResult {
|
||||
const profileName = getActiveHermesProfileName();
|
||||
if (!profileName) return {};
|
||||
|
||||
const profilePath = getHermesProfilePath(profileName);
|
||||
const description = profilePath
|
||||
? readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
avatar: '⚡',
|
||||
description,
|
||||
title: profileName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - hermes: active profile name + SOUL.md description
|
||||
*/
|
||||
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
|
||||
const { platform, agentId } = params;
|
||||
@@ -96,8 +176,7 @@ export async function getAgentProfile(params: GetAgentProfileParams): Promise<Ag
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
// Profile fetch not yet implemented for Hermes — return empty
|
||||
return {};
|
||||
return getHermesProfile();
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
@@ -6,7 +9,36 @@ import { getTrpcClient } from '../api/client';
|
||||
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
// ─── Hermes session persistence ───
|
||||
// Maps topicId → hermes session_id so multi-turn conversations can resume
|
||||
// the same session across separate `runHeteroTask` invocations.
|
||||
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const HERMES_SESSIONS_FILE = path.join(os.homedir(), LOBEHUB_DIR_NAME, 'hermes-sessions.json');
|
||||
|
||||
function getHermesSessionId(topicId: string): string | undefined {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return data[topicId];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHermesSessionId(topicId: string, sessionId: string): void {
|
||||
let data: Record<string, string> = {};
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<string, string>;
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh.
|
||||
}
|
||||
data[topicId] = sessionId;
|
||||
fs.mkdirSync(path.dirname(HERMES_SESSIONS_FILE), { recursive: true });
|
||||
fs.writeFileSync(HERMES_SESSIONS_FILE, JSON.stringify(data), 'utf8');
|
||||
}
|
||||
|
||||
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
|
||||
function resolveLhPath(): string {
|
||||
@@ -32,40 +64,6 @@ export interface CancelHeteroTaskParams {
|
||||
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,
|
||||
@@ -231,37 +229,84 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
|
||||
if (!(await isHermesGatewayRunning(port))) {
|
||||
log.info(`Hermes gateway not running on port ${port}, starting...`);
|
||||
await startHermesGateway(port);
|
||||
// Kill any existing hermes process for this topicId before spawning a new one.
|
||||
for (const existing of listTasks()) {
|
||||
if (existing.topicId === topicId && existing.agentType === 'hermes') {
|
||||
try {
|
||||
process.kill(existing.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
removeTask(existing.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
// Resume the previous session for this topic if one exists.
|
||||
const existingSessionId = getHermesSessionId(topicId);
|
||||
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
|
||||
if (existingSessionId) {
|
||||
hermesArgs.push('--resume', existingSessionId);
|
||||
}
|
||||
|
||||
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
|
||||
// We capture stdout, parse both fields on exit, and relay the response via notify.
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
// 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,
|
||||
pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
});
|
||||
log.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
|
||||
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
|
||||
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
removeTask(taskId);
|
||||
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "session_id: <id>" from the first line, response from the rest.
|
||||
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
|
||||
const sessionId = sessionIdMatch?.[1];
|
||||
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
|
||||
|
||||
if (sessionId) saveHermesSessionId(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
} else {
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType as string}`);
|
||||
@@ -275,25 +320,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
|
||||
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.
|
||||
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,6 +41,7 @@ const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
export async function executeToolCall(
|
||||
apiName: string,
|
||||
argsStr: string,
|
||||
timeout?: number,
|
||||
): Promise<{
|
||||
content: string;
|
||||
error?: string;
|
||||
@@ -53,8 +54,12 @@ export async function executeToolCall(
|
||||
|
||||
try {
|
||||
const args = JSON.parse(argsStr);
|
||||
const finalArgs =
|
||||
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
|
||||
? { ...args, timeout }
|
||||
: args;
|
||||
|
||||
const result = await handler(args);
|
||||
const result = await handler(finalArgs);
|
||||
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
|
||||
return { content, success: true };
|
||||
|
||||
@@ -16,6 +16,7 @@ export class TrpcIngestSink implements IngestSink {
|
||||
private readonly agentType: 'claude-code' | 'codex',
|
||||
private readonly operationId: string,
|
||||
private readonly topicId: string,
|
||||
private readonly assistantMessageId?: string,
|
||||
) {}
|
||||
|
||||
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
|
||||
@@ -30,6 +31,7 @@ export class TrpcIngestSink implements IngestSink {
|
||||
async ingest(events: AgentStreamEvent[]): Promise<void> {
|
||||
await this.client.aiAgent.heteroIngest.mutate({
|
||||
agentType: this.agentType,
|
||||
assistantMessageId: this.assistantMessageId,
|
||||
events: events as any,
|
||||
operationId: this.operationId,
|
||||
topicId: this.topicId,
|
||||
|
||||
@@ -54,8 +54,10 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/chat-adapter-imessage": "workspace:*",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
@@ -104,7 +106,7 @@
|
||||
"stylelint": "^15.11.0",
|
||||
"superjson": "^2.2.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "8.0.14",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- '../cli'
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/chat-adapter-imessage'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/const'
|
||||
- '../../packages/electron-server-ipc'
|
||||
@@ -8,6 +9,7 @@ packages:
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/local-file-shell'
|
||||
- './stubs/business-const'
|
||||
- './stubs/types'
|
||||
|
||||
@@ -56,9 +56,11 @@
|
||||
"help.about": "关于",
|
||||
"help.githubRepo": "GitHub 仓库",
|
||||
"help.openConfigDir": "配置目录",
|
||||
"help.openHeteroAgentDir": "打开 HeteroAgent 目录",
|
||||
"help.openLogsDir": "打开日志目录",
|
||||
"help.reportIssue": "反馈问题",
|
||||
"help.title": "帮助",
|
||||
"help.toggleHeteroTracing": "记录 Agent CLI 调试日志",
|
||||
"help.visitWebsite": "打开官网",
|
||||
"history.back": "后退",
|
||||
"history.forward": "前进",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Default global `electron` mock (registered in `setup.ts`).
|
||||
*
|
||||
* Provides a fully-formed `app` (paths + readiness) plus light stubs for the
|
||||
* other commonly-imported namespaces. The point is that modules which touch
|
||||
* electron at import time — notably `@/const/dir`'s eager `app.getAppPath()` /
|
||||
* `app.getPath('userData')` — can be imported from ANY test without each suite
|
||||
* re-stubbing these basics. This keeps production code free to use plain
|
||||
* value-style path constants instead of lazy getter functions.
|
||||
*
|
||||
* Test files that need specific behavior still declare their own
|
||||
* `vi.mock('electron', …)`, which takes precedence per-file over this default.
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const app = {
|
||||
getAppPath: vi.fn(() => '/mock/app'),
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getName: vi.fn(() => 'LobeHub'),
|
||||
getPath: vi.fn((name: string) => `/mock/${name}`),
|
||||
getVersion: vi.fn(() => '0.0.0-test'),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
setName: vi.fn(),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
export const BrowserWindow = Object.assign(vi.fn(), {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
getFocusedWindow: vi.fn(() => null),
|
||||
});
|
||||
|
||||
export const Menu = {
|
||||
buildFromTemplate: vi.fn(() => ({})),
|
||||
setApplicationMenu: vi.fn(),
|
||||
};
|
||||
|
||||
export const ipcMain = { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() };
|
||||
|
||||
export const shell = {
|
||||
openExternal: vi.fn(() => Promise.resolve()),
|
||||
openPath: vi.fn(() => Promise.resolve('')),
|
||||
};
|
||||
|
||||
export const dialog = { showMessageBox: vi.fn(), showOpenDialog: vi.fn() };
|
||||
|
||||
export const nativeTheme = { on: vi.fn(), shouldUseDarkColors: false, themeSource: 'system' };
|
||||
|
||||
export const protocol = { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() };
|
||||
|
||||
export const clipboard = { readText: vi.fn(() => ''), writeText: vi.fn() };
|
||||
|
||||
export const nativeImage = { createEmpty: vi.fn(), createFromPath: vi.fn() };
|
||||
|
||||
export default {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
protocol,
|
||||
shell,
|
||||
};
|
||||
@@ -5,3 +5,9 @@ import { vi } from 'vitest';
|
||||
|
||||
// Mock node-mac-permissions before any imports
|
||||
vi.mock('node-mac-permissions', () => import('./node-mac-permissions'));
|
||||
|
||||
// Default electron mock: gives every suite a ready `app` (paths + readiness)
|
||||
// so modules with import-time electron access (e.g. `@/const/dir`) load safely
|
||||
// without per-suite stubbing. A test's own `vi.mock('electron', …)` overrides
|
||||
// this per-file.
|
||||
vi.mock('electron', () => import('./electron'));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Heterogeneous-agent (CC / Codex) working-directory segment names, relative to
|
||||
* `appStoragePath`. Kept in this side-effect-free module (no electron import)
|
||||
* so lightweight importers — the menu impls, the controller — get a single
|
||||
* source of truth without dragging in `@/const/dir`'s load-time `app.getPath`
|
||||
* calls.
|
||||
*
|
||||
* - `<HETERO_AGENT_DIR>/files` — downloaded-file cache
|
||||
* - `<HETERO_AGENT_DIR>/tracing` — CLI trace sessions (packaged / opted-in)
|
||||
*/
|
||||
export const HETERO_AGENT_DIR = 'heteroAgent';
|
||||
export const HETERO_AGENT_FILES_DIR = `${HETERO_AGENT_DIR}/files`;
|
||||
export const HETERO_AGENT_TRACING_DIR = `${HETERO_AGENT_DIR}/tracing`;
|
||||
@@ -34,6 +34,8 @@ export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
gatewayDeviceName: '',
|
||||
gatewayEnabled: true,
|
||||
gatewayUrl: 'https://device-gateway.lobehub.com',
|
||||
heteroTracingEnabled: false,
|
||||
imessageBridgeConfigs: [],
|
||||
locale: 'auto',
|
||||
localFileWorkspaceRoots: [],
|
||||
networkProxy: defaultProxySettings,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
|
||||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -13,8 +15,6 @@ 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`.
|
||||
@@ -55,6 +55,9 @@ interface PlatformTaskEntry {
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
type ToolCallHandler = () => Promise<unknown>;
|
||||
type ToolCallHandlerMap = Record<string, ToolCallHandler>;
|
||||
|
||||
/**
|
||||
* GatewayConnectionCtr
|
||||
*
|
||||
@@ -66,6 +69,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
/** In-memory registry for running platform agent tasks (openclaw / hermes). */
|
||||
private readonly platformTasks = new Map<string, PlatformTaskEntry>();
|
||||
|
||||
/** Maps topicId → hermes session_id for multi-turn conversation continuity. */
|
||||
private readonly hermesSessionMap = new Map<string, string>();
|
||||
|
||||
// ─── Service Accessor ───
|
||||
|
||||
private get service() {
|
||||
@@ -84,6 +90,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
|
||||
private get imessageBridgeSrv() {
|
||||
return this.app.getService(ImessageBridgeService);
|
||||
}
|
||||
|
||||
private get heterogeneousAgentCtr() {
|
||||
return this.app.getController(HeterogeneousAgentCtr);
|
||||
}
|
||||
@@ -102,9 +112,17 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
// Wire up tool call handler
|
||||
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
|
||||
|
||||
// Wire up message API handler
|
||||
srv.setMessageApiHandler((platform, apiName, payload) =>
|
||||
this.executeMessageApi(platform, apiName, payload),
|
||||
);
|
||||
|
||||
// Wire up agent run handler
|
||||
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
|
||||
|
||||
// Wire up device registrar (persists this device to the server registry)
|
||||
srv.setDeviceRegistrar((info) => this.registerDevice(info));
|
||||
|
||||
// Auto-connect if already logged in
|
||||
this.tryAutoConnect();
|
||||
}
|
||||
@@ -172,36 +190,26 @@ 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' };
|
||||
}
|
||||
|
||||
// Map agentType to binary name.
|
||||
// claude-code → `claude` CLI; all other platforms use their type name as the binary.
|
||||
const command = request.agentType === 'claude-code' ? 'claude' : request.agentType;
|
||||
|
||||
// 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,
|
||||
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,
|
||||
systemContext: request.systemContext,
|
||||
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);
|
||||
@@ -212,6 +220,37 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
|
||||
const methodMap = {
|
||||
...this.getLocalFileToolHandlers(args),
|
||||
...this.getShellCommandToolHandlers(args),
|
||||
...this.getPlatformAgentToolHandlers(args),
|
||||
} satisfies ToolCallHandlerMap;
|
||||
|
||||
const handler = methodMap[apiName];
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
|
||||
);
|
||||
}
|
||||
|
||||
return handler();
|
||||
}
|
||||
|
||||
private async executeMessageApi(
|
||||
platform: string,
|
||||
apiName: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
if (platform === 'imessage') {
|
||||
return this.imessageBridgeSrv.handleGatewayMessageApi(apiName, payload);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Message API "${platform}/${apiName}" is not available on this device. It may not be supported in the current desktop version.`,
|
||||
);
|
||||
}
|
||||
|
||||
private getLocalFileToolHandlers(args: any): ToolCallHandlerMap {
|
||||
const editFile = () => this.localFileCtr.handleEditFile(args);
|
||||
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
|
||||
const listFiles = () => this.localFileCtr.listLocalFiles(args);
|
||||
@@ -220,7 +259,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
|
||||
const writeFile = () => this.localFileCtr.handleWriteFile(args);
|
||||
|
||||
const methodMap: Record<string, () => Promise<unknown>> = {
|
||||
return {
|
||||
editFile,
|
||||
globFiles,
|
||||
grepContent: () => this.localFileCtr.handleGrepContent(args),
|
||||
@@ -230,10 +269,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
searchFiles,
|
||||
writeFile,
|
||||
|
||||
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
|
||||
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
|
||||
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
|
||||
|
||||
// Legacy aliases — keep these so older Gateway versions sending the long
|
||||
// names continue to route correctly. `renameLocalFile` is also kept even
|
||||
// though the new surface drops rename (it's now handled by `moveFiles`).
|
||||
@@ -245,7 +280,19 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: searchFiles,
|
||||
writeLocalFile: writeFile,
|
||||
};
|
||||
}
|
||||
|
||||
private getShellCommandToolHandlers(args: any): ToolCallHandlerMap {
|
||||
return {
|
||||
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
|
||||
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
|
||||
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
|
||||
};
|
||||
}
|
||||
|
||||
private getPlatformAgentToolHandlers(args: any): ToolCallHandlerMap {
|
||||
return {
|
||||
// Platform agent capability probing
|
||||
checkPlatformCapability: () => this.checkPlatformCapability(args),
|
||||
getAgentProfile: () => this.getAgentProfile(args),
|
||||
@@ -254,15 +301,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
cancelHeteroTask: () => this.cancelHeteroTask(args),
|
||||
runHeteroTask: () => this.runHeteroTask(args),
|
||||
};
|
||||
|
||||
const handler = methodMap[apiName];
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
|
||||
);
|
||||
}
|
||||
|
||||
return handler();
|
||||
}
|
||||
|
||||
// ─── Platform Capability Probing ───
|
||||
@@ -312,10 +350,71 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.getOpenClawProfile(agentId);
|
||||
}
|
||||
|
||||
// hermes and unknown platforms: not yet implemented
|
||||
if (platform === 'hermes') {
|
||||
return this.getHermesProfile();
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getHermesProfile(): { avatar?: string; description?: string; title?: string } {
|
||||
// Find the active profile (marked with ◆ in `hermes profile list`).
|
||||
let profileName: string | undefined;
|
||||
try {
|
||||
const listOutput = execFileSync('hermes', ['profile', 'list'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
profileName = listOutput.match(/◆(\S+)/)?.[1];
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!profileName) return {};
|
||||
|
||||
// Get the profile's filesystem path.
|
||||
let profilePath: string | undefined;
|
||||
try {
|
||||
const showOutput = execFileSync('hermes', ['profile', 'show', profileName], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const raw = showOutput.match(/^Path:\s+(.+)/m)?.[1]?.trim();
|
||||
profilePath = raw?.replace(/^~(?=\/|$)/, os.homedir());
|
||||
} catch {
|
||||
// Profile path unavailable — still return name + avatar.
|
||||
}
|
||||
|
||||
const description = profilePath
|
||||
? this.readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
|
||||
: undefined;
|
||||
|
||||
return { avatar: '⚡', description, title: profileName };
|
||||
}
|
||||
|
||||
private readHermesSoulDescription(soulPath: string): string | undefined {
|
||||
try {
|
||||
const content = fs.readFileSync(soulPath, 'utf8');
|
||||
// Loop until stable to handle any malformed/nested comment sequences.
|
||||
let stripped = content;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
|
||||
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
|
||||
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
|
||||
} while (stripped !== previous);
|
||||
return (
|
||||
stripped
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) || undefined
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getOpenClawProfile(agentId?: string): {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
@@ -391,6 +490,18 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
const [serverUrl, accessToken] = await Promise.all([
|
||||
this.remoteServerConfigCtr.getRemoteServerUrl(),
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
|
||||
// Inject auth into child env so `lh notify` can authenticate without CLI config.
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...(accessToken && { LOBEHUB_JWT: accessToken }),
|
||||
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
|
||||
};
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
const lhPath = this.resolveLhPath();
|
||||
const openclawAgent = process.env['OPENCLAW_AGENT_ID'] ?? 'main';
|
||||
@@ -426,7 +537,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
enrichedPrompt,
|
||||
'--local',
|
||||
],
|
||||
{ cwd: workDir, detached: true, env: { ...process.env }, stdio: 'ignore' },
|
||||
{ cwd: workDir, detached: true, env: childEnv, stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const pid = child.pid;
|
||||
@@ -453,20 +564,74 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
if (!(await this.isHermesRunning(port))) {
|
||||
await this.startHermesGateway(port);
|
||||
// Kill any existing hermes process for this topicId before spawning a new one.
|
||||
for (const [existingTaskId, entry] of this.platformTasks) {
|
||||
if (entry.topicId === topicId && entry.agentType === 'hermes') {
|
||||
try {
|
||||
process.kill(entry.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
this.platformTasks.delete(existingTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
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()}`);
|
||||
// Resume the previous session for this topic if one exists.
|
||||
const existingSessionId = this.hermesSessionMap.get(topicId);
|
||||
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
|
||||
if (existingSessionId) {
|
||||
hermesArgs.push('--resume', existingSessionId);
|
||||
}
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid: 0, topicId });
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "session_id: <id>" from the first line, response from the rest.
|
||||
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
|
||||
const sessionId = sessionIdMatch?.[1];
|
||||
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
|
||||
|
||||
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void this.sendNotify({ agentId, content: response, 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 });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType}`);
|
||||
@@ -480,28 +645,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
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.
|
||||
// Both openclaw and hermes: kill by PID; the close handler sends the done signal.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch {
|
||||
@@ -536,11 +680,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/agentNotify.notify`, {
|
||||
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
|
||||
body: JSON.stringify({ json: params }),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -549,6 +693,34 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this device to the server registry via `device.register`.
|
||||
* Fire-and-forget from the connect path: a failure must not block the WS
|
||||
* connection, the device just won't appear in the offline list until the
|
||||
* next successful connect.
|
||||
*/
|
||||
private async registerDevice(info: {
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
identitySource: string;
|
||||
platform: string;
|
||||
}): Promise<void> {
|
||||
const [serverUrl, token] = await Promise.all([
|
||||
this.remoteServerConfigCtr.getRemoteServerUrl(),
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/lambda/device.register`, {
|
||||
body: JSON.stringify({ json: info }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Platform Agent Helpers ───
|
||||
|
||||
private resolveLhPath(): string {
|
||||
@@ -558,37 +730,4 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { HETERO_AGENT_FILES_DIR, HETERO_AGENT_TRACING_DIR } from '@/const/heteroAgent';
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import type {
|
||||
HeterogeneousAgentBuildPlan,
|
||||
@@ -62,7 +63,7 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
|
||||
] as const;
|
||||
|
||||
/** Directory under appStoragePath for caching downloaded files */
|
||||
const FILE_CACHE_DIR = 'heteroAgent/files';
|
||||
const FILE_CACHE_DIR = HETERO_AGENT_FILES_DIR;
|
||||
const CLI_TRACE_DIR = '.heerogeneous-tracing';
|
||||
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
|
||||
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
|
||||
@@ -434,7 +435,32 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
|
||||
if (process.env.NODE_ENV === 'test') return false;
|
||||
// Dev builds always trace. Packaged builds trace only when the user has
|
||||
// flipped the Help-menu developer toggle — so production issues can be
|
||||
// captured on demand without polluting normal runs.
|
||||
if (!electronApp.isPackaged) return true;
|
||||
return this.app.storeManager.get('heteroTracingEnabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root directory for CLI trace sessions.
|
||||
*
|
||||
* When the user has explicitly opted in via the `heteroTracingEnabled`
|
||||
* Help-menu toggle, centralize traces under the app storage dir
|
||||
* (`<appStoragePath>/heteroAgent/tracing`) — this is the only path packaged
|
||||
* builds ever trace through, and it keeps traces out of the user's real
|
||||
* project directory while staying reachable from one stable Help-menu entry.
|
||||
*
|
||||
* Otherwise (a plain dev run with the toggle off) keep writing into the
|
||||
* working directory (`cwd/.heerogeneous-tracing`) — devs expect traces to
|
||||
* show up alongside the repo they're running in.
|
||||
*/
|
||||
private resolveTraceRootDir(cwd: string): string {
|
||||
if (this.app.storeManager.get('heteroTracingEnabled', false)) {
|
||||
return path.join(this.app.appStoragePath, HETERO_AGENT_TRACING_DIR);
|
||||
}
|
||||
return path.join(cwd, CLI_TRACE_DIR);
|
||||
}
|
||||
|
||||
private formatTraceTimestamp(date: Date): string {
|
||||
@@ -501,7 +527,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const rootDir = path.join(cwd, CLI_TRACE_DIR);
|
||||
const rootDir = this.resolveTraceRootDir(cwd);
|
||||
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
|
||||
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
|
||||
session.sessionId,
|
||||
@@ -1251,4 +1277,89 @@ 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;
|
||||
systemContext?: string;
|
||||
topicId: string;
|
||||
}): void {
|
||||
const {
|
||||
agentType,
|
||||
cwd,
|
||||
jwt,
|
||||
operationId,
|
||||
prompt,
|
||||
resumeSessionId,
|
||||
serverUrl,
|
||||
systemContext,
|
||||
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'],
|
||||
});
|
||||
|
||||
// When systemContext is provided, send a content-block array so CC sees the
|
||||
// context block first, then the user's actual message — mirrors
|
||||
// spawnHeteroSandbox. lh handles JSON arrays via coerceJsonPrompt, so no lh
|
||||
// changes are required.
|
||||
const stdinPayload = systemContext
|
||||
? JSON.stringify([
|
||||
{ text: systemContext, type: 'text' },
|
||||
{ text: prompt, type: 'text' },
|
||||
])
|
||||
: JSON.stringify(prompt);
|
||||
child.stdin.write(stdinPayload);
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
ImessageBridgeConfig,
|
||||
ImessageBridgeSaveResult,
|
||||
ImessageBridgeStatus,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
|
||||
const logger = createLogger('controllers:ImessageBridgeCtr');
|
||||
|
||||
export default class ImessageBridgeCtr extends ControllerModule {
|
||||
static override readonly groupName = 'imessageBridge';
|
||||
|
||||
private get service() {
|
||||
return this.app.getService(ImessageBridgeService);
|
||||
}
|
||||
|
||||
private get remoteServerConfigCtr() {
|
||||
return this.app.getController(RemoteServerConfigCtr);
|
||||
}
|
||||
|
||||
afterAppReady() {
|
||||
this.service.setRemoteServerProvider({
|
||||
getAccessToken: () => this.remoteServerConfigCtr.getAccessToken(),
|
||||
getServerUrl: async () => (await this.remoteServerConfigCtr.getRemoteServerUrl()) ?? null,
|
||||
});
|
||||
|
||||
this.service.start().catch((error) => {
|
||||
// The user can fix BlueBubbles or remote-server settings from the UI and start again.
|
||||
logger.warn('Failed to auto-start iMessage bridge:', error);
|
||||
});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getStatus(): Promise<ImessageBridgeStatus> {
|
||||
return this.service.getStatus();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async upsertConfig(config: ImessageBridgeConfig): Promise<ImessageBridgeSaveResult> {
|
||||
const saved = await this.service.upsertConfig(config);
|
||||
return { config: saved, success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async removeConfig(params: { applicationId: string }): Promise<{ success: boolean }> {
|
||||
return this.service.removeConfig(params.applicationId);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async start(): Promise<ImessageBridgeStatus> {
|
||||
return this.service.start();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async stop(): Promise<{ success: boolean }> {
|
||||
return this.service.stop();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async testConfig(config: ImessageBridgeConfig): Promise<{ success: boolean }> {
|
||||
return this.service.testConfig(config);
|
||||
}
|
||||
}
|
||||
@@ -248,14 +248,15 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
logger.debug('Attempting to open file:', { filePath });
|
||||
const resolvedPath = expandTilde(filePath) ?? filePath;
|
||||
logger.debug('Attempting to open file:', { filePath: resolvedPath });
|
||||
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
logger.debug('File opened successfully:', { filePath });
|
||||
await shell.openPath(resolvedPath);
|
||||
logger.debug('File opened successfully:', { filePath: resolvedPath });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to open file ${filePath}:`, error);
|
||||
logger.error(`Failed to open file ${resolvedPath}:`, error);
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
}
|
||||
@@ -265,8 +266,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
|
||||
logger.debug('Attempting to open folder:', { folderPath, isDirectory, targetPath });
|
||||
const resolvedTarget = expandTilde(targetPath) ?? targetPath;
|
||||
const folderPath = isDirectory ? resolvedTarget : path.dirname(resolvedTarget);
|
||||
logger.debug('Attempting to open folder:', {
|
||||
folderPath,
|
||||
isDirectory,
|
||||
targetPath: resolvedTarget,
|
||||
});
|
||||
|
||||
try {
|
||||
await shell.openPath(folderPath);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
|
||||
import GatewayConnectionCtr from '../GatewayConnectionCtr';
|
||||
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
@@ -34,6 +35,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
sendToolCallResponse = vi.fn();
|
||||
sendMessageApiResponse = vi.fn();
|
||||
sendAgentRunAck = vi.fn();
|
||||
|
||||
constructor(options: any) {
|
||||
@@ -67,6 +69,19 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
});
|
||||
}
|
||||
|
||||
simulateMessageApiRequest(
|
||||
platform: string,
|
||||
apiName: string,
|
||||
payload: Record<string, unknown>,
|
||||
requestId = 'msg-req-1',
|
||||
) {
|
||||
this.emit('message_api_request', {
|
||||
api: { apiName, payload, platform },
|
||||
requestId,
|
||||
type: 'message_api_request',
|
||||
});
|
||||
}
|
||||
|
||||
simulateAuthExpired() {
|
||||
this.emit('auth_expired');
|
||||
}
|
||||
@@ -80,6 +95,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
operationId = 'op-1',
|
||||
prompt = 'hello',
|
||||
jwt = 'mock-jwt',
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
this.emit('agent_run_request', {
|
||||
agentType,
|
||||
@@ -88,6 +104,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
prompt,
|
||||
topicId: 'topic-1',
|
||||
type: 'agent_run_request',
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,6 +177,10 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
GatewayClient: MockGatewayClient,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/imessageBridgeSrv', () => ({
|
||||
default: class ImessageBridgeService {},
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
}));
|
||||
@@ -200,11 +221,17 @@ const mockShellCommandCtr = {
|
||||
|
||||
const mockHeterogeneousAgentCtr = {
|
||||
sendPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
spawnLhHeteroExec: vi.fn(),
|
||||
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
|
||||
} as unknown as HeterogeneousAgentCtr;
|
||||
|
||||
const mockImessageBridgeSrv = {
|
||||
handleGatewayMessageApi: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as unknown as ImessageBridgeService;
|
||||
|
||||
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;
|
||||
@@ -224,6 +251,7 @@ const mockApp = {
|
||||
}),
|
||||
getService: vi.fn((Cls) => {
|
||||
if (Cls === GatewayConnectionService) return mockGatewayConnectionSrv;
|
||||
if (Cls === ImessageBridgeService) return mockImessageBridgeSrv;
|
||||
return null;
|
||||
}),
|
||||
storeManager: { get: mockStoreGet, set: mockStoreSet },
|
||||
@@ -580,6 +608,66 @@ describe('GatewayConnectionCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('message API routing', () => {
|
||||
async function connectAndOpen() {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
return client;
|
||||
}
|
||||
|
||||
it('should route iMessage message API requests to the iMessage bridge service', async () => {
|
||||
vi.mocked(mockImessageBridgeSrv.handleGatewayMessageApi).mockResolvedValueOnce({
|
||||
guid: 'sent-1',
|
||||
});
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateMessageApiRequest(
|
||||
'imessage',
|
||||
'sendText',
|
||||
{
|
||||
applicationId: 'home-mac-mini',
|
||||
chatGuid: 'iMessage;-;chat-1',
|
||||
message: 'hello',
|
||||
},
|
||||
'msg-req-42',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockImessageBridgeSrv.handleGatewayMessageApi).toHaveBeenCalledWith('sendText', {
|
||||
applicationId: 'home-mac-mini',
|
||||
chatGuid: 'iMessage;-;chat-1',
|
||||
message: 'hello',
|
||||
});
|
||||
expect(client.sendMessageApiResponse).toHaveBeenCalledWith({
|
||||
requestId: 'msg-req-42',
|
||||
result: {
|
||||
content: JSON.stringify({ guid: 'sent-1' }),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send message_api_response with error for unsupported platforms', async () => {
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateMessageApiRequest('unsupported', 'sendText', {}, 'msg-req-err');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const errorMsg =
|
||||
'Message API "unsupported/sendText" is not available on this device. It may not be supported in the current desktop version.';
|
||||
expect(client.sendMessageApiResponse).toHaveBeenCalledWith({
|
||||
requestId: 'msg-req-err',
|
||||
result: {
|
||||
content: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth Expired ───
|
||||
|
||||
describe('auth_expired handling', () => {
|
||||
@@ -631,26 +719,39 @@ describe('GatewayConnectionCtr', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockClear();
|
||||
vi.mocked(mockHeterogeneousAgentCtr.sendPrompt).mockClear();
|
||||
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['openclaw', 'openclaw'],
|
||||
['hermes', 'hermes'],
|
||||
['codex', 'codex'],
|
||||
['claude-code', 'claude'],
|
||||
] as const)('uses command "%s" for agentType "%s"', async (agentType, expectedCommand) => {
|
||||
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('forwards cwd and systemContext from the request to spawnLhHeteroExec', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest(agentType);
|
||||
client.simulateAgentRunRequest('claude-code', 'op-ctx', 'hi', 'mock-jwt', {
|
||||
cwd: '/Users/alice/repo',
|
||||
systemContext: 'WORKSPACE CONTEXT',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockHeterogeneousAgentCtr.startSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentType, command: expectedCommand }),
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/Users/alice/repo',
|
||||
systemContext: 'WORKSPACE CONTEXT',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends accepted ack and fires sendPrompt', async () => {
|
||||
it('sends accepted ack and spawns lh hetero exec', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-xyz');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -659,15 +760,37 @@ describe('GatewayConnectionCtr', () => {
|
||||
operationId: 'op-xyz',
|
||||
status: 'accepted',
|
||||
});
|
||||
expect(mockHeterogeneousAgentCtr.sendPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ operationId: 'op-xyz', sessionId: 'mock-session-id' }),
|
||||
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 startSession throws', async () => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockRejectedValueOnce(
|
||||
new Error('binary not found'),
|
||||
);
|
||||
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');
|
||||
|
||||
@@ -5,6 +5,9 @@ import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { HeterogeneousAgentSessionErrorCode } from '@lobechat/electron-client-ipc';
|
||||
// `electron` is mocked below; this binding is the mock object so tests can
|
||||
// flip `isPackaged` to exercise the packaged-build tracing gate.
|
||||
import { app as electronAppMock } from 'electron';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
@@ -23,6 +26,7 @@ const { mockGetAllWindows } = vi.hoisted(() => ({
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => mockGetAllWindows() },
|
||||
app: {
|
||||
getAppPath: vi.fn(() => '/fake/appPath'),
|
||||
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
@@ -331,13 +335,14 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
sessionOverrides: Record<string, any> = {},
|
||||
stdoutLines: string[] = [],
|
||||
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
|
||||
storeGet?: (key: string, defaultValue?: any) => any,
|
||||
) => {
|
||||
const { proc, writes } = createFakeProc({ stdoutLines });
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
storeManager: { get: storeGet ? vi.fn(storeGet) : vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
@@ -620,6 +625,85 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('centralizes to heteroAgent/tracing in dev too when the toggle is on', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
// Dev (isPackaged stays false), but the user opted in via the toggle.
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
try {
|
||||
const prompt = 'trace this opted-in dev run';
|
||||
const rawLine = `${JSON.stringify({
|
||||
thread_id: 'thread_codex_dev_optin',
|
||||
type: 'thread.started',
|
||||
})}\n`;
|
||||
await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {}, (key: string) =>
|
||||
key === 'heteroTracingEnabled' ? true : undefined,
|
||||
);
|
||||
|
||||
const agentTraceRoot = path.join(appStoragePath, 'heteroAgent', 'tracing', 'codex');
|
||||
const traceDirs = await readdir(agentTraceRoot);
|
||||
expect(traceDirs).toHaveLength(1);
|
||||
|
||||
// Toggle wins over the dev cwd default.
|
||||
await expect(readdir(path.join(appStoragePath, '.heerogeneous-tracing'))).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('traces to the centralized heteroAgent/tracing dir in packaged builds when the toggle is on', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
// The gate short-circuits to `false` under NODE_ENV=test, so simulate a
|
||||
// real packaged production process.
|
||||
process.env.NODE_ENV = 'production';
|
||||
(electronAppMock as any).isPackaged = true;
|
||||
|
||||
try {
|
||||
const prompt = 'trace this packaged run';
|
||||
const rawLine = `${JSON.stringify({
|
||||
thread_id: 'thread_codex_packaged',
|
||||
type: 'thread.started',
|
||||
})}\n`;
|
||||
await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {}, (key: string) =>
|
||||
key === 'heteroTracingEnabled' ? true : undefined,
|
||||
);
|
||||
|
||||
// Centralized under appStoragePath/heteroAgent/tracing — NOT in the cwd.
|
||||
const traceRoot = path.join(appStoragePath, 'heteroAgent', 'tracing');
|
||||
const agentTraceRoot = path.join(traceRoot, 'codex');
|
||||
const traceDirs = await readdir(agentTraceRoot);
|
||||
expect(traceDirs).toHaveLength(1);
|
||||
|
||||
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
|
||||
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
|
||||
|
||||
// The dev-style cwd location must NOT be written in packaged mode.
|
||||
await expect(readdir(path.join(appStoragePath, '.heerogeneous-tracing'))).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
(electronAppMock as any).isPackaged = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trace in packaged builds when the toggle is off', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
(electronAppMock as any).isPackaged = true;
|
||||
|
||||
try {
|
||||
await runSendPrompt('no trace please', { cwd: appStoragePath }, [], {}, (key: string) =>
|
||||
key === 'heteroTracingEnabled' ? false : undefined,
|
||||
);
|
||||
|
||||
await expect(
|
||||
readdir(path.join(appStoragePath, 'heteroAgent', 'tracing')),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
(electronAppMock as any).isPackaged = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
@@ -143,6 +143,17 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open' });
|
||||
});
|
||||
|
||||
it('should expand a leading ~ to the user home directory', async () => {
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '~/git/work/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work/file.txt'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFolder', () => {
|
||||
@@ -158,6 +169,20 @@ describe('LocalFileCtr', () => {
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should expand a leading ~ when opening a directory', async () => {
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '~/git/work',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work'));
|
||||
});
|
||||
|
||||
it('should open parent directory when isDirectory is false', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
|
||||
@@ -29,10 +29,6 @@ vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'test-uuid-123'),
|
||||
}));
|
||||
|
||||
vi.mock('../CliCtr', () => ({
|
||||
default: class CliCtr {},
|
||||
}));
|
||||
@@ -59,7 +55,9 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
mockChildProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
exitCode: null,
|
||||
};
|
||||
@@ -73,6 +71,10 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
@@ -89,14 +91,21 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
});
|
||||
|
||||
it('should delegate handleGetCommandOutput to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('bg output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await ctr.handleRunCommand({
|
||||
const runResult = await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
@@ -104,7 +113,7 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const result = await ctr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
shell_id: runResult.shell_id!,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -113,16 +122,17 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
|
||||
it('should delegate handleKillCommand to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.once.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await ctr.handleRunCommand({
|
||||
const runResult = await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const result = await ctr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
shell_id: runResult.shell_id!,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import DevtoolsCtr from './DevtoolsCtr';
|
||||
import GatewayConnectionCtr from './GatewayConnectionCtr';
|
||||
import GitCtr from './GitCtr';
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import ImessageBridgeCtr from './ImessageBridgeCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import McpInstallCtr from './McpInstallCtr';
|
||||
@@ -33,6 +34,7 @@ export const controllerIpcConstructors = [
|
||||
GatewayConnectionCtr,
|
||||
GitCtr,
|
||||
LocalFileCtr,
|
||||
ImessageBridgeCtr,
|
||||
McpCtr,
|
||||
McpInstallCtr,
|
||||
MenuController,
|
||||
|
||||
@@ -7,7 +7,7 @@ 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';
|
||||
import { resolveLocalFileMimeType } from '../../utils/mime';
|
||||
|
||||
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: false,
|
||||
@@ -22,20 +22,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
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;
|
||||
@@ -130,7 +116,7 @@ export class LocalFileProtocolManager {
|
||||
|
||||
const buffer = await readFile(realResolvedPath);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', getMimeType(realResolvedPath));
|
||||
headers.set('Content-Type', resolveLocalFileMimeType(realResolvedPath, buffer));
|
||||
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
|
||||
|
||||
@@ -56,9 +56,11 @@ const menu = {
|
||||
'help.about': 'About',
|
||||
'help.githubRepo': 'GitHub Repository',
|
||||
'help.openConfigDir': 'Open Config Directory',
|
||||
'help.openHeteroAgentDir': 'Open HeteroAgent Directory',
|
||||
'help.openLogsDir': 'Open Logs Directory',
|
||||
'help.reportIssue': 'Send Feedback',
|
||||
'help.title': 'Help',
|
||||
'help.toggleHeteroTracing': 'Record Agent CLI Trace Logs',
|
||||
'help.visitWebsite': 'Open Website',
|
||||
'history.back': 'Back',
|
||||
'history.forward': 'Forward',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import ZoomService, { type ZoomAction } from '@/services/zoomSrv';
|
||||
|
||||
export abstract class BaseMenuPlatform {
|
||||
protected app: App;
|
||||
@@ -7,4 +11,44 @@ export abstract class BaseMenuPlatform {
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
protected buildZoomMenuItem(
|
||||
action: ZoomAction,
|
||||
label: string,
|
||||
accelerator: string,
|
||||
): MenuItemConstructorOptions {
|
||||
return this.buildZoomMenuItemOption(action, label, accelerator);
|
||||
}
|
||||
|
||||
protected buildZoomMenuItems(
|
||||
action: ZoomAction,
|
||||
label: string,
|
||||
accelerator: string,
|
||||
alternateAccelerators: string[],
|
||||
): MenuItemConstructorOptions[] {
|
||||
return [
|
||||
this.buildZoomMenuItemOption(action, label, accelerator),
|
||||
...alternateAccelerators.map((alternateAccelerator) =>
|
||||
this.buildZoomMenuItemOption(action, label, alternateAccelerator, false),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private buildZoomMenuItemOption(
|
||||
action: ZoomAction,
|
||||
label: string,
|
||||
accelerator: string,
|
||||
visible = true,
|
||||
): MenuItemConstructorOptions {
|
||||
return {
|
||||
accelerator,
|
||||
click: (_item, win) => {
|
||||
const target = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow();
|
||||
if (!target) return;
|
||||
this.app.getService(ZoomService).apply(action, target.webContents);
|
||||
},
|
||||
label,
|
||||
visible,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@ const createMockApp = () => {
|
||||
updaterManager: {
|
||||
checkForUpdates: vi.fn(),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
} as unknown as App;
|
||||
};
|
||||
|
||||
@@ -496,13 +500,20 @@ describe('LinuxMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const viewMenu = template.find((item: any) => item.label === 'View');
|
||||
|
||||
const resetZoomItem = viewMenu.submenu.find((item: any) => item.role === 'resetZoom');
|
||||
const zoomInItem = viewMenu.submenu.find((item: any) => item.role === 'zoomIn');
|
||||
const zoomOutItem = viewMenu.submenu.find((item: any) => item.role === 'zoomOut');
|
||||
const resetZoomItem = viewMenu.submenu.find((item: any) => item.label === 'Reset Zoom');
|
||||
const zoomInItems = viewMenu.submenu.filter((item: any) => item.label === 'Zoom In');
|
||||
const zoomInItem = zoomInItems.find((item: any) => item.visible !== false);
|
||||
const alternateZoomInItem = zoomInItems.find((item: any) => item.visible === false);
|
||||
const zoomOutItem = viewMenu.submenu.find((item: any) => item.label === 'Zoom Out');
|
||||
|
||||
expect(resetZoomItem).toBeDefined();
|
||||
expect(zoomInItem).toBeDefined();
|
||||
expect(zoomOutItem).toBeDefined();
|
||||
expect(resetZoomItem.accelerator).toBe('CmdOrCtrl+0');
|
||||
expect(typeof resetZoomItem.click).toBe('function');
|
||||
expect(zoomInItem.accelerator).toBe('CmdOrCtrl+=');
|
||||
expect(typeof zoomInItem.click).toBe('function');
|
||||
expect(alternateZoomInItem.accelerator).toBe('CmdOrCtrl+Plus');
|
||||
expect(typeof alternateZoomInItem.click).toBe('function');
|
||||
expect(zoomOutItem.accelerator).toBe('CmdOrCtrl+-');
|
||||
expect(typeof zoomOutItem.click).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
|
||||
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
|
||||
import { BaseMenuPlatform } from './BaseMenuPlatform';
|
||||
@@ -154,9 +157,9 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
submenu: [
|
||||
{ accelerator: 'F12', label: t('dev.devTools'), role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ label: t('view.resetZoom'), role: 'resetZoom' },
|
||||
{ label: t('view.zoomIn'), role: 'zoomIn' },
|
||||
{ label: t('view.zoomOut'), role: 'zoomOut' },
|
||||
this.buildZoomMenuItem('reset', t('view.resetZoom'), 'CmdOrCtrl+0'),
|
||||
...this.buildZoomMenuItems('in', t('view.zoomIn'), 'CmdOrCtrl+=', ['CmdOrCtrl+Plus']),
|
||||
this.buildZoomMenuItem('out', t('view.zoomOut'), 'CmdOrCtrl+-'),
|
||||
{ type: 'separator' },
|
||||
{ label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
|
||||
],
|
||||
@@ -214,6 +217,25 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
label: t('help.githubRepo'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR);
|
||||
console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`);
|
||||
shell.openPath(heteroAgentPath).catch((err) => {
|
||||
console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err);
|
||||
});
|
||||
},
|
||||
label: t('help.openHeteroAgentDir'),
|
||||
},
|
||||
{
|
||||
checked: this.app.storeManager.get('heteroTracingEnabled', false),
|
||||
click: (item) => {
|
||||
this.app.storeManager.set('heteroTracingEnabled', item.checked);
|
||||
},
|
||||
label: t('help.toggleHeteroTracing'),
|
||||
type: 'checkbox',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
const commonT = this.app.i18n.ns('common');
|
||||
|
||||
@@ -94,7 +94,9 @@ const createMockApp = () => {
|
||||
rebuildAppMenu: vi.fn(),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(),
|
||||
openInEditor: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
} as unknown as App;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
import NotificationCtr from '@/controllers/NotificationCtr';
|
||||
import SystemController from '@/controllers/SystemCtr';
|
||||
|
||||
@@ -205,9 +206,9 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ label: t('view.forceReload'), role: 'forceReload' },
|
||||
{ accelerator: 'F12', label: t('dev.devTools'), role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ label: t('view.resetZoom'), role: 'resetZoom' },
|
||||
{ label: t('view.zoomIn'), role: 'zoomIn' },
|
||||
{ label: t('view.zoomOut'), role: 'zoomOut' },
|
||||
this.buildZoomMenuItem('reset', t('view.resetZoom'), 'CmdOrCtrl+0'),
|
||||
...this.buildZoomMenuItems('in', t('view.zoomIn'), 'CmdOrCtrl+=', ['CmdOrCtrl+Plus']),
|
||||
this.buildZoomMenuItem('out', t('view.zoomOut'), 'CmdOrCtrl+-'),
|
||||
{ type: 'separator' },
|
||||
{ accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
|
||||
],
|
||||
@@ -294,6 +295,25 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
},
|
||||
label: t('help.openConfigDir'),
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR);
|
||||
console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`);
|
||||
shell.openPath(heteroAgentPath).catch((err) => {
|
||||
console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err);
|
||||
});
|
||||
},
|
||||
label: t('help.openHeteroAgentDir'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
checked: this.app.storeManager.get('heteroTracingEnabled', false),
|
||||
click: (item) => {
|
||||
this.app.storeManager.set('heteroTracingEnabled', item.checked);
|
||||
},
|
||||
label: t('help.toggleHeteroTracing'),
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -85,6 +85,10 @@ const createMockApp = () => {
|
||||
getUpdaterState: vi.fn(() => ({ stage: 'idle' })),
|
||||
installNow: vi.fn(),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
} as unknown as App;
|
||||
};
|
||||
|
||||
@@ -421,13 +425,20 @@ describe('WindowsMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const viewMenu = template.find((item: any) => item.label === 'View');
|
||||
|
||||
const resetZoomItem = viewMenu.submenu.find((item: any) => item.role === 'resetZoom');
|
||||
const zoomInItem = viewMenu.submenu.find((item: any) => item.role === 'zoomIn');
|
||||
const zoomOutItem = viewMenu.submenu.find((item: any) => item.role === 'zoomOut');
|
||||
const resetZoomItem = viewMenu.submenu.find((item: any) => item.label === 'Reset Zoom');
|
||||
const zoomInItems = viewMenu.submenu.filter((item: any) => item.label === 'Zoom In');
|
||||
const zoomInItem = zoomInItems.find((item: any) => item.visible !== false);
|
||||
const alternateZoomInItem = zoomInItems.find((item: any) => item.visible === false);
|
||||
const zoomOutItem = viewMenu.submenu.find((item: any) => item.label === 'Zoom Out');
|
||||
|
||||
expect(resetZoomItem).toBeDefined();
|
||||
expect(zoomInItem).toBeDefined();
|
||||
expect(zoomOutItem).toBeDefined();
|
||||
expect(resetZoomItem.accelerator).toBe('CmdOrCtrl+0');
|
||||
expect(typeof resetZoomItem.click).toBe('function');
|
||||
expect(zoomInItem.accelerator).toBe('CmdOrCtrl+=');
|
||||
expect(typeof zoomInItem.click).toBe('function');
|
||||
expect(alternateZoomInItem.accelerator).toBe('CmdOrCtrl+Plus');
|
||||
expect(typeof alternateZoomInItem.click).toBe('function');
|
||||
expect(zoomOutItem.accelerator).toBe('CmdOrCtrl+-');
|
||||
expect(typeof zoomOutItem.click).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user