云计算百科
云计算领域专业知识百科平台

码道 · 寻艺——从零构建“非遗交互式对话平台“的完整实践

码道 · 寻艺——从零构建"非遗交互式对话平台"的完整实践

用一行行代码,在数字世界里为千年技艺留一盏灯。
—— 记"寻艺 · 非遗交互式对话平台"的诞生


仓库地址:https://gitcode.com/Zch070214/FEIYI
在这里插入图片描述

码道代码生成
在这里插入图片描述

一、写在前面:我们为什么要做这件事

中国非物质文化遗产浩如烟海。据文化和旅游部公布的数据,我国已有国家级非物质文化遗产代表性项目一千五百余项,涵盖民间文学、传统音乐、传统舞蹈、传统戏剧、曲艺、传统美术、传统技艺、传统医药、民俗等十大类别。然而在快节奏的现代生活中,这些承载着民族记忆的技艺,正面临着传承人老龄化、年轻人认知断层、传播渠道单一的困境。

"非遗"这个词,很多人听过,但说不出三样具体的项目;"苏绣"很美,但多数人不知道一根丝线可以劈成八十八分之一;“梁祝传说"人人耳熟能详,却少有人了解它在民间文学谱系中的位置。信息时代的悖论在于:知识前所未有的丰裕,注意力却前所未有地稀缺。非遗要"活"下去,首先要"被看见”。

而 AI 对话恰恰提供了这样一个入口:它天然是一个扁平的、无门槛的、一对一的交互界面。把 AI 塑造成一位"非遗守护人",让用户像和朋友聊天一样,问一个故事、听一段历史、学一门技艺——这正是本项目 “寻艺” 的初心:在码道之上,寻千年之艺。

二、项目定位与技术选型

2.1 定位:一个纯粹的、轻量的单页应用

在设计之初,我们明确了几个原则:

  • 零门槛使用:无需安装、无需注册、打开即用;
  • 零构建成本:不使用 React/Vue 等框架,不引入打包工具,保持项目极简,让人人都能读懂和维护;
  • 开箱即用的智能:接入云端大模型 API,让"非遗守护人"具备真正的知识广度和对话温度;
  • 结构化呈现:不满足于"打字机式"的纯文本回复,而是让 AI 返回结构化数据,前端渲染出卡片、按钮等丰富的交互形态。

最终技术栈定格为:HTML5 + CSS3 + 原生 JavaScript(ES6+)+ 云端 AI API。这份简单背后,是对"少即是多"的坚持——一个完整的对话产品,用三份 JS、一份 CSS 和一份 HTML 就能承载。

2.2 为什么是"流式"对话

传统的一次性请求(请求发出、等几秒、整段返回)存在两个体验问题:一是等待时间感知过长,二是无法看到"思考的过程",会让用户怀疑系统是否卡死。流式(Streaming)响应则像人在说话:一个字一个字地"打字"出来,先给用户即时的反馈,再逐步充实内容。

这在心理学上叫"感知性能优化"——用户体验到的响应时间,取决于第一帧内容的到达速度,而非全部内容的完成时间。因此本项目核心采用的正是 SSE(Server-Sent Events)协议的流式对话,这一点也决定了后续 JavaScript 改造方案的形态。

三、核心实现:从 Python 参考代码到 JavaScript

3.1 参考实现:一次"翻译"之旅

项目最初提供了一份 Python 参考代码,用于演示 API 的标准调用方式。其核心是一个生成器:

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]":
return
yield json.loads(line.decode("utf-8").lstrip("data:"))

这段代码短小精悍,却浓缩了流式对话的完整骨架:发起流式 POST 请求 → 逐行读取 → 过滤 data: 前缀 → 遇到 [DONE] 终止 → 逐条产出 JSON。而我们要做的,是把这套逻辑完整地移植到浏览器环境。浏览器没有 requests,却有同样强大的 fetch 与 ReadableStream。

3.2 JavaScript 改写:fetch + ReadableStream + TextDecoder

在浏览器端,fetch 的 response.body 返回一个 ReadableStream,我们可以通过 reader.read() 循环读取二进制块。这里有一个关键细节:网络数据是按块到达的,一个 JSON 对象可能被拆散在多个数据块里,甚至一个数据块里可能包含多行 SSE 数据。因此必须引入一个"缓冲区"(buffer)策略:

const reader = response.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 line of lines) {
if (!line.trim().startsWith("data:")) continue;
const data = line.trim().slice(5).trim();
if (data === "[DONE]") { /* 结束 */ }
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content ?? "";
if (delta) onDelta(delta);
}
}

