dsh-roost
v1.0.5
Published
ai-chat 插件的 DSH 宿主:把 QQ 消息交给 DSH 会话回答,带一键接入
Maintainers
Readme
dsh-roost
ai-chat 插件的 DSH 宿主:QQ 消息推过来,交给 DSH 会话回答,再把回答送回去。
让 QQ 机器人从「只会聊天」变成「能用 DSH 的全部能力」 —— 工具、记忆、上下文压缩、主动搭话,都归它。
📚 目录
它是什么(以及不是什么)
QQ ⇄ Roost 内核 ⇄ ai-chat 插件 ⇄ POST /inbound ⇄ 本插件 ⇄ DSH 会话 ⇄ DeepSeek
⇄ POST /reply ⇄它不碰 QQ。 收消息、整形(语音 ASR / 引用 / 合并转发)、发消息、切段、防吵(冷却 / 配额 / 认领)全在 ai-chat 那边。这边只做三件事:
- 起一个固定端口的 HTTP 服务,等 ai-chat 推消息
- 把消息喂给对应的 DSH 会话,等 AI 说完
- 把回复发回 ai-chat 给的地址
为什么要它:ai-chat 直连模型时,AI 只有「聊天」这一件事。挂到 DSH 上之后,它能用 DSH 的工具(文件、命令)、三层记忆、上下文压缩——这是 adapter: dsh 唯一的价值,也是它比直连贵的地方。
特性
- 🗨️ 一个 QQ 会话 = 一个 DSH 会话:断线重连、重启之后映射还在,会话不丢
- 🧭 一键接入:七步向导 + 「一键就绪」,Roost 本体、ai-chat 插件、适配器配置都能自动装
- 🔐 逐条权限判定:每一句都告诉 AI「谁在说、什么档位」,并按档位收本地工具(不是整群一刀切)
- 🧠 三层记忆:跨群/本群事实 + 日记(三级衰减 + 关键词/向量检索)+ 知识图谱(实体·关系·结束时间)
- 🛠️ 记忆工具:给模型
memory_search/memory_note两把「拉」的手,能主动翻旧账 - 🌙 凌晨巩固:打分、升降级、到期压缩、抽三元组、提取约定,全在凌晨做,不花白天的钱
- 🌅 觉醒:约定/想你/随性三类机会打分,到点「问她要不要说」,不是到点硬发
- 💬 对话收尾:她判断这段聊完了就记一句「收在哪 / 下次怎么接」——不发言、不动记忆。下一轮接得上,刚说完晚安 90 分钟内也不会又冒出来(只做私聊)
- 🛠️ 发言走工具:
qq_send/qq_close由模型自己调——分几段说、说完要不要退场、一句都不说,都它定。调几次就是几条独立消息(不是拼成一条),不再是一整段文本被动切段 - 📎 能发文件:
qq_send_file工具,路径直接给(想收紧就开sendFile.restrictPaths)。发之前先问对面支不支持——老版本 ai-chat 会拒,不做「发你了」却什么都没有的空头承诺。一轮一个、200MB 上限,超过 5MB 自动分片 - 📣 能 @ 人:
qq_send的at参数(填sender就是刚说话的人,也可以填 openid)。群里每条消息头带[昵称 (openid)],标签由宿主拼——模型不碰那串 id 的语法,抄错了也没机会发出去。私聊 / 非第一段 / markdown 关着时会自动降级并告诉模型为什么 - 🗜️ 上下文压缩:到阈值(群 85% / 私聊 75%)开新会话 + 摘要开场,旧会话保留可回查;压之前那一刻的缓存命中率记在面板和日志里,阈值该往上还是往下调用它判断
- 📱 侧栏面板:十一页仪表盘,状态、记忆、人格、能力(谁能用哪些工具)、排期、账号迁移、日志全在 DSH 里看
- 🧬 人格四段式:人格文件分性格 / 开场白 / 说话示例 / 世界书四段。世界书一行一条
触发词 | 内容,命中才注入;开场白给主动搭话一个语气锚;近处指令贴在离历史最近的位置,聊到几十轮人设也不被冲淡 - 🔄 人格库一键更新:人格放在独立仓库 dsh-roost-personas,改完推一个 commit,面板点一下就拿到——不用发版。只覆盖没改过的,你改过和自建的一个不碰
- ⬆️ 本体自更新:设置页顶部查最新版、一键装。走 DSH 自己的
pluginManager(失败会回滚package.json+pnpm-lock.yaml),装完提示重启。开发机(符号链接装的)自动禁用 - 🎭 双人格 preset:私聊(
roost-private)/ 群聊(roost-social)各一套提示词 - 🖼️ 表情包:图丢进目录就能用,文件名或侧车
.tags当标签;图多了建子目录分类,sticker_pick按需取图——加图不会冲掉提示词缓存 - 🔊 语音合成(选装,默认关):四种 provider 任选 —— 本地模型(一键装 23MB 引擎 + 30MB 模型,完全离线)、微软在线朗读(免费、不要 Key,一键装 0.4MB 依赖)、OpenAI 兼容
/audio/speech、本地 GPT-SoVITS 原生 API。语速一份tts.speed四个 provider 共用,切换时不用重填。不配就完全静默——不请求上游、不加载模型、不占提示词;配好后模型才能用<<say: 内容>>发语音条(转 silk 和发送在 ai-chat 那侧) - 🔑 账号迁移:把 QQ号 / 群号和 openid 对上,换机器人(换 AppID)后旧记忆照样读得到。支持批量追加 openid、群成员 QQ号登记(需本人提供)、昵称候选提示,以及「一键改写」——把某号码名下所有旧键合并到号码键后删掉(带备份与一键回退)。昵称候选只提示不自动绑:官方不给 QQ号,自动猜会把别人的记忆串过来。绑定完全自愿、非必需,不绑定不影响任何功能(详见「账号迁移」章节的声明)
安装
DSH 插件市场里输入包名:
dsh-roost或命令行:
dsh plugin --profile web add dsh-roost前提:DSH 桌面版 / Web 组合(需要
sessionController和agents)。 Roost 和 ai-chat 不用提前准备——「接入」里会装。
从源码装也行:
git clone https://github.com/tdcq9594/dsh-roost.git一键接入
装好插件后,侧栏「Roost → 接入」是一条七步路径,能自动的都自动做掉:
| 步 | 做什么 | 自动? | |---|---|---| | 1 | 装 Roost(下载解压,零依赖,不用 npm install) | ✅ 点一下 · 已装过可点「已装过?」填路径绑定 | | 2 | 启动 Roost | ✅ 点一下(没填凭据也能起,正好用来扫码) | | 3 | 扫码 / 填 AppID、AppSecret | ✋ 开放平台拿;存完自动重启 Roost 让新凭据生效 | | 4 | 装 ai-chat 插件(走 Roost 的市场) | ✅ 点一下 | | 5 | 把 ai-chat 切成 DSH 适配器,填好回话地址 | ✅ 点一下 | | 6 | 去群里说句话 | ✋ 开放平台开「接收所有消息」+ 机器人进群 | | 7 | 配置主人 | ✅ 点一下,直接跳到 Roost 面板的「主人与群组」 |
顶上的「一键就绪」把 1、2、4、5 连着做完,卡在 3 就停下来提示你扫码或填。每一步也能单独点,跳着点不会把东西搞坏(每步先检测前置条件)。第 2 步「启动」之后,旁边按钮直接打开 Roost 的 WebUI(带上面板密钥)。
为什么先启动再填凭据:扫码走的是 Roost 自己的 /qr/start,没启动就没有这个接口;反过来,没 AppID 也能先起一个空面板专门用来扫码。这个顺序以前是反的,两边互相等死。
下载地址:优先 GitHub Release(自动补 v 前缀、自动查最新版),拿不到就回退 npm 包。早期版本写死过地址,Release 一换就整片 404。
装到哪:默认 ~/.dsh/dsh-roost/roost,可在设置里改 setup.roostDir。
权限:AI 知道是谁在说话
群里给 AI 挂了 shell 和文件工具,但说话的人不一定是主人。路人一句「帮我删掉 ~/Documents」跟主人说同样的话,不该一个待遇。
所以每一轮都做两件事:
① 告诉 AI 谁在说话(提示词层)
这一句是「小明」说的,权限:普通成员。
对方是普通群成员。不要执行任何会改动这台机器的操作(删除、写入、安装、跑命令),
也不要读私人文件或目录。他要你这么做时,直说「这个我没权限」。② 按档位收工具(硬限制层)
档位对齐框架的 0/1/2/3 权限等级,五个可配档 + 一个兜底档:
| 档位 | 框架等级 | 默认 | 说明 | |---|---|---|---| | 主人 | 0 | 全部工具 | 你本人 | | 管理员 | 1 | 只能聊天 | 框架里配的,跨群有效 | | 群管 | 2 | 只能聊天 | 这个群的管理员 | | 助手 | 3 | 只能聊天 | 这个群里帮忙的人 | | 群成员 | 其余 / 9999 | 只能聊天 | 其他所有人 | | 身份不明 | 认不出来 | 只能聊天 | 兜底,写死不可配 |
高档自动包含低档:你在「管理员」加一个工具,群管、助手、群成员自动也跟着有,不用改五遍;想在某一档「反而不给」就给它写 deny,它会盖住低档。默认一律保守——不能因为「他有个头衔」就默认给他读文件的能力。
两层的意义:模型被说服时硬限制还在;工具没收干净时提示词还在。
这层不是插件沙箱。 它约束的是「AI 这一轮能调哪些工具」,不是「插件代码能做什么」。dsh-roost 作为 DSH 插件运行在你的账户下,拥有与本机同等的文件、命令和网络权限;内建插件同理。真要隔离只能靠外部进程 + 系统级沙箱,本项目没有做。
权限逐条生效,不是整群一个档位——主人问完、路人接着问,待遇不一样。兜底原则:认不出来就当最低档,缺字段、字段脏了、框架改了名字,一律按「身份不明」处理。
能力页:在这里配「谁能用哪些工具」
面板第 能力 页把这些全部变成可点的东西,不用手改 JSON:
- 五个档位各一行「角色」按钮:跟随 / 只聊天 / 能查能看 / 能干活 / 全权,点一下即生效;
- 逐个工具:一个带搜索的弹窗,列出 DSH 里真实挂着的工具(装了新插件、接了新 MCP 会自动多出来),每个工具五个勾位,危险动作标 🔴;
- 分群例外:默认按档位,只在要例外的那几个群上配「只能聊天」(硬承诺,不随全局配置漂移);
- 功能开关:11 项「有没有这个功能」,和上面「谁能用」是两层——这里关掉,谁的调不出来;
- 次数限制:
联网搜索一天 20 次这种,按工具配、按档位配,超了当场拒绝这一次调用。
启用前先问一遍:
capabilities.json不存在时这一页完全不参与决策,走的是老的permissions配置——升级上来行为不会变,点「启用能力策略」才接管。改完下一条消息生效,不用重启 DSH。
面板
| 标签 | 内容 | |---|---| | 概览 | 四个数字(会话数 / 正在等 / 最近一轮耗时 / 能用工具的档位)、状态条、一键自测、最近动态 | | 接入 | 七步一键流程(上面那张表) | | 人格 | 人格列表与编辑、从仓库更新(带「几个可更新」角标)与仓库面板(预览 / 恢复官方版),「我的人格 / 人格仓库」分段切换 | | 能力 | 五个档位各能调哪些工具、逐个工具勾选(弹窗 + 搜索)、分群例外、11 个功能开关、按工具/按档位的次数限制 | | 记忆 | 会话选择器 + 三层:跨群/本群事实、日记(三级)、知识图谱;检索、手动记、从旧版导入、对话收尾、她自己、清空 | | 主动搭话 | 此刻情绪(三维条形图,只读,可用 👍/👎 影响)、这一轮算出来的机会、私聊排期、群聊主动(默认全关)、现在试一次 | | 上下文 | 窗口占用、压缩触发、这一轮注入了什么、记忆工具状态 | | 会话 | 每个 QQ 会话对应的 DSH 会话,可「清空」(下一句重建) | | 账号迁移 | 把记忆里的旧 openid 对上 QQ 号 / 群号,换机器人(换 AppID)后旧记忆照样读得到;扫描只读、群成员绑定、批量追加 openid、昵称候选(只提示不自动绑)、带备份与一键回退的「一键改写」 | | 设置 | 最上面是更新(本体 + 人格库);下面是配置全文(JSON)+ 分节说明与表情包 / 语音条设置,改完保存即热重载 | | 日志 | 最近 400 条动静,按级别筛,3 秒自动刷新 |
自测不经过 QQ,直接跑一轮 DSH 对话,验的是「会话建得起来、AI 回得来、回复取得回来」——链路里最容易坏的那段。QQ 那半边(收消息、发消息)它测不到,那部分出问题看日志。
配置
存在 ~/.dsh/dsh-roost/config.json(可用 DSH_ROOST_DATA 改目录),面板「设置」页直接编辑。
{
"server": {
"port": 17321, // ai-chat 的「DSH 地址」默认指向这里,两边要一致
"token": "" // 共享密钥;留空不校验(只绑本机时可行)
},
"chat": {
"turnTimeoutSec": 150, // 要小于 ai-chat 那边的 180 秒
"upstreamRetry": { // 上游 502/429/超时时的补重试
"enabled": true,
"attempts": 1, // 补几次(1 = 失败后再补 1 轮,总共 2 轮)
"delayMs": 3000, // 补之前等多久
"budgetSec": 170 // 整轮总预算,必须小于 ai-chat 的 180
}
},
"workspace": {
"dir": "" // QQ 会话挂哪个工作区:留空=单开 <数据目录>/workspace,
// __default__=用 DSH 默认工作区,其它值=自定义目录
},
"permissions": {
"owner": "all", // 主人:全部工具
"admin": "chat", // 管理员:只能聊天
"member": "chat", // 普通成员:只能聊天
"unknown": "chat" // 兜底:认不出身份只给聊天
},
"setup": { "roostDir": "", "version": "v0.8.6", "registration": "qr" },
"memory": { /* 日记寿命、注入条数、多少轮全量重注、记忆工具开关、向量检索 */ },
"compact": { /* 群聊/私聊两档压缩阈值、开场带几条原话 */ },
"awake": { /* 清醒时段、机会分门槛、群白名单 */ },
"speakUp": { /* 私聊排期:间隔/免打扰/每日上限 */ },
"persona": { "current": "default", "byChat": {} },
"stickers": { "enabled": true, "dir": "" },
"tts": { // 选装,默认关;面板「设置 → 语音合成」有专用表单
"enabled": false,
"provider": "openai", // sherpa(本地离线)| edge(微软在线)| openai | gpt-sovits
"speed": 1, // 四个 provider 共用的语速,0.5–2,1 是原速
"baseUrl": "", // openai: 含版本前缀,如 https://api.openai.com/v1
"apiKey": "", // 面板里显示成 ******,原样保存不会覆盖真值
"model": "", // openai: tts-1
"voice": "", // openai: alloy
"timeoutSec": 30,
"maxChars": 300, // 太长的会被 QQ 语音条截断,不如发文字
"sherpa": { // provider=sherpa 时才用;引擎和模型都在数据目录,删目录即卸载
"model": "aishell3", // 30MB / 8kHz / 174 个音色
"modelDir": "", // 留空 = 数据目录/tts-models/vits-icefall-zh-aishell3
"sid": 88, // 说话人编号(0-173),换一个就是换一个音色
"numThreads": 2
},
"edge": { // provider=edge 时才用;不用填地址和 Key
"voice": "zh-CN-XiaoxiaoNeural"
},
"gptSovits": { // provider=gpt-sovits 时才用
"installDir": "", // 含 api_v2.py 的那层,填了才能在面板一键启动
"model": "", // 扫描出来的角色名;留空 = 用 tts_infer.yaml 里已加载的
"gptWeights": "", // 手填权重路径,优先于按角色名自动配对
"sovitsWeights": "", // 两个都填最稳;只填一个时面板会警告另一半仍是猜的
"baseUrl": "http://127.0.0.1:9880",
"apiKey": "", // 走反向代理时才填;本地原生 API 留空
"referWavPath": "", // GPT-SoVITS 那台机器上的路径
"promptText": "", // 必须与参考音频逐字一致
"promptLanguage": "中文",
"textLanguage": "中文",
"apiVersion": "v1", // v1=api.py / v2=api_v2.py
"endpointPath": "/"
}
},
"channel": { "current": "qq" }
}两边的地址要对上:ai-chat 的「DSH 地址」= http://127.0.0.1:<port>。
server.token 非空时,dsh-roost 会把它同步到 ai-chat 的「共享密钥」——
不同步的话 /inbound、/capabilities、/tts 全是 401,而两边都不会主动说「密钥不对」。
本地语音(provider=sherpa):面板点「一键安装」,自动下 23MB 引擎(sherpa-onnx-node)
- 30MB 模型(aishell3),解压后一共约 230MB,装到
数据目录/tts-runtime和数据目录/tts-models。 不进插件目录——DSH 更新插件不会冲掉,卸载就是删这两个目录。
- 那 230MB 里有 173MB 是
rule.far(多音字规则库)。它让「行长/银行」「2024年」这类读得准,所以留着 - 解压用系统自带的
tar(Windows 10 1803+ / Linux / macOS 都有)。没有 tar 时会明确报错,不会假装在装 - GitHub 下不动时,手动下
vits-icefall-zh-aishell3.tar.bz2解压到tts-models/vits-icefall-zh-aishell3/即可,面板会认 - 首次加载模型约 5 秒,之后合成 6 秒语音约 130ms。装完会自动预热,平时启动也会在后台预热一次
sid是说话人编号(aishell3 有 174 个),换一个就是换一个音色
GPT-SoVITS(provider=gpt-sovits):面板填一个安装目录,点「一键启动」就行。
- 自动认目录:找
api_v2.py/api.py、runtime\python.exe,从tts_infer.yaml读出当前加载的角色和版本 - 扫描成下拉:把
GPT_weights_*/SoVITS_weights_*里的权重按角色名配对,同名的取最新 epoch,做成「角色」下拉;参考音频也扫出来做成下拉。选中的角色在服务就绪后通过/set_gpt_weights+/set_sovits_weights切过去,不改你的tts_infer.yaml - 自动挑端口:从 17860 往上试绑。不要用 9880——它落在 Windows 保留段 9861–9960 里,绑上去是
WinError 10013 - 就绪后自动把
baseUrl/endpointPath/apiVersion写进配置,不用手抄 - 参考音频只在你没填的时候才替你选;填过就尊重原值
- 语种填「中文」「日文」就行,会自动转成 API 要的
zh/ja。v2 的字段是text_lang/prompt_lang,v1 才是text_language - 加载模型要一两分钟,期间面板会显示日志尾部;插件重载时会把进程一起带走
- 选的就是配置文件里已加载的角色时会跳过切换:v2 切一次权重等于重新加载一遍模型,没必要白等一轮。切换失败时面板红字写明「现在实际生效的是谁」,不会显示「正常」却放着别的音色
- 音色不对时优先手填权重:自动配对靠文件名猜角色名,权重一多(比如十几个)很容易把不同 epoch / 不同角色的 GPT 和 SoVITS 配错一对。填了
gptWeights/sovitsWeights就以它为准;只填一个时面板会警告另一半仍是自动配对的。另外参考音频也要和模型配套,音色主要由它决定
微软在线朗读(provider=edge):不用填地址、不用填 Key,选一个音色就能用。
- 依赖是三个纯 JS 包(约 0.4MB),点「一键安装依赖」装到
数据目录/tts-edge/node_modules——不进插件目录,DSH 更新插件不会冲掉;也刻意和本地模型的tts-runtime分开,两边不会互相删 - 输出 mp3,宿主自己转 silk。要念的文本会发到微软的服务器,介意隐私就用本地模型
@andresaya/edge-tts是 GPL-3.0,只下载到你自己的数据目录,不随插件分发- 卸载本地模型后不会再刷「预热失败」:没装是正常状态,装了但加载不起来才是故障
语速:tts.speed 一份配置,四个 provider 共用,范围 0.5–2。
| provider | 映射成 |
|---|---|
| 本地 sherpa | 推理参数 speed |
| OpenAI 兼容 | 请求体 speed(只在非原速时带,避免被中转服务拒) |
| GPT-SoVITS v2 | speed_factor;v1 接口没有语速参数,不支持 |
| 微软朗读 | rate: '+20%' 这种百分比字符串 |
以前只有 sherpa.speed,现在统一到 tts.speed;老配置会自动迁移,不用手动改。
账号迁移(v1.0.0)
QQ 官方事件只给 openid(群是 group_openid),从来不给 QQ号。换机器人、
换 AppID 之后 openid 就变一次,旧记忆还在盘上却再也没人去找它。
这一页把号码和 openid 对上——读取时合并,存量数据一个字节不动。
绑定完全自愿、非必需:QQ 官方接口不提供 QQ号 / 群号,本项目不会以任何方式获取、推算或抓取;号码只来自用户本人主动提供,或由管理员依据本人提供的号码代为登记(应事先取得本人同意)。不绑定不影响任何功能,未绑定时行为与上线前完全一致;绑定信息仅存本机
identity.json,可随时解绑 / 停用,不上传、不共享,也不用于「认人认群、找回旧记忆」以外的用途。请勿把提供号码设为使用条件,也不要替他人报号;昵称候选只提示不自动绑,正是为了不替用户猜身份。
| 分区 | 干什么 |
|---|---|
| 01 待迁移 | 本机数据里有记忆、但还没绑过号码的 openid 键 |
| 02 已绑定 | Roost 身份表里的号码;给已有号码追加新 openid(旧的一条不删) |
| 03 群里的人 | 群发言人的 QQ号。会话键里没有他们(除非开过私聊),数据来自 Roost 的 unresolved.users |
| 04 批量追加 | 选一个号码 → 勾多个新 openid → 一次提交。换机器人后让新旧 openid 都指向同一个号码 |
| 05 一键改写 | 把某号码名下所有旧键合并到号码键后删掉,把结构改干净 |
昵称候选只提示,绝不自动绑:官方不给 QQ号,uin 是从绑定表反查的,
按昵称 / 群名猜出来的「这个人可能是 10001」没有任何权威依据,绑错会把
别人的记忆串过来。候选只帮你少打字,必须人工确认。
⚠️ 一键改写是唯一会重写记忆数据的操作
前面所有操作都只写 identity.json(叠加层),这一个会真的改
memory.json / sessions.json / journal.sqlite / graph.sqlite。
执行前必须:
- 先停掉旧机器人——它还跑着就会继续往旧 openid 写记忆,改完又多出一个键
- 点「预览改写清单」,核对要动的文件(绝对路径)、每个键的条数
- 勾选「已停掉旧机器人」+ 原样输入「改写」,两样齐了才能执行
执行时自动整文件备份到 D://WorkBuddy//_archive//2026-11-04-账号改写//<时间戳>//
(这个盘写不了时退到 数据目录/rewrite-backups),失败自动回退;
成功后「可回退的备份」列表里仍可一键回退。改写和回退成功后页面会重新拉数据,
上面「待迁移」列表里那些键会消失。
表情包
往里丢图,她自己在合适的时候配一张。不发明新机制:发的还是 <<image: 路径>>,
这边只负责让她知道「有哪些图、这些图是什么意思」——她不认识文件名以外的东西。
图放在 <数据目录>/stickers/(面板「设置 → 表情包」能直接改路径、看到字数和张数)。
图分散在好几个目录时,stickers.dir 可以写成数组,合并成一个库:
"stickers": { "enabled": true, "dir": ["D:\\表情包", "E:\\旧备份"] }面板里能直接增删这几行路径,也能看出哪个路径打不开(填错盘符时不会只显示「共 0 张」)。
两个目录里同名的分类(都有 开心/)会合并计数,根目录的图也都归同一个「未分类」。
最省事:平铺
所有图丢在根目录,文件名就是标签:
stickers/
开心_猫.png → 标签「开心」「猫」
无语-2.gif → 标签「无语」(序号被丢掉)
联动壁纸 (1).png → 标签「联动壁纸」(括号里的序号不算标签)分隔符认 _ - 空格 ( ) () · 、 , .,纯数字当序号丢弃,最多取 4 个。
文件名不够用时:侧车标签
中文文件名没有分隔符就拆不出词(熊猫开心.png 只能得到一个整串标签),
这时候在同目录放一个同名 .tags 文件,里面只写一行(海妖.png.tags 就对应 海妖.png):
海妖, 立绘; 提到深海、怪物时
↑标签 ↑场合(`;` 之后都算,会原样给模型看)侧车优先于文件名。这是给图补语义最省事的办法——不用改名,也不动原文件。
标签最多 6 个(走文件名那条路最多 4 个)。注意 .tags 里没有注释语法,
写 # 会被当成标签的一部分。
图多了:分类
在 stickers/ 下建一级子目录,目录名就是分类:
stickers/
开心/
_desc.txt ← 一句说明,写「高兴、庆祝的时候用」
a.png
无语/
b.png
零散.png ← 根目录的图归「未分类」,不会消失有分类之后,提示词只列「分类名 + 张数 + 说明 + 最多 3 个示例文件名」,
具体文件由 sticker_pick 工具按需给——图加到几百张,提示词也不会跟着涨。
每类留 3 个示例是兜底:万一工具没挂上(DSH 版本差异),模型还能直接拼路径发图。
从聊天缓存里同步
群里收到的图已经缓存在 <数据目录>/images/(一张一份,按内容哈希命名)。
面板「设置 → 表情包 → 同步缓存图」能把里面像表情包的搬进 未分类/:
- 筛选只看尺寸:长边 ≤1024、长宽比 ≤1.8、5KB–1MB。实测 21 张缓存能切掉 2560×1600 截图、1920×1080 壁纸、1469×161 长条,留下 12 张 240×240 这类真表情。
- 先预览再落盘:弹窗里带缩略图,不对的取消勾选;被规则挡掉的也能手动勾上 (比如一张 1074×947 的近方形图会因为是「边长超标」被跳过,但你可能就是想要它)。
- 顺手打标:每行两个输入框。填「文件夹」就进那个分类(已有的分类会作为输入提示),
填「标签」就用它当文件名——
开心, 猫→开心_猫.jpg,同时写一份.tags侧车。 两个都留空就是老规矩:进未分类/,文件名叫缓存.jpg。同名自动加_2,不覆盖。 顶部还有一排「批量」,先勾几张再一键套上同一个文件夹和标签。 - 复制而不是移动——缓存里的路径还被历史对话引用着。同步过的记在
stickers/.synced.json,不会重复收;删了图想重来就点「重置同步记录」。
为什么不做内容识别:permissions 里群成员是 chat 档,allow 没有 read_image,
所以模型看不见群友发的图,只有主人那一轮(owner: all)看得见。要覆盖所有人就得
给群里每个人开放读本地文件——不值。于是这些图没有语义标签,清单里只显示
「缓存同步,等待分类」。真正让它们可用的一步,是把图拖进 开心/、无语/
这类目录:目录名就是唯一的语义,模型只认它。
她会怎么选
- 平铺:清单里有每张图的文件名和标签,她自己判断该不该用
- 分类:先调
sticker_pick("开心")拿 1~3 个真实路径,再用<<image: 路径>>发 - 两种模式都会告诉她「没有合适的就别发」——宁可不配图,不要为了配图硬凑
同一张图不会连着给两次(按「最久没给过」排序);会降级成文件的图排在后面。
几个硬限制
| 限制 | 为什么 |
|---|---|
| 超过 200MB 的图不进清单 | 官方硬限,分片上传也发不出去 |
| 超过 20MB 的图仍可用,但会标 degrade | 超过软限会被降级成文件发送,群里看到的是附件不是表情 |
| 一次最多扫 1000 张 | 扫描是同步 IO,几千张会卡住消息处理;超出部分清单里会写「只扫了前 N 张」 |
| 清单有 3 秒缓存 | 丢图进去最多 3 秒后才进提示词 |
发出去是什么样
是普通图片消息,不是 QQ 收藏表情——对方收到的是图,不会进他的「表情」收藏夹。 这是协议层的限制,插件改不了。
目录结构
src/
host.js 插件入口:起服务、会话映射、面板接口、记忆工具挂载
server.js HTTP /inbound(鉴权、立刻 200、回话)
inbound.js payload → 给 AI 看的一段话
turn.js 跑一轮:投递 + 从 session/event 等结果
sessions.js chatId → DSH 会话(存盘)
permissions.js 权限:谁在说话、能用什么工具
capabilities.js 能力策略:五档栈式继承 + 分群覆盖 + 与工具池求交(纯逻辑,可单测)
capability-store.js capabilities.json 的读写,每轮观察工具池的新增/失效
features.js 功能开关:11 项「有没有这个功能」→ 关掉之后该收走哪些工具
quota.js 次数配额:固定窗口 + 幂等去重,默认不限(填了数字才算)
setup.js 一键流程:下载解压、写配置、起停、装插件、配适配器
config.js 配置读写(默认值 + 合并)
memory.js 三层记忆第一层:跨群/本群事实
journal.js 日记:三级衰减 + FTS5 关键词 + 可选向量检索(RRF 融合)
graph.js 知识图谱:实体 / 关系(双时态) / 原始记录
embed.js 可选向量:只接一个 /embeddings 端点,不配就纯关键词
tokenize.js 中文切词(索引侧存二元组,FTS5 对中文是废的)
consolidate.js 凌晨巩固:打分 → 升降级 → 到期压缩 → 抽三元组 → 提取约定
personas.js 人格库:本地 personas/*.md 的读写(正文分四段)
persona-repo.js 人格仓库:拉 index.json、一键更新、恢复官方版、静态扫描
update.js 本体自更新:查最新版、装法检测(符号链接禁用)、豁免 24 小时冷却
version.js 版本号比较(三处共用,按数字比不按字符串比)
awake.js 觉醒:约定/想你/随性三类机会纯函数打分
compact.js 上下文压缩:读占用 → 开新会话 + 摘要开场
channel.js 通道抽象(以后接别的平台时切这里)
registration.js 接入方式抽象(扫码 / 手动填)
lease.js 端口租约:端口被占时自动换
sync-retry.js 「等一下再试」的退避调度(Roost 比我们晚起时用来补同步 ai-chat 地址)
tools.js 记忆工具(memory_search / memory_note)定义与挂载
client/
client.js 侧栏面板(React,沿用 DSH 主题变量)
cordis.patch.yml 两档 agent preset(私聊 / 群聊)
personas/ 内置人格镜像(8 份)+ index.json(生成物)。权威源在独立仓库 dsh-roost-personas
scripts/ 生成人格索引、dry-run、同步 GitHub
docs/ 设计文档、调研、和 Aurabot 的对比核心技术点:DSH 没有「发一句返回一句」的接口,是事件驱动的。turn.js 监听 session/event,收 assistant/message(可能来多次,取最后一次)和 turn/end(reason.kind === 'completed' 才算说完)。
和 Aurabot 的对比
角色设计参考了 Aurabot(dsh-aurabot-qq):一个 QQ 会话对一个 DSH 会话、人格独占系统提示词、安全默认、按会话挂东西、用 agent.followup + sessions.flush 投递、从 session/event 取回复。
传输层完全不同:Aurabot 走 OneBot(SnowLuma,hook 本机 QQ 客户端),这里走 ai-chat 插件(Roost 官方接口)。所以 Aurabot 里那批 OneBot 特有的东西(数字 QQ 号、base64 语音段)这里一个都不需要。
一句话:传输层我们更稳(官方接口),对话体验它更成熟。 只想用现成的,先去试试 Aurabot。
已知限制
- 只支持 QQ(
channel.current = 'qq'),通道抽象已备好但还没接第二个平台 - 语音靠官方 ASR(
asr_refer_text),没有本地语音模型;TTS 音色由 ai-chat 设置,面板只读 - 面板的配置正文仍是 JSON 直编辑(不是逐字段表单),代价是要看得懂结构——所以配了分节说明。「能力」页那批(档位、功能开关、次数限制、分群例外)已经是可点的,只有
config.json的其余部分要手改 - 模型服务挂了插件救不了:DeepSeek 网关 502 时,补重试能救回一部分,但网关整体挂掉时只能等它自己恢复。这种时候插件回一句「模型服务那边暂时抽风了」,不会把内部报错糊给你
- 表情包发出去是普通图片消息,不是 QQ 收藏表情——对方不能一键存进表情夹(协议层限制)
- 排队只存内存:进程重启时正在排的那条会丢(inbound 本来就是一次性的);面板暂时看不到队列长度(
stats()已备好) - 巩固不进队列:
memory.consolidateChat配成私聊 openid 时,撞上正在跑的一轮仍会失败(次日补跑)
开发与测试
npm test1357 条测试,覆盖整形、会话映射、HTTP 契约(立刻 200 / 鉴权 / 健康检查 / SSRF 防护)、一轮的状态机、权限分档与兜底(五档栈式继承、只往低档切片、坏配置不让主人丢全权)、能力策略(人格空名单=全禁、群覆盖通配、只能聊天不管主人)、次数配额(固定窗口、幂等去重、计数失败一律放行、默认不限)、功能开关与工具收窄(关掉功能谁都没有)、记忆三层、觉醒打分、压缩、通道/接入抽象、工具定义与权限(含 at 参数的四条降级路径)、工具 schema 是否落在 DSH 支持的子集里(缺 additionalProperties 会注册即被拒)、发文件(路径白名单、通道协商、大小上限、一轮一个)、人格注入预算裁剪与中文 id / 角色卡自报名字、上游错误分类与补重试、会话类型标签(群 / 私聊)、配置保存与端口候选顺序、启动前置检查(实例表里还有活进程就不 spawn、端口漂移时按候选列表探活)、停止的两级终止、前端十一页空数据渲染、人格页合并清单(已装在前 / 未装可直接装)、自更新的返回契约(applied / restart-required / refused、错误码与 pnpm 失败分类)、表情包(标签解析、侧车、分类、挑图去重、扫描上限、多目录合并与「未分类」归并、缓存同步的尺寸筛选 / 标签改名 / 路径 sanitize)、记忆失效(作废不删行)与 L1/L2/L3 三级撤销、会话查询工具 chat_look、排队。
CI(GitHub Actions,Node 22 / 24 矩阵)跑同样这些,外加三件单测覆盖不到的:
node --check全量语法检查(TDZ 这类运行时错它查不出,但拼错、少括号能拦住)scripts/dry-run.mjs用假 ctx 真跑一遍apply(),确认插件能加载、路由能注册npm pack --dry-run检查files字段没漏src/host.js/client/client.js——打包漏文件,用户装上去就是坏的
