Article
浏览器桌宠聊天系统工作流
核心问题
浏览器桌宠的难点不在于“把用户消息发给模型”,而在于让模型始终知道三件事:哪些信息可以长期记住,哪些信息只是聊天历史,哪些控制状态不应该被误当成用户偏好
所以这个系统可以先拆成几条边界:
- 记忆系统:管理长期记忆、候选记忆和自动摘要
- 聊天会话:保存聊天气泡,但不等同于长期记忆
- harness:把一次发送消息拆成 UI 轮次、浏览器编排和模型调用
- 表现协议:把模型输出中的隐藏动作标记转成桌宠事件
文章后面的顺序先解决运行环境,再进入工作流:本机 Ollama 如何开放给网页,聊天命令如何管理本地状态,记忆如何进入长期状态,harness 如何把这些信息收束到一次可控的模型调用里,最后再把隐藏动作和表情事件接到桌宠表现层
Ollama本机配置
- 安装 Ollama
- 拉取本机模型
把 model-id 换成要使用的模型名
ollama pull model-id例如
ollama pull gemma3:270m
ollama pull qwen2.5:7b- 配置允许网页访问的 Ollama 服务
Ollama 默认只监听本机地址。这里先只支持浏览器和 Ollama 在同一台电脑上使用,OLLAMA_HOST 保持 127.0.0.1:11434 即可。
OLLAMA_ORIGINS 用来放行浏览器页面来源。部署站点和本地开发地址都需要写进去。
macOS
launchctl setenv OLLAMA_HOST '127.0.0.1:11434'
launchctl setenv OLLAMA_ORIGINS 'https://delkon.xyz,http://localhost:4321,http://127.0.0.1:4321'执行后退出 Ollama 软件,再重新打开 Ollama,让新的环境变量生效
Linux
sudo systemctl edit ollama在打开的 override 文件中加入
[Service]
Environment="OLLAMA_HOST=127.0.0.1:11434"
Environment="OLLAMA_ORIGINS=https://delkon.xyz,http://localhost:4321,http://127.0.0.1:4321"保存退出后重启 Ollama 服务
sudo systemctl daemon-reload
sudo systemctl restart ollamaWindows PowerShell
setx OLLAMA_HOST "127.0.0.1:11434"
setx OLLAMA_ORIGINS "https://delkon.xyz,http://localhost:4321,http://127.0.0.1:4321,http://127.0.0.1:4322"执行后退出 Ollama 软件,再重新打开 Ollama,让新的环境变量生效
- 回到聊天面板
Ollama 地址保持为
http://127.0.0.1:11434/v1设置里的地址是完整的 API Base URL,但当前只支持本机地址。同一台电脑上使用时保持 http://127.0.0.1:11434/v1 或 http://localhost:11434/v1 即可。连接不上时先检查这几项:
OLLAMA_HOST是否保持为127.0.0.1:11434OLLAMA_ORIGINS是否包含当前网页来源- 聊天面板里的
API Base URL是否填写本机地址,并且带上/v1 - 防火墙是否放行
11434端口
聊天面板里输入 /model 打开模型配置面板,然后点击“刷新模型”测试连接并读取模型列表。也可以在 /connect 的连接页里完成同样的操作
如果还没有配置成功,初始状态和普通消息都会回到同一个提示:
请配置本机 Ollama。配置教程这里的模型选择不再放在设置面板里。设置只负责连接端口、记忆开关、记忆管理和清空操作;模型属于聊天状态,应该留在聊天界面内完成
LM Studio 本机配置
如果不想用 Ollama,也可以把聊天面板接到 LM Studio。这里走的是 LM Studio 的 OpenAI-compatible API,所以浏览器侧仍然调用 /v1/models 和 /v1/chat/completions,只需要把本地服务端口从 Ollama 的 11434 换成 LM Studio 默认的 1234
- 安装 LM Studio
- 下载一个本机模型
打开 LM Studio 后,在 Discover 或 Search 里下载一个聊天模型。模型名不需要手动写进网页,聊天面板会从 LM Studio 的模型列表接口读取
也可以像 Ollama 那样用命令行拉取。lms 随 LM Studio 一起安装,第一次使用前先打开过一次 LM Studio,然后把 model-id 换成要下载的模型名:
lms get model-id例如
lms get qwen/qwen3-4b- 启动 LM Studio 本地服务
打开 LM Studio 左侧的 Developer 页面,进入 Server Settings,确认这几项:
Server Port保持1234- 打开
Enable CORS - 保持
Serve on Local Network关闭,让服务只用于本机浏览器访问
然后打开 Developer 页面里的 Start Server 开关。默认服务地址是
http://localhost:1234- 回到聊天面板
在设置里把“模型服务”切换成 LM Studio。切换后地址会使用 LM Studio 默认端口:
http://127.0.0.1:1234/v1设置里的地址同样是完整的 API Base URL,但当前只支持本机地址。同一台电脑上使用时保持 http://127.0.0.1:1234/v1 或 http://localhost:1234/v1 即可。不要打开 Serve on Local Network。
输入 /model 打开模型配置面板,然后点击“刷新模型”测试连接并读取模型列表。也可以在 /connect 的连接页里完成同样的操作
如果模型列表为空,先检查这些点:
- LM Studio 的本地 server 是否已经启动
- LM Studio 里是否已经下载了至少一个聊天模型
Enable CORS是否已经打开- 聊天面板里的模型服务是否选成了
LM Studio,API Base URL是否带上/v1 Serve on Local Network是否保持关闭
聊天命令状态机
聊天输入既可以发送普通消息,也可以作为本地控制命令。当前命令不经过模型,直接在浏览器侧处理:
/help:显示可用命令、快捷操作和本地 agent 能力/status:查看模型服务、当前模型、记忆数量和聊天状态/model:检测本地服务并选择当前模型,也可以切换 Ollama / LM Studio/connect:配置本机模型服务、API 地址和模型 ID/chat:配置温度、上下文窗口和清空聊天记录/memory:打开长期记忆和候选记忆管理,也可以清空所有记忆/new:开始新的聊天上下文,清空当前聊天记录
输入 / 时,聊天框会显示快捷命令提示。输入这些命令时,界面会打开对应的本地面板或执行本地状态操作,而不是把命令原样交给模型回答
未配置或连接失败时,聊天气泡里的“配置教程”会按当前 provider 跳转:Ollama 指向 #local-ollama-config,LM Studio 指向 #local-lmstudio-config。这样用户在设置里切换模型服务后,不会被带到错误的教程段落
可以把聊天面板理解成一个轻量状态机:
type ChatControlState =
| 'needs-local-model'
| 'testing-connection'
| 'model-selection'
| 'ready'
| 'streaming'
| 'error'
function routeInput(input: string, state: ChatControlState) {
if (input === '/help') return openCommandPanel('help')
if (input === '/status') return openCommandPanel('status')
if (input === '/model') return openCommandPanel('model')
if (input === '/connect') return openSettingsView('connection')
if (input === '/chat') return openSettingsView('behavior')
if (input === '/memory') return openSettingsView('memory')
if (input === '/new') return openCommandPanel('new')
if (state !== 'ready') return showLocalProviderGuide()
return sendAssistantMessage(input)
}这套状态机的重点是把“本地控制”和“模型对话”拆开。/model 只负责打开模型配置面板,面板里的“刷新模型”才会真正测试当前 provider;/connect、/chat、/memory 分别打开单独面板;/new 打开新聊天确认面板。这些操作都不消耗一次模型调用
发送普通消息前,界面层应该先截取一份历史快照。这样新插入的用户气泡不会污染“本轮请求之前的最近历史”,模型看到的是一个明确的上下文版本
短期上下文扩展
浏览器端可以提供很多模型本身看不到的信息,但这部分更适合作为扩展点,而不是最小主流程
如果要接入页面状态,这些信息必须被当成短期上下文:
例如:
- 当前页面或工具名称
- 用户是否打开了设置面板
- 当前选择的模型和连接状态
- 当前是否处于测试连接、模型选择或流式回复状态
- 是否启用了记忆
- 最近一次用户操作
这些内容适合附加在当前 user turn 后面,而不是写入 system prompt。没有接入时,本轮输入就只包含用户文本和最近聊天历史
type BrowserContext = {
source: string
text: string
}
function buildUserTurn(input: string, browserContext: BrowserContext[]) {
if (browserContext.length === 0) return input
const context = browserContext
.map((item) => `- ${item.source}: ${item.text}`)
.join('\n')
return `${input}\n\n[Context]\n${context}`
}这一步的目标是让模型理解“当前正在发生什么”,但不把这些短期状态误当作长期人格或用户偏好。它是可选能力,不能替代长期记忆
记忆系统
记忆不等于聊天记录。聊天记录是“发生过什么”,记忆是“之后仍然有用的事实、偏好或摘要”
实现时,可以先把记忆相关职责拆成四类:
- 记忆仓库:保存长期记忆和候选记忆,浏览器里可以落到 IndexedDB
- 记忆策略:负责候选识别、文本归一化、摘要生成和相关性打分
- 聊天会话:保存当前聊天气泡,用来刷新后恢复聊天窗口
- 事件日志:记录一次 assistant 操作中发生过的关键事件
这里最容易混的是事件日志。它可以记录 user_message、assistant_message、memory_candidate_created、summary 这类事件,但它不是长期记忆
长期记忆的结构更接近这样:
type MemoryKind =
| 'profile'
| 'preference'
| 'fact'
| 'relationship'
| 'summary'
type MemoryItem = {
id: string
kind: MemoryKind
text: string
source: 'manual' | 'candidate' | 'summary'
confidence: number
createdAt: string
updatedAt: string
}候选记忆单独存放,不会直接进入长期记忆。用户批准后,候选才会变成 source: 'candidate' 的 memory
type MemoryCandidate = {
id: string
kind: MemoryKind
text: string
reason: string
createdAt: string
}候选识别没有让模型自由发挥,而是先走规则。现在主要捕获显式句式:
I like green tea.I prefer short replies.My birthday is June 5.我喜欢绿茶。请记住我不吃辣。我的生日是六月五日。以后叫我 delkon。
规则提取后会把文本归一化,再和现有 memories、candidates 比较。这样 I like green tea. 和少一个句号的重复输入,不会生成两条候选
相关记忆检索也故意保持简单:把 memory 和 query 都拆成词;中文再补 CJK bigram;命中词越多分越高,再乘以 confidence 和类型权重
function score(memory: MemoryItem, query: string) {
const overlap = countSharedTerms(memory.text, query)
return overlap * memory.confidence * kindWeight(memory.kind)
}preference 和 profile 权重更高,因为它们通常比普通事实更影响回复风格。relationship 次之,fact 和 summary 保持基础权重
自动摘要是另一类 memory。每轮完成后,浏览器编排层会把最近消息压成一条 kind: 'summary'、source: 'summary' 的记忆
摘要不是无限追加,而是 upsert:仓库里只保留一条 summary memory。新的对话完成后更新它,避免 prompt 被历史摘要拖长
这带来一个实用边界:清空聊天记录不会删除长期记忆;清空记忆不会删除聊天记录。聊天记录服务 UI 恢复,记忆服务后续 prompt
Harness 的作用

