浏览器桌宠聊天系统工作流

核心问题

浏览器桌宠的难点不在于“把用户消息发给模型”,而在于让模型始终知道三件事:哪些信息可以长期记住,哪些信息只是聊天历史,哪些控制状态不应该被误当成用户偏好

所以这个系统可以先拆成几条边界:

  • 记忆系统:管理长期记忆、候选记忆和自动摘要
  • 聊天会话:保存聊天气泡,但不等同于长期记忆
  • harness:把一次发送消息拆成 UI 轮次、浏览器编排和模型调用
  • 表现协议:把模型输出中的隐藏动作标记转成桌宠事件

文章后面的顺序先解决运行环境,再进入工作流:本机 Ollama 如何开放给网页,聊天命令如何管理本地状态,记忆如何进入长期状态,harness 如何把这些信息收束到一次可控的模型调用里,最后再把隐藏动作和表情事件接到桌宠表现层

Ollama本机配置

  1. 安装 Ollama

https://ollama.com/download

  1. 拉取本机模型

model-id 换成要使用的模型名

ollama pull model-id

例如

ollama pull gemma3:270m
ollama pull qwen2.5:7b
  1. 配置允许网页访问的 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 ollama

Windows 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,让新的环境变量生效

  1. 回到聊天面板

Ollama 地址保持为

http://127.0.0.1:11434/v1

设置里的地址是完整的 API Base URL,但当前只支持本机地址。同一台电脑上使用时保持 http://127.0.0.1:11434/v1http://localhost:11434/v1 即可。连接不上时先检查这几项:

  • OLLAMA_HOST 是否保持为 127.0.0.1:11434
  • OLLAMA_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

  1. 安装 LM Studio

https://lmstudio.ai/download

  1. 下载一个本机模型

打开 LM Studio 后,在 Discover 或 Search 里下载一个聊天模型。模型名不需要手动写进网页,聊天面板会从 LM Studio 的模型列表接口读取

也可以像 Ollama 那样用命令行拉取。lms 随 LM Studio 一起安装,第一次使用前先打开过一次 LM Studio,然后把 model-id 换成要下载的模型名:

lms get model-id

例如

lms get qwen/qwen3-4b
  1. 启动 LM Studio 本地服务

打开 LM Studio 左侧的 Developer 页面,进入 Server Settings,确认这几项:

  • Server Port 保持 1234
  • 打开 Enable CORS
  • 保持 Serve on Local Network 关闭,让服务只用于本机浏览器访问

然后打开 Developer 页面里的 Start Server 开关。默认服务地址是

http://localhost:1234
  1. 回到聊天面板

在设置里把“模型服务”切换成 LM Studio。切换后地址会使用 LM Studio 默认端口:

http://127.0.0.1:1234/v1

设置里的地址同样是完整的 API Base URL,但当前只支持本机地址。同一台电脑上使用时保持 http://127.0.0.1:1234/v1http://localhost:1234/v1 即可。不要打开 Serve on Local Network

输入 /model 打开模型配置面板,然后点击“刷新模型”测试连接并读取模型列表。也可以在 /connect 的连接页里完成同样的操作

如果模型列表为空,先检查这些点:

  • LM Studio 的本地 server 是否已经启动
  • LM Studio 里是否已经下载了至少一个聊天模型
  • Enable CORS 是否已经打开
  • 聊天面板里的模型服务是否选成了 LM StudioAPI 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_messageassistant_messagememory_candidate_createdsummary 这类事件,但它不是长期记忆

长期记忆的结构更接近这样:

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)
}

preferenceprofile 权重更高,因为它们通常比普通事实更影响回复风格。relationship 次之,factsummary 保持基础权重

自动摘要是另一类 memory。每轮完成后,浏览器编排层会把最近消息压成一条 kind: 'summary'source: 'summary' 的记忆

摘要不是无限追加,而是 upsert:仓库里只保留一条 summary memory。新的对话完成后更新它,避免 prompt 被历史摘要拖长

这带来一个实用边界:清空聊天记录不会删除长期记忆;清空记忆不会删除聊天记录。聊天记录服务 UI 恢复,记忆服务后续 prompt

Harness 的作用

talkingflow

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:nod]]
  • 未白名单的标记要被丢弃,例如 [[motion:dance]][[expression:angry]] 不应该出现在用户文本里,也不应该触发未知表现

当前 motion 白名单是:

  • talk:说话基础动作
  • nod:同意或确认
  • shake:否定、纠正或提醒
  • blink:轻停顿

当前 expression 白名单是:

  • smile:温和笑容
  • squint-eyes:更轻松或有把握的眯眼
  • tears:被触动、难过或委屈
  • tear-drop:轻微尴尬或压力

talk 和其他动作的关系不是完全互斥,而是分层。talk 是说话基础层,只要 assistant 还在流式输出文本,它会持续循环,让嘴型跟完整回复长度对齐;nodshakeblink 属于单个手势层,彼此不能叠加,新的手势会替换旧手势,但可以覆盖在 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、运行状态和记忆管理的约束思路