第六阶段 · 高级功能

14 · Canvas 工作流

让模型在消息正文里输出 `canvasArtifact`,并把结构化 React 结果接到右侧只读 Canvas 面板。

一、学习目标

本课所在阶段:第六阶段 · 高级功能

学完这一课后,你应该能够:

  • 解释为什么 canvas 出现在工具面板里,但它本身不是一个真正的 LangChain Tool
  • 说清 toolIds、Canvas 系统提示、canvasArtifact XML、右侧 Canvas 面板之间的完整关系
  • 看懂 parseCanvasArtifactsFromContent(...) 怎样把消息正文解析成结构化 artifact
  • 理解 app/hooks/useCanvasArtifacts.ts 为什么要用 messageId -> artifactId 的双层 Map 管理状态
  • 明确这节课只打通“生成并打开”的最小链路,还没有进入编辑、持久化和分享

二、问题背景

第 13 课已经证明了一件事:聊天系统不一定只会输出纯文本,它也可以通过统一工具配置触发图片生成,并把结果渲染成图片卡片。

但到了“生成一个 React 组件”这种需求时,上一课的链路就不够用了。

如果你继续把组件代码当作普通文本处理,会马上遇到三个问题:

  1. 代码只能堆在消息气泡里,读起来很长,也没法直接切换到一个独立工作区。
  2. 你不知道这段代码到底只是“模型说的话”,还是一个应该被前端单独消费的结构化结果。
  3. 后面想做预览、编辑、保存时,没有稳定的协议边界可以承接。

所以这一课真正解决的不是“再多加一个面板”,而是先定义一条新链路:

  1. 模型在正文里输出结构化标签。
  2. 前端把正文里的结构化标签提取成 artifact。
  3. 消息区显示可点击入口。
  4. 右侧 Canvas 以只读方式打开结果。

如果没有这一步,后面的“编辑工作区”“保存 artifact”“分享预览页”都没有落脚点。

三、核心概念

1. canvas 是能力开关,不是 LangChain Tool

这一课最容易看错的地方,就是把 canvas 当成普通工具。

当前代码里,app/agent/config/unified-tools.config.ts 把它放在 capabilityOptions,而不是 toolDefinitions。这意味着:

  1. 前端工具面板可以让你勾选 canvas
  2. createLangChainTools(...) 不会真的创建一个名为 canvasDynamicStructuredTool
  3. 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>

这段正文有两层意义:

  1. 对消息历史来说,它仍然是 assistant 回复的一部分。
  2. 对前端渲染来说,它又是一段需要被提取、变成 artifact,再映射成 CanvasTitleCard 的结构化数据。

这就是为什么第 14 课的关键,不在于“再写一段 JSX”,而在于让文本流第一次长出结构化分支。

3. store 不是按 artifact 平铺,而是按消息归档

app/hooks/useCanvasArtifacts.ts 维护的是:

Map<string, Map<string, CanvasArtifact>>

对应关系是:

  1. 外层 key 是 messageId
  2. 内层 key 是 artifactId

这么做的价值很直接:

  1. 你能知道“某条 assistant 消息里有哪些 artifact”。
  2. MarkdownRenderer 能在渲染当前消息时,通过 messageId + artifactId 找到对应 artifact。
  3. 当消息列表重算时,store 能整批替换当前消息对应的 artifact,而不会把不同消息的结构化结果混在一起。

4. 本课的增量集中在渲染层,聊天主链路基本不动

相对第 13 课,这一课真正新增的是:

维度第 13 课第 14 课
高级输出类型<imagecard><canvasArtifact>
触发方式真实 LangChain Toolcanvas 能力开关 + 系统提示
解析位置工具结果并回消息正文直接从 assistant 正文解析
前端状态只管理消息和工具调用新增 canvasStore 管理 artifact
右侧工作区没有新增只读 CanvasPanel

同时有两条链路刻意没改:

  1. /api/chat 的 SSE 事件结构仍然沿用上一课。
  2. 图片工具、计算器、当前时间这些真实工具的注册方式没有变化。

这说明第 14 课的目标很克制:先把 Canvas 作为“新输出协议”插进去,而不是把整套聊天架构再推倒重来。

四、关键文件

app/agent/config/unified-tools.config.ts

这里把 canvas 作为 capabilityOptions 暴露给工具面板。它解释了为什么 UI 上能勾选 Canvas,但后端不会真的创建一个名为 canvas 的 LangChain tool。

app/agent/chatbot.ts

这是协议切换的入口。它根据 toolIds 判断是否启用 Canvas,再决定给模型注入 getCanvasSystemPrompt(...) 还是普通工具提示。

app/canvas/canvas-prompt.ts

这里定义了 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

这里最关键的判断是:

  1. canvas 会随 toolIds 一起提交。
  2. 但后端不会把它变成真实工具函数。
  3. 它只负责告诉模型“这轮允许你用 Canvas 协议回答”。

2. chatbot.ts 根据 toolIds 决定注入哪种系统提示

app/agent/chatbot.ts 里有两条互斥逻辑:

  1. 如果启用了 canvas,优先注入 getCanvasSystemPrompt(generateArtifactId())
  2. 否则如果只是启用了普通工具,再注入 getToolUsagePrompt()

