Joye Dev

Back

feat(storybook): add agent chat message-part stories

**PR:**feat(storybook): add agent chat message-part stories (#4390)


正文来源:飞书学习文档。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。

今日选择#

**PR:**feat(storybook): add agent chat message-part stories (#4390)

**作者:**Horcrux / magicismight

**Merge 时间:**2026-07-06T03:17:37Z(Australia/Melbourne 2026-07-06 13:17:37)

链接:https://github.com/adastralab-ai/voyager/pull/4390

**模块:**packages/site agent chat tool-part UI、packages/storybook Vite/Next Storybook 配置

**学习标签:**agent-chat、tool-part-registry、storybook-fixtures、presentational-adapter-split、merged-tool-parts、devex

为什么值得学#

  • 标题是补 Storybook,但实际做了边界重切:把 web_search / image_generation 的展示组件从 registry adapter 中剥离出来,adapter 只负责把 AI SDK 的 ToolUIPart 映射成稳定 UI props。
  • 它没有为 Storybook 另起一条假路径。bespoke tool 的 story 可以直接覆盖纯展示组件,而生产消息列表继续通过 renderToolPartAtIndex 走 registry dispatch,避免 demo 和产品行为分叉。
  • 它把“多个 image_generation tool call 合并展示”的语义显式放进 registry entry 的 merge=true,而不是散落在消息列表循环里。这让 grouped UI、隐藏 stopped unfinished tool part、fallback legacy tool 等行为集中在 registry 层。
  • 它补的是高杠杆调试面:agent 聊天状态以前要靠 live agent 恰好跑到某个状态,现在 running / completed / error / no_image / insufficient credits 都能用固定 fixture 看。

关键代码#

1. registry entry 同时声明 tool 覆盖范围和是否合并#

export function defineToolPart<Name extends keyof AgentTools>({
  tool,
  render,
  aliases,
  merge,
}: ...): ToolPartEntry<Name> {
  const tools = [tool, ...(aliases ?? [])];

  if (merge) {
    return {
      tools,
      merge: true,
      render: (parts, ctx) =>
        render(
          parts.filter((part) => isToolPartForEntry<Name>(part, tools)),
          ctx,
        ),
    };
  }

  return {
    tools,
    render: (part, ctx) =>
      isToolPartForEntry<Name>(part, tools) ? render(part, ctx) : null,
  };
}
plaintext

设计点:一个 tool renderer 的类型覆盖、legacy alias、是否按邻近 part 合并都由同一个 entry 表达。这样扩展新 tool 时不是去改中心 switch,而是新增一个局部 entry,并让类型系统继续检查覆盖面。

2. 消息列表按 registry 渲染,合并逻辑集中在一处#

export function renderToolPartAtIndex(
  parts: readonly MessagePart[],
  index: number,
  ctx: ToolPartContext,
  shouldHideToolPart: (part: StaticMessageToolPart) => boolean,
): ReactNode {
  const part = parts[index];
  if (!isStaticMessageToolPart(part) || shouldHideToolPart(part)) return null;

  const entry = getToolPartEntry(part);
  if (!entry) return <FallbackToolCallPart part={part} />;

  if (!entry.merge) {
    return entry.render(part, ctx);
  }

  if (hasPreviousVisibleMergedToolPart(parts, index, entry, shouldHideToolPart)) {
    return null;
  }

  return entry.render(
    collectMergedToolParts(parts, index, entry, shouldHideToolPart),
    ctx,
  );
}
plaintext

设计点:消息列表只提供 part 数组、当前位置、runtime context 和隐藏规则;registry 负责 fallback、single render、merged render。这个边界让 Storybook 可以测底层展示组件,也让生产渲染保持一条入口。

3. image_generation adapter 把 runtime state 映射成 UI tile union#

function ImageGenerationAssetTileAdapter({ part, onInsert }: { ... }) {
  const output = part.state === "output-available" ? part.output : undefined;
  const showLoading = useToolIsLoading(part);

  if (output?.status === "insufficient_credits") {
    return <ImageGenerationAssetTile tile={{ kind: "insufficientCredits", ... }} />;
  }

  if (output?.status === "no_image") {
    return <ImageGenerationAssetTile tile={{ kind: "noImage", ... }} />;
  }

  const { aspectRatio } = getToolInput(part);
  const style = thumbnailStyle(aspectRatio);
  if (!style) return null;

  if (isSuccessfulImageOutput(output)) {
    return <ImageGenerationAssetTile onInsert={onInsert} tile={{ kind: "success", ... }} />;
  }

  if (part.state === "output-error") {
    return <ImageGenerationAssetTile tile={{ kind: "error", ... }} />;
  }

  return showLoading ? <ImageGenerationAssetTile tile={{ kind: "loading", ... }} /> : null;
}
plaintext

设计点:UI 组件不直接理解 ToolUIPart、output-available、output-error、tool input 等 runtime 细节;adapter 把它们压成 tile.kind。这个拆法使 fixture 很稳定,也让 no_image 这种业务语义不会被误渲染成失败。

4. Storybook Vite shim 只处理实际打包边界#

viteFinal: async (viteConfig) => {
  const { mergeConfig } = await import("vite");
  const tailwindcss = (await import("@tailwindcss/vite")).default;
  return mergeConfig(viteConfig, {
    plugins: [tailwindcss()],
    define: {
      __dirname: '""',
      "process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID": JSON.stringify(
        process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "",
      ),
    },
    resolve: {
      alias: {
        "@": fileURLToPath(new URL("../../site/src", import.meta.url)),
      },
    },
  });
}
plaintext

设计点:Storybook 复用 site 的 Next config,但 browser bundle 会碰到 next/navigation 间接拉入的 Node-only __dirname。这里把兼容性修补放在 Storybook 的 bundling boundary,而不是污染业务组件。

和最近学习记录的关系#

和昨天 #4344 有直接模块关系:#4344 在 backend/workers/agent 的 image_generation tool schema 中把附件图作为 referenceAssetIds 传给任务边界;#4390 则在 packages/site 的 agent chat 渲染层,把 image_generation tool part 的各种输出状态拆成可观察、可固定 fixture 的 UI。两者都围绕 image_generation,但关注点分别是 agent runtime/tool schema 和前端 tool-part presentation boundary。

和 2026-07-04 的 #4314 也有间接关系:#4314 迁移 task worker 的图片生成供应商边界,并把 content-policy/no-image 等供应商结果映射成产品语义;#4390 在 UI 侧继续消费 no_image / error / loading / success 这些语义,说明后端 outcome taxonomy 正在向前端调试面沉淀。

我会怎么吸收#

  • 给复杂 runtime UI 做 Storybook 时,先拆 presentational component 和 runtime adapter;fixture 测展示层,生产路径保留真实 dispatch。
  • 多工具、多状态、多 legacy alias 的 UI,不要靠消息列表堆 if;用 registry entry 表达 tool 覆盖、合并策略和 fallback。
  • 把“模型没产出图”这类业务结果建成中性状态,而不是默认 error;这能降低用户误判,也方便日志和 UI 一致。

边界/风险#

  • 这个 PR 主要验证 Storybook 和静态 fixture,没有新增交互级 regression test。对纯展示组件这符合“测试要值回成本”,但 registry merge/hide 的边界仍依赖现有路径和人工 Storybook 验证。
  • ImageGenerationToolPart 仍依赖 AgentImagePreview、InsufficientCreditsNotice 这类 site runtime 组件;stories 里覆盖了 tile 外观,但成功态的真实图片加载/插入行为没有在本 PR 中端到端验证。
  • Storybook shim __dirname 是明确的 bundler 兼容补丁;后续 next/navigation 或 OpenTelemetry 打包路径变化时,这类 shim 可能需要重新评估。

候选说明#

按 Australia/Melbourne 2026-07-06 当天窗口,查到 4 个未讲过的 merged PR:#4406 Cloudflare agents runtime 依赖升级、#4390 agent chat message-part stories、#4384 editor-api 本地开发修补、#4310 UI states polish。昨天窗口里还有 #4352、#4335、#4341、#4180 等;#4344 已在学习日志中记录,因此去重跳过。

最终选择 #4390,因为它虽然以 Storybook 为标题,但 diff 里有清晰的工程边界调整:tool-part registry、presentational/adapter split、merged tool part grouping、Storybook bundling shim。它比依赖升级和纯 UI polish 更适合作为今天的工程学习样本。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back