中文 ▾
Uncensored Chatbot API直接访问一个无审查 LLM 的 API获取 API 密钥

Uncensored Chatbot API网页聊天

构建流式网页聊天(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 的上下文与回复共享。

只差一张表单,即可获得密钥

创建账户,复制密钥,更改基础 URL。这就是全部设置。

获取 API 密钥阅读文档