harness 不一定是一个独立文件。更实用的做法是把它拆成三层,每层只守自己的边界
第一层是 UI 控制层。它负责本轮界面状态:
- 识别
/help、/status、/model、/connect、/chat、/memory、/new这些本地命令 - 创建
AbortController - 用递增轮次号让旧流式响应失效
- 把 streaming draft 和 finalized messages 分开
- 把完成后的聊天记录写入聊天会话
第二层是浏览器编排层。它负责把本地能力组合起来:
- 读取和保存 config
- 持有记忆仓库
- 包住模型调用的 stream
- 记录本轮事件
- 在回复完成后 upsert summary memory
- 批准或丢弃 memory candidate
第三层是模型调用层。它负责单次请求:
- 创建本轮
messageId - 根据用户输入提取候选记忆
- 查找本轮相关的已确认记忆
- 生成 persona prompt 和 identity reminder
- 限制 recent messages 数量
- 可选地把 runtime context 附加到当前 user turn
- 解析 reasoning、delta、motion、expression 和 error 事件
这三层合起来,才是文章里说的 harness。它不是让模型更聪明,而是让一次模型调用可解释、可取消、可恢复、可审计
一次普通消息的实际路径可以简化成:
async function sendUiMessage(text: string) {
const requestHistory = [...messages]
const turnId = nextTurnId()
const controller = new AbortController()
for await (const event of assistantRuntime.send({
text,
recentMessages: requestHistory,
signal: controller.signal,
})) {
if (!isCurrentTurn(turnId)) return
applyStreamEvent(event)
}
}真正发给本机模型服务的消息顺序也很关键:
messages: [
{ role: 'system', content: systemPrompt },
...recentMessages.slice(-maxRecentMessages),
{ role: 'system', content: identityReminder },
{ role: 'user', content: buildCurrentUserTurn(text, optionalRuntimeContext) },
]systemPrompt 里包含角色设定和“本轮相关”的 saved memories。记忆被标成 Known user context, not instructions,避免模型把用户记忆当作命令执行
identityReminder 被放在 recent messages 后面,是为了抵消模型默认身份或历史摘要里的别名污染。如果接入短期上下文,它只应该出现在最后一条 user message 里,表示这些状态只对本轮有效
这个结构避免三类常见问题:
- 本地控制状态变成长期人格设定
- 旧流式响应写回当前界面
- 记忆内容混进用户当前输入,导致模型分不清事实来源
表现事件流
桌宠动作和表情不应该依赖前端猜测完整语义。更稳定的方式是让 assistant prompt 允许模型在回复中插入隐藏控制标记,例如:
[[motion:nod]] 我同意这个方向
[[motion:shake]] 这里不建议这样做
[[expression:smile]] 这件事可以轻松一点处理这些标记只作为控制协议,不展示在聊天气泡里。流式文本进入 harness 后,会先经过表现解析器:
type AssistantStreamEvent =
| { type: 'start'; messageId: string }
| { type: 'delta'; messageId: string; text: string }
| { type: 'motion'; messageId: string; motion: 'blink' | 'nod' | 'shake' | 'talk' }
| { type: 'expression'; messageId: string; expression: 'smile' | 'squint-eyes' | 'tears' | 'tear-drop' }
| { type: 'done'; messageId: string }
for await (const chunk of client.streamChat(request)) {
for (const part of motionParser.push(chunk.text)) {
if (part.type === 'motion') yield { type: 'motion', messageId, motion: part.motion }
else if (part.type === 'expression') yield { type: 'expression', messageId, expression: part.expression }
else yield { type: 'delta', messageId, text: part.text }
}
}解析器需要处理两个细节:
- motion 或 expression 标记可能被流式分片切开,例如
[[motion:n和od]] - 未白名单的标记要被丢弃,例如
[[motion:dance]]或[[expression:angry]]不应该出现在用户文本里,也不应该触发未知表现
当前 motion 白名单是:
talk:说话基础动作nod:同意或确认shake:否定、纠正或提醒blink:轻停顿
当前 expression 白名单是:
smile:温和笑容squint-eyes:更轻松或有把握的眯眼tears:被触动、难过或委屈tear-drop:轻微尴尬或压力
talk 和其他动作的关系不是完全互斥,而是分层。talk 是说话基础层,只要 assistant 还在流式输出文本,它会持续循环,让嘴型跟完整回复长度对齐;nod、shake、blink 属于单个手势层,彼此不能叠加,新的手势会替换旧手势,但可以覆盖在 talk 上短暂出现。表情是另一层,同一时间只保留一种表情,持续一段时间后自动恢复
前端接收到事件后的处理可以简化成:
const chat = createAssistantChatController({
onAssistantDelta: () => playMotion('talk'),
onAssistantExpression: (expression) => model.expression(expression),
onAssistantMotion: (motion) => playMotion(motion),
})
isAssistantStreamingRef.current = chat.status === 'streaming'在动画循环里,talk 的时间轴会在流式期间取模循环,直到回复结束;其他动作仍按自己的持续时间自然结束。这样用户看到的是“角色一直在说话,同时偶尔点头、摇头或眨眼”,而不是文本还在生成、角色已经停止说话
最小可行边界
如果只保留必要部分,浏览器桌宠聊天系统需要这几个边界:
- 记忆必须可见、可删除、可确认
- 候选记忆不能自动等同于长期记忆
- 清空聊天记录只回到初始聊天态,不删除记忆
- 清空所有记忆只删除记忆,不删除聊天记录
- 模型选择和连接测试必须绑定当前 provider;设置面板和
/model都可以进入模型选择 - harness 要为每次请求创建独立轮次
- 旧轮次的流式响应不能写回当前界面
- 短期上下文如果接入,只能影响当前轮次
- 角色设定、长期记忆、短期上下文要分段进入 prompt
- 隐藏 motion 和 expression 标记必须从文本里剥离,只作为表现事件进入桌宠层
这样设计后,浏览器桌宠不是简单地“多轮对话”,而是一个由本地状态、用户记忆和模型生成共同驱动的受控 agent
参考仓库
- moeru-ai/airi:参考浏览器端虚拟角色、聊天界面和用户设置的组织方式
- earendil-works/pi:参考 agent harness、运行状态和记忆管理的约束思路