第六阶段 · 高级功能

15 · 自定义渲染与交互

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

一、学习目标

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

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

  • 解释为什么这一课几乎不改 canvasArtifact 协议,却依然是一次明显的能力升级
  • 看懂 CanvasPanel 里为什么要同时维护 originalCodedraftCode
  • 理解 CodePreviewPanel 为什么要从工作区 UI 中单独拆出来
  • 明白 canvasStore.updateArtifactCode(...)canvasVersionmergeArtifacts(...) 分别解决什么问题
  • 说清 /api/artifactsartifact.service.tsdatabase/artifacts.ts 怎样把工作区结果保存到 Supabase
  • 知道分享页 app/artifact/[id]/page.tsx 和可调宽度面板 ResizablePanel 是如何补齐产品体验的

二、问题背景

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

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

但这还远远算不上“工作区”。

只要你真的试着使用它,就会立刻碰到几个卡点:

  1. 组件只要想改一个按钮文案,就得把代码复制出去再改。
  2. 刷新页面以后,右侧工作区里的结果不会单独留下来。
  3. 你没法把某个比较满意的 artifact 分享给别人单独查看。
  4. 画布宽度固定,预览和编辑没有清晰边界。

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

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

三、核心概念

1. 协议层稳定后,渲染层可以单独演进

第 14 课建立的协议核心并没有变:

  1. assistant 仍然输出 <canvasArtifact>
  2. 前端仍然调用 parseCanvasArtifactsFromContent(...)
  3. MarkdownRenderer 仍然把自定义标签映射成 CanvasTitleCard

也就是说,这一课没有重新定义“模型怎么输出”,而是重新定义“前端怎么消费这个结果”。

这正是你在工程里经常会遇到的情况:

  1. 协议一旦稳定,最好不要每次都重做
  2. 体验升级应该尽量集中在消费层
  3. 这样后续功能迭代才不会把上下游接口一起拖乱

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

CanvasPanel.tsx 这节课最关键的变化,就是把代码状态拆成了两份:

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

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

这一步很重要,因为它让你可以:

  1. 先试着改
  2. 再点“应用到预览”
  3. 再决定要不要“保存”或“分享”
  4. 也可以随时“重置”回当前 artifact 的正式版本

如果没有这层分离,你每敲一个字符都会直接污染 store,后面就很难支持重置、比较修改、保存成功提示这些交互。

3. store 现在不只存“打开哪个 artifact”,还要管理版本和恢复

相对第 14 课,这一课的 CanvasArtifact 多了三类信息:

  1. sessionId
  2. currentVersion
  3. isPersisted

同时 canvasStore 也多了几类行为:

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

这说明 store 不再只是“最小打开 / 关闭状态”,而是开始承担工作区生命周期管理。

4. 保存和分享把 artifact 从“页面内状态”升级成“可复用资产”

本课新增的 /api/artifactsapp/artifact/[id]/page.tsx,把 artifact 的定位往前推了一步:

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

不过也要注意本课边界:

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

5. 这一课的增量集中在工作区,Agent 主链路刻意没变

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

维度第 14 课第 15 课
Canvas 面板只读预览 / 代码查看草稿编辑、保存、分享、下载、复制
preview 实现逻辑写在 CanvasPanel.tsx抽离成 CodePreviewPanel.tsx
store只管可见性和激活项新增更新、合并、持久化标记、初始标签页
artifact 数据只存在消息派生状态里可落库、可按会话恢复、可分享
页面布局固定宽度侧栏ResizablePanel 支持宽度记忆

同时有几条链路刻意保持不变:

  1. app/agent/chatbot.ts 继续沿用第 14 课的 Canvas 提示策略。
  2. app/canvas/canvas-prompt.tsapp/canvas/parse-canvas-artifacts.ts 仍然围绕同一份 canvasArtifact 协议工作。
  3. 聊天 SSE 主链路没有被新的工作区交互打断。

四、关键文件

app/components/canvas/CanvasPanel.tsx

这是本课的核心。它把右侧画布从只读查看器升级成真正的工作区,承担草稿编辑、保存、分享、复制、下载和重置行为。

app/components/canvas/CodePreviewPanel.tsx

这里把 JSX 预览运行时单独抽了出来。这样 CanvasPanel 负责交互,CodePreviewPanel 负责 iframe 渲染,职责会更清楚。

app/hooks/useCanvasArtifacts.ts

store 在这一课有了实质升级:除了打开 / 关闭,还要管合并服务端 artifact、更新代码、标记已保存、记录初始标签页。

app/page.tsx

