欢迎来到小灰灰的博客空间!Weclome you!

博客主页:IT·小灰灰

爱发电:小灰灰的爱发电
热爱领域:前端(HTML)、后端(PHP)、人工智能、云服务


目录

效果示图

一、为什么是"单文件架构"?

1.1 拒绝过度工程化

1.2 技术可行性

二、硅基流动平台配置(关键步骤)

2.1 注册获取API Key

2.2 模型选择建议

三、核心实现解析

3.1 状态管理:IIFE模块化

3.2 流式输出实现

3.3 Prmpt工程:去AI味

四、完整源码

五、进阶优化建议

5.1 模型自动降级(省钱技巧)

5.2 安全加固(前端防key暴露)

5.3 语音输入/输出

六、常见问题Q&A

结语 


本文探讨如何在不依赖任何构建工具、后端服务或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

  1. 访问 https://cloud.siliconflow.cn,点右上角注册

  2. 手机号验证码登录后,进入控制台 → API密钥

  3. 点击"新建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:"来啦~今天有没有想我呀?"

四、完整源码

使用方法:

  1. 复制下面代码

  2. 保存为 index.html

  3. 双击打开(浏览器就行)

  4. 点击顶部"配置"按钮,填入你的硅基流动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>


源码解读重点:

  1. 灵动岛交互:点击顶部胶囊展开菜单,纯CSS实现动画

  2. 背景更换:file input读取图片转base64存localStorage

  3. 历史记录:只保留最近20轮,防止localStorage撑爆

  4. 移动端适配: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,但可以:

  1. 混淆+分割存储:把Key拆成3段存在不同localStorage key里,运行时拼接

  2. 限流:前端记录调用次数,超过50次/小时弹出提示(防止被刷)

  3. 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元免费额度

如果觉得有用,欢迎在评论区留言"学到了",你的支持是我更新的动力!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