13 · Google 图片生成工具
在统一工具系统中接入真实图片生成 API,让工具结果以 loading 卡片和最终图片卡片两段式进入聊天界面。
课时资源
一、学习目标
本课所在阶段:第六阶段 · 高级功能。
学完这一课后,你应该能够:
- 说清为什么图片工具仍然应该走统一工具配置,而不是单独开一条特殊 API
- 看懂图片工具的结果为什么不是一次性直接落到消息气泡,而是先经过工具过程态,再进入最终回复
- 理解
tool.call占位事件和完成事件各自负责什么 - 明白为什么最终图片展示是
markdown -> imagecard -> ImageCard这条渲染链,而不是工具卡片里直接塞一张图 - 认识这一课已经接入真实 Google 图片生成 API,并把结果上传到 Supabase Storage
二、问题背景
到了第十二课,聊天系统已经具备真实数据库边界,但工具结果仍然主要是文本。
这时会出现一个新问题:如果模型调用的不是"查时间"或"算表达式"这种文本工具,而是"生成一张图片",系统该如何承接?
如果没有这一课,后面的高级输出能力会卡在几个地方:
- 工具只能返回字符串,无法表达更丰富的结果结构
- 前端看不到清晰的"图片生成中"过程态
- 最终回复没办法把图片作为正式内容的一部分渲染出来
所以这一课真正解决的不是"接一个图片 API"这么简单,而是把图片型工具纳入现有 Agent 链路,并让 UI 能稳定地展示过程态和完成态。
三、核心概念
这一课最重要的概念是:图片工具的结果要分成两层展示。
第一层是工具调用过程:
on_chat_model_end先发出占位tool.call- 前端先展示工具卡片和 loading
ImageCard
第二层是 assistant 最终回复:
- 工具完成后返回
output、imageUrl、markdown message.end时把markdown片段拼进 assistant 消息正文MarkdownRenderer再把<imagecard>解析成真正的ImageCard
这意味着当前代码里,工具卡片和最终消息不是同一件事:
ToolCallDisplay负责展示"工具正在做什么"MarkdownRenderer负责展示"assistant 最终回复里真正要呈现什么"
四、整体流程
五、运行过程
1. 图片工具继续走统一工具配置
前端不会为图片生成单独发一个特殊请求,而是和前一课一样,把 toolIds 一起发给后端。
这样 chatbot.ts 里只需要:
- 根据
toolIds创建 LangChain tools - 让模型决定是否调用
google_image_generation
这一步说明图片工具虽然能力更复杂,但系统结构并没有分叉。
2. 图片生成现在已经是"真实 API + Storage"链路
这一课当前代码不再是本地 SVG 演示版。
google-image-generation.ts 里会:
- 读取
GOOGLE_API_KEY - 调用
gemini-3-pro-image-preview - 从响应里取出图片二进制
- 上传到 Supabase Storage
- 返回可公开访问的
imageUrl
也就是说,这一课的重点已经从"伪造一个图片结果"升级成"把真实图片结果送回聊天界面"。
3. tool.call 不是一次性完成,而是先占位再补全
chatbot.ts 里的事件流有一个很关键的升级:
on_chat_model_end先读出模型决定调用的工具,发出只包含id、name、args的占位tool.callon_tool_end再把output、imageUrl、markdown补回来
这一步非常重要,因为它给了前端明确的"过程态"。
4. 前端会按 toolCall.id 合并同一条工具调用
page.tsx 里的 attachToolCall(...) 不是简单 push,而是:
- 先找有没有同一个
toolCall.id - 有就合并
- 没有才新增
所以同一条图片工具调用会经历:
- 先出现 loading 卡片
- 再变成完成态结果
而不是前端列表里插入两条重复工具记录。
5. 最终图片不在工具卡片里直接展示
这是这一课最容易看错的地方。
当前 ToolCallDisplay.tsx 的设计是:
- 图片工具未完成时:显示 loading
ImageCard - 图片工具完成后:只显示一句"图片已生成,结果会显示在下方回复中"
真正的图片结果会在 message.end 时,通过 finishAssistantMessage(...) 把 toolCall.markdown 追加到 assistant 消息正文。
6. 最终图片展示由 MarkdownRenderer 完成
工具返回的 markdown 片段形如:
<imagecard status="ready" src="..." prompt="..."></imagecard>assistant 气泡不是直接渲染纯文本,而是走 MarkdownRenderer:
react-markdownrehype-raw- 自定义
imagecard组件映射
最后才变成真正的 ImageCard。
六、关键代码解析
app/agent/config/unified-tools.config.ts - 统一工具配置,把图片工具接入统一工具注册表
关键代码:
{
id: 'google_image_generation',
name: 'google_image_generation',
description: '生成图片',
icon: '🖼️',
enabled: true,
schema: z.object({
prompt: z.string().describe('要生成图片的描述'),
aspectRatio: z.enum(['1:1', '16:9', '9:16', '4:3', '3:4']).optional().default('1:1'),
imageSize: z.enum(['1K', '2K', '4K']).optional().default('1K'),
}),
handler: async (input) =>
generateImageWithGoogle({
prompt: String(input.prompt || 'Generated image'),
aspectRatio: input.aspectRatio as '1:1' | '16:9' | '9:16' | '4:3' | '3:4' | undefined,
imageSize: input.imageSize as '1K' | '2K' | '4K' | undefined,
}),
}代码解析:
- 图片工具和文本工具放在同一张
toolDefinitions表里 - 模型可以拿到更完整的工具参数约束,而不只是一个
prompt - 高级工具的接入方式仍然和前面课时保持一致
app/agent/tools/google-image-generation.ts - 真实图片生成工具实现,调用 Google API 并上传到 Storage
关键代码:
return {
output: '图片已生成,请在最终回复中直接展示图片卡片,不要把标签放进代码块。',
imageUrl: uploadResult.url,
markdown: `<imagecard status="ready" src="${uploadResult.url}" download="${uploadResult.url}" prompt="${prompt.replace(/"/g, '"')}" aspectRatio="${aspectRatio}"></imagecard>`,
};代码解析:
output给工具过程态和模型补充说明imageUrl给前端过程组件判断是否完成markdown决定最终 assistant 回复里要出现什么内容
app/page.tsx - 在流式事件里挂接工具调用,并在消息结束时拼接 markdown
关键代码:
const markdownSnippets = (message.toolCalls ?? [])
.map((toolCall) => toolCall.markdown)
.filter((snippet): snippet is string => typeof snippet === 'string' && snippet.length > 0)
.filter((snippet) => !message.content.includes(snippet));
const extraContent = markdownSnippets.length > 0 ? `\n\n${markdownSnippets.join('\n\n')}` : '';代码解析:
- 工具调用过程和最终回复正文是分开的
- 只有在 assistant 这一轮回复结束时,才把图片卡片正式并入消息内容
- 这样 UI 才能同时保留过程态和结果态,不会互相打架
app/agent/chatbot.ts - 负责两段式 tool.call 事件和图片工具结果解析
代码解析:
on_chat_model_end发出占位tool.call事件(只包含id、name、args)on_tool_end补充完整的工具结果(包含output、imageUrl、markdown)- 使用
SupabaseSaver保持线程状态持久化 - 注意:代码日志使用
[lesson-13]前缀,后续课程复用此文件时未更新日志标记
app/components/ToolCallDisplay.tsx - 展示工具过程态,图片工具完成后提示结果在下方
代码解析:
- 未完成时显示 loading
ImageCard - 完成后只提示"结果会显示在下方回复中"
- 保持工具卡片作为"过程信息"的职责边界
app/components/MarkdownRenderer.tsx - 把 assistant 消息里的 <imagecard> 渲染成真正的图片卡片组件
代码解析:
- 使用
react-markdown和rehype-raw解析自定义标签 - 把
imagecard标签映射到ImageCard组件 - 形成最终的图片展示渲染链
七、和旧实现说明的差别
如果你看过这一课较早的 README,会发现现在的代码已经发生了几个关键变化:
- 不再是本地 SVG 预览方案,而是接入真实 Google 图片生成 API
- 不再是工具卡片里直接把图片渲染完就结束
ToolCallRecord现在除了output、imageUrl,还新增了markdown- 图片结果最终进入 assistant 回复,而不是停留在工具过程区
所以这一课现在更准确的主线应该是:
- 统一工具配置扩展到高级工具
- 工具事件升级成占位态和完成态两段式
- 消息渲染升级成"过程展示 + 最终展示"两层
八、常见问题
这一课还沿用第十二课的数据库边界吗?
沿用。当前 chatbot.ts 继续使用 SupabaseSaver,线程元数据边界也还在 Supabase 那条链路上。
为什么图片完成后,工具卡片里反而不直接显示图片?
因为工具卡片负责的是"过程信息",最终内容应该作为 assistant 回复的一部分出现。这样聊天记录在回放时语义更稳定。
为什么还要引入 markdown,不能只靠 imageUrl 吗?
只靠 imageUrl 只能说明"有一张图"。加入 markdown 之后,最终渲染协议就能继续扩展到更多自定义组件,而不只是图片。
这一课真正升级的点是什么?
不是单纯加了一个图片工具,而是让高级工具第一次具备了完整的事件流、结构化结果和最终消息渲染链。
九、练习题
- 解释为什么同一条图片工具调用要分成占位
tool.call和完成tool.call两个阶段。 - 如果要增加"下载图片"或"复制提示词"能力,你会优先改
ImageCard还是ToolCallDisplay?为什么? - 说清
imageUrl和markdown在这一课里分别承担什么职责。
十、总结
这一课真正完成的是:让高级工具第一次以"真实图片 API + 两段式事件流 + 最终消息渲染协议"的方式进入聊天系统。
从这一课开始,工具已经不只是给 assistant 补一句文本,而是可以生成真正的多媒体结果,并以课程后续还能继续扩展的结构进入 UI。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。