它新增了 canvasVersioncanvasWidth、会话切换后加载 artifact、ResizablePanel 包装等逻辑,是工作区状态和页面状态同步的总控点。

app/api/artifacts/route.ts

这一课新增的持久化入口。GET 用于按 session_id 读取已保存 artifact,POST 用于保存当前工作区结果。

app/services/artifact.service.ts

它把 API 层和数据库读写隔开,把 ArtifactRow 转回前端真正要用的 CanvasArtifact 结构。

app/database/artifacts.ts

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

app/artifact/[id]/page.tsx

这是分享链路的入口。保存成功后,某个 artifact 可以在独立页面里直接打开,不必再进入原聊天会话。

五、整体流程

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

六、运行过程

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

parseCanvasArtifactsFromContent(...) 现在接受第三个参数 sessionId,并给每个新解析出的 artifact 赋上:

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

这样 artifact 从一开始就不仅仅是“从正文里抠出来的代码块”,而是一个可以进入保存流程的工作区对象。

2. page.tsx 新增了工作区同步层

这节课的页面状态明显比上一课更复杂,主要多了三件事:

  1. canvasVersion 当 store 内部 artifact 被直接修改时,用它触发重新取 activeArtifact

  2. canvasWidth 通过 localStorage 记住右侧面板宽度。

  3. loadArtifacts()threadId 变化时,主动请求 /api/artifacts?session_id=...,把服务端保存过的结果合并回来。

这一步非常重要,因为从本课开始,artifact 不能只依赖消息正文本身,还得考虑“用户后续编辑后保存下来的版本”。

3. CanvasPanel 先复制草稿,再决定是否提交

打开某个 artifact 时,CanvasPanel 会:

  1. 读取 artifact.code.content
  2. 复制到 draftCode
  3. 根据 store 里的 initialTab 决定先打开预览还是编辑器

这样做的好处是:

  1. 你可以放心试改
  2. 预览更新和正式保存可以分开
  3. 切换不同 artifact 时,不会把上一个工作区的草稿串到下一个里

4. 预览运行时被单独抽到 CodePreviewPanel.tsx

这一课把 iframe 运行时从 CanvasPanel.tsx 里拆了出来。现在:

  1. CanvasPanel.tsx 主要负责交互和工具栏
  2. CodePreviewPanel.tsx 负责清理 import、拼接 HTML、跑 Babel 和渲染 React 组件

这一步的价值不只是“文件更整洁”,而是为后续继续升级渲染器留出清晰边界。

5. “应用到预览”本质上是在更新 store 中的正式 artifact

这节课有个很容易误解的按钮叫“应用到预览”。

它并不是单纯让 iframe 刷新,因为 iframe 本来就会根据 draftCode 实时重算。这个按钮真正做的是:

  1. 调用 canvasStore.updateArtifactCode(...)
  2. 更新 store 内部当前 artifact 的正式内容
  3. 让页面通过 canvasVersion 重新拿到最新 activeArtifact

所以它更准确的作用是:把本地草稿提交回当前工作区状态。

6. 保存和分享使用同一条 API 主链路

当你点击“保存”或“分享”时,CanvasPanel.tsx 都会走 persistArtifact()

  1. POST /api/artifacts
  2. API 层通过 artifactService.saveArtifact(...) 组织数据
  3. database/artifacts.ts 调 Supabase upsert
  4. 成功后把返回的记录转回 CanvasArtifact
  5. canvasStore.markArtifactPersisted(...) 更新前端状态

“分享”和“保存”的区别只是:

  1. 保存到此为止
  2. 分享会再拼出 /artifact/{id} 链接并复制到剪贴板

7. 独立分享页会直接渲染 artifact

app/artifact/[id]/page.tsx 会按 id 读取 artifact,然后交给 ArtifactView.tsx

这条链路解决的是:

  1. 不进入聊天页也能单独看一个 artifact
  2. 分享对象不需要先知道原始 session
  3. 即使记录还在保存中,ArtifactNotFound.tsx 也会自动重试几次

这就是本课相比上一课最明显的产品化升级之一。

8. 本课依然保留了明确的简化边界

虽然工作区已经明显更完整了,但它仍然是教学版实现:

  1. 编辑器使用 textarea,不是 Monaco
  2. 预览是轻量 iframe + Babel,不是完整沙箱
  3. 保存的是 artifact 记录,不会反向改写 assistant 原消息里的 XML
  4. 右侧面板的复杂编排仍然没有扩展到完整设计器级别

这也意味着你在学这一课时,重点应该放在“状态边界”和“链路衔接”,而不是把它误以为是一套完整低代码平台。

