在这里插入图片描述
项目仓库:https://atomgit.com/gcw_tMdJJ8WX/computer-second-test.git

用 HTML5 + CSS3 + JavaScript 将一段 Python 流式调用代码,改造成一个可以和 AI 实时对战成语接龙、还能边玩边学的完整 Web 应用。这篇文章完整还原了我的开发思路、代码转换过程、提示词工程细节以及踩过的坑。


在这里插入图片描述

一、缘起:为什么想做成语接龙 AI?

我一直觉得,把传统文化和 AI 结合起来是一件很有魅力的事情。成语接龙是中国人从小到大都玩过的文字游戏,规则简单:上一个成语的最后一个字,必须是下一个成语的第一个字。然而真要让一个 AI 来玩好这个游戏,难度其实远超想象——它不仅要"接得上",还要"接得准"、“接得美”,最好还能顺便讲讲这个成语的出处和含义,让玩家在游戏里学到东西。

恰好我手头有一段现成的 Python 参考代码,它做的事情很简单:调用一个 OpenAI 兼容格式的大模型接口,用流式(SSE)方式拿到输出。我的目标也很明确:把这个 Python 版本的"骨架",移植成一个纯前端的、可以真正运行在浏览器里的成语接龙 AI 对话应用。于是就有了「成语接龙 AI 对话」这个项目。

二、技术选型:三个文件的极简主义

在动手之前,我认真思考过技术栈。做 AI 对话界面,市面上有太多的框架可以选择:React、Vue、Svelte……但我最终选择了"裸奔"方案——只有三个文件:

computer-second-test/
├── index.html            # 页面结构
├── css/
│   └── style.css         # 国风深色主题样式
└── js/
    ├── config.js         # API 配置 + 系统提示词(JSON 约束)
    └── app.js            # 核心逻辑:SSE 流式调用 + JSON 解析 + UI 渲染

这么选有四个理由。

第一,零依赖。 这个项目不需要 npm、不需要构建工具、不需要打包器。git clone 下来,双击 index.html 就能跑。对于想学习前端 AI 应用开发的人来说,这是门槛最低的打开方式。

第二,明确的教学价值。 这个项目的核心难点在于"Python 代码如何用 JavaScript 等价实现",如果引入 React 之类的框架,核心逻辑会被框架的复杂度稀释。保留最朴素的 DOM 操作,读者才能把注意力聚焦在"流式请求"和"JSON 解析"这两个真正值钱的点上。

第三,现代浏览器能力已经足够。 fetch + ReadableStream + TextDecoder 的组合,已经能优雅地处理 SSE 流式响应,完全不需要额外引入第三方库。原生能力够用,就别给自己增加维护成本。

第四,可部署性。 纯静态文件意味着可以丢到任意静态托管平台上(GitHub Pages、对象存储、Nginx 等),一个文件不落地就能全球访问。

事实证明这个选择是对的。整个项目大约 500 行代码,逻辑清晰,每一行都有它的存在价值。

三、读懂参考代码:Python 的流式请求

先看看我手里的 Python 参考代码,它是这一切的起点:

import os
import requests
import json

API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
    "Authorization": f"Bearer uEohGiEZpY2kYQFnuyrWsxFS",
}

def query(payload):
    response = requests.post(API_URL, headers=headers, json=payload, stream=True)
    for line in response.iter_lines():
        if not line.startswith(b"data:"):
            continue
        if line.strip() == b"data:[DONE]" or line.strip() == b"data: [DONE]":
            return
        yield json.loads(line.decode("utf-8").lstrip("data:").rstrip("/n"))

chunks = query({
    "model": "deepseek-ai/DeepSeek-V4-Flash",
    "messages": [...],
    "stream": True,
    "max_tokens": 2048,
    ...
})

for chunk in chunks:
    print(chunk["choices"])