这说明 Canvas 在这一课不是“和其他工具并列执行”的概念,而是直接改变 assistant 正文的输出方式。

3. assistant 在正文里输出固定 XML 结构

当模型命中 Canvas 场景时,正文必须包含:

  1. <canvasArtifact>
  2. idtitle
  3. <canvasCode language="jsx">
  4. 一段完整的 export default function

这一步如果格式漂了,后面的解析和渲染都会断掉,所以 canvas-prompt.ts 把规则写得很死。

4. page.tsx 每次消息变化都会重算 artifact

page.tsx 并没有维护一个“单独的 Canvas 事件流”,而是更直接地在 messages 变化后执行:

  1. 遍历所有 assistant 消息
  2. 调用 parseCanvasArtifactsFromContent(message.id, message.content)
  3. 把结果统一交给 canvasStore.replaceArtifacts(...)

这也是为什么本课的思路很好理解:先让 artifact 依附在消息正文里,再从正文里提取,不需要多教一套新的流式协议。

5. canvasStore 负责把“解析结果”变成“可交互状态”

这份最小 store 只做三类事情:

  1. 保存所有 artifact
  2. 记录当前激活的 artifact id
  3. 控制右侧 Canvas 是否可见

当前实现还没有编辑、保存、分享这些高级行为,所以 store 的职责边界很清楚,也很适合你在这一课先读懂。

6. MarkdownRenderer 把正文里的 XML 入口换成标题卡

真正把学习体验拉开的,是这一跳:

  1. MessageBubble 会把 message.id 传给 MarkdownRenderer
  2. MarkdownRenderer 识别 <canvasartifact>
  3. 它再从 canvasStore 里找出这条消息对应的 artifact
  4. 最终渲染成 CanvasTitleCard

也就是说,用户看到的不是一坨 XML,而是一个可点击的“打开 Canvas”入口。

7. 右侧 CanvasPanel 目前是只读查看器

打开之后,右侧面板支持两个标签页:

  1. 预览
  2. 代码

预览用的是 iframe + CDN React + Babel + Tailwind 的最小运行时。它能把 JSX 跑起来,但这节课故意不做:

  1. 在线编辑
  2. 保存到数据库
  3. 分享链接
  4. 独立 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];
}

代码解析:

  1. 这段代码决定了 Canvas 的本质不是“执行一个函数”,而是“给模型换一套输出规范”。
  2. createLangChainTools(...) 只会生成真实工具,canvasEnabled 则额外从 toolIds 里检查能力开关。两者分开,正好对应了“工具执行”和“正文协议”两条职责。
  3. 如果没有这段分流逻辑,模型就不知道什么时候该输出普通文本、什么时候该输出 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;
}

代码解析:

  1. 这段代码把“消息正文中的 XML”翻译成了前端真正能消费的 CanvasArtifact 结构。
  2. messageId 会被一起带进去,所以 artifact 从一开始就和具体消息绑定,而不是脱离聊天上下文单独存在。
  3. 如果没有这层解析,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}
  />
),

代码解析:

  1. 这里完成了“正文结构”到“交互组件”的最后一跳。用户点击的其实不是消息原文,而是由 MarkdownRenderer 动态替换出来的 CanvasTitleCard
  2. getArtifact(...) 会结合当前 messageId 去 store 里找 artifact,所以同一个页面里多条 assistant 消息也不会串。
  3. 如果少了这段映射,Canvas 协议仍然存在,但用户体验会退化成“只能看到一段标签文本”,也就失去了独立工作区的意义。

八、常见问题

1. 为什么 canvas 放在工具面板里,却不是一个真实工具?

因为它解决的不是“执行一个动作并返回结果”,而是“允许模型换一种正文输出格式”。这一课需要的是协议切换,不是再注册一个函数调用入口。

2. 为什么这节课不直接做编辑器和数据库保存?

因为本课的首要任务是把“结构化结果能稳定进入 Canvas”这条主链路跑通。编辑、持久化、分享都建立在这个协议已经稳定的前提上,后一课再做更合适。

3. 为什么不用代码块而要用 XML 标签?

因为前端需要一种足够稳定、可定位、可解析的结构。普通代码块只能表示“这里有一段代码”,但很难可靠地表达 idtitletype 这些额外元数据。

九、练习题

  1. CanvasTitleCard 增加一个“在预览模式打开”的副标题状态,让你更明确地看到当前打开的是哪一种 artifact。
  2. 尝试在 canvas-prompt.ts 中增加对“页面标题”或“组件用途”的额外约束,再观察 assistant 生成结果是否更稳定。
  3. CanvasPanel 里新增一个“复制代码”按钮,但不要引入编辑器和数据库保存,保持本课边界不变。

十、总结

第 14 课最重要的成果,不是右侧多了一个面板,而是聊天正文第一次拥有了可解析、可打开、可继续演进的结构化分支。

读完这一课后,你最应该牢牢记住三件事:

  1. canvas 是协议能力开关,不是真正工具。
  2. canvasArtifact 先存在于正文,再被前端解析成 artifact。
  3. 当前 Canvas 只是最小只读工作区,真正的编辑、保存和分享要到下一课才开始。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page