# SJ **Repository Path**: nomyowo/sj ## Basic Information - **Project Name**: SJ - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 3 - **Forks**: 0 - **Created**: 2026-08-27 - **Last Updated**: 2026-10-15 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 图片保存机器人 | 命令 | 作用 | | --- | --- | | `sj` | 发一张当前群的图片,按权重推荐:越久没被推荐过的图越容易抽到 | | `sj 关键词` | 只在备注含该关键词的图片里按同样的权重挑 | | `sj -a [备注]` | 引用一张图、或和图片同条消息发出,保存并记录群号/添加者/时间/备注;图片重复且带了新备注时会更新备注 | | `sj -i` | 引用一张图,查它的添加者、添加时间和备注 | | `sj -s 关键词` | 按图片 ID / 昵称 / QQ 号 / 时间 / 备注 筛选后按权重发一张 | | `sj -f 一句描述` | 按**图片里的内容**找图:图上写的字、画面里有什么都能搜,不用事先写备注 | | `sj -d` | 引用一张图,删除当前群的记录(仅群主/管理员) | | `sj -h` / `sj -help` | 返回全部命令的说明 | | `@机器人 今日词云` | 发命令的人今天在本群说的话,画成词云 | | `@机器人 昨日词云` | 发命令的人昨天在本群说的话,画成词云 | | `@机器人 本群今日词云` | 本群今天(00:00 到此刻)所有人的词云 | | `@机器人 本群昨日词云` | 本群昨天所有人的词云 | | `@机器人 本周词云` | 本群本周(周一到此刻)的词云 | | `@机器人 设精排行` | 本群今日「抽圣经」「加圣经」两张排行榜,各列前十 | | `@机器人 别的话` | **内测**:在管理网页上勾过的群里,交给 AI 回一句,它记得之前聊过什么(`sj -h` 里没写这条) | | 引用一条消息再 `@机器人 …` | **内测**:开了 AI 的群里,它能看到被引用的那条消息是谁说的、说了什么,「他说的对吗」「这是啥意思」这种话就有得答;只引用不说话也行。引用的消息里有图、或者图和话一起发给它,图会直接和这句话一起发给聊天模型(DeepSeek 支持看图),它看完图回话,顺手写一句图的描述存进记忆;`config.py` 里把 `AI_IMAGE_DIRECT` 关掉就退回老办法:先用识图模型(就是 `sj -f` 那套配置)看一眼,再把描述交给 AI | | `@机器人 发张…的图` | **内测**:开了 AI 的群里,直接说想要什么图,它自己去图库里找并发出来(走的就是 `sj -f` 那套) | 命令名 `sj` 和 `圣经` 等价,不区分大小写;带 `-` 的参数前面有没有空格都行(`sj-a` = `sj -a`)。 词云和排行命令必须在群里 **@机器人** 才触发,命令文字中间夹空格也认(`昨日 词云` = `昨日词云`)。 基于 OneBot v11 协议,配合第三方协议端(NapCat 等)。 ## 为什么不用官方机器人 一开始走的是腾讯开放平台官方 SDK,但**官方机器人做不到「复读被引用的普通群消息」**, 两条限制叠在一起堵死了: 1. 机器人在群里**只能收到 @它的消息** —— 那条普通群消息平台从没发给过机器人。 2. 官方群聊 API **没有**按 ID 查消息的接口(只有频道有 `GET /channels/{id}/messages/{id}`)。 所以即使事件里带了 `message_reference.message_id`,那也只是个孤零零的 ID, 拿不到内容。官方版留在 `botpy_official.py` 里作参考,能力见文件顶部注释。 OneBot 协议端没有这个问题:它用你自己的 QQ 号登录,**收得到全部群消息**, 还有 `get_msg` 接口可以按 ID 取任意消息原文。 > ⚠️ 第三方协议端属于非官方途径,存在封号风险,且需要一个 QQ 号常驻登录。 ## 架构 ``` QQ 服务器 <--> 协议端(NapCat) <--WebSocket--> main.py(furrybot 包) ``` 登录 QQ 的活由协议端做,本项目只是 OneBot 客户端。 ## 跑起来 0. **准备 Python 3.8**(项目按 3.8 写,更高版本也能跑) ``` py -3.8 -m venv .venv ``` 1. **装依赖** ``` .venv\Scripts\pip install -r requirements.txt ``` 2. **装协议端**:推荐 [NapCat](https://github.com/NapNeko/NapCatQQ)(或 Lagrange.OneBot)。 登录 QQ 后开启 **正向 WebSocket** 服务,端口 `3001`。 3. **启动** ``` .venv\Scripts\python main.py ``` 看到 `已连接协议端` 就通了。 ## 配置 | 环境变量 | 默认值 | 说明 | | --- | --- | --- | | `ONEBOT_WS_URL` | `ws://127.0.0.1:3001` | 协议端 WebSocket 地址 | | `ONEBOT_ACCESS_TOKEN` | 空 | 协议端设了 token 时填 | | `WORDCLOUD_FONT` | 空 | 词云用的字体文件;不设就自己找系统里的中文字体 | | `DAILY_RANKING_GROUPS` | 空 | 每天 0 点推排行榜的群号,逗号分隔;不设就推给「昨天有过圣经流水」的群 | | `AI_API_KEY` / `AI_BASE_URL` / `AI_MODEL` | 空 | AI 对话(内测)的密钥、接口地址、模型;不设就读 `datai_config.json` | 其余可调的常量都集中在 `furrybot/config.py`:连接超时、推荐权重、数据保留天数、 排行推送时刻、管理网页的端口和会话时长等等。 ## Python 版本 代码跑在 **Python 3.8** 上,写的时候避开了 3.9+ 的东西: - 所有文件顶部有 `from __future__ import annotations`,所以 `list[dict]`、 `str | None` 这类注解只当字符串存着,不会在 3.8 上求值出错。 - `asyncio.to_thread` 是 3.9 才有的,`furrybot/util.py` 里按标准库的实现补了一个同名函数。 - websockets 锁在 `13.x`(14 起要求 3.9+)。13.x 的 `websockets.connect` 还是旧版 实现,所以直接从 `websockets.asyncio.client` 导入新版 `connect`, 这样 `additional_headers`、`InvalidStatus` 都能照常用; 连接关闭时的 code/reason 在新版实现里挂在 `ws.protocol` 上。 ## 实现要点 - **命令触发**:`sj` 发当前群图片,`sj -a` 保存引用图,`sj -i` 查看引用图信息,`sj -d` 删除当前群引用记录(仅群主/管理员)。 - **图片发送**:从 `data\images.sqlite3` 里按权重挑一条记录,并发送对应本地图片。 - **加权推荐**:不再用 `ORDER BY RANDOM()` 均匀抽,而是按每张图「距离上次被推荐有多久」算权重 —— 刚发过的图权重掉到 `RECOMMEND_BASE_WEIGHT`,之后按 `(间隔 / RECOMMEND_UNIT_SECONDS) ** RECOMMEND_EXPONENT` 涨回来,涨到 `RECOMMEND_MAX_WEIGHT` 封顶;从没被推荐过的图直接吃满上限,所以新图优先露面。 四个常量都在 `furrybot/config.py`,默认冷却单位 1 小时、指数 1.5、上限 2000(约 7 天涨满); 权重算法本身在 `furrybot/recommend.py`。 发送成功后把 `last_sent_at`(推荐时间)和 `sent_count`(累计推荐次数)写回 `saved_images` 表, 下次算权重就看这两列;老库启动时自动 `ALTER TABLE` 补上这两列,不用手工迁移。 - **图片保存**:从被引用消息里找第一张图片,按哈希去重后下载到 `data\saved_images\`。 - **SQLite 记录**:图片 ID、群号、添加者 ID、添加时间、保存路径和引用信息写入 `data\images.sqlite3`。 - **聊天记录**:群里的普通发言写进 `chat_messages` 表,只存文本,给词云当语料; 命令消息不入库。表和索引由 `ensure_storage()` 建,老库启动时自动补上; 超过 `CHAT_KEEP_DAYS`(默认 90 天)的记录由后台任务每天清一次。 - **每条消息独立开 task**:处理引用时要等 `get_msg` 回执,而回执要靠接收循环 继续收包才拿得到 —— 在循环里同步 `await` 会死锁。 - 已过滤 `user_id == self_id`,不会重复处理自己的消息。断线每 3 秒自动重连。 - **撤回跟撤**:群友撤回了一条机器人回过的消息(命令回执、AI 回话、发的图),机器人把自己 回它的那几条也撤掉,再 @ 他吐槽一句(`RECALL_ROAST_LINES` 里随机挑)。为此群里的回复改成 等回执、拿到自己这条的 `message_id` 记在内存里(最多 `RECALL_TRACK_LIMIT` 条,重连就忘)。 QQ 只让撤回 2 分钟内自己发的消息(机器人是管理员时不限),撤不掉也照样吐槽。 回复还没发出去原消息就被撤了(比如 AI 还在想),那句回复就不发了。 不想要就把 `RECALL_FOLLOW_ENABLED` 关掉。 ## 词云 群里 @机器人 说一句就出图,五种模式: | 说什么 | 出什么 | | --- | --- | | `今日词云` | 发命令这个人今天在本群的词云(别名:`今天词云`、`我的今日词云`、`今日个人词云`) | | `昨日词云` | 发命令这个人昨天在本群的词云(别名:`昨天词云`、`我的昨日词云`、`昨日个人词云`) | | `本群今日词云` | 本群今天全员的词云(别名:`本群今天词云`、`今日群聊词云`、`群聊今日词云`) | | `本群昨日词云` | 本群昨天全员的词云(别名:`本群昨天词云`;旧名 `昨日群聊词云`、`昨天群聊词云`、`群聊昨日词云` 仍然认) | | `本周词云` | 本群本周的词云(别名:`这周词云`、`本周群聊词云`) | - **时间口径**:「今日」「昨日」都是自然日(00:00 到次日 00:00); 「本周」是本周一 00:00 到此刻,周一算一周的头。都按机器人所在机器的本地时区算。 - **语料从哪来**:机器人会把群里的普通发言记进 `data\images.sqlite3` 的 `chat_messages` 表 (群号、QQ 号、昵称、文本、时间),词云就是拿这张表里的记录画的。 所以**功能是从装上这版开始攒数据**,刚上线时词云是空的,得等群里聊几天。 发给机器人的命令(`sj` 系列、词云命令本身)不算聊天内容,不入库; 图片、表情、@ 这些非文字段也不记。 - **撤回不算数**:群里撤回一条消息,机器人收到撤回通知后会把这条语料从库里删掉, 它不会再出现在任何词云里。靠入库时一起存的 `message_id` 对上号, 所以**只对装上这版之后新记的消息有效**,更早的老记录没有 `message_id`,撤回了也删不掉。 - **保留多久**:聊天记录只留 **90 天**,启动时清一次、之后每天清一次过期的, 库不会一直涨。天数和清理间隔是 `furrybot/config.py` 里的 `CHAT_KEEP_DAYS` / `CHAT_PRUNE_INTERVAL`。 注意 SQLite 删了行不会自动缩小文件(空间会留着复用),想真的压回去自己跑一次 `VACUUM`。 - **怎么画**:`jieba` 分词 → 去掉链接 / CQ 码 / 标点 / 单字 / 纯数字 / 停用词 → 统计词频 → `wordcloud` 排版成 1000×618 的 PNG。停用词表和图片尺寸、配色都在 `wordcloud_maker.py` 顶部。 - **图片存哪**:生成的图落在 `data\wordclouds\`,发完留着,下次生成时顺手删掉超过 24 小时的。 - **字体**:按这个顺序找中文字体 —— 环境变量 `WORDCLOUD_FONT` → 项目里的 `data onts\` → 一串常见路径(Windows 的雅黑/黑体、各发行版的 Noto CJK、文泉驿、Droid Fallback 等) → `fc-match :lang=zh`(Linux 上最准)→ 递归扫 `/usr/share/fonts` 之类的目录按文件名关键字挑。 挑中的字体还会用 fontTools 查一下 cmap 里有没有汉字,避免选到只有西文的字体画出一堆豆腐块。 结果会缓存,不会每次都翻磁盘。 - **服务器上没字体怎么办**(提示「系统里找不到中文字体」时,三选一): ``` apt install fonts-noto-cjk # Debian / Ubuntu yum install google-noto-sans-cjk-fonts # CentOS / RHEL / Fedora apk add font-noto-cjk # Alpine ``` 或者把任意中文 `.ttf/.ttc/.otf` 传到项目的 `data onts\` 目录(注意 `data\` 在 .gitignore 里, 得手动传到服务器),再或者设 `WORDCLOUD_FONT` 指到字体文件。装完不用重启,下次发命令就会重新找。 - **依赖**:`jieba` 和 `wordcloud`(会顺带装 pillow / numpy / matplotlib),都在 `requirements.txt` 里。 没装也不影响机器人其它功能,只是要词云时回一句「缺少词云依赖」。 首次分词要建词典、matplotlib 要建字体表,加起来好几秒,所以启动时会在后台先热一遍。 ## 管理网页 `webadmin.py` 是个只用标准库写的管理后台(不依赖任何 Web 框架),端口 `4000`, 必须用用户名密码登录,登录后可以增、改、删数据库里的图片和它们的信息。 ``` .venv\Scripts\python webadmin.py ``` 然后开 。 - **账号**:首次启动会在 `data\web_admin.json` 生成 `admin` 账号和一串随机密码, 密码只在控制台打印一次。改密码:`python webadmin.py --set-password 新密码 [--user 用户名]`; 也可以用环境变量 `WEB_ADMIN_USER` / `WEB_ADMIN_PASSWORD` 直接指定(优先于文件)。 密码用 PBKDF2 存哈希,不存明文;连错 5 次锁该 IP 一分钟。 - **分三页**:顶栏的「图片库」「AI」「群成员」各是一个标签页。图片库那页管图(搜图索引、 搜图测试、添加、批量导入、图片列表),AI 那页管模型(模型接口设置、AI 提示词、AI 对话开关、 群聊知识库),群成员那页导出花名册。切页只是换显示,各页的表单都留在页面上 —— 识图对比要读 AI 页里的提示词和模型名,切走了照样取得到。 - **能做什么**:上传新图并填群号/昵称/QQ 号/时间/备注(按哈希查重,可勾选强行添加); 编辑任意字段、换图(旧文件自动清理)、改群号(主键跟着迁移);删记录时可选是否连本地文件一起删; 列表里还能看到每张图的索引描述、上次推荐时间和推荐次数。 - **筛选**:按群号筛;关键词可以在「全部字段」里模糊搜,也可以先选一个字段 (添加者 / QQ 号 / 备注 / 图片描述 / 图片 ID)只搜那一列;能卡推荐次数区间, 上限填 `0` 就是只看从没被推荐过的图。 - **识图对比**:点任意一张图右侧的「识图对比」,可填写最多 5 个模型;3 组提示词分别放在 3 个并排文本框中,一次比较模型与提示词的所有组合。模型列表从已保存的图片识别 接口自动获取;每组结果显示耗时、描述或错误。测试不会覆盖图库中的现有描述和向量。 - **搜图测试**:「搜图索引」下方的卡片,选群、输一句描述,就是在网页上跑一次 `sj -f`: 显示机器人会回的那句话(没找到 / 没配置 / 没索引……)和它找到的候选图, 每张带综合分、相似度、备注和索引描述,标出哪几张分数接近会参与随机抽。 和群里一样会调向量接口、顺手补几张缺失索引,但不发图、不记推荐次数。 - **按时间筛**:「添加时间」和「上次推荐」各有一对日期时间选择器(`datetime-local`), 选起止时间即可,只填一边就是单边开口,两端都是闭区间 —— 终点选到分钟时含那一整分钟(比如选到 `14:18`,`14:18:39` 的记录也算在内)。 限定「上次推荐」区间时,从没推荐过的图(`last_sent_at` 为空)不在结果里, 要找它们就用推荐次数上限 `0`。后端只认 `YYYY-MM-DDTHH:MM[:SS]` 且真实存在的日期, 比较时截取时间串前 19 位,写坏了当没填。 - **排序**:点表头在「添加者 / QQ 号 / 添加时间 / 备注 / 上次推荐 / 推荐次数」之间切换, 再点一下反转升降序,箭头标在表头上。按上次推荐升序时「从未」排最前面 (从没推荐过 = 最久没露面,和机器人算权重的口径一致)。 - **安全**:默认只监听 `127.0.0.1`。要给局域网用,设 `WEB_ADMIN_HOST=0.0.0.0` —— 明文 HTTP,别直接暴露到公网。会话 Cookie 是 HttpOnly + SameSite=Lax,写操作还要带 CSRF token。 | 环境变量 | 默认值 | 说明 | | --- | --- | --- | | `WEB_ADMIN_HOST` | `127.0.0.1` | 监听地址 | | `WEB_ADMIN_PORT` | `4000` | 监听端口 | | `WEB_ADMIN_USER` / `WEB_ADMIN_PASSWORD` | 空 | 直接指定账号,设了就不读 `web_admin.json` | ## 排行榜 群里 @机器人 说一句 `设精排行`(别名:`圣经排行`、`今日设精排行`、`今日圣经排行`、 `设精今日排行`、`圣经今日排行`),出本群**今天**的两张榜,各列前十, 每行是「昵称(QQ 号) N 次」: | 榜 | 统计的是 | | --- | --- | | 抽圣经 Top10 | 今天在本群发 `sj` / `sj 关键词` / `sj -s 关键词` 的次数 | | 加圣经 Top10 | 今天在本群用 `sj -a` **成功存进新图**的次数 | - 只按**触发命令**记数,抽图那张榜不管有没有真发出图(库里没图、关键词没匹配上也算一次); 加图那张榜相反,只有真的入库了才算 —— 重复图(`该圣经已存在`)和只改备注都不计。 - 分群统计,各群的榜互不干扰;私聊里发 `sj` 不进任何榜。 - 流水记在 `data\images.sqlite3` 的 `command_events` 表(群号、QQ 号、昵称、动作、时间), 和聊天记录一样只留 **90 天**(`CHAT_KEEP_DAYS`),每天清一次。 昵称取当天最后一次记下来的那个,中途改名以最新的为准。 - 一样是**从装上这版开始攒数据**,之前的 `sj` 没有流水,补不出来。 ### 每天 0 点自动推送 机器人起来后会在后台挂一个定时任务(`daily_ranking_loop`),每天 **0:00** 把**前一天** 的这两张榜推到群里,标题是「本群昨日圣经排行(日期)」,内容和手动问排行一样。 - 推给谁:默认推给**前一天在本群有过圣经流水**的群 —— 昨天没人玩的群不会收到消息。 想固定推给某几个群,就设环境变量 `DAILY_RANKING_GROUPS=123456,789012`(逗号分隔), 设了就只推这些群,哪怕榜是空的也推。 - 群与群之间隔 1 秒发(`DAILY_RANKING_SEND_GAP`),免得一口气刷屏被协议端限流。 - 0 点正好断线重连时,会每 30 秒看一眼连接,最多等 10 分钟 (`DAILY_RANKING_RETRY_INTERVAL` / `DAILY_RANKING_RETRY_LIMIT`),等不到就跳过这一天。 - 「前一天」是拿推送时刻减一天算出来的,不看醒来时的当前时间,所以早醒几毫秒或者 机器休眠了一会儿都不会把日子算错;改推送时刻就调 `DAILY_RANKING_HOUR` / `DAILY_RANKING_MINUTE`。 ## 按内容搜图(`sj -f`) `sj -s` 查的是**登记信息**(谁加的、什么时候加的、备注写了什么),`sj -f` 查的是**图本身画了什么**: ``` sj -f 狼狼哭着打字 sj -f 那张写着此处有坑的 sj -f 一碗汤面 ``` 图上印的字是最好用的线索 —— 群友找图基本都是「那张写着 xxx 的」,识图提示词里第一条就是让模型把台词原样抄下来。 开了 AI 对话的群里还可以直接说人话:「发张写着此处有坑的表情包」、「有没有那张 QQ 企鹅的图」。机器人会在回话末尾偷偷写一行 `[找图]关键词`,程序认出这行就去搜图,这行不会发到群里;找到的图和它那句话拼在同一条消息里发出,没找到时提示也接在那句话后面。 ### 它是怎么搜的 | 步骤 | 做什么 | 存哪 | | --- | --- | --- | | 识图 | 把图发给视觉模型,让它把**图上的文字**和画面写成一段 120 字以内的中文 | `saved_images.description` | | 算向量 | 把这段描述交给 embedding 模型 | `saved_images.embedding`(float32 小端字节串,1024 维 = 4KB) | | 检索 | 用户那句话也算成向量,和库里逐一算余弦相似度 | 不落盘 | **打分不是只看余弦相似度。** bge-m3 给任意两段中文的相似度都不低,实测本库里「狼狼哭着打字」(库里根本没这种图)能拿到 0.533,而真能对上的「此处有坑」只有 0.534 —— 光靠余弦分不出「到底有没有找到」。所以再加一份**字面命中**:把查询切成二字片段,数有多少片段原样出现在描述里。 ``` 综合分 = 余弦相似度 + 0.25 × 字面命中率 ``` 这一份只会抬高真正的命中,对不相干的查询恒为 0。实测 5 张图的库、27 条查询:有匹配的最低 0.590,没匹配的最高 0.533,所以门槛取中间的 `0.56`(`GRAPH_MIN_SCORE`)。图多了以后「碰巧有一张分高」的概率会上升,这个门槛可能要往上调。 找不到就老老实实回「没找到像这样的圣经」,宁可说找不到也别硬发一张不相干的图。分数差在 `0.03`(`GRAPH_TIE_DELTA`)以内的算「差不多像」,这几张之间才按冷却权重抽,免得同一句话老是发同一张;差得明显的不参与 —— 搜图要的是准,不是花样。 ### 什么时候建索引 - **新图**:`sj -a` 存完、回完话之后在后台识图,加图的人不用等; - **搜的时候**:发现还有没索引的,顺手补 5 张(`GRAPH_LAZY_INDEX_LIMIT`); - **存量图片**:跑一次 `python reindex.py`,它会把每张图的描述打出来,方便你看识图质量。 也可以在管理网页顶部的「搜图索引」卡片操作:选「整库重新生成」会对全库已有图片 重新识图、生成向量,包括之前失败被跳过的图片;选「只补缺失索引」会复用已有描述, 跳过连续失败达到上限的图。处理数量填 `0` 表示不限,也可以先填 `20` 试跑。 操作范围是全库,不受图片列表的筛选影响。页面显示成功、失败、总数和最近一次失败原因, 同一个管理服务一次只运行一个索引任务。任务逐张保存,关闭或刷新页面后仍会继续, 重新登录可查看进度;关闭管理服务会中断任务,重启后可用「只补缺失索引」补齐缺项。 若要确保所有旧描述都重写过,中断后需再次选择「整库重新生成」。索引会调用已配置的 视觉和向量模型并产生接口费用;未配置向量模型时只生成描述。 ``` python reindex.py # 补完所有还缺索引的 python reindex.py --limit 20 # 先拿 20 张试试效果和花费 python reindex.py --force # 整库重做(换了模型时用) ``` 中途断了再跑一次就行,已经索引好的会跳过。换了 `graph_embed_model` 之后,旧向量会因为 `embedding_model` 对不上而被自动挑出来重算。 ### 索引失败了怎么办 如果曾遇到 HTTP 400、提示模型不支持 `enable_thinking`,新版已移除固定的思考开关。 修复后可在网页选择「整库重新生成」,包含过去因该错误被跳过的图片。 三个可用模型的小样本实测及速度比较见 [识图模型实测](docs/vision-model-comparison.md)。 失败分两种,`index_failures` 这一列只记第二种: | 失败类型 | 例子 | 记账吗 | | --- | --- | --- | | 大家一起遭的 | 接口参数错误、模型不可用、鉴权/额度问题、连接超时、HTTP 429、5xx | **不记**,修复配置后可重试 | | 这张图自己的问题 | 文件被删了、图太大、接口回 HTTP 400「不是有效图片」 | 记,次数 +1 | 连续失败 `GRAPH_MAX_FAILURES`(默认 3)次的图就不再自动索引了。这道闸是必须的:待索引是按添加时间**倒序**取前 5 张的,一张永久失败的新图会霸住名额,让后面的图一张也轮不到,而且每次 `sj -f` 都白花一次接口调用。 这个区分也不能省 —— 否则一次网络抖动就会把整库的图都记成坏图,之后谁也不再被索引。 想让被跳过的图再试: ``` python reindex.py --retry-failed # 只是不再拿失败次数挡着 python reindex.py --force # 连描述一起重写,计数也清零 ``` 索引成功时失败计数自动清零。另外,如果上次是**描述成功、只有向量没算出来**,下次只补向量,不会再花一次视觉模型的钱重写描述。 ### 配置 识图和算向量跟聊天**分开配**,因为聊天那家不一定两样都有 —— DeepSeek 能识图,但它没有 `/embeddings` 路由(实测 404,且响应体和不存在的路径一模一样)。默认指向硅基流动,Qwen3-VL 识图、bge-m3 算向量,一把 key 两件事都办: ```json { "graph_api_key": "填你的 key", "graph_base_url": "https://api.siliconflow.cn/v1", "graph_vision_model": "Qwen/Qwen3-VL-8B-Instruct", "graph_embed_model": "BAAI/bge-m3" } ``` 也可以用环境变量 `AI_GRAPH_API_KEY` / `AI_GRAPH_BASE_URL` / `AI_GRAPH_VISION_MODEL` / `AI_GRAPH_EMBED_MODEL`,设了就盖过配置文件。不填 `graph_api_key` 的话这个功能就是关着的,`sj -f` 会回一句让你去填 key,别的功能照常。 **识图跟随聊天接口**(默认开,`"graph_vision_use_chat": true`,管理网页「模型接口设置 → 图片识别」里能开关): 图库识图(`sj -a` 入库、重建索引)和聊天里的识图都直接用机器人聊天那套地址、Key、模型 (DeepSeek 的 `deepseek-flash` 能看图),不用再单独配视觉模型;`graph_vision_model` 这时不用。 向量照旧走 `graph_base_url` / `graph_api_key`(或者单独配的向量服务),所以 `graph_api_key` 还是要填。 聊天模型多是推理模型,识图额度放宽到 `GRAPH_VISION_CHAT_MAX_TOKENS`。开关只影响之后新写的描述, 已有描述想换成新模型写的,在网页上「整库重新生成」。 只填了 `graph_api_key`、没有可用的 embedding 模型时,搜图会退化成**只按字面命中**找 —— 能用,但换个说法就搜不到了。 ## AI 对话(内测) 群友 @机器人 说一句它认不出来的话(不是词云、不是排行),机器人就把这句话交给大模型回, **只在管理网页上勾过的群生效**。内测阶段 `sj -h` 的帮助里没写这个功能,先小范围试。 - 走的是 OpenAI 那套 `/chat/completions` 约定,只用标准库的 `urllib` 发请求,没加依赖。 DeepSeek、通义、硅基流动、OpenAI 以及各类中转站都能直接用,换供应商改配置就行。 - 配置在 `datai_config.json`(第一次启动自动生成模板,`data\` 在 `.gitignore` 里, 不会被 git 带走),把 `api_key` 填上就能用;改完不用重启,下次提问就生效: ```json { "api_key": "填你的 key", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat", "system_prompt": "机器人的人设,想改口气就改这里", "temperature": 0.8, "max_tokens": 800 } ``` 管理网页顶部的「AI 提示词」可以直接修改机器人角色提示词和图片索引提示词, 保存后不用重启。网页只改提示词,不会显示或覆盖配置文件里的密钥、接口地址和模型。 图片索引提示词只影响之后生成的描述;要让已有图片也使用新提示词,需要再执行一次 「搜图索引 → 整库重新生成」。 也可以用环境变量 `AI_API_KEY` / `AI_BASE_URL` / `AI_MODEL`,设了就盖过配置文件。 ### 联网搜索 群友问到新闻、比赛结果、版本更新、价格、天气这类要查最新消息的事,机器人会先联网搜一下再回。 - 用的是 DeepSeek 的 Anthropic 兼容接口(`https://api.deepseek.com/anthropic/v1/messages`) 自带的服务端 `web_search` 工具:搜网页、读网页都在 DeepSeek 那边做完,不用另配搜索 API。 - 和 `[找图]` 一样靠标记触发:模型觉得要查时,整条回话只写一行 `[搜索]关键词`, 程序认出来就去搜,把搜到的答案和来源塞进 system,再让它用平时的口吻重答一遍。 闲聊不会触发,不多花钱;要搜的那句大概多等 10 秒左右。 - 搜索失败不会直接报错,而是让模型跟群友说一声没查到。 - 配置(`data/ai_config.json`,都能不填): ```json { "search_enabled": true, "search_base_url": "https://api.deepseek.com/anthropic", "search_api_key": "", "search_model": "" } ``` `search_api_key`、`search_model` 留空就跟聊天用同一套;聊天换成别家时, 这里要单独填 DeepSeek 的 key 和模型。关掉就把 `search_enabled` 改成 `false`。 其余参数(每次最多搜几轮、结果留多少字、提示词)在 `config.py` 的 `AI_SEARCH_*`。 ### 记忆 | 记的是什么 | 存在哪 | 带多少 | | --- | --- | --- | | 这个人跟机器人聊过的话 | `ai_messages` 表,按「群 + QQ 号」分成各自一条线 | 最近 12 句,且只带 6 小时内的(`AI_HISTORY_TURNS` / `AI_HISTORY_HOURS`) | | 群里最近在聊什么 | 复用词云那张 `chat_messages` 表 | 最近 30 条、最多 1500 字,摘成一段塞进 system(`AI_GROUP_CONTEXT_LIMIT` / `AI_GROUP_CONTEXT_CHARS`) | | 网页上写进去的资料 | `kb_docs` / `kb_chunks` 两张表,见下面的「群聊知识库」 | 按这次提问检索出最相关的几段(最多 4 段、1800 字)塞进 system(`KB_TOP_N` / `KB_CONTEXT_CHARS`) | | 对这位群友的长期印象 | 知识库里「群 X 成员名单」那篇资料里这个人那一行的「印象」列(`roster.py`) | 提问的人自己的那一行整段带进 system;别人问起他时走检索 | - 所以同一个群里,每个人跟机器人的对话是**各聊各的**,互相看不到对方的上下文; 但群里公开说的话大家(包括机器人)都看得见。 - @机器人 说 `重置对话`(别名:`重置记忆`、`清空对话`、`清空记忆`、`忘记对话`、`新话题`) 只清**自己**在**本群**的那条线,不影响别人;**不清长期印象**,那个在网页上改。 - 对话记忆留 30 天(`AI_MEMORY_KEEP_DAYS`),和聊天记录、排行流水一起每天清一次。 ### 长期印象 机器人每回完一句,会**再问模型一次**:把已有的印象和这一问一答给它,让它把这次对话里 关于这位群友本人的信息(自称的物种、爱好、身份、说话习惯这类)并成一段不超过 200 字的 「印象」,写进知识库里「群 X 成员名单」那篇资料的这个人那一行,标签是「印象」。 - 放在成员名单里而不是另起一篇,是为了**和昵称、物种一样能在「群成员」页上看、能改**: 拉一次名单,印象列就从知识库回填出来;改完点「导入群聊知识库」写回去。 - 名单里没这个人就在末尾补一行;连名单都没导过就自动新建那篇资料(挂在本群)。 只改那一行,其它行一个字不动;重新索引时**没变的行沿用旧向量**(`knowledge.reusable_vectors`), 几百人的名单改一个人只算一段向量。网页上编辑资料也走同一条路。 - 模型说没什么可记(输出「无」)、或者输出和原来一样就不写;已有印象非空而模型输出空, 当它没照规矩来,保留原来的 —— 长期记忆宁可不更新也别被一次出错清空。 - 整理印象在回话发出去**之后**、后台线程里做,群友不用多等;出错只记日志。 - 每次回话多一次模型调用(温度 0.3、额度 4000 token,深度思考关掉 —— `AI_MEMORY_THINKING`, 不然 deepseek-v4-pro 会把额度全花在推理上,整理就失败了)。不想要就把 `config.py` 里的 `AI_PROFILE_ENABLED` 关掉;字数、提示词都在 `AI_PROFILE_*`。 - 提示词里明说了不记临时状态、不记问答内容、不写推测。但它写的终归是模型的理解, **群友能在群里问出来**(别人问「某某某是谁」时会检索到),觉得不合适就在网页上改掉。 - 同一个人上一句还没答完就再问,会回一句「上一句还在想」,不会并发去打接口。 - **谈到的别的群友也会更新**:这句话里 @ 到的人、引用了谁的消息、名字(最近 30 天在群里 用过的名字,或名单里手填的昵称)出现在话里的人,最多 3 个(`AI_PROFILE_OTHERS_MAX`), 他们已有的印象和记忆条目会一起交给模型;对话里明确说到了他们本人的稳定信息就改他们的印象, 说到了他们的具体事(「小狐狸新养了只柯基」)就记一条到**他们**名下。别人说的不如本人 说的可靠:玩笑、调侃、起外号不写,和本人说过的矛盾时以本人为准(`AI_IMPRESSION_HEARSAY`)。 模型只能改这几个人的,编一个 QQ 号写不进去。按名字认人时长名字先比,「小狐狸」不会被认成「小狐」。 ### 群聊旁听 没 @机器人、只是群友之间聊到的事也会记进长期记忆条目(`ai_memories`): - 后台每 10 分钟看一眼每个**开了 AI 的群**里新攒的群聊(`chat_messages`),攒够 60 条、 或者群里安静了 30 分钟且至少有 15 条,就把这段群聊交给模型,抽出以后用得上的具体事 (计划、经历、约定、喜好、群里定下的活动),记到**这件事是关于谁的**那位群友名下。 - 和 @机器人 那条路记下的条目存在一起、一起检索、一起在网页「记忆」页上看和改; 模型会看到说话的人已有的几条,重复的不再记,被推翻的删掉。 - 记到谁名下:这段群聊里说过话的人,和**没说话、但被大家谈到的人**(最多 5 个, `AI_GROUP_MEMORY_OTHERS_MAX`)。被谈到是指聊天里 @ 了他、引用了他的消息、或者叫了他的名字: 存群聊时会把这条消息 @ 了谁、引用了谁的 QQ 号记在 `chat_messages.mentions` 里(正文里不留 @), 名字则和上面一样按最近用过的群名片和手填昵称去认。模型编的 QQ 号记不进去。 - 玩笑、接梗、斗嘴、心情这类不记;别人谈到某人的事,说得明确、不像玩笑才记到那人名下。 - 同一次调用里也会更新这些人的**长期印象**(成员名单里的「印象」列),规矩和上面一样。 - 进度记在 `ai_group_digests` 表:第一次见到一个群只从当时开始听,不回头翻旧记录。 - 每整理一次多一次模型调用(一次最多 120 条群聊)。不想要就把 `AI_GROUP_MEMORY_ENABLED` 关掉;频率、批量、提示词都在 `config.py` 的 `AI_GROUP_MEMORY_*`。 ### 记忆问答(网页) 管理网页「记忆」页里的「记忆问答」:选好群,写一句想问的话(「小狐狸最近在忙什么」 「总结今天群里聊了什么」),勾选要调取的来源,点「问模型」: - **聊天记录**:选的时间范围里(6 小时到 90 天)的群聊,可以只看某个 QQ 号、只看带某个词的; 最多 600 条、2 万字,超了只带最新的(`ASK_CHAT_*`)。 - **记忆条目**:按这句话检索最相关的 20 条,再把话里点了名的群友名下的条目补上。 - **印象**:话里点了名的群友(按名字认,和整理记忆时一样)和筛选的那个 QQ 号,在成员名单里的几列。 调出来的材料、模型的回答、发给模型的完整内容都显示在页面上。「只调取,不问模型」只看材料, 不花模型的钱。默认关掉深度思考,勾上「深度思考」答得更细但更慢。全程只读,不改任何记忆, 检索也不记「用上」。接口是 `POST /api/memory/ask`,提示词在 `config.py` 的 `ASK_SYSTEM_PROMPT`。 ### 在网页上开关 管理网页顶部「模型接口设置」支持分别配置机器人聊天、图片识别、搜图向量的 基础地址、模型名和 API Key。首次展开自动通过对应接口的 `/models` 获取列表; 修改地址或 Key 后可点击「获取模型列表」重取,接口不提供列表时也能手动填写模型名。 返回列表可能包含多个用途的模型,请选择支持视觉输入的模型用于图片识别。 Key 不回显,输入框留空表示保留当前值;更换服务地址时需明确输入该服务的 Key。 保存立即用于新请求,正在运行的索引任务继续使用启动时的配置。环境变量覆盖的字段 会显示并禁用编辑,需先移除相应环境变量。向量模型名留空可以关闭向量计算。 向量服务默认跟随识图服务,也可独立保存地址和 Key;更换向量模型后旧向量不再参与 向量匹配,通过「只补缺失索引」重新计算即可。改识图模型需要「整库重新生成」重写描述。 管理网页上有一张「AI 对话(内测)」卡片,列出机器人见过的所有群(图库里、聊天记录里、 排行流水里出现过的群号),勾一下就开、取消就关;没在列表里的群可以直接填群号加进去。 开关存在 `ai_groups` 表,改完立刻生效,不用重启机器人。 密钥不走网页 —— 管理页是明文 HTTP,`api_key` 只留在 `datai_config.json` 或环境变量里, 网页只显示「配没配好」和当前用的接口地址、模型。 ## 群聊知识库(RAG) 机器人光靠人设和聊天记录答不了「本群能不能发广告」「活动几号截止」这类事 —— 它不知道, 问它就只能编或者说不知道。知识库就是把这些写下来:在管理网页「AI」页的「群聊知识库」里 建一篇资料,群友 @机器人 提问时,机器人先按这句话从资料里检索出最相关的几段, 连问题一起发给模型,所以它是照着资料回答的。 检索和按内容搜图是同一套路子,只是两头换了东西: | | 索引(写) | 检索(读) | | --- | --- | --- | | 搜图 | 图片 → 视觉模型 → 一段描述 → 向量 | 「狼狼哭着打字」 → 向量 → 和图的向量比 | | 知识库 | 一篇资料 → 切成小段 → 向量 | 「群里能发广告吗」 → 向量 → 和每一段的向量比 | 所以**向量模型和搜图共用一份配置**(`graph_embed_*`),配一次两边都能用,不用再配一份 key。 - **切段**:先按空行分段(人写资料时空行就是天然的话题边界),单段超过 400 字 (`KB_CHUNK_CHARS`)再按句号这类断句符切开。相邻两段重叠 60 字(`KB_CHUNK_OVERLAP`), 免得答案正好被切在边界上,两段各答一半。算向量时标题会跟段落一起发过去 —— 光看「报名截止到十二号」这一段,根本看不出说的是哪个活动。 - **打分**:和搜图一样是「余弦相似度 + 字面命中率 × 0.25」,低于 `KB_MIN_SCORE`(0.45) 就当没搜到。门槛比搜图的 0.56 低:搜图发错图很显眼,宁可说找不到;知识库只是多给模型 一段参考,捞错了模型也还会照常回话,代价小得多。这个值是照着搜图那边挪出来的起点, 不是量出来的 —— 总带上不相干的资料就往上调,明明写了却搜不到就往下调。 - **适用范围**:一篇资料的「适用群」填群号就只在那个群生效,留空表示所有群通用。 检索时一个群看得到的是「挂在本群的 + 所有群通用的」。关掉一篇只是检索时不看它, 正文和索引都留着,开回来不用重新索引。 - **什么时候建索引**:网页上保存一篇资料时就地切段算向量(一般一两秒);提问时发现还有 没索引的会顺手补几篇(`KB_LAZY_INDEX_LIMIT`);换过向量模型就在网页上点「重建索引」。 改了正文会把旧索引作废(旧向量对应的是旧正文,留着会搜出已经删掉的话),保存时重算。 - **没配向量模型也能用**:这时检索退回字面命中 —— 问法和资料里的用词对得上才搜得到。 向量算不出来(接口不通)时也一样:切好的段照样存下来,先按字面用着,等重建时补向量。 - **资料只进提示词,不写进对话记忆**:记忆里留着的是人说的话。资料每次提问都重新检索, 改完立刻生效。 - **出事不连累回话**:检索这一步整个包在兜底里,知识库炸了也只是这次不带资料,照常回一句。 网页上那张卡片能做这些事: - 新建 / 修改 / 删除资料,开关某一篇,看每篇切成了几段、什么时候索引的、用的哪个向量模型; - **检索测试**:输一句群友可能会问的话,看会取出哪几段、每段的综合分和相似度, 以及最后拼成的、真会塞进提示词的那段材料。命中了但被字数上限挡下的那几段也会列出来 (标成「超出字数,不带」),免得以为是根本没搜到。会调向量接口,但不会真去问聊天模型。 ## 群成员名单导出 管理网页「群成员」那一页:填个群号,把这个群的成员拉出来,导出成一份 txt。 名单是**现问协议端要的**,不是从库里翻的 —— 库里只有说过话的人,潜水的一个都没有, 词云那张表也不是花名册。所以导出时机器人的协议端得开着,而且机器人在那个群里。 **导出内容就是页面上那张表**:一行一个成员,一条里挂着这个人**有值的每一列**, 成员之间空一行。 ``` 123456789:QQ昵称:小明 555000111:本群昵称:咕咕;QQ昵称:gugu;角色:群主 987654321:本群昵称:阿狼;QQ昵称:wolf;角色:管理员;昵称:狼狼;物种:狼;印象:常聊画画,自称老群员 ``` - **空的列整段不写**。上面第一个人没设群名片,他那条里就根本没有「本群昵称」; 绝大多数人没填昵称、物种,也就没有那几段。留一串空字段只会把名单撑长, 还让知识库里每一段都多几句废话。极端情况下(群名片和 QQ 昵称都没拿到、 手填的列也全空)那一条就只剩一个 QQ 号。 - **角色只给群主和管理员**:普通成员不写 —— 一个群里绝大多数都是普通成员。 - **「昵称」「物种」「印象」协议端不知道**:前两个是手填的,印象是机器人回完话自己整理的 (见上面「AI 对话 → 长期印象」)。拉取时从本群已有的成员名单知识库文档按 QQ 号读取 (昵称兼容旧的「自定义名称」标签);本群昵称、QQ 昵称和身份仍取协议端最新值。 没有已存值时留空,三列都可手动改。修改后点「导入群聊知识库」保存,下次拉取会回填; 尚未导入的修改只保留在页面上。 - **导入是整篇覆盖**:拉取之后到导入之前,机器人新写的印象会被表格里的旧值盖掉。 所以改完尽快导入,别开着页面放一天再点。 - 这几列的值里不能有换行和分号(一人一行、分号分列),保存时换行去掉、分号换成逗号。 - 表格下面有个折叠的「看一眼导出的样子」,改一格那里就跟着变,随时能看到最终的 txt。 - **外部导入昵称 / 物种 / 印象**:拉取名单后展开那个折叠块,粘贴每行一位成员的数据,例如 `123456789 小明`、`123456789:昵称:小明;物种:狐狸;印象:爱画画`,也可粘贴以制表符或逗号分隔的两列数据、 从表格粘出来的「QQ 号、昵称、物种」三列(制表符分列),或本页导出的完整名单(印象只认标签写法)。 点击「识别并填充」按 QQ 号填入对应列;可取消覆盖,仅补空白。 重复 QQ 号按列合并、后面的行只盖掉自己写了的列,未匹配成员和无法识别的行会提示。 填好后可导出或导入知识库保存。 - 按 QQ 号升序排,同一个群导两次结果一致。 - 「保存为 txt」按 UTF-8 存,文件名是「群成员-群号-日期.txt」; 存文件是浏览器那边做的,不经过服务器。 - 有哪些列、叫什么名字、怎么拼,都在 `config.py` 的 `MEMBER_FIELDS` / `MEMBER_FIELD` / `MEMBER_LINE` / `MEMBER_SEPARATOR` 里,想换格式改这几行就行。 **只有这一个出处**:拉名单时后端把这份格式一起发给页面,页面往里填值 —— 不然同一套格式 Python 和 JS 各写一份,迟早对不上。 ### 导进群聊知识库 名单旁边有个「导入群聊知识库」,一键存成知识库里的一篇资料,机器人就能答 「谁是群主」「某某某是谁」这类问题了。 - **一个人正好是一段**:成员之间空着一行,而知识库切段就是按空行切的 (见上面的「群聊知识库」),所以检索时能单独命中到某一个人,不会把整份名单一起捞出来。 - **导的是表格里那份**,整行所有列都进去 —— 填过的「昵称」「物种」「印象」也跟着进去。 所以机器人手上有的不只是名字,还有这个人的群名片、QQ 昵称、角色、物种和它自己攒下的印象。 - 资料标题是「群 <群号> 成员名单」,适用群就是这个群。标题里带群号, 所以两个群的名单不会互相盖。 - **同一个群再导一次会覆盖原来那篇**(群里有人进出、改名片就重导一次, 知识库里始终只有一份最新的)。覆盖是破坏性的,所以网页会先问一声 —— 后端拿不到确认就只回一句「已经有了」,一个字都不动。 - 人多的时候要等一会儿:一个人一段,每段都要算向量。 **怎么问到的**:网页是独立进程(`webadmin.py`),手上没有机器人那条 WebSocket, 所以它自己开一条短连接 —— 连上、发一个 `get_group_member_list`、等到自己那条回执就断开。 这条连接只为这一次查询活着,期间协议端推过来的消息和心跳一概不理。 用的是机器人那套 `ONEBOT_WS_URL` / `ONEBOT_ACCESS_TOKEN`,不用另外配。 连不上时页面会直说是哪一种:token 不对、地址不是 WebSocket 端口、协议端没起、 机器人不在这个群 —— 只说「失败了」是没法照着去修的。 ## 文件 | 文件 | 说明 | | --- | --- | | `main.py` | 机器人入口,`python main.py` 跑的就是它 | | `webadmin.py` | 管理网页入口,`python webadmin.py` 跑的就是它 | | `wordcloud_maker.py` | 词云:分词、统计词频、画图(jieba + wordcloud) | | `reindex.py` | 给存量图片补上搜图用的描述和向量,`python reindex.py` 跑一次 | | `web\` | 管理网页的前端(index.html / style.css / app.js) | 机器人和网页的实现都在 `furrybot\` 包里,一个模块管一件事: | 模块 | 说明 | | --- | --- | | `config.py` | 环境变量、常量、提示文案、日志配置 —— 想调行为先看这里 | | `util.py` | `to_thread`、异常兜底、时间区间(今天 / 昨天 / 本周) | | `segments.py` | OneBot 消息段的解析与拼装,以及包了一层的 `Message` | | `commands.py` | 命令识别与取参:`sj -a` 之类的正则、词云和排行的关键词表 | | `media.py` | 图片下载、哈希、后缀推断 | | `storage.py` | SQLite:建表、老库补列、增删改查、过期清理 | | `recommend.py` | 加权随机:按「多久没被推荐过」算权重再抽 | | `graph.py` | 按内容搜图:视觉模型写描述、embedding 算向量、余弦 + 字面命中打分 | | `knowledge.py` | 群聊知识库:资料切段、算向量、按提问检索出最相关的几段 | | `ranking.py` | 排行榜文本拼装(今日榜和每日推送共用) | | `client.py` | 与协议端的 WebSocket 连接,只管收发和「发请求等回执」;回执成败的判断(`api_failed`)网页那边也用 | | `ai.py` | AI 对话(内测):调 OpenAI 兼容接口,拼记忆、群聊背景和知识库资料 | | `handlers.py` | 消息分发和各条命令的处理逻辑 | | `scheduler.py` | 后台定时任务:每日排行推送、过期数据清理 | | `app.py` | 连接主循环、断线重连 | | `roster.py` | 成员名单那篇知识库资料的读写(一人一行怎么解析、机器人怎么只改一行写回),机器人和网页共用 | | `admin\` | 管理网页:`auth.py` 账号会话、`images.py` 增删改查、`aichat.py` AI 群开关、`knowledge.py` 知识库管理、`members.py` 群成员导出与导入知识库、`models.py` 模型设置、`prompts.py` 提示词、`indexing.py` 搜图索引任务、`server.py` 路由、`cli.py` 命令行 |