这段代码非常典型,它展示了大模型流式调用的标准姿势:

  1. requests.post(..., stream=True):告诉 HTTP 库,响应体不是一次性读入内存,而是以"流"的形式逐块到达。这是流式对话的核心前提——大模型的输出是一段话,可能要好几十秒才能生成完,用户显然不愿意干等一个完整的响应,他们希望看到"打字机"一样逐字蹦出来的效果。
  2. response.iter_lines():按行迭代响应体。
  3. line.startswith(b"data:"):SSE(Server-Sent Events)协议的每一行数据都以 data: 前缀开头,非 data: 开头的行(例如空行、注释)直接跳过。
  4. b"data:[DONE]":这是 SSE 流的结束标记,遇到它说明生成完毕,优雅退出。
  5. json.loads(...):把每一条 data: 后的 JSON 字符串解析成对象,这个对象就是 OpenAI 兼容接口返回的"增量块"(chunk)。
  6. chunk["choices"]:每个 chunk 的 choices 数组里装着这一步生成的新内容。

理解透了这段 Python 代码,JavaScript 的移植方向就呼之欲出了——其实语言只是语法不同,协议层面的逻辑是一模一样的。

四、核心转换:从 requests 到 fetch + ReadableStream

这是整个项目最核心的工程点。我用一张表格先给出等价关系,再逐个拆解:

PythonJavaScript 等价实现
requests.post(..., stream=True)fetch(url, {method: "POST", headers, body})
response.iter_lines()response.body.getReader() + TextDecoder 按行切分
line.startswith(b"data:") 筛选line.startsWith("data:") 筛选
b"[DONE]" 结束标记/^\[DONE\]/.test(dataStr) 判断
json.loads(...)JSON.parse(...)
chunk["choices"]chunk.choices[0].delta.content(增量文本)

4.1 请求构造的差异

Python 用 requests.post,JavaScript 放到了浏览器内置的 fetch 中:

const resp = await fetch(API_CONFIG.url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + API_CONFIG.apiKey
  },
  body: JSON.stringify(payload),
  signal: controller.signal
});

有一个细节值得注意:浏览器端必须显式设置 Content-Type 和 Authorization 请求头,并且当请求头不是"简单请求"(simple request)时,浏览器会自动先发送一个 OPTIONS 预检请求(CORS preflight)。我在测试中发现,这个 API 网关对预检请求会返回 204 No Content 并带上 Access-Control-Allow-Origin 等头,说明它是支持跨域的,这为纯前端方案扫清了最大的障碍。这一点我在后面"部署与安全"一节还会展开。

4.2 SSE 行切割与缓冲

Python 的 iter_lines() 帮我们按行切分好了,JavaScript 没有这么贴心的工具,需要自己动手:

const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
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 rawLine of lines) {
    const line = rawLine.trim();
    if (!line.startsWith("data:")) continue;
    // ... 解析 JSON
  }
}

这里最关键的技巧是 buffer 缓冲区。网络传输是分块的,一个完整的 SSE 行可能被切成好几块到达,也可能一个块里塞了好几行。如果不做缓冲,直接对每个 value 切分,就会频繁遇到"半行"导致解析失败。我的做法是:

  • 把新到的字节解码后追加到 buffer;
  • 用 split("\n") 切出完整的行;
  • 把最后一段没有换行符的"残行"放回 buffer,等下一批数据到来后再拼接处理。

这个模式在网络编程里叫"按行缓冲",是处理流式文本协议的通用套路,值得记住。

4.3 delta.content 与 reasoning_content 的陷阱

模型参数里我传了 thinking_budget: 2048,这意味着模型会先"思考",再"回答"。于是流式返回的 chunk 出现了两种字段:

{"choices":[{"delta":{"reasoning_content":"我们要……"}}]}
{"choices":[{"delta":{"content":"足","reasoning_content":""}}]}

开头很长一段时间的流内容,content 都是空字符串,只有 reasoning_content 在填充(那是模型的思考过程,相当于"内心OS")。如果直接把 delta.content 拼到聊天界面上,前端会有一段时间处于"收到了数据但界面没动静"的状态,非常容易误判为接口异常。

我的处理方式是只提取 content,忽略 reasoning_content:

