mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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>
This commit is contained in:
@@ -23,7 +23,7 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**:
|
||||
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
|
||||
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
|
||||
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui/](references/ui/README.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
|
||||
|
||||
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
|
||||
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui/](ui/README.md).
|
||||
For where files live and how registries work, see [architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
@@ -156,7 +156,7 @@ export const TaskManifest: BuiltinToolManifest = {
|
||||
executors: ['client', 'server'],
|
||||
|
||||
/* Default human intervention policy for all APIs that don't specify one.
|
||||
Pair with an Intervention component (see ui.md). */
|
||||
Pair with an Intervention component (see ui/intervention.md). */
|
||||
humanIntervention: 'never' | 'always' | { /* extended config */ },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
# Tool UI Surfaces
|
||||
|
||||
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
|
||||
|
||||
| Surface | Required? | When the chat shows it | Registered in |
|
||||
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
|
||||
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
|
||||
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
|
||||
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
|
||||
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
|
||||
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
|
||||
|
||||
The two reference tools to read end-to-end:
|
||||
|
||||
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
|
||||
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
|
||||
|
||||
---
|
||||
|
||||
## Tool Render 设计原则(中文草案)
|
||||
|
||||
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
|
||||
|
||||
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
|
||||
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
|
||||
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。
|
||||
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
|
||||
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
|
||||
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
|
||||
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
|
||||
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
|
||||
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
|
||||
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
|
||||
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
|
||||
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
|
||||
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox`、`createStaticStyles` 和 `cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
|
||||
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
|
||||
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
|
||||
|
||||
---
|
||||
|
||||
## 0. Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
|
||||
### 0.1 Use `'use client'` at the top of every component file
|
||||
|
||||
Tool surfaces are leaves in the chat tree and must not block server rendering.
|
||||
|
||||
### 0.2 Prefer `createStaticStyles + cssVar.*`
|
||||
|
||||
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
|
||||
|
||||
```tsx
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
```
|
||||
|
||||
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
|
||||
|
||||
### 0.3 Use `@lobehub/ui`, not raw `antd`
|
||||
|
||||
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
|
||||
|
||||
### 0.4 Always `memo` and set `displayName`
|
||||
|
||||
```tsx
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args /* … */ }) => {
|
||||
/* … */
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
|
||||
|
||||
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
|
||||
|
||||
### 0.6 Pull strings from `t('plugin')`
|
||||
|
||||
```tsx
|
||||
const { t } = useTranslation('plugin');
|
||||
t('builtins.<identifier>.apiName.<api>');
|
||||
```
|
||||
|
||||
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
|
||||
|
||||
### 0.7 Read store state from `@/store/chat`, not props
|
||||
|
||||
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inspector — Header Chip (required)
|
||||
|
||||
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
|
||||
|
||||
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
|
||||
|
||||
### Props (`BuiltinInspectorProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
apiName: string;
|
||||
args: Arguments; // final args (only after the assistant stops streaming)
|
||||
identifier: string;
|
||||
isArgumentsStreaming?: boolean; // args still arriving
|
||||
isLoading?: boolean; // args complete, executor running
|
||||
partialArgs?: Arguments; // partial JSON during streaming
|
||||
pluginState?: State; // executor's `state` after success
|
||||
result?: { content: string | null; error?: any };
|
||||
}
|
||||
```
|
||||
|
||||
### State machine
|
||||
|
||||
| Phase | What's available | What to show |
|
||||
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
|
||||
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
|
||||
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
|
||||
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
|
||||
|
||||
### Canonical example — Search
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const query = args?.query || partialArgs?.query || '';
|
||||
const resultCount = pluginState?.results?.length ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
if (isArgumentsStreaming && !query) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
|
||||
{query && <span className={highlightTextStyles.primary}>{query}</span>}
|
||||
{!isLoading &&
|
||||
!isArgumentsStreaming &&
|
||||
pluginState?.results &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
|
||||
({t('builtins.lobe-web-browsing.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### Inspector rules
|
||||
|
||||
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
|
||||
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
|
||||
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
|
||||
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
|
||||
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
|
||||
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
|
||||
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
|
||||
|
||||
### Inspector registry — `client/Inspector/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import { CreateTaskInspector } from './CreateTask';
|
||||
import { ListTasksInspector } from './ListTasks';
|
||||
/* … */
|
||||
|
||||
export const TaskInspectors: Record<string, BuiltinInspector> = {
|
||||
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
|
||||
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
|
||||
/* one entry per ApiName */
|
||||
};
|
||||
|
||||
export { CreateTaskInspector } from './CreateTask';
|
||||
export { ListTasksInspector } from './ListTasks';
|
||||
/* re-export each */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Render — Rich Result Card (optional)
|
||||
|
||||
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
|
||||
|
||||
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
|
||||
|
||||
### Props (`BuiltinRenderProps<Args, State, Content>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
|
||||
apiName?: string;
|
||||
args: Arguments; // final params from the LLM
|
||||
content: Content; // executor's content string (or parsed)
|
||||
identifier?: string;
|
||||
messageId: string; // for store lookups
|
||||
pluginError?: any; // from BuiltinToolResult.error
|
||||
pluginState?: State; // executor's state
|
||||
toolCallId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Two patterns
|
||||
|
||||
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
|
||||
|
||||
```tsx
|
||||
// client/Render/CrawlSinglePage.tsx
|
||||
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageContent from './PageContent';
|
||||
|
||||
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
|
||||
({ messageId, pluginState, args }) => (
|
||||
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
|
||||
),
|
||||
);
|
||||
export default CrawlSinglePage;
|
||||
```
|
||||
|
||||
**Pattern B — Folder with subcomponents** (web-browsing Search):
|
||||
|
||||
```
|
||||
client/Render/Search/
|
||||
├── index.tsx # composes the subcomponents, handles error states
|
||||
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
|
||||
├── SearchQuery.tsx # editable query header
|
||||
└── SearchResult.tsx # result list
|
||||
```
|
||||
|
||||
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
|
||||
|
||||
### Error handling in Render
|
||||
|
||||
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
|
||||
|
||||
```tsx
|
||||
if (pluginError) {
|
||||
if (pluginError?.type === 'PluginSettingsInvalid') {
|
||||
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
title={pluginError?.message}
|
||||
type="error"
|
||||
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Render rules
|
||||
|
||||
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
|
||||
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
|
||||
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
|
||||
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
### Render registry — `client/Render/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import CreateTaskRender from './CreateTask';
|
||||
import RunTasksRender from './RunTasks';
|
||||
|
||||
export const TaskRenders: Record<string, BuiltinRender> = {
|
||||
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
|
||||
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
|
||||
/* only the APIs with rich result UI — others fall back to text content */
|
||||
};
|
||||
|
||||
export { default as CreateTaskRender } from './CreateTask';
|
||||
export { default as RunTasksRender } from './RunTasks';
|
||||
```
|
||||
|
||||
### Render display control (rare)
|
||||
|
||||
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Placeholder — Skeleton Between Args and Result (optional)
|
||||
|
||||
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
|
||||
|
||||
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
|
||||
|
||||
### Props (`BuiltinPlaceholderProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
|
||||
apiName: string;
|
||||
args?: T;
|
||||
identifier: string;
|
||||
}
|
||||
```
|
||||
|
||||
No `pluginState` — Placeholder lives entirely in the "executing" gap.
|
||||
|
||||
### Canonical example — Search Placeholder
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStyles } from '@/styles';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
query: cx(
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
shinyTextStyles.shinyText,
|
||||
),
|
||||
}));
|
||||
|
||||
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
|
||||
const { query } = args || {};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
|
||||
<Flexbox horizontal align="center" className={styles.query} gap={8}>
|
||||
<Icon icon={SearchIcon} />
|
||||
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
|
||||
</Flexbox>
|
||||
<Skeleton.Block active style={{ height: 20, width: 40 }} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={12}>
|
||||
{[1, 2, 3, 4, 5].map((id) => (
|
||||
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Placeholder rules
|
||||
|
||||
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
|
||||
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
|
||||
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
|
||||
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
|
||||
|
||||
### Placeholder registry — `client/Placeholder/index.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import CrawlMultiPages from './CrawlMultiPages';
|
||||
import CrawlSinglePage from './CrawlSinglePage';
|
||||
import { Search } from './Search';
|
||||
|
||||
export const WebBrowsingPlaceholders = {
|
||||
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
|
||||
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
|
||||
[WebBrowsingApiName.search]: Search,
|
||||
};
|
||||
|
||||
export { CrawlMultiPages, CrawlSinglePage, Search };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Streaming — Live Output During Execution (optional)
|
||||
|
||||
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
|
||||
|
||||
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
|
||||
|
||||
### Props (`BuiltinStreamingProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinStreamingProps<Arguments = any> {
|
||||
apiName: string;
|
||||
args: Arguments;
|
||||
identifier: string;
|
||||
messageId: string; // use to fetch the streaming buffer from store
|
||||
toolCallId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
|
||||
|
||||
### Canonical example — RunCommandStreaming
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface RunCommandParams {
|
||||
command?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
|
||||
const { command } = args || {};
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
animated
|
||||
wrap
|
||||
language="sh"
|
||||
showLanguage={false}
|
||||
style={{ padding: '4px 8px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{command}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
RunCommandStreaming.displayName = 'RunCommandStreaming';
|
||||
```
|
||||
|
||||
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
|
||||
|
||||
```tsx
|
||||
const buffer = useChatStore((state) =>
|
||||
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
|
||||
);
|
||||
```
|
||||
|
||||
### Streaming rules
|
||||
|
||||
- Render `null` until you have something to display (avoids flash).
|
||||
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
|
||||
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
|
||||
|
||||
### Streaming registry — `client/Streaming/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import { RunCommandStreaming } from './RunCommand';
|
||||
import { WriteFileStreaming } from './WriteFile';
|
||||
|
||||
export const LocalSystemStreamings = {
|
||||
[LocalSystemApiName.runCommand]: RunCommandStreaming,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Intervention — Approval / Edit-Before-Run (optional)
|
||||
|
||||
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
|
||||
|
||||
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
|
||||
|
||||
### Props (`BuiltinInterventionProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInterventionProps<Arguments = any> {
|
||||
apiName?: string;
|
||||
args: Arguments;
|
||||
identifier?: string;
|
||||
interactionMode?: 'approval' | 'custom';
|
||||
messageId: string;
|
||||
|
||||
/** Called when the user edits the args; the approve action awaits this. */
|
||||
onArgsChange?: (args: Arguments) => void | Promise<void>;
|
||||
|
||||
/** Called on approve / skip / cancel. */
|
||||
onInteractionAction?: (
|
||||
action:
|
||||
| { type: 'submit'; payload: Record<string, unknown> }
|
||||
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
|
||||
| { type: 'cancel'; payload?: Record<string, unknown> },
|
||||
) => Promise<void>;
|
||||
|
||||
/** Register a callback to flush pending saves before approval. Returns cleanup. */
|
||||
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — RunCommand Intervention
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BuiltinInterventionProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
|
||||
const { description, command, timeout } = args;
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal justify="space-between">
|
||||
{description && <Text>{description}</Text>}
|
||||
{timeout && (
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
timeout: {formatTimeout(timeout)}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{command && (
|
||||
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
|
||||
{command}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
export default RunCommand;
|
||||
```
|
||||
|
||||
### Intervention rules
|
||||
|
||||
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
|
||||
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
|
||||
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
|
||||
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
|
||||
|
||||
### Intervention registry — `client/Intervention/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import EditLocalFile from './EditLocalFile';
|
||||
import RunCommand from './RunCommand';
|
||||
import WriteFile from './WriteFile';
|
||||
/* … */
|
||||
|
||||
export const LocalSystemInterventions = {
|
||||
[LocalSystemApiName.editLocalFile]: EditLocalFile,
|
||||
[LocalSystemApiName.runCommand]: RunCommand,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFile,
|
||||
/* one entry per API that needs approval */
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Portal — Full-Screen Detail View (optional)
|
||||
|
||||
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
|
||||
|
||||
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
|
||||
|
||||
### Props (`BuiltinPortalProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
|
||||
apiName?: string;
|
||||
arguments: Arguments;
|
||||
identifier: string;
|
||||
messageId: string;
|
||||
state: State;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — Web-Browsing Portal
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import PageContent from './PageContent';
|
||||
import PageContents from './PageContents';
|
||||
import Search from './Search';
|
||||
|
||||
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
|
||||
switch (apiName) {
|
||||
case WebBrowsingApiName.search:
|
||||
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
|
||||
|
||||
case WebBrowsingApiName.crawlSinglePage: {
|
||||
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
|
||||
return <PageContent messageId={messageId} result={result} />;
|
||||
}
|
||||
|
||||
case WebBrowsingApiName.crawlMultiPages:
|
||||
return (
|
||||
<PageContents
|
||||
messageId={messageId}
|
||||
results={(state as CrawlPluginState).results}
|
||||
urls={args.urls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export default Portal;
|
||||
```
|
||||
|
||||
### Portal rules
|
||||
|
||||
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
|
||||
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
|
||||
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
|
||||
|
||||
### Portal registry — `packages/builtin-tools/src/portals.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { type BuiltinPortal } from '@lobechat/types';
|
||||
|
||||
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `client/components/` — Shared Subcomponents
|
||||
|
||||
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
|
||||
|
||||
Examples from `web-browsing/src/client/components/`:
|
||||
|
||||
- `CategoryAvatar.tsx` — search category icon
|
||||
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
|
||||
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
|
||||
|
||||
Examples from `local-system/src/client/components/`:
|
||||
|
||||
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
|
||||
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
|
||||
|
||||
### Rules
|
||||
|
||||
- Live under `client/components/`, exported via `client/components/index.ts`.
|
||||
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
|
||||
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
|
||||
|
||||
---
|
||||
|
||||
## 8. `client/index.ts` — Package Public API
|
||||
|
||||
Re-exports everything the registries need plus useful types/manifest:
|
||||
|
||||
```ts
|
||||
// Inspector — required
|
||||
export { TaskInspectors } from './Inspector';
|
||||
|
||||
// Render — only if any API has one
|
||||
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
|
||||
|
||||
// Placeholder / Streaming / Intervention — only if used
|
||||
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
|
||||
export { LocalSystemStreamings } from './Streaming';
|
||||
export { LocalSystemInterventions } from './Intervention';
|
||||
|
||||
// Portal — single export per tool
|
||||
export { default as WebBrowsingPortal } from './Portal';
|
||||
|
||||
// Reusable components if other packages need them
|
||||
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
|
||||
|
||||
// Re-export manifest, identifier, types for convenience
|
||||
export { TaskManifest, TaskIdentifier } from '../manifest';
|
||||
export * from '../types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Diagnostic Quick-Lookup
|
||||
|
||||
| Symptom | Surface to check | | |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
|
||||
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
|
||||
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
|
||||
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
|
||||
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
|
||||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
|
||||
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
|
||||
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
|
||||
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
|
||||
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
|
||||
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Tool UI Surfaces
|
||||
|
||||
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
|
||||
|
||||
| Surface | Required? | When the chat shows it | Registered in |
|
||||
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
|
||||
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
|
||||
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
|
||||
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
|
||||
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
|
||||
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
|
||||
|
||||
The two reference tools to read end-to-end:
|
||||
|
||||
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
|
||||
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
|
||||
|
||||
---
|
||||
|
||||
## Files in this folder
|
||||
|
||||
Read **principles** and **shared-rules** first — they apply to every surface. Then jump to the surface you're building.
|
||||
|
||||
| File | What it covers |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [principles.md](principles.md) | Design principles — when each surface exists and how far to take it |
|
||||
| [shared-rules.md](shared-rules.md) | Cross-surface rules: component skeleton, styling, single-layer surfaces |
|
||||
| [inspector.md](inspector.md) | Inspector — header chip (required) |
|
||||
| [render.md](render.md) | Render — rich result card |
|
||||
| [placeholder.md](placeholder.md) | Placeholder — skeleton between args and result |
|
||||
| [streaming.md](streaming.md) | Streaming — live output during execution |
|
||||
| [intervention.md](intervention.md) | Intervention — approval / edit-before-run |
|
||||
| [portal.md](portal.md) | Portal — full-screen detail view |
|
||||
| [composition.md](composition.md) | Shared subcomponents (`client/components/`) + package public API |
|
||||
| [diagnostics.md](diagnostics.md) | Symptom → surface quick-lookup |
|
||||
@@ -0,0 +1,51 @@
|
||||
# Composition — Shared Components & Package API
|
||||
|
||||
## `client/components/` — Shared Subcomponents
|
||||
|
||||
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
|
||||
|
||||
Examples from `web-browsing/src/client/components/`:
|
||||
|
||||
- `CategoryAvatar.tsx` — search category icon
|
||||
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
|
||||
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
|
||||
|
||||
Examples from `local-system/src/client/components/`:
|
||||
|
||||
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
|
||||
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
|
||||
|
||||
### Rules
|
||||
|
||||
- Live under `client/components/`, exported via `client/components/index.ts`.
|
||||
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
|
||||
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
|
||||
|
||||
---
|
||||
|
||||
## `client/index.ts` — Package Public API
|
||||
|
||||
Re-exports everything the registries need plus useful types/manifest:
|
||||
|
||||
```ts
|
||||
// Inspector — required
|
||||
export { TaskInspectors } from './Inspector';
|
||||
|
||||
// Render — only if any API has one
|
||||
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
|
||||
|
||||
// Placeholder / Streaming / Intervention — only if used
|
||||
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
|
||||
export { LocalSystemStreamings } from './Streaming';
|
||||
export { LocalSystemInterventions } from './Intervention';
|
||||
|
||||
// Portal — single export per tool
|
||||
export { default as WebBrowsingPortal } from './Portal';
|
||||
|
||||
// Reusable components if other packages need them
|
||||
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
|
||||
|
||||
// Re-export manifest, identifier, types for convenience
|
||||
export { TaskManifest, TaskIdentifier } from '../manifest';
|
||||
export * from '../types';
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# Diagnostic Quick-Lookup
|
||||
|
||||
| Symptom | Surface to check |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry |
|
||||
| Header shows the API name but no chips | Inspector missing `args?.X \|\| partialArgs?.X` fallback |
|
||||
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` |
|
||||
| Empty result card under header | Render returned `<div />` instead of `null` when no data |
|
||||
| Render looks "complex" / card-in-card | Filled container (`colorFillQuaternary`) wrapping more filled boxes — flatten to single-layer, see [shared-rules.md](shared-rules.md) |
|
||||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions |
|
||||
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry |
|
||||
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` |
|
||||
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName |
|
||||
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) |
|
||||
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` |
|
||||
@@ -0,0 +1,118 @@
|
||||
# Inspector — Header Chip (required)
|
||||
|
||||
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
|
||||
|
||||
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
|
||||
|
||||
## Props (`BuiltinInspectorProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
apiName: string;
|
||||
args: Arguments; // final args (only after the assistant stops streaming)
|
||||
identifier: string;
|
||||
isArgumentsStreaming?: boolean; // args still arriving
|
||||
isLoading?: boolean; // args complete, executor running
|
||||
partialArgs?: Arguments; // partial JSON during streaming
|
||||
pluginState?: State; // executor's `state` after success
|
||||
result?: { content: string | null; error?: any };
|
||||
}
|
||||
```
|
||||
|
||||
## State machine
|
||||
|
||||
| Phase | What's available | What to show |
|
||||
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
|
||||
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
|
||||
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
|
||||
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
|
||||
|
||||
## Canonical example — Search
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const query = args?.query || partialArgs?.query || '';
|
||||
const resultCount = pluginState?.results?.length ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
if (isArgumentsStreaming && !query) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
|
||||
{query && <span className={highlightTextStyles.primary}>{query}</span>}
|
||||
{!isLoading &&
|
||||
!isArgumentsStreaming &&
|
||||
pluginState?.results &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
|
||||
({t('builtins.lobe-web-browsing.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
## Inspector rules
|
||||
|
||||
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
|
||||
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
|
||||
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
|
||||
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
|
||||
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
|
||||
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
|
||||
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
|
||||
|
||||
## Inspector registry — `client/Inspector/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import { CreateTaskInspector } from './CreateTask';
|
||||
import { ListTasksInspector } from './ListTasks';
|
||||
/* … */
|
||||
|
||||
export const TaskInspectors: Record<string, BuiltinInspector> = {
|
||||
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
|
||||
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
|
||||
/* one entry per ApiName */
|
||||
};
|
||||
|
||||
export { CreateTaskInspector } from './CreateTask';
|
||||
export { ListTasksInspector } from './ListTasks';
|
||||
/* re-export each */
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Intervention — Approval / Edit-Before-Run (optional)
|
||||
|
||||
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
|
||||
|
||||
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
|
||||
|
||||
## Props (`BuiltinInterventionProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInterventionProps<Arguments = any> {
|
||||
apiName?: string;
|
||||
args: Arguments;
|
||||
identifier?: string;
|
||||
interactionMode?: 'approval' | 'custom';
|
||||
messageId: string;
|
||||
|
||||
/** Called when the user edits the args; the approve action awaits this. */
|
||||
onArgsChange?: (args: Arguments) => void | Promise<void>;
|
||||
|
||||
/** Called on approve / skip / cancel. */
|
||||
onInteractionAction?: (
|
||||
action:
|
||||
| { type: 'submit'; payload: Record<string, unknown> }
|
||||
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
|
||||
| { type: 'cancel'; payload?: Record<string, unknown> },
|
||||
) => Promise<void>;
|
||||
|
||||
/** Register a callback to flush pending saves before approval. Returns cleanup. */
|
||||
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Canonical example — RunCommand Intervention
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BuiltinInterventionProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
|
||||
const { description, command, timeout } = args;
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal justify="space-between">
|
||||
{description && <Text>{description}</Text>}
|
||||
{timeout && (
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
timeout: {formatTimeout(timeout)}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{command && (
|
||||
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
|
||||
{command}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
export default RunCommand;
|
||||
```
|
||||
|
||||
## Intervention rules
|
||||
|
||||
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
|
||||
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
|
||||
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
|
||||
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
|
||||
|
||||
## Intervention registry — `client/Intervention/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import EditLocalFile from './EditLocalFile';
|
||||
import RunCommand from './RunCommand';
|
||||
import WriteFile from './WriteFile';
|
||||
/* … */
|
||||
|
||||
export const LocalSystemInterventions = {
|
||||
[LocalSystemApiName.editLocalFile]: EditLocalFile,
|
||||
[LocalSystemApiName.runCommand]: RunCommand,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFile,
|
||||
/* one entry per API that needs approval */
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Placeholder — Skeleton Between Args and Result (optional)
|
||||
|
||||
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
|
||||
|
||||
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
|
||||
|
||||
## Props (`BuiltinPlaceholderProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
|
||||
apiName: string;
|
||||
args?: T;
|
||||
identifier: string;
|
||||
}
|
||||
```
|
||||
|
||||
No `pluginState` — Placeholder lives entirely in the "executing" gap.
|
||||
|
||||
## Canonical example — Search Placeholder
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStyles } from '@/styles';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
query: cx(
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
shinyTextStyles.shinyText,
|
||||
),
|
||||
}));
|
||||
|
||||
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
|
||||
const { query } = args || {};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
|
||||
<Flexbox horizontal align="center" className={styles.query} gap={8}>
|
||||
<Icon icon={SearchIcon} />
|
||||
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
|
||||
</Flexbox>
|
||||
<Skeleton.Block active style={{ height: 20, width: 40 }} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={12}>
|
||||
{[1, 2, 3, 4, 5].map((id) => (
|
||||
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Placeholder rules
|
||||
|
||||
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
|
||||
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
|
||||
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
|
||||
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
|
||||
|
||||
## Placeholder registry — `client/Placeholder/index.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import CrawlMultiPages from './CrawlMultiPages';
|
||||
import CrawlSinglePage from './CrawlSinglePage';
|
||||
import { Search } from './Search';
|
||||
|
||||
export const WebBrowsingPlaceholders = {
|
||||
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
|
||||
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
|
||||
[WebBrowsingApiName.search]: Search,
|
||||
};
|
||||
|
||||
export { CrawlMultiPages, CrawlSinglePage, Search };
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Portal — Full-Screen Detail View (optional)
|
||||
|
||||
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
|
||||
|
||||
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
|
||||
|
||||
## Props (`BuiltinPortalProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
|
||||
apiName?: string;
|
||||
arguments: Arguments;
|
||||
identifier: string;
|
||||
messageId: string;
|
||||
state: State;
|
||||
}
|
||||
```
|
||||
|
||||
## Canonical example — Web-Browsing Portal
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import PageContent from './PageContent';
|
||||
import PageContents from './PageContents';
|
||||
import Search from './Search';
|
||||
|
||||
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
|
||||
switch (apiName) {
|
||||
case WebBrowsingApiName.search:
|
||||
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
|
||||
|
||||
case WebBrowsingApiName.crawlSinglePage: {
|
||||
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
|
||||
return <PageContent messageId={messageId} result={result} />;
|
||||
}
|
||||
|
||||
case WebBrowsingApiName.crawlMultiPages:
|
||||
return (
|
||||
<PageContents
|
||||
messageId={messageId}
|
||||
results={(state as CrawlPluginState).results}
|
||||
urls={args.urls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export default Portal;
|
||||
```
|
||||
|
||||
## Portal rules
|
||||
|
||||
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
|
||||
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
|
||||
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
|
||||
|
||||
## Portal registry — `packages/builtin-tools/src/portals.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { type BuiltinPortal } from '@lobechat/types';
|
||||
|
||||
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tool Render 设计原则(中文草案)
|
||||
|
||||
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
|
||||
|
||||
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
|
||||
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
|
||||
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。
|
||||
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
|
||||
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
|
||||
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
|
||||
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
|
||||
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
|
||||
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
|
||||
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
|
||||
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
|
||||
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
|
||||
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox`、`createStaticStyles` 和 `cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。具体的样式约定见 [shared-rules.md](shared-rules.md)。
|
||||
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
|
||||
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
|
||||
@@ -0,0 +1,101 @@
|
||||
# Render — Rich Result Card (optional)
|
||||
|
||||
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
|
||||
|
||||
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
|
||||
|
||||
## Props (`BuiltinRenderProps<Args, State, Content>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
|
||||
apiName?: string;
|
||||
args: Arguments; // final params from the LLM
|
||||
content: Content; // executor's content string (or parsed)
|
||||
identifier?: string;
|
||||
messageId: string; // for store lookups
|
||||
pluginError?: any; // from BuiltinToolResult.error
|
||||
pluginState?: State; // executor's state
|
||||
toolCallId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Two patterns
|
||||
|
||||
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
|
||||
|
||||
```tsx
|
||||
// client/Render/CrawlSinglePage.tsx
|
||||
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageContent from './PageContent';
|
||||
|
||||
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
|
||||
({ messageId, pluginState, args }) => (
|
||||
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
|
||||
),
|
||||
);
|
||||
export default CrawlSinglePage;
|
||||
```
|
||||
|
||||
**Pattern B — Folder with subcomponents** (web-browsing Search):
|
||||
|
||||
```
|
||||
client/Render/Search/
|
||||
├── index.tsx # composes the subcomponents, handles error states
|
||||
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
|
||||
├── SearchQuery.tsx # editable query header
|
||||
└── SearchResult.tsx # result list
|
||||
```
|
||||
|
||||
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
|
||||
|
||||
## Error handling in Render
|
||||
|
||||
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
|
||||
|
||||
```tsx
|
||||
if (pluginError) {
|
||||
if (pluginError?.type === 'PluginSettingsInvalid') {
|
||||
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
title={pluginError?.message}
|
||||
type="error"
|
||||
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Render rules
|
||||
|
||||
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
|
||||
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
|
||||
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
|
||||
- **Keep the Render single-layer** — the tool card is already your surface, so don't open with your own filled container and then nest more filled boxes inside it. See [shared-rules.md](shared-rules.md) → "Stay single-layer".
|
||||
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
## Render registry — `client/Render/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import CreateTaskRender from './CreateTask';
|
||||
import RunTasksRender from './RunTasks';
|
||||
|
||||
export const TaskRenders: Record<string, BuiltinRender> = {
|
||||
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
|
||||
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
|
||||
/* only the APIs with rich result UI — others fall back to text content */
|
||||
};
|
||||
|
||||
export { default as CreateTaskRender } from './CreateTask';
|
||||
export { default as RunTasksRender } from './RunTasks';
|
||||
```
|
||||
|
||||
## Render display control (rare)
|
||||
|
||||
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
|
||||
## The component skeleton
|
||||
|
||||
Every surface file is the same shape, so internalize it once instead of re-deriving it per rule. The skeleton below bakes in five mechanical conventions — copy it and fill the body:
|
||||
|
||||
```tsx
|
||||
'use client'; // (a) leaves of the chat tree must not block server rendering
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// (b) type with BuiltinXProps<Args, State> — never widen to `any`.
|
||||
// Args = the JSON Schema params, State = the executor's `state` field;
|
||||
// they should match <Name>Params / <Name>State from types.ts.
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, pluginState }) => {
|
||||
const { t } = useTranslation('plugin'); // (c) all strings from the `plugin` namespace
|
||||
|
||||
// (d) cross-cutting state (loading, streaming buffer) comes from the store,
|
||||
// not props — props only carry args/state/messageId.
|
||||
// const buffer = useChatStore((s) => chatToolSelectors.streamingBuffer(messageId)(s));
|
||||
|
||||
return <span>{t('builtins.<identifier>.apiName.search')}</span>;
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector'; // (e) always memo + displayName
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
- **(c)** Default an Inspector to `t('builtins.<identifier>.apiName.<api>')` so the row is non-empty while args stream in.
|
||||
- **(d)** Read the store via Zustand selectors inside the component; see [streaming.md](streaming.md) for the buffer selector.
|
||||
|
||||
## Styling: `createStaticStyles + cssVar.*`, `@lobehub/ui` over `antd`
|
||||
|
||||
Zero-runtime CSS-in-JS — styles compile once and read CSS variables at runtime:
|
||||
|
||||
```tsx
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
```
|
||||
|
||||
- Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
|
||||
- Components come from `@lobehub/ui` (`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton`), not raw `antd`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
- Note: `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. For that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
|
||||
|
||||
## Stay single-layer — don't nest filled cards
|
||||
|
||||
The framework already wraps every Render / Intervention in a tool card, so that card **is** your surface. A Render that opens with its own `background: ${cssVar.colorFillQuaternary}` container is already one card deep; put another filled box inside it (`colorBgContainer` / `colorFillTertiary`) and you get the card-in-card look that reads as "complex" — two or three stacked fills for what is really a flat list of fields.
|
||||
|
||||
- **The outermost wrapper carries no fill.** Use a flat container with only `padding-block: 4px` for breathing room; let the tool card provide the card. (See `Agent/index.tsx`'s `container`.)
|
||||
- **At most one filled box, and only to delineate real content** — a Markdown preview, a diff, a code/result block. Labels, key–value fields, question/answer text, chips: render flat on the surface, separated by spacing or a hairline divider (`height: 1px; background: ${cssVar.colorFillSecondary}`), not by wrapping each in its own box.
|
||||
- **A box on a flat surface needs a visible fill.** Once the outer fill is gone, an inner `colorBgContainer` box can vanish against the tool card (same color). Use `colorFillTertiary` for the one content box so it still reads as delineated.
|
||||
- Don't wrap a single value in a box just to give it padding — that's the redundant-nesting smell (a `detailCard` around a `value` box around one string).
|
||||
|
||||
```tsx
|
||||
// ❌ card-in-card: filled container wrapping a filled preview box
|
||||
container: css`
|
||||
padding: 12px;
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
previewBox: css`
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
|
||||
// ✅ single-layer: flat container, one visible content box
|
||||
container: css`
|
||||
padding-block: 4px;
|
||||
`,
|
||||
previewBox: css`
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
```
|
||||
|
||||
For the common "icon + file/title header, then one content box" shape, reuse `ToolResultCard` from `@lobechat/shared-tool-ui/components` instead of rebuilding it — it's already single-layer (flat wrapper, one `colorFillTertiary` content box) and is what CC `Read` / `Grep` / `Glob` / `Write` / `WebSearch` / `WebFetch` render through.
|
||||
|
||||
The exception is a deliberate **panel** pattern — an `<Block variant="outlined">` with a header bar + list rows (CC `TodoWrite` / `Task`). There the single outlined block is the panel and the header fill is a header bar, not a nested card. One structured panel is fine; stacked decorative fills are not.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Streaming — Live Output During Execution (optional)
|
||||
|
||||
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
|
||||
|
||||
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
|
||||
|
||||
## Props (`BuiltinStreamingProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinStreamingProps<Arguments = any> {
|
||||
apiName: string;
|
||||
args: Arguments;
|
||||
identifier: string;
|
||||
messageId: string; // use to fetch the streaming buffer from store
|
||||
toolCallId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
|
||||
|
||||
## Canonical example — RunCommandStreaming
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface RunCommandParams {
|
||||
command?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
|
||||
const { command } = args || {};
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
animated
|
||||
wrap
|
||||
language="sh"
|
||||
showLanguage={false}
|
||||
style={{ padding: '4px 8px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{command}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
RunCommandStreaming.displayName = 'RunCommandStreaming';
|
||||
```
|
||||
|
||||
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
|
||||
|
||||
```tsx
|
||||
const buffer = useChatStore((state) =>
|
||||
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
|
||||
);
|
||||
```
|
||||
|
||||
## Streaming rules
|
||||
|
||||
- Render `null` until you have something to display (avoids flash).
|
||||
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
|
||||
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
|
||||
|
||||
## Streaming registry — `client/Streaming/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import { RunCommandStreaming } from './RunCommand';
|
||||
import { WriteFileStreaming } from './WriteFile';
|
||||
|
||||
export const LocalSystemStreamings = {
|
||||
[LocalSystemApiName.runCommand]: RunCommandStreaming,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
|
||||
};
|
||||
```
|
||||
@@ -1,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.
|
||||
|
||||
@@ -56,9 +56,11 @@
|
||||
"help.about": "关于",
|
||||
"help.githubRepo": "GitHub 仓库",
|
||||
"help.openConfigDir": "配置目录",
|
||||
"help.openHeteroAgentDir": "打开 HeteroAgent 目录",
|
||||
"help.openLogsDir": "打开日志目录",
|
||||
"help.reportIssue": "反馈问题",
|
||||
"help.title": "帮助",
|
||||
"help.toggleHeteroTracing": "记录 Agent CLI 调试日志",
|
||||
"help.visitWebsite": "打开官网",
|
||||
"history.back": "后退",
|
||||
"history.forward": "前进",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Default global `electron` mock (registered in `setup.ts`).
|
||||
*
|
||||
* Provides a fully-formed `app` (paths + readiness) plus light stubs for the
|
||||
* other commonly-imported namespaces. The point is that modules which touch
|
||||
* electron at import time — notably `@/const/dir`'s eager `app.getAppPath()` /
|
||||
* `app.getPath('userData')` — can be imported from ANY test without each suite
|
||||
* re-stubbing these basics. This keeps production code free to use plain
|
||||
* value-style path constants instead of lazy getter functions.
|
||||
*
|
||||
* Test files that need specific behavior still declare their own
|
||||
* `vi.mock('electron', …)`, which takes precedence per-file over this default.
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const app = {
|
||||
getAppPath: vi.fn(() => '/mock/app'),
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getName: vi.fn(() => 'LobeHub'),
|
||||
getPath: vi.fn((name: string) => `/mock/${name}`),
|
||||
getVersion: vi.fn(() => '0.0.0-test'),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
setName: vi.fn(),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
export const BrowserWindow = Object.assign(vi.fn(), {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
getFocusedWindow: vi.fn(() => null),
|
||||
});
|
||||
|
||||
export const Menu = {
|
||||
buildFromTemplate: vi.fn(() => ({})),
|
||||
setApplicationMenu: vi.fn(),
|
||||
};
|
||||
|
||||
export const ipcMain = { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() };
|
||||
|
||||
export const shell = {
|
||||
openExternal: vi.fn(() => Promise.resolve()),
|
||||
openPath: vi.fn(() => Promise.resolve('')),
|
||||
};
|
||||
|
||||
export const dialog = { showMessageBox: vi.fn(), showOpenDialog: vi.fn() };
|
||||
|
||||
export const nativeTheme = { on: vi.fn(), shouldUseDarkColors: false, themeSource: 'system' };
|
||||
|
||||
export const protocol = { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() };
|
||||
|
||||
export const clipboard = { readText: vi.fn(() => ''), writeText: vi.fn() };
|
||||
|
||||
export const nativeImage = { createEmpty: vi.fn(), createFromPath: vi.fn() };
|
||||
|
||||
export default {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
protocol,
|
||||
shell,
|
||||
};
|
||||
@@ -5,3 +5,9 @@ import { vi } from 'vitest';
|
||||
|
||||
// Mock node-mac-permissions before any imports
|
||||
vi.mock('node-mac-permissions', () => import('./node-mac-permissions'));
|
||||
|
||||
// Default electron mock: gives every suite a ready `app` (paths + readiness)
|
||||
// so modules with import-time electron access (e.g. `@/const/dir`) load safely
|
||||
// without per-suite stubbing. A test's own `vi.mock('electron', …)` overrides
|
||||
// this per-file.
|
||||
vi.mock('electron', () => import('./electron'));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Heterogeneous-agent (CC / Codex) working-directory segment names, relative to
|
||||
* `appStoragePath`. Kept in this side-effect-free module (no electron import)
|
||||
* so lightweight importers — the menu impls, the controller — get a single
|
||||
* source of truth without dragging in `@/const/dir`'s load-time `app.getPath`
|
||||
* calls.
|
||||
*
|
||||
* - `<HETERO_AGENT_DIR>/files` — downloaded-file cache
|
||||
* - `<HETERO_AGENT_DIR>/tracing` — CLI trace sessions (packaged / opted-in)
|
||||
*/
|
||||
export const HETERO_AGENT_DIR = 'heteroAgent';
|
||||
export const HETERO_AGENT_FILES_DIR = `${HETERO_AGENT_DIR}/files`;
|
||||
export const HETERO_AGENT_TRACING_DIR = `${HETERO_AGENT_DIR}/tracing`;
|
||||
@@ -34,6 +34,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
gatewayDeviceName: '',
|
||||
gatewayEnabled: true,
|
||||
gatewayUrl: 'https://device-gateway.lobehub.com',
|
||||
heteroTracingEnabled: false,
|
||||
imessageBridgeConfigs: [],
|
||||
locale: 'auto',
|
||||
localFileWorkspaceRoots: [],
|
||||
|
||||
@@ -206,6 +206,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
prompt: request.prompt,
|
||||
resumeSessionId: request.resumeSessionId,
|
||||
serverUrl,
|
||||
systemContext: request.systemContext,
|
||||
topicId: request.topicId,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -95,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,
|
||||
@@ -103,6 +104,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
prompt,
|
||||
topicId: 'topic-1',
|
||||
type: 'agent_run_request',
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -733,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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -108,6 +108,10 @@ const createMockApp = () => {
|
||||
updaterManager: {
|
||||
checkForUpdates: vi.fn(),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
} as unknown as App;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -214,6 +217,25 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
label: t('help.githubRepo'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR);
|
||||
console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`);
|
||||
shell.openPath(heteroAgentPath).catch((err) => {
|
||||
console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err);
|
||||
});
|
||||
},
|
||||
label: t('help.openHeteroAgentDir'),
|
||||
},
|
||||
{
|
||||
checked: this.app.storeManager.get('heteroTracingEnabled', false),
|
||||
click: (item) => {
|
||||
this.app.storeManager.set('heteroTracingEnabled', item.checked);
|
||||
},
|
||||
label: t('help.toggleHeteroTracing'),
|
||||
type: 'checkbox',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
const commonT = this.app.i18n.ns('common');
|
||||
|
||||
@@ -94,7 +94,9 @@ const createMockApp = () => {
|
||||
rebuildAppMenu: vi.fn(),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(),
|
||||
openInEditor: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
} as unknown as App;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
import NotificationCtr from '@/controllers/NotificationCtr';
|
||||
import SystemController from '@/controllers/SystemCtr';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -19,6 +19,12 @@ 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[];
|
||||
|
||||
@@ -271,6 +271,11 @@
|
||||
"devices.actions.edit": "Edit",
|
||||
"devices.actions.remove": "Remove",
|
||||
"devices.channel.connected": "Connected {{time}}",
|
||||
"devices.currentBadge": "This device",
|
||||
"devices.detail.connections": "Connections",
|
||||
"devices.detail.noRecent": "No recent directories",
|
||||
"devices.detail.recentDirs": "Recent directories",
|
||||
"devices.edit.browse": "Browse…",
|
||||
"devices.edit.cancel": "Cancel",
|
||||
"devices.edit.defaultCwd": "Default working directory",
|
||||
"devices.edit.defaultCwdPlaceholder": "e.g. /Users/me/projects",
|
||||
|
||||
@@ -271,6 +271,11 @@
|
||||
"devices.actions.edit": "编辑",
|
||||
"devices.actions.remove": "移除",
|
||||
"devices.channel.connected": "已连接 {{time}}",
|
||||
"devices.currentBadge": "当前设备",
|
||||
"devices.detail.connections": "连接通道",
|
||||
"devices.detail.noRecent": "暂无最近目录",
|
||||
"devices.detail.recentDirs": "最近目录",
|
||||
"devices.edit.browse": "浏览…",
|
||||
"devices.edit.cancel": "取消",
|
||||
"devices.edit.defaultCwd": "默认工作目录",
|
||||
"devices.edit.defaultCwdPlaceholder": "例如 /Users/me/projects",
|
||||
|
||||
@@ -42,9 +42,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
container: css`
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
padding-block: 4px;
|
||||
`,
|
||||
field: css`
|
||||
margin-block-end: 8px;
|
||||
@@ -80,9 +78,9 @@ export const CreateAgentRender = memo<BuiltinRenderProps<CreateAgentParams, Crea
|
||||
if (pluginState?.success && (pluginState.agentId || pluginState.sessionId)) {
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.agentCard}
|
||||
horizontal
|
||||
gap={12}
|
||||
onClick={handleNavigateToSession}
|
||||
>
|
||||
|
||||
@@ -10,9 +10,7 @@ import type { GetAgentDetailParams, GetAgentDetailState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
padding-block: 4px;
|
||||
`,
|
||||
field: css`
|
||||
margin-block-end: 8px;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Check, PenLine } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { AskUserQuestionArgs, AskUserQuestionItem } from '../../../types';
|
||||
|
||||
@@ -16,29 +17,36 @@ interface AskUserQuestionState {
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
answer: css`
|
||||
line-height: 1.6;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
answerRow: css`
|
||||
padding-block: 6px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 6px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
check: css`
|
||||
flex-shrink: 0;
|
||||
margin-block-start: 3px;
|
||||
color: ${cssVar.colorPrimary};
|
||||
`,
|
||||
container: css`
|
||||
padding: 12px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
padding-block: 4px;
|
||||
`,
|
||||
header: css`
|
||||
description: css`
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
divider: css`
|
||||
align-self: stretch;
|
||||
height: 1px;
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
label: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
question: css`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
@@ -48,46 +56,57 @@ interface QABlockProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* One question/answer pair for the completed Render. The original question
|
||||
* stays visible (header + body); the answer renders as one card per picked
|
||||
* option (multi-select fans out into multiple rows). When `answer` is
|
||||
* absent — older messages persisted before added structured
|
||||
* storage — we show a `—` placeholder so the layout stays uniform.
|
||||
* One question/answer pair for the completed Render, laid out as a single
|
||||
* flat surface (no nested cards): a "Question" label + the question text,
|
||||
* a hairline divider, then a "Selected" label + the picked option(s). Each
|
||||
* pick is one check-prefixed line with its description underneath; multi-select
|
||||
* fans out into multiple lines. When `answer` is absent — older messages
|
||||
* persisted before structured storage — we show a `—` placeholder so the
|
||||
* layout stays uniform.
|
||||
*/
|
||||
const QABlock = memo<QABlockProps>(({ question, answer }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const labels: string[] = Array.isArray(answer) ? answer : answer ? [answer] : [];
|
||||
const optionByLabel = new Map(question.options.map((o) => [o.label, o]));
|
||||
|
||||
return (
|
||||
<Flexbox gap={6}>
|
||||
{question.header && <span className={styles.header}>{question.header}</span>}
|
||||
<Text className={styles.question}>{question.question}</Text>
|
||||
{labels.length > 0 ? (
|
||||
<Flexbox gap={4}>
|
||||
{labels.map((label) => {
|
||||
const opt = optionByLabel.get(label);
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
className={cx(styles.answerRow)}
|
||||
gap={8}
|
||||
key={label}
|
||||
>
|
||||
<Icon className={styles.check} icon={Check} size={14} />
|
||||
<Flexbox flex={1} gap={2}>
|
||||
<Text className={styles.answer}>{label}</Text>
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox gap={6}>
|
||||
<span className={styles.label}>
|
||||
{t('builtins.lobe-claude-code.askUserQuestion.question')}
|
||||
</span>
|
||||
<div className={styles.question}>{question.question}</div>
|
||||
</Flexbox>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<Flexbox gap={8}>
|
||||
<span className={styles.label}>
|
||||
{t('builtins.lobe-claude-code.askUserQuestion.selected')}
|
||||
</span>
|
||||
{labels.length > 0 ? (
|
||||
<Flexbox gap={10}>
|
||||
{labels.map((label) => {
|
||||
const opt = optionByLabel.get(label);
|
||||
return (
|
||||
<Flexbox gap={2} key={label}>
|
||||
<Flexbox horizontal align="flex-start" gap={8}>
|
||||
<Icon className={styles.check} icon={Check} size={14} />
|
||||
<Text className={styles.answer}>{label}</Text>
|
||||
</Flexbox>
|
||||
{opt?.description && opt.description !== label && (
|
||||
<span className={styles.header}>{opt.description}</span>
|
||||
<span className={styles.description} style={{ paddingInlineStart: 22 }}>
|
||||
{opt.description}
|
||||
</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
@@ -106,10 +125,15 @@ QABlock.displayName = 'CCAskUserQuestionQABlock';
|
||||
* `setInterventionAnswers` in `conversationControl` at submit time. If the
|
||||
* key is missing (older messages, or skipped/cancelled flows where there's
|
||||
* nothing to show), we fall back to the question list with a status hint.
|
||||
*
|
||||
* Single-layer surface: the framework's tool card already provides the
|
||||
* containing card, so this Render stays flat (no own background) to avoid the
|
||||
* card-in-card look.
|
||||
*/
|
||||
const AskUserQuestion = memo<
|
||||
BuiltinRenderProps<AskUserQuestionArgs, AskUserQuestionState, unknown>
|
||||
>(({ args, pluginError, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const questions = args?.questions ?? [];
|
||||
const answers = pluginState?.askUserAnswers;
|
||||
const freeform = answers?.['__freeform__'];
|
||||
@@ -118,35 +142,43 @@ const AskUserQuestion = memo<
|
||||
|
||||
// Escape-mode reply: the user opted out of the multi-choice form and
|
||||
// wrote freeform text instead. The form picks are intentionally absent,
|
||||
// so render the questions for context (header + body only) plus the
|
||||
// typed reply as one card — Q&A pairs would render as empty rows.
|
||||
// so render the questions for context (label + body) plus the typed reply
|
||||
// as a check-style line — Q&A pairs would render as empty rows.
|
||||
if (freeformText) {
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={12}>
|
||||
<Flexbox className={styles.container} gap={16}>
|
||||
{questions.map((q, idx) => (
|
||||
<Flexbox gap={4} key={`${q.question}-${idx}`}>
|
||||
{q.header && <span className={styles.header}>{q.header}</span>}
|
||||
<Text className={styles.question}>{q.question}</Text>
|
||||
<Flexbox gap={6} key={`${q.question}-${idx}`}>
|
||||
<span className={styles.label}>
|
||||
{t('builtins.lobe-claude-code.askUserQuestion.question')}
|
||||
</span>
|
||||
<div className={styles.question}>{q.question}</div>
|
||||
</Flexbox>
|
||||
))}
|
||||
<Flexbox horizontal align="flex-start" className={cx(styles.answerRow)} gap={8}>
|
||||
<Icon className={styles.check} icon={PenLine} size={14} />
|
||||
<Text className={styles.answer}>{freeformText}</Text>
|
||||
<div className={styles.divider} />
|
||||
<Flexbox gap={8}>
|
||||
<span className={styles.label}>
|
||||
{t('builtins.lobe-claude-code.askUserQuestion.reply')}
|
||||
</span>
|
||||
<Flexbox horizontal align="flex-start" gap={8}>
|
||||
<Icon className={styles.check} icon={PenLine} size={14} />
|
||||
<Text className={styles.answer}>{freeformText}</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{isError && (
|
||||
<Text type="warning">(No answer received — model continued without their input.)</Text>
|
||||
<Text type="warning">{t('builtins.lobe-claude-code.askUserQuestion.noAnswer')}</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={12}>
|
||||
<Flexbox className={styles.container} gap={20}>
|
||||
{questions.map((q, idx) => (
|
||||
<QABlock answer={answers?.[q.question]} key={`${q.question}-${idx}`} question={q} />
|
||||
))}
|
||||
{isError && (
|
||||
<Text type="warning">(No answer received — model continued without their input.)</Text>
|
||||
<Text type="warning">{t('builtins.lobe-claude-code.askUserQuestion.noAnswer')}</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ import { memo, useMemo } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
path: css`
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
word-break: break-all;
|
||||
`,
|
||||
}));
|
||||
|
||||
@@ -39,6 +39,7 @@ const stripLineNumbers = (text: string): string => {
|
||||
const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
|
||||
const filePath = args?.file_path || '';
|
||||
const fileName = filePath ? path.basename(filePath) : '';
|
||||
const dir = filePath ? path.dirname(filePath) : '';
|
||||
const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : '';
|
||||
|
||||
const source = useMemo(() => stripLineNumbers(content || ''), [content]);
|
||||
@@ -49,9 +50,9 @@ const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
|
||||
header={
|
||||
<>
|
||||
<Text strong>{fileName || 'Read'}</Text>
|
||||
{filePath && filePath !== fileName && (
|
||||
{dir && dir !== '.' && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{filePath}
|
||||
{dir}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,9 +10,7 @@ import type { SkillArgs } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
padding-block: 4px;
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
@@ -25,7 +23,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
padding-inline: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,14 +10,12 @@ import { LocalFile, LocalFolder } from '@/features/LocalFile';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
padding-block: 4px;
|
||||
`,
|
||||
previewBox: css`
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -59,15 +59,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
value: css`
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 10px;
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export class GatewayHttpClient {
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
systemContext?: string;
|
||||
timeout?: number;
|
||||
topicId: string;
|
||||
userId: string;
|
||||
|
||||
@@ -137,6 +137,13 @@ export interface AgentRunRequestMessage {
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
/**
|
||||
* Static context injected before the user prompt (workspace conventions,
|
||||
* conversation history on resume). The desktop sends it to `lh hetero exec`
|
||||
* as the first text block of a content-block array. Optional — omitted for
|
||||
* older servers that don't build a device-specific context.
|
||||
*/
|
||||
systemContext?: string;
|
||||
topicId: string;
|
||||
type: 'agent_run_request';
|
||||
}
|
||||
|
||||
@@ -96,6 +96,86 @@ describe('ClaudeCodeAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a 429 "not your usage limit" server throttle as overloaded, not rate_limit', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const rawError =
|
||||
'API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited';
|
||||
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
// CC still emits a generic rate_limit_event (rejected, no resetsAt) for
|
||||
// this transient throttle — it must NOT tip the classifier toward the
|
||||
// user-facing usage-limit guide.
|
||||
adapter.adapt({
|
||||
rate_limit_info: { isUsingOverage: false, status: 'rejected' },
|
||||
type: 'rate_limit_event',
|
||||
});
|
||||
|
||||
const events = adapter.adapt({
|
||||
api_error_status: 429,
|
||||
is_error: true,
|
||||
result: rawError,
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
|
||||
expect(events[1].data).toMatchObject({
|
||||
agentType: 'claude-code',
|
||||
clearEchoedContent: true,
|
||||
code: 'overloaded',
|
||||
message: rawError,
|
||||
stderr: rawError,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a 429 with no reset window in rate_limit_event as overloaded, not rate_limit', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
// Generic "Rate limited" wording + a rate_limit_event that carries no
|
||||
// resetsAt / rateLimitType. The structured signal — not the 429 status
|
||||
// or the "rate limit" substring — decides: no reset window → transient
|
||||
// server throttle → overloaded.
|
||||
const rawError = 'API Error: 429 · Rate limited';
|
||||
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
adapter.adapt({
|
||||
rate_limit_info: { status: 'rejected' },
|
||||
type: 'rate_limit_event',
|
||||
});
|
||||
|
||||
const events = adapter.adapt({
|
||||
api_error_status: 429,
|
||||
is_error: true,
|
||||
result: rawError,
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
|
||||
expect(events[1].data).toMatchObject({ code: 'overloaded', message: rawError });
|
||||
});
|
||||
|
||||
it('classifies a user quota limit from rateLimitType alone (no resetsAt)', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const rawError = 'API Error: 429 · Rate limited';
|
||||
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
// rateLimitType is itself a user-quota signal even without resetsAt.
|
||||
adapter.adapt({
|
||||
rate_limit_info: { rateLimitType: 'seven_day', status: 'rejected' },
|
||||
type: 'rate_limit_event',
|
||||
});
|
||||
|
||||
const events = adapter.adapt({
|
||||
api_error_status: 429,
|
||||
is_error: true,
|
||||
result: rawError,
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
expect(events[1].data).toMatchObject({
|
||||
code: 'rate_limit',
|
||||
rateLimitInfo: { rateLimitType: 'seven_day' },
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies rate-limit failures from paired rate_limit_event + result events', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const rawError = "You've hit your limit · resets 9am (Asia/Shanghai)";
|
||||
|
||||
@@ -183,14 +183,54 @@ const CLI_AUTH_REQUIRED_PATTERNS = [
|
||||
/\b401\b/,
|
||||
] as const;
|
||||
|
||||
const CLI_RATE_LIMIT_PATTERNS = [/you'?ve hit your limit/i, /rate limit/i] as const;
|
||||
/**
|
||||
* Genuinely user-side limit wording. Used only as the text fallback for
|
||||
* batch CLI / sandbox runs that don't emit a structured `rate_limit_event`
|
||||
* (so {@link isUserQuotaRateLimit} can't fire). The ambiguous bare
|
||||
* `rate limit` / `rate limited` substring is deliberately NOT here — it also
|
||||
* appears in Anthropic's transient server throttle, so leaning on it would
|
||||
* reintroduce the very misclassification this set exists to avoid.
|
||||
*/
|
||||
const CLI_USER_RATE_LIMIT_PATTERNS = [
|
||||
/you'?ve hit your limit/i,
|
||||
/usage limit reached/i,
|
||||
/\blimit reached\b/i,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Anthropic's server-side transient throttle. CC surfaces this as a 429 with
|
||||
* a message that explicitly disclaims the user's plan limit ("not your usage
|
||||
* limit") — e.g. `API Error: Server is temporarily limiting requests (not your
|
||||
* usage limit) · Rate limited`. It clears on its own in moments, so it must be
|
||||
* classified as `overloaded` (retry UX), NOT `rate_limit` (which renders a
|
||||
* misleading "usage limit reached" reset-time guide).
|
||||
*/
|
||||
const CLI_SERVER_THROTTLE_PATTERNS = [
|
||||
/not your usage limit/i,
|
||||
/server is temporarily limiting requests/i,
|
||||
] as const;
|
||||
|
||||
const CLI_OVERLOADED_PATTERNS = [
|
||||
/overloaded_error/i,
|
||||
/\boverloaded\b/i,
|
||||
/api error:\s*529\b/i,
|
||||
...CLI_SERVER_THROTTLE_PATTERNS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The one reliable discriminator between a user-side plan/quota limit and a
|
||||
* transient server throttle: only the genuine user limit carries a concrete
|
||||
* reset window in the structured `rate_limit_event` — `resetsAt` (epoch
|
||||
* seconds) and/or a named `rateLimitType` (e.g. `seven_day`). Anthropic's
|
||||
* transient throttle emits a rate_limit_event too, but with just
|
||||
* `status: 'rejected'` and no reset info. Status codes (429 / 529) alone are
|
||||
* ambiguous, so this structured signal — not the HTTP status, not the message
|
||||
* text — is what decides whether we show the "usage limit reached, resets at
|
||||
* X" guide vs the "temporarily overloaded, retry" guide.
|
||||
*/
|
||||
const isUserQuotaRateLimit = (info?: HeterogeneousRateLimitInfo): boolean =>
|
||||
!!info && (info.resetsAt != null || info.rateLimitType != null);
|
||||
|
||||
const getCliResultMessage = (result: unknown): string | undefined => {
|
||||
if (typeof result === 'string') return result;
|
||||
if (
|
||||
@@ -248,10 +288,18 @@ const toRateLimitInfo = (value: unknown): HeterogeneousRateLimitInfo | undefined
|
||||
const getOverloadedTerminalError = (
|
||||
result: unknown,
|
||||
apiErrorStatus?: unknown,
|
||||
rateLimitInfo?: HeterogeneousRateLimitInfo,
|
||||
): HeterogeneousTerminalErrorData | undefined => {
|
||||
const rawMessage = getCliResultMessage(result);
|
||||
// A real user-quota limit is the rate-limit classifier's job — never steal
|
||||
// it here, even if it happened to ride in on a 429/529.
|
||||
if (isUserQuotaRateLimit(rateLimitInfo)) return;
|
||||
|
||||
const looksOverloaded =
|
||||
// Both 529 (upstream overloaded) and a 429 with no quota signal (transient
|
||||
// server throttle) are momentary server-side conditions — same retry UX.
|
||||
apiErrorStatus === 529 ||
|
||||
apiErrorStatus === 429 ||
|
||||
(!!rawMessage && CLI_OVERLOADED_PATTERNS.some((pattern) => pattern.test(rawMessage)));
|
||||
|
||||
if (!looksOverloaded || !rawMessage) return;
|
||||
@@ -269,15 +317,24 @@ const getOverloadedTerminalError = (
|
||||
const getRateLimitTerminalError = (
|
||||
result: unknown,
|
||||
rateLimitInfo?: HeterogeneousRateLimitInfo,
|
||||
apiErrorStatus?: unknown,
|
||||
): HeterogeneousTerminalErrorData | undefined => {
|
||||
const rawMessage = getCliResultMessage(result);
|
||||
const looksLikeRateLimit =
|
||||
apiErrorStatus === 429 ||
|
||||
!!rateLimitInfo ||
|
||||
(!!rawMessage && CLI_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage)));
|
||||
|
||||
if (!looksLikeRateLimit || !rawMessage) return;
|
||||
// Primary signal: the structured rate_limit_event carries a concrete reset
|
||||
// window → this is the user's plan/quota limit. Fallback (batch runs with no
|
||||
// rate_limit_event): clearly user-side wording that doesn't disclaim the
|
||||
// limit. Everything else — bare 429, "rate limited", server throttle — is
|
||||
// left to the overloaded classifier so it gets the retry UX, not a
|
||||
// misleading "usage limit reached, resets at X" guide.
|
||||
const looksLikeServerThrottle =
|
||||
!!rawMessage && CLI_SERVER_THROTTLE_PATTERNS.some((pattern) => pattern.test(rawMessage));
|
||||
const looksLikeUserLimit =
|
||||
isUserQuotaRateLimit(rateLimitInfo) ||
|
||||
(!!rawMessage &&
|
||||
!looksLikeServerThrottle &&
|
||||
CLI_USER_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage)));
|
||||
|
||||
if (!looksLikeUserLimit || !rawMessage) return;
|
||||
|
||||
return {
|
||||
agentType: 'claude-code',
|
||||
@@ -1166,16 +1223,16 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
}
|
||||
|
||||
const resultMessage = getCliResultMessage(raw.result) || 'Agent execution failed';
|
||||
const rateLimitError = getRateLimitTerminalError(
|
||||
raw.result,
|
||||
this.pendingRateLimitInfo,
|
||||
raw.api_error_status,
|
||||
);
|
||||
const rateLimitError = getRateLimitTerminalError(raw.result, this.pendingRateLimitInfo);
|
||||
const finalEvent: HeterogeneousAgentEvent = raw.is_error
|
||||
? this.makeEvent(
|
||||
'error',
|
||||
rateLimitError ||
|
||||
getOverloadedTerminalError(raw.result, raw.api_error_status) ||
|
||||
getOverloadedTerminalError(
|
||||
raw.result,
|
||||
raw.api_error_status,
|
||||
this.pendingRateLimitInfo,
|
||||
) ||
|
||||
getAuthRequiredTerminalError(raw.result) || {
|
||||
error: resultMessage,
|
||||
message: resultMessage,
|
||||
|
||||
@@ -7,9 +7,7 @@ import { memo, type ReactNode } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
padding-block: 4px;
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
@@ -19,7 +17,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ export interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
isLoading?: boolean;
|
||||
partialArgs?: Arguments;
|
||||
pluginState?: State;
|
||||
result?: { content: string | null; error?: any };
|
||||
result?: { content: string | null; error?: any; state?: any };
|
||||
}
|
||||
|
||||
export type BuiltinInspector = <A = any, S = any>(props: BuiltinInspectorProps<A, S>) => ReactNode;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, Icon, Input, Popover, Tooltip } from '@lobehub/ui';
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { CheckIcon, ChevronDownIcon, FolderIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import { useUpdateDeviceCwd } from './useUpdateDeviceCwd';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
button: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 2px;
|
||||
padding-inline: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
dirItem: css`
|
||||
cursor: pointer;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
dirItemActive: css`
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
dirName: css`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
dirPath: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 11px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
scrollContainer: css`
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
`,
|
||||
sectionTitle: css`
|
||||
padding-block: 6px 2px;
|
||||
padding-inline: 8px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const getDirName = (path: string) => path.split('/').findLast(Boolean) || path;
|
||||
|
||||
interface DeviceWorkingDirectoryProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Working-directory picker for runs dispatched to a remote device
|
||||
* (`executionTarget='device'`). Unlike the desktop picker, the device's
|
||||
* filesystem isn't browsable from here, so the cwd comes from the device's
|
||||
* `recentCwds` (persisted via the registry) plus a manual path field. A pick is
|
||||
* pinned to the active topic (override) and persisted back to the device
|
||||
* (`defaultCwd` + `recentCwds`) so it seeds future topics and the recent list.
|
||||
*/
|
||||
const DeviceWorkingDirectory = memo<DeviceWorkingDirectoryProps>(({ agentId }) => {
|
||||
const { t } = useTranslation(['plugin', 'chat']);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId));
|
||||
const boundDeviceId = agencyConfig?.boundDeviceId;
|
||||
|
||||
const { data: devices } = lambdaQuery.device.listDevices.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const device = useMemo(
|
||||
() => devices?.find((d) => d.deviceId === boundDeviceId),
|
||||
[devices, boundDeviceId],
|
||||
);
|
||||
const recentCwds = device?.recentCwds ?? [];
|
||||
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
// Mirror the server's resolution (topic override > device.defaultCwd).
|
||||
const effectiveDir = topicWorkingDirectory || device?.defaultCwd || '';
|
||||
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
const activeTopic = useChatStore((s) =>
|
||||
s.activeTopicId ? topicSelectors.getTopicById(s.activeTopicId)(s) : undefined,
|
||||
);
|
||||
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
|
||||
const updateDeviceCwd = useUpdateDeviceCwd();
|
||||
|
||||
const commitDir = useCallback(
|
||||
async (path: string) => {
|
||||
const newPath = path.trim();
|
||||
if (!newPath || !boundDeviceId) return;
|
||||
|
||||
const commit = async () => {
|
||||
// Pin this topic to the chosen cwd (override wins server-side), and
|
||||
// persist to the device so defaultCwd + recentCwds stay in sync.
|
||||
if (activeTopicId) await updateTopicMetadata(activeTopicId, { workingDirectory: newPath });
|
||||
await updateDeviceCwd(boundDeviceId, newPath, recentCwds);
|
||||
setInput('');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// Changing a topic's cwd invalidates its pinned CC session (sessions are
|
||||
// keyed per-cwd), so warn before the implicit reset — same as the local picker.
|
||||
const priorSessionId = activeTopic?.metadata?.heteroSessionId;
|
||||
const priorCwd = activeTopic?.metadata?.workingDirectory;
|
||||
if (priorSessionId && priorCwd && priorCwd !== newPath) {
|
||||
confirmModal({
|
||||
cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }),
|
||||
content: t('heteroAgent.switchCwd.content', { ns: 'chat' }),
|
||||
okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }),
|
||||
onOk: commit,
|
||||
title: t('heteroAgent.switchCwd.title', { ns: 'chat' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await commit();
|
||||
},
|
||||
[
|
||||
activeTopicId,
|
||||
activeTopic,
|
||||
boundDeviceId,
|
||||
recentCwds,
|
||||
t,
|
||||
updateDeviceCwd,
|
||||
updateTopicMetadata,
|
||||
],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
<div className={styles.sectionTitle}>{t('localSystem.workingDirectory.recent')}</div>
|
||||
<div className={styles.scrollContainer}>
|
||||
{recentCwds.length === 0 ? (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ color: cssVar.colorTextQuaternary, fontSize: 12, padding: '12px 8px' }}
|
||||
>
|
||||
{t('localSystem.workingDirectory.noRecent')}
|
||||
</Flexbox>
|
||||
) : (
|
||||
recentCwds.map((path) => {
|
||||
const isActive = path === effectiveDir;
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={cx(styles.dirItem, isActive && styles.dirItemActive)}
|
||||
gap={8}
|
||||
key={path}
|
||||
onClick={() => void commitDir(path)}
|
||||
>
|
||||
<Icon
|
||||
icon={FolderIcon}
|
||||
size={16}
|
||||
style={{ color: cssVar.colorTextTertiary, flex: 'none' }}
|
||||
/>
|
||||
<Flexbox flex={1} style={{ minWidth: 0 }}>
|
||||
<div className={styles.dirName}>{getDirName(path)}</div>
|
||||
<div className={styles.dirPath}>{path}</div>
|
||||
</Flexbox>
|
||||
{isActive ? (
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size={16}
|
||||
style={{ color: cssVar.colorSuccess, flex: 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
</Flexbox>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('localSystem.workingDirectory.placeholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPressEnter={() => void commitDir(input)}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
const displayName = effectiveDir
|
||||
? getDirName(effectiveDir)
|
||||
: t('localSystem.workingDirectory.notSet');
|
||||
|
||||
const trigger = (
|
||||
<div className={styles.button}>
|
||||
<Icon icon={FolderIcon} size={14} />
|
||||
<span>{displayName}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip title={effectiveDir || t('localSystem.workingDirectory.notSet')}>
|
||||
{trigger}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
DeviceWorkingDirectory.displayName = 'DeviceWorkingDirectory';
|
||||
|
||||
export default DeviceWorkingDirectory;
|
||||
@@ -7,14 +7,17 @@ import { CheckIcon, FolderIcon, FolderOpenIcon, GitBranchIcon, XIcon } from 'luc
|
||||
import { memo, type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import { addRecentDir, getRecentDirs, type RecentDirEntry, removeRecentDir } from './recentDirs';
|
||||
import { useRepoType } from './useRepoType';
|
||||
import { useUpdateDeviceCwd } from './useUpdateDeviceCwd';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
chooseFolderItem: css`
|
||||
@@ -154,6 +157,20 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
|
||||
const updateAgentRuntimeEnvConfig = useAgentStore((s) => s.updateAgentRuntimeEnvConfigById);
|
||||
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
|
||||
|
||||
// Local runs execute on this very machine, so also record the chosen dir in
|
||||
// its device-registry `recentCwds` — keeps the settings detail view + future
|
||||
// device-mode picker in sync. recentCwds only; the device default is untouched.
|
||||
const useFetchDeviceInfo = useElectronStore((s) => s.useFetchGatewayDeviceInfo);
|
||||
const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo);
|
||||
useFetchDeviceInfo();
|
||||
const currentDeviceId = gatewayDeviceInfo?.deviceId;
|
||||
const { data: allDevices } = lambdaQuery.device.listDevices.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const deviceRecentCwds =
|
||||
allDevices?.find((d) => d.deviceId === currentDeviceId)?.recentCwds ?? [];
|
||||
const updateDeviceCwd = useUpdateDeviceCwd();
|
||||
|
||||
const [recentDirs, setRecentDirs] = useState(getRecentDirs);
|
||||
|
||||
const displayDirs = useMemo(() => {
|
||||
@@ -178,6 +195,10 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
|
||||
await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: newPath });
|
||||
}
|
||||
setRecentDirs(addRecentDir(entry));
|
||||
// Record on this machine's device registry (recentCwds only).
|
||||
if (currentDeviceId) {
|
||||
void updateDeviceCwd(currentDeviceId, newPath, deviceRecentCwds, { setDefault: false });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -205,8 +226,11 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
|
||||
activeTopicId,
|
||||
activeTopic,
|
||||
agentId,
|
||||
currentDeviceId,
|
||||
deviceRecentCwds,
|
||||
t,
|
||||
updateAgentRuntimeEnvConfig,
|
||||
updateDeviceCwd,
|
||||
updateTopicMetadata,
|
||||
onClose,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { nextRecentCwds, RECENT_CWDS_MAX } from './deviceCwd';
|
||||
|
||||
describe('nextRecentCwds', () => {
|
||||
it('prepends a new path as most-recent', () => {
|
||||
expect(nextRecentCwds('/b', ['/a'])).toEqual(['/b', '/a']);
|
||||
});
|
||||
|
||||
it('moves an existing path to the front without duplicating it', () => {
|
||||
expect(nextRecentCwds('/a', ['/a', '/b', '/c'])).toEqual(['/a', '/b', '/c']);
|
||||
expect(nextRecentCwds('/c', ['/a', '/b', '/c'])).toEqual(['/c', '/a', '/b']);
|
||||
});
|
||||
|
||||
it('caps the list length', () => {
|
||||
const current = Array.from({ length: RECENT_CWDS_MAX }, (_, i) => `/p${i}`);
|
||||
const result = nextRecentCwds('/new', current);
|
||||
expect(result).toHaveLength(RECENT_CWDS_MAX);
|
||||
expect(result[0]).toBe('/new');
|
||||
expect(result).not.toContain(`/p${RECENT_CWDS_MAX - 1}`); // oldest dropped
|
||||
});
|
||||
|
||||
it('trims the input and ignores a blank path', () => {
|
||||
expect(nextRecentCwds(' /a ', ['/b'])).toEqual(['/a', '/b']);
|
||||
expect(nextRecentCwds(' ', ['/b'])).toEqual(['/b']);
|
||||
expect(nextRecentCwds('', ['/b'])).toEqual(['/b']);
|
||||
});
|
||||
|
||||
it('defaults to an empty current list', () => {
|
||||
expect(nextRecentCwds('/a')).toEqual(['/a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Max number of recent working directories persisted per device. Matches the
|
||||
* `recentCwds` cap enforced by the `device.updateDevice` tRPC input. */
|
||||
export const RECENT_CWDS_MAX = 20;
|
||||
|
||||
/**
|
||||
* Compute the next `recentCwds` list after the user picks `cwd`: move it to the
|
||||
* front (most-recent-first), drop any earlier duplicate, and cap the length.
|
||||
* Blank paths are ignored (returns the list unchanged).
|
||||
*
|
||||
* The server stores `recentCwds` verbatim — there is no server-side dedupe or
|
||||
* cap — so the client owns this logic.
|
||||
*/
|
||||
export const nextRecentCwds = (
|
||||
cwd: string,
|
||||
current: readonly string[] = [],
|
||||
max: number = RECENT_CWDS_MAX,
|
||||
): string[] => {
|
||||
const trimmed = cwd.trim();
|
||||
if (!trimmed) return [...current];
|
||||
return [trimmed, ...current.filter((p) => p !== trimmed)].slice(0, max);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
|
||||
import { nextRecentCwds } from './deviceCwd';
|
||||
|
||||
/**
|
||||
* Persist a working-directory choice to a device's registry record
|
||||
* (`defaultCwd` + `recentCwds`) with an **optimistic** update of the
|
||||
* `listDevices` cache, so the picker reflects the pick instantly and the
|
||||
* server's `device.defaultCwd` (read by the hetero device-dispatch branch)
|
||||
* stays in sync. Rolls back on error.
|
||||
*/
|
||||
export const useUpdateDeviceCwd = () => {
|
||||
const utils = lambdaQuery.useUtils();
|
||||
|
||||
const mutation = lambdaQuery.device.updateDevice.useMutation({
|
||||
onMutate: async ({ defaultCwd, deviceId, recentCwds }) => {
|
||||
// Optimistic write: cancel in-flight refetches so they don't clobber it,
|
||||
// then patch the touched device in place. onSettled re-fetches the truth
|
||||
// afterwards (on both success and error), so a failed write self-corrects
|
||||
// without a manual rollback.
|
||||
await utils.device.listDevices.cancel();
|
||||
utils.device.listDevices.setData(undefined, (old) => {
|
||||
if (!old) return old;
|
||||
// `listDevices` returns a union (registered device | online-only ghost);
|
||||
// spreading widens the touched item out of its branch, so re-assert the
|
||||
// query's own element type rather than fight the literal union.
|
||||
return old.map((device) =>
|
||||
device.deviceId === deviceId
|
||||
? {
|
||||
...device,
|
||||
defaultCwd: defaultCwd ?? device.defaultCwd,
|
||||
recentCwds: recentCwds ?? device.recentCwds,
|
||||
}
|
||||
: device,
|
||||
) as typeof old;
|
||||
});
|
||||
},
|
||||
onSettled: () => utils.device.listDevices.invalidate(),
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
deviceId: string,
|
||||
cwd: string,
|
||||
currentRecentCwds: readonly string[] = [],
|
||||
// Local-mode runs only want to record the dir in the recent list, not
|
||||
// repoint the device's default working directory.
|
||||
options: { setDefault?: boolean } = {},
|
||||
) => {
|
||||
const trimmed = cwd.trim();
|
||||
if (!trimmed) return;
|
||||
const setDefault = options.setDefault ?? true;
|
||||
return mutation.mutateAsync({
|
||||
...(setDefault ? { defaultCwd: trimmed } : {}),
|
||||
deviceId,
|
||||
recentCwds: nextRecentCwds(trimmed, currentRecentCwds),
|
||||
});
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import type { ApiEntry } from './useDevtoolsEntries';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
column: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
border-inline-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
dot: css`
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: ${cssVar.colorTextQuaternary};
|
||||
`,
|
||||
dotActive: css`
|
||||
background: ${cssVar.colorPrimary};
|
||||
`,
|
||||
header: css`
|
||||
flex-shrink: 0;
|
||||
padding-block: 14px 10px;
|
||||
padding-inline: 16px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
item: css`
|
||||
cursor: pointer;
|
||||
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
height: 30px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
}
|
||||
`,
|
||||
itemActive: css`
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
/** Namespace prefix of an mcp__ name — muted, and the part that elides. */
|
||||
labelHead: css`
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
labelRow: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 13px;
|
||||
`,
|
||||
/** Trailing action segment — always kept visible. */
|
||||
labelTail: css`
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
list: css`
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
|
||||
min-height: 0;
|
||||
padding-block: 8px;
|
||||
padding-inline: 8px;
|
||||
`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Split a name at its last `__` so the long `mcp__<server>__` namespace can
|
||||
* elide from the middle (`mcp__claude_ai_Li…get_diff`) — keeping both the
|
||||
* `mcp` signal up front and the distinguishing action at the end, instead of
|
||||
* truncating one or the other away. Non-namespaced names are all tail.
|
||||
*/
|
||||
const splitName = (name: string): { head: string; tail: string } => {
|
||||
const cut = name.lastIndexOf('__');
|
||||
if (cut === -1) return { head: '', tail: name };
|
||||
return { head: name.slice(0, cut + 2), tail: name.slice(cut + 2) };
|
||||
};
|
||||
|
||||
interface ApiListProps {
|
||||
activeApiName?: string;
|
||||
apis: ApiEntry[];
|
||||
onSelect: (apiName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle column for the render gallery: a dense jump-list of the current
|
||||
* toolset's APIs. Clicking scrolls the matching `ToolPreview` card into view
|
||||
* and pins a URL hash (`#api-<name>`) so a specific render is deep-linkable;
|
||||
* the active item is driven by the scrollspy in `ToolPage`. The leading dot
|
||||
* lights up when the API ships a Render.
|
||||
*/
|
||||
const ApiList = memo<ApiListProps>(({ apis, activeApiName, onSelect }) => {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the highlighted item visible as the scrollspy walks down the right
|
||||
// pane — otherwise the list stays pinned at the top and you lose your place.
|
||||
useEffect(() => {
|
||||
if (!activeApiName) return;
|
||||
const el = listRef.current?.querySelector(`[data-api="${CSS.escape(activeApiName)}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeApiName]);
|
||||
|
||||
return (
|
||||
<aside className={styles.column}>
|
||||
<div className={styles.header}>
|
||||
<Text fontSize={12} type={'secondary'} weight={600}>
|
||||
APIs · {apis.length}
|
||||
</Text>
|
||||
</div>
|
||||
<Flexbox className={styles.list} ref={listRef}>
|
||||
{apis.map((api) => {
|
||||
const active = api.apiName === activeApiName;
|
||||
const { head, tail } = splitName(api.apiName);
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
className={cx(styles.item, active && styles.itemActive)}
|
||||
data-api={api.apiName}
|
||||
key={api.apiName}
|
||||
title={api.apiName}
|
||||
onClick={() => onSelect(api.apiName)}
|
||||
>
|
||||
<span className={cx(styles.dot, api.render && styles.dotActive)} />
|
||||
<span className={styles.labelRow}>
|
||||
{head && <span className={styles.labelHead}>{head}</span>}
|
||||
<span className={styles.labelTail}>{tail}</span>
|
||||
</span>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
|
||||
ApiList.displayName = 'DevtoolsApiList';
|
||||
|
||||
export default ApiList;
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { LOADING_FLAT } from '@lobechat/const';
|
||||
import type { ChatToolPayload, UIChatMessage } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
type ConversationContext,
|
||||
ConversationProvider,
|
||||
MessageItem,
|
||||
} from '@/features/Conversation';
|
||||
import { MessageActionProvider } from '@/features/Conversation/Messages/Contexts/MessageActionProvider';
|
||||
import { dataSelectors, useConversationStore } from '@/features/Conversation/store';
|
||||
|
||||
import { DEVTOOLS_AGENT_ID } from './fixtures';
|
||||
import { deriveFixtureProps, type LifecycleMode } from './lifecycleMode';
|
||||
import type { ApiEntry } from './useDevtoolsEntries';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
empty: css`
|
||||
padding-block: 48px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
text-align: center;
|
||||
`,
|
||||
thread: css`
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
margin-inline: auto;
|
||||
padding-block: 8px 48px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 14px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
const coerceContent = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the **flat** DB-shaped messages a real conversation produces, then let
|
||||
* `parse()` (conversation-flow, via `ConversationProvider.replaceMessages`)
|
||||
* synthesize the `assistantGroup` / tool grouping exactly as it does in chat —
|
||||
* instead of hand-rolling the grouped shape. Each render-bearing API becomes:
|
||||
*
|
||||
* assistant { content, tools: [tool_use] } → tool { tool_call_id, result… }
|
||||
*
|
||||
* The whole sequence is one parentId chain so it reads as a single conversation.
|
||||
* Lifecycle state is carried on the tool result message the same way the real
|
||||
* pipeline carries it:
|
||||
* - success → tool message `content` + `pluginState`
|
||||
* - error → tool message `pluginError`
|
||||
* - intervention → tool message `pluginIntervention.status = 'pending'`
|
||||
* - streaming → `LOADING_FLAT` content + unterminated `arguments` JSON on the tool_use
|
||||
* - loading / placeholder → `LOADING_FLAT` content
|
||||
*
|
||||
* Every API emits a tool message even for the unfinished states (content
|
||||
* `LOADING_FLAT`) — the tool_use → tool_result link is what lets
|
||||
* conversation-flow chain the turns into ONE assistantGroup; without it the
|
||||
* unfinished modes fall back to one orphaned group per tool.
|
||||
*/
|
||||
const buildMessages = (apis: ApiEntry[], mode: LifecycleMode, now: number): UIChatMessage[] => {
|
||||
const renderable = apis.filter(
|
||||
(api) => api.render || api.streaming || api.placeholder || api.intervention,
|
||||
);
|
||||
|
||||
const messages: UIChatMessage[] = [];
|
||||
|
||||
for (const api of renderable) {
|
||||
const variant = api.fixture.variants[0];
|
||||
const derived = deriveFixtureProps(variant, mode);
|
||||
const key = `${api.identifier}-${api.apiName}`;
|
||||
const assistantId = `devtools-asst-${key}`;
|
||||
const toolCallId = `devtools-tool-${key}`;
|
||||
|
||||
const toolUse: ChatToolPayload = {
|
||||
apiName: api.apiName,
|
||||
// Streaming: drop the closing brace so args fail to parse → "still typing".
|
||||
arguments:
|
||||
mode === 'streaming'
|
||||
? JSON.stringify(derived.partialArgs ?? {}).replace(/\}$/, '')
|
||||
: JSON.stringify(derived.args),
|
||||
id: toolCallId,
|
||||
identifier: api.identifier,
|
||||
source: api.apiName.startsWith('mcp__') ? 'mcp' : 'builtin',
|
||||
type: 'builtin',
|
||||
};
|
||||
|
||||
// Chain onto the previous turn's last message so the whole thread is one
|
||||
// conversation; the first assistant has no parent (conversation root).
|
||||
messages.push({
|
||||
content: api.description || variant.description || '',
|
||||
createdAt: now,
|
||||
id: assistantId,
|
||||
parentId: messages.at(-1)?.id,
|
||||
role: 'assistant',
|
||||
tools: [toolUse],
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Always emit the paired tool result — it's the tool_use → tool_result link
|
||||
// that lets conversation-flow chain every turn into ONE assistantGroup.
|
||||
// Unfinished states use LOADING_FLAT so the tool still reads as in-flight.
|
||||
messages.push({
|
||||
content: mode === 'success' ? coerceContent(derived.content) : LOADING_FLAT,
|
||||
createdAt: now,
|
||||
id: `devtools-toolmsg-${key}`,
|
||||
parentId: assistantId,
|
||||
pluginError: mode === 'error' ? derived.pluginError : undefined,
|
||||
pluginIntervention: mode === 'intervention' ? { status: 'pending' } : undefined,
|
||||
pluginState: mode === 'success' ? derived.pluginState : undefined,
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
const InnerList = memo(() => {
|
||||
const ids = useConversationStore(dataSelectors.displayMessageIds);
|
||||
return (
|
||||
<MessageActionProvider withSingletonActionsBar={false}>
|
||||
<div className={styles.thread}>
|
||||
{ids.map((id, index) => (
|
||||
<MessageItem
|
||||
disableEditing
|
||||
defaultWorkflowExpandLevel={'full'}
|
||||
id={id}
|
||||
index={index}
|
||||
key={id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MessageActionProvider>
|
||||
);
|
||||
});
|
||||
|
||||
InnerList.displayName = 'DevtoolsAggregateInnerList';
|
||||
|
||||
interface MessageListProps {
|
||||
apis: ApiEntry[];
|
||||
mode: LifecycleMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate preview tab: renders every render-bearing API as a tool call inside
|
||||
* the **real** `Conversation` renderer. Flat fixture messages are seeded through
|
||||
* `ConversationProvider` (`skipFetch`) so conversation-flow's `parse()` performs
|
||||
* the real `assistantGroup` grouping — the preview is byte-for-byte what ships
|
||||
* in chat. Inspector-only tools (most MCP entries) are dropped to keep the
|
||||
* thread about the renders.
|
||||
*/
|
||||
const MessageList = memo<MessageListProps>(({ apis, mode }) => {
|
||||
// One stable timestamp per (apis, mode) render so message identity is steady.
|
||||
const messages = useMemo(() => buildMessages(apis, mode, Date.now()), [apis, mode]);
|
||||
const context = useMemo<ConversationContext>(
|
||||
() => ({ agentId: DEVTOOLS_AGENT_ID, topicId: 'devtools-aggregate' }),
|
||||
[],
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return <Text className={styles.empty}>No renderable APIs in this toolset.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationProvider hasInitMessages skipFetch context={context} messages={messages}>
|
||||
<InnerList />
|
||||
</ConversationProvider>
|
||||
);
|
||||
});
|
||||
|
||||
MessageList.displayName = 'DevtoolsMessageList';
|
||||
|
||||
export default MessageList;
|
||||
@@ -2,24 +2,46 @@
|
||||
|
||||
import { Flexbox, Segmented, Tag, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import ApiList from './ApiList';
|
||||
import { LIFECYCLE_MODE_LABEL, LIFECYCLE_MODES, type LifecycleMode } from './lifecycleMode';
|
||||
import MessageList from './MessageList';
|
||||
import ToolPreview from './ToolPreview';
|
||||
import { useDevtoolsEntries } from './useDevtoolsEntries';
|
||||
import { toApiAnchor, useDevtoolsEntries } from './useDevtoolsEntries';
|
||||
|
||||
const STORAGE_KEY = 'devtools-render-gallery:lifecycle-mode';
|
||||
const MODE_STORAGE_KEY = 'devtools-render-gallery:lifecycle-mode';
|
||||
const VIEW_STORAGE_KEY = 'devtools-render-gallery:view';
|
||||
|
||||
type GalleryView = 'api' | 'aggregate';
|
||||
|
||||
const isLifecycleMode = (value: string | null): value is LifecycleMode =>
|
||||
!!value && (LIFECYCLE_MODES as string[]).includes(value);
|
||||
|
||||
const isGalleryView = (value: string | null): value is GalleryView =>
|
||||
value === 'api' || value === 'aggregate';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
body: css`
|
||||
gap: 24px;
|
||||
max-width: 1200px;
|
||||
padding: 28px;
|
||||
`,
|
||||
content: css`
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
|
||||
/* keep a jumped-to card clear of the sticky lifecycle bar */
|
||||
& [id^='api-'] {
|
||||
scroll-margin-block-start: 80px;
|
||||
}
|
||||
`,
|
||||
controlGroup: css`
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
`,
|
||||
empty: css`
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
@@ -37,7 +59,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
z-index: 2;
|
||||
inset-block-start: 0;
|
||||
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 10px;
|
||||
@@ -56,19 +78,94 @@ const DevtoolsToolPage = () => {
|
||||
const toolset = identifier ? toolsetMap.get(identifier) : undefined;
|
||||
|
||||
const [mode, setMode] = useState<LifecycleMode>('success');
|
||||
const [view, setView] = useState<GalleryView>('api');
|
||||
const [activeApi, setActiveApi] = useState<string>();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Hydrate from localStorage so the choice survives navigation between toolsets.
|
||||
// Hydrate from localStorage so the choices survive navigation between toolsets.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (isLifecycleMode(stored)) setMode(stored);
|
||||
const storedMode = window.localStorage.getItem(MODE_STORAGE_KEY);
|
||||
if (isLifecycleMode(storedMode)) setMode(storedMode);
|
||||
const storedView = window.localStorage.getItem(VIEW_STORAGE_KEY);
|
||||
if (isGalleryView(storedView)) setView(storedView);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(STORAGE_KEY, mode);
|
||||
window.localStorage.setItem(MODE_STORAGE_KEY, mode);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(VIEW_STORAGE_KEY, view);
|
||||
}, [view]);
|
||||
|
||||
// Scrollspy (per-API view only): highlight the API-list item for the card the
|
||||
// reader is on — the last card whose top has crossed a trigger line just under
|
||||
// the sticky bar. A plain scroll listener (rAF-throttled) is used instead of
|
||||
// an IntersectionObserver so the boundary cases stay exact: at the very bottom
|
||||
// the last card can't reach the trigger line, and at the very top the first
|
||||
// card sits above it, so both ends are pinned explicitly.
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root || !toolset || view !== 'api') return;
|
||||
|
||||
const apiNames = toolset.apis.map((api) => api.apiName);
|
||||
|
||||
// Honor a deep-link hash (#api-<name>) on load; otherwise start at the top.
|
||||
const hash = window.location.hash.replace(/^#/, '');
|
||||
const linked = apiNames.find((name) => toApiAnchor(name) === hash);
|
||||
if (linked) {
|
||||
setActiveApi(linked);
|
||||
const card = root.querySelector(`#${CSS.escape(toApiAnchor(linked))}`);
|
||||
requestAnimationFrame(() => card?.scrollIntoView({ block: 'start' }));
|
||||
} else {
|
||||
setActiveApi(apiNames[0]);
|
||||
root.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
const TRIGGER = 96; // px below the scroll-area top — clears the sticky bar
|
||||
let frame = 0;
|
||||
|
||||
const compute = () => {
|
||||
frame = 0;
|
||||
if (root.scrollTop <= 0) return setActiveApi(apiNames[0]);
|
||||
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 2)
|
||||
return setActiveApi(apiNames.at(-1));
|
||||
|
||||
const rootTop = root.getBoundingClientRect().top;
|
||||
let current = apiNames[0];
|
||||
for (const name of apiNames) {
|
||||
const el = document.getElementById(toApiAnchor(name));
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top - rootTop <= TRIGGER) current = name;
|
||||
else break;
|
||||
}
|
||||
setActiveApi(current);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(compute);
|
||||
};
|
||||
|
||||
root.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
root.removeEventListener('scroll', onScroll);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [toolset, view]);
|
||||
|
||||
const handleSelect = (apiName: string) => {
|
||||
setActiveApi(apiName);
|
||||
const root = scrollRef.current;
|
||||
const card = root?.querySelector(`#${CSS.escape(toApiAnchor(apiName))}`);
|
||||
card?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// Pin a shareable anchor without spamming browser history.
|
||||
window.history.replaceState(null, '', `#${toApiAnchor(apiName)}`);
|
||||
};
|
||||
|
||||
if (!toolset) {
|
||||
return (
|
||||
<Flexbox className={styles.empty}>
|
||||
@@ -83,42 +180,68 @@ const DevtoolsToolPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.body}>
|
||||
<Flexbox className={styles.header}>
|
||||
<Flexbox horizontal align={'center'} gap={10} wrap={'wrap'}>
|
||||
<Text fontSize={22} weight={700}>
|
||||
{toolset.toolsetName}
|
||||
</Text>
|
||||
<Tag>{toolset.identifier}</Tag>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{toolset.apis.length} API{toolset.apis.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
<Flexbox horizontal height={'100%'} style={{ overflow: 'hidden' }} width={'100%'}>
|
||||
{view === 'api' && (
|
||||
<ApiList activeApiName={activeApi} apis={toolset.apis} onSelect={handleSelect} />
|
||||
)}
|
||||
<div className={styles.content} ref={scrollRef}>
|
||||
<Flexbox className={styles.body}>
|
||||
<Flexbox className={styles.header}>
|
||||
<Flexbox horizontal align={'center'} gap={10} wrap={'wrap'}>
|
||||
<Text fontSize={22} weight={700}>
|
||||
{toolset.toolsetName}
|
||||
</Text>
|
||||
<Tag>{toolset.identifier}</Tag>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{toolset.apis.length} API{toolset.apis.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
{toolset.toolsetDescription && (
|
||||
<Text fontSize={13} type={'secondary'}>
|
||||
{toolset.toolsetDescription}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal className={styles.modeBar} wrap={'wrap'}>
|
||||
<Flexbox horizontal className={styles.controlGroup}>
|
||||
<Text fontSize={12} type={'secondary'} weight={600}>
|
||||
View
|
||||
</Text>
|
||||
<Segmented
|
||||
size={'small'}
|
||||
value={view}
|
||||
options={[
|
||||
{ label: 'By API', value: 'api' },
|
||||
{ label: 'Aggregate', value: 'aggregate' },
|
||||
]}
|
||||
onChange={(value) => setView(value as GalleryView)}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal className={styles.controlGroup}>
|
||||
<Text fontSize={12} type={'secondary'} weight={600}>
|
||||
Lifecycle
|
||||
</Text>
|
||||
<Segmented
|
||||
size={'small'}
|
||||
value={mode}
|
||||
options={LIFECYCLE_MODES.map((value) => ({
|
||||
label: LIFECYCLE_MODE_LABEL[value],
|
||||
value,
|
||||
}))}
|
||||
onChange={(value) => setMode(value as LifecycleMode)}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{view === 'api' &&
|
||||
toolset.apis.map((api) => (
|
||||
<ToolPreview api={api} key={`${api.identifier}:${api.apiName}`} mode={mode} />
|
||||
))}
|
||||
</Flexbox>
|
||||
{toolset.toolsetDescription && (
|
||||
<Text fontSize={13} type={'secondary'}>
|
||||
{toolset.toolsetDescription}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal className={styles.modeBar}>
|
||||
<Text fontSize={12} type={'secondary'} weight={600}>
|
||||
Lifecycle
|
||||
</Text>
|
||||
<Segmented
|
||||
size={'small'}
|
||||
value={mode}
|
||||
options={LIFECYCLE_MODES.map((value) => ({
|
||||
label: LIFECYCLE_MODE_LABEL[value],
|
||||
value,
|
||||
}))}
|
||||
onChange={(value) => setMode(value as LifecycleMode)}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
{toolset.apis.map((api) => (
|
||||
<ToolPreview api={api} key={`${api.identifier}:${api.apiName}`} mode={mode} />
|
||||
))}
|
||||
{view === 'aggregate' && <MessageList apis={toolset.apis} mode={mode} />}
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Block, Flexbox, Segmented, Tag, Text } from '@lobehub/ui';
|
||||
import { Flexbox, Segmented, Tag, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Component, type ReactNode, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
bodyKindForMode,
|
||||
deriveFixtureProps,
|
||||
type FixtureBodyKind,
|
||||
type LifecycleMode,
|
||||
type ToolRenderFixtureVariant,
|
||||
} from './lifecycleMode';
|
||||
import { ToolBodySlot, ToolInspectorSlot } from './toolSurfaces';
|
||||
import type { ApiEntry } from './useDevtoolsEntries';
|
||||
import { toApiAnchor } from './useDevtoolsEntries';
|
||||
|
||||
@@ -24,7 +24,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
border-radius: 20px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
box-shadow: ${cssVar.boxShadowSecondary};
|
||||
`,
|
||||
cardBody: css`
|
||||
padding: 20px;
|
||||
@@ -62,15 +61,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
missingShell: css`
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
border: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
border-radius: 12px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
previewShell: css`
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
@@ -82,51 +72,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
`,
|
||||
}));
|
||||
|
||||
class RenderBoundary extends Component<
|
||||
{ children: ReactNode; label: string },
|
||||
{ error?: Error | undefined }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; label: string }) {
|
||||
super(props);
|
||||
this.state = { error: undefined };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<Block padding={16} variant={'outlined'}>
|
||||
<Flexbox gap={8}>
|
||||
<Text fontSize={14} type={'danger'} weight={500}>
|
||||
{this.props.label} crashed
|
||||
</Text>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const coerceInspectorContent = (value: unknown): string | null => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const Missing = ({ kind }: { kind: string }) => (
|
||||
<div className={styles.missingShell}>No {kind} component registered for this API.</div>
|
||||
);
|
||||
|
||||
interface ToolPreviewProps {
|
||||
api: ApiEntry;
|
||||
mode: LifecycleMode;
|
||||
@@ -136,12 +81,6 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
|
||||
const messageId = `devtools-${api.identifier}-${api.apiName}`;
|
||||
const toolCallId = `${messageId}-tool`;
|
||||
|
||||
const Inspector = api.inspector;
|
||||
const Render = api.render;
|
||||
const Streaming = api.streaming;
|
||||
const Placeholder = api.placeholder;
|
||||
const Intervention = api.intervention;
|
||||
|
||||
const variants = api.fixture.variants;
|
||||
const [activeVariantId, setActiveVariantId] = useState<string>(variants[0]?.id ?? 'default');
|
||||
const activeVariant: ToolRenderFixtureVariant =
|
||||
@@ -149,94 +88,6 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
|
||||
|
||||
const derived = useMemo(() => deriveFixtureProps(activeVariant, mode), [activeVariant, mode]);
|
||||
|
||||
const targetBodyKind: FixtureBodyKind = bodyKindForMode(mode);
|
||||
|
||||
const inspectorResult = {
|
||||
content: coerceInspectorContent(activeVariant.content),
|
||||
error: derived.pluginError,
|
||||
state: derived.pluginState,
|
||||
};
|
||||
|
||||
const bodyContent = (() => {
|
||||
switch (targetBodyKind) {
|
||||
case 'streaming': {
|
||||
if (Streaming) {
|
||||
return (
|
||||
<RenderBoundary label={'Streaming'}>
|
||||
<Streaming
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
messageId={messageId}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
);
|
||||
}
|
||||
// No dedicated Streaming slot — fall back to Render shown in streaming state.
|
||||
if (Render) {
|
||||
return (
|
||||
<RenderBoundary label={'Render'}>
|
||||
<Render
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
content={derived.content}
|
||||
identifier={api.identifier}
|
||||
messageId={messageId}
|
||||
pluginError={derived.pluginError}
|
||||
pluginState={derived.pluginState}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
);
|
||||
}
|
||||
return <Missing kind={'streaming'} />;
|
||||
}
|
||||
case 'placeholder': {
|
||||
return Placeholder ? (
|
||||
<RenderBoundary label={'Placeholder'}>
|
||||
<Placeholder apiName={api.apiName} args={derived.args} identifier={api.identifier} />
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
<Missing kind={'placeholder'} />
|
||||
);
|
||||
}
|
||||
case 'intervention': {
|
||||
return Intervention ? (
|
||||
<RenderBoundary label={'Intervention'}>
|
||||
<Intervention
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
interactionMode={'approval'}
|
||||
messageId={messageId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
<Missing kind={'intervention'} />
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return Render ? (
|
||||
<RenderBoundary label={'Render'}>
|
||||
<Render
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
content={derived.content}
|
||||
identifier={api.identifier}
|
||||
messageId={messageId}
|
||||
pluginError={derived.pluginError}
|
||||
pluginState={derived.pluginState}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
<Missing kind={'render'} />
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.card} id={toApiAnchor(api.apiName)}>
|
||||
<Flexbox className={styles.cardHeader}>
|
||||
@@ -272,22 +123,7 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<div className={styles.previewShell}>
|
||||
{Inspector ? (
|
||||
<RenderBoundary label={'Inspector'}>
|
||||
<Inspector
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
isArgumentsStreaming={derived.isArgumentsStreaming}
|
||||
isLoading={derived.isLoading}
|
||||
partialArgs={derived.partialArgs}
|
||||
pluginState={derived.pluginState}
|
||||
result={inspectorResult}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
<Missing kind={'inspector'} />
|
||||
)}
|
||||
<ToolInspectorSlot api={api} derived={derived} variant={activeVariant} />
|
||||
</div>
|
||||
</Flexbox>
|
||||
|
||||
@@ -296,9 +132,17 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
|
||||
<Text fontSize={12} type={'secondary'} weight={600}>
|
||||
Body
|
||||
</Text>
|
||||
<Tag>{targetBodyKind}</Tag>
|
||||
<Tag>{bodyKindForMode(mode)}</Tag>
|
||||
</Flexbox>
|
||||
<div className={styles.previewShell}>{bodyContent}</div>
|
||||
<div className={styles.previewShell}>
|
||||
<ToolBodySlot
|
||||
api={api}
|
||||
derived={derived}
|
||||
messageId={messageId}
|
||||
mode={mode}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -75,6 +75,14 @@ export default defineFixtures({
|
||||
description: 'Look up deferred tools by name or keyword.',
|
||||
name: 'ToolSearch',
|
||||
},
|
||||
{
|
||||
description: 'Fetch a URL and answer a prompt about it.',
|
||||
name: 'WebFetch',
|
||||
},
|
||||
{
|
||||
description: 'Search the web.',
|
||||
name: 'WebSearch',
|
||||
},
|
||||
{
|
||||
description: 'Write a new file.',
|
||||
name: 'Write',
|
||||
@@ -366,6 +374,22 @@ export default defineFixtures({
|
||||
args: { max_results: 5, query: 'select:Read,Edit,Grep' },
|
||||
content: 'Loaded 3 deferred tool schemas: Read, Edit, Grep.',
|
||||
}),
|
||||
WebFetch: single({
|
||||
args: {
|
||||
prompt: 'Summarize the key changes in the latest release.',
|
||||
url: 'https://github.com/lobehub/lobe-chat/releases/latest',
|
||||
},
|
||||
content:
|
||||
'## LobeChat v1.0\n\n- New agent runtime with tool streaming\n- Faster cold start\n- Fixed a memory leak in the chat store',
|
||||
}),
|
||||
WebSearch: single({
|
||||
args: {
|
||||
allowed_domains: ['developer.mozilla.org'],
|
||||
query: 'CSS container queries browser support',
|
||||
},
|
||||
content:
|
||||
'1. Container queries — MDN — developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment\n2. Can I use: CSS Container Queries — caniuse.com/css-container-queries',
|
||||
}),
|
||||
Write: single({
|
||||
args: {
|
||||
content: "export const previewEnabled = process.env.NODE_ENV === 'development';\n",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { builtinTools } from '@lobechat/builtin-tools';
|
||||
import { DEFAULT_INBOX_AVATAR } from '@lobechat/const';
|
||||
import type { BuiltinToolManifest, LobeChatPluginApi } from '@lobechat/types';
|
||||
|
||||
import type { ToolRenderFixture } from '../lifecycleMode';
|
||||
@@ -40,6 +41,19 @@ export interface ToolRenderMeta {
|
||||
|
||||
export const DEVTOOLS_GROUP_ID = 'devtools-preview-group';
|
||||
|
||||
/**
|
||||
* Identity for the seeded Aggregate-preview conversation. The fixture messages
|
||||
* resolve their avatar/name through this agentId, so seeding `agentMap` with
|
||||
* this meta makes the preview turn read as "Lobe AI" instead of the
|
||||
* unresolved-agent fallback ("Unnamed Assistant").
|
||||
*/
|
||||
export const DEVTOOLS_AGENT_ID = 'devtools-render-gallery';
|
||||
|
||||
export const DEVTOOLS_AGENT_META = {
|
||||
avatar: DEFAULT_INBOX_AVATAR,
|
||||
title: 'Lobe AI',
|
||||
};
|
||||
|
||||
export const DEVTOOLS_GROUP_DETAIL = {
|
||||
agents: [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineFixtures({
|
||||
fixtures: {
|
||||
callAgent: single({
|
||||
args: {
|
||||
agentId: 'agent_workspace_helper',
|
||||
instruction:
|
||||
'Review the `/devtools` route and list any preview cards that still need richer fixtures.',
|
||||
},
|
||||
@@ -22,6 +23,10 @@ export default defineFixtures({
|
||||
},
|
||||
}),
|
||||
duplicateAgent: single({
|
||||
args: {
|
||||
agentId: 'agent_workspace_helper',
|
||||
newTitle: 'Workspace Helper Copy',
|
||||
},
|
||||
pluginState: {
|
||||
newAgentId: 'agent_preview_clone',
|
||||
sourceAgentId: 'agent_workspace_helper',
|
||||
@@ -29,6 +34,9 @@ export default defineFixtures({
|
||||
},
|
||||
}),
|
||||
getAgentDetail: single({
|
||||
args: {
|
||||
agentId: 'agent_preview_specialist',
|
||||
},
|
||||
pluginState: {
|
||||
config: {
|
||||
model: 'gpt-5.4',
|
||||
@@ -46,6 +54,11 @@ export default defineFixtures({
|
||||
},
|
||||
}),
|
||||
installPlugin: single({
|
||||
args: {
|
||||
agentId: 'agent_preview_specialist',
|
||||
identifier: 'lobe-cloud-sandbox',
|
||||
source: 'official',
|
||||
},
|
||||
pluginState: {
|
||||
installed: true,
|
||||
pluginId: 'lobe-cloud-sandbox',
|
||||
@@ -53,6 +66,10 @@ export default defineFixtures({
|
||||
},
|
||||
}),
|
||||
searchAgent: single({
|
||||
args: {
|
||||
keyword: 'preview',
|
||||
source: 'all',
|
||||
},
|
||||
pluginState: {
|
||||
agents: [
|
||||
{
|
||||
@@ -76,6 +93,7 @@ export default defineFixtures({
|
||||
}),
|
||||
updateAgent: single({
|
||||
args: {
|
||||
agentId: 'agent_preview_specialist',
|
||||
config: JSON.stringify({
|
||||
model: 'gpt-5.4',
|
||||
systemRole: 'Prioritize maintainable developer tooling and preview coverage.',
|
||||
@@ -88,6 +106,7 @@ export default defineFixtures({
|
||||
}),
|
||||
updatePrompt: single({
|
||||
args: {
|
||||
agentId: 'agent_preview_specialist',
|
||||
prompt:
|
||||
'When asked for a visual check, prefer building a reusable preview harness before taking a screenshot.',
|
||||
},
|
||||
|
||||
@@ -5,23 +5,25 @@ import { defineFixtures, single, variants } from './_helpers';
|
||||
export default defineFixtures({
|
||||
identifier: 'lobe-local-system',
|
||||
fixtures: {
|
||||
editLocalFile: single({
|
||||
editFile: single({
|
||||
args: { path: '/workspace/src/spa/router/desktopRouter.config.tsx' },
|
||||
pluginState: {
|
||||
diffText:
|
||||
"--- a/workspace/src/spa/router/desktopRouter.config.tsx\n+++ b/workspace/src/spa/router/desktopRouter.config.tsx\n@@ -1,3 +1,7 @@\n export const desktopRoutes = [\n+ {\n+ path: 'devtools',\n+ },\n ];\n",
|
||||
},
|
||||
}),
|
||||
listLocalFiles: single({
|
||||
listFiles: single({
|
||||
args: { path: '/workspace' },
|
||||
pluginState: {
|
||||
files: [
|
||||
{ isDirectory: true, name: 'src' },
|
||||
{ isDirectory: false, name: 'package.json', size: 1320 },
|
||||
{ isDirectory: false, name: 'README.md', size: 4096 },
|
||||
{ isDirectory: true, name: 'src', path: '/workspace/src' },
|
||||
{ isDirectory: false, name: 'package.json', path: '/workspace/package.json', size: 1320 },
|
||||
{ isDirectory: false, name: 'README.md', path: '/workspace/README.md', size: 4096 },
|
||||
],
|
||||
totalCount: 3,
|
||||
},
|
||||
}),
|
||||
moveLocalFiles: single({
|
||||
moveFiles: single({
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
@@ -31,7 +33,7 @@ export default defineFixtures({
|
||||
],
|
||||
},
|
||||
}),
|
||||
readLocalFile: single({
|
||||
readFile: single({
|
||||
args: { path: '/workspace/src/routes/(main)/devtools/index.tsx' },
|
||||
pluginState: {
|
||||
content:
|
||||
@@ -54,7 +56,7 @@ export default defineFixtures({
|
||||
success: true,
|
||||
},
|
||||
}),
|
||||
searchLocalFiles: variants([
|
||||
searchFiles: variants([
|
||||
{
|
||||
args: { keywords: 'quarterly report sample' },
|
||||
label: 'Multiple matches',
|
||||
@@ -97,7 +99,7 @@ export default defineFixtures({
|
||||
},
|
||||
},
|
||||
]),
|
||||
writeLocalFile: single({
|
||||
writeFile: single({
|
||||
args: {
|
||||
content: 'export const devtoolsEnabled = process.env.NODE_ENV === "development";\n',
|
||||
path: '/workspace/src/routes/(main)/devtools/flags.ts',
|
||||
|
||||
@@ -5,16 +5,26 @@ import { createStaticStyles } from 'antd-style';
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
|
||||
import { DEVTOOLS_GROUP_DETAIL, DEVTOOLS_GROUP_ID } from './fixtures';
|
||||
import {
|
||||
DEVTOOLS_AGENT_ID,
|
||||
DEVTOOLS_AGENT_META,
|
||||
DEVTOOLS_GROUP_DETAIL,
|
||||
DEVTOOLS_GROUP_ID,
|
||||
} from './fixtures';
|
||||
import Sidebar from './Sidebar';
|
||||
import { toToolsetPath, useDevtoolsEntries } from './useDevtoolsEntries';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
main: css`
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
background:
|
||||
radial-gradient(circle at top, ${cssVar.colorFillTertiary} 0%, transparent 35%),
|
||||
${cssVar.colorBgLayout};
|
||||
@@ -22,7 +32,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
page: css`
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
/* Bind to the viewport directly so the columns scroll internally regardless
|
||||
of whether the mounting route provides a bounded height. */
|
||||
height: 100dvh;
|
||||
`,
|
||||
}));
|
||||
|
||||
@@ -42,11 +55,19 @@ const DevtoolsLayout = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Seed the Aggregate-preview agent meta so its turns read as "Lobe AI"
|
||||
// (avatar + name) instead of the unresolved-agent fallback.
|
||||
const previousAgentMap = useAgentStore.getState().agentMap;
|
||||
useAgentStore.setState({
|
||||
agentMap: { ...previousAgentMap, [DEVTOOLS_AGENT_ID]: DEVTOOLS_AGENT_META as any },
|
||||
});
|
||||
|
||||
return () => {
|
||||
useAgentGroupStore.setState({
|
||||
activeGroupId: previousGroupState.activeGroupId,
|
||||
groupMap: previousGroupState.groupMap,
|
||||
});
|
||||
useAgentStore.setState({ agentMap: previousAgentMap });
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { Block, Flexbox, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Component, memo, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
bodyKindForMode,
|
||||
type DerivedFixtureProps,
|
||||
type LifecycleMode,
|
||||
type ToolRenderFixtureVariant,
|
||||
} from './lifecycleMode';
|
||||
import type { ApiEntry } from './useDevtoolsEntries';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
missingShell: css`
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
border: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
border-radius: 12px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
/** Catches a render/inspector crash so one bad fixture can't blank the page. */
|
||||
export class RenderBoundary extends Component<
|
||||
{ children: ReactNode; label: string },
|
||||
{ error?: Error | undefined }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; label: string }) {
|
||||
super(props);
|
||||
this.state = { error: undefined };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<Block padding={16} variant={'outlined'}>
|
||||
<Flexbox gap={8}>
|
||||
<Text fontSize={14} type={'danger'} weight={500}>
|
||||
{this.props.label} crashed
|
||||
</Text>
|
||||
<Text fontSize={12} type={'secondary'}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Missing = ({ kind }: { kind: string }) => (
|
||||
<div className={styles.missingShell}>No {kind} component registered for this API.</div>
|
||||
);
|
||||
|
||||
const coerceInspectorContent = (value: unknown): string | null => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
interface ToolInspectorSlotProps {
|
||||
api: ApiEntry;
|
||||
derived: DerivedFixtureProps;
|
||||
variant: ToolRenderFixtureVariant;
|
||||
}
|
||||
|
||||
/** Renders the API's Inspector with the lifecycle-derived props, or a Missing hint. */
|
||||
export const ToolInspectorSlot = memo<ToolInspectorSlotProps>(({ api, derived, variant }) => {
|
||||
const Inspector = api.inspector;
|
||||
if (!Inspector) return <Missing kind={'inspector'} />;
|
||||
|
||||
return (
|
||||
<RenderBoundary label={'Inspector'}>
|
||||
<Inspector
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
isArgumentsStreaming={derived.isArgumentsStreaming}
|
||||
isLoading={derived.isLoading}
|
||||
partialArgs={derived.partialArgs}
|
||||
pluginState={derived.pluginState}
|
||||
result={{
|
||||
content: coerceInspectorContent(variant.content),
|
||||
error: derived.pluginError,
|
||||
state: derived.pluginState,
|
||||
}}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
ToolInspectorSlot.displayName = 'ToolInspectorSlot';
|
||||
|
||||
interface ToolBodySlotProps {
|
||||
api: ApiEntry;
|
||||
derived: DerivedFixtureProps;
|
||||
/** Aggregate flow renders nothing for an absent slot instead of a Missing hint. */
|
||||
hideMissing?: boolean;
|
||||
messageId: string;
|
||||
mode: LifecycleMode;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the API's body surface for the active lifecycle mode — the dedicated
|
||||
* Streaming / Placeholder / Intervention component when the mode targets one,
|
||||
* otherwise the Render. Streaming falls back to the Render shown mid-stream when
|
||||
* no Streaming slot exists.
|
||||
*/
|
||||
export const ToolBodySlot = memo<ToolBodySlotProps>(
|
||||
({ api, derived, mode, messageId, toolCallId, hideMissing }) => {
|
||||
const missing = (kind: string) => (hideMissing ? null : <Missing kind={kind} />);
|
||||
|
||||
const renderSlot = () =>
|
||||
api.render ? (
|
||||
<RenderBoundary label={'Render'}>
|
||||
<api.render
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
content={derived.content}
|
||||
identifier={api.identifier}
|
||||
messageId={messageId}
|
||||
pluginError={derived.pluginError}
|
||||
pluginState={derived.pluginState}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
missing('render')
|
||||
);
|
||||
|
||||
switch (bodyKindForMode(mode)) {
|
||||
case 'streaming': {
|
||||
if (api.streaming) {
|
||||
return (
|
||||
<RenderBoundary label={'Streaming'}>
|
||||
<api.streaming
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
messageId={messageId}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
);
|
||||
}
|
||||
// No dedicated Streaming slot — fall back to the Render shown mid-stream.
|
||||
return api.render ? renderSlot() : missing('streaming');
|
||||
}
|
||||
case 'placeholder': {
|
||||
return api.placeholder ? (
|
||||
<RenderBoundary label={'Placeholder'}>
|
||||
<api.placeholder
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
missing('placeholder')
|
||||
);
|
||||
}
|
||||
case 'intervention': {
|
||||
return api.intervention ? (
|
||||
<RenderBoundary label={'Intervention'}>
|
||||
<api.intervention
|
||||
apiName={api.apiName}
|
||||
args={derived.args}
|
||||
identifier={api.identifier}
|
||||
interactionMode={'approval'}
|
||||
messageId={messageId}
|
||||
/>
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
missing('intervention')
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return renderSlot();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ToolBodySlot.displayName = 'ToolBodySlot';
|
||||
@@ -42,6 +42,18 @@ export interface DevtoolsEntries {
|
||||
toolsetMap: Map<string, ToolsetEntry>;
|
||||
}
|
||||
|
||||
/** Toolsets that still ship renders but are deprecated — hidden from the gallery. */
|
||||
const DEPRECATED_TOOLSETS = new Set(['lobe-notebook']);
|
||||
|
||||
/**
|
||||
* Legacy `*Local*` aliases (e.g. `grepLocalFiles`, `listLocalFiles`) only stay
|
||||
* registered so historical DB messages keep rendering after the rename — they
|
||||
* have no manifest/fixture, so they show up as empty cards. Current local-system
|
||||
* API names carry no `Local` marker, so hiding by that marker is safe.
|
||||
*/
|
||||
const isDeprecatedApi = (identifier: string, apiName: string) =>
|
||||
identifier === 'lobe-local-system' && apiName.includes('Local');
|
||||
|
||||
export const toToolsetPath = (identifier: string) => `/devtools/${encodeURIComponent(identifier)}`;
|
||||
|
||||
export const toApiAnchor = (apiName: string) => `api-${apiName}`;
|
||||
@@ -106,6 +118,9 @@ export const useDevtoolsEntries = (): DevtoolsEntries =>
|
||||
render,
|
||||
streaming,
|
||||
} of byKey.values()) {
|
||||
if (DEPRECATED_TOOLSETS.has(identifier)) continue;
|
||||
if (isDeprecatedApi(identifier, apiName)) continue;
|
||||
|
||||
const meta = getToolRenderMeta(identifier, apiName);
|
||||
const fixture = getToolRenderFixture(identifier, apiName, meta.api);
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ export default {
|
||||
'builtins.lobe-agent.title': 'Lobe Agent',
|
||||
'builtins.lobe-claude-code.agent.instruction': 'Instruction',
|
||||
'builtins.lobe-claude-code.agent.result': 'Result',
|
||||
'builtins.lobe-claude-code.askUserQuestion.noAnswer':
|
||||
'No answer received — model continued without their input.',
|
||||
'builtins.lobe-claude-code.askUserQuestion.question': 'Question',
|
||||
'builtins.lobe-claude-code.askUserQuestion.reply': 'Reply',
|
||||
'builtins.lobe-claude-code.askUserQuestion.selected': 'Selected',
|
||||
'builtins.lobe-claude-code.task.create.completed': 'Task created: ',
|
||||
'builtins.lobe-claude-code.task.create.loading': 'Creating task: ',
|
||||
'builtins.lobe-claude-code.task.getLabel': 'View task #{{taskId}}',
|
||||
|
||||
@@ -328,6 +328,11 @@ export default {
|
||||
'devices.actions.edit': 'Edit',
|
||||
'devices.actions.remove': 'Remove',
|
||||
'devices.channel.connected': 'Connected {{time}}',
|
||||
'devices.currentBadge': 'This device',
|
||||
'devices.detail.connections': 'Connections',
|
||||
'devices.detail.noRecent': 'No recent directories',
|
||||
'devices.detail.recentDirs': 'Recent directories',
|
||||
'devices.edit.browse': 'Browse…',
|
||||
'devices.edit.cancel': 'Cancel',
|
||||
'devices.edit.defaultCwd': 'Default working directory',
|
||||
'devices.edit.defaultCwdPlaceholder': 'e.g. /Users/me/projects',
|
||||
|
||||
+42
-22
@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
|
||||
import CloudRepoSwitcher from '@/features/ChatInput/RuntimeConfig/CloudRepoSwitcher';
|
||||
import DeviceWorkingDirectory from '@/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory';
|
||||
import GitStatus from '@/features/ChatInput/RuntimeConfig/GitStatus';
|
||||
import HeteroDeviceSwitcher from '@/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher';
|
||||
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
|
||||
@@ -84,6 +85,12 @@ const WorkingDirectoryBar = memo(() => {
|
||||
const enableExecutionDeviceSwitcher = useUserStore(
|
||||
labPreferSelectors.enableExecutionDeviceSwitcher,
|
||||
);
|
||||
const agencyConfig = useAgentStore((s) =>
|
||||
agentId ? agentByIdSelectors.getAgencyConfigById(agentId)(s) : undefined,
|
||||
);
|
||||
// Runs dispatched to a remote device can't browse the local filesystem — use
|
||||
// the device-scoped picker (recent dirs + manual input) instead.
|
||||
const isDeviceMode = agencyConfig?.executionTarget === 'device' && !!agencyConfig?.boundDeviceId;
|
||||
|
||||
const repoType = useRepoType(effectiveWorkingDirectory);
|
||||
|
||||
@@ -101,7 +108,11 @@ const WorkingDirectoryBar = memo(() => {
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{enableExecutionDeviceSwitcher && <HeteroDeviceSwitcher agentId={agentId} />}
|
||||
<CloudRepoSwitcher agentId={agentId} />
|
||||
{isDeviceMode ? (
|
||||
<DeviceWorkingDirectory agentId={agentId} />
|
||||
) : (
|
||||
<CloudRepoSwitcher agentId={agentId} />
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
@@ -139,28 +150,37 @@ const WorkingDirectoryBar = memo(() => {
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
{enableExecutionDeviceSwitcher && <HeteroDeviceSwitcher agentId={agentId} />}
|
||||
<Popover
|
||||
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
dirButton
|
||||
) : (
|
||||
<Tooltip
|
||||
title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}
|
||||
>
|
||||
{dirButton}
|
||||
</Tooltip>
|
||||
{isDeviceMode ? (
|
||||
// A remote device's filesystem isn't browsable from here — use the
|
||||
// device-scoped picker (recent dirs + manual input) instead of the
|
||||
// local folder picker + git status.
|
||||
<DeviceWorkingDirectory agentId={agentId} />
|
||||
) : (
|
||||
<>
|
||||
<Popover
|
||||
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
dirButton
|
||||
) : (
|
||||
<Tooltip
|
||||
title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}
|
||||
>
|
||||
{dirButton}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
|
||||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
|
||||
|
||||
@@ -44,20 +44,27 @@ const HeterogeneousChatInput = memo(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const providerType = useAgentStore(agentSelectors.currentAgentHeterogeneousProviderType);
|
||||
const executionTarget = useAgentStore(agentSelectors.currentAgentExecutionTarget);
|
||||
const isRemoteAgent = !!providerType && isRemoteHeterogeneousType(providerType);
|
||||
|
||||
const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isRemoteAgent });
|
||||
// A run goes to an `lh connect` device when the provider is a remote-only type
|
||||
// (openclaw / hermes) OR a local-CLI type (claude-code / codex) explicitly
|
||||
// targeted at a device. Either way the bound device must be online before we
|
||||
// let the user send — guard it here instead of failing at dispatch time.
|
||||
const isDeviceExecution = isRemoteAgent || executionTarget === 'device';
|
||||
|
||||
const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isDeviceExecution });
|
||||
|
||||
const goToAgentProfile = () => {
|
||||
if (params.aid) navigate(urlJoin('/agent', params.aid, 'profile'));
|
||||
};
|
||||
|
||||
const deviceBlocked =
|
||||
isRemoteAgent &&
|
||||
isDeviceExecution &&
|
||||
(status === 'device-offline' || status === 'platform-unavailable' || status === 'no-device');
|
||||
|
||||
const renderDeviceGuard = () => {
|
||||
if (!isRemoteAgent || !deviceBlocked) return null;
|
||||
if (!deviceBlocked) return null;
|
||||
|
||||
let title: string;
|
||||
let desc: string;
|
||||
@@ -69,7 +76,9 @@ const HeterogeneousChatInput = memo(() => {
|
||||
title = t('platformAgent.deviceGuard.deviceOffline.title');
|
||||
desc = t('platformAgent.deviceGuard.deviceOffline.desc');
|
||||
} else {
|
||||
const name = HETEROGENEOUS_TYPE_LABELS[providerType] ?? providerType;
|
||||
// `platform-unavailable` only arises for remote-typed agents (the guard's
|
||||
// capability check), so providerType is always set here — fall back safely.
|
||||
const name = (providerType && HETEROGENEOUS_TYPE_LABELS[providerType]) || providerType || '';
|
||||
title = t('platformAgent.deviceGuard.platformUnavailable.title', { name });
|
||||
desc = t('platformAgent.deviceGuard.platformUnavailable.desc', { name });
|
||||
}
|
||||
@@ -97,11 +106,13 @@ const HeterogeneousChatInput = memo(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const inputDisabled = (!isConfigured && !isRemoteAgent) || deviceBlocked;
|
||||
// Device execution doesn't use the cloud sandbox, so it doesn't need cloud
|
||||
// credentials — only the sandbox path gates on `isConfigured`.
|
||||
const inputDisabled = (!isConfigured && !isDeviceExecution) || deviceBlocked;
|
||||
|
||||
return (
|
||||
<Flexbox>
|
||||
{!isRemoteAgent && !isConfigured && (
|
||||
{!isDeviceExecution && !isConfigured && (
|
||||
<WideScreenContainer>
|
||||
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
|
||||
<Alert
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { ActionIcon, Button, Flexbox, Icon, Input, Tag, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { FolderOpenIcon, XIcon } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { nextRecentCwds } from '@/features/ChatInput/RuntimeConfig/deviceCwd';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
|
||||
import type { DeviceListItem } from './DeviceItem';
|
||||
import { getDeviceIcon } from './getDeviceIcon';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
`,
|
||||
dot: css`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
`,
|
||||
header: css`
|
||||
padding-block-end: 16px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
label: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
path: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
recentRow: css`
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
removeBtn: css`
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DeviceDetailPanelProps {
|
||||
device: DeviceListItem;
|
||||
isCurrent?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DeviceDetailPanel = memo<DeviceDetailPanelProps>(({ device, isCurrent, onClose }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const utils = lambdaQuery.useUtils();
|
||||
|
||||
const [name, setName] = useState(device.friendlyName ?? '');
|
||||
const [cwd, setCwd] = useState(device.defaultCwd ?? '');
|
||||
|
||||
const update = lambdaQuery.device.updateDevice.useMutation({
|
||||
onSuccess: () => utils.device.listDevices.invalidate(),
|
||||
});
|
||||
|
||||
// Only the machine you're on can browse its own filesystem natively.
|
||||
const canBrowse = !!isCurrent && isDesktop;
|
||||
|
||||
// Render the device's live connections straight from `device.channels` — one
|
||||
// row per connection; an empty array means offline.
|
||||
const channels = device.channels ?? [];
|
||||
|
||||
const isDirty = name !== (device.friendlyName ?? '') || cwd !== (device.defaultCwd ?? '');
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = cwd.trim();
|
||||
await update.mutateAsync({
|
||||
defaultCwd: trimmed || null,
|
||||
deviceId: device.deviceId,
|
||||
friendlyName: name.trim() || null,
|
||||
// Setting a default cwd also seeds the recent list.
|
||||
recentCwds: trimmed ? nextRecentCwds(trimmed, device.recentCwds) : device.recentCwds,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const result = await electronSystemService.selectFolder({
|
||||
defaultPath: cwd.trim() || undefined,
|
||||
title: t('devices.edit.defaultCwd'),
|
||||
});
|
||||
if (result?.path) setCwd(result.path);
|
||||
};
|
||||
|
||||
const handleRemoveRecent = (path: string) => {
|
||||
update.mutate({
|
||||
deviceId: device.deviceId,
|
||||
recentCwds: device.recentCwds.filter((p) => p !== path),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={20}>
|
||||
{/* ─── Header ─── */}
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
{getDeviceIcon(device.platform)}
|
||||
<Text ellipsis style={{ flex: 1, minWidth: 0 }} weight={600}>
|
||||
{device.friendlyName || device.hostname || device.deviceId}
|
||||
</Text>
|
||||
{isCurrent && <Tag>{t('devices.currentBadge')}</Tag>}
|
||||
<ActionIcon icon={XIcon} size={'small'} onClick={onClose} />
|
||||
</Flexbox>
|
||||
|
||||
{/* ─── Connections ─── */}
|
||||
<Flexbox gap={8}>
|
||||
<span className={styles.label}>{t('devices.detail.connections')}</span>
|
||||
{channels.length > 0 ? (
|
||||
channels.map((channel, index) => (
|
||||
<Flexbox horizontal align={'center'} gap={8} key={`${channel.connectedAt}-${index}`}>
|
||||
<span
|
||||
className={styles.dot}
|
||||
style={{ background: cssVar.colorSuccess, flex: 'none' }}
|
||||
/>
|
||||
{channel.channel && <Tag size={'small'}>{channel.channel}</Tag>}
|
||||
<Text style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{t('devices.channel.connected', { time: dayjs(channel.connectedAt).fromNow() })}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
))
|
||||
) : (
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<span
|
||||
className={styles.dot}
|
||||
style={{ background: cssVar.colorTextQuaternary, flex: 'none' }}
|
||||
/>
|
||||
<Text style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{t('devices.status.offline')} ·{' '}
|
||||
{t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() })}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
{/* ─── Name ─── */}
|
||||
<Flexbox gap={6}>
|
||||
<span className={styles.label}>{t('devices.edit.friendlyName')}</span>
|
||||
<Input
|
||||
placeholder={t('devices.edit.friendlyNamePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
{/* ─── Default working directory ─── */}
|
||||
<Flexbox gap={6}>
|
||||
<span className={styles.label}>{t('devices.edit.defaultCwd')}</span>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input
|
||||
placeholder={t('devices.edit.defaultCwdPlaceholder')}
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
{canBrowse && (
|
||||
<Button icon={<Icon icon={FolderOpenIcon} />} onClick={handleBrowse}>
|
||||
{t('devices.edit.browse')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{/* ─── Recent directories ─── */}
|
||||
<Flexbox gap={6}>
|
||||
<span className={styles.label}>{t('devices.detail.recentDirs')}</span>
|
||||
{device.recentCwds.length === 0 ? (
|
||||
<Text style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{t('devices.detail.noRecent')}
|
||||
</Text>
|
||||
) : (
|
||||
device.recentCwds.map((path) => (
|
||||
<Flexbox horizontal align={'center'} className={styles.recentRow} gap={8} key={path}>
|
||||
<Text
|
||||
className={styles.path}
|
||||
style={{ color: cssVar.colorTextSecondary, cursor: 'pointer', flex: 1 }}
|
||||
onClick={() => setCwd(path)}
|
||||
>
|
||||
{path}
|
||||
</Text>
|
||||
<Icon
|
||||
className={styles.removeBtn}
|
||||
icon={XIcon}
|
||||
size={14}
|
||||
onClick={() => handleRemoveRecent(path)}
|
||||
/>
|
||||
</Flexbox>
|
||||
))
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
{/* ─── Save ─── */}
|
||||
{isDirty && (
|
||||
<Flexbox horizontal justify={'flex-end'}>
|
||||
<Button loading={update.isPending} type={'primary'} onClick={handleSave}>
|
||||
{t('devices.edit.save')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
DeviceDetailPanel.displayName = 'DeviceDetailPanel';
|
||||
|
||||
export default DeviceDetailPanel;
|
||||
@@ -1,17 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, DropdownMenu, Flexbox, Icon, Input, Tag, Text, Tooltip } from '@lobehub/ui';
|
||||
import { confirmModal, Modal } from '@lobehub/ui/base-ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { ActionIcon, DropdownMenu, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
FolderIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilLineIcon,
|
||||
Trash2Icon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { FolderIcon, MoreVerticalIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
@@ -40,10 +34,6 @@ export interface DeviceListItem {
|
||||
}
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
channels: css`
|
||||
margin-block-start: 4px;
|
||||
padding-inline-start: 30px;
|
||||
`,
|
||||
cwd: css`
|
||||
overflow: hidden;
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
@@ -67,55 +57,52 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
row: css`
|
||||
cursor: pointer;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 12px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
rowActive: css`
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => {
|
||||
interface DeviceItemProps {
|
||||
device: DeviceListItem;
|
||||
isCurrent?: boolean;
|
||||
onSelect: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const DeviceItem = memo<DeviceItemProps>(({ device, isCurrent, onSelect, selected }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const utils = lambdaQuery.useUtils();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [cwd, setCwd] = useState('');
|
||||
|
||||
const updateDevice = lambdaQuery.device.updateDevice.useMutation({
|
||||
onSuccess: () => utils.device.listDevices.invalidate(),
|
||||
});
|
||||
const removeDevice = lambdaQuery.device.removeDevice.useMutation({
|
||||
onSuccess: () => utils.device.listDevices.invalidate(),
|
||||
});
|
||||
|
||||
const displayName = device.friendlyName || device.hostname || device.deviceId;
|
||||
const isFallback = device.identitySource === 'fallback';
|
||||
// `channels` may be absent when the backend predates the channel-aware
|
||||
// `listDevices` shape; fall back to a single synthetic channel when online.
|
||||
const channels =
|
||||
device.channels ??
|
||||
(device.online
|
||||
? [{ channel: null, connectedAt: device.lastSeen, hostname: null, platform: null }]
|
||||
: []);
|
||||
|
||||
const openEdit = () => {
|
||||
setName(device.friendlyName ?? '');
|
||||
setCwd(device.defaultCwd ?? '');
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateDevice.mutateAsync({
|
||||
defaultCwd: cwd.trim() || null,
|
||||
deviceId: device.deviceId,
|
||||
friendlyName: name.trim() || null,
|
||||
});
|
||||
setEditOpen(false);
|
||||
};
|
||||
// Online when the device has at least one live connection in `device.channels`.
|
||||
const channels = device.channels ?? [];
|
||||
const online = channels.length > 0;
|
||||
const statusTooltip = online
|
||||
? t('devices.channel.connected', {
|
||||
time: dayjs(channels[0]?.connectedAt ?? device.lastSeen).fromNow(),
|
||||
})
|
||||
: t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() });
|
||||
|
||||
const handleRemove = () =>
|
||||
confirmModal({
|
||||
@@ -129,66 +116,43 @@ const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flexbox horizontal align={'flex-start'} className={styles.row} gap={12}>
|
||||
<span className={styles.icon} style={{ marginBlockStart: 2 }}>
|
||||
{getDeviceIcon(device.platform)}
|
||||
</span>
|
||||
<Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<Text ellipsis weight={500}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{isFallback && (
|
||||
<Tooltip title={t('devices.fallbackTooltip')}>
|
||||
<Tag icon={<Icon icon={TriangleAlertIcon} />}>{t('devices.fallbackBadge')}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flexbox>
|
||||
{device.defaultCwd && (
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
<Icon icon={FolderIcon} size={12} style={{ color: cssVar.colorTextQuaternary }} />
|
||||
<Text className={styles.cwd} style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{device.defaultCwd}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
className={cx(styles.row, selected && styles.rowActive)}
|
||||
gap={12}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className={styles.icon} style={{ marginBlockStart: 2 }}>
|
||||
{getDeviceIcon(device.platform)}
|
||||
</span>
|
||||
<Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<Text ellipsis weight={500}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Tooltip title={statusTooltip}>
|
||||
<span className={online ? styles.dotOnline : styles.dotOffline} />
|
||||
</Tooltip>
|
||||
{isCurrent && <Tag>{t('devices.currentBadge')}</Tag>}
|
||||
{isFallback && (
|
||||
<Tooltip title={t('devices.fallbackTooltip')}>
|
||||
<Tag icon={<Icon icon={TriangleAlertIcon} />}>{t('devices.fallbackBadge')}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Flexbox className={styles.channels} gap={6}>
|
||||
{channels.length > 0 ? (
|
||||
channels.map((channel, index) => (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={6}
|
||||
key={`${channel.connectedAt}-${index}`}
|
||||
>
|
||||
<span className={styles.dotOnline} />
|
||||
<Text style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{channel.channel ? `${channel.channel} · ` : ''}
|
||||
{t('devices.channel.connected', { time: dayjs(channel.connectedAt).fromNow() })}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
))
|
||||
) : (
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
<span className={styles.dotOffline} />
|
||||
<Text style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{t('devices.status.offline')} ·{' '}
|
||||
{t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() })}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{device.defaultCwd && (
|
||||
<Flexbox horizontal align={'center'} gap={6}>
|
||||
<Icon icon={FolderIcon} size={12} style={{ color: cssVar.colorTextQuaternary }} />
|
||||
<Text className={styles.cwd} style={{ fontSize: 12 }} type={'secondary'}>
|
||||
{device.defaultCwd}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu
|
||||
items={[
|
||||
{
|
||||
icon: <Icon icon={PencilLineIcon} />,
|
||||
key: 'edit',
|
||||
label: t('devices.actions.edit'),
|
||||
onClick: openEdit,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash2Icon} />,
|
||||
@@ -200,37 +164,8 @@ const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => {
|
||||
>
|
||||
<ActionIcon icon={MoreVerticalIcon} />
|
||||
</DropdownMenu>
|
||||
</Flexbox>
|
||||
<Modal
|
||||
cancelText={t('devices.edit.cancel')}
|
||||
confirmLoading={updateDevice.isPending}
|
||||
okText={t('devices.edit.save')}
|
||||
open={editOpen}
|
||||
title={t('devices.edit.title')}
|
||||
width={440}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Flexbox gap={16} paddingBlock={8}>
|
||||
<Flexbox gap={6}>
|
||||
<Text weight={500}>{t('devices.edit.friendlyName')}</Text>
|
||||
<Input
|
||||
placeholder={t('devices.edit.friendlyNamePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox gap={6}>
|
||||
<Text weight={500}>{t('devices.edit.defaultCwd')}</Text>
|
||||
<Input
|
||||
placeholder={t('devices.edit.defaultCwdPlaceholder')}
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Modal>
|
||||
</>
|
||||
</span>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { Flexbox, Skeleton, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import DeviceDetailPanel from './DeviceDetailPanel';
|
||||
import DeviceItem from './DeviceItem';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
detailCol: css`
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
`,
|
||||
@@ -19,6 +24,11 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
text-align: center;
|
||||
`,
|
||||
listCol: css`
|
||||
min-width: 0;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
`,
|
||||
}));
|
||||
|
||||
const DeviceList = memo(() => {
|
||||
@@ -27,6 +37,18 @@ const DeviceList = memo(() => {
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Identify which row is the machine the user is on right now (desktop only —
|
||||
// the web client isn't itself a registered device), so it can be badged and
|
||||
// offered a native folder picker for its working directory.
|
||||
const useFetchDeviceInfo = useElectronStore((s) => s.useFetchGatewayDeviceInfo);
|
||||
const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo);
|
||||
useFetchDeviceInfo();
|
||||
const currentDeviceId = isDesktop ? gatewayDeviceInfo?.deviceId : undefined;
|
||||
|
||||
// No device is selected by default — the detail panel only appears once the
|
||||
// user clicks a row.
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
|
||||
if (isLoading) return <Skeleton active paragraph={{ rows: 4 }} title={false} />;
|
||||
|
||||
if (!devices || devices.length === 0)
|
||||
@@ -36,11 +58,35 @@ const DeviceList = memo(() => {
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
const selected = selectedId ? devices.find((d) => d.deviceId === selectedId) : undefined;
|
||||
const isCurrent = (id: string) => !!currentDeviceId && id === currentDeviceId;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} padding={4}>
|
||||
{devices.map((device) => (
|
||||
<DeviceItem device={device} key={device.deviceId} />
|
||||
))}
|
||||
<Flexbox horizontal align={'flex-start'} gap={16}>
|
||||
<Flexbox className={styles.listCol} flex={1} padding={4}>
|
||||
{devices.map((device) => (
|
||||
<DeviceItem
|
||||
device={device}
|
||||
isCurrent={isCurrent(device.deviceId)}
|
||||
key={device.deviceId}
|
||||
selected={device.deviceId === selectedId}
|
||||
onSelect={() =>
|
||||
setSelectedId((prev) => (prev === device.deviceId ? undefined : device.deviceId))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
{selected && (
|
||||
<Flexbox className={styles.detailCol} flex={1}>
|
||||
{/* keyed on deviceId so the form state resets when the selection changes */}
|
||||
<DeviceDetailPanel
|
||||
device={selected}
|
||||
isCurrent={isCurrent(selected.deviceId)}
|
||||
key={selected.deviceId}
|
||||
onClose={() => setSelectedId(undefined)}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import { AgentModel } from '@/database/models/agent';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { AgentSkillModel } from '@/database/models/agentSkill';
|
||||
import { AiModelModel } from '@/database/models/aiModel';
|
||||
import { DeviceModel } from '@/database/models/device';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { PluginModel } from '@/database/models/plugin';
|
||||
@@ -946,9 +947,39 @@ export class AiAgentService {
|
||||
userMessageId: userMsg?.id ?? parentMessageId ?? '',
|
||||
};
|
||||
}
|
||||
// Resolve the working directory for the run: a topic-level override
|
||||
// wins, else the device's user-configured defaultCwd. The device row
|
||||
// lives in the DB (the gateway only knows live connections), so read
|
||||
// it directly rather than via deviceProxy.
|
||||
const boundDevice = await new DeviceModel(this.db, this.userId).findByDeviceId(
|
||||
dispatchDeviceId,
|
||||
);
|
||||
// Prefer the topic's own pinned cwd — an existing topic carries it in
|
||||
// `metadata.workingDirectory`, whereas `initialTopicMetadata` is only
|
||||
// populated for a brand-new topic. Fall back to the device default.
|
||||
const deviceCwd =
|
||||
topic?.metadata?.workingDirectory ||
|
||||
appContext?.initialTopicMetadata?.workingDirectory ||
|
||||
boundDevice?.defaultCwd ||
|
||||
undefined;
|
||||
|
||||
// A device is the user's own persistent machine — build a
|
||||
// device-specific context instead of reusing the cloud-sandbox one
|
||||
// (which describes an ephemeral /workspace + pre-cloned repos and
|
||||
// would mislead the agent).
|
||||
const { buildRemoteDeviceHeteroContext } =
|
||||
await import('@/server/services/heterogeneousAgent/remoteDeviceHeteroContext');
|
||||
const deviceSystemContext = buildRemoteDeviceHeteroContext({
|
||||
agentSystemContext: agentConfig.agencyConfig?.heterogeneousProvider?.systemContext,
|
||||
conversationHistory,
|
||||
cwd: deviceCwd,
|
||||
});
|
||||
|
||||
const result = await deviceProxy.dispatchAgentRun({
|
||||
...heteroParams,
|
||||
cwd: deviceCwd,
|
||||
deviceId: dispatchDeviceId,
|
||||
systemContext: deviceSystemContext,
|
||||
});
|
||||
if (!result.success) {
|
||||
log('execAgent: hetero device dispatch failed: %s', result.error);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRemoteDeviceHeteroContext } from './remoteDeviceHeteroContext';
|
||||
|
||||
describe('buildRemoteDeviceHeteroContext', () => {
|
||||
it('returns undefined when there is nothing to inject', () => {
|
||||
expect(buildRemoteDeviceHeteroContext({})).toBeUndefined();
|
||||
expect(buildRemoteDeviceHeteroContext({ agentSystemContext: ' ' })).toBeUndefined();
|
||||
expect(buildRemoteDeviceHeteroContext({ conversationHistory: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts the agent static context first', () => {
|
||||
const result = buildRemoteDeviceHeteroContext({ agentSystemContext: 'Follow the repo rules.' });
|
||||
expect(result).toBe('Follow the repo rules.');
|
||||
});
|
||||
|
||||
it('describes the working directory without cloud-sandbox boilerplate', () => {
|
||||
const result = buildRemoteDeviceHeteroContext({ cwd: '/Users/alice/projects/app' });
|
||||
expect(result).toContain('/Users/alice/projects/app');
|
||||
expect(result).toContain("user's own machine");
|
||||
// Must NOT leak the cloud-sandbox context.
|
||||
expect(result).not.toContain('/workspace');
|
||||
expect(result).not.toContain('ephemeral');
|
||||
expect(result).not.toContain('cloud sandbox');
|
||||
});
|
||||
|
||||
it('trims a blank cwd instead of emitting an empty workspace note', () => {
|
||||
expect(buildRemoteDeviceHeteroContext({ cwd: ' ' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends and truncates prior conversation turns', () => {
|
||||
const result = buildRemoteDeviceHeteroContext({
|
||||
conversationHistory: [
|
||||
{ content: 'a'.repeat(2000), role: 'user' },
|
||||
{ content: 'short reply', role: 'assistant' },
|
||||
],
|
||||
});
|
||||
expect(result).toContain('<previous_conversation>');
|
||||
expect(result).toContain('… [truncated]'); // user turn exceeds the 1 KB cap
|
||||
expect(result).toContain('short reply');
|
||||
});
|
||||
|
||||
it('orders sections: agent context → workspace → history', () => {
|
||||
const result = buildRemoteDeviceHeteroContext({
|
||||
agentSystemContext: 'AGENT_CTX',
|
||||
conversationHistory: [{ content: 'HIST', role: 'user' }],
|
||||
cwd: '/repo',
|
||||
})!;
|
||||
expect(result.indexOf('AGENT_CTX')).toBeLessThan(result.indexOf('/repo'));
|
||||
expect(result.indexOf('/repo')).toBeLessThan(result.indexOf('<previous_conversation>'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ConversationHistoryEntry } from './cloudHeteroContext';
|
||||
|
||||
/**
|
||||
* Builds the system context injected before every user prompt for hetero runs
|
||||
* dispatched to a **remote device** (`lh connect`), as opposed to a cloud
|
||||
* sandbox.
|
||||
*
|
||||
* Unlike {@link buildCloudHeteroContext}, this deliberately strips all the
|
||||
* cloud-sandbox boilerplate (ephemeral `/workspace`, pre-cloned repo list,
|
||||
* "commit-or-lose-your-work" warnings, injected GITHUB_TOKEN). A device is the
|
||||
* user's own persistent machine with their real filesystem and credentials, so
|
||||
* none of that applies — injecting it would actively mislead the agent.
|
||||
*
|
||||
* What remains is only what's genuinely useful on a device:
|
||||
* - the agent-level static context (workspace conventions / rules), and
|
||||
* - prior conversation turns when a session is resumed without a native session
|
||||
* file.
|
||||
*
|
||||
* Returns `undefined` when there's nothing meaningful to inject, so the caller
|
||||
* can omit the extra content block entirely.
|
||||
*/
|
||||
export function buildRemoteDeviceHeteroContext(params: {
|
||||
/** Static systemContext from HeterogeneousProviderConfig.systemContext (agent-level). */
|
||||
agentSystemContext?: string;
|
||||
/**
|
||||
* Recent conversation turns to inject when resuming a session whose native
|
||||
* context is unavailable (e.g. a fresh CLI process on the device).
|
||||
*/
|
||||
conversationHistory?: ConversationHistoryEntry[];
|
||||
/** Working directory the agent will run in, surfaced so it can orient itself. */
|
||||
cwd?: string;
|
||||
}): string | undefined {
|
||||
const { agentSystemContext, conversationHistory, cwd } = params;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// --- Agent-level static context (highest priority, goes first) ---
|
||||
if (agentSystemContext?.trim()) {
|
||||
parts.push(agentSystemContext.trim());
|
||||
}
|
||||
|
||||
// --- Device workspace note (minimal — it's the user's real machine) ---
|
||||
if (cwd?.trim()) {
|
||||
parts.push(
|
||||
[
|
||||
'## Workspace',
|
||||
`You are running on the user's own machine. Your working directory is \`${cwd.trim()}\`.`,
|
||||
'This is a persistent local filesystem — changes are not lost when the task ends, so',
|
||||
'there is no need to commit or push purely to preserve your work.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Previous conversation context (injected when session was reset) ---
|
||||
// Mirrors buildCloudHeteroContext truncation: user 1 KB, assistant 2 KB.
|
||||
if (conversationHistory && conversationHistory.length > 0) {
|
||||
const USER_MAX = 1024;
|
||||
const ASST_MAX = 2048;
|
||||
const entries = conversationHistory.map((entry) => {
|
||||
const limit = entry.role === 'user' ? USER_MAX : ASST_MAX;
|
||||
const body =
|
||||
entry.content.length > limit
|
||||
? `${entry.content.slice(0, limit)}… [truncated]`
|
||||
: entry.content;
|
||||
return `<${entry.role}>\n${body}\n</${entry.role}>`;
|
||||
});
|
||||
parts.push(`<previous_conversation>\n${entries.join('\n')}\n</previous_conversation>`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n\n') : undefined;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export class DeviceProxy {
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
systemContext?: string;
|
||||
topicId: string;
|
||||
userId: string;
|
||||
}): Promise<{ error?: string; success: boolean }> {
|
||||
|
||||
@@ -291,11 +291,15 @@ const canCurrentAgentPublishToCommunity = (s: AgentStoreState): boolean =>
|
||||
const currentAgentHeterogeneousProviderType = (s: AgentStoreState) =>
|
||||
currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider?.type;
|
||||
|
||||
const currentAgentExecutionTarget = (s: AgentStoreState) =>
|
||||
currentAgentConfig(s)?.agencyConfig?.executionTarget;
|
||||
|
||||
const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) =>
|
||||
s.agentDocumentsMap[agentId];
|
||||
|
||||
export const agentSelectors = {
|
||||
canCurrentAgentPublishToCommunity,
|
||||
currentAgentExecutionTarget,
|
||||
currentAgentHeterogeneousProviderType,
|
||||
currentAgentAvatar,
|
||||
currentAgentBackgroundColor,
|
||||
|
||||
Reference in New Issue
Block a user