这里有三处容易被忽视的细节:

  • decoder.decode(value, { stream: true }):这个 stream: true 参数至关重要。它告诉解码器"数据还没结束,请处理可能被截断的多字节字符(如中文的 UTF-8 编码)",避免出现乱码。
  • 末行回填:buffer = lines.pop() 把最后一段"不完整的行"保留下来,与下一批数据拼接。这是流式解析半包数据的标准做法。
  • 增量取数:每个 chunk 的 choices[0].delta.content 是增量文本(而非全量),必须累积拼接才能得到完整回复。
  • 这一步"翻译",完成了从 requests 生态到 Web 平台的平滑迁移。

    3.3 思考预算:thinking_budget 与推理内容

    参考代码的 payload 里还包含一个特别的参数:thinking_budget: 2048。这类参数指示模型在回答前先进行一段"内部思考"(reasoning)。在返回的流中,思考过程会出现在 delta.reasoning_content 字段,而正式回答出现在 delta.content。

    我们在前端对二者做了差异化处理:思考到达时,气泡显示"寻艺正在查阅古籍,梳理脉络……“的占位状态,营造"守护人正在沉吟"的仪式感;一旦正式内容开始输出,立即切换到流式打字效果。这样既保留了模型的思考质量,又不会让用户面对一大段无用的"内心独白”。

    四、系统提示词:让 AI 输出程序认识的 JSON

    4.1 为什么要求结构化输出

    如果放任大模型自由发挥,回复可能是任意形态的文本:可能是散文、可能是列表、可能带 Markdown,也可能什么格式都没有。前端想在此基础上渲染"追问按钮"或"非遗卡片",就只能靠正则去猜,极易出错。

    更好的做法是用系统提示词约定输出协议:告诉 AI"你必须只输出一个合法 JSON 对象",并给出明确的字段定义。这样 AI 的"说话"就和程序的"理解"对齐了——这正是当前大模型应用工程中的核心方法论:用自然语言定义接口契约(LLM-native API design)。

    4.2 我们的 JSON 契约

    我们最终定义的响应协议包含三个字段:

    {
    "reply": "回复正文,可含 Markdown,约 90~260 字,信息密度高",
    "suggestions": ["追问示例1", "追问示例2", "追问示例3"],
    "related": [{"name": "苏绣", "type": "传统美术", "region": "江苏苏州", "desc": "一句话简介"}]
    }

    • reply:真正的对话正文,允许适度使用 Markdown(加粗、列表、引用),前端渲染为富文本;
    • suggestions:恰好三条推荐追问。它承担"对话引导"的职责——AI 不仅要回答问题,还要把对话引向深入。点击追问即可继续对话,形成对话闭环;
    • related:涉及具体非遗项目时输出的"知识卡片"。包含项目名称、所属门类、地区与简介,前端渲染为卡片墙,把零散的回复沉淀为可浏览的知识单元。

    4.3 角色设定的写作技巧

    系统提示词的角色塑造,我们刻意融入了"人味":不要官方辞令式的"本助手为您解答",而是"你知识渊博而温和,说话如一位讲书人,亲切、有画面感、有温度";要求"善于用色彩、声音、触感构建画面";甚至规定了"每次回复尽量传达一点新知识或新视角"。同时我们也划了红线:“不编造非遗项目和传承人信息;不确定时坦诚说明,并给出查阅方向”。

    实测效果令人惊喜:模型回答"苏绣的起源"时,会讲述浣纱女西施"以血染线绣芙蓉"的传说;回答"梁祝"时,会聚焦"十八相送"这个被常被略过的动人片段。好的角色设定,是把模型的"知识量"转化为"感染力"的关键。

    五、前端增量解析:无损地从流中"捞出" JSON

    5.1 难点:流式与 JSON 的天然冲突

    流式响应是"边生成边传输"的,因此任意时刻我们手里的文本都是不完整的 JSON 片段。如果等全部传输完毕再解析,就丧失了流式即时渲染的意义;如果急于解析,又会被"半个对象"卡住。我们的目标是:一旦 JSON 在大括号层面闭合、可以被完整解析,就立即渲染,一秒也不用等。

    5.2 方案:字符串感知的大括号配对扫描

    最初的写法是 text.lastIndexOf("{") 拿最后一个左花括号作为起点去解析——但这在带缩进格式的多层 JSON 前会失效:related 数组里嵌套对象自身的 { 才是最后一个花括号,从它开始的子串根本不是合法 JSON(见下图示意)。

    {
    "reply": "…",
    "suggestions": […],
    "related": [ { "name": …, … } ] ← 最后一个 { 在这里,从它切片必失败
    }

    正确的解法是从头扫描、状态机式地统计花括号配对,同时跳过字符串字面量内的花括号:

    function extractJsonObj(text) {
    const start = text.indexOf("{");
    if (start < 0) return null;
    let depth = 0, inStr = false, escaped = false;
    for (let i = start; i < text.length; i++) {
    const ch = text[i];
    if (inStr) {
    if (escaped) escaped = false;
    else if (ch === "\\\\") escaped = true;
    else if (ch === '"') inStr = false;
    continue;
    }
    if (ch === '"') { inStr = true; continue; }
    if (ch === "{") depth++;
    else if (ch === "}") {
    depth;
    if (depth === 0) return JSON.parse(text.slice(start, i + 1));
    }
    }
    return null; // 尚未闭合
    }

    这个函数有三层保险:字符串内的大括号不计入配对(防止回复正文里的 {} 干扰);闭合到顶层即尝试解析(保证效率);解析失败返回 null 而非抛错(保证流式过程零中断)。它同时被用于流式过程中的"增量尝试"和流结束后的"最终兜底",一处实现、两处复用。

    5.3 渲染优先级的设计

    在流式过程中我们遵循这样的渲染策略:

  • 收到首段增量 → 清除"思考占位",开始展示正在生成的文本(截断预览);
  • 每次增量到达 → 用配对扫描尝试解析;一旦命中完整 JSON → 立即渲染最终形态:富文本 + 追问按钮 + 非遗卡片;
  • 渲染完成后标记 rendered,后续增量不再重复渲染(因为完整对象闭合后,剩余内容基本不会再有)——避免抖动和一帧多画。
  • 这套策略在保持"即时感"的同时,把最终呈现切换得干净利落。实测中,模型约在数秒内完成整段输出,用户几乎无感知地看到"打字 → 卡片浮现"的流畅过渡。

    六、富文本渲染与 XSS 防护

    6.1 自研轻量 Markdown 渲染器

    为保持零依赖,我们没有引入 marked / markdown-it 等库,而是手写了一个约四十行的轻量渲染器,覆盖对话场景最常用的语法:行内代码、加粗、> 引用、####/### 标题、- 无序列表、段落分隔。渲染顺序经过精心设计:先做 HTML 转义,再做模式替换,并且注意"列表项先分组为 ul、再组段落"的顺序,避免嵌套错乱。

    6.2 安全是硬底线

    AI 生成的内容是不可信的第三方输入。如果直接把模型的输出拼进 innerHTML,一旦模型被诱导输出 <script> 或 <img onerror> 之类的载荷,就会造成存储型 XSS。因此我们坚持:

  • 所有渲染路径先经过 escapeHtml(),把 < > & " 转义为实体;
  • 再在转义后的文本上应用 Markdown 替换规则(此时替换产物是受控的标签,注入的原始标签早已失效);
  • 追问按钮、卡片文本一律通过 textContent 写入,绝不直接插入 HTML。
  • “先转义、后富化” 是安全富文本渲染的黄金次序,任何直接对模型输出做 .replace 注入标签再赋给 innerHTML 的做法都应视为高危。

    七、体验细节:那些让产品"有温度"的小事

    一个产品的质感,往往藏在不被注意的细节里。我们在这一版里打磨了这些点:

    • Enter 发送、Shift+Enter 换行:遵循即时通讯软件的心智模型,长文本需求交给换行键;
    • Textarea 自适应高度:输入框随内容增长,最高 140px,超过后内部滚动,避免遮挡对话区;
    • 快捷追问条:最近一次的结构化回复中的追问,会常驻输入框上方,形成"电梯按钮"式的引导,用户可以连续追问而无需打字;
    • 两个发际线:AI 侧用黛蓝印章"寻",用户侧用朱砂印章"艺",一冷一暖,呼应"守护人"与"探艺人"的身份对应;
    • 本地会话记忆:对话历史持久化到 localStorage,刷新页面后原样恢复;"新对话"按钮一键清空重建;
    • 打字机与思考占位:按钮禁用、占位文案切换、"阅读古籍"的耐心提示,避免用户在等待中的焦虑;
    • 流式错误兜底:遇网络错误弹出 toast 并保留现场;极端情况下模型未按约返回 JSON 时,自动退化为纯文本渲染,产品绝不"白屏"。

    所谓匠心,不过是把这些几像素级的工作一一做完。

    八、国风视觉:让界面本身成为"非遗作品"

    8.1 色彩取自古籍

    整套视觉体系建立在宣纸与墨色的意象上:米白宣纸底色 #f7f1e5,配以墨色正文 #2b2520 与次级棕灰文字;点缀色选用了传统色中的朱砂红(#a63a2b)与黛蓝(#33465f),鎏金(#b98a2f)仅用于徽标、引用等少量点缀。三种色相的饱和度都经过压制,保持"素雅"的文人气质,也保证长时间的阅读舒适。

    8.2 字体师承宋明

    正文采用无衬线的现代中文(苹方、雅黑),而标题与印章则回落到 Songti / SimSun / Noto Serif SC 等宋体字族——宋体承载着刻书与版画的千年记忆,恰与项目气质相合。页头的"寻"字印章,用朱砂渐变打底、浮雕描边,是整套视觉的锚点。

    8.3 细节里的东方意象

    • 欢迎页标题下的鎏金下划线,用 SVG data-URI 绘制了一道"飞白"笔意;
    • 消息气泡默认尖角朝上,AI 消息直角微圆、用户消息朱砂渐变,符合"宣纸对话"的隐喻;
    • 卡片、徽章、状态灯均采用克制而统一的圆角系统,整套界面无一张外部图片——全部装饰由 CSS 与内联 SVG 完成,轻量且快。

    我们希望用户在打开页面的第一秒,就能从视觉上相信:这是一位"讲书人",而不是一台冰冷的问答机器。

    九、质量验证:不止于"能跑"

    在交付之前,我们做了两层自动化验证。

    9.1 真实 API 自测脚本

    scripts/test_api.js 以 Node 环境完整走通:发起流式请求 → 累积增量 → 验证 JSON 可解析 → 校验 reply / suggestions / related 字段的完整性。在本地实测中,模型顺利返回了符合契约的结构:

    {
    "reply": "讲到非遗,我想起**梁祝传说**(民间文学·浙江绍兴上虞)……",
    "suggestions": ["梁祝在哪些地方流传最广?", "越剧《梁祝》和原传说有什么不同?", "还有哪些非遗里藏着爱情传说?"],
    "related": [{ "name": "梁祝传说", "type": "民间文学", "region": "浙江杭州、宁波鄞州、绍兴上虞等", "desc": "中国古代四大民间传说之一……" }]
    }

    9.2 浏览器级端到端验证

    scripts/verify_page.js 基于 Puppeteer 在无头 Chromium 中完成端到端回归:加载页面 → 断言欢迎页与四张推荐卡 → 点击推荐卡触发真实对话 → 等待流式返回 → 断言用户消息与 AI 气泡出现 → 断言追问按钮(≥3)与非遗卡片(≥1)渲染成功 → 断言发送按钮恢复可用。这条链路把"界面 → 调用 → 解析 → 渲染"全部串起来,防止任何一环悄悄退化。

    两份脚本连同用法一并写入了 README,让项目"可被测",而不只是"能运行"。

    十、过程中的踩坑复盘

    开发并非一帆风顺,这些坑值得记录:

  • lastIndexOf 解析缩进 JSON 失败:已在第五章详述,最终以状态机配对扫描解决——避免"用正则解析结构"的典型陷阱。
  • js 测试沙箱中顶层 const 不可见于全局对象:在 Node 的 vm 沙箱验证时,发现用 vm.runInContext 执行脚本后,顶层 const AI_SYSTEM_PROMPT 不会挂载到沙箱全局对象,导致发给 API 的 system 消息 content 为 undefined,请求 400。这不是浏览器的问题(浏览器多个 <script> 共享词法环境),但提醒我们:跨环境复用时,显式导出(globalThis.__EXPORT)永远是更稳的做法。
  • 多字节字符半包:中文在 UTF-8 下占 3 字节,网络分包可能把字符"劈"成两半,若直接按块解码会乱码。必须配合 decoder.decode(value, { stream: true })。
  • 模型偶尔不守契约:即便提示词写明"只输出 JSON",极端情况仍有非 JSON 输出。因此兜底逻辑必须存在:JSON 解析失败时退化为普通文本渲染,保证用户永远有所见。
  • favicon 404:页面验证时捕获到浏览器自动请求 /favicon.ico 返回 404 的报错,最终以内联 SVG data-URI 图标根除——顺手消灭每一个控制台红字,是对品质的洁癖。
  • 十一、成果与展望

    目前"寻艺"已完成第一个可交付版本,并通过真实环境验证。用户可以在欢迎页一键开启四类旅程:听传承故事、按推荐寻艺、学技艺入门、参与守护行动。

    回望这段开发,我们更愿意把它看作一个"方法论样本":如何用极简的技术栈,搭一个有温度的 AI 产品。 项目未来的想象空间还有很多:

    • 对话图谱化:把用户探索过的非遗项目沉淀为一张可视化知识图谱;
    • 多模态寻艺:引入图片生成,让"苏绣针法""皮影雕刻"以图说话;
    • 传承人连麦:接入语音,让用户"听"到传承人的口述史;
    • 开放 API 化:将"非遗守护人"的能力封装为可嵌入的对话插件,助力文旅场景。

    十二、结语

    非遗传承的本质,是一场跨越时空的对话:古人把技艺交给今人,今人再把它讲给机器,机器又将这份记忆讲给下一代。在这场对话里,AI 不该是替代者,而应该是摆渡人。

    "寻艺"是一个很小的项目,小到只有几份文件;它也很重,重到承载着让年轻人"被非遗打动一次"的愿望。我们走在码道上,写下代码;也走在文化的长路上,捡起技艺。愿每一位打开这个页面的朋友,都能遇到那位持朱砂印的守护人,问一句:“江湖这么远,你从哪里来?”

    项目地址:https://gitcode.com/Zch070214/FEIYI

    最后,借一句在民间广为流传的老话作结——“手艺人,守艺人也。” 在码道上,愿我们都能成为新一代的守艺人。

    附录一:三分钟跑起来

    拿到代码之后,最快的方式有两种。

    方式一:直接打开。 使用最新版的 Chrome、Edge 或 Firefox 浏览器,双击 index.html 即可。页面会自动携带密钥向云端 API 发起请求,适合快速体验。需要注意的是,部分浏览器对 file:// 协议下的网络请求有更严格的限制,若遇到跨域或请求失败,建议改用本地服务器方式。

    方式二:本地服务器。 项目附带了一个四十行左右的零依赖静态服务器脚本:

    node scripts/serve.js

    然后访问 http://127.0.0.1:8642。本地服务器方式最接近生产环境,也便于调试。将该目录整体上传到任意静态托管平台(如 GitCode Pages、Nginx、对象存储)同样可以直接运行,因为本项目没有后端、没有构建步骤,全仓库都是纯静态产物。

    附录二:配置与替换

    所有配置都收拢在 js/config.js 一个文件里,按需修改即可:

    • API_URL:默认指向 GitCode AI 的聊天补全接口,若切换到其他兼容 OpenAI 风格的网关,只需替换此地址;
    • API_KEY:替换为你自己的令牌;
    • MODEL:默认 deepseek-ai/DeepSeek-V4-Flash;
    • MAX_TOKENS、TEMPERATURE、TOP_P、FREQUENCY_PENALTY 与 THINKING_BUDGET:生成参数,与参考代码一一对应。

    替换完成后,Ctrl + F5 强制刷新即可生效。若希望更换角色设定,直接修改 AI_SYSTEM_PROMPT 字符串——把"非遗守护人"换成"茶文化导师"“古建讲解员”,前端无需任何改动,因为渲染契约是通用的。

    附录三:常见问题

    问:为什么回复有时是纯文本,没有卡片? 答:系统提示词规定,仅当回复确实涉及具体非遗项目时才输出 related 卡片;寒暄类、科普类回复会返回空数组。这是刻意的设计,避免"硬塞卡片"破坏对话自然感。若你希望任何回复都带卡片调整字段即可。

    问:密钥写在浏览器里安全吗? 答:纯前端方案下密钥对用户可见,这是取舍而非疏漏。面向公众的大规模部署,建议把请求放到后端代理,由服务端持有密钥并做频控、鉴权;个人自用或演示场景,直接暴露是可接受的。

    问:为什么不用 Vue 或 React? 答:本项目刻意保持原生实现,为的是"每一行都可读、每一处都可改",同时把运行时体积压缩到极致。对于单页对话应用,原生 DOM 操作完全够用;若未来交互复杂度上升,迁移到框架时,api.js 与 config.js 的职责边界也能无缝复用。

    问:多轮对话的上下文是怎么维护的? 答:前端维护一个 messages 数组,system 提示词恒在首位,其后按序追加用户与助手的消息,超出二十条时自动丢弃最旧的轮次,避免上下文超长。每次发送时把整个数组随请求带过去,模型据此理解对话脉络。

    问:模型偶尔不按 JSON 返回怎么办? 答:这是所有"LLM 即后端"应用的常态风险。我们的策略是"提示词约束 + 增量解析 + 双重重试 + 纯文本兜底"四道防线:先尽量让模型按契约输出;流式途中实时尝试解析;结束后若仍失败,则用宽松模式抓取任意完整对象;连对象都抓不到时,把原始文本当作普通回复渲染。用户在任何情况下都不会面对空白页面。


    本文由"寻艺 · 非遗交互式对话平台"项目开发过程整理而成。# 码道 · 寻艺——从零构建"非遗交互式对话平台"的完整实践

    用一行行代码,在数字世界里为千年技艺留一盏灯。
    —— 记"寻艺 · 非遗交互式对话平台"的诞生


    一、写在前面:我们为什么要做这件事

    中国非物质文化遗产浩如烟海。据文化和旅游部公布的数据,我国已有国家级非物质文化遗产代表性项目一千五百余项,涵盖民间文学、传统音乐、传统舞蹈、传统戏剧、曲艺、传统美术、传统技艺、传统医药、民俗等十大类别。然而在快节奏的现代生活中,这些承载着民族记忆的技艺,正面临着传承人老龄化、年轻人认知断层、传播渠道单一的困境。

    "非遗"这个词,很多人听过,但说不出三样具体的项目;"苏绣"很美,但多数人不知道一根丝线可以劈成八十八分之一;“梁祝传说"人人耳熟能详,却少有人了解它在民间文学谱系中的位置。信息时代的悖论在于:知识前所未有的丰裕,注意力却前所未有地稀缺。非遗要"活"下去,首先要"被看见”。

    而 AI 对话恰恰提供了这样一个入口:它天然是一个扁平的、无门槛的、一对一的交互界面。把 AI 塑造成一位"非遗守护人",让用户像和朋友聊天一样,问一个故事、听一段历史、学一门技艺——这正是本项目 “寻艺” 的初心:在码道之上,寻千年之艺。

    二、项目定位与技术选型

    2.1 定位:一个纯粹的、轻量的单页应用

    在设计之初,我们明确了几个原则:

    • 零门槛使用:无需安装、无需注册、打开即用;
    • 零构建成本:不使用 React/Vue 等框架,不引入打包工具,保持项目极简,让人人都能读懂和维护;
    • 开箱即用的智能:接入云端大模型 API,让"非遗守护人"具备真正的知识广度和对话温度;
    • 结构化呈现:不满足于"打字机式"的纯文本回复,而是让 AI 返回结构化数据,前端渲染出卡片、按钮等丰富的交互形态。

    最终技术栈定格为:HTML5 + CSS3 + 原生 JavaScript(ES6+)+ 云端 AI API。这份简单背后,是对"少即是多"的坚持——一个完整的对话产品,用三份 JS、一份 CSS 和一份 HTML 就能承载。

    2.2 为什么是"流式"对话

    传统的一次性请求(请求发出、等几秒、整段返回)存在两个体验问题:一是等待时间感知过长,二是无法看到"思考的过程",会让用户怀疑系统是否卡死。流式(Streaming)响应则像人在说话:一个字一个字地"打字"出来,先给用户即时的反馈,再逐步充实内容。

    这在心理学上叫"感知性能优化"——用户体验到的响应时间,取决于第一帧内容的到达速度,而非全部内容的完成时间。因此本项目核心采用的正是 SSE(Server-Sent Events)协议的流式对话,这一点也决定了后续 JavaScript 改造方案的形态。

    三、核心实现:从 Python 参考代码到 JavaScript

    3.1 参考实现:一次"翻译"之旅

    项目最初提供了一份 Python 参考代码,用于演示 API 的标准调用方式。其核心是一个生成器:

    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]":
    return
    yield json.loads(line.decode("utf-8").lstrip("data:"))

    这段代码短小精悍,却浓缩了流式对话的完整骨架:发起流式 POST 请求 → 逐行读取 → 过滤 data: 前缀 → 遇到 [DONE] 终止 → 逐条产出 JSON。而我们要做的,是把这套逻辑完整地移植到浏览器环境。浏览器没有 requests,却有同样强大的 fetch 与 ReadableStream。

    3.2 JavaScript 改写:fetch + ReadableStream + TextDecoder

    在浏览器端,fetch 的 response.body 返回一个 ReadableStream,我们可以通过 reader.read() 循环读取二进制块。这里有一个关键细节:网络数据是按块到达的,一个 JSON 对象可能被拆散在多个数据块里,甚至一个数据块里可能包含多行 SSE 数据。因此必须引入一个"缓冲区"(buffer)策略:

    const reader = response.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 line of lines) {
    if (!line.trim().startsWith("data:")) continue;
    const data = line.trim().slice(5).trim();
    if (data === "[DONE]") { /* 结束 */ }
    const chunk = JSON.parse(data);
    const delta = chunk.choices?.[0]?.delta?.content ?? "";
    if (delta) onDelta(delta);
    }
    }

    这里有三处容易被忽视的细节:

  • decoder.decode(value, { stream: true }):这个 stream: true 参数至关重要。它告诉解码器"数据还没结束,请处理可能被截断的多字节字符(如中文的 UTF-8 编码)",避免出现乱码。
  • 末行回填:buffer = lines.pop() 把最后一段"不完整的行"保留下来,与下一批数据拼接。这是流式解析半包数据的标准做法。
  • 增量取数:每个 chunk 的 choices[0].delta.content 是增量文本(而非全量),必须累积拼接才能得到完整回复。
  • 这一步"翻译",完成了从 requests 生态到 Web 平台的平滑迁移。

    3.3 思考预算:thinking_budget 与推理内容

    参考代码的 payload 里还包含一个特别的参数:thinking_budget: 2048。这类参数指示模型在回答前先进行一段"内部思考"(reasoning)。在返回的流中,思考过程会出现在 delta.reasoning_content 字段,而正式回答出现在 delta.content。

    我们在前端对二者做了差异化处理:思考到达时,气泡显示"寻艺正在查阅古籍,梳理脉络……“的占位状态,营造"守护人正在沉吟"的仪式感;一旦正式内容开始输出,立即切换到流式打字效果。这样既保留了模型的思考质量,又不会让用户面对一大段无用的"内心独白”。

    四、系统提示词:让 AI 输出程序认识的 JSON

    4.1 为什么要求结构化输出

    如果放任大模型自由发挥,回复可能是任意形态的文本:可能是散文、可能是列表、可能带 Markdown,也可能什么格式都没有。前端想在此基础上渲染"追问按钮"或"非遗卡片",就只能靠正则去猜,极易出错。

    更好的做法是用系统提示词约定输出协议:告诉 AI"你必须只输出一个合法 JSON 对象",并给出明确的字段定义。这样 AI 的"说话"就和程序的"理解"对齐了——这正是当前大模型应用工程中的核心方法论:用自然语言定义接口契约(LLM-native API design)。

    4.2 我们的 JSON 契约

    我们最终定义的响应协议包含三个字段:

    {
    "reply": "回复正文,可含 Markdown,约 90~260 字,信息密度高",
    "suggestions": ["追问示例1", "追问示例2", "追问示例3"],
    "related": [{"name": "苏绣", "type": "传统美术", "region": "江苏苏州", "desc": "一句话简介"}]
    }

    • reply:真正的对话正文,允许适度使用 Markdown(加粗、列表、引用),前端渲染为富文本;
    • suggestions:恰好三条推荐追问。它承担"对话引导"的职责——AI 不仅要回答问题,还要把对话引向深入。点击追问即可继续对话,形成对话闭环;
    • related:涉及具体非遗项目时输出的"知识卡片"。包含项目名称、所属门类、地区与简介,前端渲染为卡片墙,把零散的回复沉淀为可浏览的知识单元。

    4.3 角色设定的写作技巧

    系统提示词的角色塑造,我们刻意融入了"人味":不要官方辞令式的"本助手为您解答",而是"你知识渊博而温和,说话如一位讲书人,亲切、有画面感、有温度";要求"善于用色彩、声音、触感构建画面";甚至规定了"每次回复尽量传达一点新知识或新视角"。同时我们也划了红线:“不编造非遗项目和传承人信息;不确定时坦诚说明,并给出查阅方向”。

    实测效果令人惊喜:模型回答"苏绣的起源"时,会讲述浣纱女西施"以血染线绣芙蓉"的传说;回答"梁祝"时,会聚焦"十八相送"这个被常被略过的动人片段。好的角色设定,是把模型的"知识量"转化为"感染力"的关键。

    五、前端增量解析:无损地从流中"捞出" JSON

    5.1 难点:流式与 JSON 的天然冲突

    流式响应是"边生成边传输"的,因此任意时刻我们手里的文本都是不完整的 JSON 片段。如果等全部传输完毕再解析,就丧失了流式即时渲染的意义;如果急于解析,又会被"半个对象"卡住。我们的目标是:一旦 JSON 在大括号层面闭合、可以被完整解析,就立即渲染,一秒也不用等。

    5.2 方案:字符串感知的大括号配对扫描

    最初的写法是 text.lastIndexOf("{") 拿最后一个左花括号作为起点去解析——但这在带缩进格式的多层 JSON 前会失效:related 数组里嵌套对象自身的 { 才是最后一个花括号,从它开始的子串根本不是合法 JSON(见下图示意)。

    {
    "reply": "…",
    "suggestions": […],
    "related": [ { "name": …, … } ] ← 最后一个 { 在这里,从它切片必失败
    }

    正确的解法是从头扫描、状态机式地统计花括号配对,同时跳过字符串字面量内的花括号:

    function extractJsonObj(text) {
    const start = text.indexOf("{");
    if (start < 0) return null;
    let depth = 0, inStr = false, escaped = false;
    for (let i = start; i < text.length; i++) {
    const ch = text[i];
    if (inStr) {
    if (escaped) escaped = false;
    else if (ch === "\\\\") escaped = true;
    else if (ch === '"') inStr = false;
    continue;
    }
    if (ch === '"') { inStr = true; continue; }
    if (ch === "{") depth++;
    else if (ch === "}") {
    depth;
    if (depth === 0) return JSON.parse(text.slice(start, i + 1));
    }
    }
    return null; // 尚未闭合
    }

    这个函数有三层保险:字符串内的大括号不计入配对(防止回复正文里的 {} 干扰);闭合到顶层即尝试解析(保证效率);解析失败返回 null 而非抛错(保证流式过程零中断)。它同时被用于流式过程中的"增量尝试"和流结束后的"最终兜底",一处实现、两处复用。

    5.3 渲染优先级的设计

    在流式过程中我们遵循这样的渲染策略:

  • 收到首段增量 → 清除"思考占位",开始展示正在生成的文本(截断预览);
  • 每次增量到达 → 用配对扫描尝试解析;一旦命中完整 JSON → 立即渲染最终形态:富文本 + 追问按钮 + 非遗卡片;
  • 渲染完成后标记 rendered,后续增量不再重复渲染(因为完整对象闭合后,剩余内容基本不会再有)——避免抖动和一帧多画。
  • 这套策略在保持"即时感"的同时,把最终呈现切换得干净利落。实测中,模型约在数秒内完成整段输出,用户几乎无感知地看到"打字 → 卡片浮现"的流畅过渡。

    六、富文本渲染与 XSS 防护

    6.1 自研轻量 Markdown 渲染器

    为保持零依赖,我们没有引入 marked / markdown-it 等库,而是手写了一个约四十行的轻量渲染器,覆盖对话场景最常用的语法:行内代码、加粗、> 引用、####/### 标题、- 无序列表、段落分隔。渲染顺序经过精心设计:先做 HTML 转义,再做模式替换,并且注意"列表项先分组为 ul、再组段落"的顺序,避免嵌套错乱。

    6.2 安全是硬底线

    AI 生成的内容是不可信的第三方输入。如果直接把模型的输出拼进 innerHTML,一旦模型被诱导输出 <script> 或 <img onerror> 之类的载荷,就会造成存储型 XSS。因此我们坚持:

  • 所有渲染路径先经过 escapeHtml(),把 < > & " 转义为实体;
  • 再在转义后的文本上应用 Markdown 替换规则(此时替换产物是受控的标签,注入的原始标签早已失效);
  • 追问按钮、卡片文本一律通过 textContent 写入,绝不直接插入 HTML。
  • “先转义、后富化” 是安全富文本渲染的黄金次序,任何直接对模型输出做 .replace 注入标签再赋给 innerHTML 的做法都应视为高危。

    七、体验细节:那些让产品"有温度"的小事

    一个产品的质感,往往藏在不被注意的细节里。我们在这一版里打磨了这些点:

    • Enter 发送、Shift+Enter 换行:遵循即时通讯软件的心智模型,长文本需求交给换行键;
    • Textarea 自适应高度:输入框随内容增长,最高 140px,超过后内部滚动,避免遮挡对话区;
    • 快捷追问条:最近一次的结构化回复中的追问,会常驻输入框上方,形成"电梯按钮"式的引导,用户可以连续追问而无需打字;
    • 两个发际线:AI 侧用黛蓝印章"寻",用户侧用朱砂印章"艺",一冷一暖,呼应"守护人"与"探艺人"的身份对应;
    • 本地会话记忆:对话历史持久化到 localStorage,刷新页面后原样恢复;"新对话"按钮一键清空重建;
    • 打字机与思考占位:按钮禁用、占位文案切换、"阅读古籍"的耐心提示,避免用户在等待中的焦虑;
    • 流式错误兜底:遇网络错误弹出 toast 并保留现场;极端情况下模型未按约返回 JSON 时,自动退化为纯文本渲染,产品绝不"白屏"。

    所谓匠心,不过是把这些几像素级的工作一一做完。

    八、国风视觉:让界面本身成为"非遗作品"

    8.1 色彩取自古籍

    整套视觉体系建立在宣纸与墨色的意象上:米白宣纸底色 #f7f1e5,配以墨色正文 #2b2520 与次级棕灰文字;点缀色选用了传统色中的朱砂红(#a63a2b)与黛蓝(#33465f),鎏金(#b98a2f)仅用于徽标、引用等少量点缀。三种色相的饱和度都经过压制,保持"素雅"的文人气质,也保证长时间的阅读舒适。

    8.2 字体师承宋明

    正文采用无衬线的现代中文(苹方、雅黑),而标题与印章则回落到 Songti / SimSun / Noto Serif SC 等宋体字族——宋体承载着刻书与版画的千年记忆,恰与项目气质相合。页头的"寻"字印章,用朱砂渐变打底、浮雕描边,是整套视觉的锚点。

    8.3 细节里的东方意象

    • 欢迎页标题下的鎏金下划线,用 SVG data-URI 绘制了一道"飞白"笔意;
    • 消息气泡默认尖角朝上,AI 消息直角微圆、用户消息朱砂渐变,符合"宣纸对话"的隐喻;
    • 卡片、徽章、状态灯均采用克制而统一的圆角系统,整套界面无一张外部图片——全部装饰由 CSS 与内联 SVG 完成,轻量且快。

    我们希望用户在打开页面的第一秒,就能从视觉上相信:这是一位"讲书人",而不是一台冰冷的问答机器。

    九、质量验证:不止于"能跑"

    在交付之前,我们做了两层自动化验证。

    9.1 真实 API 自测脚本

    scripts/test_api.js 以 Node 环境完整走通:发起流式请求 → 累积增量 → 验证 JSON 可解析 → 校验 reply / suggestions / related 字段的完整性。在本地实测中,模型顺利返回了符合契约的结构:

    {
    "reply": "讲到非遗,我想起**梁祝传说**(民间文学·浙江绍兴上虞)……",
    "suggestions": ["梁祝在哪些地方流传最广?", "越剧《梁祝》和原传说有什么不同?", "还有哪些非遗里藏着爱情传说?"],
    "related": [{ "name": "梁祝传说", "type": "民间文学", "region": "浙江杭州、宁波鄞州、绍兴上虞等", "desc": "中国古代四大民间传说之一……" }]
    }

    9.2 浏览器级端到端验证

    scripts/verify_page.js 基于 Puppeteer 在无头 Chromium 中完成端到端回归:加载页面 → 断言欢迎页与四张推荐卡 → 点击推荐卡触发真实对话 → 等待流式返回 → 断言用户消息与 AI 气泡出现 → 断言追问按钮(≥3)与非遗卡片(≥1)渲染成功 → 断言发送按钮恢复可用。这条链路把"界面 → 调用 → 解析 → 渲染"全部串起来,防止任何一环悄悄退化。

    两份脚本连同用法一并写入了 README,让项目"可被测",而不只是"能运行"。

    十、过程中的踩坑复盘

    开发并非一帆风顺,这些坑值得记录:

  • lastIndexOf 解析缩进 JSON 失败:已在第五章详述,最终以状态机配对扫描解决——避免"用正则解析结构"的典型陷阱。
  • js 测试沙箱中顶层 const 不可见于全局对象:在 Node 的 vm 沙箱验证时,发现用 vm.runInContext 执行脚本后,顶层 const AI_SYSTEM_PROMPT 不会挂载到沙箱全局对象,导致发给 API 的 system 消息 content 为 undefined,请求 400。这不是浏览器的问题(浏览器多个 <script> 共享词法环境),但提醒我们:跨环境复用时,显式导出(globalThis.__EXPORT)永远是更稳的做法。
  • 多字节字符半包:中文在 UTF-8 下占 3 字节,网络分包可能把字符"劈"成两半,若直接按块解码会乱码。必须配合 decoder.decode(value, { stream: true })。
  • 模型偶尔不守契约:即便提示词写明"只输出 JSON",极端情况仍有非 JSON 输出。因此兜底逻辑必须存在:JSON 解析失败时退化为普通文本渲染,保证用户永远有所见。
  • favicon 404:页面验证时捕获到浏览器自动请求 /favicon.ico 返回 404 的报错,最终以内联 SVG data-URI 图标根除——顺手消灭每一个控制台红字,是对品质的洁癖。
  • 十一、成果与展望

    目前"寻艺"已完成第一个可交付版本,并通过真实环境验证。用户可以在欢迎页一键开启四类旅程:听传承故事、按推荐寻艺、学技艺入门、参与守护行动。

    回望这段开发,我们更愿意把它看作一个"方法论样本":如何用极简的技术栈,搭一个有温度的 AI 产品。 项目未来的想象空间还有很多:

    • 对话图谱化:把用户探索过的非遗项目沉淀为一张可视化知识图谱;
    • 多模态寻艺:引入图片生成,让"苏绣针法""皮影雕刻"以图说话;
    • 传承人连麦:接入语音,让用户"听"到传承人的口述史;
    • 开放 API 化:将"非遗守护人"的能力封装为可嵌入的对话插件,助力文旅场景。

    十二、结语

    非遗传承的本质,是一场跨越时空的对话:古人把技艺交给今人,今人再把它讲给机器,机器又将这份记忆讲给下一代。在这场对话里,AI 不该是替代者,而应该是摆渡人。

    "寻艺"是一个很小的项目,小到只有几份文件;它也很重,重到承载着让年轻人"被非遗打动一次"的愿望。我们走在码道上,写下代码;也走在文化的长路上,捡起技艺。愿每一位打开这个页面的朋友,都能遇到那位持朱砂印的守护人,问一句:“江湖这么远,你从哪里来?”

    项目地址:https://gitcode.com/Zch070214/FEIYI

    最后,借一句在民间广为流传的老话作结——“手艺人,守艺人也。” 在码道上,愿我们都能成为新一代的守艺人。

    附录一:三分钟跑起来

    拿到代码之后,最快的方式有两种。

    方式一:直接打开。 使用最新版的 Chrome、Edge 或 Firefox 浏览器,双击 index.html 即可。页面会自动携带密钥向云端 API 发起请求,适合快速体验。需要注意的是,部分浏览器对 file:// 协议下的网络请求有更严格的限制,若遇到跨域或请求失败,建议改用本地服务器方式。

    方式二:本地服务器。 项目附带了一个四十行左右的零依赖静态服务器脚本:

    node scripts/serve.js

    然后访问 http://127.0.0.1:8642。本地服务器方式最接近生产环境,也便于调试。将该目录整体上传到任意静态托管平台(如 GitCode Pages、Nginx、对象存储)同样可以直接运行,因为本项目没有后端、没有构建步骤,全仓库都是纯静态产物。

    附录二:配置与替换

    所有配置都收拢在 js/config.js 一个文件里,按需修改即可:

    • API_URL:默认指向 GitCode AI 的聊天补全接口,若切换到其他兼容 OpenAI 风格的网关,只需替换此地址;
    • API_KEY:替换为你自己的令牌;
    • MODEL:默认 deepseek-ai/DeepSeek-V4-Flash;
    • MAX_TOKENS、TEMPERATURE、TOP_P、FREQUENCY_PENALTY 与 THINKING_BUDGET:生成参数,与参考代码一一对应。

    替换完成后,Ctrl + F5 强制刷新即可生效。若希望更换角色设定,直接修改 AI_SYSTEM_PROMPT 字符串——把"非遗守护人"换成"茶文化导师"“古建讲解员”,前端无需任何改动,因为渲染契约是通用的。

    附录三:常见问题

    问:为什么回复有时是纯文本,没有卡片? 答:系统提示词规定,仅当回复确实涉及具体非遗项目时才输出 related 卡片;寒暄类、科普类回复会返回空数组。这是刻意的设计,避免"硬塞卡片"破坏对话自然感。若你希望任何回复都带卡片调整字段即可。

    问:密钥写在浏览器里安全吗? 答:纯前端方案下密钥对用户可见,这是取舍而非疏漏。面向公众的大规模部署,建议把请求放到后端代理,由服务端持有密钥并做频控、鉴权;个人自用或演示场景,直接暴露是可接受的。

    问:为什么不用 Vue 或 React? 答:本项目刻意保持原生实现,为的是"每一行都可读、每一处都可改",同时把运行时体积压缩到极致。对于单页对话应用,原生 DOM 操作完全够用;若未来交互复杂度上升,迁移到框架时,api.js 与 config.js 的职责边界也能无缝复用。

    问:多轮对话的上下文是怎么维护的? 答:前端维护一个 messages 数组,system 提示词恒在首位,其后按序追加用户与助手的消息,超出二十条时自动丢弃最旧的轮次,避免上下文超长。每次发送时把整个数组随请求带过去,模型据此理解对话脉络。

    问:模型偶尔不按 JSON 返回怎么办? 答:这是所有"LLM 即后端"应用的常态风险。我们的策略是"提示词约束 + 增量解析 + 双重重试 + 纯文本兜底"四道防线:先尽量让模型按契约输出;流式途中实时尝试解析;结束后若仍失败,则用宽松模式抓取任意完整对象;连对象都抓不到时,把原始文本当作普通回复渲染。用户在任何情况下都不会面对空白页面。


    本文由"寻艺 · 非遗交互式对话平台"项目开发过程整理而成。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 码道 · 寻艺——从零构建“非遗交互式对话平台“的完整实践
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!