七、关键代码解析

关键代码 1:app/components/canvas/CanvasPanel.tsx 如何管理草稿

const [tab, setTab] = useState<'preview' | 'editor'>('preview');
const [draftCode, setDraftCode] = useState('');
const originalCode = useMemo(() => artifact?.code.content ?? '', [artifact]);

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

代码解析:

  1. 这段代码确立了本课工作区的核心交互模型:先复制一份草稿,再围绕草稿做预览、保存和重置。
  2. originalCodedraftCode 分开之后,才有可能支持“已修改 / 原始版本”这种状态判断,也才能安全切换不同 artifact。
  3. getInitialTab() 让 store 可以控制“从标题卡打开时,默认先进预览还是编辑器”,说明工作区状态已经不再只是一个布尔值。

关键代码 2:app/hooks/useCanvasArtifacts.ts 如何把 store 升级成工作区状态中心

updateArtifactCode(artifactId: string, content: string) {
  for (const [, messageArtifacts] of this.artifacts) {
    const artifact = messageArtifacts.get(artifactId);
    if (artifact) {
      messageArtifacts.set(artifactId, {
        ...artifact,
        code: {
          ...artifact.code,
          content,
        },
        currentVersion: artifact.currentVersion + 1,
        updatedAt: new Date(),
      });
      this.emit();
      return;
    }
  }
}

代码解析:

  1. 这里把 store 从“最小展示状态”升级成了“真正的工作区状态中心”。它已经开始负责版本递增和正式内容更新。
  2. 这一层更新不依赖消息正文重新解析,所以第 15 课才需要在 page.tsx 里增加 canvasVersion 来强制重新取值。
  3. 如果没有这类方法,所有编辑逻辑都只能塞回组件局部状态,保存成功后页面也无法得到统一后的 artifact 版本。

关键代码 3:app/api/artifacts/route.ts 如何把工作区结果保存到服务端

export async function POST(request: NextRequest) {
  const body = (await request.json()) as {
    id: string;
    messageId: string;
    sessionId: string | null;
    title: string;
    type?: string;
    codeContent: string;
    codeLanguage?: string;
    currentVersion?: number;
  };

  const userId = await getUserIdFromRequest(request);

  const artifact = await artifactService.saveArtifact({
    ...body,
    userId,
  });

  return NextResponse.json({ artifact });
}

代码解析:

  1. 这段代码把“右侧面板里的编辑结果”正式变成了一个可保存、可恢复、可分享的服务端对象。
  2. API 层自己不碰 Supabase 细节,而是把工作交给 artifactServicedatabase/artifacts.ts,这样层次更稳定。
  3. 如果没有这条持久化入口,工作区再丰富也只是一次性前端状态,刷新或换设备后就全没了。

八、常见问题

1. 为什么“应用到预览”和“保存”要分成两步?

因为它们解决的是不同问题:

  1. “应用到预览”更新当前页面里的正式 artifact 状态
  2. “保存”把这个状态写进数据库

这两步分开之后,你才能既拥有快速试改体验,又保留明确的持久化边界。

2. 为什么保存后还要有独立的 /artifact/{id} 页面?

因为聊天页和分享页面对的是两种场景:

  1. 聊天页强调上下文和持续生成
  2. 分享页强调直接打开某个结果

如果两者混在一起,链接分享和独立预览都会变得很别扭。

3. 为什么这节课还不用更重的编辑器和沙箱?

因为本课的重点不是做一个完整代码平台,而是把“草稿编辑 -> 预览 -> 保存 -> 分享”这条主链路先搭完整。轻量实现更利于你看清边界。

九、练习题

  1. CanvasTitleCard 增加“直接进编辑器”入口,然后通过 canvasStore.openArtifact(artifactId, 'editor') 打开工作区。
  2. CanvasPanel 里增加一个“未保存离开提醒”,让关闭面板时能提示当前草稿是否还没保存。
  3. 在分享页 ArtifactView.tsx 里加入一个简单标题栏,展示 artifact 标题、版本号和返回首页按钮。

十、总结

第 15 课最重要的不是再发明一种新协议,而是证明了同一份 canvasArtifact 可以被更完整的前端工作区消费。

你应该把这一课记成三条主线:

  1. 协议保持稳定,升级集中在消费层和工作区状态层。
  2. draftCode、store 更新、数据库持久化分别对应本地编辑、页面正式状态、跨会话存储三个层次。
  3. 右侧 Canvas 已经从“只读查看器”进化成“可编辑、可保存、可分享的教学版工作区”。

登录以继续阅读

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

立即登录

On this page