Compare commits

...

40 Commits

Author SHA1 Message Date
ONLY-yours a823410bba 🐛 fix(gateway): guard against stale operation after token refresh
Re-check the topic's running operationId after the async token-refresh
await. A newer executeGatewayAgent call may have taken over for the
same topic during that wait, which would cause two concurrent streams.
Bail early if the operationId no longer matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:38:30 +08:00
ONLY-yours fbda59ff28 🐛 fix(agent-builder): explicitly sync editing agent ID to chatStore
The Agent Builder reads the wrong agent's context because
`getChatStoreState().activeAgentId` — which the chat service uses to
build `agentBuilderContext` — can drift from the agent currently open in
the profile editor under certain timing conditions (SWR cache hits,
navigation order, React effect scheduling).

Fix: `AgentBuilderProvider` now accepts an `editingAgentId` prop and
writes it to `chatStore.activeAgentId` in a `useEffect`. This makes
the data flow explicit instead of relying on `AgentIdSync` alone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:12:52 +08:00
Arvin Xu e4d5017e76 feat(device): add recent directory management with drag-to-reorder (#15351)
Add a full-width "Add directory" button to pick a folder via the native
picker, make the recent directories list reorderable via SortableList, and
drop the Save button so all device edits (name, default cwd, recent dirs)
persist immediately.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:22:34 +08:00
Zhijie He 27121a6f1a 💄 style: add step-3.7-flash support (#15317)
* style: add step-3.7-flash support

* chore: support step-3.5 reasoning effort
2026-05-31 17:02:32 +08:00
Arvin Xu 373b5e90b2 style(device): run remote CC on a configured device (#15343)
*  feat(device): run remote CC on a configured device with cwd + device context

Make `claude-code`/`codex` dispatched to an `lh connect` device (executionTarget
='device') run in the user's configured directory with a device-appropriate
system context, instead of inheriting the cloud-sandbox setup.

3a — server cwd passthrough:
- resolve the run cwd in the useDevice branch: topic-level workingDirectory
  override > the bound device's `defaultCwd` (read from DB via DeviceModel; the
  gateway only knows live connections, not the user-owned cwd), and pass it to
  dispatchAgentRun.

3b — device-specific systemContext, end to end:
- new `buildRemoteDeviceHeteroContext` — strips the cloud-sandbox boilerplate
  (ephemeral /workspace, pre-cloned repos, commit-or-lose warnings) that would
  mislead an agent on the user's own persistent machine; keeps agent static
  context + resumed conversation history + a minimal cwd note.
- thread `systemContext` through the contract: AgentRunRequestMessage,
  GatewayHttpClient.dispatchAgentRun, deviceProxy.dispatchAgentRun.
- desktop: spawnLhHeteroExec now injects systemContext as the first text block
  of a content-block array on stdin (mirrors spawnHeteroSandbox); previously it
  wrote only the bare prompt, so any context was silently dropped.

The gateway relays unknown fields transparently (`...runParams`), so no gateway
change is needed.

Tests: buildRemoteDeviceHeteroContext unit (6) + GatewayConnectionCtr forwards
cwd/systemContext. type-check clean; existing device/desktop/pkg suites green.

Part of LOBE-9579 (Step 3a/3b). Old ephemeral boundDeviceId migration (3d) and
the web cwd picker (3c) are out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): optimistic device cwd persistence (defaultCwd + recentCwds)

Foundation for the device-scoped cwd picker (executionTarget=device): persist a
working-directory pick to the bound device's registry record so the server's
hetero dispatch (which reads device.defaultCwd) stays in sync and the picker can
offer recent dirs.

- nextRecentCwds: pure most-recent-first / dedupe / cap-20 list builder (the
  server stores recentCwds verbatim, so the client owns this) — unit tested.
- useUpdateDeviceCwd: optimistic `device.updateDevice` — patches the listDevices
  cache in onMutate for instant UI, invalidates onSettled to re-sync truth (self-
  corrects a failed write without manual rollback).

Not yet wired into a picker — the target=device recentCwds-list + manual-input
picker mode that consumes this is the next step.

Part of LOBE-9579 (Step 3c, data layer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): gate send on bound-device online for device-targeted hetero

Extend the pre-send device guard from remote-only agents (openclaw / hermes) to
any hetero agent whose run dispatches to a device — i.e. claude-code / codex with
executionTarget='device'. If the bound device is offline (or none is bound), the
send button is disabled and a guard alert is shown, instead of letting the run
fail at dispatch time.

- new selector currentAgentExecutionTarget
- isDeviceExecution = remote-typed OR executionTarget==='device'; drives the
  guard's enabled flag, the blocked state, and the alert.
- device execution no longer requires cloud credentials (it doesn't use the
  cloud sandbox), so the cloud-not-configured gate now exempts it.

The guard hook already handled non-remote types (online check only, no platform
capability probe), so no hook change is needed.

Part of LOBE-9579 (Step 3, device online guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(tool-render): flatten nested-background tool renders into single-layer surfaces

Remove the card-in-card look across builtin tool renders by dropping the outer
colorFillQuaternary container fill (the framework tool card already provides the
surface) and keeping at most one delineated inner box.

- claude-code AskUserQuestion: rebuilt as a flat Question / divider / Selected
  layout; add i18n keys (question/selected/reply/noAnswer)
- claude-code Skill, local-system WriteFile: flat container + single previewBox
- agent-management CreateAgent/GetAgentDetail: flat container, keep outlined
  systemRole block
- web-onboarding SaveUserQuestion: drop the redundant inner value box

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(builtin-tool): document single-layer surface rule for tool renders

Add §0.8 "stay single-layer — don't nest filled cards": the framework tool
card is already the surface, so the Render's outer wrapper carries no fill and
at most one filled box delineates real content. Cross-link from §2 Render rules
and the diagnostic table, and note the deliberate outlined-panel exception
(TodoWrite / Task).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(builtin-tool): consolidate fragmented UI shared-style rules

The §0 shared rules had drifted into 8 one-line subsections (0.1–0.8). Fold the
five mechanical "every file looks like this" rules ('use client', memo +
displayName, BuiltinXProps generics, t('plugin'), store reads) into a single
annotated component skeleton (0.1), merge the two styling rules into 0.2, and
keep the single-layer surface rule as 0.3. Update the §0.8 cross-references in
§2 and the diagnostic table to §0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(builtin-tool): split UI reference into a per-topic ui/ folder

The single 770-line ui.md had grown unwieldy. Break it into references/ui/
with a README index and one file per topic: principles, shared-rules, the six
surfaces (inspector/render/placeholder/streaming/intervention/portal),
composition, and diagnostics. Convert in-doc §-number cross-refs to cross-file
links and repoint SKILL.md + tool-design.md at the new folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): device-scoped cwd picker for executionTarget=device

When a hetero run is bound to a remote device, the device's filesystem isn't
browsable from here, so the local folder picker doesn't apply. Add
DeviceWorkingDirectory — a self-contained bar item (chip + popover) sourced from
the bound device's recentCwds plus a manual path input.

- Picking/typing a cwd pins it to the active topic (override) and persists it to
  the device via useUpdateDeviceCwd (optimistic defaultCwd + recentCwds), which
  is exactly what the server's device-dispatch branch reads back.
- Same per-cwd CC-session-reset confirm as the local picker.
- WorkingDirectoryBar routes to it when executionTarget==='device' (both web —
  replacing CloudRepoSwitcher — and desktop, replacing the local picker +
  GitStatus); local/sandbox paths are unchanged.
- Reuses existing i18n keys (recent / noRecent / placeholder).

Completes LOBE-9579 Step 3c. type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(tool-render): flatten ToolResultCard + de-duplicate Read header

ToolResultCard was the card-in-card shared component (colorFillQuaternary
wrapper around a colorBgContainer box) behind CC Read/Grep/Glob/Write/WebSearch/
WebFetch. Flatten it to single-layer (flat wrapper, one colorFillTertiary
content box) so all consumers stop stacking fills inside the framework tool card.

CC Read header showed the filename strong-label and then dumped the full
absolute path whose tail repeated the same basename, end-truncated so the
meaningful suffix was hidden. Show the directory only (filename stays the
strong label), and drop the conflicting word-break so the dir ellipsizes on one
line.

Note ToolResultCard in the skill as the canonical single-layer header+content
card to reuse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): mark current device, native cwd browse, fix edit Save button

