LobeHub Builtin Tool UI 六面体系:Inspector、Render、Portal 等六种客户端界面的设计原理与实现指南

📅 发布时间:2026/9/7 8:58:15
LobeHub Builtin Tool UI 六面体系:Inspector、Render、Portal 等六种客户端界面的设计原理与实现指南
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.tsRender可选结果返回后,头部下方的富结果卡packages/builtin-tools/src/renders.tsPlaceholder可选参数流式完成与结果到达之间的骨架屏packages/builtin-tools/src/placeholders.tsStreaming可选执行期间的实时输出(如命令 stdout)packages/builtin-tools/src/streamings.tsIntervention可选审批 / 执行前编辑对话框(由humanIntervention触发)packages/builtin-tools/src/interventions.tsPortal可选全屏详情视图(右侧面板或模态)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) 用 BuiltinXPropsArgs, State 带类型——绝不放宽到 any export const SearchInspector memoBuiltinInspectorPropsSearchQuery, 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) 用BuiltinXPropsArgs, State泛型标注——Args对应 JSON Schema 参数,State对应执行器的state字段,应与types.ts中的NameParams/NameState匹配;(c) Inspector 默认渲染t(builtins.identifier.apiName.api),保证参数流式传输的最早期行内非空;(d) 横切状态(Zustand selector 读取)在组件内部获取,props 只承载 args/state/messageId;(e) 永远memodisplayName。样式:零运行时 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 typesecondary比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 variantoutlined(如 ClaudeCodeTodoWrite/Task),此时单一描边块即面板,头部填充是 header bar 而非嵌套卡片。四、Inspector:每次工具调用的一句话(必选)详细规范见 inspector.md。Inspector 在工具调用的所有阶段渲染:参数流式传输中、执行器运行中、结果返回后——它是唯一始终可见的界面。目标是保持一行,用当前可获得的信息展示正在发生什么。Props 契约(BuiltinInspectorPropsArgs, State):interface BuiltinInspectorPropsArguments 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 memoBuiltinInspectorPropsSearchQuery, UniformSearchResponse( ({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) { const { t } useTranslation(plugin); const query args?.query || partialArgs?.query || ; const resultCount pluginState?.results?.length ?? 0; const hasResults resultCount 0; if (isArgumentsStreaming !query) { return ( div className{cx(inspectorTextStyles.root, shinyTextStyles.shinyText)} span{t(builtins.lobe-web-browsing.apiName.search)}/span /div ); } return ( div className{cx( inspectorTextStyles.root, (isArgumentsStreaming || isLoading) shinyTextStyles.shinyText, )} span{t(builtins.lobe-web-browsing.apiName.search)}:nbsp;/span {query span className{highlightTextStyles.primary}{query}/span} {!isLoading !isArgumentsStreaming pluginState?.results (hasResults ? ( span style{{ marginInlineStart: 4 }}({resultCount})/span ) : ( Text asspan 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 为键建Recordstring, BuiltinInspector,并逐一 re-export,例如:export const TaskInspectors: Recordstring, BuiltinInspector { [TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector, [TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector, /* 每个 ApiName 一条 */ };五、Render:富结果卡(可选)详细规范见 render.md。Render 在结果到达后渲染(从 Placeholder/Streaming 交接而来),位于 Inspector 头部下方。API 是只读的、或结果只是文本时应跳过——框架已经展示执行器的content字符串;只有当存在值得展示的结构化产物(卡片、图表、diff、文件列表)时才添加 Render。Props 契约(BuiltinRenderPropsArgs, State, Content):interface BuiltinRenderPropsArguments 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 memoBuiltinRenderPropsCrawlSinglePageQuery, 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} typeerror extra{Highlighter languagejson{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(BuiltinPlaceholderPropsArgs):interface BuiltinPlaceholderPropsT extends Recordstring, 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(BuiltinStreamingPropsArgs):interface BuiltinStreamingPropsArguments 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、languagesh、variantoutlined)渲染出来,命令为空时返回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(BuiltinInterventionPropsArgs)携带三个回调:interface BuiltinInterventionPropsArguments any { apiName?: string; args: Arguments; identifier?: string; interactionMode?: approval | custom; messageId: string; /** 用户编辑参数时调用;approve 动作会等待它 */ onArgsChange?: (args: Arguments) void | Promisevoid; /** approve / skip / cancel 时调用 */ onInteractionAction?: ( action: | { type: submit; payload: Recordstring, unknown } | { type: skip; payload?: Recordstring, unknown; reason?: string } | { type: cancel; payload?: Recordstring, unknown }, ) Promisevoid; /** 注册批准前 flush 待保存回调,返回清理函数 */ registerBeforeApprove?: (id: string, callback: () void | Promisevoid) () 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(BuiltinPortalPropsArgs, State):interface BuiltinPortalPropsArguments Recordstring, any, State any { apiName?: string; arguments: Arguments; // 注意字段名是 arguments 而非 args identifier: string; messageId: string; state: State; }规范示例(web-browsing Portal)是一个纯路由层,switch (apiName)分流到各 API 的子组件:const Portal memoBuiltinPortalProps(({ 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: Recordstring, 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),仅供参考