像素画工具换调色板、改通信、加画廊
给像素画工具换了 NES 16 色调色板,通信从 SSE 改成 WebSocket 流式推送,加了作品画廊。三次改造解决的是同一个问题:让模型专注于画画本身。
上次写了看 AI 画像素画,工具上线后自己用了一阵子,陆续改了三处。
第一个是颜色。原来模型直接输出 hex 色值,比如 #FF0000 画红色、#008751 画绿色。理论上很灵活,实际上模型经常犯错。它可能输出 #FF00O0(把 0 写成 O),或者挑一个肉眼几乎看不出差别的邻近色。更常见的情况是,模型在同一个画面里用了十几种颜色,失去像素画应有的克制。
问题的根源在于自由度太高。像素画的魅力恰恰来自限制——颜色越少,轮廓越清晰,风格越统一。让模型从几百万千色中自由选择,不如给它一个固定的调色板。
我选了 NES 16 色调色板。从 NES 的 32 色中挑出 16 个代表色,覆盖黑、白、灰、红、橙、黄、绿、蓝、紫、粉。每种颜色有一个短编号:c1 到 c16。模型只需要说「画 c6」而不是「画 #F83800」。
这个改动同时在提示词和代码两层落地。提示词里放了一张调色板对照表,列出色号、颜色名、hex 值和典型用途。代码层加了一个映射函数,把 c1-c16 转换成实际的 hex 值。前端完全不变,收到的仍然是 hex。
效果立竿见影。模型的输出干净了很多,颜色数量稳定在 4-8 种,不再出现「一种红色画出五个微妙变体」的情况。色号比 hex 好记,模型犯格式错误的概率也降低了。
第二个改造是通信方式。原来用 SSE(Server-Sent Events),前端通过 fetch 发起 POST 请求,服务端用 ReadableStream 逐行推送事件。SSE 的问题是单向的——客户端只能听,不能说。如果用户想中途停止,前端只能中断 fetch,服务端那边的 LLM 调用还在继续跑,资源白白浪费。
改成 WebSocket 后,客户端和服务端之间是一条双向通道。前端发一条 {type: "start", image: "..."} 消息启动绘制,服务端逐笔推送每个工具调用的结果。用户点停止时,前端直接 ws.close(),服务端立刻感知到连接断开,终止后续的 LLM 调用。
SSE 还有一个隐性问题:心跳。Caddy 反代对空闲连接有超时机制,如果 LLM 调用耗时较长(比如 10 秒以上),中间没有数据流动,Caddy 会断开连接。原来用 SSE 时,服务端每隔 3 秒发一个注释行做心跳保活。WebSocket 天然有 ping/pong 机制,不需要额外处理。
Bun 的 WebSocket 实现基于 uWebSockets,性能比 Node.js 的 ws 库高 7 倍左右。对于这个工具来说性能不是瓶颈(一次只有一个用户在画),但 API 设计更简洁。server.upgrade(req) 一行完成协议升级,ws.send() 发消息,ws.close() 关连接。
前端的改动也不大。原来是一段 SSE 解析逻辑,要按空行分割事件帧、过滤心跳行、提取 data 字段。改成 WebSocket 后,ws.onmessage 直接拿到完整的 JSON 对象,解析一次就行。
通信层改完之后,又做了一层优化:流式推送。原来 LLM 调用用的是 stream: false,服务端等模型生成完全部内容,才开始处理 tool call。改成 stream: true 后,服务端逐 token 接收 LLM 的 SSE delta 流,按 index 收集 tool_calls 碎片,拼出完整的 tool call 后立即执行并推送到前端。首帧延迟从「LLM 总耗时」降到「第一个 tool call 完成时」。
最后加了作品画廊。每次绘制结束后,前端把参考图、最终画布和完整操作序列(每一步的 paint / fill_rect)保存到服务端。画廊页面下方展示所有作品,点击可以回放绘制过程。操作序列的存在让「过程」本身成为作品的一部分,不只是结果截图。
三次改造指向同一个方向:减少模型需要做的无关决策。固定调色板让模型不用纠结选色,WebSocket 流式推送让前端不用纠结协议和延迟,画廊让作品过程可追溯。好的工具应该让使用者专注于创作本身,而不是跟工具搏斗。
工具地址:AI 像素画
相关文章
- 看 AI 画像素画 — 像素画工具的初版实现
- Harness Engineering 为什么AI犯错时该追问系统 — 系统层面思考 AI 犯错
- 用 Bun WebView 给博客搭了一个 Mermaid 渲染引擎 — 另一个 Bun 实战项目