Settings → Devices page polish:
- Badge the row for the machine you're on ("This device"), resolved from the
  desktop gateway's own deviceId (web has no current device → no badge).
- For the current device, the edit modal's Default working directory gains a
  native folder picker (electronSystemService.selectFolder) next to the manual
  input — you can't browse a remote device's filesystem, only your own.
- Edit modal footer now uses real Button components (Cancel + primary Save)
  instead of the base-ui Modal's default okText, which rendered with the wrong
  (non-primary) color.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): neutral current-device tag + per-channel tags

- "This device" badge uses the default neutral tag instead of success green.
- Show each live connection's channel as a small tag (desktop / cli) so a
  multi-channel device's connections are individually legible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(devtools): add API jump-list column to the render gallery

The render gallery stacked all of a toolset's API previews in one scroll column
(67 for Claude Code), making any specific render slow to find. Add a middle
column listing the toolset's apiNames: clicking scrolls the matching preview
card into view (landing below the sticky lifecycle bar via scroll-margin), and
an rAF-throttled scrollspy highlights the API the reader is on and keeps that
item visible in the list. A leading dot marks APIs that ship a Render. The
content area now owns its own scroll so the list stays pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(devtools): make the API jump-list readable + deep-linkable

The jump-list was a wall of identical `mcp__claude_ai_Linear__…` truncations and
the active item barely differed from hover. Show just the trailing action for
mcp__ tools (full id in a title tooltip + the preview card header), render names
in monospace, and give the active item a primary left-accent so it reads as
selected. Clicking now pins a `#api-<name>` hash (deep-linkable / shareable) and
loading a hashed URL jumps straight to that card below the sticky bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(devtools): add an Aggregate message-flow preview tab

The gallery only previewed each API in isolation. Add a View tab (By API /
Aggregate): Aggregate stitches every render-bearing API into one compact
content + tool message flow, so renders can be judged in conversational context
across any lifecycle mode. Inspector-only MCP tools are dropped to keep the
thread about the renders, and the API jump-list column hides in this view.

Extract the Inspector/Body surface rendering out of ToolPreview into shared
ToolInspectorSlot / ToolBodySlot (toolSurfaces.tsx) so both tabs derive props
identically and never drift. View choice persists to localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(devtools): densify API list + keep mcp prefix visible

The earlier "shorten mcp names" change solved the wrong problem and hid the
`mcp__` prefix, so MCP tools no longer read as MCP. The actual complaint was row
height. Restore the full identifier and instead middle-elide it
(`mcp__claude_ai_Li…get_diff`) so both the muted `mcp` namespace and the
distinguishing trailing action stay visible; full id remains in the title
tooltip. Drop row height to a fixed dense 22px (flex-shrink:0 so it scrolls
instead of squishing) to fit far more APIs per screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(devtools): render Aggregate tab through the real Conversation renderer

The hand-rolled MessageList only approximated the chat. Replace it with the
actual shipping renderer: seed a `ConversationProvider` (skipFetch) with fixture
`assistantGroup` messages and map each render-bearing API to a real tool
payload, then render the real `MessageItem` for each. Tool state is driven
purely by the message shape — `result` → success, `result.error` → error,
`intervention.pending` → intervention, unterminated `arguments` JSON →
streaming — so the preview is byte-for-byte what users see in chat. Skips the
virtualized `ChatList` (and its data fetches) by mapping `MessageItem` directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): device detail drawer (channels + recent dirs + config)

Clicking a device row now opens a right-side detail drawer instead of a small
edit modal:
- Connections: render every live connection from the `channels` array, each
  with its channel tag (desktop / cli) + connected-since.
- Name + default working directory (native folder browse on the current
  device); saving a default cwd also seeds the recent list.
- Recent directories: list `recentCwds`, click to reuse, × to remove — this is
  where you can see and manage the recent list (previously not surfaced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(device): record recentCwds on the local device picker

Local-mode runs execute on this machine, but the local working-directory picker
only persisted to a desktop-local recents store — the dir never reached the
device registry, so the settings detail view (and a future device-mode picker)
couldn't see it.

- WorkingDirectory.selectDir now also records the chosen dir into the current
  device's recentCwds (resolved from the gateway's own deviceId).
- useUpdateDeviceCwd gains a { setDefault } option so local mode records
  recentCwds without repointing the device's defaultCwd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🩹 fix(devtools): thread Aggregate preview messages via parentId

Each fixture turn was an orphaned message with no parentId, so the renderer saw
a pile of disconnected messages rather than one conversation. Chain every turn
onto the previous one (`parentId` = prior message id) so they read as a single
linear thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(devtools): seed flat messages so conversation-flow groups the Aggregate

The previous version hand-built `role: 'assistantGroup'` messages, bypassing the
real grouping. Seed the flat DB-shaped messages instead — an `assistant` message
carrying the tool_use plus a linked `role: 'tool'` result message per API — and
let conversation-flow's `parse()` synthesize the assistantGroup exactly as it
does in chat. The consecutive tool turns now collapse into one real workflow
group (one avatar, N content+tool blocks) instead of N hand-rolled groups.
Lifecycle state rides the tool message the same way production carries it
(content/pluginState = success, pluginError = error, pluginIntervention = pending).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 refactor(device): inline master-detail device settings; drop uppercase labels

Per feedback:
- Replace the floating edit Drawer with an inline right-hand detail panel —
  the devices page is now a master-detail layout (device list on the left,
  selected device's detail on the right), like the rest of settings.
- Drop the ALL-CAPS section labels (no more text-transform: uppercase /
  letter-spacing) — labels use natural case + a muted color.

DeviceItem becomes a selectable list row (no own modal); DeviceDetailPanel
renders the detail inline (connections per channel, name, default cwd + browse,
recent dirs). Keyed on deviceId so the form resets on selection change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 refactor(device): detail panel opens on click, not by default

Per feedback — mirror the memory-preferences master-detail pattern:
- No device is selected by default; the right detail panel only renders once a
  row is clicked (clicking the selected row again closes it). Panel has its own
  close (×).
- List flexes to fill when nothing is selected; the detail appears as a right
  column on selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devtools): bind render gallery to viewport height so columns scroll

