LobeHub Builtin Tool UI 六面体系:Inspector、Render、Portal 等六种客户端界面的设计原理与实现指南
【免费下载链接】lobehub🤯 LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub
在 LobeHub 的 builtin tool 架构中,一个工具在聊天界面里的"长相"由最多六种客户端界面(surface)共同决定:必选的 Inspector 头部芯片、可选的 Render 结果卡、Placeholder 骨架屏、Streaming 实时输出、Intervention 审批交互和 Portal 全屏详情。读完本文,你将掌握每种界面出现的确切生命周期、对应的 Props 契约与注册文件位置,并能按照仓库约定的组件骨架、样式规范与"单层卡片"规则,为一个 builtin tool 从零补齐完整的 Tool UI。
一、六种 UI 界面总览:谁必选、何时出现、在哪里注册
仓库文档 Tool UI Surfaces 给出了权威总表:一个 builtin tool 最多可以携带六种客户端界面,每种界面在聊天 UI 中承担不同角色。只有Inspector是强制的,其余五种按需添加,并各自注册到独立的中央文件:
| Surface | 是否必选 | 何时在聊天中显示 | 注册位置 |
|---|---|---|---|
| Inspector | ✅ 始终 | 每次工具调用的头部条(一行芯片) | packages/builtin-tools/src/inspectors.ts |
| Render | 可选 | 结果返回后,头部下方的富结果卡 | packages/builtin-tools/src/renders.ts |
| Placeholder | 可选 | "参数流式完成"与"结果到达"之间的骨架屏 | packages/builtin-tools/src/placeholders.ts |
| Streaming | 可选 | 执行期间的实时输出(如命令 stdout) | packages/builtin-tools/src/streamings.ts |
| Intervention | 可选 | 审批 / 执行前编辑对话框(由humanIntervention触发) | packages/builtin-tools/src/interventions.ts |
| Portal | 可选 | 全屏详情视图(右侧面板或模态) | packages/builtin-tools/src/portals.ts |
从源码结构看,六个注册文件都存在于packages/builtin-tools/src/下,并且采用统一的"注册表"模式。以 inspectors.ts 为例,它维护了一个identifier -> apiName -> 组件的两级字典,并通过registerBuiltinInspectors(entries)让各工具包把自己的 Inspector 合并进全局注册表,再由listBuiltinInspectorEntries()扁平化为(identifier, apiName, inspector)三元组供聊天框架消费。这意味着框架渲染工具消息时,是按"工具标识符 + API 名"两个键来查找对应界面的——这正是每种界面需要"注册"的原因。
文档推荐端到端阅读两个参考实现:
packages/builtin-tool-web-browsing/src/client/— 包含 Inspector + Render + Placeholder + Portal(没有 Intervention/Streaming);packages/builtin-tool-local-system/src/client/— 六种界面齐全,且带有components/共享构件目录。
两个目录的实际子目录结构(Inspector、Render、Placeholder、Streaming、Intervention、Portal、components)与文档描述完全一致,可直接作为新工具的脚手架范本。
二、设计原则:每种界面"该做什么、做到什么程度"
principles.md 定义了 15 条设计原则,可归纳为五个层面:
可读性底线。先保证折叠态可读:每个 API 都必须有 Inspector,用户不展开也应能看懂"正在做什么 / 对什么做 / 当前结果是什么"。Inspector 是一句话而不是详情页——优先表达动作、关键对象、数量、状态,例如"分析图片 3 张""搜索 12 个结果""读取 config.json";长文本、列表和结构化结果留给 Render 或 Portal。
生命周期覆盖。Inspector 要覆盖执行全生命周期:args还在流式传输、工具执行中、执行完成、执行失败时都应有稳定展示,必要时同时读取args、partialArgs和pluginState,避免空白、跳变或只显示半截参数。
文案时态切换。这是极易被忽略又影响体验最直观的一条:同一个动作在 loading 与 completed 两个阶段必须用不同措辞——执行中用现在进行时("正在创建任务 / Creating task"),完成后切到完成态("已创建任务 / Task created")。因为 Inspector 芯片会一直留在聊天记录里,若一直挂着"正在 xxx",几小时后回看历史时会读起来像工具还在跑。约定的 i18n 形式是<api>.loading/<api>.completed一对键(仓库中可参考lobe-agent.apiName.callSubAgent.{loading,completed}与lobe-claude-code.task.{create,list,update,get}.{loading,completed}),渲染时按isArgumentsStreaming || isLoading决定取哪一个;只读/查询类(名词性的"查看任务")可以共用一个键。
结果呈现原则。只有结构化结果(列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求)才需要 Render;纯自然语言总结不需要。Render 要帮助用户检查结果而不是复述参数——围绕工具产物组织,可预览、可比较、可筛选、可定位。同时args与结果要一起参与渲染:用args解释意图、用pluginState展示真实执行结果,但pluginState只放结果域数据,不反向塞入能从args推导的内容。
状态完整性与克制。慢操作要有 Placeholder(占住最终 Render 的版式,而不是泛化 loading);Streaming 只用于连续产物(搜索列表、日志、长文本、分阶段计划),且完成后要自然过渡到最终 Render;有风险的动作(写文件、删除、发送、安装、执行命令、权限敏感操作)必须 Intervention,确认文案要说明影响范围而不是只问"是否继续";错误、空态和截断都是正式状态——Render 不能在失败、无结果、超长结果时退化成空白。
视觉与验收。Tool UI 应使用@lobehub/ui/ base-ui、Flexbox、createStaticStyles和cssVar.*,遵循现有间距、圆角、颜色、字号,不为单个工具发明独立视觉语言;新增或修改 Tool UI 时应在/devtools里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;先做用户会看的 UI,再做调试 UI(Raw JSON、trace、schema 默认收起或放调试区)。
三、跨界面共享规则:统一组件骨架与样式约定
shared-rules.md 规定"每种界面文件都是同一个形状"——理解一次即可,不需要每条规则重新推导。骨架内置了五条机械约定:
'use client'; // (a) 聊天树的叶子节点不能阻塞服务端渲染 import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; // (b) 用 BuiltinXProps<Args, State> 带类型——绝不放宽到 any export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>( ({ args, pluginState }) => { const { t } = useTranslation('plugin'); // (c) 所有文案来自 `plugin` 命名空间 // (d) 横切状态(loading、streaming 缓冲)从 store 取,而不是 props return <span>{t('builtins.<identifier>.apiName.search')}</span>; }, ); SearchInspector.displayName = 'SearchInspector'; // (e) 永远 memo + displayName export default SearchInspector;- (a)
'use client':聊天树的叶子组件不阻塞服务端渲染; - (b) 用
BuiltinXProps<Args, State>泛型标注——Args对应 JSON Schema 参数,State对应执行器的state字段,应与types.ts中的<Name>Params/<Name>State匹配; - (c) Inspector 默认渲染
t('builtins.<identifier>.apiName.<api>'),保证参数流式传输的最早期行内非空; - (d) 横切状态(Zustand selector 读取)在组件内部获取,props 只承载 args/state/messageId;
- (e) 永远
memo+displayName。
样式:零运行时 CSS-in-JS。使用createStaticStyles + cssVar.*——样式编译一次、运行时读取 CSS 变量:
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}; `, }));仅在需要运行时 token 计算(很少)时回退到createStyles + token;一次性动态值可以内联style={{ color: cssVar.colorTextSecondary }}。组件优先取自@lobehub/ui(Block、Text、Flexbox、Highlighter、Alert、Tooltip、Skeleton)而非原生antd;模态框来自@lobehub/ui/base-ui(createModal、useModalContext、confirmModal)。注意<Text type='secondary'>比colorTextSecondary更浅,要精确使用该 token 颜色应写<Text style={{ color: cssVar.colorTextSecondary }}>。
保持单层,不嵌套填充卡片。框架已经为每个 Render / Intervention 套了一层工具卡片,这张卡就是你的界面。外层容器再开一个colorFillQuaternary填充容器、内部再嵌一个colorBgContainer填充盒,就是那种"看起来复杂"的卡中卡观感。具体规则:最外层容器不带填充(仅padding-block: 4px呼吸空间);至多一个填充盒且只用于界定真实内容(Markdown 预览、diff、代码块);标签、键值字段、问答文本应平铺在界面上,用间距或发丝分隔线(height: 1px; background: ${cssVar.colorFillSecondary})分隔。对于常见的"图标 + 文件/标题头 + 一个内容盒"形态,直接复用@lobechat/shared-tool-ui/components的ToolResultCard,它本身就是单层的,也是 ClaudeCode 的Read/Grep/Glob/Write/WebSearch/WebFetch的渲染通道。例外是刻意的panel 模式——带头部栏 + 列表行的<Block variant="outlined">(如 ClaudeCodeTodoWrite/Task),此时单一描边块即面板,头部填充是 header bar 而非嵌套卡片。
四、Inspector:每次工具调用的"一句话"(必选)
详细规范见 inspector.md。Inspector 在工具调用的所有阶段渲染:参数流式传输中、执行器运行中、结果返回后——它是唯一始终可见的界面。目标是保持一行,用当前可获得的信息展示正在发生什么。
Props 契约(BuiltinInspectorProps<Args, State>):
interface BuiltinInspectorProps<Arguments = any, State = any> { apiName: string; args: Arguments; // 最终参数(仅在助手停止流式传输后) identifier: string; isArgumentsStreaming?: boolean; // 参数仍在到达 isLoading?: boolean; // 参数完成,执行器运行中 partialArgs?: Arguments; // 流式传输中的部分 JSON pluginState?: State; // 成功后的执行器 state result?: { content: string | null; error?: any }; }状态机。Inspector 的展示随可用数据分四档:
| 阶段 | 可用数据 | 展示内容 |
|---|---|---|
| 参数流式中,尚无有用字段 | isArgumentsStreaming === true,partialArgs.X未定义 | 仅 API 标题,加shinyTextStyles.shinyText脉动 |
| 参数流式中,关键字段已到达 | partialArgs.X有值 | 标题 + 关键字段芯片,仍脉动 |
| 参数完成,执行器运行中 | args有值,isLoading === true | 同上,仍脉动 |
| 结果到达 | pluginState有值,isLoading === false | 标题 + 芯片 + 结果摘要(数量、标识、状态) |
规范示例——web-browsing 的 Search Inspector(位于packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx):
'use client'; 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';Inspector 规则要点:整行包在inspectorTextStyles.root里(提供正确的 flex / 行高基线);isArgumentsStreaming || isLoading时始终用shinyTextStyles.shinyText脉动;先显示 i18n 标题让最早期阶段非空;args?.X与partialArgs?.X一起读取;不同侧面(标识、名称、父级、状态、数量)用芯片表达,每个芯片要有max-width和text-overflow: ellipsis防止撑爆聊天气泡;pluginState派生的后缀(数量、"(无结果)")只在 loading 结束后追加;按阶段切换文案(见前述 loading/completed 键对)。
Inspector 注册表位于各工具包的client/Inspector/index.ts,以 apiName 为键建Record<string, BuiltinInspector>,并逐一 re-export,例如:
export const TaskInspectors: Record<string, BuiltinInspector> = { [TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector, [TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector, /* 每个 ApiName 一条 */ };五、Render:富结果卡(可选)
详细规范见 render.md。Render 在结果到达后渲染(从 Placeholder/Streaming 交接而来),位于 Inspector 头部下方。API 是只读的、或结果只是文本时应跳过——框架已经展示执行器的content字符串;只有当存在值得展示的结构化产物(卡片、图表、diff、文件列表)时才添加 Render。
Props 契约(BuiltinRenderProps<Args, State, Content>):
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> { apiName?: string; args: Arguments; // LLM 的最终参数 content: Content; // 执行器的 content 字符串(或已解析) identifier?: string; messageId: string; // 用于 store 查询 pluginError?: any; // 来自 BuiltinToolResult.error pluginState?: State; // 执行器 state toolCallId?: string; }两种组织模式。模式 A 单文件 Render(web-browsing CrawlSinglePage 的做法)——一个薄壳组件把pluginState与args透传给共享子组件:
// client/Render/CrawlSinglePage.tsx const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>( ({ messageId, pluginState, args }) => ( <PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} /> ), ); export default CrawlSinglePage;模式 B 文件夹 + 子组件(web-browsing Search 的做法),当 Render 有内部状态(编辑模式、展开项)、错误变体或体量大到值得拆分时使用:
client/Render/Search/ ├── index.tsx # 组合子组件,处理错误态 ├── ConfigForm.tsx # pluginError.type === 'PluginSettingsInvalid' 时出现 ├── SearchQuery.tsx # 可编辑的查询头 └── SearchResult.tsx # 结果列表Render 是pluginError的规范展示位置,因为聊天不会自动渲染类型化错误:
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 规则:暂无可画内容时返回null(避免流式期间的空卡片);用pluginState取服务端事实(id、数量、服务端状态),用args取 LLM 的意图——两者结合,单独任何一个都不够;列表用头部行概括、展示前 N 项并带"+N more"尾部;保持单层(见共享规则);从 Render 打开模态框用@lobehub/ui/base-ui。注册表在client/Render/index.ts,只收录有富结果 UI 的 API,其余回退到文本 content。罕见情况下若某结果应隐藏 Render(如 ClaudeCode TodoWrite 在 agent 流式传输中途隐藏),向 packages/builtin-tools/src/displayControls.ts 添加RenderDisplayControl。
六、Placeholder:参数与结果之间的骨架屏(可选)
详细规范见 placeholder.md。Placeholder 在参数流式结束但执行器未返回时渲染,pluginState到达时消失,桥接感知延迟的窗口。为有明显执行耗时的 API 添加(网络搜索、网页抓取、文件列表、大型 grep);即时操作(状态翻转、计算器)跳过。
Props(BuiltinPlaceholderProps<Args>):
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> { apiName: string; args?: T; identifier: string; }注意没有pluginState——Placeholder 完全生活在"执行中"的空档里。规范示例(web-browsingSearch)用@lobehub/ui的Skeleton.Block/Skeleton.Button搭出与最终结果同构的骨架:查询行(嵌入已有的query文本,无则 20×40 骨架块)+ 5 个 160×80 的结果卡按钮,移动端/桌面端用useIsMobile()切换横纵排布,且文字部分叠加shinyTextStyles.shinyText脉动。
Placeholder 规则:镜像最终 Render 的版式——结果到达时 Placeholder 卸载、Render 挂载,两者共享尺寸则聊天不跳变;用Skeleton.Block/Skeleton.Button搭形状;嵌入已有的 args(如查询文本)帮用户知道正在加载什么;含字面文本时同样脉动。注册表client/Placeholder/index.ts以 apiName 为键:
export const WebBrowsingPlaceholders = { [WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages, [WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage, [WebBrowsingApiName.search]: Search, };七、Streaming:执行期间的实时输出(可选)
详细规范见 streaming.md。Streaming 在执行器仍在运行、且 API 产生增量输出的场景下渲染;组件自己负责从聊天 store 取在途流并渲染。适用于有连续输出的长时操作:shell 命令执行(stdout/stderr)、文件写入进度、代码解释器 cell。
Props(BuiltinStreamingProps<Args>):
interface BuiltinStreamingProps<Arguments = any> { apiName: string; args: Arguments; identifier: string; messageId: string; // 用于从 store 拉取流式缓冲 toolCallId: string; }同样没有state或resultprop——Streaming 专为在途阶段设计,自己通过chatToolSelectors.streamingBuffer(messageId, toolCallId)(state)之类的 selector 从useChatStore拉实时缓冲。规范示例(local-systemRunCommandStreaming)最小形态是把待执行命令用Highlighter(animated、language="sh"、variant="outlined")渲染出来,命令为空时返回null避免闪烁;需要真正的 stderr/stdout 流式输出时再接 store 缓冲。
Streaming 规则:有内容可显示前渲染null;终端风格输出用带animated的Highlighter呈现打字效果;执行结束时组件必须干净卸载——通常由框架自动换成 Render。注册表client/Streaming/index.ts:
export const LocalSystemStreamings = { [LocalSystemApiName.runCommand]: RunCommandStreaming, [LocalSystemApiName.writeLocalFile]: WriteFileStreaming, };八、Intervention:执行前审批/编辑(可选)
详细规范见 intervention.md。Intervention 在执行器运行之前渲染,面向 manifest 中设置了humanIntervention的 API:用户看到参数预览,可以编辑,然后批准或跳过/取消。为破坏性或敏感操作添加:shell 命令、文件写入、文件移动、支付、消息广播。
Props(BuiltinInterventionProps<Args>)携带三个回调:
interface BuiltinInterventionProps<Arguments = any> { apiName?: string; args: Arguments; identifier?: string; interactionMode?: 'approval' | 'custom'; messageId: string; /** 用户编辑参数时调用;approve 动作会等待它 */ onArgsChange?: (args: Arguments) => void | Promise<void>; /** 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>; /** 注册"批准前 flush 待保存"回调,返回清理函数 */ registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void; }规范示例(local-systemRunCommand Intervention)展示了一个典型的"预览而非表单":描述行 + timeout 次要文本 + 命令的Highlighter代码块。
Intervention 规则:默认展示预览而不是表单——编辑 UI 通过onArgsChange显式开启,通常内联(点击编辑代码块等);有防抖编辑态(文本域)时用registerBeforeApprove(id, flushFn)让 approve 动作等待防抖 flush,并务必返回清理函数;批准时调onInteractionAction({ type: 'submit', payload }),带原因跳过用'skip',取消整个回合用'cancel';工具若需在批准前做作用域/路径校验,在包根添加对应的interventionAudit.ts(参考local-system/src/interventionAudit.ts)。注册表client/Intervention/index.ts按"每个需要审批的 API 一条"收录,如[LocalSystemApiName.runCommand]: RunCommand。
九、Portal:全屏详情视图(可选)
详细规范见 portal.md。Portal 在用户于侧边面板或全屏模态中打开工具消息时渲染。注意粒度不同:一个工具一个 Portal(而非一个 API 一个),Portal 文件内部按apiName分流。为结果值得深看的工具添加:带可编辑过滤器的搜索结果、阅读模式的页面内容、代码解释器会话。
Props(BuiltinPortalProps<Args, State>):
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> { apiName?: string; arguments: Arguments; // 注意字段名是 arguments 而非 args identifier: string; messageId: string; state: State; }规范示例(web-browsing Portal)是一个纯路由层,switch (apiName)分流到各 API 的子组件:
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; });Portal 规则:一个工具一个 Portal,文件即路由层,子组件实现各 API 视图;Portal 可以直接读聊天 store 检测"仍在流式"并在内部渲染 Skeleton;布局假设比 Render 更宽裕的空间——用Flexbox配合height={'100%',为侧边面板视口组织结构。与其余五种界面"每包注册再汇入中央文件"不同,Portal 的注册直接在中央文件 packages/builtin-tools/src/portals.ts 中按Manifest.identifier登记:
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = { [WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal, };十、落点:如何为一个新工具组装完整的 Tool UI
综合 README 的指引与上述各篇规范,实操路径是:先读 principles.md 与 shared-rules.md(适用于所有界面),再按要实现的界面跳读对应文档;共享子组件(client/components/)与包公共 API 见 composition.md,症状→界面的快速排查表见 diagnostics.md。
具体步骤:在工具包下建src/client/目录(对齐packages/builtin-tool-web-browsing/src/client/或packages/builtin-tool-local-system/src/client/的结构);为每个 API 写 Inspector 并在client/Inspector/index.ts建 apiName→组件的注册表;评估结果是否结构化,是则在client/Render/index.ts注册 Render 并处理pluginError;有感知延迟就补client/Placeholder/index.ts(镜像最终版式);有增量输出就补client/Streaming/index.ts(自取 store 缓冲);有风险操作就补client/Intervention/index.ts并在 manifest 设置humanIntervention;结果值得深看就在client/Portal/index.tsx写按 apiName 分流的单工具 Portal。最后,把各包注册表汇入packages/builtin-tools/src/下对应的中央文件(inspectors.ts / renders.ts / placeholders.ts / streamings.ts / interventions.ts / portals.ts),并在/devtools里补上覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture——一个 API 如果会在真实聊天里出现,就不应在 devtools 中缺席。
【免费下载链接】lobehub🤯 LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考