码道:零后端纯前端打造「拾言 AI 对话小站」的完整实战记录

从一段 Python 参考代码出发,用 HTML5 + CSS3 + JavaScript 构建一个开箱即用的流式 AI 对话应用,并在迁移过程中让模型学会「说人话、讲结构」——这是我在这篇文章里想与你完整分享的全部过程。

—在这里插入图片描述在这里插入图片描述
在这里插入图片描述

一、缘起:为什么我要做这样一个项目

1.1 一次意外的「脚手架」任务

大概在一个寻常的工作日,我收到一个很有趣的挑战式需求:用纯前端技术栈,实现一个 AI 对话应用。需求清单并不复杂:能对话、能流式输出、能多轮上下文、界面要好看。真正让人眼前一亮的是需求里附带的参考实现——一段十几行的 Python 脚本,调用某个大模型 API,通过 SSE(Server-Sent Events)协议流式接收回复。

这段 Python 代码本身很简单:

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"))

但恰恰是这份简洁,让我意识到一个有趣的问题:如果把这个逻辑搬到浏览器端,会发生什么? 它并不难,但它会逼迫你去思考很多纯后端脚本里根本不会出现的问题——跨域、流式解析、用户中断、错误恢复、界面的实时反馈……于是,「拾言 AI 对话小站」这个项目就诞生了。

1.2 为什么叫「拾言」

起名向来是项目里最耗费脑细胞的一步。我想要的不是一个冷冰冰的「ChatBox」或者「AI Chat」,而是有一点温度的名字。后来我想到了「拾言」——拾取文字,对话交流。它有两层含义:

  • 拾:从零散的知识碎片中捡拾有价值的信息,这与 AI 对话「从海量知识中提取答案」的本质暗合;
  • 言:语言、对话,点出产品的核心交互形式。

副标题定为「拾取文字,对话交流」。整个产品希望传达的是一种轻松、友善、愿意交流的氛围,所以配色、文案、交互细节都围绕「温暖、克制、有设计感」来打磨。

1.3 技术路线的三次抉择

在动手之前,我先做了技术选型的推演,其实是在三条路线之间反复权衡:

路线一:重前端框架方案(React / Vue)
成熟、生态强大,但对这个需求来说有点「杀鸡用牛刀」——一个单页对话应用,没有复杂状态树,没有路由,没有组件复用压力。而且需要引入 Node 工具链、构建配置、依赖管理,交付物会变重,部署也要多一步构建。

路线二:纯 HTML5 + CSS3 + JavaScript
零依赖,零构建,浏览器打开 index.html 就能跑,任何静态托管平台都能直接部署。对于「工具型」「展示型」的项目,这反而是最优雅的答案——代码即交付物。

路线三:加后端代理的服务端渲染方案
能保护 API 令牌、能绕开 CORS,但多了一个要维护的服务端,部署成本翻倍,与「轻量」的初衷背道而驰。

最终我选择了路线二。这是一个刻意为之的取舍:在「能跑」和「好跑」之间,我选择让交付物足够简单,让任何拿到代码的人都能在十秒内跑起来。至于令牌安全、跨域这类问题,我在 README 里做了明确的说明,也给出了生产环境的建议。技术债可以有,但必须是「明示」的技术债,而不是「隐藏」的技术债。


二、架构设计:一个页面如何撑起一个完整的 AI 对话产品

2.1 总体结构

整个项目只有四个文件,却覆盖了一个完整对话产品的所有能力。我坚持一个原则:单一职责、文件各自清晰。

PickTalkAI/
├── index.html        # 页面骨架:侧边栏 + 聊天区 + 输入区
├── css/
│   └── style.css     # 全部样式:布局、主题、动画、响应式
├── js/
│   ├── config.js     # 唯一配置入口:API、模型参数、系统提示词
│   └── app.js        # 全部业务逻辑:SSE 解析、渲染、会话管理
└── README.md         # 完整使用与开发文档

2.2 页面骨架:index.html

我刻意让 HTML 保持「骨架态」——它只负责定义结构,不负责任何业务逻辑。页面被划分为三个视觉区域:

  • 左侧侧边栏:品牌区(拾言 Logo + 标题)、「新对话」按钮、历史会话列表、底部模型状态与「清空全部对话」入口;
  • 右侧聊天区:顶部的对话标题与在线状态,中部的消息流,底部的输入框与发送按钮;
  • 整个应用用一个 .app 容器约束为经典的「侧边栏 + 主区」双栏布局。