const delta = chunk && chunk.choices && chunk.choices[0] && chunk.choices[0].delta;
if (delta && typeof delta.content === "string" && delta.content) {
  onDelta(delta.content);
}

这样思考过程不会被展示给用户,等"正文"开始输出后,界面自然就动起来了。这也是很多带「深度思考」模式的大模型产品在前端渲染时的通用取舍:思考过程可以折叠展示,但绝不能污染正文字内容。

五、让 AI 返回结构化 JSON:系统提示词设计

这是整个项目里"技术含量"最高的部分之一。如果只是让 AI 输出普通文本,前端就得用正则去猜"哪个词是成语、哪个是拼音、哪个是释义",既不可靠又不可维护。我的解法是:在系统提示词里约定死输出格式,要求 AI 只返回一个合法的 JSON 对象,前端直接按字段渲染。

5.1 为什么必须用 JSON 约束

大模型默认输出的是自由文本,而我们的前端需要的是"成语 + 拼音 + 释义 + 出处 + 接龙状态"这样结构化的数据。如果靠解析文本,会有几个致命问题:

  • 成语、拼音、释义没有明确边界,正则很难稳定分离;
  • AI 偶尔会加"好的!"、"哈哈"这类寒暄词,进一步污染结构;
  • 输入错误、AI 认输等不同状态无法用文本区分。

用 JSON 约定一套"协议",AI 的输出就变成了一份契约,前端渲染逻辑可以写得非常简洁、稳健。

5.2 JSON 返回结构设计

我在系统提示词中约定的返回结构如下:

{
  "code": 0,
  "data": {
    "status": "success",
    "valid": true,
    "assistant_idiom": "足智多谋",
    "assistant_pinyin": "zú zhì duō móu",
    "meaning": "富有智慧,善于谋划。形容人善于料事和用计。",
    "source": "元·无名氏《连环计》第一折:\u201c此人足智多谋,可与共事。\u201d",
    "tip": "接龙以\u201c足\u201d开头,下一个字是\u201c谋\u201d。",
    "game_over": false,
    "last_char": "谋"
  },
  "message": "success"
}

各字段的职责划分是这样的:

字段类型作用
statusstringsuccess 正常接龙 / warning 用户输入有误 / error 无法判断
validboolean用户上一条成语是否有效(是否真成语、是否符合接龙规则)
assistant_idiomstringAI 接出的成语,认输或无需接龙时为空字符串
assistant_pinyinstring拼音(空格分隔),供卡片展示
meaningstring释义,游戏的教育价值所在
sourcestring出处,拿不准时可为空
tipstring本轮讲解或引导提示
game_overboolean是否结束本轮游戏
last_charstring本轮最后一字,用于校验下一轮

这个结构设计上我刻意做了三件事:一是把"状态"(status/valid/game_over)和数据(idiom/pinyin/meaning)分开,前端可以先判断状态再决定渲染策略;二是把"机器要用的"(last_char)和"人要看要学的"(meaning/source)都放在同一份数据里,后续如果要加"错题本""成语收藏"等功能,数据已经齐备;三是留了 code 和 message 两个通用字段,保持和传统 HTTP 接口返回风格统一,未来如果引入后端中转,也能无缝衔接。

5.3 提示词中的三条"硬约束"

