14 · Canvas 工作流
让模型在消息正文里输出 `canvasArtifact`,并把结构化 React 结果接到右侧只读 Canvas 面板。
- 源码目录:phase-06-advanced-features/lesson-14-canvas-workflow
- 建议先读:
app/agent/chatbot.ts、app/canvas/parse-canvas-artifacts.ts、app/hooks/useCanvasArtifacts.ts
一、学习目标
本课所在阶段:第六阶段 · 高级功能。
学完这一课后,你应该能够:
- 解释为什么
canvas出现在工具面板里,但它本身不是一个真正的 LangChain Tool - 说清
toolIds、Canvas 系统提示、canvasArtifactXML、右侧 Canvas 面板之间的完整关系 - 看懂
parseCanvasArtifactsFromContent(...)怎样把消息正文解析成结构化 artifact - 理解
app/hooks/useCanvasArtifacts.ts为什么要用messageId -> artifactId的双层 Map 管理状态 - 明确这节课只打通“生成并打开”的最小链路,还没有进入编辑、持久化和分享
二、问题背景
第 13 课已经证明了一件事:聊天系统不一定只会输出纯文本,它也可以通过统一工具配置触发图片生成,并把结果渲染成图片卡片。
但到了“生成一个 React 组件”这种需求时,上一课的链路就不够用了。
如果你继续把组件代码当作普通文本处理,会马上遇到三个问题:
- 代码只能堆在消息气泡里,读起来很长,也没法直接切换到一个独立工作区。
- 你不知道这段代码到底只是“模型说的话”,还是一个应该被前端单独消费的结构化结果。
- 后面想做预览、编辑、保存时,没有稳定的协议边界可以承接。
所以这一课真正解决的不是“再多加一个面板”,而是先定义一条新链路:
- 模型在正文里输出结构化标签。
- 前端把正文里的结构化标签提取成 artifact。
- 消息区显示可点击入口。
- 右侧 Canvas 以只读方式打开结果。
如果没有这一步,后面的“编辑工作区”“保存 artifact”“分享预览页”都没有落脚点。
三、核心概念
1. canvas 是能力开关,不是 LangChain Tool
这一课最容易看错的地方,就是把 canvas 当成普通工具。
当前代码里,app/agent/config/unified-tools.config.ts 把它放在 capabilityOptions,而不是 toolDefinitions。这意味着:
- 前端工具面板可以让你勾选
canvas。 createLangChainTools(...)不会真的创建一个名为canvas的DynamicStructuredTool。chatbot.ts只是根据toolIds.includes('canvas')决定是否注入 Canvas 专用系统提示。
换句话说,canvas 不是“让模型调用一个工具”,而是“允许模型在正文里使用另一套输出协议”。
2. 消息正文同时承担两层语义
assistant 在这一课输出的不是普通 markdown,而是形如下面这种结构:
<canvasArtifact id="canvas-xxx" type="react" title="用户资料卡片">
<canvasCode language="jsx">
import React from 'react';
export default function UserCard() {
return <div>...</div>;
}
</canvasCode>
</canvasArtifact>这段正文有两层意义:
- 对消息历史来说,它仍然是 assistant 回复的一部分。
- 对前端渲染来说,它又是一段需要被提取、变成 artifact,再映射成
CanvasTitleCard的结构化数据。
这就是为什么第 14 课的关键,不在于“再写一段 JSX”,而在于让文本流第一次长出结构化分支。
3. store 不是按 artifact 平铺,而是按消息归档
app/hooks/useCanvasArtifacts.ts 维护的是:
Map<string, Map<string, CanvasArtifact>>对应关系是:
- 外层 key 是
messageId - 内层 key 是
artifactId
这么做的价值很直接:
- 你能知道“某条 assistant 消息里有哪些 artifact”。
MarkdownRenderer能在渲染当前消息时,通过messageId + artifactId找到对应 artifact。- 当消息列表重算时,store 能整批替换当前消息对应的 artifact,而不会把不同消息的结构化结果混在一起。
4. 本课的增量集中在渲染层,聊天主链路基本不动
相对第 13 课,这一课真正新增的是:
| 维度 | 第 13 课 | 第 14 课 |
|---|---|---|
| 高级输出类型 | <imagecard> | <canvasArtifact> |
| 触发方式 | 真实 LangChain Tool | canvas 能力开关 + 系统提示 |
| 解析位置 | 工具结果并回消息正文 | 直接从 assistant 正文解析 |
| 前端状态 | 只管理消息和工具调用 | 新增 canvasStore 管理 artifact |
| 右侧工作区 | 没有 | 新增只读 CanvasPanel |
同时有两条链路刻意没改:
/api/chat的 SSE 事件结构仍然沿用上一课。- 图片工具、计算器、当前时间这些真实工具的注册方式没有变化。
这说明第 14 课的目标很克制:先把 Canvas 作为“新输出协议”插进去,而不是把整套聊天架构再推倒重来。
四、关键文件
app/agent/config/unified-tools.config.ts
这里把 canvas 作为 capabilityOptions 暴露给工具面板。它解释了为什么 UI 上能勾选 Canvas,但后端不会真的创建一个名为 canvas 的 LangChain tool。
这是协议切换的入口。它根据 toolIds 判断是否启用 Canvas,再决定给模型注入 getCanvasSystemPrompt(...) 还是普通工具提示。
这里定义了 canvasArtifact 必须长什么样。前端之所以能稳定解析,很大程度上依赖这里把输出格式约束死了。
app/canvas/parse-canvas-artifacts.ts
它负责把 assistant 正文里的 XML 标签提取成 CanvasArtifact[]。这是“文本流转结构化数据”的核心一跳。
app/hooks/useCanvasArtifacts.ts
这是本课最小 store 实现。它负责存 artifact、记录当前激活的 artifact,以及控制右侧 Canvas 是否可见。
app/components/MarkdownRenderer.tsx
这里把 <canvasartifact> 自定义标签映射成 CanvasTitleCard。如果没有它,正文里的 XML 只会原样显示出来。
app/components/canvas/CanvasPanel.tsx
右侧最小工作区。它目前只做两件事:预览和看代码,没有编辑器、保存或数据库持久化。
五、整体流程
这张图里最值得你先记住的一点是:Canvas 并没有新开一条独立 API,它仍然从原有聊天流里长出来,只是 assistant 正文里多了一种结构化格式。
六、运行过程
1. canvas 先作为可选能力出现在工具面板
前端工具面板拿到的 tools 里,已经包含了 canvas 这个选项。你勾选它之后,page.tsx 仍然像上一课一样,把 toolIds 一起发给 /api/chat。
这里最关键的判断是:
canvas会随toolIds一起提交。- 但后端不会把它变成真实工具函数。
- 它只负责告诉模型“这轮允许你用 Canvas 协议回答”。
2. chatbot.ts 根据 toolIds 决定注入哪种系统提示
app/agent/chatbot.ts 里有两条互斥逻辑:
- 如果启用了
canvas,优先注入getCanvasSystemPrompt(generateArtifactId()) - 否则如果只是启用了普通工具,再注入
getToolUsagePrompt()
这说明 Canvas 在这一课不是“和其他工具并列执行”的概念,而是直接改变 assistant 正文的输出方式。
3. assistant 在正文里输出固定 XML 结构
当模型命中 Canvas 场景时,正文必须包含:
<canvasArtifact>id、title<canvasCode language="jsx">- 一段完整的
export default function
这一步如果格式漂了,后面的解析和渲染都会断掉,所以 canvas-prompt.ts 把规则写得很死。
4. page.tsx 每次消息变化都会重算 artifact
page.tsx 并没有维护一个“单独的 Canvas 事件流”,而是更直接地在 messages 变化后执行:
- 遍历所有 assistant 消息
- 调用
parseCanvasArtifactsFromContent(message.id, message.content) - 把结果统一交给
canvasStore.replaceArtifacts(...)
这也是为什么本课的思路很好理解:先让 artifact 依附在消息正文里,再从正文里提取,不需要多教一套新的流式协议。
5. canvasStore 负责把“解析结果”变成“可交互状态”
这份最小 store 只做三类事情:
- 保存所有 artifact
- 记录当前激活的 artifact id
- 控制右侧 Canvas 是否可见
当前实现还没有编辑、保存、分享这些高级行为,所以 store 的职责边界很清楚,也很适合你在这一课先读懂。
6. MarkdownRenderer 把正文里的 XML 入口换成标题卡
真正把学习体验拉开的,是这一跳:
MessageBubble会把message.id传给MarkdownRendererMarkdownRenderer识别<canvasartifact>- 它再从
canvasStore里找出这条消息对应的 artifact - 最终渲染成
CanvasTitleCard
也就是说,用户看到的不是一坨 XML,而是一个可点击的“打开 Canvas”入口。
7. 右侧 CanvasPanel 目前是只读查看器
打开之后,右侧面板支持两个标签页:
预览代码
预览用的是 iframe + CDN React + Babel + Tailwind 的最小运行时。它能把 JSX 跑起来,但这节课故意不做:
- 在线编辑
- 保存到数据库
- 分享链接
- 独立 artifact 页面
相比参考项目或后续课时,这一版更像“最小可打开工作区”。这正是第 14 课应该停下来的位置。
七、关键代码解析
关键代码 1:app/agent/chatbot.ts 如何切到 Canvas 协议
const tools = createLangChainTools(toolIds);
const canvasEnabled = toolIds?.includes('canvas') ?? false;
const hasSelectedTools = tools.length > 0;
let modelMessages = toModelMessages(state.messages);
if (canvasEnabled) {
modelMessages = [new SystemMessage(getCanvasSystemPrompt(generateArtifactId())), ...modelMessages];
} else if (hasSelectedTools) {
modelMessages = [new SystemMessage(getToolUsagePrompt()), ...modelMessages];
}代码解析:
- 这段代码决定了 Canvas 的本质不是“执行一个函数”,而是“给模型换一套输出规范”。
createLangChainTools(...)只会生成真实工具,canvasEnabled则额外从toolIds里检查能力开关。两者分开,正好对应了“工具执行”和“正文协议”两条职责。- 如果没有这段分流逻辑,模型就不知道什么时候该输出普通文本、什么时候该输出
canvasArtifact。
关键代码 2:app/canvas/parse-canvas-artifacts.ts 如何把正文提取成 artifact
export function parseCanvasArtifactsFromContent(messageId: string, content: string): CanvasArtifact[] {
const artifacts: CanvasArtifact[] = [];
const artifactPattern = /<canvasArtifact\b([^>]*)>([\s\S]*?)<\/canvasArtifact>/gi;
let artifactMatch = artifactPattern.exec(content);
while (artifactMatch) {
const artifactAttributes = parseAttributes(artifactMatch[1] || '');
const artifactBody = artifactMatch[2] || '';
const codeMatch = artifactBody.match(/<canvasCode\b([^>]*)>([\s\S]*?)<\/canvasCode>/i);
if (artifactAttributes.id && artifactAttributes.title && codeMatch) {
const code = codeMatch[2].replace(/^\n+|\n+$/g, '');
artifacts.push({
id: artifactAttributes.id,
type: 'react',
title: artifactAttributes.title,
code: {
language: 'jsx',
content: code,
},
status: 'ready',
messageId,
createdAt: new Date(),
updatedAt: new Date(),
});
}
artifactMatch = artifactPattern.exec(content);
}
return artifacts;
}代码解析:
- 这段代码把“消息正文中的 XML”翻译成了前端真正能消费的
CanvasArtifact结构。 messageId会被一起带进去,所以 artifact 从一开始就和具体消息绑定,而不是脱离聊天上下文单独存在。- 如果没有这层解析,
MarkdownRenderer就只能看到原始标签字符串,右侧工作区也不知道该打开什么。
关键代码 3:app/components/MarkdownRenderer.tsx 如何把 XML 入口换成标题卡
function handleOpenArtifact(artifactId: string) {
canvasStore.setActiveArtifactId(artifactId);
canvasStore.setIsCanvasVisible(true);
}
canvasartifact: ({ node, ...props }: any) => (
<CanvasTitleCard
key={props.id || 'canvas-artifact'}
artifact={getArtifact(props.id || 'canvas-artifact')}
onOpen={handleOpenArtifact}
/>
),代码解析:
- 这里完成了“正文结构”到“交互组件”的最后一跳。用户点击的其实不是消息原文,而是由
MarkdownRenderer动态替换出来的CanvasTitleCard。 getArtifact(...)会结合当前messageId去 store 里找 artifact,所以同一个页面里多条 assistant 消息也不会串。- 如果少了这段映射,Canvas 协议仍然存在,但用户体验会退化成“只能看到一段标签文本”,也就失去了独立工作区的意义。
八、常见问题
1. 为什么 canvas 放在工具面板里,却不是一个真实工具?
因为它解决的不是“执行一个动作并返回结果”,而是“允许模型换一种正文输出格式”。这一课需要的是协议切换,不是再注册一个函数调用入口。
2. 为什么这节课不直接做编辑器和数据库保存?
因为本课的首要任务是把“结构化结果能稳定进入 Canvas”这条主链路跑通。编辑、持久化、分享都建立在这个协议已经稳定的前提上,后一课再做更合适。
3. 为什么不用代码块而要用 XML 标签?
因为前端需要一种足够稳定、可定位、可解析的结构。普通代码块只能表示“这里有一段代码”,但很难可靠地表达 id、title、type 这些额外元数据。
九、练习题
- 给
CanvasTitleCard增加一个“在预览模式打开”的副标题状态,让你更明确地看到当前打开的是哪一种 artifact。 - 尝试在
canvas-prompt.ts中增加对“页面标题”或“组件用途”的额外约束,再观察 assistant 生成结果是否更稳定。 - 在
CanvasPanel里新增一个“复制代码”按钮,但不要引入编辑器和数据库保存,保持本课边界不变。
十、总结
第 14 课最重要的成果,不是右侧多了一个面板,而是聊天正文第一次拥有了可解析、可打开、可继续演进的结构化分支。
读完这一课后,你最应该牢牢记住三件事:
canvas是协议能力开关,不是真正工具。canvasArtifact先存在于正文,再被前端解析成 artifact。- 当前 Canvas 只是最小只读工作区,真正的编辑、保存和分享要到下一课才开始。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。