值得一提的细节是 textarea 输入框的设计:单行展示,但支持自动增高、Enter 发送、Shift+Enter 换行,几乎复刻主流对话产品的输入体验。这听起来简单,但实际上「自动增高」与「Enter/Shift+Enter 的键位冲突处理」是不少新手项目会踩坑的地方。

2.3 样式层:CSS 变量驱动的主题系统

如果你翻过 style.css,会发现在文件最顶部定义了一组 CSS 变量:

:root {
  --bg: ...;
  --panel: ...;
  --text: ...;
  --accent: ...;
  ...
}

这是我在设计阶段就定下的规矩:所有颜色、间距、圆角、字号,全部走设计令牌(Design Tokens)。好处是显而易见的——后续任何一次改色,只需要改几个变量;做暗色模式,也只需要切换一套变量。

具体到视觉风格,我的目标是「克制的温度感」:整体以柔和的浅色为底,品牌色选了一个不刺眼的蓝紫渐变色;气泡、头像、按钮全部使用适度的圆角;流式输出的光标「▍」设置了闪烁动画,让「正在生成」的状态显得生动而不焦虑;消息出现时有极轻的上浮淡入动画,增强反馈的节奏感。

响应式方面,@media (max-width: 768px) 断点下侧边栏自动隐藏,聊天区占满全屏,移动端同样可用。

2.4 配置层:config.js 的职责边界

很多人会把配置写死在业务代码里,我选择单独抽出 js/config.js。它的内容包括:

配置项作用
API_URL大模型 API 地址(OpenAI 兼容格式)
AUTH_TOKENAPI 鉴权令牌
MODEL模型名称,如 deepseek-ai/DeepSeek-V4-Flash
MAX_TOKENS单次回复最大生成 token 数
TEMPERATURE采样温度,默认 0.6
TOP_P核采样概率,默认 0.95
THINKING_BUDGET思考预算,控制模型深度思考长度
SYSTEM_PROMPT系统提示词,这是整个项目最有意思的部分

代码一旦写好,以后换模型、换令牌、调参,都只改这一个文件,业务代码零改动。配置与逻辑分离,是工程化最基本的体面。


三、核心难点一:Python 流式请求的 JavaScript 化

3.1 逐行对照:从 requests 到 fetch + ReadableStream

需求里那一段 Python 代码,本质上做了四件事:发起 POST 请求、按行读取响应流、过滤出 data: 前缀的数据、把 [DONE] 当作结束标记。我把这四步逐一映射到浏览器环境:

Python 参考实现JavaScript 对应实现
requests.post(..., stream=True)fetch(url, { method: 'POST', body: JSON.stringify(payload) })
response.iter_lines()response.body.getReader() + TextDecoder 按行切分
line.startswith(b"data:")line.trim().startsWith("data:")
data:[DONE] 结束标记payload === "[DONE]" 终止循环
chunk["choices"]json.choices[0].delta

但这里有一个非常关键的工程细节:Python 的 iter_lines() 天然会按 \n 切好行,而浏览器流不会。ReadableStream 返回的 chunk 是任意长度的二进制块,一条 SSE 事件可能被分成多个 chunk 送达,也可能多个事件被合并在一个 chunk 里。处理不当,解析就会错乱。

我的解决方式是维护一个 buffer 字符串:

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 line of lines) {
    // 解析 data: 行
  }
}

每轮循环先 split("\n"),把最后一段不完整的行重新放回 buffer,等待下一块数据补全。这样无论网络怎么分包,解析永远不会错位。这段代码虽然是「翻译」,但其实是移植过程中最需要动脑筋的地方。

3.2 更隐蔽的问题:TextDecoder 与审计字符集

还有一个容易被忽略但很现实的坑:中文内容的 UTF-8 多字节字符可能被 chunk 边界切断。一个汉字在 UTF-8 下占 3 个字节,如果某一块数据正好把这个汉字拦腰截断,直接 decode 会产生乱码。

解决办法是给 TextDecoder 传入 { stream: true }:

const decoder = new TextDecoder("utf-8");
buffer += decoder.decode(value, { stream: true });