The page root used height:100%, which only resolves when an ancestor route
provides a bounded height — under mounts that don't, the whole page grew to
content height and the API list never scrolled internally. Bind the root to
100dvh directly and add min-height:0 to the flex chain (main + the API list)
so the scroll container engages regardless of how the route is mounted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(devtools): add WebFetch / WebSearch fixtures so they render

Both APIs had no fixture, so the gallery fell back to schema-sampled args with no
content and the renders drew empty (just the icon). Add fixtures with realistic
args + content: WebFetch (url + prompt + markdown answer), WebSearch (query +
allowed_domains + results), plus their apiList descriptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): render connections straight from device.channels[]

Drop the device.online-based synthetic single-channel fallback — the connection
rows now come purely from the device.channels[] array (one row per live
connection), with offline = empty array.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(hetero): distinguish CC server throttle from user quota limit

A 429 "Server is temporarily limiting requests (not your usage limit)"
was classified as a user rate_limit, rendering the misleading "Claude
Code usage limit reached" reset-time guide. Key the rate_limit vs
overloaded decision on the structured rate_limit_event reset window
(resetsAt / rateLimitType) instead of the HTTP status, so 429/529 with
no quota signal fall through to the overloaded (retry) UX.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(devtools): loosen the API list density

22px rows at 12px overcorrected into a cramped sidebar. Relax to 30px rows,
13px label, a small inter-row gap, and a touch more vertical padding so the
jump-list reads comfortably.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): align connection rows in the list item (drop 30px indent)

The connection rows had a 30px inline-start padding that pushed them right of
the cwd line; align them with the rest of the device info.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(device): move connection status dot to the first line

The online/offline status now sits as a dot next to the device name + badges
(with the connected / last-active time as a tooltip), instead of a separate
third line. Per-channel connection detail still lives in the detail panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 feat(devtools): show the Aggregate preview as "Lobe AI"

The seeded preview conversation resolved its avatar/name through an agentId that
wasn't in the agent store, so every turn fell back to the unresolved-agent
"Unnamed Assistant" / UN avatar. Seed agentMap with a Lobe AI meta
(DEFAULT_INBOX_AVATAR + title) for the devtools agentId, shared via
DEVTOOLS_AGENT_ID / DEVTOOLS_AGENT_META so MessageList's context and the store
seed stay in sync. Restored on unmount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devtools): carry tool result state in BuiltinInspectorProps

The Aggregate preview passes `result.state` to inspectors, matching the
real runtime, but the canonical `result` type omitted `state` — failing
type-check. Add `state?: any` so devtools and runtime agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(device): pin topic cwd and add hetero-tracing toggle

- Prefer the topic's own `metadata.workingDirectory` over the device
  default when dispatching, so an existing topic keeps its pinned cwd
- Add `heteroTracingEnabled` store flag to trace CLI raw streams in
  packaged builds (Help menu checkbox)
- Reorder the connection status dot ahead of badges in DeviceItem

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

*  feat(device): add Help-menu toggle to record hetero-agent CLI traces in production

Packaged builds previously never wrote hetero-agent (CC / Codex) CLI traces,
so production issues couldn't be captured. Add a persisted `heteroTracingEnabled`
toggle in the Help menu (all 3 platforms) plus an "Open HeteroAgent Directory"
entry. Dev still always traces to `cwd/.heerogeneous-tracing`; packaged builds,
when enabled, centralize traces under `<appStoragePath>/heteroAgent/tracing`
(sibling to the existing files cache) via shared dir constants.

Closes LOBE-9828

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(skills): fold stacked-prs guidance into the pr skill

Merge the standalone `stacked-prs` skill into `pr` as a supplementary section
(ordering rule, file placement, git split recipe, dependency verification,
Linear bookkeeping, gotchas) and absorb its triggers into the pr description,
rather than keeping a separate skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devtools): chain RenderGallery previews into one assistantGroup

Unfinished tool states (streaming / loading) now emit a paired tool result
message with `LOADING_FLAT` content instead of none, and every assistant turn
chains onto the previous message's id. The tool_use → tool_result link is what
lets conversation-flow merge the turns into one assistantGroup; without it the
unfinished modes rendered as one orphaned group per tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(device): key hetero trace location off the toggle, not isPackaged

`resolveTraceRootDir` now centralizes traces under
`<appStoragePath>/heteroAgent/tracing` whenever `heteroTracingEnabled` is on,
instead of gating on `isPackaged`. Packaged behavior is unchanged (it only
traces when the toggle is on), and a dev who opts in now also gets the
centralized dir reachable from the Help-menu entry. Plain dev runs keep
writing to `cwd/.heerogeneous-tracing`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): move hetero dir consts to a side-effect-free module

