首页
/ LobeHub 内置工具 Inspector 实战:用一行 Chip 讲清工具调用的完整生命周期

LobeHub 内置工具 Inspector 实战:用一行 Chip 讲清工具调用的完整生命周期

2026-09-05 19:26:49作者:范垣楠Rhoda

在 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.tsas 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 === truepartialArgs.X 为 undefined 仅显示 API 标题,并套用 shinyTextStyles.shinyText 闪烁样式
参数流式中,关键字段已到达 partialArgs.X 有值 标题 + 关键字段 Chip,仍保持脉冲动画
参数完整,执行器运行中 args 有值,isLoading === true 同上,仍保持脉冲动画
结果已到达 pluginState 有值,isLoading === false 标题 + Chip + 结果摘要(数量、标识符、状态)

这个状态机有两个值得注意的设计考量:

  1. 最早阶段不能渲染空行。参数还在流式传输时,任何业务字段都可能未到达,此时 i18n 标题(来自 t('builtins.<identifier>.apiName.<api>'))是保证行非空的唯一可靠内容;
  2. 结果摘要必须等加载完全结束。数量或 "(no results)" 之类的后缀,在搜索还没完成时出现会误导用户,因此要等 isLoading === falsepluginState 存在后才追加。

四、规范示例: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;

对照状态机逐段解读:

  1. 第一道分支 isArgumentsStreaming && !query:参数在流式传输但 query 字段还没解析出来,走状态机第一行——只显示 API 标题并加 shinyTextStyles.shinyText 脉冲动画,让用户知道"搜索正在被调用";
  2. query 的取值策略 args?.query || partialArgs?.query || '':这是指南明确要求的写法——同时读 argspartialArgsargs 是最终值(停止流式后才有),partialArgs 是流式中的部分值;|| 链让行内展示能尽早拿到已到达的字段;
  3. 脉冲条件的统一收口:正文分支中 cx((isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText)——只要还在"参数到达中"或"执行中"任一阶段,标题就保持闪烁,覆盖状态机第二、三行;
  4. 结果摘要的三重守卫!isLoading && !isArgumentsStreaming && pluginState?.results 三个条件同时满足才渲染计数,有结果显示 (N),无结果用弱化的 colorTextDescription 颜色显示 i18n 文案,实现状态机第四行的"标题 + Chip + 结果摘要"。

实现细节还体现了两条工程约定:组件用 memo 包裹并显式设置 displayName(Inspector 在消息流中数量可能很多,避免不必要重渲染);高亮字段用 highlightTextStyles.primary 突出,弱化信息用 cssVar.colorTextDescription

五、Inspector 编写规则(逐条对照实现)

开发指南列出了七条硬性规则,每一条都能在上文实现中找到对应:

  1. 整行包裹 inspectorTextStyles.root:该样式提供正确的 flex 布局与行高基线,保证所有 Inspector 在聊天中视觉对齐;
  2. isArgumentsStreaming || isLoading 时一律套 shinyTextStyles.shinyText 脉冲:这是"进行中"状态的统一视觉语言;
  3. i18n 标题永远放在最前:保证最早流式阶段行不为空;
  4. args?.XpartialArgs?.X 必须一起读:前者是最终值,后者是流中值,|| 串联是标准取值模式;
  5. 用 Chip/Tag 表达不同维度(identifier、name、parent、status、count):每个 Chip 必须 text-overflow: ellipsis 截断并设 max-width,防止超长值撑爆聊天气泡;
  6. pluginState 派生的后缀只能在加载完成后追加:搜索还没完成时不得出现数量或"无结果";
  7. 按阶段切换文案(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 constWebBrowsingApiName 对象(而非 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,其中可见 WebBrowsingInspectorsWebBrowsingManifest.identifier 为键挂入,与 Task、SkillStore、UserInteraction 等工具包并列——这也是 identifier 一旦写入消息历史就必须永久稳定(重命名只能加 @deprecated 别名)的原因。

消费端。 聊天 UI 在渲染工具消息头部时查找自定义 Inspector:Inspector/index.tsx 中先 getBuiltinInspector(identifier, apiName),命中后对原始参数串做 safeParseJSON(argsStr) 得到最终 argssafeParsePartialJSON(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 在 BrowserInspectorsgetBuiltinInspector 中都有对应组件。新增 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 始终保留其"唯一常显表面"的定位。

登录后查看全文
热门项目推荐
相关项目推荐