stream: true 会让解码器把不完整的字节序列暂存在内部,等下一块数据补齐后再解码,从根上规避乱码。这也是移植 Python 代码时 Python 侧完全不需要关心、而浏览器侧必须处理的问题。

3.3 用户中断:AbortController

Python 脚本里没有「用户点击停止」这个概念,但网页必须考虑。我用 AbortController 实现了生成中断:

  • 每次开始流式请求前 abortController = new AbortController(),并把 signal 传给 fetch;
  • 「切换会话」「新对话」「清空」等操作触发 abortController.abort();
  • 捕获 AbortError 后,把已经生成的部分内容规整保存,实现「停在该停的地方」。

这保证了用户永远有「反悔权」:AI 说歪了,或无聊了,随时可以打断,而不是干瞪眼看它跑完 2048 个 token。


四、核心难点二:系统提示词工程——让模型开口就是结构化 JSON

4.1 为什么要结构化输出

对话应用最常见的丑陋体验是什么?——模型在正文里乱塞 **加粗** 的碎片、动不动把整个返回包进 ```json 代码块里、回答末尾「热情」地反问一串问题。这些行为本身没错,但它们会让前端渲染非常痛苦,也让「推荐追问」这类高级功能无从谈起。

于是我想:能不能在系统提示词层面,约束模型始终返回一个固定结构的 JSON?这样前端解析就变得稳定、可预期,还能顺势实现「主题标签」「智能追问」等花活。这正是需求里明确提到的方向——把系统提示词改造成项目所需的 JSON 数据返回格式。

4.2 提示词怎么写才「稳」

我写的系统提示词核心内容如下(节选):

你的每一次回答必须以严格的 JSON 对象返回,不要输出任何额外文字、
不要用 markdown 代码块包裹 JSON,格式如下:

{
  "reply": "回答正文,使用 Markdown 语法,内容详实准确",
  "suggestions": ["追问一", "追问二", "追问三"],
  "topic": "用 2-6 个字概括本次回复主题的关键词标签"
}

几个关键设计决策:

  1. 字段语义清晰:reply(正文)、suggestions(推荐追问)、topic(主题标签),一眼能懂;
  2. 给出强约束的否定式指令:「不要输出任何额外文字」「不要用 markdown 代码块包裹」——否定式明确指令对约束模型格式比肯定式更有效;
  3. 给出完整示例:人类学格式靠示范,模型也一样,示例让模型「照葫芦画瓢」;
  4. 行为准则兜底:始终用简体中文、不确定时坦诚说明、内容具体实用——保证 JSON 里的内容质量。

4.3 前端如何「容忍」模型的任性

即便提示词写得再好,模型偶尔还是会「越界」——吐出个 ```json 包边,或者开头加一句「好的,回复如下:」。所以我在前端做了一个三级容错解析链:

function parseReplyJson(src) {
  // 第一级:整体直接 JSON.parse
  // 第二级:剥掉 ```json ... ```代码块再解析
  // 第三级:掐头去尾,从第一个 { 到最后一个 } 截取再解析
}

三级策略层层兜底,把模型「99% 听话 + 1% 调皮」的风险降到最低。而流式过程中,我还写了一个 extractReplyRaw 函数,用正则实时抓取 "reply" 字段的进行时内容,让用户在生成过程中就看到正文逐字出现,而不是等到整个 JSON 结束。这一行细节,直接决定了流式体验的成色。

4.4 多轮上下文的「清扫」

还有一个隐藏设计:发送给 API 的历史消息里,assistant 的历史回复我并不会把整个 JSON 原样塞回去(那会污染上下文、浪费 token),而是用 plainOf 把历史回复剥壳成纯 reply 正文再送进上下文。系统提示词 + 用户消息按原文、助手消息按正文摘要,这种「上下文净化」让多轮对话既准确又省 token。


五、核心难点三:零依赖 Markdown 渲染器

5.1 为什么不用 Marked / Showdown

引入一个 Markdown 库只需要一行 CDN,但这次我选择手写轻量渲染器。理由很朴素:

  1. 零依赖是项目立项时的硬约束,尽量不打破;
  2. 对话场景对 Markdown 的需求有限:标题、粗斜体、行内代码、代码块、列表、引用、链接、分割线,覆盖了就够;
  3. 手写渲染器意味着完全可控——我可以确保任何情况下 XSS 都被正确转义(事件属性、javascript: 链接等一律被干掉),也能针对流式场景做增量优化。