为了让 AI 稳定输出合法 JSON,我的系统提示词里下了三条很关键的死命令:

  1. “你只能且必须返回一个合法的 JSON 对象,禁止输出任何多余文字、解释、Markdown 代码块标记(禁止使用```)或换行前缀。” —— 这一条直接堵死了 AI 最常见的两个坏习惯:说废话,以及用 Markdown 代码块把 JSON 包起来。
  2. “若你确实无法接到合适的成语,必须诚实认输(assistant_idiom 返回空字符串,game_over 设为 true)。” —— 这是"接龙游戏"和"聊天机器人"最大的区别:AI 必须学会认输,而不是绕开规则东拉西扯。
  3. “记住对话历史:严格要求首尾相接,不得重复使用已出现过的成语。” —— 禁止重复是游戏体验的核心保障,接龙最怕 AI 反复用同一个成语糊弄玩家。

事实证明,约束越具体、越可验证,大模型的遵循率越高。把字段含义写清楚、把非法行为点名道姓地禁止,比笼统地写"请返回规范的 JSON"有效得多。

六、前端渲染:从 JSON 到成语卡片

拿到 JSON 之后,前端要解决两个问题:解析容错和结构化渲染。

6.1 容错解析 extractJson

即便提示词约束再严格,生成模型偶尔还是会"越狱"——在 JSON 前后加一句寒暄,或者用 ``````json ````包裹。为此我写了一个具有兜底能力的解析函数:

function extractJson(text) {
  let cleaned = text.trim();
  const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fence) cleaned = fence[1].trim();
  const firstBrace = cleaned.indexOf("{");
  const lastBrace = cleaned.lastIndexOf("}");
  if (firstBrace >= 0 && lastBrace > firstBrace) {
    cleaned = cleaned.slice(firstBrace, lastBrace + 1);
  }
  return JSON.parse(cleaned);
}

它的思路是"三连兜底":先剥掉 Markdown 代码块外壳,再截取第一个 { 到最后一个 } 之间的子串,最后才交给 JSON.parse。这样即使 AI 在 JSON 外面套了马甲,也基本能"剥壳取肉"。我在真实测试中也确实遇到过模型输出带转义引号的情况,这套兜底逻辑保证了极端情况下页面不会白屏,而会降级展示原始文本。

6.2 结构化渲染

渲染函数根据 status 和 game_over 走不同的 UI 分支:

  • 成功接龙:渲染一张"成语卡片",包含 ✅ 接龙成功标签、超大成语文字(金色、字间距拉宽)、拼音、释义、出处;如果 game_over 为 true,再追加一个 🏁 游戏结束标签。
  • AI 认输:不渲染卡片,只渲染一句温和的认输提示,例如"AI 认输啦!换个成语再来一局吧 😄"。
  • 用户输入有误:展示 warning 分支的引导提示,比如"这是一个句子哦,让我们来玩成语接龙吧!请说出一个成语作为开始。"

对比纯文本方案,这个结构化方案带来的不仅仅是"好看",而是渲染逻辑的确定性:什么状态出什么界面,一目了然,维护起来极其舒服。

6.3 打字机效果

由于接口是流式的,我在接收增量时实时把内容写进一个临时消息气泡,实现"逐字蹦出"的打字机效果:

await chatCompletion(payload, (delta) => {
  fullText += delta;
  // 创建/复用当前 AI 气泡,把增量拼进去
  bufferEl.textContent = fullText;
  scrollToBottom();
});

流结束后,我把这个"半成品"气泡删掉,换成正式的成语卡片。这个"先流式预览、后结构化替换"的两段式渲染,兼顾了流式体验和最终效果,是我个人很喜欢的一个细节。

七、多轮记忆与游戏状态管理

成语接龙是典型的多轮游戏:这一轮的规则依赖上一轮的结果。因此对话记忆是刚需。

我用一个 history 数组维护完整的消息序列([{role, content}]),每次请求时把系统提示词 + 最近若干轮历史 + 当前用户输入拼接成 messages 一起发给模型:

function buildPayload(userText) {
  const messages = [
    { role: "system", content: SYSTEM_PROMPT },
    ...history.slice(-MAX_HISTORY_ROUNDS * 2)
  ];
  messages.push({ role: "user", content: userText });
  // ...
}

这里值得展开讲的是 MAX_HISTORY_ROUNDS = 10 这个设计。每轮对话会产生 2 条消息(user + assistant),如果不加限制,历史会无限膨胀,既浪费 token 又稀释模型注意力。我换算成"轮数"来裁剪:history.slice(-MAX_HISTORY_ROUNDS * 2) 表示只保留最近 10 轮(20 条消息)。这个上限经过实践是够用的——接龙失败或被判无效时,状态都写在最新一轮的 JSON 里,不需要回溯太远。

另外,"重新开始"按钮会清空 history 并清空界面,让玩家随时可以开一局新的。这是状态管理的最后一块拼图:三条状态渠道——历史数组(轮次记忆)、DOM(界面呈现)、进度校验(JSON 里的 last_char)——各司其职,互不干扰。

八、UI/UX:国风深色设计

作为一个文化属性很强的游戏,UI 绝不能是默认白底黑字。我选择了国风深色主题,并用 CSS 变量建立了一套完整的色彩体系:

:root {
  --primary: #b45309;          /* 赭石橙,主色 */
  --bg-deep: #1c1917;          /* 深褐黑,背景 */
  --gold: #fbbf24;             /* 鎏金,点缀 */
  --success: #34d399;          /* 状态色 */
  --danger: #f87171;
  ...
}

在视觉上做了几处有"国风味道"的设计:

  • 渐变标题:标题"成语接龙 AI 对话"使用从金色到暖白的渐变文字,配合毛笔感标题,呼应"龙"的意象;
  • 毛玻璃 Header/Footer:backdrop-filter: blur(12px) 让吸顶的页头和页脚有一种"透过宣纸看东西"的朦胧感;
  • 成语卡片金框:AI 的成语大字用鎏金色、加宽字距、四周由淡金色的渐变背景托底,模拟"烫金名帖"的质感;
  • 头像区分:用户 👤 气泡是赭石橙渐变,AI 🐉 气泡是深灰带金边,一眼可辨;
  • 打字动画:思考中显示三点跳动动画,与 status-dot 状态灯(就绪=绿、忙碌=金、异常=红)呼应,让用户始终知道系统在干什么。

在响应式适配方面,@media (max-width: 640px) 下会隐藏发送按钮的文字、收窄气泡宽度、去除页面边框,保证手机端体验依然完整。作为一个纯静态页面,这套 UI 做到了"零图片、纯 CSS",加载极快。

九、测试验证:Playwright 端到端

写代码容易,证明代码能跑难。我引入了 Playwright + Chromium 做真实浏览器端的端到端测试,验证的不仅仅是"页面能打开",而是真实调用 AI 接口后的完整链路。

测试脚本的核心流程是:打开页面 → 输入"画蛇添足" → 点击发送 → 等待 .idiom-card 出现 → 断言成语、拼音、释义都已渲染 → 再输入"谋事在人" → 等待第二个卡片出现 → 检查控制台无报错。

真实跑出来的结果:

第1轮 用户[画蛇添足] -> AI[足智多谋] ✅ 以"足"字开头,卡片渲染正常
第2轮 用户[谋事在人] -> AI[人山人海] ✅ 以"人"字开头,多轮记忆生效
纠错 用户[今天天气不错] -> AI 友好提示(非成语) ⚠️ 智能纠错生效

这里有一个让我印象深刻的测试插曲:我在第一次端到端测试时就发现,当 AI 返回 warning 状态时,tip 字段会在界面上重复渲染两遍——因为提示文案同时走了"通用 tip 渲染分支"和"异常状态渲染分支"。这是我光看代码很难发现的 bug,只有真实跑起来才能暴露。修复也很简单:在通用分支加一个 status !== "warning" && status !== "error" 的条件。这件事让我切身体会到:对 UI 应用来说,端到端测试不是可选项,而是必备项。

这个 bug 的发现和修复,也解释了为什么我在项目里坚持"能用真 API 测就绝不用 mock"。mock 能验证代码逻辑,但永远验证不了"模型真的按提示词的约定返回了"这件事,而这恰恰是这类 AI 应用的最大不确定性来源。

十、部署与安全

10.1 部署

项目是纯静态资源,部署选项非常多:

# 本地预览
python3 -m http.server 8080
# 或
npx serve .

推到仓库后,也可以直接静态托管到任意平台。我实际验证过两种情况都能正常调用 API:

  • localhost 场景:CORS 预检返回 Access-Control-Allow-Origin: http://localhost:8080;
  • file:// 直接打开:浏览器发的 Origin: null 也能通过预检。

这意味着哪怕用户不搭服务器、直接双击 HTML 打开,也能玩。这是纯前端方案独有的便利性。

10.2 一个必须强调的安全问题

我必须诚实地指出这个方案的安全边界:API Key 写在 js/config.js 里,部署到公网后任何访问者都能在浏览器开发者工具里看到这个密钥。 这是纯前端方案的先天缺陷,无法通过改代码根治。

如果这是一个生产项目,正确做法是:增加一层后端代理——前端请求自己的后端,后端持有密钥并转发请求到模型 API,这样密钥永不暴露。架构上只需要多一个 Node/Java/Go 的薄代理层,前端代码几乎不用改(改一下 API_CONFIG.url 指向代理即可)。

我之所以在这个演示项目里保留明文密钥,是为了保持"零后端、纯前端、可直接运行"的教学价值,但请大家务必牢记:生产环境永远不要把密钥交给浏览器。

十一、踩坑记录

最后把我开发过程中踩过的、认为有价值的坑集中复盘一下,希望后来者少走弯路。

坑 1:SSE 分包导致解析失败。 最初我拿到 value 就直接 split("\n") 解析,结果经常报 JSON 解析错误,因为一行 data: 被切成了两半。解法就是前面说的"按行缓冲":残行留在 buffer 里等下一批。这个坑几乎每个手写 SSE 解析的人都会踩。

坑 2:thinking_budget 造成"假死"假象。 模型启用推理模式后,会先输出一大段 reasoning_content,content 一直为空。如果前端把 delta.content 为空当作"没收到数据",很容易误判超时。认清这个机制后,我在 UI 上保留了"AI 正在思考"的打字动画,等待正文出现。

坑 3:AI 输出包裹 Markdown 代码块。 即便提示词里三令五申"禁止使用 ````````````,模型偶尔还是会包。所以我写了 extractJson 的三连兜底。对付大模型,永远要做防御性编程。

坑 4:warning 状态 tip 重复渲染。 就是第九章里提到的那个 bug。它提醒我:渲染逻辑里一旦出现"多个分支都会追加同一份数据"的写法,一定要问自己一句:这些分支会同时命中吗?

坑 5:CORS 与 file:// 的兼容。 开发前我担心纯前端能否直连 API,实测发现网关对任意 Origin(包括 null)都放行,这才让"双击即玩"成为可能。如果换了别的 API 网关,一定要先做一次 OPTIONS 预检,确认 Access-Control-Allow-Origin 的返回策略再决定架构。

十二、总结与展望

回看这个项目,从一段 20 行不到的 Python 参考代码,到一个能流畅对话、能正确接龙、能智能纠错、还有完整 UI 的 Web 应用,中间跨越的其实不完全是代码量,而是思路的转变:

  • 协议思维:流式接口的迁移,本质是理解 SSE 协议,而不是背诵某个库的 API;
  • 契约思维:让模型输出结构化 JSON,本质是和大模型建立一份"数据契约",前端从此不再是"猜"而是"读取";
  • 用户体验思维:流式打字机、思考态动画、认输文案,每一个细节都在回答同一句话——用户此刻应该看到什么、感受到什么;
  • 防御思维:模型不可控,所以解析要容错、渲染要兜底、测试要真实。

这个项目的可扩展空间也很大。比如:加入本地成语词库做"前端预检",让 AI 只负责锦上添花;增加积分制、计时制,把游戏性拉满;接入 TTS,让 AI 把成语朗读出来;以及给 AI 加上"难度档位",让它对小朋友放水、对高手上强度。

技术从来都只是工具,真正有意思的,是它被拿来创造的那一小片风景。希望这篇文章能给你一些启发,让你也想动手把一个"有点意思的想法"变成"一个真的能玩的东西"。

本文由「码道」整理分享。欢迎 Star、Fork,也欢迎在评论区交流你的 AI 应用开发心得。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