Importing the new `HETERO_AGENT_*` constants from `@/const/dir` dragged that
module's load-time `app.getPath()` / `app.getAppPath()` calls into the menu and
controller import graphs, breaking menu/controller suites whose electron mocks
or partial `@/const/dir` mocks didn't anticipate it. Relocate the pure path
segments to `@/const/heteroAgent` (no electron import) and point the controller
+ all three menu impls there. Also add the now-required `storeManager.get/set`
to the menu test app mocks (the Help-menu tracing checkbox reads it at build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(devtools): refine RenderGallery surfaces and fix local-system fixtures

- flatten the active ApiList item (drop accent bar) and the ToolPreview card shadow
- give the Aggregate thread a white container surface
- hide deprecated lobe-notebook toolset and legacy *Local* aliases from the gallery
- re-key local-system fixtures to current API names + add missing call args
- backfill agent-management call args so inspectors render their argument rows

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(desktop): default global electron mock so import-time app access is safe

`@/const/dir` reads `app.getAppPath()` / `app.getPath()` at module load — fine
in production (app is ready), but it forced every test that transitively imports
it to stub those basics, which is the real root of the recent breakages.

Register a default `electron` mock in the global vitest setup, giving every
suite a ready `app` (paths + readiness) plus light stubs for the common
namespaces. Suites that need specific behavior still declare their own
`vi.mock('electron', …)`, which overrides this per-file. This keeps production
free to use plain value-style path constants instead of lazy getter functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:11:55 +08:00
Arvin Xu 3caa3efb18 feat(device): auto-register desktop & CLI devices with stable machine ID (#15300)
 feat(device): auto-register desktop & CLI devices; send connectionId + channel

App layer — wires desktop and `lh connect` to the device registry and the
connection-routing scheme. Depends on @lobechat/device-identity and the
gateway-client connectionId/channel options (earlier PRs in this stack), plus
the device.register / listDevices endpoints (already on canary).

- desktop derives the stable deviceId on gateway connect (old per-install random
  UUID demoted to the routing `connectionId`), registers via device.register,
  and tags channel `desktop` / `desktop-dev`
- `lh connect` derives + registers before opening the WS (explicit --device-id
  still pins a VM); channel `cli` (env-overridable); connectionId persisted in
  `~/.lobehub/connection-id`
- CLI api client preserves explicit --token connects during registration

Part of LOBE-9572. Closes LOBE-9576 / LOBE-9577.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:35:09 +08:00
Arvin Xu c27b62e10c 💄 style(imessage): wrap BlueBubbles bridge config into a connection card (#15342)
* 💄 style(imessage): wrap BlueBubbles bridge config into a connection card

Regroup the iMessage BlueBubbles bridge settings into a single bordered
card with a clearer top status / middle form / bottom action layout:

- Header shows the connection title + overall test status badge
  (Pending Test / Connected / Failed), with breathing room before the
  form fields.
- Server URL field gains an inline hint box (127.0.0.1 vs LAN IP).
- A full-width bridge service bar at the bottom: running/stopped status
  with the listening address on the left, the primary Enable Bridge
  toggle on the right, and the less-frequent Refresh / Test actions on a
  second row.

Test status is tracked locally and reset on any field edit so the badge
never shows a stale pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(claude-code): fix WebFetch inspector URL truncation and align chip with Bash

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(imessage): use BlueBubbles logo for the bridge status icon

Swap the generic plug glyph for the BlueBubbles app logo so the bridge
service card reads more recognizably. The icon sits in a white rounded
tile; the running state is already conveyed by the Running tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(imessage): move BlueBubbles logo to the connection header

Promote the BlueBubbles logo next to the section title so it identifies
the integration up front, and drop the icon tile from the bridge service
row — the running/stopped state reads fine as text + status tag there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(imessage): enlarge bridge logo, fix disabled status, clarify relay copy

- Logo now spans both header lines (44px) for a stronger section anchor.
- Bridge status reflects this config's Enable toggle (running && enabled),
  so flipping it off no longer keeps showing "Running" until the next save.
- Service descriptions now explain the bridge relays iMessage messages to
  LobeHub, so the local server's purpose is clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(imessage): make Electron main the SoT for the bridge status

Read the bridge status via SWR (revalidates on focus + after each mutation)
instead of caching a divergent copy, and drop the manual Refresh button.

- `enabled` / `running` / `serverUrl` / `passwordSet` now derive from the
  main-process status, not local form state.
- Enable is a write-through toggle: it auto-persists the current Server URL +
  password and starts/stops the bridge immediately (option B), surfacing real
  connection errors on enable.
- Test is ungated from enable — it pings BlueBubbles directly and only needs a
  Server URL + password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:31:40 +08:00
Arvin Xu a9d74bb143 chore: remove LOBE-XXX auto-link pattern from WelcomeText (#15336)
Remove the LOBE-\d+ regex from AUTO_LINK_PATTERNS since LOBE issue references
should not appear in an open-source codebase. Only GitHub issue references (#\d+)
remain auto-linked.

Co-authored-by: arvinxx <arvinxx@lobehub.com>
2026-05-30 19:26:33 +08:00
Arvin Xu e1fe37933d feat(cli): add lh topic view command (#15340)
* feat(cli): add `lh topic view` command to display topic details and messages

* test(cli): add unit tests for `lh topic view` command

* fix(cli): improve topic view - fix --no-messages bug, add tool calls, threads, pagination

* test(cli): update view tests - fix mock, add tool/thread/pagination cases

* feat(topic): add getTopicDetail trpc procedure for structured topic metadata

* refactor(cli): use getTopicDetail for view command metadata, show full fields

* test(cli): update view tests to use getTopicDetail mock
2026-05-30 19:13:34 +08:00
Innei 1c3e973bab 🐛 fix(library): refresh folder data on slug switch and dedupe breadcrumb fetch (#15335)
Resource Explorer kept showing the previous folder's items when sidebar
hierarchy clicks switched the URL slug. SWR `onSuccess` only fires after
revalidate completes, so cache-hit navigations could not update the
zustand mirror that the Explorer reads from.

- Move SWR data → store sync into a `useEffect` so cache hits also push
  fresh items into `useFileStore` immediately, while keeping the 30s
  deduping window to avoid wasted background revalidations.
- Reuse the Breadcrumb SWR cache in `LibraryHierarchy`: replace
  `tree.navigateTo(slug)` (which fetched the breadcrumb directly) with
  `tree.expandAncestors(ids)`, and let `useFetchFolderBreadcrumb` feed
  the ids so a folder switch no longer issues two parallel
  `document.getFolderBreadcrumb` requests.

Fixes LOBE-4293
2026-05-30 17:27:07 +08:00
Innei 22c264bb77 feat(page-share): add document share flow with business slot stubs (#15309)
*  feat(page-share): add document share flow

*  improve page share probe fallback

* ♻️ refactor(page-share): extract to business slot stubs

* ♻️ refactor(page-share): move shared-page viewer to /share/page/:id

- Drop anonymous handling on /page/🆔 revert middleware allowlist, main
  layout PageShareLayout wrap, and outlet-context probe branch
- Add /share/page/:id route under share tree (parallel to /share/t/:id),
  registered in desktop/desktop-vite/mobile router configs
- New PublishedShell business slot stub (pass-through); cloud provides the
  marketing banner + chrome
- Align SharePopover i18n schema with the topic-share pattern

* 🐛 fix(page-share): provide pageShare router stub procedures for OSS type-check

The /share/page/:id route calls lambdaClient.pageShare.getSharedDocument;
the empty router({}) stub left the OSS standalone type-check unable to
resolve it. Stub now declares all three procedures (getShareSettings,
updateShareSettings, getSharedDocument) with cloud-matching inputs and
throws NOT_FOUND when invoked without the cloud override.
2026-05-29 22:36:51 +08:00
Innei 1736faf3af 📝 docs(spa-routes): document .desktop.{ts,tsx} variant pattern (#15327)
Extend the spa-routes skill so agents catch all `.desktop` colocated
variants under `src/routes/`, not just the desktopRouter pair. Adds a
new "3b. Other .desktop variants" section listing the current known
cases (settings componentMap, agent index, group index), spells out
the drift risk for each, and lists the rules for editing/adding/
removing variant pairs. Also updates the skill description so the
trigger glob covers `componentMap.desktop`, `index.desktop.tsx`, and
`.desktop.tsx variant`.
2026-05-29 17:50:41 +08:00
Innei 6c58af9c84 🐛 fix(desktop): upload .blockmap files to S3 for differential updates (#15326)
The S3 publish action was missing *.blockmap from its upload glob,
causing electron-updater to always fall back to full downloads.
2026-05-29 17:23:19 +08:00
Innei 0139c054a2 ⬆️ chore: update @lobehub/ui to v5.15.5 (#15325)
Bump @lobehub/ui from the pkg.pr.new preview to the released v5.15.5,
and switch the community user list search inputs from antd Input.Search
to @lobehub/ui SearchBar to align height with the status Select.
2026-05-29 17:10:54 +08:00
Arvin Xu 063fa61c49 feat(device): connectionId + channel routing in gateway client & device list (#15322)
*  feat(device): connectionId + channel routing in gateway client & device list

Shared client + server + settings-UI half of decoupling the gateway connection
routing key from the stable deviceId (the gateway DO change lives in the
device-gateway repo).

- GatewayClient gains `connectionId` (per-install routing UUID) + `channel`
  (freeform label) options, both sent on the WS URL; `currentConnectionId` getter
- consume the gateway's device-centric `/api/device/devices` shape: deviceProxy
  maps it to runtime devices + nested channels (tolerant of a legacy flat shape
  via `?? []`); device.listDevices flattens channels; DeviceItem shows the label

Part of LOBE-9572. Closes LOBE-9781.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔥 chore(device): remove unused in-repo apps/device-gateway

`apps/device-gateway` was a stale, non-deployed mirror of the device-gateway
Cloudflare worker (the real one lives in its own repo and already diverged —
it has AdminDO / geo / message-api / the tool-call-timeout refactor this copy
never got, and no CI here deploys this directory). Keeping it around just makes
the in-repo gateway look like it ignores the connectionId/channel this client
now sends. Drop it; the gateway contract is owned by the service repo.

- delete apps/device-gateway/**
- drop its tsconfig `exclude` entry
- retarget the protocol-mirror comment in device-gateway-client to the service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:03:40 +08:00
YuTengjing dc3186a990 🐛 fix: preserve empty agent prompt export (#15316) 2026-05-29 14:06:47 +08:00
YuTengjing 50d7b126c8 🐛 fix: stop transliterating model names in home starter (#15324) 2026-05-29 14:00:01 +08:00
Innei 42487663b9 🐛 fix(scripts): kill dev child processes on parent shutdown (#15246)
Detach next/vite children into their own process group so process.kill(-pid)
reaps the whole tree (npm -> vite, etc.). Forward SIGHUP, escalate to SIGKILL
after a timeout, and add uncaughtException / 'exit' fallbacks to avoid
leaving orphan processes when the dev startup script is killed.
2026-05-29 13:55:14 +08:00
Arvin Xu 94c7fa4d76 chore(device): add @lobechat/device-identity (#15321)
 feat(device): add @lobechat/device-identity (stable machine-derived deviceId)

New shared package: `deriveDeviceId` hashes the OS machine id with the userId
(+ salt) so one machine + one user → one stable, user-scoped deviceId that
survives LobeHub reinstalls. Falls back to a caller-supplied random UUID (flagged
via `identitySource: 'fallback'`) when the machine id is unavailable.

Foundational layer — no consumers yet; desktop/CLI wire it up in a later PR.

Part of LOBE-9572. Closes LOBE-9574.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:28:10 +08:00
LiJian 2461709de4 🐛 fix(desktop): market OAuth expiry triggers wrong re-login modal (#15290)
🐛 fix(desktop): market OAuth expiry no longer triggers LobeHub re-login modal

When sandbox tools (Document Writing, Agent Browser) encountered a
Market OAuth token expiry on desktop, the server threw UNAUTHORIZED
which caused responseMeta to set X-Auth-Required: true, triggering the
LobeHub cloud re-login modal instead of the Market OAuth dialog.

- Add MARKET_AUTH_REQUIRED_MESSAGE sentinel to desktop-bridge
- market.ts uses this message for Market auth TRPCErrors
- responseMeta skips X-Auth-Required for Market auth errors
- MarketAuthProvider on desktop now calls handleUnauthorized() when
  silent token refresh fails, correctly opening the Market OAuth flow

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:54:37 +08:00
lobehubbot 5609b6313b Merge remote-tracking branch 'origin/main' into canary 2026-05-29 01:52:45 +00:00
Arvin Xu 53e13ea3b1 🚀 release: 20260528 (#15302)
# 🚀 LobeHub Release (20260528)

**Release Date:** May 28, 2026  
**Since v2.2.0:** 220 merged PRs · 15 contributors

> This cycle brings heterogeneous "platform agents" you can dispatch to
local or remote devices, a rebuilt onboarding flow, document-centric
chat, and a unified model-runtime error model — with new DeepSeek V4 and
Gemini 3.5 Flash support along the way.

---

##  Highlights

- **More Hetero Agents (OpenClaw / Hermes)** — Create heterogeneous
agents and dispatch them to local or remote devices through the device
gateway, with an execution-target switcher in the composer and
persistent CLI sessions. (#15065, #15179, #15022)
- **iMessage on Desktop** — New iMessage setup and bridge on desktop,
plus bot attachments across every platform. (#15228, #15227, #15029)
- **Skills in the Composer** — Drag skill chips into chat, trigger
installed skills from the slash menu mid-line, and surface project-level
skills in the homogeneous agent runtime. (#15095, #15061, #15110)
- **New Models** — DeepSeek V4 Flash/Pro and Gemini 3.5 Flash across
providers, with thinking params for structured output and chat cost
estimates. (#15031, #15001, #15051, #14876)
- **Agent Runtime Observability** — OpenTelemetry GenAI semantic
conventions plus per-call generation tracing. (#15123, #15124)

---

## 🤖 Agents & Heterogeneous Runtime

- **Platform agent creation** — OpenClaw/Hermes creation UI, device
guard, and remote dispatch backend. (#15065)
- **Execution-target switcher** — Pick local vs remote execution
directly in the composer; device-selection UX with actionable guidance.
(#15179, #15111)
- **CLI hetero dispatch** — OpenClaw/Hermes dispatch with persistent
sessions and a notify protocol. (#15022)
- **Gateway snapshot as source of truth** — Consume the gateway
`uiMessages` snapshot at step boundaries to keep chat state consistent.
(#15153, #15152)
- **Client sub-agent as a normal tool call** — Simplifies the sub-agent
execution path. (#15281)
- **Hermes agent chain** — Implements the Hermes agent chain logic.
(#15189)
- **Device registry** — TRPC endpoints to register, list, update, and
remove devices. (#15299)
- **Desktop device routing** — Route gateway agent runs through `lh
hetero exec`; restore `userId` in gateway dispatch and gate local-system
by execution target. (#15132, #15232)
- **Agent signals** — Anchor agent-signal receipts to messages and
isolate memory-agent messages into a child thread. (#14969, #14921)

---

## 🚀 Onboarding

- **Simplified first screen** — Defer topic creation to first send.
(#15090)
- **Market Agent Picker** — Added as a classic onboarding step, with
template prefetch. (#14980, #15041)
- **Welcome guidance** — Show agent welcome guidance on first run.
(#15098)
- **Mobile** — Adapt agent onboarding UI and restore Classic-step
padding on mobile. (#15019, #15032)
- **Discovery** — Streamline discovery to a single profession question.
(#14987)
- **Analytics** — Track onboarding step events and create-agent modal
source. (#15133, #15028)

---

## 📄 Documents, Pages & Knowledge

- **Thread chat in preview** — Embed thread chat in the document preview
portal. (#15216)
- **Non-markdown rendering** — Render non-markdown docs as a read-only
highlight. (#15272)
- **Multi-select** — Multi-select delete in the document tree. (#15125)
- **Page-agent streaming** — Preview `initPage` streaming arguments.
(#15039)
- **Per-agent topics** — Per-agent topic management page. (#15207)
- **Server-side category** — Derive document category server-side and
drop frontend predicates. (#15076)

---

## 🧩 Skills & Tools

- **Drag skill chips** — Drag skills into chat input and register
agent-document skills. (#15095)
- **Slash menu** — Installed skills appear in the slash menu with a
mid-line trigger. (#15061)
- **Project skills** — Recognize project-level skills in the homogeneous
agent runtime and surface them regardless of active device. (#15110,
#15177)
- **VFS archiving** — Archive oversized tool results to VFS instead of
truncating. (#15074)
- **@localFile mentions** — Drag folders into chat input as `@localFile`
mentions on desktop. (#15071)

---

## 🧠 Model Runtime & Providers

- **Error spec registry** — Unify error codes into a spec + pattern
registry, split `ProviderBizError` into finer codes, classify Cloud-only
codes via a tier digit, and add `DatabasePersistError`. (#15262, #15286,
#15278, #15279)
- **New models** — DeepSeek V4 Flash/Pro (opencode-go) and Gemini 3.5
Flash; DeepSeek V4 Pro on SiliconCloud. (#15031, #15001, #15017, #15267)
- **Structured output** — Thinking params for structured output, Bedrock
structured generation, and DeepSeek `generateObject` tool choice.
(#15051, #15174, #15054)
- **Cost** — Chat cost estimate support; preserve usage cost in custom
streams. (#14876, #15218)

---

## 💬 Chat & User Experience

- **Follow-up chips** — Extend follow-up chip suggestions to general
chat with scene-specific model config. (#15101, #14797)
- **Input drafts** — Persist unsent input drafts across tab switches and
prevent repeated draft restore. (#14992, #15024)
- **Command menu** — Order topic/message search by recency and promote
inline type filters. (#15094, #14986)
- **Zoom HUD** — Show a zoom-level HUD on Cmd +/− and Cmd 0. (#15294)
- **Copy** — Unescape markdown escapes when copying user messages.
(#15253)

---

## 🖥️ Desktop

- **App Nap fix** — Prevent App Nap from dropping the gateway WebSocket
during display sleep. (#14994)
- **File preview** — Preview `.cjs`/`.mjs`/no-extension files instead of
binary fallback and expand `~` when opening local files. (#15168,
#15284)
- **Cross-platform settings** — Open settings via main-window navigation
on Windows/Linux and restore the route after an update restart. (#15036,
#14922)
- **Token refresh** — Prevent frequent logout from token-refresh
retries. (#14928)

---

## 📊 Observability

- **OTel GenAI** — Instrument Agent Runtime with OpenTelemetry GenAI
semantic conventions. (#15123)
- **Generation tracing** — Per-call `llm_generation_tracing` with a
pre-allocated tracingId and recordFeedback router. (#15124, #15146)
- **Error classification** — Persist `ERROR_CODE_SPECS` classification
on operation errors. (#15273)

---

## 🗃️ Database Migrations

- **Batch migrations** — Topic usage stats, push tokens,
`tasks.editor_data`, and document shares. (#15280)
- **Tracing & eval tables** — Add `llm_generation_tracing` and agent
eval experiment tables. (#15126)

> Self-hosted operators should run the database migration (`pnpm
db:migrate`, or restart with auto-migrate enabled) after upgrading. The
changes are additive and backwards-compatible.

---

## 🔒 Security & Reliability

- **Security:** Remove the `getPlaintextCred` tool to prevent plaintext
credential exposure. (#14998)
- **Security:** Prompt account selection for Google OAuth and add
`prompt=consent` to the OIDC authorization URL to fix missing refresh
tokens. (#15234, #15010)
- **Reliability:** Preserve streamed content across a mid-stream cancel.
(#15173)
- **Reliability:** Bound the Redis command timeout and configure the
Anthropic client timeout. (#15091, #15042)
- **Reliability:** Prevent infinite recursion in the assistant chain.
(#15288)

---

## 👥 Contributors

Huge thanks to **15 contributors** who shipped **220 merged PRs** this
cycle.

@AnotiaWang · @sxjeru · @algojogacor · @hardy-one · @arvinxx · @Innei ·
@tjx666 · @LiJian · @AmAzing129 · @Rdmclin2 · @Neko · @cy948 ·
@CanisMinor · @sudongyuer · @rivertwilight

Plus @lobehubbot and renovate[bot] for maintenance.

---

**Full Changelog**: v2.2.0...release/weekly-20260528
2026-05-29 09:51:56 +08:00
Arvin Xu 21aceb6fee feat(settings): add Devices settings page behind Execution Device Switcher lab (#15315)
Add a "Devices" tab under the General settings group (above Hotkeys) that
lists the user's registered devices. Each device is keyed by deviceId; the
gateway's live WS connections are nested as channel rows under their device
rather than shown as separate devices. The tab is gated behind the
`enableExecutionDeviceSwitcher` lab flag.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 03:02:48 +08:00
YuTengjing 2657b667be feat: export agent profiles as Markdown (#15312) 2026-05-29 02:45:25 +08:00
YuTengjing f042dd352e feat: support Claude Opus 4.8 (#15314) 2026-05-29 02:19:05 +08:00
Innei 15cb3be9cc 🐛 fix(conversation): keep open ActionBar popup when hovering another message (#15303)
* 🐛 fix(conversation): keep open ActionBar popup when hovering another message

When a dropdown inside the singleton message ActionBar is open, hovering
another message used to move the singleton host's DOM and swap the rendered
actionType, which unanchored or unmounted the open popup. Freeze both the
host placement target and the rendered actionType while any descendant has
`data-popup-open`, and re-commit the latest live values once the popup
closes (observed via MutationObserver).

* ♻️ refactor(conversation): freeze message ActionBar subtree while popup is open

Replace the manual committed-state freeze with `@lobehub/ui` `Freeze`:
split the host migration effect + portal render into `ActionBarBody`, and
wrap it with `<Freeze frozen={isPopupOpen}>` in `SingletonMessageActionsBar`.

While any descendant of the host has `data-popup-open`, the inner body is
suspended — its migration effect doesn't run and its render is paused, so
hovering another message no longer DOM-moves the trigger or unmounts the
dropdown's React subtree. Once the popup closes, the body resumes with the
latest live `actionType` / `portalElement` and migrates the host normally.

* Revert "♻️ refactor(conversation): freeze message ActionBar subtree while popup is open"

This reverts commit a8d47bedbb.
2026-05-29 01:31:41 +08:00
Innei 65113ca2a7 feat(kb): extend BM25 search to file-backed documents (#15247)
`searchKnowledgeBaseDocuments` only matched inline `custom/document`
pages, so parsed PDFs and other file-backed documents never surfaced
via the BM25 path — vector search was the sole way to retrieve them.

Run two scoped ParadeDB queries in parallel (inline via
`documents.knowledge_base_id`, file-backed via a `knowledge_base_files`
join) and merge by score in JS. A single OR-ed predicate trips
ParadeDB's `Unsupported query shape` because `paradedb.score()`
requires a conjunctive tantivy scan.

Folder rows are excluded; hits now carry an optional `fileId` so the
agent can read with either `docs_*` or `file_*` ids. The XML formatter
exposes the new attribute downstream.
2026-05-29 01:01:47 +08:00
YuTengjing 2194b23390 🐛 fix: show artifact source while streaming (#15310) 2026-05-29 00:52:13 +08:00
YuTengjing 234c87dd9d 🐛 fix: restore file access URL policy (#15305) 2026-05-28 22:51:32 +08:00
Innei 9945cecf87 feat(portal): editable CodeMirror viewer for LocalFile + Document highlight (#15298)
*  feat(portal): editable CodeMirror viewer for LocalFile + Document highlight

Replace the read-only Highlighter in the LocalFile portal preview and the
Document portal highlight mode with a shared `CodeEditorPane` powered by
`@lobehub/editor/codemirror`. Pane supports inline editing, Cmd/Ctrl+S to
save, lobeTheme tokens, and language-aware syntax highlighting.

LocalFile flow
- Track per-path edit buffers + save action in the chat portal store
  (`dirtyLocalFileContents`, `setLocalFileBuffer`, `saveLocalFile`).
- Show a filled dot on the tab close button when the file is dirty;
  hovering still reveals the X. Closing a dirty tab (via X or the context
  menu's "Close") prompts a confirmation modal via `confirmModal` from
  `@lobehub/ui/base-ui`.
- After save, mutate the SWR cache to the just-saved content before
  clearing the buffer so CodeMirror does not see a stale `value` prop and
  reset the cursor.

Document flow
- For non-markdown documents (`getDocumentRenderMode` → `highlight`),
  render `CodeEditorPane` with a local edit buffer keyed by `documentId`.
- Save calls `documentService.updateDocument({ saveSource: 'manual' })`,
  mutates the document-meta SWR cache, then clears the buffer.

Bump `@lobehub/editor` to ^4.15.0 to pick up the new
`@lobehub/editor/codemirror` subpath export.

* 🐛 fix(portal): force read-only on truncated local file previews

When a file exceeds MAX_PREVIEW_CHARS the preview only holds the first
500k character prefix. Editing and saving against that prefix would
silently overwrite the rest of the file with the truncated content.

Pass `readOnly={truncated}` to the editor, ignore any stale buffer when
truncated, and short-circuit handleSave so Cmd/Ctrl+S is a no-op in this
mode.

* ♻️ refactor(portal): drop MAX_PREVIEW_CHARS truncation for local files

Always pass the full file content to the editor instead of slicing at
500k characters. The truncation existed only to avoid losing data when
saving the previously-Highlighter-rendered prefix, but with full content
available the editor can both display and persist the file safely.

Removes the `truncated` / `truncatedLabel` plumbing, the truncated
banner, and the associated read-only short-circuit in handleSave.

*  test(portal): update document body highlight editor test
2026-05-28 22:42:25 +08:00
Arvin Xu 671b2527b8 feat(device): device registry TRPC (register / list / update / remove) (#15299)
Server-side foundation for the device registry. Builds on the `devices` table
(already on canary) so devices persist beyond the gateway's in-memory WS
sessions and stay visible/bindable while offline.

- new DeviceModel: register upserts on (userId, deviceId) and only refreshes
  machine-reported fields + lastSeenAt, so user-owned friendlyName / defaultCwd
  / recentCwds survive re-registration
- device.* router gains register / updateDevice / removeDevice (DB row only, no
  OIDC token revocation); listDevices is rewritten as a DB ∪ online union so
  offline devices stay listed and not-yet-registered online devices surface as
  transient entries
- HeteroDeviceSwitcher adapts to the richer listDevices shape (null-safe
  platform, prefers friendlyName)

Desktop / CLI auto-registration ships in a follow-up PR that depends on this.

Part of LOBE-9572. Closes LOBE-9575.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:51:35 +08:00
Arvin Xu 6d94635631 feat(bot): add iMessage Desktop setup and bridge (#15228)
 feat(bot): add iMessage Desktop bridge with Labs gate

Desktop-side BlueBubbles bridge for the iMessage channel:

- Bridge runtime (ImessageBridgeCtr/Srv) + gateway message_api_request routing;
  chat-adapter-imessage api lists all webhooks instead of the 500-prone url
  filter (first-time save no longer fails).
- iMessage channel UI: desktopDeviceId + webhookSecret are auto-filled/generated
  (not user fields); a single "Save Configuration" persists both the cloud
  provider and the local bridge via a post-save extension point — no separate
  "Save Bridge" button.
- Gated behind the `enableImessage` Labs preference (off → "Coming Soon").
- Group local-testing bot skills into per-channel folders + add iMessage
  bridge/outbound regression scripts.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:44 +08:00
Innei 109545c3b1 feat(desktop): show zoom level HUD on Cmd+/- and Cmd+0 (#15294)
*  feat(desktop): show zoom level HUD on Cmd+/- and Cmd+0

Replace Electron built-in zoomIn/zoomOut/resetZoom menu roles with custom
handlers backed by a new ZoomService, which clamps the zoom level to
[-3, +3] and broadcasts zoom:changed to the renderer. The renderer mounts
a macOS-style frosted HUD that fades in for 1.5s after each zoom change
so users can see the resulting percentage and confirm when they're back
to 100%.

* ⌨️ fix(desktop): preserve plus zoom shortcut
2026-05-28 21:24:56 +08:00
Arvin Xu 47daf09be1 Revert "🐛 fix: resolve file access urls via file service (#15295)"
This reverts commit 41172a6740.
2026-05-28 20:51:35 +08:00
YuTengjing 41172a6740 🐛 fix: resolve file access urls via file service (#15295) 2026-05-28 20:26:46 +08:00
Arvin Xu caa7905be2 🔨 feat(db): batch topic usage stats, push tokens, tasks editor_data & document shares migrations (#15280)
* 🔨 feat(db): batch topic usage stats, push tokens, tasks editor_data & document shares

Bundle four independent schema changes onto one migration branch:

- 0104 topics: add usage/cost aggregate columns (total_cost, token totals,
  cost/usage jsonb, model, provider) + model/provider indexes
- 0105 push_tokens: new table for Expo push notification tokens
- 0106 tasks: add editor_data jsonb column
- 0107 document_shares: new table for document share flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🔨 chore(db): combine batch schema changes into a single migration

Squash the four sequential migrations (0104-0107) into one 0104 SQL file
containing all DDL: topic usage/cost columns, push_tokens table,
tasks.editor_data column, and document_shares table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 🔨 chore(db): make push_tokens unique constraint device-only

Drop the userId prefix from the push_tokens unique index — one row per
device, reassigned to the new user on switch (upsert by deviceId).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

*  feat(db): add user_connectors and user_connector_tools schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(db): add user_connectors and user_connector_tools schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ♻️ refactor(db): merge connectorTool schema into connector.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  revert(db): restore push_tokens unique constraint to (userId, deviceId)

This reverts commit addf14c2a6 (device-only unique index).

The device-only index conflicts with #15186's pushToken upsert, whose
onConflict target is (userId, deviceId). Restore the composite unique
index so the upsert lands consistently with both PRs.

Also re-point 0105 snapshot prevId to the restored 0104 id and carry the
(userId, deviceId) index forward so the migration chain stays consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(db): add devices table and consolidate batch migration into 0104

Add the `devices` identity anchor (surrogate uuid PK + unique(userId, deviceId))
as the stable, reinstall-proof base for binding agent runtime instances per
machine. Fold the prior 0104/0105 migrations and the new table into a single
idempotent 0104 migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

*  test(db): add topic usage/cost columns to topic.create assertions

The batch added 8 nullable topic columns (totalCost/usage/model/...) but
topic.create.test.ts still asserted the pre-batch 19-field shape via toEqual.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ♻️ refactor(db): use uuid primary key for document_shares

Align document_shares.id with the other new batch tables (uuid defaultRandom);
table has no consumers yet so no compat impact. Regenerated 0104 + snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: ONLY-yours <1349021570@qq.com>
2026-05-28 19:36:45 +08:00
Arvin Xu a7f38114d5 ♻️ refactor(bot): slim iMessage setup schema to user-edited fields (#15291)
♻️ refactor(bot): drop iMessage desktopDeviceId + webhookSecret from user schema

These are not user-supplied: the Desktop client fills the device id from the
local gateway and generates the webhook secret on first save. Removing them
from the platform schema keeps the iMessage setup form to the fields the user
actually edits.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:48:16 +08:00
Arvin Xu 1b74566b4c feat(model-runtime): split ProviderBizError into finer codes + reclassify catch-all at write time (#15286)
*  feat(model-runtime): split ProviderBizError into finer codes + reclassify catch-all at write time

Add UpstreamGatewayError (E8010), UpstreamMalformedResponse (E8011), and
UpstreamHttpError (E8012), migrating the matching patterns out of the
ProviderBizError catch-all. Add a refineErrorCode() step (message-pattern match
+ HTTP-status fallback) wired into formatErrorForState so generic ProviderBizError
is reclassified into the correct existing code (rate-limit / quota / network /
service-unavailable / model-not-found) instead of collapsing into one opaque
8xxx bucket. Production sampling showed ~72% of ProviderBizError actually belongs
to existing codes and only ~5% is a true residual.

*  feat(model-runtime): add isFallback flag to mark catch-all error buckets

Add an `isFallback` boolean to ErrorCodeSpec / ChatMessageError, set on the
catch-all codes (ProviderBizError, UpstreamHttpError, AgentRuntimeError,
DatabasePersistError). It flows onto agent_operations.error via the write-path
enrichment so monitoring can track how much volume still lands in fallback
buckets — the signal for where finer codes are still worth carving out.

*  test(model-runtime): add refineErrorCode to @lobechat/model-runtime mocks

formatErrorForState now imports refineErrorCode, so the partial module mocks in
AgentRuntimeService / RuntimeExecutors must expose it or vitest throws on access.

*  test(model-runtime): bump UpstreamGatewayError numericId to 8011 after canary 8010 collision

canary claimed 8010 for ProviderContentPolicyViolation, so the Upstream* codes
shifted to 8011/8012/8013 during rebase; update the refinement test assertion.
2026-05-28 17:02:39 +08:00
LiJian 1024ee961b 🐛 fix(cc-adapter): emit reasoning chunk before text in batch mode (#15289)
In the batch path (CLI / sandbox without --include-partial-messages),
the adapter extracted thinking and text from the complete assistant
block and emitted text first, reasoning second. This reversed order
caused `gatewayEventHandler` to call `startReasoningIfNeeded()` AFTER
text had already been dispatched, making the brain icon appear below
the rendered text content instead of preceding it.

Fix: swap the emission order so reasoning is always emitted before
text in both the main-agent and subagent batch paths, matching Claude's
natural output order (thinking → response) and the streaming delta path.

The desktop driver uses --include-partial-messages (partial deltas
arrive in correct order naturally), so it is unaffected.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:00:54 +08:00
lobehubbot 694a25822f 🔖 chore(release): release version v2.2.0 [skip ci] 2026-05-18 04:43:53 +00:00
617 changed files with 33640 additions and 3212 deletions
+1 -1
View File
@@ -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')}:&nbsp;</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')}:&nbsp;</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, keyvalue 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,
};
```
+74 -33
View File
@@ -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.).
@@ -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
```
@@ -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
@@ -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
```
@@ -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"
@@ -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
```
@@ -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"
@@ -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
```
@@ -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"
@@ -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
```
@@ -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"
@@ -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
```
@@ -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"
+80 -1
View File
@@ -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.
+22 -1
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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'
+20
View File
@@ -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;
+1
View File
@@ -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(),
+40 -2
View File
@@ -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();
}
+142
View File
@@ -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'));
});
});
});
+166
View File
@@ -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.`,
),
);
}
},
);
}
+25 -1
View File
@@ -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);
});
});
+29
View File
@@ -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;
+2
View File
@@ -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:*",
+2
View File
@@ -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,
};
+6
View File
@@ -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`;
+2
View File
@@ -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');
});
});
+25 -3
View File
@@ -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;
};
+23 -3
View File
@@ -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');
});
});
+25 -3
View File
@@ -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);
}
+40
View File
@@ -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);
}
}
}
+8
View File
@@ -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;
-23
View File
@@ -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));
-406
View File
@@ -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 },
);
}
}
}
-96
View File
@@ -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();
});
});
-110
View File
@@ -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;
}
-47
View File
@@ -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;
-136
View File
@@ -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;
-17
View File
@@ -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"]
}
-16
View File
@@ -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
+121
View File
@@ -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']
}
+106
View File
@@ -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.
+106
View File
@@ -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` webhookDesktop 桥接转发到 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 能在本地提供该文件。
+1
View File
@@ -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) |
+11 -10
View File
@@ -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 桥接连接 iMessageLabs |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
## 工作原理
+30
View File
@@ -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
View File
@@ -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",
+3 -1
View File
@@ -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": "تكبير"
}
+4
View File
@@ -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": "تجزئة جماعية",
+2
View File
@@ -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": "يحمّل حزمة مهارات قابلة لإعادة الاستخدام لهذا الطلب.",
+1
View File
@@ -35,6 +35,7 @@
"navigation.recentView": "المشاهدات الأخيرة",
"navigation.resources": "الموارد",
"navigation.settings": "الإعدادات",
"navigation.task": "مهمة",
"navigation.tasks": "المهام",
"navigation.unpin": "إلغاء التثبيت",
"notification.finishChatGeneration": "اكتمل توليد الرسالة بواسطة الذكاء الاصطناعي",
+2
View File
@@ -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": "مكتبة جديدة",
+2 -5
View File
@@ -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": "كتابة"
}
+1
View File
@@ -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": "الحجم",
+8 -2
View File
@@ -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": "المختبرات"
}
+1 -2
View File
@@ -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": "المعاينة غير متوفرة",
+42
View File
@@ -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
View File
@@ -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 مع دعم لغات موسع وسياق أطول.",
+1 -8
View File
@@ -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
View File
@@ -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