mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +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.
|
||||
|
||||
Reference in New Issue
Block a user