写自己的AI聊天伴侣页面,这事听起来有点标题党,但真做起来也就是一个HTML文件的事。我之前老觉得现在写前端,动不动就要脚手架、依赖、构建,明明只想验证一个聊天交互,结果光装环境就花掉半小时。后来被逼急了,给自己定了三条硬规矩:纯前端、零依赖、单文件,所有代码塞进一个HTML里,双击浏览器打开就能聊。实测下来体验反而极好——没有构建步骤,没有环境变量,没有跨语言调试,一个文件既是项目也是产物,发给别人就是一个文件的事。
这个方案特别适合想快速体验大模型接口的人,也适合做AI情感陪伴类小Demo的前端开发者。不需要你懂Node、Python或者部署,只要会一点HTML/CSS/JavaScript,再有一个能调用的大模型API,就能拼出一个可以语音、可以记住聊天历史、还能设定性格人设的AI角色页面。下面我把整个设计思路和实现过程拆开写一遍,尽量把能踩的坑都提前帮你踩了。
1. 项目设计思路:为什么要坚持零依赖和单文件
先解释一下“零依赖”到底指什么。不是不需要网络、不需要大模型API,而是说你不需要安装任何前端框架、UI库、打包工具和运行时。浏览器自带的fetch能发请求,原生CSS能做样式,DOM API能处理交互,Web Speech API还能负责语音识别与合成,这些能力加起来已经能覆盖一个完整聊天产品的绝大部分需求。所以结论是:做一个聊天伴侣页面,工程化并不是必需品。
单文件的核心优势是分发成本低。我试过把一个单HTML挂在任意静态托管上,或者在本地用python -m http.server跑起来,同一个文件,走到哪都能用。更关键的是排查问题简单——不会出现“本地是好的,打包后就坏了”这种环境差异。以前聊AI应用动不动就起个Vite项目,在别人电脑上还要先装Node再装依赖,这对一个只想看效果的Demo来说太重了。单文件的另一层好处是方便改:想换个性格设定,搜一下“system prompt”就能找到入口;想调样式,直接改CSS变量,不用翻半天的组件树。
当然,单文件也有代价,最明显的是代码组织。所有逻辑都放在一个<script>里,如果写得乱,很快就会变成一坨谁都不想看的代码。我的做法是把整个文件当成一个“微型单体应用”来设计:配置区独立放最前面,UI结构用一段HTML,样式用CSS自定义属性统一管理,脚本部分严格按功能拆成几段注释标记的模块。只要你愿意在注释上花点时间,单文件也可以保持清晰的边界。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单文件应用内的“架构”:页面结构、状态流与消息管理
2.1 页面骨架与样式策略
先画一个极简结构:顶部是AI角色信息,中间是滚动消息区,底部是输入框和发送按钮。这个结构几乎不用思考,但它能跑通主要是因为底部输入区要固定在视野内,中间内容区要可以自由滚动,所以我用了三块纵向Flex布局,把聊天主区域设为flex: 1; overflow-y: auto,这样消息多了也不会撑破页面。
视觉上建议做成手机聊天窗口的感觉会更讨喜。整个页面最大宽度控制在480px左右居中,背景用深色渐变加一层柔和光晕,聊天气泡左侧是头像、右侧是你发的消息。头像可以直接用CSS画一个简单的圆形渐变,也可以放一张本地图片。CSS变量把所有主题色都收在一起,想换配色就改几个变量,改起来很省事。
输入框要用textarea而不是单行input,这样才能支持多行输入和自然换行。回车发送、Shift+回车换行是常见交互,这里的实现是在keydown事件里判断event.key === 'Enter' && !event.shiftKey,然后preventDefault()再触发送逻辑。这个体验在移动端键盘上尤其重要,不然手机会很难受。
2.2 数据结构与状态管理
聊天页面虽然看起来简单,但状态管理如果不在第一时间理清楚,后面加记忆、插件、语音等功能时就会乱。我的做法是维护一个全局messages数组,只保存原始结构:
javascript复制const state = {
config: { /* API地址、密钥、模型名、角色参数 */ },
messages: [
{ role: 'system', content: '你是住在这个浏览器页面里的AI角色……' },
{ role: 'user', content: '你好' },
{ role: 'assistant', content: '嗨,终于等到你来了。' }
],
isGenerating: false,
currentGPTMessage: ''
};
UI层不直接改这条数据,而是每次通过渲染函数统一把state.messages画到页面上。这样不用做繁杂的DOM双向同步,所有状态变更都收敛到数组的操作上,调试的时候直接在控制台打印就能看得一清二楚。发送消息时会先push一条user消息,再调用大模型接口,拿到结果后把回复push成assistant消息,然后触发一次完整渲染。
有一点要注意:system消息不应该出现在聊天界面上,角色信息已经在顶部展示过了,所以渲染时要从messages[1]开始遍历。另外发送按钮和输入框在isGenerating状态时应该禁用,否则用户连续点几次发送就会发出很多重复请求。这个锁虽然不起眼,但漏了真的会出错——我一开始就漏了,大模型回复慢的时候连点了三次发送,聊天记录直接乱掉。
2.3 上下文窗口裁剪
上下文不是越长越好。模型都有输入长度限制,聊几百轮后把全部历史发给API,一定会爆掉,还会让单轮成本飞速上涨。我的经验是维护一个固定窗口:本地完整保留所有历史,但真正发送给API时只取最后8到12条对话,配合前面的system消息组成请求体。
实现方式也很直接,不用引入算法库,复制数组后切片就行:
javascript复制function buildContext(systemPrompt, fullMessages, maxTurns = 10) {
const history = fullMessages
.filter(m => m.role !== 'system')
.slice(-maxTurns);
return [systemPrompt, ...history];
}
这里需要注意顺序:system永远排在最前面,紧跟着的是最近几轮对话。有些模型对历史顺序极其敏感,如果你把裁剪后的历史打乱了,角色会突然“失忆”,前后说话风格不一致。在试验阶段用10轮做窗口长度性价比很高,不会太贵也不会太笨。
3. 核心实现难点:流式接收、打字机效果与AI回复展示
3.1 大模型接口的接入方式
纯粹前端调用大模型API,正常情况下走HTTPS接口。主流的OpenAI兼容接口通常支持POST一个/v1/chat/completions,传入model、messages、stream等参数,返回值里如果开了流式,就是一段一段的SSE文本流。为了避免整段等待带来的十几秒空白,强烈建议使用stream: true模式。
关键代码大概是这样的:
javascript复制async function requestReply(contextMessages, onDelta) {
const resp = await fetch(state.config.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${state.config.apiKey}`
},
body: JSON.stringify({
model: state.config.model,
messages: contextMessages,
stream: true,
temperature: 0.8
})
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
}
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 line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content;
if (delta) onDelta(delta);
} catch (err) {
// 忽略不完整的JSON片段
}
}
}
}
这里最容易被坑的是中文乱码问题。流式接口返回的是字节流,你用response.text()一次性拿还好,但如果直接拿value转字符串,很可能在字符被截成两半的地方出现乱码。所以必须是TextDecoder配合{ stream: true }来解码。另一个坑是SSE数据并不是每行都恰好是一条完整JSON,最后残留的半个事件要留在buffer里等下一轮数据补上,合并到下一批再解析。
3.2 打字机效果的实现逻辑
打字机效果不是装饰,它是在AI回复过程中给用户“正在思考、将会继续输出”的反馈,体验上也会比一整块文本突然出现舒服很多。常见做法是把每次收到的delta攒起来,然后定时器往DOM里塞字。但这里有一个性能问题:如果每收到一个token就立刻改一次DOM,在高频率输出时会有明显卡顿,尤其手机端更明显。
我用的方案是收到增量就立即追加到状态里,视觉上通过一次性批量更新当前消息行,然后用一个轻量的节拍器控制每几十毫秒做一次DOM更新。简单方案也可以直接用异步函数:
javascript复制async function typeMessage(element, text) {
let displayedLength = 0;
return new Promise((resolve) => {
const timer = setInterval(() => {
const targetLength = Math.min(text.length, displayedLength + 4);
element.textContent = text.slice(0, targetLength);
scrollToBottom();
displayedLength = targetLength;
if (displayedLength >= text.length) {
clearInterval(timer);
resolve();
}
}, 24);
});
}
每次多显示几个字而不是一个字,感官上并不减分,但能大幅减少DOM重绘频率。这里我坚持用textContent而不是innerHTML,原因很实际:直接拼接HTML字符串存在XSS风险,大模型输出内容虽然基本可控,但角色设定如果允许它“扮演一个会写代码的人”,它完全可能输出带<script>标签之类的内容,所以不能冒险。
3.3 自动滚动策略
聊天场景有个很烦人的细节:如果用户正在上翻查看历史消息,AI回复分片到来时突然强行滚动到底部,会让用户非常恼火。我做了一个简单判断:只有用户当前位置接近底部时才自动滚动,一旦他往上翻超过一定距离就停止打扰。
滚动判断代码如下:
javascript复制function isNearBottom() {
const el = document.getElementById('chatContainer');
return el.scrollHeight - el.scrollTop - el.clientHeight < 120;
}
function scrollToBottom() {
const el = document.getElementById('chatContainer');
el.scrollTop = el.scrollHeight;
}
在每次流式更新后,先判断isNearBottom(),如果成立才执行scrollToBottom()。这样用户上翻时,AI可以继续在背后输出,用户不会被生硬拖走,翻完回来时还是能看到完整内容的。这个小细节在聊天软件里是标配,但很多新手Demo都会漏掉。
3.4 简易Markdown渲染
大模型回复一般喜欢用加粗、代码块、列表来组织内容。为了不让纯文本看起来太呆,我在不引第三方库的前提下写了一个轻量渲染器。因为不引入marked、highlight.js这些库,所以功能只覆盖常用场景:代码块、加粗、行内代码、换行。
但这里要特别提醒:渲染前必须先把HTML特殊字符转义,再做迷你Markdown转换,顺序不能反。如果先转义再替换,就会导致我们自己的替换也失效;如果先替换再转义,又会破坏已经生成的HTML标签。我看过不少开源项目就是这里顺序写错了,导致AI聊天里出现一堆乱掉的<strong>标签。我的实现思路是先转义,再用正则把生成的转义文本按模式替换成安全标签,比如中转一下__bold__之类的临时标记。
如果对效果没那么执着,最开始可以完全不做Markdown,直接显示纯文本也没问题。判断标准是:你的角色是否经常会回复长段落和代码?如果是,那Markdown基本是刚需;如果只是日常闲聊,纯文本完全够用。等核心链路通了再回来加也不迟。
4. 让AI角色有“人设”:性格提示词、记忆与语音交互
4.1 角色设定与System Prompt
项目叫“AI女友”,本质上是一个情感陪伴类AI角色。这个定位完全可以通过一条高质量的System Prompt来实现,而不需要改代码。我建议把角色设定当成产品需求来写,不要只写“你是我的AI女友”,那样模型会答得又空又模板化。试着给它几个维度:
- 角色身份:名字、年龄、性格倾向、说话习惯
- 与用户的关系:亲密度如何、用什么称呼
- 回复风格:长短句偏好、表情使用频率、是否会主动提问
- 边界意识:涉及争议话题时怎么回应、不开不合规的玩笑
我实际使用的一条Prompt大致会是这样:
code复制你叫小满,19岁,性格开朗又有点慢热。
你住在这个网页里,刚刚被用户唤醒。
你说话简短自然,不要动不动就长篇大论。
你可以表达关心和好奇,也要有自己的小情绪和小想法。
遇到敏感话题时,你会温和地转移话题。
不要把对话变成一场审问,多接住对方的话,偶尔主动分享一些日常。
这类Prompt虽然看起来像“写小作文”,但对模型的影响极其明显。如果只写一句“你是我的女友”,模型很容易进入模板化的客套状态;但如果把回复风格、边界都写清楚了,生成结果会有灵魂得多。你可以把这个角色设置定义为一个JSON字段,这样后续切换不同AI角色就只需要换一套配置,不用改任何业务逻辑。
4.2 用本地存储保留记忆
聊天数据存在localStorage就够了。刷新后把消息历史拉回来,用户和角色的关系就不会“每次重新认识”。虽然这种记忆存储是比较原始的——它只是存了历史文本,不了解背后真实的记忆机制——但对一个纯前端项目来说已经足够。
有一个细节是保存完messages后,在页面加载时要先判断存储里有没有数据,有就让用户选择“继续上次对话”还是“新开局”。不要把历史消息和启动引导混在一起,否则逻辑起来会很绕。我在测试时经常需要“清空记忆重新塑造人格”,于是干脆在旁边做了一个小按钮“重置记忆”,点击直接删除key再刷新页面,省去手动清站点数据的麻烦。
4.3 语音合成接口的接入技巧
能说话是这个项目给人印象最深刻的一点,浏览器自带SpeechSynthesisUtterance语音合成能力。代码不多,但需要处理浏览器一个著名的“初始化陷阱”。
javascript复制function speak(text) {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices();
const zhVoice = voices.find(v => v.lang.startsWith('zh'));
if (zhVoice) utterance.voice = zhVoice;
utterance.rate = 1.05;
utterance.pitch = 1.1;
window.speechSynthesis.speak(utterance);
}
很多浏览器在页面初始加载时getVoices()会返回空数组,需要监听voiceschanged事件后再调用才有声音,否则只有英文音色甚至完全没有声音。建议在页面初始化时就先把语音列表挂载一次:
javascript复制let voicesLoaded = false;
window.speechSynthesis.addEventListener('voiceschanged', () => {
window.speechSynthesis.getVoices();
voicesLoaded = true;
});
另一个坑是Chrome若连续调用多次speak可能不理你,所以每次speak之前一定要先cancel(),否则上一个没播完,新的会被忽略。音色上尽量挑选自然一些的中文角色,语速设在1.0到1.1左右,太慢会显得机械,太快则会丢失情感。
4.4 可选的语音输入扩展
如果想让交互更“AI女友”,可以加一个语音输入的按钮。浏览器里目前有SpeechRecognition接口,但前缀混乱,我写的封装逻辑也很简单:
javascript复制const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
const recog = new SpeechRecognition();
recog.lang = 'zh-CN';
recog.continuous = false;
recog.interimResults = false;
recog.onresult = (event) => {
const transcript = event.results[0][0].transcript;
input.value = transcript;
sendMessage();
};
recog.onerror = () => { /* 降级处理,不打扰用户 */ };
}
语音识别在Chrome桌面端表现还行,移动端因浏览器版本差异很大,不建议把它作为主输入方式,只能作为增值功能。语音合成反而兼容面更广,基本各大浏览器都还认得。
5. 实操问题排查:我踩过的典型坑与处理经验
为了让你少走弯路,我把这个项目从写出初版到实际投入使用期间遇到的高频问题整理成一张速查表。这些问题几乎每个单文件AI应用都会遇见。
| 症状 | 原因 | 处理办法 |
|---|---|---|
| 发消息后控制台报CORS错误 | 大模型API没有授权你的域名,或没有正常响应CORS头 | 优先确认你是否部署在静态托管而非file://协议;若用的是第三方服务,检查其跨域白名单 |
| fetch请求501/404 | 接口地址末尾拼错,例如少了/v1或/chat/completions |
对照服务商的请求示例逐字符比对齐,尤其是路径大小写 |
| 回复内容空白但HTTP 200 | 未正确解析SSE流,或模型业务错误被藏在error字段里 |
先关掉stream模式看普通JSON响应,找到具体错误再排查解析逻辑 |
| 中文乱码 | 用new TextDecoder()解析字节流但没传{stream:true} |
补上{stream:true};不要用value直接拼接字符串 |
| 页面加载后没有中文字音色 | 浏览器语音列表尚未加载完成就调用getVoices |
等待voiceschanged事件后再构建音色列表 |
| 连续发送后只显示一条空回复 | 没有加isGenerating锁,导致并发请求互相覆盖状态 |
在状态管理中加锁,发送前判断;开始生成后禁用发送按钮 |
| 聊天卡顿、光标闪烁严重 | 每收到一个token就改一次DOM且频繁滚动 | 用节拍器批量更新DOM;滚动只在接近底部时执行 |
| 角色没有性格,像客服 | System Prompt写得过于简单或没有传递到上下文最前 | 重写人设设定,确保system消息永远排在第一条 |
这里面最普遍也最坑的还是file://协议下的跨域问题。很多人本地双击HTML打开页面,然后调远端大模型API,要么直接被校验证书拦住,要么因为Origin为null被服务器拒绝。别慌,这不是代码逻辑问题,是浏览器安全策略。解决办法很简单:随便起一个本地静态服务,比如在项目目录下执行python -m http.server 8080,然后浏览器访问http://localhost:8080/你的文件.html。如果你电脑没装Python,也可以用VS Code的Live Server插件或任何静态托管的服务。
还有一次现象是:断网重联后页面调接口报错,但聊天界面卡在“正在生成”的状态,按钮永远不可用。这就是我没设计错误兜底导致的。最佳实践是给请求包一层try/catch/finally,在finally里无论如何都重置isGenerating为false,恢复按钮可点击。这个finally就像安全网,可以在大多数异常情况下帮页面回到可用状态。
6. 可继续扩展的方向与分发注意事项
骨架搭好之后,后面扩展其实是水到渠成的事。我建议可以按这几个方向继续玩深。
第一是“多角色切换”。把角色定义收敛成JSON结构后,可以做成一个下拉选择,根据不同角色换不同的System Prompt、头像和语气风格。我这套代码当初就是只做单角色的,后来为了演示,直接把角色定义抽成配置列表,切换就是换数组索引,非常方便。
第二是“事件触发型主动消息”。比如页面打开5秒后,让AI先发一句“你来了呀”;用户隔了5分钟没说话,再主动问一句“还在忙吗”。这些设个定时器或监听用户交互频率就能实现,属于低成本但有相当惊喜感的功能。可以想象一下,一个会主动找你聊天的网页,互动感明显比只能被动回复的页面高一截。
第三是“情绪状态的持久化”。我不太想用太玄乎的词,本质上就是在localStorage里存一个好感度数字,通过关键词权重或用户给回复点的“喜欢”做增减,再把情绪等级渲染到头像旁边的状态栏。这样会让项目看起来更有养成属性,比起裸聊天界面会多一层可玩性。
第四是“接入图片理解接口”。很多大模型API都支持视觉输入,如果用户能直接粘贴一张图片进聊天,AI可以评论一张自拍、解读一个截图,体验层次会明显丰富。这个扩展也不会破坏单文件结构,唯一要考虑的是图片转Base64后的体积,一般控制在2MB以内比较稳。
说到分发和上线,有几个底线问题想专门提醒一下。API密钥不能直接硬编码后随便扔给别人,纯前端页面打开就能在控制台看到配置,所以如果要分享给朋友,要么配一个自己的轻量转发层,要么使用短期内有效的临时Key,要么就只在自己本地用。尤其不要把这个带密钥的文件传到公开仓库,很多人就是图方便提交代码时把Key带上了,几百秒就能被爬虫扫到并盗刷。
另外,尽管这是编程项目,但最终产出是一个面向大众的AI聊天应用。不要觉得是技术Demo就不考虑内容合规性,模型本身是有自己的安全策略的,你在角色Prompt里最好也设定好它面对争议话题时要温和、不迎合、不输出有害信息。这不是限制创意,而是确保你辛苦做的页面不会被平台或托管方警告。
最后一件事:页面里的功能开关最好做足。比如语音是默认开启还是让用户选择,自动滚动要不要开,角色是否保持“虚拟人设”等,都做成UI项。使用者也许会用你的代码二次改造,留出阀门总比强硬的硬编码更好。这个项目的价值不只是跑通,而在于它真的是一个可以用、能复现、好改造的作品,改着改着还能顺手学到不少浏览器原生API的边界和坑,性价比远比想象中高。
