LobeHub 内置工具 Inspector 实战:用一行 Chip 讲清工具调用的完整生命周期
【免费下载链接】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 中,Agent 每次调用内置工具(如 Web 搜索、任务管理、本地系统操作)都会在聊天流中生成一条工具消息。无论参数是否还在流式传输、执行器是否在运行、结果是否已返回,这条消息的头部都必须有一个始终可见的表面,展示"当前正在发生什么"——这就是Inspector(头部 Chip)。本文基于仓库中的开发指南 inspector.md 与对应源码实现,系统讲解 Inspector 的职责边界、Props 契约、四阶段状态机、规范示例(以 Web 搜索为例)、编写规则,以及它如何注册进全局注册表并被聊天 UI 消费。读完本文,你可以为一个内置工具的任意 API 写出符合框架约定、可跑在聊天历史中的 Inspector 组件。
一、Inspector 在六类 UI 表面中的定位
一个内置工具最多可携带六类客户端 UI 表面,各自承担不同的展示角色。根据 ui/README.md,Inspector 是唯一必选的表面:
| 表面 | 是否必需 | 聊天中何时出现 | 注册位置 |
|---|---|---|---|
| Inspector | 必需(Always) | 每个工具调用的头部条(一行 Chip) | inspectors.ts |
| Render | 可选 | 头部下方的富结果卡片(调用返回后) | renders.ts |
| Placeholder | 可选 | "参数流式完成"到"结果到达"之间的骨架屏 | placeholders.ts |
| Streaming | 可选 | 执行中的实时输出(如命令 stdout) | streamings.ts |
| Intervention | 可选 | 审批 / 运行前编辑对话框 | interventions.ts |
| Portal | 可选 | 全屏详情视图(右侧或弹窗) | portals.ts |
Inspector 的生命周期贯穿工具调用的每一个阶段:参数还在流式传入时、执行器运行中、结果返回后,它都是唯一始终可见的表面。它的设计目标非常克制——保持单行,用当前已知的最多信息说明"正在发生什么"。这也是它与 Render(富结果卡片)的核心分工:Inspector 负责"进度叙事",Render 负责"结果呈现"。
二、Props 契约:BuiltinInspectorProps<Args, State>
Inspector 组件接收一个泛型 Props 接口BuiltinInspectorProps<Arguments, State>,第一个泛型参数是参数类型,第二个是执行器状态类型。仓库中的实际类型定义见 builtin.ts,与开发指南完全一致,并补充了指南发布后新增的toolCallId字段:
interface BuiltinInspectorProps<Arguments = any, State = any> { apiName: string; args: Arguments; // final args (only after the assistant stops streaming) identifier: string; /** Whether the tool arguments are currently streaming (not yet complete) */ isArgumentsStreaming?: boolean; 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?: any }; /** * Stable id of this tool call. Required for inspectors that need to * correlate with side data — e.g. via `metadata.sourceToolCallId`. */ toolCallId?: string; }各字段语义需要精确理解,这是后面状态机判断的基础:
apiName:当前调用的 API 名(对应工具types.ts中as const的<Name>ApiName对象),用于取 i18n 标题;args:最终参数。关键点:只有当助手停止流式输出后它才是完整的,流式阶段不要依赖它;partialArgs:流式阶段对不完整 JSON 的解析结果,可能只有部分字段;isArgumentsStreaming:区分"参数还在到达"与"工具正在执行"两个阶段;isLoading:参数已完整、执行器正在运行;pluginState:执行器成功返回后state字段中的结果域数据(注意 SKILL 指南强调state只放结果域数据,不要回显全部参数);result:包含content(LLM 可读文本)与error;toolCallId:稳定的工具调用 id,供需要与侧边数据关联的 Inspector 使用(例如通过metadata.sourceToolCallId关联子 Agent 线程)。
类型声明本身也值得注意:BuiltinInspector是一个泛型函数组件类型<A = any, S = any>(props: BuiltinInspectorProps<A, S>) => ReactNode,返回ReactNode而非强制JSX.Element,允许组件在数据不足时返回null。
三、四阶段状态机
开发指南为 Inspector 定义了明确的状态机,核心思想是**"每一阶段展示当时能拿到的最多信息"**:
| 阶段 | 可用数据 | 应展示内容 |
|---|---|---|
| 参数流式中,尚无可用字段 | isArgumentsStreaming === true,partialArgs.X为 undefined | 仅显示 API 标题,并套用shinyTextStyles.shinyText闪烁样式 |
| 参数流式中,关键字段已到达 | partialArgs.X有值 | 标题 + 关键字段 Chip,仍保持脉冲动画 |
| 参数完整,执行器运行中 | args有值,isLoading === true | 同上,仍保持脉冲动画 |
| 结果已到达 | pluginState有值,isLoading === false | 标题 + Chip + 结果摘要(数量、标识符、状态) |
这个状态机有两个值得注意的设计考量:
- 最早阶段不能渲染空行。参数还在流式传输时,任何业务字段都可能未到达,此时 i18n 标题(来自
t('builtins.<identifier>.apiName.<api>'))是保证行非空的唯一可靠内容; - 结果摘要必须等加载完全结束。数量或 "(no results)" 之类的后缀,在搜索还没完成时出现会误导用户,因此要等
isLoading === false且pluginState存在后才追加。
四、规范示例:Web 搜索的 SearchInspector
开发指南以 Web 浏览工具的 Search Inspector 作为规范范例。仓库中的实际实现位于 Search/index.tsx,与指南示例在逻辑上完全一致(实际代码在样式组织上略有演进,将shinyText应用到了具体<span>上而非整行容器):
'use client'; import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types'; import { Text } from '@lobehub/ui/base-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={inspectorTextStyles.root}> <span className={shinyTextStyles.shinyText}> {t('builtins.lobe-web-browsing.apiName.search')} </span> </div> ); } return ( <div className={inspectorTextStyles.root}> <span className={cx((isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText)}> {t('builtins.lobe-web-browsing.apiName.search')}:{'\u00A0'} </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;对照状态机逐段解读:
- 第一道分支
isArgumentsStreaming && !query:参数在流式传输但query字段还没解析出来,走状态机第一行——只显示 API 标题并加shinyTextStyles.shinyText脉冲动画,让用户知道"搜索正在被调用"; - query 的取值策略
args?.query || partialArgs?.query || '':这是指南明确要求的写法——同时读args和partialArgs。args是最终值(停止流式后才有),partialArgs是流式中的部分值;||链让行内展示能尽早拿到已到达的字段; - 脉冲条件的统一收口:正文分支中
cx((isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText)——只要还在"参数到达中"或"执行中"任一阶段,标题就保持闪烁,覆盖状态机第二、三行; - 结果摘要的三重守卫:
!isLoading && !isArgumentsStreaming && pluginState?.results三个条件同时满足才渲染计数,有结果显示(N),无结果用弱化的colorTextDescription颜色显示 i18n 文案,实现状态机第四行的"标题 + Chip + 结果摘要"。
实现细节还体现了两条工程约定:组件用memo包裹并显式设置displayName(Inspector 在消息流中数量可能很多,避免不必要重渲染);高亮字段用highlightTextStyles.primary突出,弱化信息用cssVar.colorTextDescription。
五、Inspector 编写规则(逐条对照实现)
开发指南列出了七条硬性规则,每一条都能在上文实现中找到对应:
- 整行包裹
inspectorTextStyles.root:该样式提供正确的 flex 布局与行高基线,保证所有 Inspector 在聊天中视觉对齐; isArgumentsStreaming || isLoading时一律套shinyTextStyles.shinyText脉冲:这是"进行中"状态的统一视觉语言;- i18n 标题永远放在最前:保证最早流式阶段行不为空;
args?.X与partialArgs?.X必须一起读:前者是最终值,后者是流中值,||串联是标准取值模式;- 用 Chip/Tag 表达不同维度(identifier、name、parent、status、count):每个 Chip 必须
text-overflow: ellipsis截断并设max-width,防止超长值撑爆聊天气泡; pluginState派生的后缀只能在加载完成后追加:搜索还没完成时不得出现数量或"无结果";- 按阶段切换文案(Switch copy by phase):这是最容易被忽视的一条。如果动词隐含"进行中的动作"("Creating"、"Searching"、"Listing"),需要定义
<api>.loading与<api>.completed两个 i18n key,并用isArgumentsStreaming || isLoading ? loadingKey : completedKey选择——因为Inspector Chip 会永久保留在聊天历史里,一个已完成的任务如果还显示 "Creating task",读起来就像工具仍在运行。而本身就是名词性的只读标签(如 "View task")可以只用一个 key。指南指出CallSubAgentInspector是这一"双 key 模式"的规范参考。
六、注册链路:从包内 Registry 到全局查找
Inspector 的可见性依赖两级注册。
第一级:包内注册表。每个工具包在src/client/Inspector/index.ts中导出一个以ApiName为键的 Record,每个 API 一个条目,并逐一 re-export。Web 浏览工具的实际文件 Inspector/index.ts 展示了这个模式:
import { WebBrowsingApiName } from '../../types'; import { CrawlMultiPagesInspector } from './CrawlMultiPages'; import { CrawlSinglePageInspector } from './CrawlSinglePage'; import { SearchInspector } from './Search'; /** * Web Browsing Inspector Components Registry */ export const WebBrowsingInspectors = { [WebBrowsingApiName.crawlMultiPages]: CrawlMultiPagesInspector, [WebBrowsingApiName.crawlSinglePage]: CrawlSinglePageInspector, [WebBrowsingApiName.search]: SearchInspector, };这里键值来自as const的WebBrowsingApiName对象(而非 TS enum),保证类型安全且与 manifest 中的api[]一一对应。
第二级:全局注册表。中心注册表 packages/builtin-tools/src/inspectors.ts 维护一个Record<identifier, Record<apiName, BuiltinInspector>>的二级结构,并对外提供:
registerBuiltinInspectors(entries):按 identifier 合并注册(Object.assign语义,可增量合并);getBuiltinInspector(identifier, apiName):按工具标识符 + API 名查找组件;listBuiltinInspectorEntries():扁平化列出全部条目。
实际的批量注册发生在 register.ts,其中可见WebBrowsingInspectors以WebBrowsingManifest.identifier为键挂入,与 Task、SkillStore、UserInteraction 等工具包并列——这也是 identifier 一旦写入消息历史就必须永久稳定(重命名只能加@deprecated别名)的原因。
消费端。聊天 UI 在渲染工具消息头部时查找自定义 Inspector:Inspector/index.tsx 中先getBuiltinInspector(identifier, apiName),命中后对原始参数串做safeParseJSON(argsStr)得到最终args、safeParsePartialJSON(argsStr)得到partialArgs,再渲染:
const CustomInspector = getBuiltinInspector(identifier, apiName); if (CustomInspector) { const args = safeParseJSON(argsStr); const partialJson = safeParsePartialJSON(argsStr); return ( <Flexbox allowShrink horizontal align={'center'} gap={6}> <StatusIndicator intervention={intervention} isToolExecuting={isToolCalling} result={result} ... /> <SafeBoundary minHeight={22} resetKeys={[argsStr, result]}> <CustomInspector ... />可以看到完整闭环:safeParsePartialJSON正是让partialArgs在流式阶段可用的机制,SafeBoundary保证 Inspector 内部抛错不会拖垮整条消息,StatusIndicator负责左侧状态图标。这套消费逻辑也解释了为什么 Inspector 契约必须容忍args缺失、pluginState缺失等一切中间态。
七、配套约定与验证方式
- i18n key 位置:Inspector 标题必须来自
t('builtins.<identifier>.apiName.<api>'),key 存放在 plugin.ts(默认语言),开发时需要在en-US/zh-CN种子中补全,否则最早阶段标题会是空字符串; - 样式规范:优先
createStaticStyles + cssVar.*(零运行时),确需运行时值才退回createStyles + token;使用@lobehub/ui组件而非裸 antd(Search 实现中Text即来自@lobehub/ui/base-ui); - 组件骨架:
'use client'+memo+displayName,与仓库中既有 Inspector 保持一致; - 测试:注册正确性由 builtinToolRegistry.test.ts 一类用例覆盖,例如遍历
Object.values(BrowserApiName)断言每个 API 在BrowserInspectors与getBuiltinInspector中都有对应组件。新增 API 后运行bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'与bun run type-check验证。
小结
Inspector 是 LobeHub 内置工具 UI 中约束最紧、职责最单一的表面:单行、四阶段状态机、args/partialArgs双读、结果后缀晚到、双 key 文案切换、两级注册表挂接。掌握 inspector.md 中的状态机与规则清单,再对照 SearchInspector 与 [CallSubAgentInspector] 这类规范实现,再结合 packages/types/src/tool/builtin.ts 的 Props 契约和 inspectors.ts 的注册机制,即可为新工具写出既能在流式早期给出反馈、又能在聊天历史中长期可读的头部 Chip。若工具还有富结果、执行中实时输出或全屏详情,再按需补充 Render、Streaming、Portal 等可选表面并各自注册,而 Inspector 始终保留其"唯一常显表面"的定位。
【免费下载链接】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),仅供参考