mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
40 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 | |||
| 694a25822f |
@@ -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,
|
||||
};
|
||||
```
|
||||
@@ -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"
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 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: spa-routes
|
||||
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`, or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
|
||||
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/` |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -54,6 +55,9 @@ interface PlatformTaskEntry {
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
type ToolCallHandler = () => Promise<unknown>;
|
||||
type ToolCallHandlerMap = Record<string, ToolCallHandler>;
|
||||
|
||||
/**
|
||||
* GatewayConnectionCtr
|
||||
*
|
||||
@@ -86,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);
|
||||
}
|
||||
@@ -104,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();
|
||||
}
|
||||
@@ -190,6 +206,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
prompt: request.prompt,
|
||||
resumeSessionId: request.resumeSessionId,
|
||||
serverUrl,
|
||||
systemContext: request.systemContext,
|
||||
topicId: request.topicId,
|
||||
});
|
||||
|
||||
@@ -203,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);
|
||||
@@ -211,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),
|
||||
@@ -221,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`).
|
||||
@@ -236,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),
|
||||
@@ -245,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 ───
|
||||
@@ -646,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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -1266,10 +1292,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
serverUrl: string;
|
||||
systemContext?: string;
|
||||
topicId: string;
|
||||
}): void {
|
||||
const { agentType, cwd, jwt, operationId, prompt, resumeSessionId, serverUrl, topicId } =
|
||||
params;
|
||||
const {
|
||||
agentType,
|
||||
cwd,
|
||||
jwt,
|
||||
operationId,
|
||||
prompt,
|
||||
resumeSessionId,
|
||||
serverUrl,
|
||||
systemContext,
|
||||
topicId,
|
||||
} = params;
|
||||
const workDir = cwd ?? process.cwd();
|
||||
|
||||
const args = [
|
||||
@@ -1305,7 +1341,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(prompt));
|
||||
// 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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: '' }),
|
||||
}));
|
||||
@@ -204,6 +225,10 @@ const mockHeterogeneousAgentCtr = {
|
||||
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'),
|
||||
@@ -226,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 },
|
||||
@@ -582,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', () => {
|
||||
@@ -649,6 +735,22 @@ describe('GatewayConnectionCtr', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards cwd and systemContext from the request to spawnLhHeteroExec', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('claude-code', 'op-ctx', 'hi', 'mock-jwt', {
|
||||
cwd: '/Users/alice/repo',
|
||||
systemContext: 'WORKSPACE CONTEXT',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/Users/alice/repo',
|
||||
systemContext: 'WORKSPACE CONTEXT',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends accepted ack and spawns lh hetero exec', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-xyz');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import path from 'node:path';
|
||||
|
||||
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 type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
|
||||
import { BaseMenuPlatform } from './BaseMenuPlatform';
|
||||
@@ -139,9 +142,9 @@ export class WindowsMenu 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' },
|
||||
],
|
||||
@@ -211,6 +214,25 @@ export class WindowsMenu 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { request } from 'node:http';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import ImessageBridgeService from '../imessageBridgeSrv';
|
||||
|
||||
const { MockBlueBubblesApiClient, getPortMock } = vi.hoisted(() => {
|
||||
class _MockBlueBubblesApiClient {
|
||||
static instances: _MockBlueBubblesApiClient[] = [];
|
||||
|
||||
getMessage = vi.fn().mockResolvedValue({
|
||||
chats: [{ guid: 'iMessage;-;chat-1' }],
|
||||
guid: 'msg-1',
|
||||
text: 'hello',
|
||||
});
|
||||
listWebhooks = vi.fn().mockResolvedValue([]);
|
||||
ping = vi.fn().mockResolvedValue(undefined);
|
||||
registerWebhook = vi.fn().mockResolvedValue({ events: ['new-message'], id: 1 });
|
||||
sendText = vi.fn().mockResolvedValue({ guid: 'sent-1', text: 'hello' });
|
||||
|
||||
constructor(public options: unknown) {
|
||||
_MockBlueBubblesApiClient.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockBlueBubblesApiClient: _MockBlueBubblesApiClient,
|
||||
getPortMock: vi.fn().mockResolvedValue(43_210),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@lobechat/chat-adapter-imessage', () => ({
|
||||
BlueBubblesApiClient: MockBlueBubblesApiClient,
|
||||
}));
|
||||
|
||||
vi.mock('get-port-please', () => ({
|
||||
getPort: getPortMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const config = {
|
||||
applicationId: 'home-mac-mini',
|
||||
blueBubblesPassword: 'local-password',
|
||||
blueBubblesServerUrl: 'http://127.0.0.1:1234',
|
||||
enabled: true,
|
||||
webhookSecret: 'shared-secret',
|
||||
};
|
||||
|
||||
function createService() {
|
||||
const store = new Map<string, unknown>([['imessageBridgeConfigs', []]]);
|
||||
const app = {
|
||||
storeManager: {
|
||||
get: vi.fn((key: string, fallback?: unknown) => store.get(key) ?? fallback),
|
||||
set: vi.fn((key: string, value: unknown) => store.set(key, value)),
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
const service = new ImessageBridgeService(app);
|
||||
service.setRemoteServerProvider({
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
getServerUrl: vi.fn().mockResolvedValue('https://lobehub.example.com'),
|
||||
});
|
||||
|
||||
return { app, service, store };
|
||||
}
|
||||
|
||||
function postLocal(path: string, body: unknown): Promise<{ body: string; status: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const req = request(
|
||||
{
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
hostname: '127.0.0.1',
|
||||
method: 'POST',
|
||||
path,
|
||||
port: 43_210,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
status: res.statusCode ?? 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ImessageBridgeService', () => {
|
||||
let fetchSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
MockBlueBubblesApiClient.instances = [];
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores local BlueBubbles credentials and registers a loopback webhook', async () => {
|
||||
const { service, store } = createService();
|
||||
|
||||
const saved = await service.upsertConfig(config);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
applicationId: 'home-mac-mini',
|
||||
blueBubblesPasswordSet: true,
|
||||
blueBubblesServerUrl: 'http://127.0.0.1:1234',
|
||||
enabled: true,
|
||||
});
|
||||
expect(store.get('imessageBridgeConfigs')).toEqual([config]);
|
||||
expect(MockBlueBubblesApiClient.instances.at(-1)?.registerWebhook).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:43210/webhooks/bluebubbles/home-mac-mini?secret=shared-secret',
|
||||
['new-message'],
|
||||
);
|
||||
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it('keeps the saved BlueBubbles password when updating bridge metadata', async () => {
|
||||
const { service, store } = createService();
|
||||
await service.upsertConfig(config);
|
||||
|
||||
await service.upsertConfig({
|
||||
applicationId: 'home-mac-mini',
|
||||
blueBubblesServerUrl: 'http://127.0.0.1:5678',
|
||||
enabled: true,
|
||||
webhookSecret: 'new-secret',
|
||||
});
|
||||
|
||||
expect(store.get('imessageBridgeConfigs')).toEqual([
|
||||
{
|
||||
applicationId: 'home-mac-mini',
|
||||
blueBubblesPassword: 'local-password',
|
||||
blueBubblesServerUrl: 'http://127.0.0.1:5678',
|
||||
enabled: true,
|
||||
webhookSecret: 'new-secret',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it('executes outbound iMessage sends from device-gateway message API calls', async () => {
|
||||
const { service } = createService();
|
||||
await service.upsertConfig(config);
|
||||
|
||||
const result = await service.handleGatewayMessageApi('sendText', {
|
||||
applicationId: 'home-mac-mini',
|
||||
chatGuid: 'iMessage;-;chat-1',
|
||||
message: 'hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ guid: 'sent-1', text: 'hello' });
|
||||
expect(MockBlueBubblesApiClient.instances.at(-1)?.sendText).toHaveBeenCalledWith(
|
||||
'iMessage;-;chat-1',
|
||||
'hello',
|
||||
undefined,
|
||||
);
|
||||
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it('receives BlueBubbles webhook locally and forwards the enriched event to LobeHub', async () => {
|
||||
const { service } = createService();
|
||||
await service.upsertConfig(config);
|
||||
|
||||
const response = await postLocal('/webhooks/bluebubbles/home-mac-mini?secret=shared-secret', {
|
||||
data: { guid: 'msg-1' },
|
||||
type: 'new-message',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toBe(
|
||||
'https://lobehub.example.com/api/agent/webhooks/imessage/home-mac-mini?secret=shared-secret',
|
||||
);
|
||||
expect(fetchSpy.mock.calls[0][1]).toMatchObject({
|
||||
headers: {
|
||||
'Authorization': 'Bearer access-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
const forwarded = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(forwarded.data.chats[0].guid).toBe('iMessage;-;chat-1');
|
||||
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it('stops the loopback server when the last enabled config is disabled', async () => {
|
||||
const { service } = createService();
|
||||
await service.upsertConfig(config);
|
||||
expect(service.getStatus().running).toBe(true);
|
||||
|
||||
await service.upsertConfig({ ...config, enabled: false });
|
||||
|
||||
const status = service.getStatus();
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.configs[0]).toMatchObject({ applicationId: 'home-mac-mini', enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import ZoomService, { ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN } from '../zoomSrv';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockWebContents {
|
||||
destroyed: boolean;
|
||||
getZoomLevel: () => number;
|
||||
isDestroyed: () => boolean;
|
||||
level: number;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
setZoomLevel: (level: number) => void;
|
||||
}
|
||||
|
||||
const createMockWebContents = (initialLevel = 0): MockWebContents => {
|
||||
const wc: MockWebContents = {
|
||||
destroyed: false,
|
||||
level: initialLevel,
|
||||
getZoomLevel: () => wc.level,
|
||||
isDestroyed: () => wc.destroyed,
|
||||
send: vi.fn(),
|
||||
setZoomLevel: (level: number) => {
|
||||
wc.level = level;
|
||||
},
|
||||
};
|
||||
return wc;
|
||||
};
|
||||
|
||||
describe('ZoomService', () => {
|
||||
let service: ZoomService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ZoomService({} as App);
|
||||
});
|
||||
|
||||
it('increments zoom level by 1 on action=in', () => {
|
||||
const wc = createMockWebContents(0);
|
||||
service.apply('in', wc as any);
|
||||
expect(wc.level).toBe(1);
|
||||
expect(wc.send).toHaveBeenCalledWith('zoom:changed', { factor: 1.2, level: 1 });
|
||||
});
|
||||
|
||||
it('decrements zoom level by 1 on action=out', () => {
|
||||
const wc = createMockWebContents(0);
|
||||
service.apply('out', wc as any);
|
||||
expect(wc.level).toBe(-1);
|
||||
expect(wc.send).toHaveBeenCalledWith('zoom:changed', {
|
||||
factor: Number((1.2 ** -1).toFixed(4)),
|
||||
level: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('resets to 0 on action=reset', () => {
|
||||
const wc = createMockWebContents(2);
|
||||
service.apply('reset', wc as any);
|
||||
expect(wc.level).toBe(0);
|
||||
expect(wc.send).toHaveBeenCalledWith('zoom:changed', { factor: 1, level: 0 });
|
||||
});
|
||||
|
||||
it('clamps at ZOOM_LEVEL_MAX and still broadcasts current level', () => {
|
||||
const wc = createMockWebContents(ZOOM_LEVEL_MAX);
|
||||
service.apply('in', wc as any);
|
||||
expect(wc.level).toBe(ZOOM_LEVEL_MAX);
|
||||
expect(wc.send).toHaveBeenCalledWith('zoom:changed', {
|
||||
factor: Number((1.2 ** ZOOM_LEVEL_MAX).toFixed(4)),
|
||||
level: ZOOM_LEVEL_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps at ZOOM_LEVEL_MIN and still broadcasts current level', () => {
|
||||
const wc = createMockWebContents(ZOOM_LEVEL_MIN);
|
||||
service.apply('out', wc as any);
|
||||
expect(wc.level).toBe(ZOOM_LEVEL_MIN);
|
||||
expect(wc.send).toHaveBeenCalledWith('zoom:changed', {
|
||||
factor: Number((1.2 ** ZOOM_LEVEL_MIN).toFixed(4)),
|
||||
level: ZOOM_LEVEL_MIN,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when webContents is destroyed', () => {
|
||||
const wc = createMockWebContents(0);
|
||||
wc.destroyed = true;
|
||||
service.apply('in', wc as any);
|
||||
expect(wc.level).toBe(0);
|
||||
expect(wc.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('factor matches 1.2 ** level for reset', () => {
|
||||
const wc = createMockWebContents(0);
|
||||
service.apply('reset', wc as any);
|
||||
const payload = wc.send.mock.calls[0][1];
|
||||
expect(payload.factor).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,17 @@ import os from 'node:os';
|
||||
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
MessageApiRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
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 { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
import { app, powerSaveBlocker } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
@@ -22,10 +26,23 @@ interface ToolCallHandler {
|
||||
(apiName: string, args: any): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface MessageApiHandler {
|
||||
(platform: string, apiName: string, payload: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface AgentRunHandler {
|
||||
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
|
||||
}
|
||||
|
||||
interface DeviceRegistrar {
|
||||
(info: {
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
identitySource: IdentitySource;
|
||||
platform: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionService
|
||||
*
|
||||
@@ -38,10 +55,14 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
private deviceId: string | null = null;
|
||||
private powerSaveBlockerId: number | null = null;
|
||||
|
||||
private identitySource: IdentitySource | null = null;
|
||||
|
||||
private tokenProvider: (() => Promise<string | null>) | null = null;
|
||||
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
|
||||
private toolCallHandler: ToolCallHandler | null = null;
|
||||
private messageApiHandler: MessageApiHandler | null = null;
|
||||
private agentRunHandler: AgentRunHandler | null = null;
|
||||
private deviceRegistrar: DeviceRegistrar | null = null;
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
@@ -66,12 +87,30 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.toolCallHandler = handler;
|
||||
}
|
||||
|
||||
setMessageApiHandler(handler: MessageApiHandler) {
|
||||
this.messageApiHandler = handler;
|
||||
}
|
||||
|
||||
setAgentRunHandler(handler: AgentRunHandler) {
|
||||
this.agentRunHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this device to the server's device registry. Called on every
|
||||
* connect once the userId is known (deviceId is user-scoped). Injected by the
|
||||
* controller, which owns the authed server URL + token.
|
||||
*/
|
||||
setDeviceRegistrar(registrar: DeviceRegistrar) {
|
||||
this.deviceRegistrar = registrar;
|
||||
}
|
||||
|
||||
// ─── Device ID ───
|
||||
|
||||
/**
|
||||
* Ensure a stored fallback id exists. Pre-login this doubles as the device id
|
||||
* shown by `getDeviceInfo`; once a userId is available `resolveDeviceIdentity`
|
||||
* replaces it with a stable machine-derived id.
|
||||
*/
|
||||
loadOrCreateDeviceId() {
|
||||
const stored = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
if (stored) {
|
||||
@@ -83,10 +122,40 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
logger.debug(`Device ID: ${this.deviceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the stable, user-scoped device id. Survives LobeHub reinstalls
|
||||
* because it hashes the OS machine id; falls back to the stored random UUID
|
||||
* when the machine id is unavailable. Caches the result for this session.
|
||||
*/
|
||||
resolveDeviceIdentity(userId: string): { deviceId: string; identitySource: IdentitySource } {
|
||||
const fallbackId = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
const identity = deriveDeviceId(userId, { fallbackId });
|
||||
this.deviceId = identity.deviceId;
|
||||
this.identitySource = identity.identitySource;
|
||||
return identity;
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
return this.deviceId || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection routing key — the gateway's stale-socket dedupe key, decoupled
|
||||
* from the stable `deviceId`. Reuses the persisted random UUID (historically
|
||||
* `gatewayDeviceId`, now used purely as the connectionId) so a reconnect of
|
||||
* this install replaces only its own previous socket, while a co-running
|
||||
* `lh connect` on the same machine (same deviceId, different connectionId)
|
||||
* stays connected.
|
||||
*/
|
||||
getConnectionId(): string {
|
||||
let id = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
if (!id) {
|
||||
id = randomUUID();
|
||||
this.app.storeManager.set('gatewayDeviceId', id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ─── Connection Status ───
|
||||
|
||||
getStatus(): GatewayConnectionStatus {
|
||||
@@ -161,7 +230,24 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
const userId = this.extractUserIdFromToken(token);
|
||||
logger.info(`Connecting to device gateway: ${gatewayUrl}, userId: ${userId || 'unknown'}`);
|
||||
|
||||
// Resolve the stable, user-scoped device id and register with the server
|
||||
// registry before opening the WS, so the device row exists by the time the
|
||||
// gateway reports it online.
|
||||
if (userId) {
|
||||
const identity = this.resolveDeviceIdentity(userId);
|
||||
await this.deviceRegistrar?.({
|
||||
deviceId: identity.deviceId,
|
||||
hostname: os.hostname(),
|
||||
identitySource: identity.identitySource,
|
||||
platform: process.platform,
|
||||
}).catch((err) => {
|
||||
logger.warn(`Device registration failed (non-fatal): ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
|
||||
const client = new GatewayClient({
|
||||
channel: isDev ? 'desktop-dev' : 'desktop',
|
||||
connectionId: this.getConnectionId(),
|
||||
deviceId: this.getDeviceId(),
|
||||
gatewayUrl,
|
||||
logger,
|
||||
@@ -185,6 +271,10 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.handleToolCallRequest(request, client);
|
||||
});
|
||||
|
||||
client.on('message_api_request', (request) => {
|
||||
this.handleMessageApiRequest(request, client);
|
||||
});
|
||||
|
||||
client.on('system_info_request', (request) => {
|
||||
this.handleSystemInfoRequest(client, request);
|
||||
});
|
||||
@@ -319,6 +409,50 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Message API Routing ───
|
||||
|
||||
private handleMessageApiRequest = async (
|
||||
request: MessageApiRequestMessage,
|
||||
client: GatewayClient,
|
||||
) => {
|
||||
const { requestId, api } = request;
|
||||
const { apiName, payload, platform } = api;
|
||||
|
||||
logger.info(
|
||||
`Received message API request: platform=${platform}, apiName=${apiName}, requestId=${requestId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (!this.messageApiHandler) {
|
||||
throw new Error('No message API handler configured');
|
||||
}
|
||||
|
||||
const result = await this.messageApiHandler(platform, apiName, payload);
|
||||
|
||||
client.sendMessageApiResponse({
|
||||
requestId,
|
||||
result: {
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
`Message API request failed: platform=${platform}, apiName=${apiName}, error=${errorMsg}`,
|
||||
);
|
||||
|
||||
client.sendMessageApiResponse({
|
||||
requestId,
|
||||
result: {
|
||||
content: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Power Save Blocker ───
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
|
||||
import {
|
||||
BlueBubblesApiClient,
|
||||
type BlueBubblesMessage,
|
||||
type BlueBubblesOutboundAttachment,
|
||||
type BlueBubblesSendOptions,
|
||||
type BlueBubblesWebhookEvent,
|
||||
} from '@lobechat/chat-adapter-imessage';
|
||||
import type {
|
||||
ImessageBridgeConfig,
|
||||
ImessageBridgePublicConfig,
|
||||
ImessageBridgeStatus,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { getPort } from 'get-port-please';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
const logger = createLogger('services:ImessageBridgeSrv');
|
||||
|
||||
const STORE_KEY = 'imessageBridgeConfigs';
|
||||
const LOCAL_HOST = '127.0.0.1';
|
||||
const MAX_WEBHOOK_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
interface RemoteServerProvider {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
getServerUrl: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
type StoredImessageBridgeConfig = ImessageBridgeConfig & { blueBubblesPassword: string };
|
||||
|
||||
interface ChatMessagesOptions {
|
||||
after?: number | string;
|
||||
before?: number | string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: 'ASC' | 'DESC';
|
||||
withParts?: string[];
|
||||
}
|
||||
|
||||
function toPublicConfig(config: StoredImessageBridgeConfig): ImessageBridgePublicConfig {
|
||||
const { blueBubblesPassword, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
blueBubblesPasswordSet: Boolean(blueBubblesPassword),
|
||||
};
|
||||
}
|
||||
|
||||
function assertString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export default class ImessageBridgeService extends ServiceModule {
|
||||
private httpServer: Server | null = null;
|
||||
private remoteServerProvider: RemoteServerProvider | null = null;
|
||||
private serverPort = 0;
|
||||
|
||||
setRemoteServerProvider(provider: RemoteServerProvider) {
|
||||
this.remoteServerProvider = provider;
|
||||
}
|
||||
|
||||
getConfigs(): ImessageBridgePublicConfig[] {
|
||||
return this.readConfigs().map(toPublicConfig);
|
||||
}
|
||||
|
||||
getStatus(): ImessageBridgeStatus {
|
||||
return {
|
||||
configs: this.getConfigs(),
|
||||
running: Boolean(this.httpServer),
|
||||
serverUrl: this.httpServer ? this.getLocalServerUrl() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async upsertConfig(config: ImessageBridgeConfig): Promise<ImessageBridgePublicConfig> {
|
||||
const configs = this.readConfigs();
|
||||
const index = configs.findIndex((item) => item.applicationId === config.applicationId?.trim());
|
||||
const normalized = this.normalizeConfig(config, index >= 0 ? configs[index] : undefined);
|
||||
if (index >= 0) {
|
||||
configs[index] = normalized;
|
||||
} else {
|
||||
configs.push(normalized);
|
||||
}
|
||||
|
||||
this.writeConfigs(configs);
|
||||
|
||||
if (normalized.enabled) {
|
||||
await this.ensureServer();
|
||||
await this.registerWebhook(normalized);
|
||||
} else if (configs.every((item) => !item.enabled)) {
|
||||
// Disabling the last enabled config must tear the loopback server down,
|
||||
// otherwise getStatus() keeps reporting running:true (mirrors removeConfig).
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
return toPublicConfig(normalized);
|
||||
}
|
||||
|
||||
async removeConfig(applicationId: string): Promise<{ success: boolean }> {
|
||||
const id = applicationId.trim();
|
||||
this.writeConfigs(this.readConfigs().filter((config) => config.applicationId !== id));
|
||||
if (this.readConfigs().every((config) => !config.enabled)) {
|
||||
await this.stop();
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async start(): Promise<ImessageBridgeStatus> {
|
||||
const enabled = this.readConfigs().filter((config) => config.enabled);
|
||||
if (enabled.length === 0) return this.getStatus();
|
||||
|
||||
await this.ensureServer();
|
||||
await Promise.all(enabled.map((config) => this.registerWebhook(config)));
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean }> {
|
||||
if (!this.httpServer) return { success: true };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.httpServer?.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.httpServer = null;
|
||||
this.serverPort = 0;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async testConfig(config: ImessageBridgeConfig): Promise<{ success: boolean }> {
|
||||
const existing = this.readConfigs().find(
|
||||
(item) => item.applicationId === config.applicationId?.trim(),
|
||||
);
|
||||
await this.createApiClient(this.normalizeConfig(config, existing)).ping();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async handleGatewayMessageApi(apiName: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const applicationId = assertString(args.applicationId, 'applicationId');
|
||||
const config = this.findConfig(applicationId);
|
||||
const api = this.createApiClient(config);
|
||||
|
||||
switch (apiName) {
|
||||
case 'ping': {
|
||||
await api.ping();
|
||||
return { ok: true };
|
||||
}
|
||||
case 'sendText': {
|
||||
const chatGuid = assertString(args.chatGuid, 'chatGuid');
|
||||
const message = assertString(args.message, 'message');
|
||||
return api.sendText(chatGuid, message, args.options as BlueBubblesSendOptions | undefined);
|
||||
}
|
||||
case 'sendAttachment': {
|
||||
const chatGuid = assertString(args.chatGuid, 'chatGuid');
|
||||
return api.sendAttachment(
|
||||
chatGuid,
|
||||
args.attachment as BlueBubblesOutboundAttachment,
|
||||
args.options as BlueBubblesSendOptions | undefined,
|
||||
);
|
||||
}
|
||||
case 'startTyping': {
|
||||
const chatGuid = assertString(args.chatGuid, 'chatGuid');
|
||||
await api.startTyping(chatGuid);
|
||||
return { ok: true };
|
||||
}
|
||||
case 'downloadAttachment': {
|
||||
const guid = assertString(args.guid, 'guid');
|
||||
const attachment = await api.downloadAttachment(guid);
|
||||
return {
|
||||
data: attachment.buffer.toString('base64'),
|
||||
mimeType: attachment.mimeType,
|
||||
};
|
||||
}
|
||||
case 'getChat': {
|
||||
const guid = assertString(args.guid, 'guid');
|
||||
return api.getChat(guid, args.withParts as string[] | undefined);
|
||||
}
|
||||
case 'getChatMessages': {
|
||||
const chatGuid = assertString(args.chatGuid, 'chatGuid');
|
||||
return api.getChatMessages(
|
||||
chatGuid,
|
||||
(args.options as ChatMessagesOptions | undefined) ?? {},
|
||||
);
|
||||
}
|
||||
case 'queryMessages': {
|
||||
return api.queryMessages((args.body as Record<string, unknown>) ?? {});
|
||||
}
|
||||
case 'queryChats': {
|
||||
return api.queryChats((args.body as Record<string, unknown>) ?? {});
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported iMessage bridge action: ${apiName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readConfigs(): StoredImessageBridgeConfig[] {
|
||||
return (this.app.storeManager.get(STORE_KEY, []) as StoredImessageBridgeConfig[]) ?? [];
|
||||
}
|
||||
|
||||
private writeConfigs(configs: StoredImessageBridgeConfig[]) {
|
||||
this.app.storeManager.set(STORE_KEY, configs);
|
||||
}
|
||||
|
||||
private normalizeConfig(
|
||||
config: ImessageBridgeConfig,
|
||||
existing?: StoredImessageBridgeConfig,
|
||||
): StoredImessageBridgeConfig {
|
||||
const blueBubblesPassword =
|
||||
config.blueBubblesPassword?.trim() || existing?.blueBubblesPassword?.trim();
|
||||
if (!blueBubblesPassword) throw new Error('blueBubblesPassword is required');
|
||||
|
||||
return {
|
||||
applicationId: assertString(config.applicationId, 'applicationId'),
|
||||
blueBubblesPassword,
|
||||
blueBubblesServerUrl: assertString(config.blueBubblesServerUrl, 'blueBubblesServerUrl'),
|
||||
enabled: config.enabled,
|
||||
webhookSecret: assertString(config.webhookSecret, 'webhookSecret'),
|
||||
};
|
||||
}
|
||||
|
||||
private findConfig(applicationId: string): StoredImessageBridgeConfig {
|
||||
const config = this.readConfigs().find((item) => item.applicationId === applicationId);
|
||||
if (!config) throw new Error(`iMessage bridge config not found: ${applicationId}`);
|
||||
if (!config.enabled) throw new Error(`iMessage bridge config is disabled: ${applicationId}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
private createApiClient(config: StoredImessageBridgeConfig): BlueBubblesApiClient {
|
||||
return new BlueBubblesApiClient({
|
||||
password: config.blueBubblesPassword,
|
||||
serverUrl: config.blueBubblesServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureServer(): Promise<void> {
|
||||
if (this.httpServer) return;
|
||||
|
||||
this.serverPort = await getPort({
|
||||
host: LOCAL_HOST,
|
||||
port: 33_270,
|
||||
ports: [33_271, 33_272, 33_273, 33_274, 33_275],
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
await this.handleHttpRequest(req, res);
|
||||
} catch (error) {
|
||||
logger.error('Unhandled iMessage bridge request error:', error);
|
||||
writeText(res, 500, 'Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(this.serverPort, LOCAL_HOST, () => {
|
||||
this.httpServer = server;
|
||||
logger.info(`iMessage local bridge started on ${this.getLocalServerUrl()}`);
|
||||
resolve();
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async registerWebhook(config: StoredImessageBridgeConfig): Promise<void> {
|
||||
const webhookUrl = this.getLocalWebhookUrl(config);
|
||||
const api = this.createApiClient(config);
|
||||
const existing = await api.listWebhooks();
|
||||
if (existing.some((webhook) => webhook.url === webhookUrl)) {
|
||||
return;
|
||||
}
|
||||
await api.registerWebhook(webhookUrl, ['new-message']);
|
||||
logger.info('Registered BlueBubbles local webhook for iMessage appId=%s', config.applicationId);
|
||||
}
|
||||
|
||||
private getLocalServerUrl(): string {
|
||||
return `http://${LOCAL_HOST}:${this.serverPort}`;
|
||||
}
|
||||
|
||||
private getLocalWebhookUrl(config: ImessageBridgeConfig): string {
|
||||
const url = new URL(
|
||||
`/webhooks/bluebubbles/${encodeURIComponent(config.applicationId)}`,
|
||||
this.getLocalServerUrl(),
|
||||
);
|
||||
url.searchParams.set('secret', config.webhookSecret);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (req.method === 'OPTIONS') {
|
||||
writeText(res, 204, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
writeText(res, 405, 'Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? '/', this.getLocalServerUrl());
|
||||
const match = url.pathname.match(/^\/webhooks\/bluebubbles\/([^/]+)$/);
|
||||
if (!match) {
|
||||
writeText(res, 404, 'Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
const applicationId = decodeURIComponent(match[1]);
|
||||
const config = this.findConfig(applicationId);
|
||||
if (url.searchParams.get('secret') !== config.webhookSecret) {
|
||||
writeText(res, 401, 'Invalid secret');
|
||||
return;
|
||||
}
|
||||
|
||||
const event = (await readJson(req)) as BlueBubblesWebhookEvent;
|
||||
const enriched = await this.enrichWebhookEvent(config, event);
|
||||
await this.forwardWebhook(config, enriched);
|
||||
writeJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
private async enrichWebhookEvent(
|
||||
config: StoredImessageBridgeConfig,
|
||||
event: BlueBubblesWebhookEvent,
|
||||
): Promise<BlueBubblesWebhookEvent> {
|
||||
const message = event.data;
|
||||
if (event.type !== 'new-message' || !message?.guid) return event;
|
||||
|
||||
try {
|
||||
const enriched = await this.createApiClient(config).getMessage(message.guid, [
|
||||
'chats',
|
||||
'attachments',
|
||||
]);
|
||||
return { ...event, data: { ...message, ...enriched } as BlueBubblesMessage };
|
||||
} catch (error) {
|
||||
logger.warn('Failed to enrich iMessage webhook message=%s: %O', message.guid, error);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
private async forwardWebhook(
|
||||
config: ImessageBridgeConfig,
|
||||
event: BlueBubblesWebhookEvent,
|
||||
): Promise<void> {
|
||||
if (!this.remoteServerProvider) {
|
||||
throw new Error('Remote server provider is not configured');
|
||||
}
|
||||
|
||||
const [serverUrl, accessToken] = await Promise.all([
|
||||
this.remoteServerProvider.getServerUrl(),
|
||||
this.remoteServerProvider.getAccessToken(),
|
||||
]);
|
||||
if (!serverUrl) throw new Error('Remote server URL is not configured');
|
||||
|
||||
const target = new URL(
|
||||
`/api/agent/webhooks/imessage/${encodeURIComponent(config.applicationId)}`,
|
||||
serverUrl.endsWith('/') ? serverUrl : `${serverUrl}/`,
|
||||
);
|
||||
target.searchParams.set('secret', config.webhookSecret);
|
||||
|
||||
const response = await fetch(target, {
|
||||
body: JSON.stringify(event),
|
||||
headers: {
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = await response.text();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read LobeHub webhook error response:', error);
|
||||
}
|
||||
throw new Error(detail || `LobeHub webhook failed with HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (size > MAX_WEBHOOK_BYTES) throw new Error('Webhook payload is too large');
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function writeText(res: ServerResponse, status: number, body: string) {
|
||||
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
||||
res.end(body);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { WebContents } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
export const ZOOM_LEVEL_MIN = -3;
|
||||
export const ZOOM_LEVEL_MAX = 3;
|
||||
|
||||
export type ZoomAction = 'in' | 'out' | 'reset';
|
||||
|
||||
const logger = createLogger('services:ZoomService');
|
||||
|
||||
export default class ZoomService extends ServiceModule {
|
||||
apply(action: ZoomAction, webContents: WebContents): void {
|
||||
if (!webContents || webContents.isDestroyed()) return;
|
||||
|
||||
const current = webContents.getZoomLevel();
|
||||
const next =
|
||||
action === 'reset'
|
||||
? 0
|
||||
: Math.min(ZOOM_LEVEL_MAX, Math.max(ZOOM_LEVEL_MIN, current + (action === 'in' ? 1 : -1)));
|
||||
|
||||
if (next !== current) {
|
||||
webContents.setZoomLevel(next);
|
||||
logger.debug(`Zoom ${action}: level ${current} -> ${next}`);
|
||||
}
|
||||
|
||||
this.broadcast(webContents, next);
|
||||
}
|
||||
|
||||
private broadcast(webContents: WebContents, level: number): void {
|
||||
const factor = Number((1.2 ** level).toFixed(4));
|
||||
try {
|
||||
webContents.send('zoom:changed', { factor, level });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to broadcast zoom:changed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DataSyncConfig,
|
||||
ImessageBridgeConfig,
|
||||
NetworkProxySettings,
|
||||
UpdateChannel,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
@@ -18,6 +19,13 @@ export interface ElectronMainStore {
|
||||
gatewayDeviceName: string;
|
||||
gatewayEnabled: boolean;
|
||||
gatewayUrl: string;
|
||||
/**
|
||||
* Developer toggle: when true, hetero-agent (CC / Codex) CLI raw streams are
|
||||
* traced to disk even in packaged production builds. Dev builds always trace
|
||||
* regardless of this flag. Exposed via the Help menu checkbox.
|
||||
*/
|
||||
heteroTracingEnabled: boolean;
|
||||
imessageBridgeConfigs: ImessageBridgeConfig[];
|
||||
locale: string;
|
||||
localFileWorkspaceRoots: string[];
|
||||
networkProxy: NetworkProxySettings;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@lobechat/device-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.12.5",
|
||||
"jose": "^6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.12.19",
|
||||
"@cloudflare/workers-types": "^4.20260301.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "~3.2.4",
|
||||
"wrangler": "^4.70.0"
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Extract RS256 public key from JWKS_KEY environment variable.
|
||||
* Output is the JSON string to use with `wrangler secret put JWKS_PUBLIC_KEY`.
|
||||
*
|
||||
* Usage:
|
||||
* JWKS_KEY='{"keys":[...]}' node scripts/extract-public-key.mjs
|
||||
* # or load from .env
|
||||
* node --env-file=../../.env scripts/extract-public-key.mjs
|
||||
*/
|
||||
|
||||
const jwksString = process.env.JWKS_KEY;
|
||||
|
||||
if (!jwksString) {
|
||||
console.error('Error: JWKS_KEY environment variable is not set.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jwks = JSON.parse(jwksString);
|
||||
const privateKey = jwks.keys?.find((k) => k.alg === 'RS256' && k.kty === 'RSA');
|
||||
|
||||
if (!privateKey) {
|
||||
console.error('Error: No RS256 RSA key found in JWKS_KEY.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const publicJwks = {
|
||||
keys: [
|
||||
{
|
||||
alg: privateKey.alg,
|
||||
e: privateKey.e,
|
||||
kid: privateKey.kid,
|
||||
kty: privateKey.kty,
|
||||
n: privateKey.n,
|
||||
use: privateKey.use,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Remove undefined fields
|
||||
for (const key of publicJwks.keys) {
|
||||
for (const [k, v] of Object.entries(key)) {
|
||||
if (v === undefined) delete key[k];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(publicJwks));
|
||||
@@ -1,406 +0,0 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { resolveSocketAuth, verifyApiKeyToken, verifyDesktopToken } from './auth';
|
||||
import type { AgentRunRequestMessage, DeviceAttachment, Env } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
|
||||
const HEARTBEAT_TIMEOUT = 90_000; // 90s without heartbeat → close
|
||||
const HEARTBEAT_CHECK_INTERVAL = 90_000; // check every 90s
|
||||
|
||||
export class DeviceGatewayDO extends DurableObject<Env> {
|
||||
private pendingRequests = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (result: any) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
|
||||
private router = new Hono()
|
||||
.all('/api/device/status', async () => {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
return Response.json({
|
||||
deviceCount: sockets.length,
|
||||
online: sockets.length > 0,
|
||||
});
|
||||
})
|
||||
.post('/api/device/tool-call', async (c) => {
|
||||
return this.handleToolCall(c.req.raw);
|
||||
})
|
||||
.post('/api/device/system-info', async (c) => {
|
||||
return this.handleSystemInfo(c.req.raw);
|
||||
})
|
||||
.post('/api/device/agent/run', async (c) => {
|
||||
return this.handleAgentRun(c.req.raw);
|
||||
})
|
||||
.all('/api/device/devices', async () => {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
const devices = sockets.map((ws) => ws.deserializeAttachment() as DeviceAttachment);
|
||||
return Response.json({ devices });
|
||||
});
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
// ─── WebSocket upgrade (from Desktop) ───
|
||||
if (request.headers.get('Upgrade') === 'websocket') {
|
||||
return this.handleWebSocketUpgrade(request);
|
||||
}
|
||||
|
||||
// ─── HTTP API routes ───
|
||||
return this.router.fetch(request);
|
||||
}
|
||||
|
||||
// ─── Hibernation Handlers ───
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
|
||||
const data = JSON.parse(message as string);
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
|
||||
// ─── Auth message handling ───
|
||||
if (data.type === 'auth') {
|
||||
if (att.authenticated) return; // Already authenticated, ignore
|
||||
|
||||
try {
|
||||
const token = data.token as string | undefined;
|
||||
const tokenType = data.tokenType as 'apiKey' | 'jwt' | 'serviceToken' | undefined;
|
||||
const serverUrl = data.serverUrl as string | undefined;
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
|
||||
const verifiedUserId = await resolveSocketAuth({
|
||||
serverUrl,
|
||||
serviceToken: this.env.SERVICE_TOKEN,
|
||||
storedUserId,
|
||||
token,
|
||||
tokenType,
|
||||
verifyApiKey: verifyApiKeyToken,
|
||||
verifyJwt: async (jwt) => {
|
||||
const result = await verifyDesktopToken(this.env, jwt);
|
||||
return { userId: result.userId };
|
||||
},
|
||||
});
|
||||
|
||||
// Verify userId matches the DO routing
|
||||
if (storedUserId && verifiedUserId !== storedUserId) {
|
||||
throw new Error('userId mismatch');
|
||||
}
|
||||
|
||||
// Mark as authenticated
|
||||
att.authenticated = true;
|
||||
att.authDeadline = undefined;
|
||||
ws.serializeAttachment(att);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'auth_success' }));
|
||||
|
||||
// Schedule heartbeat check for authenticated connections
|
||||
await this.scheduleHeartbeatCheck();
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'Authentication failed';
|
||||
ws.send(JSON.stringify({ reason, type: 'auth_failed' }));
|
||||
ws.close(1008, reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Reject unauthenticated messages ───
|
||||
if (!att.authenticated) return;
|
||||
|
||||
// ─── Business messages (authenticated only) ───
|
||||
if (
|
||||
data.type === 'tool_call_response' ||
|
||||
data.type === 'system_info_response' ||
|
||||
data.type === 'agent_run_ack'
|
||||
) {
|
||||
const pending = this.pendingRequests.get(data.requestId ?? data.operationId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(data.type === 'agent_run_ack' ? data : data.result);
|
||||
this.pendingRequests.delete(data.requestId ?? data.operationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'heartbeat') {
|
||||
att.lastHeartbeat = Date.now();
|
||||
ws.serializeAttachment(att);
|
||||
ws.send(JSON.stringify({ type: 'heartbeat_ack' }));
|
||||
}
|
||||
}
|
||||
|
||||
async webSocketClose(_ws: WebSocket, _code: number) {
|
||||
// Hibernation API handles connection cleanup automatically
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket, _error: unknown) {
|
||||
ws.close(1011, 'Internal error');
|
||||
}
|
||||
|
||||
// ─── Heartbeat Timeout ───
|
||||
|
||||
async alarm() {
|
||||
const now = Date.now();
|
||||
const closedSockets = new Set<WebSocket>();
|
||||
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
|
||||
// Auth timeout: close unauthenticated connections past deadline
|
||||
if (!att.authenticated && att.authDeadline && now > att.authDeadline) {
|
||||
ws.send(JSON.stringify({ reason: 'Authentication timeout', type: 'auth_failed' }));
|
||||
ws.close(1008, 'Authentication timeout');
|
||||
closedSockets.add(ws);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heartbeat timeout: only for authenticated connections
|
||||
if (att.authenticated && now - att.lastHeartbeat > HEARTBEAT_TIMEOUT) {
|
||||
ws.close(1000, 'Heartbeat timeout');
|
||||
closedSockets.add(ws);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep alarm running while there are active connections
|
||||
const remaining = this.ctx.getWebSockets().filter((ws) => !closedSockets.has(ws));
|
||||
if (remaining.length > 0) {
|
||||
await this.scheduleHeartbeatCheck();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WebSocket Upgrade ───
|
||||
|
||||
private async handleWebSocketUpgrade(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const userId = request.headers.get('X-User-Id');
|
||||
|
||||
const deviceId = url.searchParams.get('deviceId') || 'unknown';
|
||||
const hostname = url.searchParams.get('hostname') || '';
|
||||
const platform = url.searchParams.get('platform') || '';
|
||||
|
||||
// Close stale connection from the same device
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
if (att.deviceId === deviceId) {
|
||||
ws.close(1000, 'Replaced by new connection');
|
||||
}
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = Object.values(pair);
|
||||
|
||||
this.ctx.acceptWebSocket(server);
|
||||
|
||||
const now = Date.now();
|
||||
server.serializeAttachment({
|
||||
authDeadline: now + AUTH_TIMEOUT,
|
||||
authenticated: false,
|
||||
connectedAt: now,
|
||||
deviceId,
|
||||
hostname,
|
||||
lastHeartbeat: now,
|
||||
platform,
|
||||
} satisfies DeviceAttachment);
|
||||
|
||||
if (userId) {
|
||||
await this.ctx.storage.put('_userId', userId);
|
||||
}
|
||||
|
||||
// Schedule auth timeout check (10s)
|
||||
await this.scheduleAuthTimeout();
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
private async scheduleAuthTimeout() {
|
||||
const currentAlarm = await this.ctx.storage.getAlarm();
|
||||
if (!currentAlarm) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + AUTH_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
private async scheduleHeartbeatCheck() {
|
||||
const currentAlarm = await this.ctx.storage.getAlarm();
|
||||
if (!currentAlarm) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_CHECK_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
private getAuthenticatedSockets(): WebSocket[] {
|
||||
return this.ctx.getWebSockets().filter((ws) => {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
return att.authenticated;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── System Info RPC ───
|
||||
|
||||
private async handleSystemInfo(request: Request): Promise<Response> {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
if (sockets.length === 0) {
|
||||
return Response.json({ error: 'DEVICE_OFFLINE', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
const { deviceId, timeout = 10_000 } = (await request.json()) as {
|
||||
deviceId?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const targetWs = deviceId
|
||||
? sockets.find((ws) => {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
return att.deviceId === deviceId;
|
||||
})
|
||||
: sockets[0];
|
||||
|
||||
if (!targetWs) {
|
||||
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error('TIMEOUT'));
|
||||
}, timeout);
|
||||
|
||||
this.pendingRequests.set(requestId, { resolve, timer });
|
||||
|
||||
targetWs.send(
|
||||
JSON.stringify({
|
||||
requestId,
|
||||
type: 'system_info_request',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return Response.json({ success: true, ...(result as object) });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
error: (err as Error).message,
|
||||
success: false,
|
||||
},
|
||||
{ status: 504 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent Run RPC ───
|
||||
|
||||
private async handleAgentRun(request: Request): Promise<Response> {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
if (sockets.length === 0) {
|
||||
return Response.json({ error: 'DEVICE_OFFLINE', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
cwd?: string;
|
||||
deviceId?: string;
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
timeout?: number;
|
||||
topicId: string;
|
||||
};
|
||||
const { deviceId, timeout = 10_000, ...runParams } = body;
|
||||
|
||||
const targetWs = deviceId
|
||||
? sockets.find((ws) => {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
return att.deviceId === deviceId;
|
||||
})
|
||||
: sockets[0];
|
||||
|
||||
if (!targetWs) {
|
||||
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const ack = await new Promise<{ status: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(runParams.operationId);
|
||||
reject(new Error('TIMEOUT'));
|
||||
}, timeout);
|
||||
|
||||
this.pendingRequests.set(runParams.operationId, { resolve, timer });
|
||||
|
||||
const msg: AgentRunRequestMessage = { type: 'agent_run_request', ...runParams };
|
||||
targetWs.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
if (ack.status === 'rejected') {
|
||||
return Response.json({ error: 'DEVICE_REJECTED', success: false }, { status: 422 });
|
||||
}
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
return Response.json({ error: (err as Error).message, success: false }, { status: 504 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Call RPC ───
|
||||
|
||||
private async handleToolCall(request: Request): Promise<Response> {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
if (sockets.length === 0) {
|
||||
return Response.json(
|
||||
{ content: 'Desktop device offline', error: 'DEVICE_OFFLINE', success: false },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
deviceId,
|
||||
timeout = 30_000,
|
||||
toolCall,
|
||||
} = (await request.json()) as {
|
||||
deviceId?: string;
|
||||
timeout?: number;
|
||||
toolCall: unknown;
|
||||
};
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
// Select target device (specified > first available)
|
||||
const targetWs = deviceId
|
||||
? sockets.find((ws) => {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
return att.deviceId === deviceId;
|
||||
})
|
||||
: sockets[0];
|
||||
|
||||
if (!targetWs) {
|
||||
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error('TIMEOUT'));
|
||||
}, timeout);
|
||||
|
||||
this.pendingRequests.set(requestId, { resolve, timer });
|
||||
|
||||
targetWs.send(
|
||||
JSON.stringify({
|
||||
requestId,
|
||||
toolCall,
|
||||
type: 'tool_call_request',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return Response.json({ success: true, ...(result as object) });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
content: `Tool call timed out (${timeout / 1000}s)`,
|
||||
error: (err as Error).message,
|
||||
success: false,
|
||||
},
|
||||
{ status: 504 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveSocketAuth } from './auth';
|
||||
|
||||
describe('resolveSocketAuth', () => {
|
||||
it('rejects missing token', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('Missing token');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the real service token when storedUserId is missing', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
token: 'service-secret',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('Missing userId');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
it('rejects clients that only self-declare serviceToken mode', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn().mockRejectedValue(new Error('invalid jwt'));
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'attacker-token',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('invalid jwt');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).toHaveBeenCalledWith('attacker-token');
|
||||
});
|
||||
|
||||
it('treats a forged serviceToken claim with a valid JWT as JWT auth', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn().mockResolvedValue({ userId: 'user-123' });
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'valid-jwt',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).resolves.toBe('user-123');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).toHaveBeenCalledWith('valid-jwt');
|
||||
});
|
||||
|
||||
it('accepts the real service token', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'service-secret',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).resolves.toBe('user-123');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { importJWK, jwtVerify } from 'jose';
|
||||
|
||||
import type { Env } from './types';
|
||||
|
||||
let cachedKey: CryptoKey | null = null;
|
||||
|
||||
interface CurrentUserResponse {
|
||||
data?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
};
|
||||
error?: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveSocketAuthOptions {
|
||||
serverUrl?: string;
|
||||
serviceToken: string;
|
||||
storedUserId?: string;
|
||||
token?: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
verifyApiKey: (serverUrl: string, token: string) => Promise<{ userId: string }>;
|
||||
verifyJwt: (token: string) => Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
async function getPublicKey(env: Env): Promise<CryptoKey> {
|
||||
if (cachedKey) return cachedKey;
|
||||
|
||||
const jwks = JSON.parse(env.JWKS_PUBLIC_KEY);
|
||||
const rsaKey = jwks.keys.find((k: any) => k.alg === 'RS256');
|
||||
|
||||
if (!rsaKey) {
|
||||
throw new Error('No RS256 key found in JWKS_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
cachedKey = (await importJWK(rsaKey, 'RS256')) as CryptoKey;
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
export async function verifyDesktopToken(
|
||||
env: Env,
|
||||
token: string,
|
||||
): Promise<{ clientId: string; userId: string }> {
|
||||
const publicKey = await getPublicKey(env);
|
||||
const { payload } = await jwtVerify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
|
||||
if (!payload.sub) throw new Error('Missing sub claim');
|
||||
|
||||
return {
|
||||
clientId: payload.client_id as string,
|
||||
userId: payload.sub,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyApiKeyToken(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
): Promise<{ userId: string }> {
|
||||
const normalizedServerUrl = new URL(serverUrl).toString().replace(/\/$/, '');
|
||||
|
||||
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
let body: CurrentUserResponse | undefined;
|
||||
try {
|
||||
body = (await response.json()) as CurrentUserResponse;
|
||||
} catch {
|
||||
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
|
||||
}
|
||||
|
||||
if (!response.ok || body?.success === false) {
|
||||
throw new Error(
|
||||
body?.error || body?.message || `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = body?.data?.id || body?.data?.userId;
|
||||
if (!userId) {
|
||||
throw new Error('Current user response did not include a user id.');
|
||||
}
|
||||
|
||||
return { userId };
|
||||
}
|
||||
|
||||
export async function resolveSocketAuth(options: ResolveSocketAuthOptions): Promise<string> {
|
||||
const { serverUrl, serviceToken, storedUserId, token, tokenType, verifyApiKey, verifyJwt } =
|
||||
options;
|
||||
|
||||
if (!token) throw new Error('Missing token');
|
||||
|
||||
if (tokenType === 'apiKey') {
|
||||
if (!serverUrl) throw new Error('Missing serverUrl');
|
||||
const result = await verifyApiKey(serverUrl, token);
|
||||
return result.userId;
|
||||
}
|
||||
|
||||
if (token === serviceToken) {
|
||||
if (!storedUserId) throw new Error('Missing userId');
|
||||
return storedUserId;
|
||||
}
|
||||
|
||||
const result = await verifyJwt(token);
|
||||
return result.userId;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { DeviceGatewayDO } from './DeviceGatewayDO';
|
||||
import type { Env } from './types';
|
||||
|
||||
export { DeviceGatewayDO };
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// ─── Health check ───
|
||||
app.get('/health', (c) => c.text('OK'));
|
||||
|
||||
// ─── Auth middleware for service APIs ───
|
||||
const serviceAuth = (): ((c: any, next: () => Promise<void>) => Promise<Response | void>) => {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (authHeader !== `Bearer ${c.env.SERVICE_TOKEN}`) {
|
||||
return c.text('Unauthorized', 401);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Desktop WebSocket connection ───
|
||||
app.get('/ws', async (c) => {
|
||||
const userId = c.req.query('userId');
|
||||
if (!userId) return c.text('Missing userId', 400);
|
||||
|
||||
const id = c.env.DEVICE_GATEWAY.idFromName(`user:${userId}`);
|
||||
const stub = c.env.DEVICE_GATEWAY.get(id);
|
||||
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.set('X-User-Id', userId);
|
||||
return stub.fetch(new Request(c.req.raw, { headers }));
|
||||
});
|
||||
|
||||
// ─── Vercel Agent HTTP API ───
|
||||
app.all('/api/device/*', serviceAuth(), async (c) => {
|
||||
const body = (await c.req.raw.clone().json()) as { userId: string };
|
||||
if (!body.userId) return c.text('Missing userId', 400);
|
||||
|
||||
const id = c.env.DEVICE_GATEWAY.idFromName(`user:${body.userId}`);
|
||||
const stub = c.env.DEVICE_GATEWAY.get(id);
|
||||
return stub.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,136 +0,0 @@
|
||||
export interface Env {
|
||||
DEVICE_GATEWAY: DurableObjectNamespace;
|
||||
JWKS_PUBLIC_KEY: string;
|
||||
SERVICE_TOKEN: string;
|
||||
}
|
||||
|
||||
// ─── Device Info ───
|
||||
|
||||
export interface DeviceAttachment {
|
||||
authDeadline?: number;
|
||||
authenticated: boolean;
|
||||
connectedAt: number;
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
lastHeartbeat: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
// ─── WebSocket Protocol Messages ───
|
||||
|
||||
// Desktop → CF
|
||||
export interface AuthMessage {
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
type: 'auth';
|
||||
}
|
||||
|
||||
export interface HeartbeatMessage {
|
||||
type: 'heartbeat';
|
||||
}
|
||||
|
||||
export interface ToolCallResponseMessage {
|
||||
requestId: string;
|
||||
result: {
|
||||
content: string;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
};
|
||||
type: 'tool_call_response';
|
||||
}
|
||||
|
||||
export interface SystemInfoResponseMessage {
|
||||
requestId: string;
|
||||
result: DeviceSystemInfo;
|
||||
type: 'system_info_response';
|
||||
}
|
||||
|
||||
export interface DeviceSystemInfo {
|
||||
arch: string;
|
||||
desktopPath: string;
|
||||
documentsPath: string;
|
||||
downloadsPath: string;
|
||||
homePath: string;
|
||||
musicPath: string;
|
||||
picturesPath: string;
|
||||
userDataPath: string;
|
||||
videosPath: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
// CF → Desktop
|
||||
export interface AuthSuccessMessage {
|
||||
type: 'auth_success';
|
||||
}
|
||||
|
||||
export interface AuthFailedMessage {
|
||||
reason: string;
|
||||
type: 'auth_failed';
|
||||
}
|
||||
|
||||
export interface HeartbeatAckMessage {
|
||||
type: 'heartbeat_ack';
|
||||
}
|
||||
|
||||
export interface AuthExpiredMessage {
|
||||
type: 'auth_expired';
|
||||
}
|
||||
|
||||
export interface ToolCallRequestMessage {
|
||||
requestId: string;
|
||||
toolCall: {
|
||||
apiName: string;
|
||||
arguments: string;
|
||||
identifier: string;
|
||||
};
|
||||
type: 'tool_call_request';
|
||||
}
|
||||
|
||||
export interface SystemInfoRequestMessage {
|
||||
requestId: string;
|
||||
type: 'system_info_request';
|
||||
}
|
||||
|
||||
/**
|
||||
* CF → Desktop: request the desktop to spawn `lh hetero exec` for a
|
||||
* heterogeneous agent run. The JWT is operation-scoped (4h TTL) and only
|
||||
* grants `heteroIngest` / `heteroFinish` for this operationId.
|
||||
*/
|
||||
export interface AgentRunRequestMessage {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
/** Working directory to pass to `lh hetero exec --cwd`. */
|
||||
cwd?: string;
|
||||
/** Operation-scoped JWT signed by the server — inject as LOBEHUB_JWT env. */
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
/** Plain-text prompt to pass via `lh hetero exec --prompt`. */
|
||||
prompt: string;
|
||||
/** Native CLI session id for `lh hetero exec --resume`. */
|
||||
resumeSessionId?: string;
|
||||
topicId: string;
|
||||
type: 'agent_run_request';
|
||||
}
|
||||
|
||||
/** Desktop → CF: acknowledgement for an `agent_run_request`. */
|
||||
export interface AgentRunAckMessage {
|
||||
operationId: string;
|
||||
reason?: string;
|
||||
status: 'accepted' | 'rejected';
|
||||
type: 'agent_run_ack';
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| AgentRunAckMessage
|
||||
| AuthMessage
|
||||
| HeartbeatMessage
|
||||
| SystemInfoResponseMessage
|
||||
| ToolCallResponseMessage;
|
||||
export type ServerMessage =
|
||||
| AgentRunRequestMessage
|
||||
| AuthExpiredMessage
|
||||
| AuthFailedMessage
|
||||
| AuthSuccessMessage
|
||||
| HeartbeatAckMessage
|
||||
| SystemInfoRequestMessage
|
||||
| ToolCallRequestMessage;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
name = "device-gateway"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-01-01"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "DEVICE_GATEWAY", class_name = "DeviceGatewayDO" }
|
||||
]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["DeviceGatewayDO"]
|
||||
|
||||
# Secrets (injected via `wrangler secret put`):
|
||||
# - JWKS_PUBLIC_KEY: RS256 public key JSON (extracted from JWKS_KEY)
|
||||
# - SERVICE_TOKEN: Vercel → CF service-to-service auth secret
|
||||
@@ -598,6 +598,81 @@ table chat_groups_agents {
|
||||
}
|
||||
}
|
||||
|
||||
table user_connector_tools {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_connector_id uuid [not null]
|
||||
user_id text [not null]
|
||||
tool_name varchar(255) [not null]
|
||||
display_name varchar(255)
|
||||
description text
|
||||
input_schema jsonb
|
||||
output_schema jsonb
|
||||
crud_type text [not null]
|
||||
render_config jsonb
|
||||
permission text [not null]
|
||||
is_work_artifact boolean [not null, default: false]
|
||||
work_artifact_config jsonb
|
||||
limit_config jsonb
|
||||
metadata jsonb
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(user_connector_id, tool_name) [name: 'user_connector_tools_connector_tool_unique', unique]
|
||||
user_id [name: 'user_connector_tools_user_id_idx']
|
||||
user_connector_id [name: 'user_connector_tools_connector_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table user_connectors {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
identifier varchar(255) [not null]
|
||||
name varchar(255) [not null]
|
||||
source_type text [not null]
|
||||
mcp_server_url text
|
||||
mcp_connection_type text
|
||||
mcp_stdio_config jsonb
|
||||
status text [not null]
|
||||
is_enabled boolean [not null, default: true]
|
||||
oidc_config jsonb
|
||||
credentials text
|
||||
token_expires_at "timestamp with time zone"
|
||||
metadata jsonb
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(user_id, identifier) [name: 'user_connectors_user_identifier_unique', unique]
|
||||
user_id [name: 'user_connectors_user_id_idx']
|
||||
token_expires_at [name: 'user_connectors_token_expires_at_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table devices {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
device_id varchar(64) [not null]
|
||||
identity_source varchar(20) [not null]
|
||||
hostname text
|
||||
platform varchar(20)
|
||||
friendly_name text
|
||||
default_cwd text
|
||||
recent_cwds text[] [not null, default: `[]`]
|
||||
first_seen_at "timestamp with time zone" [not null, default: `now()`]
|
||||
last_seen_at "timestamp with time zone" [not null, default: `now()`]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(user_id, device_id) [name: 'devices_user_id_device_id_unique', unique]
|
||||
user_id [name: 'devices_user_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table document_histories {
|
||||
id varchar(255) [pk, not null]
|
||||
document_id varchar(255) [not null]
|
||||
@@ -613,6 +688,23 @@ table document_histories {
|
||||
}
|
||||
}
|
||||
|
||||
table document_shares {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
document_id varchar(255) [not null]
|
||||
user_id text [not null]
|
||||
visibility text [not null, default: 'private']
|
||||
permission text [not null, default: 'read']
|
||||
page_view_count integer [not null, default: 0]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
document_id [name: 'document_shares_document_id_unique', unique]
|
||||
user_id [name: 'document_shares_user_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table documents {
|
||||
id varchar(255) [pk, not null]
|
||||
title text
|
||||
@@ -1297,6 +1389,24 @@ table oidc_sessions {
|
||||
}
|
||||
}
|
||||
|
||||
table push_tokens {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
expo_token text [not null]
|
||||
device_id text [not null]
|
||||
platform text [not null]
|
||||
app_version text
|
||||
locale text
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
last_seen_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(user_id, device_id) [name: 'idx_push_tokens_user_device', unique]
|
||||
user_id [name: 'idx_push_tokens_user']
|
||||
last_seen_at [name: 'idx_push_tokens_last_seen']
|
||||
}
|
||||
}
|
||||
|
||||
table chunks {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
text text
|
||||
@@ -1724,6 +1834,7 @@ table tasks {
|
||||
name text
|
||||
description varchar(255)
|
||||
instruction text [not null]
|
||||
editor_data jsonb
|
||||
status text [not null, default: 'backlog']
|
||||
priority integer [default: 0]
|
||||
sort_order integer [default: 0]
|
||||
@@ -1839,6 +1950,14 @@ table topics {
|
||||
mode text
|
||||
status text
|
||||
completed_at "timestamp with time zone"
|
||||
total_cost "numeric(20, 6)"
|
||||
total_input_tokens integer
|
||||
total_output_tokens integer
|
||||
total_tokens integer
|
||||
cost jsonb
|
||||
usage jsonb
|
||||
model text
|
||||
provider text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -1852,6 +1971,8 @@ table topics {
|
||||
agent_id [name: 'topics_agent_id_idx']
|
||||
trigger [name: 'topics_trigger_idx']
|
||||
status [name: 'topics_status_idx']
|
||||
model [name: 'topics_model_idx']
|
||||
provider [name: 'topics_provider_idx']
|
||||
(user_id, completed_at) [name: 'topics_user_id_completed_at_idx']
|
||||
() [name: 'topics_extract_status_gin_idx']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Connect LobeHub to iMessage
|
||||
description: >-
|
||||
Learn how to connect iMessage to your LobeHub agent through the local LobeHub Desktop BlueBubbles bridge.
|
||||
|
||||
tags:
|
||||
- iMessage
|
||||
- BlueBubbles
|
||||
- Message Channels
|
||||
- Bot Setup
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Connect LobeHub to iMessage
|
||||
|
||||
LobeHub connects to iMessage through [BlueBubbles](https://bluebubbles.app/) running on a Mac signed into Messages. The LobeHub Desktop app runs a local bridge on that same Mac: it receives BlueBubbles webhooks on `127.0.0.1`, forwards them to LobeHub Cloud, and relays the agent's replies back to the local BlueBubbles REST API. BlueBubbles never needs to be exposed to the public internet.
|
||||
|
||||
```text
|
||||
iMessage user -> macOS Messages -> BlueBubbles -> LobeHub Desktop bridge -> LobeHub Cloud
|
||||
LobeHub agent -> Device Gateway -> LobeHub Desktop bridge -> BlueBubbles -> iMessage user
|
||||
```
|
||||
|
||||
> **Labs feature:** iMessage is gated behind a Labs toggle. The channel stays a "Coming Soon" placeholder until you enable it in **Settings → Advanced → Labs**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Mac signed into the Apple ID you want the bot to use
|
||||
- [BlueBubbles Server](https://bluebubbles.app/) installed on that Mac, with a server password set
|
||||
- LobeHub Desktop signed in and connected to the Device Gateway (Settings shows the gateway as connected)
|
||||
|
||||
> **Private API note:** BlueBubbles sends basic text and attachments through AppleScript out of the box. Advanced features such as typing indicators require the BlueBubbles Private API, which needs SIP disabled / a jailbroken Mac. LobeHub only depends on basic text and attachment send/receive — typing-indicator failures are logged and ignored.
|
||||
|
||||
## Step 1: Set Up BlueBubbles
|
||||
|
||||
<Steps>
|
||||
### Install BlueBubbles Server
|
||||
|
||||
Install [BlueBubbles Server](https://bluebubbles.app/) on the Mac that hosts the iMessage account. Keep the Mac awake and on the network.
|
||||
|
||||
### Set a server password
|
||||
|
||||
In BlueBubbles Server, set a strong password. The LobeHub Desktop bridge uses it locally to call the BlueBubbles REST API.
|
||||
|
||||
### Keep it local
|
||||
|
||||
A local address such as `http://127.0.0.1:1234` (or a private LAN address) is all you need — no public HTTPS URL required.
|
||||
</Steps>
|
||||
|
||||
## Step 2: Enable the iMessage Lab
|
||||
|
||||
<Steps>
|
||||
### Open Labs
|
||||
|
||||
In LobeHub, go to **Settings → Advanced** and find the **Labs** section.
|
||||
|
||||
### Turn on "iMessage Channel"
|
||||
|
||||
Toggle it on. The iMessage entry in your agent's channel list switches from a "Coming Soon" placeholder to a configurable channel.
|
||||
</Steps>
|
||||
|
||||
## Step 3: Configure iMessage in Your Agent
|
||||
|
||||
<Steps>
|
||||
### Open the channel
|
||||
|
||||
Go to your agent's settings → **Channels** → **iMessage**.
|
||||
|
||||
### Fill in the three fields
|
||||
|
||||
1. **Application ID** — a stable identifier for this connection, e.g. `home-mac-mini`.
|
||||
2. **BlueBubbles Server URL** — your local BlueBubbles address, e.g. `http://127.0.0.1:1234`.
|
||||
3. **BlueBubbles Password** — the server password from Step 1.
|
||||
|
||||
The Desktop Device ID and webhook secret are filled in and generated automatically — you don't need to manage them.
|
||||
|
||||
### Test the connection (optional)
|
||||
|
||||
Click **Test BlueBubbles** to verify the URL and password reach your local BlueBubbles server.
|
||||
|
||||
### Save
|
||||
|
||||
Click **Save Configuration**. A single save persists the cloud channel **and** the local Desktop bridge: it starts the loopback listener, registers the BlueBubbles `new-message` webhook, and connects the bot.
|
||||
</Steps>
|
||||
|
||||
## Step 4: Test the Bot
|
||||
|
||||
Have **another person or a second Apple ID** send an iMessage to the Apple ID / phone number signed into the BlueBubbles Mac. BlueBubbles fires a local `new-message` webhook, the Desktop bridge forwards it to LobeHub, and the agent replies in the same conversation.
|
||||
|
||||
> **Why a different sender?** Messages the hosted account sends itself are ignored by the `isFromMe` loop guard (so the bot never replies to its own messages). Testing from your own number won't trigger a reply — use a different sender.
|
||||
|
||||
## Feature Notes
|
||||
|
||||
- **Markdown** — iMessage receives plain text; LobeHub strips Markdown before sending.
|
||||
- **Attachments** — inbound and outbound attachments are relayed through the bridge and the BlueBubbles attachment APIs.
|
||||
- **Typing indicators** — attempted only when the BlueBubbles Private API is available; otherwise they fail silently and don't affect replies.
|
||||
- **Group chats** — supported when BlueBubbles includes the `chatGuid` in events; use the `chatGuid` as the allowed-channel ID when scoping group access.
|
||||
- **Loop prevention** — messages from the hosted account itself are dropped before dispatch.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **iMessage still shows "Coming Soon".** The iMessage Channel lab isn't enabled — turn it on in Settings → Advanced → Labs.
|
||||
- **Connect fails with `DEVICE_NOT_FOUND`.** LobeHub Desktop isn't reachable through the Device Gateway. Make sure Desktop is open, signed in, and the gateway shows as connected, then save again.
|
||||
- **Test / save fails with a BlueBubbles error.** Recheck the local BlueBubbles URL and password.
|
||||
- **Bot never replies.** Confirm the BlueBubbles `new-message` webhook points at `127.0.0.1` and that the sender is not the hosted account itself.
|
||||
- **Typing indicator fails but text works.** Expected without the BlueBubbles Private API — safe to ignore.
|
||||
- **Attachments fail.** Confirm the attachment finished downloading on the Mac and that BlueBubbles can serve it locally.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: 将 LobeHub 连接到 iMessage
|
||||
description: >-
|
||||
了解如何通过 LobeHub Desktop 本地 BlueBubbles 桥接,将 iMessage 连接到你的 LobeHub 智能体。
|
||||
|
||||
tags:
|
||||
- iMessage
|
||||
- BlueBubbles
|
||||
- 消息渠道
|
||||
- Bot 设置
|
||||
- 集成
|
||||
---
|
||||
|
||||
# 将 LobeHub 连接到 iMessage
|
||||
|
||||
LobeHub 通过运行在已登录 Messages 的 Mac 上的 [BlueBubbles](https://bluebubbles.app/) 连接 iMessage。LobeHub Desktop 在同一台 Mac 上运行一个本地桥接:它在 `127.0.0.1` 接收 BlueBubbles 的 webhook,转发到 LobeHub 云端,并把智能体的回复经本地 BlueBubbles REST API 发回。BlueBubbles 无需暴露到公网。
|
||||
|
||||
```text
|
||||
iMessage 用户 -> macOS Messages -> BlueBubbles -> LobeHub Desktop 桥接 -> LobeHub 云端
|
||||
LobeHub 智能体 -> Device Gateway -> LobeHub Desktop 桥接 -> BlueBubbles -> iMessage 用户
|
||||
```
|
||||
|
||||
> **Labs 功能:** iMessage 由 Labs 开关控制。在你于 **设置 → 高级 → Labs** 中开启之前,该渠道会一直显示为 “即将推出” 占位。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一台已登录目标 Apple ID 的 Mac
|
||||
- 该 Mac 上安装了 [BlueBubbles Server](https://bluebubbles.app/) 并设置了服务器密码
|
||||
- LobeHub Desktop 已登录,且已连接 Device Gateway(设置中显示网关已连接)
|
||||
|
||||
> **Private API 说明:** BlueBubbles 默认通过 AppleScript 发送基础文本和附件。打字指示等高级功能需要 BlueBubbles Private API(需关闭 SIP / 越狱的 Mac)。LobeHub 只依赖基础的文本与附件收发 —— 打字指示失败会被记录并忽略,不影响回复。
|
||||
|
||||
## 第 1 步:配置 BlueBubbles
|
||||
|
||||
<Steps>
|
||||
### 安装 BlueBubbles Server
|
||||
|
||||
在用于托管 iMessage 账号的 Mac 上安装 [BlueBubbles Server](https://bluebubbles.app/)。保持 Mac 唤醒并联网。
|
||||
|
||||
### 设置服务器密码
|
||||
|
||||
在 BlueBubbles Server 中设置一个强密码。LobeHub Desktop 桥接会在本地用它调用 BlueBubbles REST API。
|
||||
|
||||
### 保持本地访问
|
||||
|
||||
使用 `http://127.0.0.1:1234` 这样的本地地址(或私有局域网地址)即可,无需公网 HTTPS 地址。
|
||||
</Steps>
|
||||
|
||||
## 第 2 步:开启 iMessage Lab
|
||||
|
||||
<Steps>
|
||||
### 打开 Labs
|
||||
|
||||
在 LobeHub 中进入 **设置 → 高级**,找到 **Labs** 区域。
|
||||
|
||||
### 打开 “iMessage Channel”
|
||||
|
||||
开启后,智能体渠道列表里的 iMessage 会从 “即将推出” 占位切换为可配置的渠道。
|
||||
</Steps>
|
||||
|
||||
## 第 3 步:在智能体中配置 iMessage
|
||||
|
||||
<Steps>
|
||||
### 打开渠道
|
||||
|
||||
进入智能体设置 → **渠道** → **iMessage**。
|
||||
|
||||
### 填写三个字段
|
||||
|
||||
1. **Application ID** —— 本次连接的稳定标识,例如 `home-mac-mini`。
|
||||
2. **BlueBubbles Server URL** —— 你的本地 BlueBubbles 地址,例如 `http://127.0.0.1:1234`。
|
||||
3. **BlueBubbles Password** —— 第 1 步设置的服务器密码。
|
||||
|
||||
Desktop Device ID 和 webhook secret 会自动填充与生成,无需手动管理。
|
||||
|
||||
### 测试连接(可选)
|
||||
|
||||
点击 **Test BlueBubbles**,验证 URL 和密码能否连上你的本地 BlueBubbles 服务。
|
||||
|
||||
### 保存
|
||||
|
||||
点击 **Save Configuration**。一次保存会同时落地云端渠道**和**本地 Desktop 桥接:启动本地回环监听、注册 BlueBubbles 的 `new-message` webhook,并连接 Bot。
|
||||
</Steps>
|
||||
|
||||
## 第 4 步:测试 Bot
|
||||
|
||||
让**另一个人或第二个 Apple ID** 给托管在 BlueBubbles Mac 上的 Apple ID / 手机号发一条 iMessage。BlueBubbles 触发本地 `new-message` webhook,Desktop 桥接转发到 LobeHub,智能体在同一会话里回复。
|
||||
|
||||
> **为什么要用其他发送方?** 托管账号自己发的消息会被 `isFromMe` 防循环逻辑忽略(这样 Bot 不会回复自己发的消息)。用你自己的号码测试不会触发回复 —— 请用其他发送方。
|
||||
|
||||
## 功能说明
|
||||
|
||||
- **Markdown** —— iMessage 接收纯文本;LobeHub 在发送前会去除 Markdown 标记。
|
||||
- **附件** —— 入站和出站附件通过桥接与 BlueBubbles 附件 API 中转。
|
||||
- **打字指示** —— 仅在 BlueBubbles Private API 可用时尝试;否则静默失败,不影响回复。
|
||||
- **群聊** —— 当 BlueBubbles 在事件中包含 `chatGuid` 时支持群聊;限定群组访问时用 `chatGuid` 作为允许渠道 ID。
|
||||
- **防循环** —— 托管账号自身发出的消息在分发前被丢弃。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **iMessage 仍显示 “即将推出”。** iMessage Channel lab 未开启 —— 在 设置 → 高级 → Labs 中打开。
|
||||
- **连接报 `DEVICE_NOT_FOUND`。** LobeHub Desktop 未通过 Device Gateway 可达。确认 Desktop 已打开、已登录、网关显示已连接,然后重新保存。
|
||||
- **测试 / 保存报 BlueBubbles 错误。** 重新检查本地 BlueBubbles URL 和密码。
|
||||
- **Bot 始终不回复。** 确认 BlueBubbles 的 `new-message` webhook 指向 `127.0.0.1`,且发送方不是托管账号本身。
|
||||
- **打字指示失败但文本正常。** 没有 BlueBubbles Private API 时属预期 —— 可忽略。
|
||||
- **附件失败。** 确认附件已在 Mac 上下载完成,且 BlueBubbles 能在本地提供该文件。
|
||||
@@ -34,6 +34,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [LINE](/docs/usage/channels/line) | Connect to LINE Messaging API for direct and group chats |
|
||||
| [iMessage](/docs/usage/channels/imessage) | Connect to iMessage through the local LobeHub Desktop BlueBubbles bridge (Labs) |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
|
||||
@@ -27,16 +27,17 @@ tags:
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | -------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | ----------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 |
|
||||
| [iMessage](/docs/usage/channels/imessage) | 通过 LobeHub Desktop 本地 BlueBubbles 桥接连接 iMessage(Labs) |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
|
||||
## 工作原理
|
||||
|
||||
|
||||
@@ -100,6 +100,35 @@
|
||||
"channel.groupPolicyOpenHint": "الرد في أي مجموعة أو قناة أو موضوع",
|
||||
"channel.historyLimit": "حد رسائل السجل",
|
||||
"channel.historyLimitHint": "العدد الافتراضي للرسائل التي يتم جلبها عند قراءة سجل القناة",
|
||||
"channel.imessage.applicationIdHint": "معرّف ثابت مشترك بين قناة السحابة وجسر سطح المكتب.",
|
||||
"channel.imessage.applicationIdPlaceholder": "مثال: home-mac-mini",
|
||||
"channel.imessage.blueBubblesPassword": "كلمة مرور BlueBubbles",
|
||||
"channel.imessage.blueBubblesPasswordHint": "يتم تخزينها محليًا في LobeHub Desktop وتُستخدم فقط لاستدعاء خادم BlueBubbles المحلي.",
|
||||
"channel.imessage.blueBubblesServerUrl": "عنوان URL لخادم BlueBubbles",
|
||||
"channel.imessage.blueBubblesServerUrlHint": "عنوان URL لخادم BlueBubbles المحلي القابل للوصول من هذا التطبيق على سطح المكتب.",
|
||||
"channel.imessage.bridgeEnabled": "تفعيل الجسر",
|
||||
"channel.imessage.bridgeEnabledHint": "عند التفعيل، يستقبل LobeHub Desktop إشعارات BlueBubbles المحلية ويعيد توجيهها إلى LobeHub.",
|
||||
"channel.imessage.bridgeMissingApplicationId": "أدخل معرّف التطبيق أولاً.",
|
||||
"channel.imessage.bridgeMissingPassword": "أدخل كلمة مرور BlueBubbles أولاً.",
|
||||
"channel.imessage.bridgeMissingServerUrl": "أدخل عنوان URL لخادم BlueBubbles أولاً.",
|
||||
"channel.imessage.bridgeMissingWebhookSecret": "أدخل السر الخاص بالويب هوك أولاً.",
|
||||
"channel.imessage.bridgePasswordSavedPlaceholder": "اتركه فارغًا للحفاظ على كلمة المرور المحفوظة",
|
||||
"channel.imessage.bridgeRefresh": "تحديث",
|
||||
"channel.imessage.bridgeRefreshFailed": "فشل في تحديث جسر iMessage لسطح المكتب",
|
||||
"channel.imessage.bridgeRunning": "يعمل",
|
||||
"channel.imessage.bridgeSave": "حفظ الجسر",
|
||||
"channel.imessage.bridgeSaveFailed": "فشل في حفظ جسر iMessage لسطح المكتب",
|
||||
"channel.imessage.bridgeSaved": "تم حفظ جسر iMessage لسطح المكتب",
|
||||
"channel.imessage.bridgeStopped": "متوقف",
|
||||
"channel.imessage.bridgeTest": "اختبار BlueBubbles",
|
||||
"channel.imessage.bridgeTestFailed": "فشل اختبار BlueBubbles",
|
||||
"channel.imessage.bridgeTestSuccess": "تم اجتياز اتصال BlueBubbles",
|
||||
"channel.imessage.description": "قم بتوصيل هذا المساعد بـ iMessage من خلال جسر LobeHub Desktop BlueBubbles المحلي.",
|
||||
"channel.imessage.desktopBridge": "جسر سطح المكتب",
|
||||
"channel.imessage.desktopDeviceId": "معرّف جهاز سطح المكتب",
|
||||
"channel.imessage.desktopDeviceIdHint": "جهاز LobeHub Desktop الذي يشغل جسر BlueBubbles المحلي. يمكنك العثور عليه في إعدادات بوابة سطح المكتب.",
|
||||
"channel.imessage.webhookSecret": "السر الخاص بالويب هوك",
|
||||
"channel.imessage.webhookSecretHint": "سر مشترك بين LobeHub Desktop وويب هوك السحابة. استخدم نفس القيمة في إعدادات جسر سطح المكتب.",
|
||||
"channel.importConfig": "استيراد التكوين",
|
||||
"channel.importFailed": "فشل في استيراد التكوين",
|
||||
"channel.importInvalidFormat": "تنسيق ملف التكوين غير صالح",
|
||||
@@ -176,6 +205,7 @@
|
||||
"channel.userIdHint": "معرف المستخدم الخاص بك على هذه المنصة. يمكن للذكاء الاصطناعي استخدامه لإرسال رسائل مباشرة إليك.",
|
||||
"channel.userIdHint.discord": "فعّل وضع المطوّر (الإعدادات → متقدم)، ثم انقر بزر الفأرة الأيمن على صورتك الشخصية → انسخ معرّف المستخدم.",
|
||||
"channel.userIdHint.feishu": "افتح تطبيقك على منصة Feishu / Lark Open Platform → الأذونات، ثم ابحث عن المعرّف المفتوح الخاص بك.",
|
||||
"channel.userIdHint.imessage": "استخدم معرف iMessage الخاص بك كما يظهر في BlueBubbles، وعادةً ما يكون عنوان بريد إلكتروني أو رقم هاتف بصيغة E.164.",
|
||||
"channel.userIdHint.line": "افتح وحدة تحكم مطوري LINE → قناتك → علامة تبويب الإعدادات الأساسية، ونسخ \"معرّف المستخدم الخاص بك\" (يبدأ بحرف U، 33 حرفًا).",
|
||||
"channel.userIdHint.qq": "رقم QQ الخاص بك، يظهر في صفحة ملفك الشخصي.",
|
||||
"channel.userIdHint.slack": "افتح ملفك الشخصي في Slack → ⋮ المزيد → انسخ معرّف العضو (يبدأ بـ U).",
|
||||
|
||||
+74
-2
@@ -69,6 +69,11 @@
|
||||
"cliAuthGuide.errorDetails": "تفاصيل الخطأ",
|
||||
"cliAuthGuide.runCommand": "شغّل هذا في الطرفية",
|
||||
"cliAuthGuide.title": "سجّل الدخول إلى {{name}}",
|
||||
"cliOverloadedGuide.actions.retry": "إعادة المحاولة",
|
||||
"cliOverloadedGuide.desc": "خدمة النموذج العلوي لـ {{name}} مثقلة مؤقتًا. عادةً ما يتم حل هذا في غضون لحظات.",
|
||||
"cliOverloadedGuide.errorDetails": "تفاصيل الخطأ",
|
||||
"cliOverloadedGuide.retryHint": "انتظر بضع ثوانٍ وأعد المحاولة. إذا استمرت المشكلة، قد يكون المزود يواجه حادثًا أوسع.",
|
||||
"cliOverloadedGuide.title": "{{name}} مثقل مؤقتًا",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "افتح أدوات النظام",
|
||||
"cliRateLimitGuide.actions.retry": "إعادة المحاولة",
|
||||
"cliRateLimitGuide.afterReset": "انتظر حتى وقت إعادة التعيين، ثم أعد محاولة إرسال رسالتك. إذا كنت تستخدم ترخيص API، يمكنك أيضًا التحقق من الحصة والحالة المالية لدى مزود الخدمة.",
|
||||
@@ -203,6 +208,17 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "لم يتم تكوين أي مستودعات. أضفها في إعدادات الوكيل.",
|
||||
"heteroAgent.cloudRepo.notSet": "لم يتم تحديد أي مستودع",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "المستودعات",
|
||||
"heteroAgent.executionTarget.infoTooltip": "اختر جهازًا بعيدًا لتشغيل هذا الجهاز من الويب. \"هذا الجهاز\" يشغل الوكيل محليًا وهو متاح فقط داخل تطبيق سطح المكتب.",
|
||||
"heteroAgent.executionTarget.loading": "جارٍ تحميل الأجهزة...",
|
||||
"heteroAgent.executionTarget.local": "هذا الجهاز",
|
||||
"heteroAgent.executionTarget.localDesc": "تشغيل كعملية محلية على تطبيق سطح المكتب هذا",
|
||||
"heteroAgent.executionTarget.noDevices": "لا توجد أجهزة بعيدة حتى الآن. قم بتثبيت تطبيق سطح المكتب أو قم بتشغيل `lh connect` على جهاز آخر.",
|
||||
"heteroAgent.executionTarget.offline": "غير متصل",
|
||||
"heteroAgent.executionTarget.online": "متصل",
|
||||
"heteroAgent.executionTarget.sandbox": "بيئة سحابية مؤقتة",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "تشغيل في بيئة سحابية مؤقتة",
|
||||
"heteroAgent.executionTarget.title": "جهاز التنفيذ",
|
||||
"heteroAgent.executionTarget.unknownDevice": "جهاز غير معروف",
|
||||
"heteroAgent.fullAccess.label": "وصول كامل",
|
||||
"heteroAgent.fullAccess.tooltip": "يعمل Claude Code محليًا مع صلاحية قراءة/كتابة كاملة في دليل العمل. تبديل أوضاع الصلاحيات غير متاح بعد.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "تم تغيير دليل العمل. لا يمكن استئناف جلسة Claude Code السابقة إلا من دليلها الأصلي، لذا بدأت محادثة جديدة.",
|
||||
@@ -274,6 +290,11 @@
|
||||
"memory.on.desc": "تذكر التفضيلات والمعلومات من المحادثات.",
|
||||
"memory.on.title": "تمكين الذاكرة",
|
||||
"memory.title": "الذاكرة",
|
||||
"mention.category.agents": "الوكلاء",
|
||||
"mention.category.members": "الأعضاء",
|
||||
"mention.category.skills": "المهارات",
|
||||
"mention.category.tools": "الأدوات",
|
||||
"mention.category.topics": "المواضيع",
|
||||
"mention.title": "الإشارة إلى الأعضاء",
|
||||
"messageAction.collapse": "طي الرسالة",
|
||||
"messageAction.continueGeneration": "متابعة التوليد",
|
||||
@@ -330,6 +351,7 @@
|
||||
"newCodexAgent": "أضف Codex",
|
||||
"newGroupChat": "إنشاء مجموعة",
|
||||
"newPage": "إنشاء صفحة",
|
||||
"newPlatformAgent": "إضافة وكيل منصة",
|
||||
"noAgentsYet": "لا يوجد أعضاء في هذه المجموعة بعد. انقر على زر + لدعوة وكلاء.",
|
||||
"noAvailableAgents": "لا يوجد أعضاء متاحون للدعوة",
|
||||
"noMatchingAgents": "لم يتم العثور على أعضاء مطابقين",
|
||||
@@ -350,6 +372,45 @@
|
||||
"pageSelection.reference": "النص المحدد",
|
||||
"pin": "تثبيت",
|
||||
"pinOff": "إلغاء التثبيت",
|
||||
"platformAgent.create.available": "متاح",
|
||||
"platformAgent.create.back": "رجوع",
|
||||
"platformAgent.create.checkFailed": "فشل التحقق",
|
||||
"platformAgent.create.checking": "جارٍ التحقق من التوفر...",
|
||||
"platformAgent.create.comingSoon": "قريبًا",
|
||||
"platformAgent.create.create": "إنشاء وكيل",
|
||||
"platformAgent.create.creating": "جارٍ الإنشاء...",
|
||||
"platformAgent.create.desc.amp": "الاتصال بـ Amp الذي يعمل على أحد أجهزتك",
|
||||
"platformAgent.create.desc.hermes": "الاتصال بـ Hermes الذي يعمل على أحد أجهزتك",
|
||||
"platformAgent.create.desc.openclaw": "الاتصال بـ OpenClaw الذي يعمل على أحد أجهزتك",
|
||||
"platformAgent.create.desc.opencode": "الاتصال بـ OpenCode الذي يعمل على أحد أجهزتك",
|
||||
"platformAgent.create.descriptionPlaceholder": "وصف مختصر (اختياري)",
|
||||
"platformAgent.create.downloadDesktop": "تحميل تطبيق سطح المكتب",
|
||||
"platformAgent.create.fetchingProfile": "جارٍ جلب الملف الشخصي...",
|
||||
"platformAgent.create.namePlaceholder": "مثال: وكيل OpenClaw الخاص بي",
|
||||
"platformAgent.create.next": "التالي",
|
||||
"platformAgent.create.noDevices": "لا توجد أجهزة متصلة",
|
||||
"platformAgent.create.noDevicesCliHint": "أو قم بتوصيل أي جهاز عبر CLI، ثم انقر على تحديث:",
|
||||
"platformAgent.create.noDevicesCmd": "lh connect",
|
||||
"platformAgent.create.noDevicesDesktopHint": "قم بتثبيت تطبيق سطح المكتب — يتصل تلقائيًا بعد تسجيل الدخول",
|
||||
"platformAgent.create.notInstalled": "{{name}} غير مثبت على هذا الجهاز",
|
||||
"platformAgent.create.refresh": "تحديث",
|
||||
"platformAgent.create.selectDevice": "اختر جهازًا",
|
||||
"platformAgent.create.step1": "اختر المنصة",
|
||||
"platformAgent.create.step2": "اختر الجهاز",
|
||||
"platformAgent.create.step3": "تكوين الوكيل",
|
||||
"platformAgent.create.title": "إضافة وكيل منصة",
|
||||
"platformAgent.create.upgradeCmd": "npm install -g @lobehub/cli",
|
||||
"platformAgent.create.versionTooLow": "إصدار lh منخفض جدًا",
|
||||
"platformAgent.create.versionTooLowHint": "قم بتحديث lh إلى أحدث إصدار:",
|
||||
"platformAgent.device.online": "متصل",
|
||||
"platformAgent.deviceGuard.configure": "تكوين",
|
||||
"platformAgent.deviceGuard.deviceOffline.desc": "الجهاز المرتبط غير متصل. قم بتشغيل `lh connect` على هذا الجهاز ثم قم بالتحديث.",
|
||||
"platformAgent.deviceGuard.deviceOffline.title": "الجهاز غير متصل",
|
||||
"platformAgent.deviceGuard.noDevice.desc": "هذا الوكيل ليس لديه جهاز مرتبط. قم بتحرير ملف الوكيل لتكوين واحد.",
|
||||
"platformAgent.deviceGuard.noDevice.title": "لا يوجد جهاز مرتبط",
|
||||
"platformAgent.deviceGuard.platformUnavailable.desc": "{{name}} غير مثبت على الجهاز المتصل.",
|
||||
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} غير متاح",
|
||||
"platformAgent.deviceGuard.refresh": "تحديث",
|
||||
"plus.addSkills": "إضافة مهارات...",
|
||||
"plus.search.appSearch": "بحث ذكي",
|
||||
"plus.search.appSearchDesc": "خدمة بحث محسّنة من LobeHub، تقدم أفضل نتائج الاسترجاع.",
|
||||
@@ -553,6 +614,9 @@
|
||||
"taskDetail.modelConfig": "تجاوز الإعدادات النموذجية",
|
||||
"taskDetail.navigation": "التنقل",
|
||||
"taskDetail.nextRunCountdown": "التشغيل التالي خلال {{countdown}}",
|
||||
"taskDetail.notFound.backToTasks": "العودة إلى جميع المهام",
|
||||
"taskDetail.notFound.desc": "قد تكون هذه المهمة قد حُذفت، أو ليس لديك إذن لعرضها.",
|
||||
"taskDetail.notFound.title": "المهمة غير موجودة",
|
||||
"taskDetail.pauseTask": "إيقاف المهمة مؤقتًا",
|
||||
"taskDetail.priority.high": "عالية",
|
||||
"taskDetail.priority.low": "منخفضة",
|
||||
@@ -912,10 +976,11 @@
|
||||
"workingPanel.resources.deleteError": "Failed to delete document",
|
||||
"workingPanel.resources.deleteSuccess": "Document deleted",
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "حذف {{count}} عنصرًا؟",
|
||||
"workingPanel.resources.empty": "لا توجد مستندات بعد. ستظهر المستندات المرتبطة بهذا الوكيل هنا.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.all": "الكل",
|
||||
"workingPanel.resources.filter.documents": "مستندات",
|
||||
"workingPanel.resources.filter.skills": "المهارات",
|
||||
"workingPanel.resources.filter.web": "ويب",
|
||||
"workingPanel.resources.loading": "Loading resources...",
|
||||
"workingPanel.resources.previewError": "Failed to load preview",
|
||||
@@ -924,6 +989,7 @@
|
||||
"workingPanel.resources.renameError": "Failed to rename document",
|
||||
"workingPanel.resources.renameSuccess": "Document renamed",
|
||||
"workingPanel.resources.tree.createError": "فشل في الإنشاء",
|
||||
"workingPanel.resources.tree.deleteSelected": "حذف المحدد ({{count}})",
|
||||
"workingPanel.resources.tree.moveError": "فشل في النقل",
|
||||
"workingPanel.resources.tree.newDocument": "مستند جديد",
|
||||
"workingPanel.resources.tree.newFolder": "مجلد جديد",
|
||||
@@ -947,12 +1013,15 @@
|
||||
"workingPanel.review.empty.noBaseRef": "تعذر تحديد الفرع الافتراضي البعيد. قم بتشغيل `git remote set-head origin --auto` في الطرفية.",
|
||||
"workingPanel.review.error": "تعذر تحميل الفرق لهذا الملف",
|
||||
"workingPanel.review.expandAll": "توسيع الكل",
|
||||
"workingPanel.review.group.collapseDiffs": "طي جميع الفروقات في هذه المجموعة",
|
||||
"workingPanel.review.group.expandDiffs": "توسيع جميع الفروقات في هذه المجموعة",
|
||||
"workingPanel.review.group.fileCount": "{{count}} ملفات",
|
||||
"workingPanel.review.group.submoduleClean": "لا تغييرات داخلية",
|
||||
"workingPanel.review.mode.branch": "فرع",
|
||||
"workingPanel.review.mode.unstaged": "غير مُرتب",
|
||||
"workingPanel.review.more": "خيارات إضافية",
|
||||
"workingPanel.review.refresh": "تحديث",
|
||||
"workingPanel.review.revealInTree": "إظهار في الشجرة",
|
||||
"workingPanel.review.revealNotFound": "الملف غير موجود في فهرس المشروع",
|
||||
"workingPanel.review.revert": "تجاهل التغييرات",
|
||||
"workingPanel.review.revert.confirm.cancel": "إلغاء",
|
||||
"workingPanel.review.revert.confirm.description": "سيتم تجاهل تغييرات شجرة العمل على {{filePath}} نهائيًا. ستُحذف الملفات غير المتعقبة من القرص.",
|
||||
@@ -970,6 +1039,9 @@
|
||||
"workingPanel.review.wordWrap.disable": "تعطيل التفاف النص",
|
||||
"workingPanel.review.wordWrap.enable": "تمكين التفاف النص",
|
||||
"workingPanel.skills.empty": "لم يتم العثور على مهارات في هذا المشروع",
|
||||
"workingPanel.skills.section.agent": "مهارات الوكيل",
|
||||
"workingPanel.skills.section.project": "مهارات المشروع",
|
||||
"workingPanel.skills.section.user": "مهارات المستخدم",
|
||||
"workingPanel.skills.title": "المهارات",
|
||||
"workingPanel.space": "مسافة",
|
||||
"workingPanel.title": "Working Panel",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"messengerBanner.title": "تحدث إلى Lobe AI عبر تطبيقات المراسلة المفضلة لديك",
|
||||
"more": "المزيد",
|
||||
"navPanel.agent": "الوكيل",
|
||||
"navPanel.bottomDivider": "العناصر أدناه تثبت في الأسفل",
|
||||
"navPanel.customizeSidebar": "تخصيص الشريط الجانبي",
|
||||
"navPanel.displayItems": "عناصر العرض",
|
||||
"navPanel.hidden": "مخفي",
|
||||
@@ -479,5 +480,6 @@
|
||||
"userPanel.setting": "الإعدادات",
|
||||
"userPanel.upgradePlan": "ترقية الخطة",
|
||||
"userPanel.usages": "إحصائيات الاستخدام",
|
||||
"version": "الإصدار"
|
||||
"version": "الإصدار",
|
||||
"zoom": "تكبير"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"DragUpload.dragDesc": "اسحب الملفات وأفلتها هنا لتحميل صور متعددة.",
|
||||
"DragUpload.dragFileDesc": "اسحب الصور والملفات وأفلتها هنا لتحميل صور وملفات متعددة.",
|
||||
"DragUpload.dragFileTitle": "تحميل الملفات",
|
||||
"DragUpload.dragFolderDesc": "أسقط المجلد للإشارة إليه كـ @إشارة في إدخال الدردشة.",
|
||||
"DragUpload.dragFolderTitle": "الإشارة إلى المجلد",
|
||||
"DragUpload.dragMixedDesc": "يتم إدراج المجلدات كـ @إشارات؛ يتم تحميل الملفات.",
|
||||
"DragUpload.dragMixedTitle": "الإشارة إلى المجلد وتحميل الملفات",
|
||||
"DragUpload.dragTitle": "تحميل الصور",
|
||||
"FileManager.actions.addToLibrary": "إضافة إلى المكتبة",
|
||||
"FileManager.actions.batchChunking": "تجزئة جماعية",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"actionTag.category.agentSkill": "مهارة الوكيل",
|
||||
"actionTag.category.command": "أمر",
|
||||
"actionTag.category.projectSkill": "مهارة المشروع",
|
||||
"actionTag.category.skill": "مهارة",
|
||||
"actionTag.category.tool": "أداة",
|
||||
"actionTag.tooltip.agentSkill": "يحمّل حزمة مهارات من مستندات هذا الوكيل للطلب.",
|
||||
"actionTag.tooltip.command": "يشغّل أمر الشرطة المائلة على جانب العميل قبل الإرسال.",
|
||||
"actionTag.tooltip.projectSkill": "يتم إرسالها كاستدعاء شرطة مائلة بحيث يقوم CLI الخاص بالوكيل بتشغيل مهارة المشروع المطابقة.",
|
||||
"actionTag.tooltip.skill": "يحمّل حزمة مهارات قابلة لإعادة الاستخدام لهذا الطلب.",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"navigation.recentView": "المشاهدات الأخيرة",
|
||||
"navigation.resources": "الموارد",
|
||||
"navigation.settings": "الإعدادات",
|
||||
"navigation.task": "مهمة",
|
||||
"navigation.tasks": "المهام",
|
||||
"navigation.unpin": "إلغاء التثبيت",
|
||||
"notification.finishChatGeneration": "اكتمل توليد الرسالة بواسطة الذكاء الاصطناعي",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"home.uploadEntries.folder.title": "تحميل مجلد",
|
||||
"home.uploadEntries.library.title": "إنشاء مكتبة جديدة",
|
||||
"home.uploadEntries.newPage.title": "صفحة جديدة",
|
||||
"library.hierarchy.empty.desc": "أضف ملفات أو أنشئ مجلدًا للبدء",
|
||||
"library.hierarchy.empty.title": "لا يوجد شيء هنا بعد",
|
||||
"library.list.confirmRemoveLibrary": "أنت على وشك حذف هذه المكتبة. لن يتم حذف الملفات الموجودة بداخلها، بل سيتم نقلها إلى جميع الملفات. لا يمكن التراجع عن هذا الإجراء، لذا يرجى المتابعة بحذر.",
|
||||
"library.list.empty": "انقر <1>+</1> لإنشاء مكتبة جديدة",
|
||||
"library.new": "مكتبة جديدة",
|
||||
|
||||
@@ -48,13 +48,10 @@
|
||||
"starter.createAgent": "إنشاء وكيل",
|
||||
"starter.createGroup": "إنشاء مجموعة",
|
||||
"starter.deepResearch": "بحث معمق",
|
||||
"starter.deepseekV4Pro": "DeepSeek V4 Pro",
|
||||
"starter.deepseekV4ProAlready": "أنت تستخدم بالفعل DeepSeek V4 Pro",
|
||||
"starter.deepseekV4ProSwitched": "تم التبديل إلى DeepSeek V4 Pro",
|
||||
"starter.developing": "قريبًا",
|
||||
"starter.image": "صورة",
|
||||
"starter.imageGeneration": "توليد الصور",
|
||||
"starter.modelInUse": "تستخدم بالفعل {{name}}",
|
||||
"starter.modelSwitched": "تم التبديل إلى {{name}}",
|
||||
"starter.newLabel": "جديد",
|
||||
"starter.videoGeneration": "Seedance 2.0",
|
||||
"starter.write": "كتابة"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"config.resolution.options.1K": "1K",
|
||||
"config.resolution.options.2K": "2K",
|
||||
"config.resolution.options.4K": "4K",
|
||||
"config.resolution.options.512": "٥١٢بكسل",
|
||||
"config.seed.label": "البذرة",
|
||||
"config.seed.random": "بذرة عشوائية",
|
||||
"config.size.label": "الحجم",
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
{
|
||||
"features.agentDocumentFloatingChatPanel.desc": "عرض لوحة الدردشة العائمة في معاينة مستند الوكيل فقط عند تمكين هذه الميزة التجريبية.",
|
||||
"features.agentDocumentFloatingChatPanel.title": "لوحة الدردشة العائمة لمستند الوكيل",
|
||||
"features.agentSelfIteration.desc": "السماح للمساعد بالتفكير الذاتي، وبناء الوعي الذاتي، والاستمرار في التطوير عبر المحاولات والتفاعلات المتواصلة.",
|
||||
"features.agentSelfIteration.title": "التكرار الذاتي للوكيل",
|
||||
"features.assistantMessageGroup.desc": "تجميع رسائل الوكيل ونتائج استدعاء الأدوات معًا للعرض",
|
||||
"features.assistantMessageGroup.title": "تجميع رسائل الوكيل",
|
||||
"features.executionDeviceSwitcher.desc": "إظهار مفتاح تبديل جهاز التنفيذ في شريط أدوات الوكيل المتنوع بحيث يمكنك توجيه العمليات إلى هذا الجهاز أو إلى سحابة تجريبية أو إلى جهاز بعيد مرتبط.",
|
||||
"features.executionDeviceSwitcher.title": "مفتاح تبديل جهاز التنفيذ",
|
||||
"features.gatewayMode.desc": "تنفيذ مهام الوكيل على الخادم عبر بوابة WebSocket بدلًا من التشغيل محليًا، مما يتيح تنفيذًا أسرع ويقلل من استهلاك موارد العميل.",
|
||||
"features.gatewayMode.title": "تنفيذ الوكيل من جانب الخادم (البوابة)",
|
||||
"features.groupChat.desc": "تمكين تنسيق الدردشة الجماعية متعددة الوكلاء.",
|
||||
"features.groupChat.title": "دردشة جماعية (متعددة الوكلاء)",
|
||||
"features.imessage.desc": "ربط الوكلاء بـ iMessage من خلال جسر BlueBubbles المحلي لتطبيق LobeHub Desktop.",
|
||||
"features.imessage.title": "قناة iMessage",
|
||||
"features.inputMarkdown.desc": "عرض Markdown في منطقة الإدخال في الوقت الفعلي (نص عريض، كتل الشيفرة، جداول، إلخ).",
|
||||
"features.inputMarkdown.title": "عرض Markdown في الإدخال",
|
||||
"features.messenger.desc": "تحدث إلى وكلائك عبر تطبيق تيليجرام (وتطبيقات المراسلة الأخرى) من خلال روبوت LobeHub المشترك. يضيف علامة تبويب المراسلة في الإعدادات لربط حسابك واختيار الوكيل الذي يتلقى الرسائل.",
|
||||
"features.messenger.title": "المراسلة",
|
||||
"features.platformAgent.desc": "عرض خيار \"إضافة وكيل منصة\" في قائمة الإنشاء. يعمل وكلاء المنصة (مثل OpenClaw، Hermes) على جهاز متصل ويتواصلون عبر lh connect.",
|
||||
"features.platformAgent.title": "إنشاء وكيل منصة",
|
||||
"title": "المختبرات"
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.imageAspectRatio2.hint": "لـ Nano Banana 2؛ يتحكم في نسبة العرض إلى الارتفاع للصور المُنشأة (يدعم النسب العريضة جدًا 1:4، 4:1، 1:8، 8:1).",
|
||||
"providerModels.item.modelConfig.extendParams.options.imageResolution.hint": "لنماذج توليد الصور من Gemini 3؛ يتحكم في دقة الصور المُولدة.",
|
||||
"providerModels.item.modelConfig.extendParams.options.imageResolution2.hint": "لـ نماذج الصور Gemini 3.1 Flash؛ يتحكم في دقة الصور المُنشأة (يدعم 512 بكسل).",
|
||||
"providerModels.item.modelConfig.extendParams.options.opus47Effort.hint": "خاص بـ Claude Opus 4.7؛ يتحكم في مستوى الجهد (منخفض/متوسط/عال/عالٍ جداً/أقصى).",
|
||||
"providerModels.item.modelConfig.extendParams.options.opus47Effort.hint": "لـ Claude Opus 4.7 والإصدارات الأحدث؛ يتحكم في مستوى الجهد (منخفض/متوسط/مرتفع/مرتفع جدًا/أقصى).",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken.hint": "لنماذج Claude وQwen3 وما شابهها؛ يتحكم في ميزانية الرموز المخصصة للاستدلال.",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken32k.hint": "لـ GLM-5 و GLM-4.7؛ يتحكم في ميزانية الرموز للتفكير (الحد الأقصى 32k).",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken80k.hint": "لسلسلة Qwen3؛ يتحكم في ميزانية الرموز للتفكير (الحد الأقصى 80k).",
|
||||
@@ -245,7 +245,6 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.thinkingLevel2.hint": "لـ نماذج المعاينة Gemini 3 Pro؛ يتحكم في عمق التفكير.",
|
||||
"providerModels.item.modelConfig.extendParams.options.thinkingLevel3.hint": "لـ نماذج المعاينة Gemini 3.1 Pro؛ يتحكم في عمق التفكير بمستويات منخفضة/متوسطة/عالية.",
|
||||
"providerModels.item.modelConfig.extendParams.options.thinkingLevel4.hint": "لـ نماذج الصور Gemini 3.1 Flash؛ تبديل التفكير تشغيل/إيقاف.",
|
||||
"providerModels.item.modelConfig.extendParams.options.thinkingLevel5.hint": "للمعاينة السريعة لـ Gemini 3.1 Flash-Lite؛ يتحكم في عمق التفكير بمستويات الحد الأدنى/المنخفض/المتوسط/العالي.",
|
||||
"providerModels.item.modelConfig.extendParams.options.urlContext.hint": "لسلسلة Gemini؛ يدعم توفير سياق من خلال عنوان URL.",
|
||||
"providerModels.item.modelConfig.extendParams.placeholder": "اختر المعلمات الموسعة لتفعيلها",
|
||||
"providerModels.item.modelConfig.extendParams.previewFallback": "المعاينة غير متوفرة",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"AccountDeactivated": "تم تعطيل حسابك أو تعليقه. قد يكون ذلك بسبب السياسات أو الأمن أو مراجعة الحساب. يرجى الاتصال بدعم المزود للحصول على المساعدة.",
|
||||
"AgentRuntimeError": "خطأ في تنفيذ وقت تشغيل نموذج اللغة Lobe. يرجى استكشاف الأخطاء وإصلاحها أو إعادة المحاولة بناءً على المعلومات التالية.",
|
||||
"CapabilityNotSupported": "عذرًا، هذا النموذج لا يدعم الإمكانية المطلوبة (مثل إدخال الرؤية أو استدعاء الأدوات). يرجى التبديل إلى نموذج يدعم ذلك.",
|
||||
"ComfyUIBizError": "حدث خطأ أثناء طلب خدمة ComfyUI. يرجى استكشاف الأخطاء باستخدام المعلومات أدناه أو إعادة المحاولة.",
|
||||
"ComfyUIEmptyResult": "لم يتم إنشاء أي صورة بواسطة ComfyUI. يرجى التحقق من إعدادات النموذج أو إعادة المحاولة.",
|
||||
"ComfyUIModelError": "فشل تحميل نموذج ComfyUI. يرجى التأكد من وجود ملف النموذج.",
|
||||
"ComfyUIServiceUnavailable": "فشل الاتصال بخدمة ComfyUI. يرجى التأكد من أنها تعمل بشكل صحيح وأن عنوان URL للخدمة تم تكوينه بشكل صحيح.",
|
||||
"ComfyUIUploadFailed": "فشل تحميل الصورة إلى ComfyUI. يرجى التحقق من اتصال الخادم أو إعادة المحاولة.",
|
||||
"ComfyUIWorkflowError": "فشل تنفيذ سير العمل في ComfyUI. يرجى التحقق من إعدادات سير العمل.",
|
||||
"ConnectionCheckFailed": "عاد الطلب فارغًا. يرجى التحقق مما إذا كان عنوان وكيل API لا ينتهي بـ `/v1`.",
|
||||
"ContentModeration": "عذرًا، تم رفض المحتوى بواسطة مرشح الأمان في المصدر. يرجى تعديل الطلب وإعادة المحاولة.",
|
||||
"ContextEnginePipelineError": "فشل تجميع سياق المحادثة لهذا الطلب. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"DatabasePersistError": "فشلت عملية قاعدة البيانات أثناء حفظ أو تحميل هذه المحادثة. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"ExceededContextWindow": "يتجاوز محتوى الطلب الحالي الطول الذي يمكن للنموذج التعامل معه. يرجى تقليل كمية المحتوى وإعادة المحاولة.",
|
||||
"InsufficientQuota": "عذرًا، تم الوصول إلى الحصة المخصصة لهذه المفتاح. يرجى التحقق مما إذا كان رصيد حسابك كافيًا أو إعادة المحاولة بعد زيادة حصة المفتاح.",
|
||||
"InvalidBedrockCredentials": "فشل التحقق من صحة Bedrock. يرجى التحقق من AccessKeyId/SecretAccessKey وإعادة المحاولة.",
|
||||
"InvalidComfyUIArgs": "إعدادات ComfyUI غير صالحة. يرجى التحقق من الإعدادات وإعادة المحاولة.",
|
||||
"InvalidGithubToken": "رمز الوصول الشخصي لـ GitHub غير صحيح أو فارغ. يرجى التحقق من رمز الوصول الشخصي لـ GitHub وإعادة المحاولة.",
|
||||
"InvalidOllamaArgs": "إعدادات Ollama غير صالحة، يرجى التحقق من إعدادات Ollama وإعادة المحاولة.",
|
||||
"InvalidProviderAPIKey": "مفتاح API الخاص بـ {{provider}} غير صحيح أو فارغ، يرجى التحقق من مفتاح API الخاص بـ {{provider}} وإعادة المحاولة.",
|
||||
"InvalidRequestFormat": "عذرًا، رفض المزود المصدر الطلب باعتباره غير صالح. يرجى التحقق من الإدخال أو تجربة نموذج مختلف.",
|
||||
"InvalidVertexCredentials": "فشل التحقق من صحة Vertex. يرجى التحقق من بيانات الاعتماد وإعادة المحاولة.",
|
||||
"LocationNotSupportError": "نأسف، موقعك الحالي لا يدعم خدمة هذا النموذج. قد يكون ذلك بسبب قيود إقليمية أو عدم توفر الخدمة. يرجى التأكد مما إذا كان الموقع الحالي يدعم استخدام هذه الخدمة، أو حاول استخدام موقع مختلف.",
|
||||
"ModelNotFound": "عذرًا، لم يتم العثور على النموذج المطلوب. قد لا يكون موجودًا أو قد لا تكون لديك الأذونات اللازمة للوصول إليه. يرجى إعادة المحاولة بعد تغيير مفتاح API أو تعديل أذونات الوصول.",
|
||||
"NoAvailableChannel": "عذرًا، لا يحتوي الوكيل أو الموجه على قناة متاحة للنموذج المطلوب. يرجى تبديل إعدادات القناة/المفتاح أو إعادة المحاولة لاحقًا.",
|
||||
"OllamaBizError": "حدث خطأ أثناء طلب خدمة Ollama، يرجى استكشاف الأخطاء وإصلاحها أو إعادة المحاولة بناءً على المعلومات التالية.",
|
||||
"OllamaServiceUnavailable": "خدمة Ollama غير متوفرة. يرجى التحقق مما إذا كانت Ollama تعمل بشكل صحيح أو إذا تم إعداد تكوين عبر الأصل لـ Ollama بشكل صحيح.",
|
||||
"OperationInactivityTimeout": "كانت عملية الوكيل خاملة لفترة طويلة وتم إنهاؤها. يرجى إعادة المحاولة.",
|
||||
"PermissionDenied": "عذرًا، ليس لديك إذن للوصول إلى هذه الخدمة. يرجى التحقق مما إذا كان مفتاحك يحتوي على حقوق الوصول اللازمة.",
|
||||
"ProviderBizError": "حدث خطأ أثناء طلب خدمة {{provider}}، يرجى استكشاف الأخطاء وإصلاحها أو إعادة المحاولة بناءً على المعلومات التالية.",
|
||||
"ProviderNetworkError": "انتهت مهلة الاتصال بالمزود أو تم قطعها. يرجى التحقق من الشبكة وإعادة المحاولة.",
|
||||
"ProviderServiceUnavailable": "المزود مشغول مؤقتًا أو غير متوفر. يرجى إعادة المحاولة قريبًا.",
|
||||
"QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى زيادة حصة المفتاح أو إعادة المحاولة لاحقًا.",
|
||||
"RateLimitExceeded": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى إعادة المحاولة لاحقًا أو زيادة حصة المفتاح.",
|
||||
"StateStorePersistError": "تسبب مشكلة مؤقتة في تخزين حالة المحادثة في تعطيل هذه العملية. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StreamChunkError": "حدث خطأ أثناء تحليل جزء الرسالة من الطلب المتدفق. يرجى التحقق مما إذا كانت واجهة API الحالية تتوافق مع المواصفات القياسية، أو الاتصال بمزود API للحصول على المساعدة.",
|
||||
"UpstreamGatewayError": "عاد البوابة أو الوكيل المصدر بخطأ. يرجى إعادة المحاولة قريبًا؛ إذا استمرت المشكلة، تحقق من إعدادات الوكيل / نقطة النهاية.",
|
||||
"UpstreamHttpError": "عاد المزود بخطأ HTTP دون تفاصيل إضافية. يرجى إعادة المحاولة، أو التحقق من الطلب وإعدادات النموذج.",
|
||||
"UpstreamMalformedResponse": "عاد المزود باستجابة غير صالحة لا يمكن تحليلها. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، جرب نموذجًا أو مزودًا مختلفًا.",
|
||||
"UserConfigError": "إعدادات المزود غير صالحة (عنوان URL الأساسي غير صحيح، متغير بيئة مفقود، قيود المفتاح الافتراضي، إلخ). يرجى مراجعة إعدادات المزود."
|
||||
}
|
||||
+13
-1
@@ -106,6 +106,7 @@
|
||||
"MiniMax-Hailuo-2.3.description": "نموذج جديد لإنشاء الفيديو مع تحسينات شاملة في حركة الجسم، والواقعية الفيزيائية، واتباع التعليمات.",
|
||||
"MiniMax-M1.description": "نموذج استدلال داخلي جديد بسلسلة تفكير تصل إلى 80K ومدخلات حتى 1M، يقدم أداءً مماثلاً لأفضل النماذج العالمية.",
|
||||
"MiniMax-M2-Stable.description": "مصمم لتدفقات العمل البرمجية والوكلاء بكفاءة عالية، مع قدرة تزامن أعلى للاستخدام التجاري.",
|
||||
"MiniMax-M2.1-Lightning.description": "قدرات برمجة متعددة اللغات قوية مع استدلال أسرع وأكثر كفاءة.",
|
||||
"MiniMax-M2.1-highspeed.description": "قدرات برمجة متعددة اللغات قوية، تجربة برمجة مطورة بشكل شامل. أسرع وأكثر كفاءة.",
|
||||
"MiniMax-M2.1.description": "MiniMax-M2.1 هو نموذج مفتوح المصدر رائد من MiniMax، يركز على حل المهام الواقعية المعقدة. يتميز بقدرات برمجة متعددة اللغات والقدرة على أداء المهام المعقدة كوكلاء ذكي.",
|
||||
"MiniMax-M2.5-highspeed.description": "MiniMax M2.5 Highspeed: نفس أداء M2.5 مع استدلال أسرع.",
|
||||
@@ -325,6 +326,7 @@
|
||||
"claude-opus-4-5.description": "Claude Opus 4.5 من Anthropic — نموذج رئيسي مع تفكير وبرمجة من الدرجة الأولى.",
|
||||
"claude-opus-4-6.description": "Claude Opus 4.6 من Anthropic — نافذة سياق 1M نموذج رئيسي مع تفكير متقدم.",
|
||||
"claude-opus-4-7.description": "Claude Opus 4.7 من Anthropic — أحدث نموذج Opus مع تفكير وبرمجة متقدمة.",
|
||||
"claude-opus-4-8.description": "Claude Opus 4.8 هو النموذج الأكثر قدرة من Anthropic، مبني على Opus 4.7 مع تحسينات في الاستدلال، البرمجة الوكيلة، واستخدام الأدوات.",
|
||||
"claude-opus-4.5.description": "Claude Opus 4.5 هو النموذج الرائد من Anthropic، يجمع بين الذكاء الفائق والأداء القابل للتوسع لمهام الاستدلال المعقدة وعالية الجودة.",
|
||||
"claude-opus-4.6-fast.description": "Claude Opus 4.6 هو النموذج الأكثر ذكاءً من Anthropic لبناء الوكلاء والبرمجة.",
|
||||
"claude-opus-4.6.description": "Claude Opus 4.6 هو النموذج الأكثر ذكاءً من Anthropic لبناء الوكلاء والبرمجة.",
|
||||
@@ -400,6 +402,7 @@
|
||||
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 هو نموذج يجمع بين الكفاءة الحسابية العالية وأداء التفكير والوكيل الممتاز. يعتمد نهجه على ثلاثة اختراقات تكنولوجية رئيسية: DeepSeek Sparse Attention (DSA)، وهي آلية انتباه فعالة تقلل بشكل كبير من التعقيد الحسابي مع الحفاظ على أداء النموذج، ومُحسنة خصيصًا للسيناريوهات ذات السياق الطويل؛ إطار عمل للتعلم المعزز القابل للتوسع يمكن من خلاله أن ينافس أداء النموذج GPT-5، مع نسخته عالية الحوسبة التي تضاهي Gemini-3.0-Pro في قدرات التفكير؛ وخط أنابيب واسع النطاق لتوليف مهام الوكيل يهدف إلى دمج قدرات التفكير في سيناريوهات استخدام الأدوات، مما يحسن اتباع التعليمات والتعميم في البيئات التفاعلية المعقدة. حقق النموذج أداءً متميزًا في الأولمبياد الدولي للرياضيات (IMO) وأولمبياد المعلوماتية الدولي (IOI) لعام 2025.",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 هو نموذج MoE يحتوي على 671 مليار معلمة، يستخدم MLA وDeepSeekMoE مع توازن تحميل خالٍ من الفقدان لتدريب واستدلال فعال. تم تدريبه مسبقًا على 14.8 تريليون رمز عالي الجودة مع SFT وRL، ويتفوق على النماذج المفتوحة الأخرى ويقترب من النماذج المغلقة الرائدة.",
|
||||
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash هو إصدار معاينة لنموذج اللغة MoE في سلسلة DeepSeek-V4. حجم المعلمات الإجمالي هو 284 مليار، حجم المعلمات النشطة هو 13 مليار، ويدعم سياق طويل للغاية يصل إلى 1 مليون رمز. يستخدم النموذج هيكل انتباه هجين يجمع بين CSA وHCA، ويقدم mHC وMuon Optimizer لتحسين كفاءة التفكير طويل السياق، استقرار التدريب، والأداء العام.",
|
||||
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro هو النموذج الرائد في سلسلة DeepSeek-V4، مع 1.6 تريليون معلمات إجمالية و49 مليار معلمات نشطة، ويدعم أصلاً سياقًا طويلًا للغاية يصل إلى مليون رمز. يعتمد النموذج على بنية هجينة مبتكرة للانتباه تجمع بين الانتباه المضغوط النادر (CSA) والانتباه المضغوط العالي (HCA)، مما يتطلب فقط 27% من عمليات الاستدلال لكل رمز مقارنة بـ DeepSeek-V3.2 و10% من ذاكرة KV عند سياق 1 مليون. كما يقدم اتصالات فائقة مقيدة بالمشعب (mHC) لتعزيز استقرار انتشار الإشارات بين الطبقات، ويستخدم محسن Muon لتسريع التقارب. تم تدريب DeepSeek-V4-Pro مسبقًا على أكثر من 32 تريليون رموز متنوعة عالية الجودة، مع تدريب لاحق باستخدام نهج من مرحلتين يتضمن زراعة خبراء المجال المستقلين بالإضافة إلى تقطير السياسات عبر الإنترنت للتكامل الموحد. يحقق وضع كثافة الاستدلال القصوى DeepSeek-V4-Pro-Max أداءً عاليًا في معايير البرمجة ويقلل بشكل كبير الفجوة مع النماذج المغلقة الرائدة في مهام الاستدلال والبرمجة الوكيلة، مما يجعله واحدًا من أقوى النماذج مفتوحة المصدر اليوم، ويدعم أوضاع كثافة الاستدلال Non-think وThink High وThink Max.",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) هو نموذج مبتكر يوفر فهمًا عميقًا للغة وتفاعلًا ذكيًا.",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 هو نموذج تفكير من الجيل التالي يتمتع بقدرات أقوى في التفكير المعقد وسلسلة التفكير لمهام التحليل العميق.",
|
||||
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2 هو نموذج استدلال من الجيل التالي يتميز بقدرات استدلال معقدة وسلسلة التفكير.",
|
||||
@@ -490,6 +493,8 @@
|
||||
"doubao-seedream-4-0-250828.description": "Seedream 4.0 هو نموذج توليد صور من ByteDance Seed، يدعم إدخال النصوص والصور مع توليد صور عالية الجودة وقابلة للتحكم بدرجة كبيرة. يُولّد الصور من التعليمات النصية.",
|
||||
"doubao-seedream-4-5-251128.description": "Seedream 4.5 هو أحدث نموذج متعدد الوسائط من ByteDance، يدمج قدرات تحويل النص إلى صورة، والصورة إلى صورة، وتوليد الصور بالجملة، مع دمج الفهم العام وقدرات الاستدلال. مقارنة بالإصدار السابق 4.0، يقدم جودة توليد محسّنة بشكل كبير، مع تحسين تناسق التحرير ودمج الصور المتعددة. يوفر تحكمًا أكثر دقة في التفاصيل البصرية، مما يجعل النصوص الصغيرة والوجوه الصغيرة أكثر طبيعية، ويحقق تخطيطًا وألوانًا أكثر انسجامًا، مما يعزز الجماليات العامة.",
|
||||
"doubao-seedream-5-0-260128.description": "Doubao-Seedream-5.0-lite هو أحدث نموذج لتوليد الصور من ByteDance. لأول مرة، يدمج قدرات الاسترجاع عبر الإنترنت، مما يسمح له بتضمين معلومات الويب في الوقت الفعلي وتعزيز حداثة الصور المولدة. كما تم ترقية ذكاء النموذج، مما يمكنه من تفسير التعليمات المعقدة والمحتوى البصري بدقة. بالإضافة إلى ذلك، يقدم تغطية محسّنة للمعرفة العالمية، وتناسقًا مرجعيًا، وجودة توليد في السيناريوهات المهنية، مما يلبي بشكل أفضل احتياجات الإبداع البصري على مستوى المؤسسات.",
|
||||
"dreamina-seedance-2-0-260128.description": "Seedance 2.0 من ByteDance هو النموذج الأكثر قوة لتوليد الفيديو، ويدعم توليد الفيديو متعدد الوسائط، تحرير الفيديو، تمديد الفيديو، تحويل النص إلى فيديو، وتحويل الصورة إلى فيديو مع صوت متزامن.",
|
||||
"dreamina-seedance-2-0-fast-260128.description": "Seedance 2.0 Fast من ByteDance يقدم نفس قدرات Seedance 2.0 مع سرعات توليد أسرع وسعر أكثر تنافسية.",
|
||||
"emohaa.description": "Emohaa هو نموذج للصحة النفسية يتمتع بقدرات استشارية احترافية لمساعدة المستخدمين على فهم المشكلات العاطفية.",
|
||||
"ernie-4.5-0.3b.description": "ERNIE 4.5 0.3B هو نموذج مفتوح المصدر وخفيف الوزن، مصمم للنشر المحلي والمخصص.",
|
||||
"ernie-4.5-8k-preview.description": "ERNIE 4.5 8K Preview هو نموذج معاينة بسياق 8K لتقييم أداء ERNIE 4.5.",
|
||||
@@ -515,6 +520,7 @@
|
||||
"ernie-x1-turbo-32k.description": "ERNIE X1 Turbo 32K هو نموذج تفكير سريع بسياق 32K للاستدلال المعقد والدردشة متعددة الأدوار.",
|
||||
"ernie-x1.1-preview.description": "معاينة ERNIE X1.1 هو نموذج تفكير مخصص للتقييم والاختبار.",
|
||||
"ernie-x1.1.description": "ERNIE X1.1 هو نموذج تفكير تجريبي للتقييم والاختبار.",
|
||||
"fal-ai/bytedance/seedream/v4.5.description": "Seedream 4.5، الذي تم تطويره بواسطة فريق Seed في ByteDance، يدعم تحرير الصور المتعددة والتكوين. يتميز باتساق محسّن للموضوع، اتباع دقيق للتعليمات، فهم المنطق المكاني، التعبير الجمالي، تصميم تخطيط الملصقات والشعارات مع تقديم نصوص وصور عالية الدقة.",
|
||||
"fal-ai/bytedance/seedream/v4.description": "Seedream 4.0 هو نموذج توليد الصور من ByteDance Seed، يدعم المدخلات النصية والصورية مع توليد صور عالي الجودة وقابل للتحكم بدرجة كبيرة. يقوم بتوليد الصور من التعليمات النصية.",
|
||||
"fal-ai/flux-kontext/dev.description": "نموذج FLUX.1 يركز على تحرير الصور، ويدعم إدخال النصوص والصور.",
|
||||
"fal-ai/flux-pro/kontext.description": "FLUX.1 Kontext [pro] يقبل النصوص وصور مرجعية كمدخلات، مما يتيح تعديلات محلية مستهدفة وتحولات معقدة في المشهد العام.",
|
||||
@@ -571,8 +577,9 @@
|
||||
"gemini-3.1-flash-lite.description": "Gemini 3.1 Flash-Lite هو النموذج متعدد الوسائط الأكثر كفاءة من Google، مُحسّن للمهام الوكيلية ذات الحجم الكبير، الترجمة، ومعالجة البيانات.",
|
||||
"gemini-3.1-pro-preview.description": "Gemini 3.1 Pro Preview يحسن من Gemini 3 Pro مع قدرات استدلال محسّنة ويضيف دعم مستوى التفكير المتوسط.",
|
||||
"gemini-3.1-pro.description": "Gemini 3.1 Pro من Google — نموذج متعدد الوسائط متميز مع نافذة سياق 1M.",
|
||||
"gemini-3.5-flash.description": "أذكى نموذج من Gemini مصمم للسرعة، يجمع بين الذكاء المتقدم والبحث المتفوق والتأصيل.",
|
||||
"gemini-flash-latest.description": "يشير إلى gemini-3-flash-preview",
|
||||
"gemini-flash-lite-latest.description": "يشير إلى gemini-2.5-flash-lite-preview-09-2025",
|
||||
"gemini-flash-lite-latest.description": "يشير إلى gemini-3.1-flash-lite",
|
||||
"gemini-pro-latest.description": "يشير إلى gemini-3.1-pro-preview",
|
||||
"gemma-7b-it.description": "Gemma 7B فعال من حيث التكلفة للمهام الصغيرة والمتوسطة.",
|
||||
"gemma2-9b-it.description": "Gemma 2 9B مُحسّن للمهام المحددة وتكامل الأدوات.",
|
||||
@@ -731,6 +738,8 @@
|
||||
"grok-4-fast-reasoning.description": "يسعدنا إطلاق Grok 4 Fast، أحدث تقدم في نماذج الاستدلال منخفضة التكلفة.",
|
||||
"grok-4.20-0309-non-reasoning.description": "نموذج غير تفكير للاستخدامات البسيطة.",
|
||||
"grok-4.20-0309-reasoning.description": "نموذج ذكي وسريع للغاية يفكر قبل الرد.",
|
||||
"grok-4.20-beta-0309-non-reasoning.description": "نسخة غير استدلالية للاستخدامات البسيطة.",
|
||||
"grok-4.20-beta-0309-reasoning.description": "نموذج ذكي وسريع للغاية يستدل قبل الرد.",
|
||||
"grok-4.20-multi-agent-0309.description": "فريق من 4 أو 16 وكيلًا، يتفوق في حالات الاستخدام البحثية، لا يدعم حاليًا الأدوات على جانب العميل. يدعم فقط أدوات xAI على جانب الخادم (مثل X Search، أدوات البحث على الويب) وأدوات MCP البعيدة.",
|
||||
"grok-4.3.description": "أكثر نموذج لغة كبير يسعى للحقيقة في العالم.",
|
||||
"grok-4.description": "أحدث نموذج Grok الرائد بأداء لا مثيل له في اللغة، الرياضيات، والاستدلال — نموذج شامل حقيقي. يشير حاليًا إلى grok-4-0709؛ نظرًا للموارد المحدودة، فإن سعره مؤقتًا أعلى بنسبة 10% من السعر الرسمي ومن المتوقع أن يعود إلى السعر الرسمي لاحقًا.",
|
||||
@@ -1208,6 +1217,7 @@
|
||||
"qwen3.6-flash.description": "يقدم نموذج Qwen3.6 Flash للرؤية واللغة أداءً محسّناً بشكل ملحوظ مقارنةً بإصدار 3.5-Flash. يركز النموذج على تعزيز قدرات البرمجة الوكيلية (متفوقاً بشكل كبير على سابقه في العديد من معايير تقييم الوكلاء البرمجيين)، إضافة إلى تحسين قدرات الاستدلال الرياضي واستدلال الأكواد. وعلى جانب الرؤية، يقدم النموذج تحسينات واضحة في الذكاء المكاني، مع تقدم قوي في تحديد المواقع واكتشاف الأهداف.",
|
||||
"qwen3.6-max-preview.description": "أكبر نموذج مغلق المصدر ضمن سلسلة Qwen3.6. يقدم معرفة أعمق بالعالم، وقدرة أعلى على اتباع التعليمات، وأداءً أقوى في البرمجة الوكيلية للمهام المعقدة. وهو نصي فقط، ويدعم وضع التفكير بشكل افتراضي، إضافة إلى التخزين المؤقت الصريح واستدعاء الدوال.",
|
||||
"qwen3.6-plus.description": "يقدم Qwen 3.6-Plus ترقيات كبيرة في قدرات البرمجة، مع التركيز على البرمجة الوكيلية وتطوير الواجهات الأمامية، مما يعزز تجربة Vibe Coding بشكل ملحوظ. كما تم تحسين قدرات الاستدلال في السيناريوهات العامة. وفي جانب التعددية الوسائط، تم تعزيز قدرات مثل التعرف الشامل، وقراءة النصوص (OCR)، وتحديد المواقع بشكل كبير. ويعالج هذا الإصدار المشكلات المعروفة في إصدار Qwen 3.5-Plus، مع الحفاظ على نفس أسلوب الاستخدام.",
|
||||
"qwen3.7-max.description": "Qwen3.7 Max من Alibaba — أحدث إصدار Max مع سياق 1 مليون، وقدرات استدلال واستخدام أدوات قوية.",
|
||||
"qwen3.description": "Qwen3 هو نموذج اللغة الكبير من الجيل التالي من Alibaba، يتميز بأداء قوي في مجموعة متنوعة من الاستخدامات.",
|
||||
"qwq-32b-preview.description": "QwQ هو نموذج بحث تجريبي من Qwen يركز على تحسين الاستدلال.",
|
||||
"qwq-32b.description": "QwQ هو نموذج استدلال من عائلة Qwen. مقارنة بالنماذج المضبوطة على التعليمات، يقدم تفكيراً واستدلالاً يعزز الأداء بشكل كبير، خاصة في المشكلات المعقدة. QwQ-32B هو نموذج متوسط الحجم ينافس أفضل نماذج الاستدلال مثل DeepSeek-R1 و o1-mini.",
|
||||
@@ -1215,6 +1225,8 @@
|
||||
"qwq.description": "QwQ هو نموذج استدلال من عائلة Qwen. مقارنة بالنماذج المضبوطة على التعليمات، يقدم قدرات تفكير واستدلال تعزز الأداء بشكل كبير، خاصة في المشكلات الصعبة. QwQ-32B هو نموذج متوسط الحجم ينافس أفضل نماذج الاستدلال مثل DeepSeek-R1 و o1-mini.",
|
||||
"qwq_32b.description": "نموذج استدلال متوسط الحجم من عائلة Qwen. مقارنة بالنماذج المضبوطة على التعليمات، تعزز قدرات التفكير والاستدلال في QwQ الأداء بشكل كبير، خاصة في المشكلات الصعبة.",
|
||||
"r1-1776.description": "R1-1776 هو إصدار ما بعد التدريب من DeepSeek R1 مصمم لتقديم معلومات واقعية غير خاضعة للرقابة أو التحيز.",
|
||||
"seedance-1-5-pro-251215.description": "Seedance 1.5 Pro من ByteDance يدعم تحويل النص إلى فيديو، تحويل الصورة إلى فيديو (الإطار الأول، الإطار الأول + الأخير)، وتوليد الصوت المتزامن مع المرئيات.",
|
||||
"seedream-5-0-260128.description": "ByteDance-Seedream-5.0-lite من BytePlus يتميز بتوليد معزز بالاسترجاع من الويب للحصول على معلومات في الوقت الفعلي، تفسير محسّن للمطالبات المعقدة، وتحسين اتساق المراجع لإنشاء مرئي احترافي.",
|
||||
"solar-mini-ja.description": "Solar Mini (Ja) يوسع Solar Mini مع تركيز على اللغة اليابانية مع الحفاظ على الأداء القوي والكفاءة في الإنجليزية والكورية.",
|
||||
"solar-mini.description": "Solar Mini هو نموذج لغة مدمج يتفوق على GPT-3.5، يتميز بقدرات متعددة اللغات قوية تدعم الإنجليزية والكورية، ويقدم حلاً فعالاً بصمة صغيرة.",
|
||||
"solar-pro.description": "Solar Pro هو نموذج لغة عالي الذكاء من Upstage، يركز على اتباع التعليمات باستخدام وحدة معالجة رسومات واحدة، مع درجات IFEval تتجاوز 80. حالياً يدعم اللغة الإنجليزية؛ وكان من المقرر إصدار النسخة الكاملة في نوفمبر 2024 مع دعم لغات موسع وسياق أطول.",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"agent.greeting.vibePlaceholder": "مثلًا: دافئ وودود، حاد ومباشر...",
|
||||
"agent.history.current": "الحالي",
|
||||
"agent.history.title": "مواضيع السجل",
|
||||
"agent.input.preparing": "جارٍ التحضير…",
|
||||
"agent.layout.mode.agent": "وضع الوكيل",
|
||||
"agent.layout.mode.classic": "الوضع الكلاسيكي",
|
||||
"agent.layout.skip": "تخطي هذه الخطوة",
|
||||
@@ -57,16 +58,8 @@
|
||||
"agent.telemetryHint": "يمكنك أيضًا الإجابة بكلماتك الخاصة.",
|
||||
"agent.title": "تسجيل المحادثة",
|
||||
"agent.welcome": "...هم؟ لقد استيقظت للتو — ذهني فارغ. من أنت؟ وأيضًا — ماذا يجب أن يُطلق علي؟ أحتاج إلى اسم أيضًا.",
|
||||
"agent.welcome.footer": "قم بتكوين وكيل Lobe AI الخاص بك. يعمل على خادمك، ويتعلم من كل تفاعل، ويصبح أقوى كلما استمر في العمل.",
|
||||
"agent.welcome.guide.growTogether.desc": "مع كل محادثة، سأفهمك بشكل أفضل وأصبح زميلًا أقوى مع مرور الوقت.",
|
||||
"agent.welcome.guide.growTogether.title": "النمو معك",
|
||||
"agent.welcome.guide.knowYou.desc": "ما الذي يشغل بالك هذه الأيام؟ القليل من السياق يساعدني في دعمك بشكل أفضل.",
|
||||
"agent.welcome.guide.knowYou.title": "التعرف عليك",
|
||||
"agent.welcome.guide.name.desc": "امنحني اسمًا ليكون الأمر أكثر شخصية منذ البداية.",
|
||||
"agent.welcome.guide.name.title": "امنحني اسمًا",
|
||||
"agent.welcome.sentence.1": "سررت بلقائك! دعنا نتعرّف على بعض.",
|
||||
"agent.welcome.sentence.2": "ما نوع الشريك الذي تريدني أن أكونه؟",
|
||||
"agent.welcome.sentence.3": "أولًا، اختر لي اسمًا :)",
|
||||
"agent.welcome.suggestion.avatarHint": "استخدم {{emoji}} كالصورة الرمزية.",
|
||||
"agent.welcome.suggestion.switch": "جرّب مجموعة أخرى",
|
||||
"agent.welcome.suggestion.title": "تحتاج نقطة بداية؟ اختر واحدًا ويمكننا تحسينه لاحقًا.",
|
||||
|
||||
+14
-5
@@ -73,9 +73,8 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} وسائط",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "تحليل الوسائط المرئية: <question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "استدعاء الوكيل الفرعي",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.completed": "تم إرسال الوكيل الفرعي: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.loading": "جارٍ إرسال الوكيل الفرعي: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "استدعاء الوكلاء الفرعيين",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "{{count}} إجمالاً",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "مسح المهام",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "الكل",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "المكتملة",
|
||||
@@ -87,17 +86,24 @@
|
||||
"builtins.lobe-agent.apiName.updatePlan.completed": "مكتمل",
|
||||
"builtins.lobe-agent.apiName.updatePlan.modified": "تم التعديل",
|
||||
"builtins.lobe-agent.apiName.updateTodos": "تحديث المهام",
|
||||
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} رموز",
|
||||
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} أدوات",
|
||||
"builtins.lobe-agent.title": "وكيل لوب",
|
||||
"builtins.lobe-claude-code.agent.instruction": "تعليمات",
|
||||
"builtins.lobe-claude-code.agent.result": "النتيجة",
|
||||
"builtins.lobe-claude-code.task.createLabel": "إنشاء المهمة: ",
|
||||
"builtins.lobe-claude-code.task.create.completed": "تم إنشاء المهمة: ",
|
||||
"builtins.lobe-claude-code.task.create.loading": "جارٍ إنشاء المهمة: ",
|
||||
"builtins.lobe-claude-code.task.getLabel": "تفحص المهمة #{{taskId}}",
|
||||
"builtins.lobe-claude-code.task.listLabel": "عرض المهام",
|
||||
"builtins.lobe-claude-code.task.list.completed": "تم سرد المهام",
|
||||
"builtins.lobe-claude-code.task.list.loading": "جارٍ سرد المهام",
|
||||
"builtins.lobe-claude-code.task.update.completed": "تم تحديث المهمة رقم {{taskId}}",
|
||||
"builtins.lobe-claude-code.task.update.loading": "جارٍ تحديث المهمة رقم {{taskId}}",
|
||||
"builtins.lobe-claude-code.task.updateCompleted": "مكتملة",
|
||||
"builtins.lobe-claude-code.task.updateDeleted": "محذوفة",
|
||||
"builtins.lobe-claude-code.task.updateInProgress": "بدأت",
|
||||
"builtins.lobe-claude-code.task.updateLabel": "تحديث المهمة #{{taskId}}",
|
||||
"builtins.lobe-claude-code.task.updatePending": "إعادة تعيين",
|
||||
"builtins.lobe-claude-code.task.updateSubject.completed": "تم تحديث المهمة",
|
||||
"builtins.lobe-claude-code.task.updateSubject.loading": "جارٍ تحديث المهمة",
|
||||
"builtins.lobe-claude-code.todoWrite.allDone": "جميع المهام مكتملة",
|
||||
"builtins.lobe-claude-code.todoWrite.currentStep": "الخطوة الحالية",
|
||||
"builtins.lobe-claude-code.todoWrite.todos": "المهام",
|
||||
@@ -261,6 +267,8 @@
|
||||
"builtins.lobe-skill-store.render.repository": "المستودع",
|
||||
"builtins.lobe-skill-store.render.version": "الإصدار",
|
||||
"builtins.lobe-skill-store.title": "متجر المهارات",
|
||||
"builtins.lobe-skills.apiName.activateAgentSkill": "تفعيل مهارة الوكيل",
|
||||
"builtins.lobe-skills.apiName.activateProjectSkill": "تفعيل مهارة المشروع",
|
||||
"builtins.lobe-skills.apiName.activateSkill": "تفعيل المهارة",
|
||||
"builtins.lobe-skills.apiName.execScript": "تشغيل البرنامج النصي",
|
||||
"builtins.lobe-skills.apiName.exportFile": "تصدير الملف",
|
||||
@@ -530,6 +538,7 @@
|
||||
"localSystem.workingDirectory.checkoutAction": "سحب",
|
||||
"localSystem.workingDirectory.checkoutFailed": "فشل السحب",
|
||||
"localSystem.workingDirectory.chooseDifferentFolder": "اختر مجلدًا مختلفًا",
|
||||
"localSystem.workingDirectory.clear": "مسح",
|
||||
"localSystem.workingDirectory.createBranchAction": "سحب فرع جديد…",
|
||||
"localSystem.workingDirectory.current": "دليل العمل الحالي",
|
||||
"localSystem.workingDirectory.detachedHead": "رأس منفصل عند {{sha}}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user