一个住在桌面里的角色。不是「助手」,是「人」:有名字、有情绪、有声音、会记得你说过的话,安静太久会先开口。
内核是 cell.cpp 单文件智能体库,外壳沿用 cell-app 的组织与打包方式:一个 WebView2 窗口 + 一个只绑回环的本机服务,界面、API、脚本驱动的是同一套协议。
| 空会话 | 记忆 | 设置 |
|---|---|---|
![]() |
![]() |
![]() |
是:一张角色卡 = 一个独立的 cell::engine 上下文(独立会话、独立工作线程、独立人设与记忆)。切角色等于换一个人,不是换一个 prompt。
不是:工具型助手。tools 永久关闭、沙箱 read-only、审批通道直接拒绝 —— 陪伴型智能体不需要在你机器上执行命令,它需要的是记得住、说得像、有反应。
由此带来的三个设计约束,贯穿整份代码:
- 演出优先。模型输出不是最终产物,是「剧本」:情绪标记驱动表情与语音音色,星号里的动作只演不念,正文按句切给 TTS。接了 Live2D 之后,
[[motion:]]/[[expr:]]还能直接指挥身体。 - 服务可插拔。TTS / ASR 各是一份配置(
openai/http/command/off),界面里改完即时生效,不重新编译。 - 传输层无特权。桌面窗口和浏览器打开的是同一个页面、同一条 HTTP + SSE 通道,没有「只有窗口能调」的私有接口。
cell.cpp/cell.cpp 单文件智能体内核(main 改名后整份编进来)
│
src/agent-core.hpp 角色内核:角色卡 / 回合管线 / 长期记忆 / 命令总线 / 事件总线
src/agent-voice.hpp 服务层:TTS、ASR、宿主侧录音(MCI,浏览器采集失败时的兜底)
src/agent-http.hpp 本机服务:页面、命令、SSE、音频与头像
src/agent-ui.hpp 内嵌页面(build_ui.py 生成的 raw string)
src/agent.cpp 入口:窗口、托盘、单实例、命令行
src/ui/src/** 页面源码(css / js 分层,按文件名顺序拼接)
| 文件 | 职责 | 关键点 |
|---|---|---|
src/agent.cpp |
进程外壳 | WebView2 窗口、托盘、单实例互斥、--serve/--headless/--ui/--smoke |
src/agent-core.hpp |
角色内核 | persona / persona_ctx / turn_pipeline / app_core |
src/agent-voice.hpp |
语音服务 | 四种 kind,全部走 libcurl + cell::net::ca_bundle();宿主录音是兜底 |
src/agent-http.hpp |
本机服务 | servic.hpp + router,SSE 每条事件一个 data: 行 |
src/agent-ui.hpp |
内嵌页面 | 生成物,不进版本库手工改 |
src/ui/src/js/07-live2d.js |
舞台 | 运行时加载、模型、动作 / 表情 / 口型 |
src/ui/src/js/08-triggers.js |
触发器引擎 | 事件 → 动作序列,含冷却与内置默认规则 |
src/ui/src/js/09-stage-panel.js |
舞台面板 | 换模型、情绪映射、规则编辑器 |
build_ui.py |
装配器 | 骨架 + css/* + js/* → 单页 HTML + C++ 字符串;@asset(路径) 换成 data URI |
页面 POST /api/cmd/send
│
▼
app_core::exec_command ──► persona_ctx::queue_turn (命令总线在 HTTP 线程上串行)
│
▼ 每个角色一条工作线程
run_turn ──► cell::engine::runner::run_turn
│ │ on_answer 流式增量
│ ▼
│ marker_stream [[emo:…]] / [[act:…]] / [[remember]]…[[/remember]]
│ │
│ ├─► answer 事件 ──► SSE ──► 页面逐字渲染
│ ├─► emo 事件 ──► 头像表情 / 语音音色
│ └─► act 事件 ──► 舞台上的动作字幕
│ ▼
│ sentence_splitter 按 。!?…;切句,洗掉 Markdown 与星号
│ ▼
│ TTS 队列 ──► 语音工作线程 ──► voice::tts ──► /api/audio/<file>
▼
on_turn_end ──► 记忆落盘(memory.jsonl)+ 正文改写(标记不进入下一轮上下文)
线程模型:HTTP 工作线程负责命令(cmd_mx 串行化);每个角色一条回合线程;一条 TTS 线程;一条主动关怀的 ticker。事件走 event_bus,每个 SSE 订阅者各自排队,慢客户端不会阻塞引擎。
| 环节 | cell-app | agent.cpp |
|---|---|---|
| 构建系统 | CMake + Ninja,build/ 落在源码树里 |
同 |
| Windows 工具链 | MSYS2 UCRT64(MinGW),非 MSVC | 同 |
| 链接 | -static,只留系统 DLL 导入 |
同(额外 winmm 给兜底的 MCI 录音) |
| 窗口 | webview.h(WebView2 / WKWebView / WebKitGTK) |
同 |
| 页面 | build_ui.py 装配 → src/*-ui.hpp raw string |
同 |
| 图标 | windres 编 assets/icon.rc(icon.ico → 资源 id 1) |
同(assets/icon.png 是母版,make_icon.py 派生) |
| 服务 | 只绑 127.0.0.1,校验 Host / Origin |
同(另加 Sec-Fetch-Site) |
| 冒烟 | --smoke 隔离数据根,自动退出 |
同(多一档 --smoke-engine) |
几个值得单独说的点:
- WebView2 运行时不用打包。它随系统 Edge 存在;
webview.h的构造只做一次性初始化。所以产物是一个 20MB 左右的 exe,没有第三方 DLL。 - 静态 libcurl 的 CA 问题。静态 curl 自带的 CA 路径是 POSIX 路径,Windows 上必然
curl 60。cell::net::ca_bundle()走系统 ROOT 证书库导出,链接时需要crypt32。 - 页面内嵌 vs 磁盘。
build_ui.py只在内容变化时写盘,src/agent-ui.hpp被声明成 CMakeOUTPUT,所以改一行 css 再cmake --build就会重新编译;想更快就用--ui直接从磁盘读页面,改完刷新,不重新编译。 - 图标只有一张母版。
assets/icon.png是母版,python assets/make_icon.py派生四份:icon.ico(16–256,exe / 窗口 / 托盘)、icon-512.png(发布用)、ui-logo.png(深色界面里的白描版)、favicon.png(浏览器标签)。界面里的图不是走路由,而是@asset(assets/ui-logo.png)在装配时内联成 data URI —— 单文件 exe 里也带着 logo。 - 窗口关闭 ≠ 退出。
webview.h在WM_CLOSE时销毁窗口,所以换掉窗口过程只把窗口藏起来,图标挂在自己的隐藏窗口上(assets/icon.rc的图标被LoadImageW取出来);托盘菜单里有「显示主窗口 / 打开数据目录 / 退出」。 - webview.h 有一处本地改动(麦克风权限放行,见下面「麦克风」一节)。它是 vendored 的单头文件,升级时注意别覆盖掉。
- 单实例。
Local\agent-cpp-desktop互斥体;重复启动只是把已有窗口拉到前台(藏在托盘里也能拉出来)。
# Windows:MSYS2 UCRT64 shell 里
pacman -S mingw-w64-ucrt-x86_64-{gcc,cmake,ninja,pkgconf,curl,libsodium,nlohmann-json,boost}
cmake -B build -G Ninja
cmake --build build # → build/agent.exe# Linux:GTK3 + WebKitGTK
sudo apt install build-essential cmake ninja-build pkg-config \
libgtk-3-dev libwebkit2gtk-4.1-dev libcurl4-openssl-dev libsodium-dev \
nlohmann-json3-dev libboost-dev
cmake -B build && cmake --build build
# macOS:Cocoa + WKWebView
brew install cmake ninja libsodium boost nlohmann-json
cmake -B build && cmake --build buildCMake 选项:
| 选项 | 默认 | 作用 |
|---|---|---|
AGENT_STATIC |
ON |
Windows:静态链接运行时 + curl + libsodium |
AGENT_CONSOLE |
OFF |
保留控制台(调试用) |
AGENT_BUILD_UI |
ON |
构建前用 Python 重新装配页面 |
AGENT_CXX_STANDARD |
26 |
编译器没有 -std=c++26 时改成 23 |
cell.cpp是 submodule:git submodule update --init --recursive。
agent.exe 桌面窗口(自动在 8899..8918 里找空闲端口起服务)
agent.exe --serve [PORT] 桌面窗口 + 固定端口(默认 8899)
agent.exe --headless [PORT] 只跑服务,不开窗口(脚本驱动 / 远程调试)
agent.exe --ui FILE 从磁盘读页面(开发循环)
agent.exe --smoke 冒烟:窗口 → 页面 → 第一条 state 命令
agent.exe --smoke-engine 冒烟:再加一次完整回合
开发页面:
python build_ui.py --html # 只装配 src/ui/index.html
build/agent.exe --headless 8787 --ui src/ui/index.html
# 浏览器打开 http://127.0.0.1:8787/ ,改 css/js,刷新即可数据根固定在 exe 同级的 .agent/(--smoke 时用临时目录,互不污染):
.agent/
├── personas/<id>/
│ ├── persona.json 角色卡:名字、副标题、开场白、人设、称呼、音色、主题色
│ ├── memory.jsonl 长期记忆,一行一条 {t,text}
│ └── avatar.png 头像(没有就用界面生成的渐变脸)
├── sessions/ cell.cpp 的会话存储(每个角色一个 cwd key)
├── audio/ TTS 成品(保留两天)
├── mic/ 录音与上传的音频(保留两天)
├── live2d/
│ ├── runtime/ 运行时库(pixi / Cubism Core / pixi-live2d-display)
│ └── models/<任意>/ 模型(model3.json 及其贴图、动作、物理)
├── config.json 模型供应商与当前模型(cell.cpp 的配置)
├── agent.json 语音服务、界面偏好、上次会话
├── .crypt / .key 加密库:所有 API Key 只在这里
└── logs/cell.log
「服务」在这个项目里是一等公民。TTS 与 ASR 各自是一份可替换的配置,四种形态覆盖了绝大多数接法:
kind |
适用 | 关键字段 |
|---|---|---|
openai |
任何 OpenAI 兼容端点:OpenAI、Azure 兼容层、faster-whisper-server、GPT-SoVITS 的 OpenAI 兼容层、本地 llama.cpp 系 TTS | base(写到 /v1)、path、model、voice、format |
http |
自有 JSON 协议(GPT-SoVITS api_v2 之类) |
url + body 模板,{text} 会被 JSON 转义后填入,另可用 {voice} {model} {lang} |
command |
本地进程:piper、bert-vits2、whisper-cli、任何 Python 脚本 |
cmd 里的 {in} {out} 占位符;TTS 正文走文本文件,不放命令行 |
off |
关闭 | — |
界面里:设置 → 语音合成 / 语音识别,选形态、填字段、点「保存」,再点「试一下」—— TTS 会合成一句「你好,我在。」并直接播放,ASR 会拿最近一条录音转写给你看。
几个实际接过的例子:
voice::service 就是「一份 HTTP / 进程调用配置」,新增一类服务不需要动传输层:
- 在
agent-voice.hpp里加一个函数(照tts()/asr()的样子:kind分支 →detail::post_json/post_audio/spawn_cmd); - 在
app_settings里加一个voice::service字段,load/save各加一行; - 在
exec_command里加service.set/service.test的分支(which == "tts" | "asr"的写法可以直接照抄); - 页面侧
serviceSection(which, svc)是通用的,openSettings()里再挂一节即可。
密钥永远不落配置文件:service.set 收到 key 就写进 cell::encrypt::crypt(.agent/.crypt),agent.json 里只留 has_key。
录音不走宿主,走 Web 平台:
getUserMedia(16k 单声道 + 回声消除/降噪/AGC)
→ MediaRecorder(webm/opus,64kbps,每 200ms 一片)
→ stop() → Blob → base64 → POST /api/cmd/voice.transcribe {data_b64, ext}
→ 服务端 asr 事件 → 页面填框(或按偏好直接发送)
WebView2 就是 Chromium,http://127.0.0.1:8899 属于可信来源(isSecureContext === true),getUserMedia 本身没有障碍。唯一的坑是权限:webview.h 的 PermissionRequested 只对剪贴板读取调 put_State(ALLOW),麦克风留在 COREWEBVIEW2_PERMISSION_STATE_DEFAULT —— 而无边框 WebView2 不会弹出可点的权限框,请求就一直 pending(实测 6 秒不 settle,既不 resolve 也不 reject)。
所以 third_party/include/webview/webview.h 有一处本地改动:麦克风请求按 get_Uri 判断,来源是回环地址才 ALLOW,其余(比如点链接跳出去之后的页面)一律 DENY。升级上游 webview.h 时记得保留这段。
- 实时电平:
AudioContext+AnalyserNode算 RMS,写进--mic-lvl这个 CSS 变量,麦克风按钮的光晕跟着说话大小呼吸。 - 兜底:浏览器通道拿不到麦克风(旧运行时、没有设备、权限被拒)时自动退回宿主的
voice.record.start(Windows MCI),提示会说明切换了来源;两条都不通就直说原因,并指向「上传音频转写」。 - 浏览器里打开:
--headless 8787 --ui src/ui/index.html时页面跑在真浏览器里,权限框由浏览器自己弹,行为和普通网页一致。
所有操作都是 POST /api/cmd/<name>,JSON 进 JSON 出,返回 {"ok":true,...} 或 {"ok":false,"error":"中文原因"}。
| 分组 | 命令 |
|---|---|
| 状态 | state |
| 角色 | personas.list persona.select persona.save persona.delete persona.avatar |
| 对话 | send stop |
| 会话 | session.new sessions.list session.select session.delete |
| 记忆 | memory.list memory.add memory.forget memory.clear |
| 语音 | voice.record.start voice.record.stop(宿主录音,兜底) voice.transcribe voice.speak voice.stop |
| 舞台 | live2d.scan live2d.open_dir |
| 服务 | service.set service.test |
| 模型 | providers.add providers.rm providers.select provider.set_key models.fetch model.set think.set |
| 杂项 | ui.pref open.data_dir app.quit |
# 用脚本跟她说话(host 头必须是回环地址,服务只认自己)
curl -s -X POST http://127.0.0.1:8899/api/cmd/send \
-H 'Host: 127.0.0.1:8899' -d '{"text":"我回来了"}'
curl -sN http://127.0.0.1:8899/api/events # SSE:一条事件一行 data:| 接口 | 说明 |
|---|---|
GET / |
页面(内嵌,或 --ui 指定的磁盘文件) |
GET /api/state |
状态快照(角色、历史、记忆、服务、模型) |
GET /api/events |
SSE 事件流 |
GET /api/audio/<file> |
TTS 成品(文件名白名单,防目录穿越) |
GET /api/avatar/<id> |
角色头像 |
GET /api/live2d/<…> |
舞台资源(运行时库 + 模型文件,逐段白名单) |
POST /api/cmd/<name> |
命令总线 |
事件:hello turn_begin answer_begin answer reason status progress error emo act motion expr round turn_end voice voice_error voice_stop memory asr voice_state quit。除 hello 外每条都带 persona,页面按角色分流。
系统提示里追加的约定(compose_prompt):
| 标记 | 含义 | 去向 |
|---|---|---|
[[emo:happy]] |
情绪:neutral / happy / sad / angry / shy / surprised / thinking / affection |
头像表情 + 语音音色 + 徽标 |
[[motion:TapBody]] |
模型动作组(装了 Live2D 才有) | 画布 |
[[expr:f01]] |
模型表情(装了 Live2D 才有) | 画布 |
*轻轻抱住你* |
动作 / 神态 | 舞台字幕,不朗读 |
[[remember]]一句话事实[[/remember]] |
值得长期记住的事 | memory.jsonl,下一轮进入 system prompt |
[[idle]](用户侧注入) |
主动开口的时刻 | 触发一次「她先说话」 |
流式输出会把标记切碎,所以 marker_stream 做增量解析:遇到可能的标记前缀就按住不放,确定不是标记再原样吐出来。回合结束时正文会被改写成「干净版」,标记不进入下一轮上下文,刷新页面也看不到。
舞台右侧原来是一枚光球(情绪由颜色、眼睛、嘴形共同表达)。接入 Live2D 之后同一个位置换成一块画布;没接模型时自动退回光球,两种形态共用同一份情绪、动作和触发器。面板入口是情绪徽标右边那个旋钮图标。
.agent/live2d/
├── runtime/
│ ├── manifest.json 可选:{"scripts":["pixi.min.js","live2dcubismcore.min.js","cubism4.min.js"]}
│ ├── pixi.min.js
│ ├── live2dcubismcore.min.js
│ └── cubism4.min.js
└── models/
└── hiyori/
├── Hiyori.model3.json
├── Hiyori.moc3
├── Hiyori.2048/texture_00.png …
└── motions/*.motion3.json
- 运行时库不随 exe 分发(Cubism Core 的许可不允许再分发),由你自己放进
runtime/。本地没有时可以在面板里打开「运行时走 CDN」,从 unpkg 现取 —— 只适合先看效果,长期用还是放本地。 - 模型放好后:旋钮图标 →「扫描资源」→ 选模型 → 保存并加载。模型加载完会把动作组 / 表情名写回角色卡(
live2d.groups/live2d.expressions),拼提示词时会列出来,模型才知道自己有哪些动作可用。 - 自定义动作模组:
motion3.json放进模型目录,在model3.json的FileReferences.Motions里给它一个组名(Cubism 的标准做法)。组名就是面板里能选、触发器里能挂、提示词里能写的那个名字 —— 换一套动作不需要改一行代码。 - 资源走
GET /api/live2d/<…>,逐段白名单 + 规范化路径校验,只认.agent/live2d/里面的文件。
角色卡里的字段:
| 字段 | 作用 |
|---|---|
live2d.model |
相对 .agent/live2d/ 的路径;空 = 用光球 |
live2d.scale x y |
缩放与偏移(偏移是画布宽/高的比例,0.1 = 10%) |
live2d.emo |
情绪 → {expr, motion};expr 可以是序号也可以是表情名 |
live2d.groups expressions |
加载后自动写回,供提示词引用 |
装了模型以后 compose_prompt 会多一段「舞台」约定,于是模型可以自己指挥身体:
| 标记 | 含义 | 去向 |
|---|---|---|
[[motion:TapBody]] |
播放动作组 | motion 事件 → 画布 |
[[motion:TapBody/2]] |
指定第几个 | 同上 |
[[expr:f01]] |
切表情 | expr 事件 → 画布 |
[[emo:…]] 仍然管情绪:它同时驱动光球配色、语音音色,以及 live2d.emo 里的表情 / 动作映射。
一条规则(存在角色卡 triggers 数组里):
{
"id": "poke-head", // 唯一名,冷却按它算
"on": "click", // 什么时候触发
"when": { "zone": "head" }, // 条件,全部满足才算命中;留空 = 不限制
"do": [ // 按顺序执行
{ "a": "motion", "group": "TapHead" },
{ "a": "say", "text": "别、别摸头…" },
{ "a": "emo", "name": "shy" }
],
"cd": 5, // 冷却秒数
"once": false, // 本次页面会话只触发一次
"off": false
}事件(on):click 舞台点击 · emotion 情绪变化 · act 星号动作 · motion 模型动作 · expr 模型表情 · reply 回复正文命中文字 · user 你发出的消息 · idle 安静太久 · turn_begin / turn_end · boot 舞台就绪。
条件(when):zone(点击区域,模型声明了 HitAreas 时是区域名,否则 head / body / hand)· emotion · act · group · expr · text(子串或 /正则/)· not_text · min_idle(安静多少秒才触发)。字符串或数组都行。
动作(do):
a |
参数 | 作用 |
|---|---|---|
motion |
group index |
播放动作组 |
expr |
id |
切表情(序号或名字) |
param |
id to ms |
补间一个模型参数,例如 ParamAngleZ |
emo |
name |
走一遍情绪链路(光球 + 语音音色 + 映射表情) |
say |
text ms |
舞台台词气泡,不朗读 |
speak |
text |
交给 TTS 念出来 |
send |
text |
交回回合管线,她真的会回你 |
idle |
— | 让她主动开口一次 |
fx |
name ms |
画面效果:pulse / shake / flash / dim |
sound |
url volume |
放一个音效 |
toast |
text |
右下角提示 |
wait |
ms |
等一会儿(串起多段演出) |
两个方向都是通的:
AI 回复 ──[[emo:]] / [[act:]] / [[motion:]]──► SSE ──► 触发器 ──► 动作 / 表情 / 台词
你戳一下 ──► 画布命中区域 ──► 触发器 ──► send ──► /api/cmd/send ──► 她回你一句
- 触发器跑在页面里,因为点击、口型、冷却本来就是界面状态;规则存在角色卡里,换角色 = 换一套演出。
- 角色卡里还没有触发器时用内置默认(摸头会躲、戳手臂会回话、开心换姿势、安静久了动一下、说到晚安就困)。改任意一条就会写进角色卡;面板里可以「恢复内置默认」。
- 同一次触发里最多只放行一个
send/idle,避免一条规则组把好几轮对话同时点着。 - 面板的规则编辑器是表单式的(事件 → 条件 → 动作列表),右上角「JSON 编辑」是逃生舱口:整份数组直接改,保存前做语法检查。
不需要任何模型,数据根隔离在临时目录,跑完自动退出:
build/agent.exe --smoke # 期望输出 SMOKE_UI_OK
build/agent.exe --smoke-engine # 期望输出 SMOKE_UI_OK (probing engine) + SMOKE_ENGINE_OK--smoke 验证的是「窗口起来了 → 页面加载了 → 页面发出了第一条 state 命令」;--smoke-engine 再多走一个完整回合(没有模型时走错误路径,turn_end 仍然会到)。
- 录音在页面里做(
getUserMedia+MediaRecorder),三个平台一条路;宿主的 MCI 录音只是 Windows 上的兜底,正常流程不会走到它。 - 不做工具调用。这是取舍,不是缺功能:陪伴型智能体不需要在你机器上执行命令,也就不需要审批 UI。
- 服务密钥只在本机加密库里,
agent.json永远只有has_key。 - 服务只绑 127.0.0.1,并校验
Host/Origin/Sec-Fetch-Site。想从别的机器访问,请自己套一层反向代理并加上认证 —— 默认配置下它不是网络服务。
MIT




