第六阶段 · 高级功能

15 · 自定义渲染与交互

在不改 `canvasArtifact` 协议的前提下,把右侧 Canvas 升级成可编辑、可保存、可分享的工作区。

课时资源

一、学习目标

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

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

  • 解释为什么第 15 课几乎不改 canvasArtifact 协议,却依然是一轮明显的能力升级
  • 看懂 CanvasPanel 为什么要同时维护 originalCodedraftCode
  • 理解 replaceArtifacts(...)mergeArtifacts(...)updateArtifactCode(...) 各自解决什么问题
  • 说清 api/artifactsartifact.service.tsdatabase/artifacts.ts 如何把工作区结果落到 Supabase
  • 明白独立分享页和可调宽度侧栏是怎样把“查看器”升级成“工作区”的

二、问题背景

第 14 课已经让你把结构化 React 结果送进右侧 Canvas。到那一步,你已经可以:

  1. 让模型在正文里输出 canvasArtifact
  2. 在消息区看到一个可点击的 CanvasTitleCard
  3. 在右侧打开预览和代码查看器

但这还远远算不上真正可用的工作区。

只要你真的开始使用上一课的 Canvas,就会马上碰到几个问题:

  1. 想改一个按钮文案,必须把代码复制出去,改完再贴回来。
  2. 刷新页面以后,右侧工作区里调整过的内容不会留下来。
  3. 没法把一个满意的 artifact 单独分享给别人查看。
  4. 侧栏宽度固定,预览和编辑没有明确的操作边界。

所以这一课真正解决的问题是:

让 Canvas 从“能打开的只读查看器”,升级成“能继续改、能继续预览、能保存、能分享”的工作区。

三、核心概念

1. 协议层稳定后,消费层可以单独演进

对比第 14 课和第 15 课,最关键的变化反而是不变的那部分:

  1. app/agent/chatbot.ts 仍然沿用 Canvas 系统提示
  2. app/canvas/canvas-prompt.ts 没有重新定义输出格式
  3. app/canvas/parse-canvas-artifacts.ts 仍然围绕 canvasArtifact 协议工作

这说明本课的升级重点不在“模型怎么输出”,而在“前端怎么消费这份结果”。

真实工程里经常会遇到这种情况:一旦协议层稳定,后续体验升级就应该尽量集中在渲染层和状态层,而不是每次都去改上下游接口。

2. 工作区必须区分“当前 artifact”与“本地草稿”

app/components/canvas/CanvasPanel.tsx 这一课最重要的设计,就是把代码拆成两份:

  1. originalCode 来自当前 artifact 的正式代码内容。

  2. draftCode 来自面板本地的可编辑副本。

这样做以后,你就可以:

  1. 先尝试修改
  2. 再决定是否应用到 store
  3. 再决定是否保存或分享
  4. 也可以随时重置回当前 artifact 的正式版本

如果没有这层分离,你每敲一个字符都会直接污染全局 store,后面就很难支持重置、版本号、保存状态这些交互。

3. store 从“打开哪个 artifact”升级成“管理工作区生命周期”

这一课的 CanvasArtifact 新增了三类字段:

  1. sessionId
  2. currentVersion
  3. isPersisted

同时 app/hooks/useCanvasArtifacts.ts 也新增了几类行为:

  1. replaceArtifacts(...)
  2. mergeArtifacts(...)
  3. updateArtifactCode(...)
  4. markArtifactPersisted(...)
  5. openArtifact(...)
  6. getInitialTab() / resetInitialTab()

这说明 store 不再只是“记录当前打开哪个 artifact”,而是开始管理工作区的恢复、版本演进和持久化状态。

4. artifact 从页面内状态升级成可复用资产

本课新增的 app/api/artifacts/route.tsapp/database/artifacts.tsapp/services/artifact.service.tsapp/artifact/[id]/page.tsx,把 artifact 的定位往前推进了一步:

  1. 它不再只是聊天页面右侧的临时结果
  2. 它可以落到 artifacts
  3. 它可以通过 artifact/{id} 单独打开

但也要注意边界:

  1. 保存的是 artifact 记录,不是回写 assistant 原消息正文
  2. 编辑器仍然是 textarea,不是完整 IDE
  3. 预览依旧是 iframe + Babel 的轻量方案,不是生产级沙箱

5. 第 15 课的真实增量

相对第 14 课,本课真正新增的源码主要是:

  • app/api/artifacts/[id]/route.ts
  • app/api/artifacts/route.ts
  • app/artifact/[id]/ArtifactNotFound.tsx
  • app/artifact/[id]/ArtifactView.tsx
  • app/artifact/[id]/page.tsx
  • app/components/ResizablePanel.tsx
  • app/components/canvas/CodePreviewPanel.tsx
  • app/database/artifacts.ts
  • app/services/artifact.service.ts
  • supabase-schema.sql

这组文件共同完成了一件事:让 Canvas 不再只是“现算现看”,而是拥有自己的保存、恢复和分享生命周期。

