ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

生图还在“盲盒抽卡”?起底 BigBanana Canvas:如何用 Next.js + SSE + MCP 协议,让 AI 在无限画布上“指点江山”!

生图还在“盲盒抽卡”?起底 BigBanana Canvas:如何用 Next.js + SSE + MCP 协议,让 AI 在无限画布上“指点江山”! 1. 从“盲盒抽卡”到无限画布生图工作流到底卡在哪如果你用过主流 AI 聊天框生图大概率经历过这种抓狂输入一段精心雕琢的提示词回车出来一张脸好看但背景、姿势、画风全错的图。你追问“把背景改成森林姿势换成奔跑”AI 爽快答应然后重新生成了一张脸完全不同、画风突变的新图。你要的是刚才那张图的脸只改背景AI 却告诉你每次生成都是随机的。问题不在于模型能力而在于交互形态。聊天框是线性的、单维度的、没有空间感的。你无法直观对比多张图无法把 A 图的局部和 B 图的提示词拼接更无法把创作过程像思维导图一样发散和沉淀。生图变成了盲盒抽卡每次敲回车都是一次命运博弈。BigBanana Canvas 这类无限画布工作台想解决的就是这件事把创作过程从“盖楼贴条”变成“空间推演”。而支撑这套体验的技术栈恰好是一组非常适合前端工程师上手的组合——Next.js 承载渲染、SSE 推送生成进度、MCP 协议打通模型与工具调用、Zustand 管理画布状态、WebDAV 做素材同步。这篇文章不聊虚的直接给可复制的 Next.js 路由与 SSE 事件流配置、MCP 工具注册示例以及画布状态与生成结果联动的验证步骤。目标很明确让你在本地跑通一条从指令到落图的完整链路。适合有 Next.js 基础、想搞懂 AI 生图工作流底层怎么搭的开发者也适合正在做类似画布产品的同学对照排障。核心检索词先摆出来无限画布生图、Next.js SSE 流式推送、MCP 工具注册、Zustand 画布状态管理、WebDAV 素材同步。下面按“问题场景 → 前置准备 → 可复制配置 → 验证请求 → 常见错排查 → 工具入口”的顺序展开每一步都能跟着做。2. 前置准备TaoToken 接入与本地环境搭建在动手写画布之前先把模型调用这条链路打通。无限画布的生图节点最终要落到一个 OpenAI 兼容接口上TaoToken 提供的就是这个统一入口。它的作用是让你用一套 Base URL 和 Key就能在画布节点里切换不同模型不用为每个模型单独改代码。先明确三个必须写全的要素后面所有配置都围绕它们Base URLhttps://taotoken.net/apiAPI Key在控制台创建形如sk-xxxxModel ID比如gpt-4o、claude-sonnet-4-20250514等按你实际要调的模型填本地环境需要 Node.js 18推荐 20 LTS。初始化项目用 Next.js App Routernpx create-next-applatest bigbanana-canvas --typescript --app --tailwind cd bigbanana-canvas npm install zustand localforage eventsource-parsereventsource-parser用来在服务端或客户端解析 SSE 流比手写字符串切割稳得多。localforage负责把画布 JSON 和图片 Blob 分开存到 IndexedDBzustand管画布状态。环境变量放.env.localTAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-你的key TAOTOKEN_MODELgpt-4o注意TAOTOKEN_API_KEY只在服务端路由里读取不要加NEXT_PUBLIC_前缀否则会打进客户端 bundle。画布前端永远不直接持有 Key所有模型请求都走 Next.js 的 Route Handler 代理。如果你用的是 Claude Code 或 Codex 这类终端 Agent 来驱动画布还需要在本地 Agent 侧配置 MCP。以 Claude Code 为例它的配置文件通常在~/.claude/settings.json或项目级.mcp.json里面要写全三件套{ mcpServers: { bigbanana-canvas: { command: node, args: [./canvas-agent/index.js], env: { TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_API_KEY: sk-你的key, TAOTOKEN_MODEL: gpt-4o } } } }这段配置的意思是Claude Code 启动时会把canvas-agent作为一个 MCP Server 拉起来通过 stdio 通信。Agent 内部再通过 SSE 把指令推给浏览器画布。Base URL、Key、Model ID 三件套在这里必须齐全缺一个都会在调用时报 401 或 model not found。Cline 的 MCP 配置类似在cline_mcp_settings.json里加同样的 server 块即可。Codex 则走auth.json把 Key 写进去Base URL 在config.toml里指定。不管哪个终端核心都是让 Agent 能拿到模型凭证同时把画布操作注册成标准 MCP Tools。环境搭好后先跑一个最小验证用 curl 直接打 TaoToken 的接口确认 Key 和网络没问题。curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: gpt-4o, messages: [{role: user, content: 回复 ok}], stream: false }返回里有choices[0].message.content就说明链路通了。这一步别跳过后面画布报错时你能快速判断是模型侧还是画布侧的问题。3. 可复制配置Next.js 路由 SSE 事件流 MCP 工具注册这一节是全文技术密度最高的部分三块配置都能直接抄。3.1 Next.js Route Handler 代理模型请求在app/api/generate/route.ts里写一个流式代理。它接收画布传来的 prompt 和参数转发给 TaoToken再把 SSE 流原样透传给前端。// app/api/generate/route.ts import { NextRequest } from next/server; export const runtime nodejs; export const dynamic force-dynamic; export async function POST(req: NextRequest) { const { prompt, model, nodeId } await req.json(); const upstream await fetch(${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions, { method: POST, headers: { Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, Content-Type: application/json, }, body: JSON.stringify({ model: model || process.env.TAOTOKEN_MODEL, messages: [{ role: user, content: prompt }], stream: true, }), }); if (!upstream.ok || !upstream.body) { return new Response( JSON.stringify({ error: upstream ${upstream.status} }), { status: 502, headers: { Content-Type: application/json } } ); } const encoder new TextEncoder(); const decoder new TextDecoder(); const stream new ReadableStream({ async start(controller) { const reader upstream.body!.getReader(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop() || ; for (const line of lines) { if (!line.startsWith(data:)) continue; const payload line.slice(5).trim(); if (payload [DONE]) { controller.enqueue(encoder.encode(event: done\ndata: ${JSON.stringify({ nodeId })}\n\n)); continue; } try { const json JSON.parse(payload); const delta json.choices?.[0]?.delta?.content || ; if (delta) { controller.enqueue( encoder.encode(event: delta\ndata: ${JSON.stringify({ nodeId, delta })}\n\n) ); } } catch { // 忽略非 JSON 心跳行 } } } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }关键点runtime nodejs不能用 edge因为要读环境变量里的 Keydynamic force-dynamic防止 Next.js 缓存流式响应事件名用delta和done前端按事件名分发比只靠 data 字段更清晰。3.2 前端 SSE 消费与 Zustand 状态联动画布节点发起生成后前端用fetchReadableStream消费 SSE不用EventSource因为要 POST。收到delta就更新对应节点的流式文本收到done就把节点状态从generating改成done。// lib/useGenerate.ts import { create } from zustand; type NodeStatus idle | generating | done | error; interface CanvasState { nodes: Recordstring, { id: string; status: NodeStatus; text: string }; setStatus: (id: string, status: NodeStatus) void; appendDelta: (id: string, delta: string) void; } export const useCanvasStore createCanvasState((set) ({ nodes: {}, setStatus: (id, status) set((s) ({ nodes: { ...s.nodes, [id]: { ...s.nodes[id], id, status, text: s.nodes[id]?.text || } } })), appendDelta: (id, delta) set((s) ({ nodes: { ...s.nodes, [id]: { ...s.nodes[id], id, text: (s.nodes[id]?.text || ) delta } }, })), })); export async function runGenerate(nodeId: string, prompt: string) { const { setStatus, appendDelta } useCanvasStore.getState(); setStatus(nodeId, generating); const res await fetch(/api/generate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ nodeId, prompt }), }); if (!res.body) { setStatus(nodeId, error); return; } const reader res.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const chunks buffer.split(\n\n); buffer chunks.pop() || ; for (const chunk of chunks) { const eventMatch chunk.match(/^event: (.)$/m); const dataMatch chunk.match(/^data: (.)$/m); if (!eventMatch || !dataMatch) continue; const event eventMatch[1]; const data JSON.parse(dataMatch[1]); if (event delta) appendDelta(nodeId, data.delta); if (event done) setStatus(nodeId, done); } } }Zustand 这里只存轻量状态图片 Blob 不进来。生成完成后图片走单独的image_fileslocalforage 实例存节点里只留storageKey。这就是“数据与媒体分离”避免画布 JSON 被 Base64 撑爆。3.3 MCP 工具注册示例本地 Agent 要把画布操作暴露成 MCP Tools让 Claude Code 这类终端能调用。用modelcontextprotocol/sdk注册两个最核心的工具读画布状态、应用画布操作。// canvas-agent/index.js import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { CallToolRequestSchema, ListToolsRequestSchema } from modelcontextprotocol/sdk/types.js; const server new Server( { name: bigbanana-canvas, version: 1.0.0 }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () ({ tools: [ { name: canvas_get_state, description: 读取当前画布节点与连线状态, inputSchema: { type: object, properties: {} }, }, { name: canvas_apply_ops, description: 在画布上应用一组操作如新建节点、连线、触发生成, inputSchema: { type: object, properties: { ops: { type: array, items: { type: object, properties: { type: { type: string, enum: [add_node, connect, generate] }, nodeId: { type: string }, payload: { type: object }, }, required: [type], }, }, }, required: [ops], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (req) { const { name, arguments: args } req.params; if (name canvas_get_state) { return { content: [{ type: text, text: JSON.stringify(await getCanvasSnapshot()) }] }; } if (name canvas_apply_ops) { const result await pushOpsToBrowser(args.ops); return { content: [{ type: text, text: JSON.stringify(result) }] }; } throw new Error(unknown tool: ${name}); }); await server.connect(new StdioServerTransport());pushOpsToBrowser内部就是通过 SSE 把 ops 推给浏览器然后挂起一个 Promise 等浏览器 POST 回结果。浏览器侧收到instruction事件后在画布上执行操作再fetch(/canvas/result, { method: POST, body: JSON.stringify({ requestId, result }) })唤醒 Agent。这条链路打通后终端 AI 就能“指点江山”了。4. 验证请求从指令到落图的完整链路跑通配置写完怎么确认真的通了按下面四步验证每步都有明确的成功标志。第一步验证模型代理。启动npm run dev用 curl 打本地路由curl -N http://localhost:3000/api/generate \ -H Content-Type: application/json \ -d {nodeId:n1,prompt:画一只戴墨镜的猫}成功标志终端里逐行滚出event: delta和data: {nodeId:n1,delta:...}最后出现event: done。如果只返回一个 JSON 错误说明上游 Key 或 Base URL 有问题回到第 2 节的 curl 排查。第二步验证前端状态联动。打开浏览器http://localhost:3000在画布上新建一个文本节点触发runGenerate。打开 DevTools 的 Network 面板找到/api/generate请求看 Response 是否以text/event-stream流式返回。同时看 Zustand DevTools装个 Redux DevTools 扩展即可节点状态应该从generating变到donetext字段逐步累加。第三步验证 MCP 工具注册。在终端里启动 Claude Code输入/mcp查看已连接的 server应该能看到bigbanana-canvas。然后让它调用请调用 canvas_get_state 读取当前画布成功标志终端返回一段 JSON包含你刚才在浏览器里创建的节点。如果报tool not found检查.mcp.json里的command路径和args是否正确Node 版本是否 18。第四步验证完整闭环。在 Claude Code 里输入在画布上新建一个文本节点内容为“赛博朋克城市”然后触发生成成功标志浏览器画布上自动出现一个新节点状态先变generating随后流式填充文本最后变done。终端侧收到canvas_apply_ops的返回结果。到这一步从指令到落图的链路就完整跑通了。如果你还配了 WebDAV可以再验证一次同步在画布上删掉一个图片节点触发cleanupUnusedImages然后看 IndexedDB 的image_files库里对应的storageKey是否被清掉。控制台会打印[GC] 成功清理无引用媒体文件: image:xxx。这一步确认了本地优先存储不会无限膨胀。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth跑链路时最容易撞的几个报错逐个对照。401 Unauthorized。最常见九成是 Key 没写对或没带上。检查三处.env.local里的TAOTOKEN_API_KEY是否以sk-开头Route Handler 里Authorization头是否是Bearer ${process.env.TAOTOKEN_API_KEY}注意 Bearer 后面有空格MCP 配置的env块里是否也带了 Key。如果 Key 放在客户端组件里读会因为NEXT_PUBLIC_缺失而拿到undefined表现也是 401。记住Key 只在服务端和本地 Agent 里出现。local proxy failed。这个报错通常出现在终端 Agent 侧意思是 Agent 尝试连本地画布服务失败。检查canvas-agent是否真的启动了端口是否被占用。如果你在.mcp.json里写的command是node但系统 PATH 里 Node 版本不对也会报这个。用which node确认路径必要时写绝对路径。另外SSE 连接如果被浏览器插件或公司网络拦截也会表现为 proxy failed换个干净浏览器 profile 试试。reading choices。典型的前端解析错误报错形如Cannot read properties of undefined (reading choices)。原因是上游返回的不是标准 OpenAI 格式可能是错误对象但前端直接按json.choices[0]取了。修复方式是在解析前加判断if (!json.choices || !Array.isArray(json.choices)) { console.warn(unexpected payload, json); continue; }同时检查 Model ID 是否拼错。如果 Model ID 不存在上游会返回{error: {...}}没有choices字段前端就会炸。把 Model ID 和 TaoToken 控制台里列出的保持一致。OAuth 相关报错。如果你用 Claude Code 且看到 OAuth token 过期或invalid_grant说明终端侧的登录态失效了。Claude Code 的 MCP 调用依赖它的账号态重新登录一次即可。注意这和 TaoToken 的 Key 是两套东西Claude Code 的 OAuth 管的是终端本身能不能跑TaoToken 的 Key 管的是模型请求能不能通。两者都正常链路才通。如果 OAuth 正常但模型报 401问题在 Key如果 OAuth 报错问题在终端登录。还有一个隐蔽的坑SSE 响应被 Next.js 的默认压缩中间件缓冲导致前端迟迟收不到delta。表现是请求发出后卡住最后一次性吐出全部内容。解决办法是在 Route Handler 的响应头里加Cache-Control: no-cache, no-transformno-transform就是禁止中间层改写响应体。这个细节在第 3.1 节的代码里已经带了照抄即可。6. 工具入口与下一步把画布接进你的编码工作流链路跑通之后下一步就是把它接进日常编码。如果你主要做排障和接入调试先去把 API Key 建好再对照接入文档把 Base URL 和 Model ID 填进你的配置创建和管理 Keyhttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档含各语言示例https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite想先在网页里验证模型输出是否符合预期用模型对话页快速试 prompt不用写代码模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite如果你打算长期用 Claude Code 或 Codex 驱动画布做 Agent 开发Coding Plan 比按量付费更划算也省去每次配 Key 的麻烦Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewriteClaude Code 用户可以直接看 Anthropic 接入指引里面有 MCP 配置的完整说明Claude Code Anthropic 接入https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite控制台里可以随时查看用量和余额方便你估算画布批量生图的成本控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite最后给一个实操建议先把第 3 节的三个配置片段原样跑通再逐步替换成你自己的模型和 UI。画布状态管理这块Zustand 的nodes结构建议一开始就设计成扁平的Recordstring, Node不要用嵌套数组否则后面做增量更新和 GC 引用计数会很痛苦。SSE 的事件名也提前定好规范delta、done、error三个就够别中途加不然前端分发逻辑会越来越乱。MCP 工具的参数 schema 尽量宽松用object而不是写死字段这样画布节点类型扩展时不用频繁改 Agent 侧代码。
返回列表