构建流式网页聊天(Express 代理 + 原生 JS)
在本教程中,你将用约 100 行代码构建一个小型聊天页面。一个小型 Express 服务器保存你的密钥并将请求中继到 Uncensored Chatbot API,而纯 JavaScript 前端逐字读取流式回复。无需框架,无需构建步骤,且你的密钥永远不会出现在浏览器中。
更新于
要点
- 切勿从浏览器代码调用 API;服务器路由可保持密钥私密并允许你验证输入。
- 代理只需传递服务器发送的事件;页面解析数据行直到 [DONE]。
- 流式输出时使用 textContent,并在渲染 Markdown 之前进行清理。
- 在服务器端限制历史记录长度和 max_tokens,以防单个用户耗尽你的余额。
我们要构建什么,以及为什么需要代理
想象一个带有文本框和滚动日志的单页。你输入一行,页面将对话发布到你自己服务器的 /api/chat 端点,你的服务器将请求转发到上游并附加真实密钥。回复流式返回,每个片段在到达时立即显示。我们将赋予机器人一点个性,一个名叫 Wren 的灯塔看守员,使演示更具活力。
你需要 Node 18 或更高版本(用于内置的 fetch)和一个 API 密钥。如果你还没有,请从 密钥页面获取免费试用额度:新账户可获得 7 天的 $0.50 额度,无需提供支付信息。Base URL 为 https://api.uncensoredchatbotapi.com/v1,模型 ID 为 uncensored。
为什么需要代理
将密钥粘贴到前端代码并直接调用 API 很有诱惑力。请不要这样做。任何发送到浏览器的内容都可以被任何打开开发者工具的人读取,而密钥泄露意味着余额被耗尽。代理可以解决此问题并提供三个额外好处。
- 保密性。 密钥存储在服务器的环境变量中。
- 控制。 你可以决定系统提示词、历史记录长度和
max_tokens,因此用户无法覆盖它们。 - 规则的位置。 每个用户的限制、年龄门控和日志策略都应放在这一层。安全指南 基于此路由构建。
步骤 1:设置项目
创建一个文件夹,将其初始化为 ES 模块项目并安装 Express。其余功能均为内置。
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir public你最终将得到两样东西:根目录下的 server.js 和包含页面及其脚本的 public 文件夹。
步骤 2:编写 Express 代理
服务器提供静态文件并暴露一个 POST 路由。分三部分阅读。首先,它清理传入的历史记录:只有 user 和 assistant 角色通过,每条消息截断为 4,000 个字符,且仅保留最近的 30 轮对话。其次,它添加你自己的系统消息,浏览器无法更改该消息。第三,它使用 stream: true 调用上游接口,并将字节直接管道传输回去。
import express from "express";
const app = express();
app.use(express.json({ limit: "256kb" }));
app.use(express.static("public"));
const UPSTREAM = "https://api.uncensoredchatbotapi.com/v1/chat/completions";
const SYSTEM = "You are Wren, a dry-witted night-shift lighthouse keeper. Stay in character.";
app.post("/api/chat", async (req, res) => {
const history = Array.isArray(req.body.messages) ? req.body.messages : [];
// Keep only well-formed turns and cap the history we forward.
const turns = history
.filter((m) => ["user", "assistant"].includes(m.role) && typeof m.content === "string")
.slice(-30)
.map((m) => ({ role: m.role, content: m.content.slice(0, 4000) }));
if (turns.length === 0) return res.status(400).json({ error: "no messages" });
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "uncensored",
messages: [{ role: "system", content: SYSTEM }, ...turns],
stream: true,
max_tokens: 500,
temperature: 0.8,
}),
});
if (!upstream.ok) {
const info = await upstream.json().catch(() => ({}));
return res.status(upstream.status).json(info);
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
for await (const chunk of upstream.body) res.write(chunk);
res.end();
});
app.listen(3000, () => console.log("http://localhost:3000"));有两个选择值得解释。传递原始字节意味着你根本不需要理解服务器上的事件格式。并且返回上游状态用于故障处理,使页面能够做出合理反应;例如,402 表示余额为空,429 表示有人速度过快。
步骤 3:页面和流读取器
现在处理前端。将其保存为 public/index.html;它故意保持简洁,以便你稍后重新设计样式。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lantern Chat</title>
<style>
body { font: 16px system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
#log { min-height: 320px; border: 1px solid #ccc; padding: 1rem; white-space: pre-wrap; }
.me { color: #234; font-weight: 600; }
form { display: flex; gap: .5rem; margin-top: 1rem; }
input { flex: 1; padding: .6rem; }
</style>
</head>
<body>
<h1>Lantern Chat</h1>
<div id="log"></div>
<form id="form">
<input id="text" autocomplete="off" placeholder="Say something...">
<button>Send</button>
</form>
<script src="chat.js"></script>
</body>
</html>接下来是 public/chat.js。有趣的部分是读取循环。网络块不尊重行边界,因此我们保留一个 buffer,按换行符分割,并保留最后一个不完整的行,直到更多数据到达。每个以 data: 开头的完整行都是 JSON,除了最终的 [DONE] 标记。
const log = document.getElementById("log");
const form = document.getElementById("form");
const input = document.getElementById("text");
const history = [];
function addLine(cls, text) {
const div = document.createElement("div");
div.className = cls;
div.textContent = text; // textContent, never innerHTML, for untrusted text
log.appendChild(div);
return div;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
history.push({ role: "user", content: text });
addLine("me", "You: " + text);
const bubble = addLine("bot", "");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
if (!res.ok) {
bubble.textContent = "(the chat is unavailable right now)";
history.pop();
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", reply = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep a partial line for the next read
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
const json = JSON.parse(data);
const piece = json.choices?.[0]?.delta?.content;
if (piece) {
reply += piece;
bubble.textContent = reply;
}
}
}
history.push({ role: "assistant", content: reply });
});注意 history 数组。API 在调用之间不保留记忆,因此页面每次重新发送整个对话,服务器对其进行修剪。启动应用程序并在浏览器中打开它:
export API_KEY="paste-your-key-here"
node server.js
关于 Markdown 渲染的注意事项
聊天模型喜欢星号、列表和偶尔的代码块。我们的演示显示原始文本,这是安全的。当你想要美观的输出时,使用库渲染 Markdown,但请遵循两条规则。在通过 innerHTML 插入之前,将 HTML 通过清理器,因为模型或诱导它的用户可能会发出你未预期的标签和属性。并且谨慎地进行增量渲染:在每次片段时重新解析整个回复对于短消息是可以的,但未完成的 Markdown 可能会闪烁,因此一些构建器在流式传输时显示纯文本,并在流结束时切换到格式化输出。
角色扮演格式是一个单独的怪癖。许多角色用星号包裹动作,例如 *调整台灯*。决定你的应用是否将这些样式化为斜体,并在系统提示词中告诉角色遵循哪种约定。我们的角色设计指南 展示了相应的提示词措辞。
步骤 4:对路由进行冒烟测试
在归咎于浏览器之前,直接探测代理。如果终端正常工作,则服务器正常,任何剩余的 bug 都在页面中。
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'-N 标志禁用 curl 自身的缓冲,因此你应该看到 data: 行逐渐出现,以 data: [DONE] 结束。如果你收到 JSON 错误,请读取其状态:401 表示你的环境变量中的密钥错误,402 表示余额为空,404 表示上游 URL 有拼写错误。
| 症状 | 可能原因 | 修复方法 |
|---|---|---|
| 页面显示不可用消息 | 服务器返回非 200 状态 | 运行 curl 测试并读取状态 |
| 文本仅在末尾出现 | 代理层缓冲了响应 | 禁用该路由的缓冲 |
| 乱码 | 未使用 stream: true 的解码器 | 在 TextDecoder.decode 上保持选项设置 |
| 回复在句子中间截断 | max_tokens 已到达 | 提高服务器限额 |
通过这四项修复,你可以在一分钟内诊断出几乎所有首次运行问题。将 curl 命令保存在笔记中;在稍后更改代理后,它也是一个方便的运行状况检查工具。
让应用显得完善的细微调整
支持流式输出的聊天已经令人愉悦,但一些细节决定了它是演示还是用户会反复使用的产品。
- 在回复到达时禁用发送按钮。 重复提交会导致历史记录交错,使用户和模型都感到困惑。
- 添加一个停止按钮。 创建一个
AbortController,将其信号传递给fetch,并在点击时调用abort()。将到目前为止收到的任何文本保存为助手轮次。 - 持久化日志。 将历史记录保存在
sessionStorage中,以便刷新不会擦除对话,并提供一个清空聊天的按钮以清空它。 - 智能自动滚动。 仅当用户已接近底部时才跟随滚动;否则让用户平静地阅读旧消息。
- 显示温和的失败提示。 用重试链接替换空气泡,该链接重新发送最后一条用户消息。
这些功能每个只需十几行原生 JavaScript,因此在界面真正需要组件、路由或共享状态之前,请克制使用框架的冲动。
加固与后续步骤
你现在有了一个可用的聊天。在真实用户到来之前,在 server.js 中添加一些安全措施。限制每个 IP 或会话的请求数,因为单个密钥总共允许每分钟 300 个请求,一个急切的用户可能会用完所有配额。显式处理上游错误:带有 upstream_busy 的 503 错误值得一个友好的重试按钮,而 403 content_blocked 值得一条清晰的提示消息,而不是空气泡。保持 max_tokens 适中;500 对于聊天来说已经足够,而允许的最大值是每次请求 32,000。
然后考虑成本。作为一个说明,假设每轮发送 1,200 个提示词 token 并接收 250 个补全 token。输入大约 $0.0003,输出大约 $0.00025,每轮大约 $0.00055。这些 token 数量是假设值,所以请使用 usage 块测量你自己的数据。文档 涵盖了该字段,托管无审查 LLM 指南 解释了此类服务的预期表现。
问答
我可以直接从浏览器调用 API 吗?
技术上可以,但这会将你的密钥暴露给所有访问者。使用本教程中的服务器路由,以便将密钥保留在环境变量中。
服务器需要解析流吗?
不需要。服务器可以原样转发字节,页面直接读取数据行。仅当你希望记录文本或过滤输出时,才在服务器端进行解析。
为什么我的回复一次性到达?
介于两者之间的是缓冲。检查请求是否将 stream 设置为 true,以及任何反向代理或压缩层是否未保持响应。
我如何赋予机器人记忆?
每次调用时重新发送消息形式的对话,并在对话变长时裁剪最早的轮次。100,000 个 token 的上下文与回复共享。