四、关键文件

本课最值得你先读的文件如下。

app/components/canvas/CanvasPanel.tsx

这是本课的中心文件。草稿编辑、保存、分享、复制、下载、重置都在这里发生。

app/components/canvas/CodePreviewPanel.tsx

这里把 JSX 预览运行时从工作区 UI 里拆了出来,让 CanvasPanel 负责交互,CodePreviewPanel 负责 iframe 渲染。

app/hooks/useCanvasArtifacts.ts

store 在这一课有了实质升级:它需要合并重算结果、记录当前版本、标记持久化状态,并支持按会话恢复 artifact。

app/page.tsx

它把工作区状态和页面状态连接起来,包括按会话加载 artifact、缓存侧栏宽度、驱动 ResizablePanel

app/api/artifacts/route.ts

GET 负责按 session_id 读取已保存 artifact,POST 负责保存当前工作区结果。

app/api/artifacts/[id]/route.ts

分享页加载失败时,ArtifactNotFound.tsx 会轮询这条接口,确认数据库里是否已经出现最新保存记录。

app/services/artifact.service.ts

它把 API 层和数据库读写拆开,并把数据库行映射回前端真正消费的 CanvasArtifact

app/database/artifacts.ts

这里是 Supabase 数据访问层,提供 upsertArtifact(...)getArtifactById(...) 和按会话查询的能力。

app/artifact/[id]/page.tsx

保存成功后,某个 artifact 可以通过独立路由直接打开,不必再进入原聊天会话。

app/components/ResizablePanel.tsx

这让右侧工作区宽度不再固定,页面第一次具备了更接近真实产品的交互手感。

supabase-schema.sql

这份 SQL 定义了 artifacts 表和对应策略,是本课保存链路真正落地的数据库边界。

五、整体流程

这张图里最重要的变化是:artifact 不再只靠消息正文“现算现用”,而是开始拥有自己的保存、恢复和分享生命周期。

六、运行过程

1. 解析器开始给 artifact 补上会话和版本信息

app/canvas/parse-canvas-artifacts.ts 现在接受第三个参数 sessionId,并给每个新解析出的 artifact 赋上:

  1. sessionId
  2. currentVersion: 1
  3. isPersisted: false

这让 artifact 从一开始就不只是“从正文里抠出来的 JSX 片段”,而是一个可以进入工作区生命周期的对象。

2. replaceArtifacts(...) 不再粗暴覆盖,而是优先保留现有工作区状态

上一课里,重新解析消息以后,旧状态可以直接被新状态替换掉。但到了本课,这样就不行了,因为用户可能已经:

  1. 编辑过代码
  2. 提高过版本号
  3. 保存到数据库

所以 replaceArtifacts(...) 会优先把现有 store 里的 artifact 合并回去,避免消息流一刷新就把工作区状态冲掉。

3. app/page.tsx 开始主动加载数据库里的 artifact

threadId 改变时,页面会去请求:

  1. api/artifacts?session_id=...
  2. 把返回结果转换成 Date
  3. 再调用 canvasStore.mergeArtifacts(...)

这意味着从本课开始,artifact 的来源不再只有消息正文,还可能来自数据库里的已保存版本。

4. CanvasPanel 先复制草稿,再决定何时写回 store

打开某个 artifact 后,CanvasPanel 会先把 artifact.code.content 复制到 draftCode,然后:

  1. 预览区实时使用 draftCode
  2. “应用到预览”才会调用 updateArtifactCode(...) 写回 store
  3. “保存”与“分享”都会直接拿 draftCode 调用 persistArtifact()

所以这里真正分开的不是“能不能预览”,而是“本地草稿”和“工作区正式版本”。

5. CodePreviewPanel.tsx 把运行时从工作区 UI 中拆了出去

这一步的价值不只是“文件更整洁”,而是让职责边界更清楚:

  1. CanvasPanel.tsx 只负责交互、状态和按钮行为
  2. CodePreviewPanel.tsx 只负责清理 import、拼接 HTML、跑 Babel、渲染 iframe

协议稳定以后,把渲染层拆清楚,是后续继续迭代最重要的准备动作。

6. 保存和分享都走同一条持久化链路

这条链路很值得你建立工程直觉:

  1. CanvasPanel.tsx 组装请求体
  2. app/api/artifacts/route.ts 拿到用户身份和 artifact 数据
  3. app/services/artifact.service.ts 把前端结构转换成数据库结构
  4. app/database/artifacts.ts 调用 Supabase upsert
  5. 成功后再把保存结果映射回 CanvasArtifact

也就是说,“保存”和“分享”不是两套系统,它们共享同一套保存逻辑,只是分享会额外复制 artifact/{id} 链接。

7. 分享页与重试页补齐了工作区的独立访问入口

保存成功后,app/artifact/[id]/page.tsx 会独立拉取 artifact 并渲染 ArtifactView

