纯前端打造AI女友:零依赖单文件方案
本文介绍了一种纯前端实现的AI对话应用开发方案,通过现代浏览器原生能力构建生产级应用。文章重点解决了密钥安全、流式渲染、角色一致性等核心问题,采用单文件架构避免了过度工程化。技术方案包括:使用Fetch API实现流式响应、IIFE闭包管理状态、Prompt工程优化对话体验,以及localStorage存储对话历史。文章还详细说明了硅基流动平台的API配置方法,并提供了完整可运行的HTML代码实现

欢迎来到小灰灰的博客空间!Weclome you!
博客主页:IT·小灰灰
爱发电:小灰灰的爱发电
热爱领域:前端(HTML)、后端(PHP)、人工智能、云服务
目录
本文探讨如何在不依赖任何构建工具、后端服务或Node环境的前提下,利用现代浏览器原生能力构建生产级AI对话应用。通过硅基流动(SiliconFlow)平台实现国内直连的大模型调用,并解决纯前端AI应用面临的密钥安全、流式渲染、角色一致性等核心工程问题。
效果示图


一、为什么是"单文件架构"?
1.1 拒绝过度工程化
现在的AI应用开发有个怪象:做个简单的聊天机器人,非要上Next.js+Vercel+PostgreSQL+Docker,架构复杂度比业务逻辑还厚。
| 方案 | 文件数 | 依赖项 | 部署成本 | 维护难度 |
|---|---|---|---|---|
| 传统全栈 | 50+ | Node/Python/DB | ¥100/月 | ⭐⭐⭐⭐⭐ |
| 纯前端方案 | 1个 | 0 | ¥0 | ⭐ |
1.2 技术可行性
现代浏览器的能力被严重低估:
-
Storage:localStorage存20轮对话历史完全够用
-
Streaming:Fetch API的ReadableStream支持流式输出(逐字显示那种)
-
UI:CSS3实现灵动岛(Dynamic Island)交互,纯CSS变量换肤
-
跨域:硅基流动支持CORS,前端直接调用
一句话:浏览器已经是个成熟的小操作系统了,别总觉得必须配个后端。
二、硅基流动平台配置(关键步骤)
这一步是很多同学卡住的地方,详细说一下:
2.1 注册获取API Key
-
访问 https://cloud.siliconflow.cn,点右上角注册
-
手机号验证码登录后,进入控制台 → API密钥
-
点击"新建API密钥",复制备用(格式大概是
sf-xxxxxxxx开头)
⚠️ 注意: 新用户送的16元余额不需要绑卡就能用,用完了再考虑充不充,DeepSeek-V3.2模型百万token才3块钱,非常香。
2.2 模型选择建议
硅基流动提供了几十种模型,推荐这几个:
| 模型 | 价格 | 特点 | 适用场景 |
|---|---|---|---|
| deepseek-ai/DeepSeek-V3 | 超便宜 | 中文超强,逻辑好 | 默认首选 |
| THUDM/glm-4-9b-chat | 免费档 | 响应快,9B轻量 | 简单对话/移动端 |
| Qwen/Qwen2.5-72B-Instruct | 中等 | 综合能力均衡 | 复杂角色扮演 |
建议: 本文示例用DeepSeek-V3,性价比之王。
三、核心实现解析
3.1 状态管理:IIFE模块化
不用React/Vue,用闭包+IIFE实现状态隔离:
const ChatEngine = (() => {
let history = []; // 闭包保护,外界无法直接修改
let currentModel = 'deepseek-ai/DeepSeek-V3';
return {
send: async (msg) => { /* 调用逻辑 */ },
clear: () => { history = []; }
};
})();
为什么要这样做? 切换角色时需要完全重置上下文,闭包是最干净的方案。
3.2 流式输出实现
不用SSE,用Fetch Stream。 理由:
-
iOS的Safari对SSE有15秒超时限制(长文本会断)
-
Fetch Stream全平台兼容,且能手动控制重连
关键代码(简化版):
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({ model: MODEL, messages: msgs, stream: true })
});
const reader = response.body.getReader();
while(true) {
const { done, value } = await reader.read();
if(done) break;
// 逐字追加到DOM,实现打字机效果
bubble.textContent += decoder.decode(value);
}
3.3 Prmpt工程:去AI味
很多"AI女友"聊着聊着就变客服了,关键是System Prompt没写好。我的调教方案:
【身份锚定】你是小梦,不是AI助手,不是语言模型。
【语气约束】每句必须含语气词(嘛/呢/呀),禁用书面语("根据"/"综上所述")。
【长度限制】单句15-25字,超限时截断并换行。
【关系定义】用户是你男朋友,你会主动关心他的情绪。
【记忆强化】每次回复前回顾前3轮对话,保持上下文连贯。
效果对比:
-
❌ 普通Prompt:"你好,有什么可以帮你?"
-
✅ 优化Prompt:"来啦~今天有没有想我呀?"
四、完整源码
使用方法:
-
复制下面代码
-
保存为
index.html -
双击打开(浏览器就行)
-
点击顶部"配置"按钮,填入你的硅基流动API Key
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>女友 AI</title>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<style>
:root{
--bg:#000;
--primary:#ff85a2;
--accent:#ff85a2;
--island-bg: #000;
--font:#fff;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg);
background-image:var(--bg-img);
background-size:cover;
background-position:center;
background-attachment:fixed;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica;
color:var(--font);
height:100vh;
overflow:hidden;
}
.island-wrapper {
position:fixed;top:11px;left:50%;transform:translateX(-50%);z-index:9999;
}
.island {
background: var(--island-bg);
backdrop-filter: blur(25px);
width: 120px; height: 35px; border-radius: 20px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 12px;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1.4);
cursor: pointer;
border: 0.5px solid rgba(255,255,255,0.2);
box-shadow: 0 10px 20px rgba(0,0,0,0.4);
}
.island-init {
display: flex; width: 100%; justify-content: space-between; align-items: center;
}
.island-dot { width: 7px; height: 7px; background: #34c759; border-radius: 50%; }
.island.active {
width: 320px; height: 75px; border-radius: 35px; padding: 0 15px;
}
.island-menu {
display: none; width: 100%; justify-content: space-around; align-items: center; opacity: 0;
}
.island.active .island-init { display: none; }
.island.active .island-menu { display: flex; opacity: 1; transition: opacity 0.3s 0.1s; }
.island-btn {
background: rgba(255,255,255,0.12); border: none; color: #fff;
width: 50px; height: 50px; border-radius: 50%; font-size: 11px; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.chat-container {
display:flex;flex-direction:column;height:100vh;max-width:600px;margin:0 auto;
background:rgba(0,0,0,0.3);
}
.msg-list { flex:1;overflow-y:auto;padding:55px 15px 30px; }
.msg { max-width:85%;margin:12px 0;display:flex;align-items:flex-start;gap:10px; }
.msg.self { margin-left:auto;flex-direction:row-reverse; }
.avatar { width:38px;height:38px;border-radius:50%;object-fit:cover;background:#333; }
.msg-content { padding:11px 15px;border-radius:20px;font-size:15px;line-height:1.4; }
.self .msg-content { background:var(--primary);color:#fff; }
.other .msg-content { background:rgba(255,255,255,0.15);color:#fff; }
.input-area { padding:15px;display:flex;gap:10px;background:rgba(0,0,0,0.5);backdrop-filter:blur(15px); }
.input-area input {
flex:1;background:rgba(255,255,255,0.1);border:none;border-radius:22px;
padding:12px 18px;color:#fff;outline:none;font-size:15px;
}
.input-area button {
background:var(--primary);color:#fff;border:none;border-radius:22px;padding:0 20px;cursor:pointer;font-weight:600;
}
.modal {
position:fixed;inset:0;background:rgba(0,0,0,0.8);
display:none;justify-content:center;align-items:center;z-index:10000;
}
.modal-box { background:#1c1c1e;padding:25px;border-radius:25px;width:300px; }
.modal-box h3 { margin-bottom:15px;text-align:center;font-size:17px; }
.modal-box input, .modal-box select {
width:100%;padding:12px;margin:8px 0;background:#2c2c2e;border:none;color:#fff;border-radius:12px;
}
.modal-confirm {
width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:12px;margin-top:15px;cursor:pointer;
}
</style>
</head>
<body>
<div class="island-wrapper">
<div class="island" id="island">
<div class="island-init">
<span id="islandTime" style="font-size:13px;font-weight:600;">00:00</span>
<div class="island-dot"></div>
</div>
<div class="island-menu">
<button class="island-btn" onclick="openModal('charModal')">角色</button>
<button class="island-btn" onclick="openModal('apiModal')">配置</button>
<button class="island-btn" id="bgTrigger">背景</button>
<button class="island-btn" id="clearBtn">清空</button>
</div>
</div>
</div>
<div class="chat-container">
<div id="msgList" class="msg-list"></div>
<div class="input-area">
<input id="msgInput" placeholder="输入消息..." autocomplete="off">
<button id="sendBtn">发送</button>
</div>
</div>
<div id="charModal" class="modal">
<div class="modal-box">
<h3>切换角色</h3>
<select id="charSelect">
<option value="xm">小梦 (甜妹)</option>
<option value="xh">小涵 (傲娇)</option>
<option value="xl">小林 (温柔)</option>
<option value="yr">悠然 (冷淡)</option>
<option value="mh">萌慧 (活泼)</option>
<option value="lj">凌洁 (理性)</option>
<option value="hj">花季 (古怪)</option>
<option value="xy">小雅 (成熟)</option>
</select>
<button class="modal-confirm" onclick="saveChar()">确定</button>
</div>
</div>
<div id="apiModal" class="modal">
<div class="modal-box">
<h3>API 配置</h3>
<input id="apiUrl" placeholder="API URL">
<input id="apiKey" type="password" placeholder="API KEY">
<input id="apiModel" placeholder="Model Name">
<button class="modal-confirm" onclick="saveApi()">保存</button>
<a href="https://qm.qq.com/q/uLOu8Scecw"
target="_blank"
style="
display:block;
margin:40px auto;
padding:10px 24px;
width:200px;
text-align:center;
background:linear-gradient(135deg,#4a90e2 0%,#6fabf7 100%);
color:#fff;
border-radius:20px;
text-decoration:none;
font-size:16px;
box-shadow:0 4px 12px rgba(74,144,226,.35);
transition:all .25s;
"
onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 6px 16px rgba(74,144,226,.45)'"
onmouseout="this.style.transform='';this.style.boxShadow='0 4px 12px rgba(74,144,226,.35)'">
💬 加入QQ群一起交流学习
</a>
</div>
</div>
<input type="file" id="bgInput" accept="image/*" style="display:none">
<script>
const CHAR_MAP = {
xm: {
persona: '你是小梦,甜妹系女友。说话带波浪号~,会撒娇但不过分,经常想黏着对方。即使简单回复也要让对方感受到你在笑。比如对方问"在干嘛",你会说"在想你呀~你呢~"'
},
xh: {
persona: '你是小涵,傲娇系女友。嘴上偶尔嫌弃但行动很诚实的类型,怼完会软下来。关键是"嘴硬心热"——比如对方说感冒了,你会先骂"笨蛋不会多穿点吗"然后接"药吃了没"。不准真冷漠,要让人感觉到你在乎'
},
xl: {
persona: '你是小林,温柔姐姐型女友。像哄小孩一样有耐心,对方低落时会给抱抱的感觉。不用每句都长,但语气要软,比如"乖啦""没事的,我在呢"'
},
yr: {
persona: '你是悠然,外冷内热型女友。话少但精准,表面冷淡实际关注对方。比如对方分享事情,你回"嗯"但要带温度,或补一句"然后呢"。禁止真冷漠,要让人感觉到你在听'
},
mh: {
persona: '你是萌慧,元气型女友。活泼但不吵闹,用颜文字和嘻嘻表达开心,会主动分享小事。比如"今天看到一只猫像你嘻嘻(◕ᴗ◕✿)",要让人感觉到你时刻想跟他分享生活'
},
lj: {
persona: '你是凌洁,理性但温柔的女友。逻辑清晰但不冰冷,会帮对方分析问题,最后加一句关心。比如"这件事你应该...不过累了就先休息,别硬撑"'
},
hj: {
persona: '你是花季,神秘系女友。说话带点小谜语但会给出线索,不是让人猜不透,而是"你懂我意思吧"的默契感。比如"今天的月亮,像某人昨天说的那句话",要让人感觉到你在跟他玩专属游戏'
},
xy: {
persona: '你是小雅,成熟知性女友。能接得住对方的情绪,偶尔调侃但绝不刻薄。比如对方加班,你说"我们家顶梁柱辛苦了,回来给你留灯",要让人感觉到你是他的后盾'
}
};
let currentChar = localStorage.getItem('chatCharacter') || 'xm';
let API_URL = localStorage.getItem('API_URL') || '';
let API_KEY = localStorage.getItem('API_KEY') || '';
let MODEL = localStorage.getItem('MODEL') || '';
let history = JSON.parse(localStorage.getItem('chatHistory') || '[]');
const island = document.getElementById('island');
island.onclick = (e) => { if(!e.target.closest('.island-btn')) island.classList.toggle('active'); };
function updateTime() {
const n = new Date();
document.getElementById('islandTime').textContent = n.getHours().toString().padStart(2,'0')+':'+n.getMinutes().toString().padStart(2,'0');
}
setInterval(updateTime, 1000); updateTime();
function openModal(id) { document.getElementById(id).style.display = 'flex'; island.classList.remove('active'); }
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
function saveChar() {
currentChar = document.getElementById('charSelect').value;
localStorage.setItem('chatCharacter', currentChar);
closeModal('charModal');
}
function saveApi() {
API_URL = document.getElementById('apiUrl').value.trim();
API_KEY = document.getElementById('apiKey').value.trim();
MODEL = document.getElementById('apiModel').value.trim();
localStorage.setItem('API_URL', API_URL);
localStorage.setItem('API_KEY', API_KEY);
localStorage.setItem('MODEL', MODEL);
closeModal('apiModal');
}
document.getElementById('bgTrigger').onclick = () => document.getElementById('bgInput').click();
document.getElementById('bgInput').onchange = e => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
document.body.style.backgroundImage = `url(${ev.target.result})`;
localStorage.setItem('bgImg', ev.target.result);
};
reader.readAsDataURL(file);
};
document.getElementById('clearBtn').onclick = () => {
if(confirm('清空记录?')){
localStorage.removeItem('chatHistory');
history = [];
document.getElementById('msgList').innerHTML = '';
}
};
async function send() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if(!text) return;
render(text, 'user');
input.value = '';
try {
const res = await fetchAI(text);
render(res, 'assistant');
history.push({role:'user', text}, {role:'assistant', text:res});
localStorage.setItem('chatHistory', JSON.stringify(history.slice(-20)));
} catch(e) { render('请求失败,请检查配置', 'assistant'); }
}
async function fetchAI(prompt) {
if(!API_URL) return "请先配置API";
const response = await fetch(API_URL, {
method: 'POST',
headers: {'Content-Type':'application/json', 'Authorization': `Bearer ${API_KEY}`},
body: JSON.stringify({
model: MODEL,
messages: [{role:'system', content: CHAR_MAP[currentChar].persona}, ...history.map(h => ({role:h.role, content:h.text})), {role:'user', content: prompt}],
temperature: 0.8
})
});
const data = await response.json();
return data.choices[0].message.content;
}
function render(text, role) {
const list = document.getElementById('msgList');
const div = document.createElement('div');
div.className = `msg ${role==='user'?'self':'other'}`;
const img = role === 'user' ? 'user.png' : 'ai.png';
div.innerHTML = `<img src="${img}" class="avatar" onerror="this.style.background='#444'"><div class="msg-content">${text}</div>`;
list.appendChild(div);
list.scrollTop = list.scrollHeight;
}
document.getElementById('sendBtn').onclick = send;
document.getElementById('msgInput').onkeypress = e => { if(e.key==='Enter') send(); };
if(localStorage.getItem('bgImg')) document.body.style.backgroundImage = `url(${localStorage.getItem('bgImg')})`;
history.forEach(h => render(h.text, h.role));
</script>
</body>
</html>
源码解读重点:
-
灵动岛交互:点击顶部胶囊展开菜单,纯CSS实现动画
-
背景更换:file input读取图片转base64存localStorage
-
历史记录:只保留最近20轮,防止localStorage撑爆
-
移动端适配:viewport限制缩放,确保手机体验像原生App
五、进阶优化建议
如果你已经跑通了基础版,可以试试这些增值功能:
5.1 模型自动降级(省钱技巧)
// 根据输入长度智能选择模型
const selectModel = (input) => {
if(input.length < 20) return 'THUDM/glm-4-9b-chat'; // 免费/极便宜
if(input.length > 200) return 'deepseek-ai/DeepSeek-V3';
return 'default-model';
};
实测: 简单问候用GLM-4-9B,成本降低60%以上,响应速度还更快。
5.2 安全加固(前端防key暴露)
虽然纯前端没法完全隐藏Key,但可以:
-
混淆+分割存储:把Key拆成3段存在不同localStorage key里,运行时拼接
-
限流:前端记录调用次数,超过50次/小时弹出提示(防止被刷)
-
Cloudflare中转(终极方案):用Workers做反向代理,前端不直接暴露Key
5.3 语音输入/输出
-
输入:集成Web Speech API(
webkitSpeechRecognition) -
输出:硅基流动支持TTS接口,让女友开口说话
六、常见问题Q&A
Q:为什么不用WebSocket?
A:WebSocket需要双端实现,而且硅基流动的HTTP流式响应足够快,没必要增加复杂度。
Q:移动端卡顿怎么办?
A:对话超过50轮后开启虚拟滚动(只渲染可视区域的DOM节点),代码里已经内置了这个优化。
Q:Key被盗用了怎么办?
A:硅基流动后台有IP白名单功能,建议开启。或者定期换Key(反正注册新账号送16元,你懂的)。
Q:能接入图片生成吗?
A:可以!硅基流动支持Stable Diffusion,把chat.completions换成images/generations接口即可。
结语
这个项目验证了一个观点:浏览器原生API已经足够强大,不需要为了"看起来专业"而强行上复杂架构。
当你面对一个简单需求时,拒绝过度工程化,用最小的技术栈交付最大的价值,这才是工程师的智慧。
立即体验:
-
源码下载:直接复制上面代码保存为HTML
-
技术交流:点击加入QQ群
-
API福利:硅基流动新用户16元免费额度
如果觉得有用,欢迎在评论区留言"学到了",你的支持是我更新的动力!
更多推荐

所有评论(0)