5.2 实现思路:转义 → 正则 → 分段

我的渲染流程分三步:

  1. 先转义:把 & < > " ' 全部替换为 HTML 实体。这是一条不可逾越的安全底线,先转义再处理标记,从根上杜绝注入;
  2. 再正则:用一系列精心排序的正则替换把 Markdown 标记转换成 HTML 标签。注意顺序很重要——必须先处理行内(行内代码、粗斜体),再处理块级(标题、列表、引用);先处理围栏代码块再处理行内代码,否则会互相干扰;
  3. 最后分段:按空行把内容切成段落,块级元素(<pre> <h1> <ul> 等)单独成段,普通文本包进 <p>。

这样一个几百行的轻量渲染器,覆盖了对话场景 95% 的排版需求,而且行为完全可控、可测。


六、核心难点四:会话管理与持久化

6.1 localStorage 承载多会话

「多会话」是对话产品的基本素养。我用 localStorage 做持久化,数据模型非常简单:

{
  "id": "s_时间戳_随机串",
  "messages": [
    { "role": "user", "content": "..." },
    { "role": "assistant", "content": "{\"reply\":...}", "thinking": "..." }
  ]
}

会话列表、当前激活会话两个 key 分开存储,刷新页面后自动恢复。会话标题不单独存储,而是从第一条用户消息动态截取(超过 14 字加省略号)——少存一个字段,就少一个不一致的来源。

6.2 流式期间的状态一致性

流式生成时,draft(进行中的助手消息)会被 push 进当前会话的 messages,同时用一个引用接着,随时原地更新 content 和 thinking。这里有个大坑:API 请求的消息列表必须排除掉这个 draft,否则会把半截 JSON 送进上下文。我的实现里用 .filter(m => m !== draft) 做了引用级排除,干净利落。

6.3 思考过程的长截断

DeepSeek 系列模型的 reasoning_content(深度思考链路)可能非常长,我设置了 6000 字符的上限,超出后从尾部截断——既保留思考过程的价值(尾部的结论往往更有用),又避免 DOM 被撑爆。


七、视觉与体验:让细节经得起推敲

7.1 「思考过程」的可折叠展示

对话应用最常见的痛点之一:AI 在「想」的时候用户看不到任何反馈,要么干等,要么怀疑卡死。在我的实现里,模型输出的 reasoning_content 会实时累积,并以一个可折叠的 details 组件展示出来——生成中默认展开并带闪烁光标,完成后可收起。这既满足了「透明度」,又避免了冗长思考内容喧宾夺主。

7.2 「智能追问」按钮

每条助手消息渲染完成后,如果解析出了 suggestions,会在回复底部渲染三个胶囊按钮「💡 可能你还想问」。点击任意一个按钮,会直接以该问题发起新一轮对话。这个功能的成本和收益比极高——几行代码,却让「对话-追问-再对话」形成闭环,极大提升了产品的可玩性。

7.3 空状态的引导

首次进入、没有任何对话时,页面中央展示欢迎文案 + 四个预置问题卡片(「🌌 黑洞的秘密」「🍂 写一首秋天的诗」「💻 闭包是什么」「📚 高效学习方法」)。这既填充了空白画面的尴尬,又给了用户「不知道该聊什么」时的明确抓手。

7.4 状态机:在线 / 思考中 / 生成中

顶栏的状态指示器是三态切换的:空闲时「在线」绿点,请求发出后「思考中…」,收到首个内容 token 后切为「生成中…」。配合输入框禁用、发送按钮禁用,用户在任何时刻都能准确感知系统的当前状态,不会产生「我是不是卡了」的焦虑。


八、踩坑实录:那些我替读者趟过的雷

8.1 CORS 与 file:// 协议