如果分享页刚打开时数据库记录还没取到,ArtifactNotFound.tsx 会自动轮询 app/api/artifacts/[id]/route.ts 最多 5 次。这让“刚保存就立刻打开分享页”的体验更平滑。

七、关键代码解析

关键代码 1:app/hooks/useCanvasArtifacts.ts 为什么要保留现有状态

nextArtifacts.forEach((artifact) => {
  if (!nextMap.has(artifact.messageId)) {
    nextMap.set(artifact.messageId, new Map());
  }
  const existingArtifact = this.getArtifact(artifact.messageId, artifact.id);
  nextMap.get(artifact.messageId)!.set(artifact.id, existingArtifact ? { ...artifact, ...existingArtifact } : artifact);
});

代码解析:

  1. 这段代码解决的是“消息重新解析后,工作区状态不能丢”。
  2. 新解析结果负责提供最新的正文协议字段,现有 artifact 负责保留已经编辑过的代码、版本号和持久化标记。
  3. 如果没有这段合并逻辑,用户切换线程、刷新消息或重新加载页面时,工作区会不断回退到初始版本。

关键代码 2:app/components/canvas/CanvasPanel.tsx 怎样分离草稿与正式版本

const [draftCode, setDraftCode] = useState('');
const originalCode = useMemo(() => artifact?.code.content ?? '', [artifact]);

useEffect(() => {
  setDraftCode(originalCode);
  setTab(canvasStore.getInitialTab());
  canvasStore.resetInitialTab();
}, [originalCode, artifact?.id]);

代码解析:

  1. originalCode 代表当前 artifact 的正式版本,draftCode 代表本地可编辑副本。
  2. 每次切换 artifact 时,都先把正式版本复制成草稿,再开始编辑,这样不同 artifact 的草稿不会互相串线。
  3. 如果没有这层分离,重置、版本号、自定义初始标签页这些交互都会变得非常混乱。

关键代码 3:app/components/canvas/CanvasPanel.tsx 的保存链路怎样回写 store

const response = await fetch('/api/artifacts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: currentArtifact.id,
    messageId: currentArtifact.messageId,
    sessionId: currentArtifact.sessionId,
    title: currentArtifact.title,
    type: currentArtifact.type,
    codeContent: draftCode,
    codeLanguage: currentArtifact.code.language,
    currentVersion: hasChanges ? currentArtifact.currentVersion + 1 : currentArtifact.currentVersion,
  }),
});

canvasStore.markArtifactPersisted(currentArtifact.id, savedArtifact);

代码解析:

  1. 发给后端的不是旧的 artifact 内容,而是当前 draftCode,这保证了“未应用到 store 的草稿”也可以直接保存。
  2. 保存成功后,markArtifactPersisted(...) 会把 store 内的同一条 artifact 标记成已持久化,并更新最新版本。
  3. 如果没有这一步,数据库里虽然已经保存成功,但右侧工作区和消息区仍然会停留在旧状态,体验会断层。

八、常见问题

1. 为什么这一课几乎不改 canvasArtifact 协议,却还是一次大升级?

因为第 14 课已经把协议边界建好了,第 15 课要做的是围绕这份稳定协议,继续升级渲染层、状态层和持久化层。

2. 为什么预览已经能看到 draftCode,还要保留“应用到预览”按钮?

按钮的真实作用不是“让 iframe 看见草稿”,而是把草稿写回 store 的正式 artifact,并推进版本号。这样消息区、分享页和保存链路才能对齐到同一份正式内容。

3. 为什么保存 artifact 不直接回写 assistant 原消息正文?

因为消息正文承担的是聊天历史,artifact 承担的是工作区资产。把它们分开以后,后续编辑、恢复和分享都会更稳定。

4. 为什么分享页还需要 ArtifactNotFound.tsx 自动重试?

因为“刚保存就立刻打开分享页”是一个真实场景。自动重试可以减少首次打开时短暂查不到数据的体验抖动。

九、练习题

  1. CanvasTitleCard 支持“直接进入编辑器”模式,并思考需要改动哪些 store 接口。
  2. api/artifacts 增加删除能力,同时补一条工作区里的“删除 artifact”按钮。
  3. 让分享页展示 currentVersionupdatedAt,练习如何复用现有的 CanvasArtifact 数据结构。

十、总结

第 15 课真正做成的事情,是在不推翻协议层的前提下,把右侧 Canvas 升级成真正的工作区。

你现在已经有了四个新能力:

  1. 把 JSX 从只读结果升级成带草稿状态的可编辑内容
  2. 把工作区状态和消息重算结果稳定合并
  3. 把 artifact 保存进数据库,并按会话恢复
  4. 通过独立分享页把单个 artifact 暴露为可访问资产

下一课不会继续扩展协议或工作区能力,而是把这些已经完成的产品功能带入部署语境,回答“本地能跑”和“可上线交付”之间到底差了什么。

登录以继续阅读

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

立即登录

On this page