1. 项目概述:一个无需登录的多角色AI聊天应用
最近在捣鼓一些AI应用的原型,发现一个挺有意思的需求:很多时候我们和AI聊天,希望它能扮演不同的角色。比如写代码时想要一个严格的代码审查员,写文案时想要一个创意爆棚的伙伴,或者单纯想找个“毒舌”朋友来吐槽。但大多数聊天应用要么只能固定一个“人格”,要么切换起来非常麻烦,历史记录还会混在一起。
基于这个痛点,我花时间研究并实现了一个叫G0DM0D3 Persona Chat的Web应用。它的核心思路很简单:让你像切换电台频道一样,在不同的AI“人格”间无缝切换,并且每个“人格”都拥有完全独立、互不干扰的对话记忆。整个应用完全在浏览器端运行,你的OpenAI API密钥只存在本地,对话历史也只用浏览器的localStorage保存,没有任何后端服务器参与,最大程度保证了隐私。你可以把它看作是一个高度可定制化的、私人的AI角色扮演工具箱。
这个项目的灵感来源于社区项目elder-plinius/G0DM0D3,我在其思路上做了大量前端工程化和用户体验上的改进,用上了最新的Next.js 15(App Router)、React 19和Tailwind CSS 4,构建了一个既现代又实用的MVP(最小可行产品)。无论你是想体验不同风格的AI对话,还是想学习如何构建一个隐私优先的流式AI聊天应用,这个项目都值得一看。
2. 核心功能与设计思路拆解
2.1 为什么是“多角色”而非“单角色”?
传统的AI聊天界面,无论是ChatGPT官网还是许多套壳应用,都默认你和同一个AI实体对话。虽然你可以通过修改系统提示词(System Prompt)来改变AI的行为,但这种改变通常是临时的、覆盖式的。下次打开,或者想同时进行两种不同风格的对话(比如一边让AI帮忙debug,一边让它写诗),就非常不便。
G0DM0D3 Persona Chat的设计哲学是“对话上下文隔离”。每个角色(Persona)不仅仅是一个名字和头像,更是一个完整的、包含独立系统提示词和完整对话历史的会话容器。你可以把“辩论教练”想象成一个独立的聊天窗口,把“斯多葛哲学家”想象成另一个。它们之间互不知晓对方的存在,也不会混淆记忆。这种设计解决了几个关键问题:
- 角色污染:避免和“创意伙伴”天马行空聊天的内容,影响后续向“代码审查员”提问时AI的严谨性。
- 并行对话:你可以同时与多个角色就同一主题进行不同角度的探讨,比如分别询问“乐观顾问”和“怀疑论者”对某个创业想法的看法。
- 快速回溯:每个角色的对话历史都是纯净的,查找和回顾特定语境下的交流非常方便。
2.2 技术栈选型背后的考量
选择合适的技术栈是项目成功的一半。这个项目面向的是现代Web,核心诉求是:开发体验好、性能优秀、能生成静态页面、对AI API集成友好。
- Next.js 15 (App Router): 这是当前React全栈框架的标杆。App Router提供了基于文件系统的、直观的路由和布局管理,非常适合构建这种单页面应用(SPA)。更重要的是,它支持静态导出(Static Export),这意味着整个应用可以构建成纯粹的HTML、CSS和JavaScript文件,部署到任何静态托管服务(如Vercel, Netlify, GitHub Pages)上,无需Node.js服务器,成本极低,速度极快。这正是“隐私优先”(无后端)架构的基础。
- React 19 + TypeScript: React 19带来了诸如
usehook等新的并发特性,为未来优化流式渲染提供了更多可能。TypeScript则是中大型前端项目的必备,它能极大地提升代码的健壮性和可维护性,尤其是在处理复杂的AI响应数据和状态管理时,类型安全至关重要。 - Tailwind CSS 4: 对于快速原型和MVP开发,Tailwind CSS的效率无与伦比。它允许你直接在JSX中编写样式,快速迭代UI。第四版在性能和功能上又有提升。深色主题和响应式布局用Tailwind实现起来非常轻松。
- OpenAI Chat Completions API: 这是目前最稳定、功能最丰富的通用大模型API。选择
gpt-4o-mini作为默认模型,是在成本、速度和能力之间取得的一个很好平衡,足够应对大多数聊天场景。其原生的流式(Streaming)响应支持,是实现“打字机”效果实时输出的关键。
注意:整个应用的数据流完全发生在客户端。你的OpenAI API密钥在输入后,仅保存在浏览器的
localStorage中。当你发送消息时,前端代码会直接使用这个密钥调用OpenAI的API,响应数据也直接流式传输回你的浏览器。没有任何中间服务器经手你的密钥或完整的对话内容,从架构上杜绝了隐私泄露的风险。
2.3 预置角色设计的心理学与实用性
应用内置了6个预置角色,这不是随便选的,每个都针对了常见的用户需求场景:
- 辩论教练 (Debate Coach): 系统提示词会引导AI主动寻找你论点的漏洞,提出反驳,帮助你锤炼逻辑和表达。适合准备演讲、论文或需要深度思考时使用。
- 怀疑论者 (The Skeptic): 这个角色不会轻易接受你的陈述,它会要求证据,质疑假设。对于检验想法、避免确认偏误非常有用。
- 直言顾问 (Blunt Advisor): 它不会给你包裹糖衣的反馈,而是直接、甚至尖锐地指出问题。当你需要残酷的诚实而不是安慰时,它是首选。
- 友好导师 (Friendly Mentor): 耐心、鼓励式引导,适合学习新技能或需要正向支持时。
- 创意混沌 (Creative Chaos): 它的提示词鼓励跳跃性、关联性思维,能产生意想不到的创意连接,适合头脑风暴。
- 斯多葛哲学家 (Stoic Philosopher): 用古典哲学的智慧来回应现代问题,提供一种冷静、超然的视角,适合反思和寻求内心平静。
这些预置角色降低了用户的使用门槛,用户无需知道如何编写有效的系统提示词,就能立即体验多角色聊天的魅力。同时,它们也是很好的示例,指导用户如何创建自己的自定义角色。
3. 核心实现细节与关键技术点
3.1 状态管理:如何优雅地隔离多个对话
这是应用最核心的部分。我们需要在内存中同时维护N个独立的对话线程,每个线程包含:
- 角色配置(名称、图标、颜色、系统提示词)
- 消息数组(用户消息和AI回复)
- 其他元数据(如是否正在生成回复)
在React中,最直接的选择是使用Context或状态管理库(如Zustand、Jotai)。考虑到这是一个相对独立的状态树,且需要持久化到localStorage,我选择了Zustand。它API简洁,与TypeScript结合完美,并且中间件生态丰富。
核心状态结构设计:
interface Message { id: string; role: 'user' | 'assistant'; content: string; timestamp: number; } interface Persona { id: string; name: string; systemPrompt: string; icon: string; // emoji或图标名称 color: string; // Tailwind CSS颜色类,如‘bg-blue-500’ messages: Message[]; isTyping?: boolean; } interface ChatStore { personas: Persona[]; // 所有角色数组 activePersonaId: string | null; // 当前激活的角色ID apiKey: string; // Actions: 添加角色、删除角色、切换角色、添加消息、清空对话、更新API密钥等 addPersona: (persona: Omit<Persona, 'id' | 'messages'>) => void; setActivePersona: (id: string) => void; sendMessage: (personaId: string, content: string) => Promise<void>; // ... 其他actions }使用Zustand的persist中间件,可以轻松地将整个personas数组和apiKey持久化到localStorage。这样,即使关闭浏览器再打开,所有角色的对话历史都完好无损。
切换角色的实现:当用户点击侧边栏的角色头像时,触发setActivePersonaaction。这会将activePersonaId更新为目标ID。UI层(主聊天区域)监听这个状态,一旦变化,就立即渲染对应persona.messages中的对话历史。这个过程是瞬间完成的,实现了真正的“频道切换”体验。
3.2 流式聊天实现:从API到UI的实时渲染
流式响应是现代AI应用的标配,它能极大提升用户体验,避免长时间等待。这里的关键是处理OpenAI API的流式返回,并实时更新UI。
步骤拆解:
- 前端发起请求:当用户在某个角色下发送消息时,前端会构建一个符合OpenAI API格式的请求体。特别注意,每次请求都必须包含该角色的
systemPrompt作为消息数组的第一条,这样才能确保AI始终以该角色的身份回复。消息历史(persona.messages)也会被格式化后附上。 - 使用Fetch API处理流:不能使用普通的
fetch().then(),需要使用fetch().then(response => response.body)获取可读流(ReadableStream)。 - 解析流数据:OpenAI的流式响应是一系列SSE(Server-Sent Events)格式的数据块。每个数据块是一个JSON对象,其中
choices[0].delta.content字段包含了最新的文本片段。我们需要用TextDecoder来解码流,并按\n\n分割事件,再过滤出data: [DONE]和有效的数据行。 - 实时更新状态:每收到一个有效的文本片段,就调用Zustand store中的一个action,例如
appendToStreamingMessage(personaId, chunk)。这个action会找到对应角色的消息数组,如果最后一条消息是AI的且正在流式输出,就追加内容;否则,就新建一条AI消息并开始追加。同时,设置persona.isTyping = true。 - UI渲染:React组件监听store中消息和
isTyping状态的变化。使用一个useEffecthook来将最新的消息内容滚动到可视区域。为了获得良好的“打字机”效果,可以考虑使用requestAnimationFrame进行节流渲染,或者直接依赖React的并发渲染特性。 - 结束与清理:当收到
[DONE]事件时,触发finishStreamingMessage(personaId)action,将isTyping设为false,并可能对完整的消息内容做一些后处理(如格式化)。
实操心得:处理流式响应时,错误处理尤为重要。网络中断、API密钥错误、模型超载都会导致流异常。一定要用
try...catch包裹整个流处理逻辑,并在UI上给用户明确的错误提示(例如,“网络中断,请重试”)。同时,要确保在组件卸载时,能正确中止未完成的fetch请求,防止内存泄漏。
3.3 侧边栏与Markdown导出:提升用户体验的关键组件
动态侧边栏: 侧边栏不仅仅是导航,它还是一个信息面板。每个角色卡片上显示:
- 角色名称和图标
- 使用角色专属的背景色(来自
persona.color),增强视觉区分。 - 当前未读消息数或总消息数(通过计算
persona.messages.length实现)。这个数字是实时更新的,让用户一目了然哪个角色有活跃的对话。 - 当前激活的角色卡片会有高亮边框或背景色变化(通过比较
persona.id === activePersonaId实现)。
Markdown导出功能: 这是一个非常实用但常被忽略的功能。实现原理很简单:
- 遍历目标角色的
messages数组。 - 将每条消息按照
[角色]: 内容的格式拼接,通常用户消息用**You**,AI消息用角色名,如**辩论教练**。 - 在内容前后加上Markdown代码块标记(```)如果内容是代码,或者直接保留。
- 使用
Blob对象将生成的Markdown文本创建为一个文件。 - 创建一个隐藏的
<a>标签,设置其href为URL.createObjectURL(blob),download属性为{persona-name}-conversation-{date}.md,然后模拟点击它触发下载。
这个功能让用户能轻松地将有价值的对话存档、分享或导入到笔记软件(如Obsidian、Notion)中,极大地延伸了应用的使用场景。
4. 从零开始的完整构建与部署指南
4.1 本地开发环境搭建
假设你已经安装了Node.js(建议18.x或以上版本)和Git。
# 1. 克隆项目仓库(请替换为实际仓库URL) git clone https://github.com/your-username/2026-03-28-g0dm0d3-persona-chat.git cd 2026-03-28-g0dm0d3-persona-chat # 2. 安装依赖 # 使用 npm 或 yarn 或 pnpm,这里以npm为例 npm install # 3. 启动开发服务器 npm run dev执行npm run dev后,Next.js会在localhost:3000启动一个热重载的开发服务器。现在打开浏览器访问这个地址,你应该能看到应用界面,但还不能聊天,因为缺少OpenAI API密钥。
4.2 项目结构与核心文件解析
了解项目结构有助于你进行自定义开发:
2026-03-28-g0dm0d3-persona-chat/ ├── app/ # Next.js 15 App Router 核心目录 │ ├── globals.css # 全局样式,导入Tailwind │ ├── layout.tsx # 根布局,定义HTML结构,包含侧边栏和主区域 │ └── page.tsx # 主页组件,包含聊天主界面和消息列表 ├── components/ # 可复用React组件 │ ├── PersonaSidebar.tsx # 侧边栏组件,显示角色列表 │ ├── ChatMessage.tsx # 单条消息的渲染组件 │ ├── ApiKeyModal.tsx # 用于输入API密钥的模态框 │ └── PersonaEditor.tsx # 创建/编辑角色的表单组件 ├── lib/ # 工具函数和核心逻辑 │ ├── store.ts # Zustand状态管理store定义 │ ├── openai.ts # 封装调用OpenAI API的函数(包含流式处理) │ └── utils.ts # 通用工具函数(如生成ID、格式化时间) ├── public/ # 静态资源 ├── next.config.js # Next.js配置文件,可能设置了静态导出 ├── tailwind.config.js # Tailwind CSS配置文件 ├── tsconfig.json # TypeScript配置 └── package.json关键文件详解:
lib/store.ts: 这是应用的大脑。所有状态(角色、消息、API密钥)和修改状态的方法(actions)都定义在这里。花时间理解这个文件,就理解了整个应用的数据流。lib/openai.ts: 这是与AI交互的核心。sendMessageStreaming函数接收角色ID和用户输入,从store中获取该角色的系统提示词和历史消息,构造请求,处理流式响应,并不断更新store。这是技术难度最高的部分之一。app/layout.tsx: 这个文件定义了基本的页面结构,通常将<PersonaSidebar />放在这里,使侧边栏在整个应用生命周期内保持状态。app/page.tsx: 主页组件,它订阅store中的activePersonaId和对应角色的消息,并将其渲染成<ChatMessage />列表。它还包含消息输入框。
4.3 配置与运行
- 获取OpenAI API密钥:访问 OpenAI平台,注册或登录后,在API密钥页面创建一个新的密钥并复制。
- 在应用中配置:在运行起来的应用界面中,找到通常位于角落的钥匙图标(🔑)并点击,在弹出的模态框中粘贴你的API密钥,然后保存。
- 开始聊天:从侧边栏选择一个预置角色,或者在侧边栏底部点击“+”创建你自己的角色(需要填写名称、系统提示词、选择图标和颜色)。然后在底部的输入框发送消息,就能看到AI以你设定的角色身份进行流式回复了。
4.4 构建与静态部署
因为这个应用是纯静态的,部署非常简单。
# 在项目根目录执行构建命令 npm run buildNext.js会执行构建过程。由于在next.config.js中配置了output: 'export',它不会生成需要Node.js服务器的服务端文件,而是会在out目录下生成纯静态文件。
# 构建完成后,你可以本地预览这个静态站点 npx serve out部署到Vercel(推荐):
- 将你的代码推送到GitHub、GitLab或Bitbucket。
- 登录 Vercel,点击“New Project”,导入你的仓库。
- Vercel会自动检测到这是Next.js项目并配置好构建命令。你几乎不需要做任何额外设置,直接点击“Deploy”。
- 部署完成后,你会获得一个
*.vercel.app的域名,你的应用就上线了。Vercel的全球CDN能确保访问速度。
部署到GitHub Pages:
- 在
next.config.js中设置basePath: '/你的仓库名'(如果部署到用户或组织页面则不需要)。 - 可以安装
gh-pages包,并配置package.json中的部署脚本。 - 运行
npm run deploy将out目录推送到仓库的gh-pages分支。 - 在GitHub仓库的Settings -> Pages中,选择
gh-pages分支作为源。
部署后,任何用户都可以通过你分享的链接访问这个应用。他们的所有数据(API密钥和对话)依然只保存在他们自己的浏览器本地,你的服务器/托管平台不存储任何用户数据。
5. 常见问题、调试技巧与扩展思路
5.1 常见问题排查速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 输入API密钥后,发送消息无反应,控制台无错误 | 1. API密钥未正确保存。 2. 浏览器插件(如广告拦截器)阻止了请求。 | 1. 检查localStorage(F12开发者工具 -> Application标签)中apiKey是否存在且正确。2. 尝试禁用插件,或使用浏览器隐私/无痕模式测试。 |
| 消息发送后,一直显示“正在输入…”但无回复 | 1. OpenAI API请求失败(网络、密钥无效、额度不足)。 2. 前端流处理逻辑有bug,未收到 [DONE]事件。 | 1. 打开浏览器开发者工具“Network”标签,查看对api.openai.com的请求状态码和响应。401表示密钥错误,429表示超额,502可能是OpenAI端问题。2. 检查 lib/openai.ts中的流解析逻辑,确保能正确处理完成事件和错误事件。 |
| 切换角色后,消息历史显示错误或为空 | 1. Zustand store中activePersonaId更新了,但UI组件未正确订阅对应角色的消息。2. localStorage数据损坏。 | 1. 确保ChatMessageList组件通过useStorehook正确订阅了state.personas.find(p => p.id === state.activePersonaId)?.messages。2. 清空浏览器 localStorage重新测试。 |
| 侧边栏角色列表不更新 | 创建/删除角色后,触发状态更新的组件可能未正确重新渲染。 | 确保侧边栏组件订阅了state.personas数组本身,而不是某个派生状态。在Zustand中,默认是严格比较,如果更新时创建了新数组,订阅的组件就会更新。 |
| 导出Markdown文件内容乱码或格式错乱 | 1. 消息内容中包含特殊字符未转义。 2. 换行符处理不当。 | 1. 在生成Markdown文本时,对内容中的Markdown特殊字符(如#,*,_,`,[]等)进行转义。2. 确保使用 \n作为换行符,并在Blob中指定正确的MIME类型:new Blob([mdContent], { type: 'text/markdown;charset=utf-8' })。 |
5.2 开发调试技巧
- 善用浏览器开发者工具:
- Console: 查看JavaScript错误和日志(在
openai.ts和store.ts中关键位置添加console.log)。 - Network: 监控所有HTTP请求。重点关注向
https://api.openai.com/v1/chat/completions发起的请求,查看请求头(是否包含Authorization)、请求体(消息格式是否正确)、响应状态和响应内容(流数据)。 - Application -> Storage -> Local Storage: 直观查看和调试持久化的状态数据。你可以手动修改或删除这里的值来模拟各种情况。
- Console: 查看JavaScript错误和日志(在
- 模拟慢速网络和API错误:在开发者工具的“Network”标签中,可以设置节流(Throttling)来模拟慢速网络,测试流式加载的鲁棒性。也可以使用工具(如 Charles、Fiddler)或浏览器插件来模拟API返回错误,测试前端错误处理逻辑是否完善。
- 状态管理调试:对于Zustand,可以安装Redux DevTools浏览器扩展,并在创建store时启用中间件,这样就能像调试Redux一样时间旅行式地调试状态变化。
5.3 项目扩展思路
这个MVP已经具备了核心功能,但还有很大的扩展空间:
- 支持更多AI模型后端:除了OpenAI,可以集成 Anthropic Claude、Google Gemini、开源模型(通过Ollama或LocalAI)等。可以在设置里增加一个“模型提供商”下拉框,根据选择切换不同的API调用逻辑。
- 对话历史管理:目前历史记录是永久的。可以增加“清空当前对话”、“导出全部历史”、“自动清理X天前的历史”等功能。
- 角色分享与发现:允许用户将自定义的角色(包括系统提示词和图标)导出为一个可分享的链接或文件(如JSON)。甚至可以搭建一个简单的社区页面,展示最受欢迎的用户创建角色。
- 高级提示词功能:为角色编辑器增加“上下文长度”、“温度(Temperature)”、“频率惩罚(Frequency Penalty)”等高级参数的设置。
- 本地模型集成:利用WebLLM等项目,尝试在浏览器中直接运行较小的开源模型(如Phi-3 Mini, Llama 3.1 8B的量化版),实现完全离线的、隐私绝对安全的聊天。这将是技术上一个很有挑战性但也很有意义的扩展。
- UI/UX增强:增加语音输入、文本朗读、消息搜索、对话重命名、为不同角色设置自定义的聊天背景或字体等功能。
这个项目就像一棵树的坚实主干,以上任何一个扩展方向都是可以生长出去的枝桠。它很好地演示了如何用现代Web技术构建一个复杂状态、实时交互的应用。无论是用于实际使用,还是作为学习前端工程化和AI应用开发的样板,都很有价值。