项目做好后最大的坑:直接用浏览器打开 index.html(file:// 协议)调用 API 会因跨域被拦截。解决方案不是我绕开 CORS,而是在 README 里明确交代——本地用 python3 -m http.server 起个静态服务,或部署到任意静态托管平台。这不是偷懒,而是把「静态页面 + 远程 API」模式本来就该面对的约束摆在台面上。

8.2 流式 JSON 的「截断时刻」

流式过程中,最怕用户切走会话再切回来——此时 draft 可能保存了未闭合的 JSON。我在最后收尾时用 normalizeJson 做规整:从第一个 { 截取到最后一个 },再交给解析器容错处理;实在解析不出来的,也保证用户能看到原始文本,绝不白屏。

8.3 渲染性能

流式期间每次 onChunk 都会触发一次全量重渲染,如果消息很多,DOM 重建会有肉眼可见的卡顿感。我做了两个妥协:一是给新增消息加轻量 CSS 动画而非重排导致的位移;二是流式结束后采用非平滑滚动到底部,避免动画叠加造成的抖动。对于对话类应用,这个量级的性能优化已经够用。

8.4 令牌的前端暴露

必须诚实面对的一个事实:纯前端应用,任何令牌都会暴露在浏览器里。这是架构本身的固有限制,我在 README 安全提示一节里专门说明,并建议生产环境用后端代理转发。让用户知情的取舍,好过假装它不存在。


九、验证与交付:如何证明它真的「能跑」

在我认为项目完成之前,我做了一套完整的验证动作:

  1. 静态检查:对 config.js、app.js 跑 node --check,确认语法零错误;
  2. 资源可达性:本地起 HTTP 服务,逐个请求 index.html、CSS、JS,全部 200;
  3. DOM 引用核对:把 HTML 里所有 id 与 app.js 中的 getElementById / querySelector 引用做交叉比对,保证零漏引;
  4. API 实测:用脚本真实调用接口,确认 HTTP 200、SSE 流式正常返回;
  5. 格式实测:用完整系统提示词跑一轮「什么是黑洞?」,确认返回 JSON 能被 JSON.parse 成功解析,且 reply、suggestions(3 个)、topic 三个字段全部齐全;
  6. 思考链路验证:确认 reasoning_content 确实随流式返回,前端思考折叠组件有真实数据可展示。

这六步验证里,第 4、5 步是最有说服力的——代码写完了不算完,接口真真实实地吐出了符合约定的结构化数据,这块拼图才算合上。


十、写在最后:这个项目教给我什么

回看整个「拾言 AI 对话小站」的创作过程,有几点体会很想和读者分享:

第一,需求里的参考代码往往是「锚点」,而不是「天花板」。 那段 Python 脚本只有十几行,但如果只做字面翻译,最终交付物也只是一个「会流式输出的控制台」。真正让项目脱胎换骨的,是围绕「浏览器这个特殊宿主」做的那些增量设计——AbortController、TextDecoder stream 模式、JSON 结构化容错、缓存行 buffer……这些才是移植的灵魂。

第二,系统提示词是产品体验的一部分,而不是一句可有可无的开场白。 一个认真设计的 SYSTEM_PROMPT(结构化字段、否定式约束、示例)能让"推荐追问""主题标签"这类产品功能从「约等于没有」变成「稳定可用」。提示词工程的每一分投入,最终都会变成用户看得见的产品差异。

第三,零依赖不是复古,而是一种清醒。 在这个 npm 包比代码还多的时代,选择「HTML + CSS + JS 三件套」有时候反而是最优雅的答案——交付物即代码、打开即用、部署零门槛。它不是万能的,但对「工具型、展示型、教学型」的项目,它恰到好处。

第四,交付不是写完代码,而是跑通闭环。 直到 API 实测返回了合规的结构化 JSON,直到每条推荐追问都能点击生效,直到刷新页面会话还在,我才觉得自己真的交付了。验证的六个步骤里,任何一环失败,我都不好意思说「项目完成了」。

如果你也对「纯前端 + AI 对话」的组合感兴趣,欢迎直接 clone 这个仓库,改一改配置里的令牌和模型名,它就是你的了。也可以顺着 README 里的对照表,亲手感受 Python 到 JavaScript 的思维迁移过程。期待看到你的「拾言」,对话愉快。

项目地址:https://atomgit.com/gcw_aQD64pbk/PickTalkAI

技术栈:HTML5 + CSS3 + 原生 JavaScript(零依赖)

一句话总结:从一段 Python 流式脚本出发,我在浏览器里重建了一个完整的 AI 对话产品,并让模型学会了用结构化 JSON「开口说话」。

Logo

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

更多推荐