1. 这个项目是做什么的,为什么值得关注
1.1 AccessAI 的定位与这次更新的背景
AccessAI 是一个开源的 Web 端 AI 对话工具,你可以把它理解为「自己掌控数据和界面的 AI 聊天前端」。它本身不包含模型推理能力,而是把各家大模型 API,比如 OpenAI、DeepSeek、Claude,以及本地 Ollama 部署的模型,统一包装成一套对话服务。你只需要一个界面,就能在不同模型之间来回切换、管理历史会话、控制上下文长度,而且所有数据都保存在你自己的服务器或本地浏览器里。
这个项目最早其实只是我给自己用的小工具。当时我手上有好几个模型的 API key,也跑着本地模型,但每次对比答案都得打开好几个页面,复制粘贴来来回回,效率很低。后来陆续有一些朋友和网友看到我在社区分享的截图,问能不能开源,我就整理了一下代码放到了 GitHub 上。没想到关注的人比预期多,于是维护着维护着,就变成了一个持续迭代的开源项目。
这次发版算是项目从「能用」到「好用」的一个拐点。之前版本界面比较朴素,代码结构也是典型的「先跑通再说」:前端一个页面堆到底,后端接口里各种 if-else 判断模型名。用的人一多,问题就藏不住了。最多的一类反馈是「我想在 A 模型和 B 模型之间切换对比答案,但每次都要重新开对话」「聊到一半刷新,记录全没了」。这些问题指向同一个方向:AI 对话工具不该只是 API 的搬运工,它得能管理对话,而管理对话的前提是有好的界面容器、统一的多模型接入、可控的上下文窗口,以及可靠的历史存储。
1.2 这次更新的四个核心模块
这次更新的核心就是围绕上面那四个痛点展开的。第一是界面重构,从内到外换了一套组件化架构,支持深色模式、移动端适配,流式打字效果也重新做了。第二是多模型接入,后端抽象出一层统一的 Provider 接口,新增一个模型供应商不再需要复制粘贴大段代码,只需要实现接口。第三是对话上下文管理,引入 token 估算和滑动窗口机制,长对话不再动不动就报错。第四是历史管理,所有会话记录落到本地数据库,支持列表检索、重命名、归档、导出和跨标签页同步。
这篇文章主要面向两类读者:一类是想自己部署或者改造 AI 对话工具的人,另一类是想从零参与开源项目、想看看一个真实项目怎么做架构演进的人。我会把这次更新的设计思路、关键代码逻辑、踩过的坑都拆开讲,尽量还原我当时的决策过程。看完之后,你至少能知道一个合格的 AI 聊天前端应该具备哪些模块,以及每个模块在实现时有哪些容易被忽略的细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新界面重构背后的几个关键决策
2.1 为什么选择整体重构而不是继续打补丁
在动手之前,我仔细盘了一下老代码的问题。老的界面用了一个全局 state 对象管理所有数据,消息列表渲染用的是 innerHTML 拼接字符串,当时觉得快,实际上遇到长对话就明显卡顿,尤其是流式输出的时候,每来一段增量文本就要把整个列表重新渲染一遍,用户体验很糟。加上深色模式、移动端适配这些需求,改起来牵扯面太大,最后还是决定推倒重来,换成组件化写法加 CSS 变量主题。
一个容易被忽略的点:重构前一定要把「用户实际使用路径」列清楚。对 AccessAI 来说,核心路径就三条:发起新对话、在对话中切换模型、回去翻历史记录。其他像设置页、关于页都是次要的。所以新界面的布局围绕这三条路径设计,聊天页永远是主界面,历史记录做成侧边栏抽屉,设置收进一个独立页面,不让它干扰主流程。
重构过程中我给自己定了一条规矩:不在重构的同时加新功能。新界面只能做到「跟旧功能等价甚至更好」,而不是借着重构的名义把功能范围越滚越大。这个规矩帮我避免了很多不必要的返工,也让老用户可以平滑迁移到新版本。
2.2 主题系统与组件拆分的落地
新界面采用 CSS 变量做主题系统,所有颜色、圆角、间距都定义在 :root 里,深色模式只需要切换一个 data-theme 属性。这么做的收益是:以后换品牌色、做高对比度模式都不用改组件代码,改变量就行。我甚至给用户留了自定义主题色的入口,虽然藏在设置页里,但确实有人用。
组件拆分上,我按消息维度拆成 MessageBubble、MessageList、MessageInput、ConversationList 等常见组件,每个组件只干一件事,状态通过 props 传递,数据流是单向的。特别注意了一个地方:AI 回复内容大部分是 Markdown 格式,直接渲染 Markdown 库没问题,但代码块需要单独处理,防止 XSS 风险。我的做法是先经过一个清洗函数,把危险的 HTML 标签过滤掉,再做 Markdown 渲染,代码高亮用的是 Prism,行内代码和块级代码分开处理,测试下来稳定性还不错。
消息气泡里还有一个细节:用户消息和 AI 消息分别靠左靠右显示,AI 消息前面加上模型小标签,鼠标悬停时能看到具体是哪个模型生成的,方便多模型对比。这个功能在社区里好评不少,因为很多人就是冲着对比模型来的。
2.3 流式渲染、滚动控制与移动端适配
流式输出是 AI 对话工具最影响体验的部分。老版本的做法是整段文本一次性渲染,用户要等所有 token 生成完才能看到内容,速度慢的模型体验极差。新版改成逐段渲染:每收到一段增量文本,就更新当前消息的显示内容,视觉上就是打字机效果。实现上用的是 Web Stream 或者 fetch 的 ReadableStream,后端把 SSE 流拆成一个个事件,前端逐个读取并 append 到缓冲区。
这里有个容易踩坑的点:自动滚动。如果每次都强制滚动到底部,用户想往上翻看前面内容时会非常痛苦,甚至会在阅读过程中被反复拉回底部。我的处理策略是「只在用户位于底部附近时自动跟随滚动」,判断方法很简单——监听滚动事件,记录当前 scrollTop 是否接近 maxScrollTop,距离小于 80px 就认为在底部,此时新内容到来才自动定位到最新一行。这个细节看起来小,实际用户反馈是最好的,因为「不打扰」本身就是一种体验优化。
移动端方面,聊天输入框要跟随键盘自适应,不然键盘弹起来内容被遮住一半。Android 上键盘行为各厂商不一致,我最终采用的是动态调整容器高度的方案:监听 focus 和 blur 事件,结合 window.visualViewport 的高度变化来调整输入区域的位置。依赖窗口 resize 事件的方案在部分 WebView 里完全失效,动态高度方案实测下来最稳。
3. 多模型接入的工程实践
3.1 统一模型抽象层怎么设计
多模型是这次更新的重头戏。之前代码里每个模型一套调用逻辑,新增一个模型就要复制粘贴一大段,还容易漏掉某个参数。这次我引入了一个 Provider 抽象层,把「对话请求」这个动作统一成一个接口。
typescript复制interface ChatProvider {
id: string;
name: string;
models(): Promise<ModelOption[]>;
chat(req: ChatRequest): Promise<ChatResponse>;
stream(req: ChatRequest): AsyncIterable<ChatDelta>;
}
所有模型供应商只需要实现这几个方法。OpenAI、DeepSeek 这些走 OpenAI 兼容协议的最省事,因为它们本来就长一个样,后端直接换 base_url 和 api_key 就行。本地 Ollama 模型走 /api/chat 接口,流式参数稍微不同,但整体结构一致。Claude 因为是 Anthropic 自家的消息格式,跟 OpenAI 的 messages 结构差异较大,需要单独写一套消息转换逻辑,但对外暴露的接口不变。Gemini 也类似,历史消息格式有自己的 schema,转换层多写一点代码而已。
3.2 参数映射与模型差异处理
不同模型的参数名和取值范围差异很大。有的模型不支持 system prompt,有的模型 temperature 只支持 0 到 1,有的 max_tokens 上限不一样。如果把这些差异直接暴露给前端,前端会变得非常啰嗦,用户也被迫去理解各个模型的细微差别。
我的做法是做一个参数归一化层:前端只传统一的 temperature、max_tokens、systemPrompt,后端根据模型 id 做映射和 clamp。比如用户界面上 temperature 始终是 0 到 1 的滑杆,但某个模型只支持 0 到 0.5,后端就在请求前乘以 0.5 再传递。这样前端永远不需要关心模型差异,所有适配逻辑集中在后端一张配置表里。
后端实现里有个细节:max_tokens 不能一概而论。上下文长度是 128k 的模型和 8k 的模型,对 max_tokens 的容忍度完全不同。我维护了一张模型参数表,记录了每个模型的 context_window 和 max_output_tokens,请求时取 min(用户设置, 模型上限)。这个表在每次发版时更新,避免用户设置一个很大的输出长度结果请求直接被拒。
3.3 密钥管理、错误重试与自定义网关
多模型接入必然涉及多套 API 密钥。AccessAI 这个项目是自部署友好的,所以密钥尽量不放前端,统一通过后端环境变量读取。前端只负责选模型、发消息,密钥都在服务端管理,用户部署时只需要在.env 里配置好各家 key。前端不需要也不应该知道密钥是什么,这样即使前端被人抓包也不会泄露凭据。
针对企业用户经常会提到的「统一出口」场景,我加了一个自定义 Base URL 功能。用户可以把自己公司内部的网关地址填进去,所有请求都走这个地址。这个在私有化部署时特别实用,比如统一走公司内部的模型网关服务,方便做审计、限流和预算控制。接口设计也很简单,就是在 Provider 初始化时传入 baseUrl 参数,没有额外魔法。
错误重试这个环节容易被低估。实际调用中,限流、超时、网络抖动都是家常便饭。我的策略是:429 限流和 5xx 服务端错误自动重试,最多重试 2 次,间隔采用指数退避(1 秒、2 秒);401 鉴权失败不重试,直接提示用户检查 key;超时时间默认 60 秒,流式请求单独处理,连接建立后长时间没有数据才判定超时。这个策略不是一下到位的,是踩过很多次超时才总结出来的。
4. 对话上下文管理的核心逻辑
4.1 为什么需要显式地管理上下文
大模型的 API 是无状态的,你发什么它就答什么,它自己不记得上一轮聊了什么。所谓「对话上下文」,其实是你每次都要把之前的历史消息重新发给模型,模型才能继续之前的对话。但模型上下文窗口是有限的,如果无限度地把历史都塞进去,很快就会超限报错。
在 AccessAI 的早期版本里,上下文管理基本靠「把全部消息发过去」,长对话聊不到几轮就爆了。用户最常遇到的错误是 400 invalid request 或者 context_length_exceeded,而且之前聊的内容如果被简单粗暴地截断,模型可能突然「失忆」,回答质量断崖式下降。所以这次更新把它单独拎出来做一个模块,核心目标就是在「保留足够的对话记忆」和「不超过模型窗口限制」之间做平衡。
这里要澄清一个概念:上下文窗口不是越大越好。窗口大意味着每轮请求携带的 token 多,成本高、延迟高,而且研究表明过长的上下文反而会引入噪声,模型可能抓不住重点。我的经验是:对大多数日常对话场景,几千 token 的窗口足够,不需要把整本小说的历史都塞进去。
4.2 Token 估算与滑动窗口策略
要做到「主动管理」而不是「报错后处理」,前端就得能估算当前会话用了多少 token。我不想在前端引入完整的 tokenizer 依赖,因为那个库体积不小,而且对前端性能有影响,所以先实现了一个轻量估算函数:英文按字符数除以 4 估算,中文按字符数乘以 0.6 估算,两者相加后向上取整。这个估算结果不是百分百精确,但误差在 10% 以内,作为预警阈值够用了。后端如果要精确计算,可以用 tiktoken 这类标准 tokenizer,前端只负责做提前判断。
估算完之后,发送请求前会先走一遍「上下文适配」逻辑:如果当前 token 数超过预设阈值,就按消息从最早到最新逐步丢弃,直到留下的消息数满足限制。这里有一个非常关键的细节:系统提示词永远不能丢。实现上我会把 system prompt 从消息数组里剥离出来单独存,trim 的时候只处理普通对话消息,请求时再把 system prompt 放到最前面拼回去。
javascript复制function trimContext(messages, maxTokenBudget, estimate) {
let total = messages.reduce((s, m) => s + estimate(m.content), 0);
const trimmed = [...messages];
while (total > maxTokenBudget && trimmed.length > 1) {
const removed = trimmed.shift();
total -= estimate(removed.content);
}
return trimmed;
}
考虑到直接丢消息会让对话「断片」,我还加了一个可选的「自动摘要」模式。这个模式下,如果窗口超限,后端会先把最早的几轮消息发给模型生成一段摘要,然后把摘要作为上下文的一部分继续对话。这个方案不完美,每次摘要会额外消耗一次 API 调用,而且摘要本身也可能丢失细节,但比直接丢消息要聪明一点。目前这个功能默认关闭,用户可以在设置里手动开启。
4.3 上下文可视化与用户习惯
除了算法层面的管理,界面侧还需要一个「上下文占用」指标。我在输入框上方加了一个小进度条,显示当前会话 token 估算值占模型窗口的比例,接近阈值时颜色从绿色变成橙色再到红色。这个功能看起来简单,但对用户行为的改变很大——很多人之前完全没有上下文的概念,聊到模型报错才一脸懵。现在能看到「这一轮用了多少、还剩多少」,他们自己就会主动开新会话或手动清理历史。
弹窗提示也要克制。我的原则是:前端只是预警,不替用户做决定。如果 token 超限,我会在输入框上方显示一条「当前上下文即将达到上限,发送时会自动裁剪最早的消息」的提示,但不会强制阻止用户发送。把事情讲清楚,让用户自己选择,这个交互比单纯禁用发送按钮要友好得多。
5. 历史管理:从一次性对话到可回查的会话库
5.1 存储选型:localStorage 还是 IndexedDB
历史管理这个需求,一开始我差点做错。最初想简单一点,全部存 localStorage,反正一个 JSON 数组就行。后来发现两个问题:一是 localStorage 存储上限大约 5MB,几十个长会话就满了;二是它只能同步读取,数据量大了之后页面加载会卡顿,体验很差。
最后选了 IndexedDB,上限大得多(通常是几百 MB),而且是异步接口,读取操作不会阻塞主线程渲染。成本是 API 繁琐一点,所以我封装了一个轻量 DAO 层,只暴露几个方法:saveConversation、getConversation、listConversations、deleteConversation,内部统一处理 IndexedDB 的事务和游标。新增存储后端时只需要替换 DAO 的实现,上层逻辑完全不用动。
| 对比项 | localStorage | IndexedDB |
|---|---|---|
| 容量上限 | 约 5MB | 通常几百 MB 以上 |
| 读写方式 | 同步 | 异步 |
| 是否阻塞主线程 | 会 | 不会 |
| 适合场景 | 简单配置、小数据量 | 大量结构化数据 |
| API 复杂度 | 低 | 较高 |
对 AccessAI 这种以会话记录为核心数据的应用,IndexedDB 是更合适的默认选择。localStorage 只用来存用户偏好设置和会话列表的版本号,职责清晰,互不干扰。
5.2 数据结构与会话列表实现
历史管理的数据结构保持简单。conversations 表存会话元信息,包括 id、标题、创建时间、更新时间、模型配置;messages 表存消息内容,包括 role、content、meta。这样设计的好处是更新一条消息不会重写整个会话,会话列表页的加载也不需要把全部消息读出来,只读元信息即可,几百条会话也能秒开。
标题生成我用了自动方案:用户发的第一条消息取前 30 个字符当标题,也可以手动重命名。对话列表支持按更新时间排序、按标题搜索、单条删除、清空全部。另外加了一个自动归档策略:超过 30 天没更新的会话默认折叠,避免列表越来越长,但不会真正删除数据,用户可以在「归档会话」里找回。这个策略比较保守,我不想替用户做任何不可逆的决定。
搜索这块我踩过一个坑:一开始只做标题搜索,但用户反馈「我记得里面聊过一个什么话题,但标题是无意义的『帮我写个方案』」。后来改成标题 + 消息内容一起搜索,实现方式是先从 conversations 里取所有 id,再逐条加载 message 做内容匹配。数据量大的时候这个方案性能堪忧,目前靠索引和分页勉强扛住,后续打算引入全文索引优化。如果你只是自己部署给自己用,这个方案够用了。
5.3 导出、迁移与多标签页同步
历史数据的可迁移性是我比较坚持的一点。用户在 AccessAI 里聊的每一条记录都是自己的数据,平台不应该锁死。我实现了两种导出格式:JSON 完整保留元信息,可用于备份和导入恢复;Markdown 方便直接分享或归档,读起来也像一份会议纪要。导入逻辑做了 JSON 格式校验,避免用户误导入损坏文件导致存储异常。
多标签页同步是历史管理里隐藏比较深的问题。用户开着两个标签页,一边聊天一边刷新另一个标签页,数据不统一,体验会很怪。IndexedDB 本身不支持跨标签页自动监听,我用的方案是:在 localStorage 里存一个版本号,每次写会话时递增,其他标签页监听 storage 事件,检测到版本号变化后重新拉取会话列表。这个方案实现简单,实测也很稳定,没有引入额外的 WebSocket 或 BroadcastChannel,算是性价比很高的一招。
6. 实操过程与踩坑记录
6.1 一次完整的多模型切换调试
我的测试场景是:同一个问题分别问 DeepSeek 和本地 Ollama 模型,对比回答质量。本来以为只是切换模型 id,结果第一次测试就翻车——本地 Ollama 模型返回的流式数据格式跟 OpenAI 的 chunk 格式不一样,前端解析器只认 OpenAI 格式,结果整个消息渲染失败。
排查过程花了半小时,最后发现是流式数据解析器里硬编码了 choices[0].delta.content 这个路径,换成 Ollama 的流式格式后完全取不到内容。解决方式是把流式解析也抽象成 provider 的一部分,每个 provider 返回统一的 delta 结构,包含 content 文本和可选的 finish_reason。前端消费统一的 delta,不需要关心底层是 OpenAI 还是 Ollama。这给了我一个很深的教训:不要在公共代码里埋太具体的格式假设,一定要在抽象层边界做转换,哪怕转换本身看起来有点笨。
6.2 上下文截断导致回答「变傻」的排查
有一次测试时发现,一个长对话聊到中间,模型突然「忘记」了最开始提到的重要信息,比如用户一开始说了「我叫小明,是程序员」,但聊到后面模型开始用「你」称呼用户,明显是上下文缺失。我看代码逻辑是对的,滑动窗口确实保留了最近的 N 条消息。后来一查才发现问题:system prompt 也被算进了消息数组,trim 的时候从头部丢消息,系统提示词被当成最早的消息丢掉了。模型失去了全局角色设定,表现自然不对。
修复方案前面已经说了:把系统提示词从消息数组里剥离出来单独存,trim 的时候只处理普通消息,发送请求时再把 system prompt 拼回去。这个 bug 暴露了一个很重要的设计原则:上下文管理的对象应该只是「用户与模型的对话」,系统级指令应该是恒定的、不被削减的。除了 system prompt,已经生成的摘要也应该单独管理,不能混在普通消息里被误删。
6.3 IndexedDB 兼容性与多标签页冲突
IndexedDB 在现代浏览器里基本没问题,但个别的内置 WebView 环境实现不完整,写入大对象时会抛出 DataCloneError。我做了两层防护:写入前先做结构化克隆检测,捕获异常时降级到 localStorage 存储,并在设置页提示用户当前处于兼容存储模式。虽然 localStorage 容量小,但至少聊天功能不断,数据不会丢。稳定性和可用性之间,我选择先保证可用性。
多标签页版本号同步还有一个并发问题:两个标签页同时写会话时版本号会互相覆盖,可能导致最后一次写入丢失。我采用的方案是乐观锁:写之前先读当前版本号,写入时带上旧版本号作为条件,如果发现版本变了就重试。虽然实现上多写了几行代码,但实测并发场景下数据丢失概率降到很低。这个坑比较经典,任何涉及多端写入的场景都可能遇到,值得早点想清楚。
7. 后续规划与参与开源的建议
7.1 下一步想做的功能
接下来优先级最高的是多模态支持,让用户可以上传图片,模型直接理解图片内容,以及在对话里引用文件。这个功能需要在 Provider 层增加附件字段,消息结构也要相应扩展,属于会影响所有已有数据的改动,所以要先把数据迁移方案想清楚再动工。我的思路是在消息表里加一个 media 字段,初始默认 null,老消息不受影响,新消息按需填充。另外还在规划一个只读分享链接功能,适合把某个对话分享给同事看,又不想暴露整个会话库的场景。
插件系统这个方向我也想推进,但属于远期目标。它的核心价值在于让用户可以自定义一些工具调用,比如让模型在回答时能查天气、算汇率。这个方向离得还比较远,但架构上我会提前预留好扩展点,比如 Provider 接口里加 tools 字段,不至于以后为了加功能把代码反过来重构一遍。开源项目最怕的就是架构定死了后面改不动,所以每一步都要留一点余地。
7.2 给想参与开源的朋友几句话
AccessAI 是一个比较典型的 Web 全栈开源项目,前端、后端、存储都涉及,非常适合用来练手。如果你刚接触开源,我建议从这三个地方入手:一是修文档,把不清楚的地方写明白,这听起来简单但价值很大;二是从 issue 里找标记了「good first issue」的任务,这类任务通常范围清晰、改动量小、容易上手;三是自己跑起来之后,把遇到的问题和解决办法更新到 README 的 FAQ 里,你踩过的坑大概率是别人也会踩的坑。
最后分享一个小技巧:参与开源项目之前,先花一个晚上把项目的代码从头到尾读一遍,哪怕是囫囵吞枣。读代码的过程能帮你理解作者的思路和项目的约定,提交代码时就不会因为风格不一致被打回。我自己在维护过程中最深的体会是,开源项目的核心不是代码多炫,而是让每个层级的贡献者都能找到适合自己的切入点,并且能看到自己的改动真的被用起来了。希望 AccessAI 能成为你进入开源世界的第一个项目。
