3 分钟
给博客挂上第一个 AI 小工具
上个月写从零手写博客的时候留了个伏笔:内容站的尽头是"内容 → 工具 → 产品"。这个伏笔今天兑现了——本站第一个小工具 AI Chat 游乐场上线,这篇文章复盘它的设计决策和踩坑。
为什么第一个工具是 Chat
选型时想过文档问答、文案助手,最后选了最"朴素"的对话:它是验证整条链路(页面 → 大模型 API → 流式渲染)最短路径的工具。后面所有工具——无论文案、问答还是智能体——底层都是这一条链路,先把水管铺通,以后接什么都方便。
关键决策:BYOK(Bring Your Own Key)
第一个要回答的问题:API Key 从哪来?
方案 A 是我在服务端放一个 Key 大家共用——但本站是纯静态站,没有服务端;要加就得引入后端和计费,违背"从零手写、无数据库"的初衷,而且等于提前做了一遍 API 中转生意(那是路线图的后续项)。
所以选了方案 B:BYOK——访客填自己的 OpenAI 兼容端点 + Key,浏览器直连。好处很直接:
- 零服务器成本,纯静态托管扛得住任意流量;
- Key 只存访客自己的 localStorage,不经过任何第三方(包括我);
- 合规简单:没有代充值、没有账号体系。
代价是访问门槛:用户得有一个 API Key。对目标读者(折腾 AI 的开发者)来说,这门槛约等于零。
技术点一:流式输出
对话体验的生命线是流式。OpenAI 兼容接口的流式响应是 SSE 格式,浏览器端用 fetch + ReadableStream 手动解析:
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || ""; // 半行留到下一轮
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") continue;
const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
if (delta) appendToLastMessage(delta);
}
}
三个细节:buf.pop() 处理跨 chunk 的半行;[DONE] 是结束标记;解析失败的行直接跳过(有的网关会夹带注释行)。另外做了降级:如果响应不是 text/event-stream,按普通 JSON 处理,兼容不支持流式的端点。
技术点二:混合内容的坑
上线前自测差点翻车:HTTPS 页面调用 HTTP 接口会被浏览器直接拦截(混合内容),报错还长得像 CORS,容易误诊。
规则记一下:
https://页面 →http://接口:拦截;- 例外只有
localhost/127.0.0.1(浏览器视为安全上下文)。
所以我在本机填 http://localhost:20128/v1(自建网关)一切正常,局域网其他电脑填 http://10.1.74.64:20128/v1 就会被拦。想让局域网机器也能连,得给网关套一层 HTTPS(Caddy 两行配置的事,之后单开一篇)。对公网访客则完全无感——主流模型服务商都是 HTTPS 端点。
技术点三:被"聪明"坑到的 details 组件
配置面板我用了原生 <details>,第一版偷懒写了 open={!ready}(没配好就展开)——结果填完 Key 的瞬间面板"啪"地自动收起,其他字段还没填完。受控组件会忠实执行你给的每个状态,哪怕这个状态并不符合用户意图。改成用户手动控制展开收起,问题消失。
教训:UI 的"自动化体贴"要克制,用户操作到一半的界面不要自作主张。
效果
- 页面:itzhouq.cn/tools/chat,支持流式输出、多轮对话、系统提示词、Ctrl+Enter 发送、停止生成;
- 配置持久化在 localStorage,第二次打开即用;
- 全部代码在本仓库
app/tools/chat/,共一个页面文件,没有新增任何依赖。
下一步
路线图更新:工具矩阵的管子通了。接下来是变现主线——API 中转商店(new-api 底座 + 自建网关做上游),到时候继续公开数据。