From 23299b2ae71cab365948898dd9ba8e9a2f958a24 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Sun, 2 Aug 2026 21:45:34 +0800 Subject: [PATCH] profile: add progressive user profiles Add cache-expiry profile maintenance and contextual injection, reorganize runtime code by responsibility, remove automatic favorability decay, and prepare version 1.15.0. --- .gitignore | 6 + FavorabilitySystem.md | 144 +- README.md | 85 +- build.gradle.kts | 2 +- src/main/kotlin/JChatGPT.kt | 1205 +---------------- .../kotlin/{ => command}/PluginCommands.kt | 124 +- src/main/kotlin/{ => config}/PluginConfig.kt | 80 +- .../conversation/ConversationContext.kt | 466 +++++++ .../kotlin/conversation/ConversationEngine.kt | 344 +++++ .../kotlin/{ => data}/ChatHistoryStore.kt | 21 +- .../kotlin/{ => data}/ChatMessageRecord.kt | 2 +- src/main/kotlin/{ => data}/PluginData.kt | 2 +- src/main/kotlin/{ => data}/SkillStore.kt | 2 +- src/main/kotlin/{ => data}/TokenUsageStore.kt | 2 +- .../kotlin/{ => llm}/LargeLanguageModels.kt | 36 +- src/main/kotlin/{ => llm}/ModelService.kt | 13 +- src/main/kotlin/{ => media}/ImageIndex.kt | 2 +- src/main/kotlin/{ => media}/LaTeXConverter.kt | 4 +- .../profile/ConversationProfileReducer.kt | 46 + .../kotlin/profile/ProfileAutoMaintenance.kt | 98 ++ .../kotlin/profile/ProfileHistoryReader.kt | 475 +++++++ .../kotlin/profile/ProfileMessageRenderer.kt | 177 +++ src/main/kotlin/profile/ProfileModelClient.kt | 140 ++ src/main/kotlin/profile/ProfilePromptStore.kt | 251 ++++ .../profile/UserProfileAnalysisService.kt | 303 +++++ .../profile/UserProfileContextRenderer.kt | 62 + src/main/kotlin/profile/UserProfileModels.kt | 232 ++++ src/main/kotlin/profile/UserProfileReducer.kt | 192 +++ src/main/kotlin/profile/UserProfileStore.kt | 473 +++++++ .../tools/AdjustUserFavorabilityAgent.kt | 4 +- src/main/kotlin/tools/DeleteSkill.kt | 4 +- src/main/kotlin/tools/GroupManageAgent.kt | 4 +- src/main/kotlin/tools/ImageAgent.kt | 2 +- src/main/kotlin/tools/LoadSkill.kt | 4 +- src/main/kotlin/tools/MemoryAppend.kt | 6 +- src/main/kotlin/tools/MemoryReplace.kt | 6 +- src/main/kotlin/tools/ReasoningAgent.kt | 6 +- src/main/kotlin/tools/RequestOwner.kt | 4 +- src/main/kotlin/tools/RunCode.kt | 4 +- src/main/kotlin/tools/SaveSkill.kt | 4 +- src/main/kotlin/tools/SearchChatHistory.kt | 6 +- src/main/kotlin/tools/SendCompositeMessage.kt | 4 +- src/main/kotlin/tools/SendLaTeXExpression.kt | 4 +- src/main/kotlin/tools/SendVoiceMessage.kt | 4 +- src/main/kotlin/tools/VisitWeb.kt | 4 +- src/main/kotlin/tools/VisualAgent.kt | 4 +- src/main/kotlin/tools/WeatherService.kt | 2 +- src/main/kotlin/tools/WebSearch.kt | 4 +- .../conversation/ConversationContextTest.kt | 35 + .../kotlin/{ => data}/ChatHistoryStoreTest.kt | 2 +- src/test/kotlin/llm/ModelServiceTest.kt | 38 + src/test/kotlin/{ => media}/ImageIndexTest.kt | 2 +- .../ConversationProfileLiveExperiment.kt | 119 ++ .../profile/ConversationProfileReducerTest.kt | 223 +++ .../profile/ProfileHistoryReaderTest.kt | 122 ++ .../profile/ProfileMessageRendererTest.kt | 46 + .../profile/UserProfileContextRendererTest.kt | 91 ++ .../kotlin/profile/UserProfileReducerTest.kt | 158 +++ .../kotlin/profile/UserProfileStoreTest.kt | 234 ++++ 59 files changed, 4799 insertions(+), 1340 deletions(-) rename src/main/kotlin/{ => command}/PluginCommands.kt (58%) rename src/main/kotlin/{ => config}/PluginConfig.kt (70%) create mode 100644 src/main/kotlin/conversation/ConversationContext.kt create mode 100644 src/main/kotlin/conversation/ConversationEngine.kt rename src/main/kotlin/{ => data}/ChatHistoryStore.kt (95%) rename src/main/kotlin/{ => data}/ChatMessageRecord.kt (98%) rename src/main/kotlin/{ => data}/PluginData.kt (99%) rename src/main/kotlin/{ => data}/SkillStore.kt (99%) rename src/main/kotlin/{ => data}/TokenUsageStore.kt (99%) rename src/main/kotlin/{ => llm}/LargeLanguageModels.kt (83%) rename src/main/kotlin/{ => llm}/ModelService.kt (91%) rename src/main/kotlin/{ => media}/ImageIndex.kt (96%) rename src/main/kotlin/{ => media}/LaTeXConverter.kt (96%) create mode 100644 src/main/kotlin/profile/ConversationProfileReducer.kt create mode 100644 src/main/kotlin/profile/ProfileAutoMaintenance.kt create mode 100644 src/main/kotlin/profile/ProfileHistoryReader.kt create mode 100644 src/main/kotlin/profile/ProfileMessageRenderer.kt create mode 100644 src/main/kotlin/profile/ProfileModelClient.kt create mode 100644 src/main/kotlin/profile/ProfilePromptStore.kt create mode 100644 src/main/kotlin/profile/UserProfileAnalysisService.kt create mode 100644 src/main/kotlin/profile/UserProfileContextRenderer.kt create mode 100644 src/main/kotlin/profile/UserProfileModels.kt create mode 100644 src/main/kotlin/profile/UserProfileReducer.kt create mode 100644 src/main/kotlin/profile/UserProfileStore.kt create mode 100644 src/test/kotlin/conversation/ConversationContextTest.kt rename src/test/kotlin/{ => data}/ChatHistoryStoreTest.kt (98%) create mode 100644 src/test/kotlin/llm/ModelServiceTest.kt rename src/test/kotlin/{ => media}/ImageIndexTest.kt (96%) create mode 100644 src/test/kotlin/profile/ConversationProfileLiveExperiment.kt create mode 100644 src/test/kotlin/profile/ConversationProfileReducerTest.kt create mode 100644 src/test/kotlin/profile/ProfileHistoryReaderTest.kt create mode 100644 src/test/kotlin/profile/ProfileMessageRendererTest.kt create mode 100644 src/test/kotlin/profile/UserProfileContextRendererTest.kt create mode 100644 src/test/kotlin/profile/UserProfileReducerTest.kt create mode 100644 src/test/kotlin/profile/UserProfileStoreTest.kt diff --git a/.gitignore b/.gitignore index 3683c7d..01dab49 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,9 @@ src/test/kotlin/RunTerminal.kt # Local Test Launch Point working directory /debug-sandbox + +# Local experiment/runtime artifacts +__pycache__/ +*.py[cod] +/nul +/.playwright-cli/ diff --git a/FavorabilitySystem.md b/FavorabilitySystem.md index 556d941..63456c1 100644 --- a/FavorabilitySystem.md +++ b/FavorabilitySystem.md @@ -1,123 +1,65 @@ -# 好感度系统功能规范 +# 好感度与主观印象系统 -## 功能概述 -为机器人添加一个可开关的好感度系统,通过AI工具自动调整用户的好感度值。好感度数据将保存在插件数据中,以用户QQ号为键,包含好感度值和调整原因。 +## 功能定位 -## 核心功能 +好感度系统保存 Bot 对群友的主观认识,与证据驱动的长期用户画像互补: -### 1. 好感度数据存储 -- 在`PluginData`中新增一个映射来存储好感度数据 -- 键:用户QQ号(Long) -- 值:包含好感度值和调整原因的数据结构 -- 默认值:0(中立) +- `FavorabilityInfo` 保存好感度、代号、标签、印象和最近调整原因; +- `user-profile.sqlite` 保存从历史聊天归纳出的长期画像; +- 两套数据独立持久化,在普通对话上下文中按用户合并渲染; +- 自动历史画像不会修改主观好感度。 -### 2. 好感度变化规则 -- **问正经问题**:+好感度(例如:询问学习/工作相关问题、寻求帮助等) -- **问无聊问题**:-好感度(例如:骚扰机器人要求评价他人、攻击性言论、让机器人做无意义的事情、引战问题等) -- **骂人**:直接降至-100 -- **时间偏移**:好感度会随时间向0偏移,偏移速度与当前好感度绝对值相关 - - 好感度越高或越低,偏移速度越慢 - - 设计算法确保极端值变化缓慢(具体公式见实现细节) +## 数据结构 -### 3. 回复概率机制 -- 当好感度为负数时,有一定概率不回复用户消息 -- 概率计算:好感度绝对值的百分比 - - 例如:好感度为-50,则有50%概率不回复(即50%概率回复) - -### 4. 好感度调整工具 -- 新增一个AI工具,允许AI根据对话内容自主调整用户的好感度 -- 工具名称:`adjustUserFavorability` -- 工具参数: - - `userId`: 用户QQ号 - - `change`: 好感度变化值(可正可负) - - `reason`: 调整原因(用于溯源) - - `impression`: 对用户的印象/画像(可选) - -### 5. 系统开关 -- 在配置文件中添加开关选项,控制是否启用好感度系统 -- 默认启用 - -### 6. 管理员命令 -- 添加插件命令手动修改某个人的好感度 -- 添加命令重置所有好感度 - -## 实现细节 - -### 1. 数据结构 -在`PluginData`中添加: ```kotlin -/** - * 用户好感度数据 - * Key: 用户QQ号 - * Value: 好感度信息 - */ -val userFavorability by value(mutableMapOf()) - -/** - * 好感度信息数据类 - * @param value 好感度值 (-100 ~ 100) - * @param reason 调整原因列表,用于溯源 - * @param impression 对用户的印象/画像 - */ +@Serializable data class FavorabilityInfo( + val userId: Long, val value: Int = 0, val reasons: List = emptyList(), - val impression: String = "" + val impression: String = "", + val name: String = "", + val tags: List = emptyList(), ) ``` -### 2. 好感度工具 -创建新的工具类`AdjustUserFavorabilityAgent`,继承`BaseAgent`。 -工具描述:`根据用户行为调整其好感度值,范围-100~100` +字段约束: -### 3. 消息处理逻辑 -在`JChatGPT.kt`的`onMessage`函数中: -- 添加好感度系统开关检查 -- 在决定是否回复前,计算回复概率 -- 如果随机数小于不回复概率,则直接返回,不进行后续处理 +- `value`:限制在 -100 到 100; +- `reasons`:只在好感度发生变化且提供原因时追加,保留最近 10 条; +- `impression`:最多 200 字符; +- `name`:最多 20 字符; +- `tags`:最多 5 项,每项最多 20 字符。 -### 4. 时间偏移机制 -设计时间偏移算法,使好感度逐渐向0回归: -- 偏移公式:`偏移量 = sign(好感度) * (1 - (|好感度| / 100)^2) * 基础偏移速度` -- 基础偏移速度可设置为每天1-5点 -- 这样确保当好感度接近极端值时,变化速度会显著减慢 +数据继续保存在 Mirai `data.yml` 中,已有字段保持兼容。 -### 5. 配置选项 -在`PluginConfig.kt`中添加: -```kotlin -/** - * 是否启用好感度系统 - */ -val enableFavorabilitySystem by value(true) +## AI 更新工具 -/** - * 好感度每日基础偏移速度(点/天) - */ -val favorabilityBaseShiftSpeed by value(2.0) +`adjustUserFavorability` 是唯一的好感度和主观印象维护入口。模型可以在一次调用中: + +- 通过 `change` 增减好感度; +- 覆盖 `impression` 或 `name`; +- 通过 `tags_add`、`tags_remove` 调整标签。 + +只更新印象或标签时,`change` 默认为 0。系统不会因为好感度为 0 而删除用户记录。 + +## 回复门控 + +启用 `enableFavorabilitySystem` 后,负好感度会降低对应用户触发 Bot 回复的概率: + +```text +忽略概率 = abs(value) / 100 ``` -### 6. 插件命令 -添加以下命令: -- `/jgpt favorability `: 设置指定QQ号的好感度值 -- `/jgpt resetFavorability`: 重置所有用户的好感度为0 +好感度不再随时间自动向 0 偏移,也没有管理员手动修改或清空好感度的命令。它只会在模型明确调用工具时变化。 -### 7. 提示词设计 -不再使用系统提示词中的占位符,而是将好感度信息直接添加到聊天历史的顶部。 +## 上下文注入 -### 8. 好感度信息展示 -- 不再使用系统提示词中的占位符 -- 在获取历史消息时,将好感度信息作为摘要添加到聊天历史的顶部 -- 格式示例: -``` -[好感度摘要] -用户840465812(筱杰) 好感度: 75 -印象: 热心的开发者,经常提供有用的建议 -调整原因: -- 2025-09-10 14:30: 提供了关于代码优化的建议 +10 -- 2025-09-09 10:15: 帮助测试新功能 +5 -``` +普通聊天会把当前相关用户的主观认识和长期画像合并为紧凑文本: -## 待确认事项 +- 群聊优先选择触发者和最近发言者; +- 私聊只注入当前联系人; +- 仅有数值、没有代号/标签/印象的空记录不会制造提示词噪声; +- 好感度为 0 但仍有代号、标签或印象的记录继续正常注入。 -1. 时间偏移的基础速度设定(每天多少点) -2. 好感度调整工具的具体参数和使用方式 \ No newline at end of file +长期画像的生成、证据校验和自动维护流程参见 `ProfileSystemDesign-v3.md`。 diff --git a/README.md b/README.md index df9c7f6..b8f122a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模 - **上下文记忆**:支持持久化记忆存储 - **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染 - **用户画像系统**:好感度、印象、标签、Bot 自定义代号 +- **渐进式历史画像**:群聊缓存闭合后,从原始上下文一次归纳所有有效参与者并持续修正长期画像 - **Token消耗统计**:按天 × 用户 × 群聚合记录,支持多维度统计查询 - **LaTeX 渲染**:自动将数学表达式渲染为图片 - **灵活的触发方式**:@机器人、关键字触发、回复消息等 @@ -50,13 +51,14 @@ AI 可以自动调用多种工具来完成复杂任务: - `/jgpt clearContextCache` - 清空所有对话上下文缓存 - `/jgpt skills` - 列出当前所有技能(名称 + 简介) -### 好感度管理 -- `/jgpt setFavor ` - 设置指定用户的好感度值(-100~100) -- `/jgpt clearFavor` - 重置所有用户的好感度 - ### Token统计 - `/jgpt tokens [days]` - 查看最近指定天数的Token使用简报(默认7天) +### 渐进式历史画像(实验) +- 日常使用无需画像命令:群聊缓存会话闭合后,一次模型调用会静默归纳其中所有有实质发言的参与者 +- `/jgpt profileAnalyze [batches]` - 诊断或验收时手动推进指定用户画像,默认1批、最多50批 +- `/jgpt profileShow ` - 诊断或验收时查看已经提交的完整画像和覆盖时间 + ## 配置文件 配置文件位于:`./config/top.jie65535.mirai.JChatGPT/Config.yml` @@ -91,6 +93,40 @@ chatFallbacks: [] # token: 'sk-xxxx' # model: 'deepseek-chat' # extraBody: '' +# 是否启用实验性的历史用户画像分析 +profileEnabled: true +# 画像模型接入点;以下字段留空时分别继承聊天模型配置 +profileModelApi: '' +profileModelToken: '' +profileModel: '' +profileModelTemperature: null +profileModelExtraBody: '' +# 定向历史画像和闭合会话多人画像的提示词文件,相对于插件配置目录 +profilePromptFile: 'ProfilePrompt.md' +profileConversationPromptFile: 'ProfileConversationPrompt.md' +# 留空使用插件自己的聊天库;本地实验可填写外部SQLite历史库的绝对路径 +profileHistoryDatabasePath: '' +# 每批目标用户消息数、片段软间隔及上下文限制 +profileBatchTargetMessages: 120 +profileBatchMaxEpisodes: 16 +profileEpisodeGapMinutes: 60 +profileContextBeforeMessages: 30 +profileContextAfterMessages: 30 +profileContextCoreMessages: 300 +profileMaxMessageChars: 1000 +# 模型结构化响应失败后的重试次数(0~3)和短摘要上限 +profileRetryMax: 2 +profileSummaryMaxLength: 500 +# 缓存会话闭合后自动维护画像;整段会话只调用一次模型 +profileAutoUpdateEnabled: true +# 一次自动归纳最多读取最近150条会话消息 +profileAutoConversationMessageLimit: 150 +# 本人有效文本少于此字符数时不调用画像模型 +profileAutoMinAuthoredTextChars: 20 +# 普通群聊自动携带相关群友的好感度状态和短画像 +profileAutoInjectEnabled: true +profileAutoInjectMaxUsers: 4 +profileAutoInjectSummaryMaxChars: 300 # 备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。0为禁用 fallbackCooldownMinutes: 5 # 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}} @@ -135,6 +171,8 @@ temperaturePermission: 50 timeout: 60000 # 首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试 firstChunkTimeout: 10000 +# 画像模型首块响应超时时间 +profileFirstChunkTimeout: 180000 # 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改 prompt: '你是一个乐于助人的助手' # 系统提示词文件路径,相对于插件配置目录 @@ -159,8 +197,6 @@ memoryEnabled: true skillsEnabled: true # 是否启用好感度系统 enableFavorabilitySystem: true -# 好感度每日基础偏移速度(点/天) -favorabilityBaseShiftSpeed: 2.0 # 聊天记录搜索最大天数 searchHistoryMaxDays: 30 # 聊天记录搜索最大查询条数,防止内存溢出 @@ -170,6 +206,34 @@ searchHistoryMaxRecords: 5000 聊天记录保存在插件数据目录的 `chat-history.sqlite` 中,并使用 SQLite WAL 模式支持记录与查询并行进行。 数据库由插件在首次启动时自动创建和维护,无需安装额外的聊天记录插件。 +### 渐进式历史画像 + +画像维护不需要群友执行命令。Bot 成功完成一轮群聊后,系统沿用上下文缓存的超时时间进行防抖;期间再次 +触发会合并为同一会话,缓存真正闭合时还会纳入 Bot 回复后的群聊消息。后台读取最近至多 150 条消息,只把 +本人有效文本达到门槛的账号列为候选,然后用一次模型调用同时比较所有候选人的当前画像并返回按用户分组的 +`ADD / UPDATE / CONFIRM / DELETE` 操作。没有可靠变化的参与者仍会被标记为已检查,但不会生成空洞画像。 + +程序逐条验证每项操作至少引用了对应用户本人的发言;任何用户别名、条目 ID、证据归因或摘要长度非法,整段 +会话都不会部分提交。全部候选人的画像和本次覆盖记录在一个 SQLite 事务中落库,失败不会阻塞 Bot 回复。 +这一阶段不自动扫描未触发群聊,也不自动回填旧历史;`profileAnalyze` 只保留给离线验收和诊断。 + +下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的 +长期画像按同一个人合并渲染,但两套数据仍独立保存,自动画像不会修改好感度。 + +画像结果保存在插件数据目录的 `user-profile.sqlite`,不会覆盖现有好感度或旧印象数据。实验时可把历史 +备份配置为只读来源,例如: + +```yaml +profileHistoryDatabasePath: 'D:\backups\chat-history.sqlite' +profileModelApi: 'https://example.com/v1/' +profileModelToken: '在部署环境中填写,不要提交到Git' +profileModel: '兼容chat/completions的模型名' +``` + +执行 `/jgpt reload` 后即可自动运行。`ProfileConversationPrompt.md` 会在首次启用时生成,可直接迭代多人归纳 +规则。`profileAnalyze` 和 `profileShow` 仍可用于人工验收,但不承担日常维护职责。外部历史库始终以只读 +方式打开,程序不会为了画像实验修改备份或创建索引。 + ### 和风天气 天气工具使用[和风天气开发服务](https://dev.qweather.com/docs/start/)和 JWT 凭据,简单配置流程如下: @@ -380,21 +444,16 @@ JChatGPT 维护对每位用户的画像,由好感度、Bot 自定义代号、 ### 好感度机制 - 负好感度用户有一定概率不会收到回复,概率 = |好感度| / 100 -- 好感度会随时间向 0 偏移:偏移量 = sign(好感度) × (1 - (|好感度| / 100)²) × 基础偏移速度 -- 极端值变化缓慢,-100 需要好几天才能回升,100 也不会快速衰减 +- 好感度不会随时间自动偏移,只会由模型工具显式调整 +- 好感度为 0 时仍会保留该用户的代号、标签、印象和调整原因 ### 注入到上下文 - 群聊:列出会话历史中"认识的群友"(name/tags/impression 任一非空) - 私聊:仅当对方有 name/tags/impression 时注入对方画像 - 仅有好感度数值、其它字段全空的用户不会被列出,避免提示词噪声 -### 管理命令 -- `/jgpt setFavor ` - 设置指定用户的好感度值(-100~100),不改其他字段 -- `/jgpt clearFavor` - 清空所有用户画像 - ### 配置选项 - `enableFavorabilitySystem` - 是否启用画像系统(默认:true) -- `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天,默认:2.0) ## 技能系统 diff --git a/build.gradle.kts b/build.gradle.kts index 2d03117..00365aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "top.jie65535.mirai" -version = "1.14.0" +version = "1.15.0" mirai { jvmTarget = JavaVersion.VERSION_11 diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index 15c29fb..bc6696f 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -1,20 +1,5 @@ package top.jie65535.mirai -import com.aallam.openai.api.chat.ChatCompletionChunk -import com.aallam.openai.api.chat.ChatCompletionRequest -import com.aallam.openai.api.chat.ChatMessage -import com.aallam.openai.api.chat.ChatRole -import com.aallam.openai.api.chat.ToolCall -import com.aallam.openai.api.core.Usage -import com.aallam.openai.api.model.ModelId -import io.ktor.util.collections.* -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register import net.mamoe.mirai.console.command.CommandSender.Companion.toCommandSender import net.mamoe.mirai.console.permission.PermissionId @@ -22,8 +7,8 @@ import net.mamoe.mirai.console.permission.PermissionService import net.mamoe.mirai.console.permission.PermissionService.Companion.hasPermission import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin -import net.mamoe.mirai.contact.* -import net.mamoe.mirai.contact.MemberPermission.* +import net.mamoe.mirai.contact.Contact +import net.mamoe.mirai.contact.isOperator import net.mamoe.mirai.event.EventPriority import net.mamoe.mirai.event.GlobalEventChannel import net.mamoe.mirai.event.events.FriendMessageEvent @@ -31,79 +16,65 @@ import net.mamoe.mirai.event.events.GroupMessageEvent import net.mamoe.mirai.event.events.MessageEvent import net.mamoe.mirai.event.events.MessagePostSendEvent import net.mamoe.mirai.event.events.MessageRecallEvent -import net.mamoe.mirai.message.data.* -import net.mamoe.mirai.message.data.Image.Key.queryUrl +import net.mamoe.mirai.message.data.At +import net.mamoe.mirai.message.data.Message +import net.mamoe.mirai.message.data.QuoteReply +import net.mamoe.mirai.message.data.content import net.mamoe.mirai.utils.info -import top.jie65535.mirai.tools.* -import util.LunarDateUtil -import java.io.File -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import kotlin.collections.* -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow -import kotlin.math.sign -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.milliseconds +import top.jie65535.mirai.command.PluginCommands +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.conversation.ConversationContext +import top.jie65535.mirai.conversation.ConversationEngine +import top.jie65535.mirai.data.ChatHistoryStore +import top.jie65535.mirai.data.ChatMessageRecord +import top.jie65535.mirai.data.PluginData +import top.jie65535.mirai.data.SkillStore +import top.jie65535.mirai.data.TokenUsageStore +import top.jie65535.mirai.llm.LargeLanguageModels +import top.jie65535.mirai.profile.ProfileAutoMaintenance +import top.jie65535.mirai.profile.ProfilePromptStore +import top.jie65535.mirai.profile.UserProfileStore +import kotlin.random.Random object JChatGPT : KotlinPlugin( JvmPluginDescription( id = "top.jie65535.mirai.JChatGPT", name = "J ChatGPT", - version = "1.14.0", + version = "1.15.0", ) { author("jie65535") } ) { - /** - * 是否包含历史对话 - */ internal var includeHistory: Boolean = false - /** - * 聊天权限 - */ val chatPermission = PermissionId("JChatGPT", "Chat") - /** - * 唤醒关键字 - */ private var keyword: Regex? = null override fun onEnable() { - // 注册聊天权限 PermissionService.INSTANCE.register(chatPermission, "JChatGPT Chat Permission") PluginConfig.reload() PluginData.reload() - - // 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug) TokenUsageStore.init(dataFolder) - - // 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群) SkillStore.init(dataFolder) - // 初始化插件自维护的 SQLite 聊天记录 includeHistory = try { - ChatHistoryStore.init(dataFolder) + ChatHistoryStore.init(dataFolder) { message, cause -> + if (cause == null) logger.warning(message) else logger.warning(message, cause) + } true - } catch (e: Throwable) { - logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", e) + } catch (cause: Throwable) { + logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", cause) false } - // 设置Token + runCatching { UserProfileStore.init(dataFolder) } + .onFailure { logger.error("初始化用户画像数据库失败,画像分析将暂时禁用", it) } + LargeLanguageModels.reload() - - // 注册插件命令 + ProfilePromptStore.reload() PluginCommands.register() - - if (PluginConfig.callKeyword.isNotEmpty()) { - keyword = Regex(PluginConfig.callKeyword) - } + keyword = PluginConfig.callKeyword.takeIf(String::isNotEmpty)?.let(::Regex) val eventChannel = GlobalEventChannel.parentScope(this) eventChannel.subscribeAlways(priority = EventPriority.HIGHEST) { event -> @@ -120,1120 +91,72 @@ object JChatGPT : KotlinPlugin( } eventChannel.subscribeAlways { event -> onMessage(event) } - // 启动定时任务处理好感度时间偏移 - if (PluginConfig.enableFavorabilitySystem) { - launch { - while (true) { - delay(24.hours) // 每24小时执行一次 - shiftFavorabilityOverTime() - } - } - } - logger.info { "Plugin loaded" } } override fun onDisable() { + ConversationEngine.clear() + ConversationContext.clearAll() + ProfileAutoMaintenance.clear() + UserProfileStore.close() ChatHistoryStore.close() } - private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss") - - private val requestMap = ConcurrentSet() - - /** - * 对话上下文缓存 - */ - private val contextCache = ConcurrentMap() - - /** - * 清空所有对话上下文缓存(供管理员命令使用) - */ fun clearContextCache() { - contextCache.clear() + ConversationContext.clearCache() } - /** - * 对话上下文缓存数据类 - * @param history 完整的消息历史 - * @param lastActivityAt 最后活动时间戳 - */ - private data class ConversationCache( - val history: MutableList, - val lastActivityAt: Int, - val replyIndex: ReplyIndex, - val imageIndex: ImageIndex, - ) { - fun isExpired(ttlSeconds: Int): Boolean { - return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds - } - } - - /** - * 回复索引:每个会话(subject)在一次对话期间维护一份「短编号 -> 消息记录」映射, - * 让 LLM 能用历史里每行行首的 [n] 来引用回复某条消息。 - * 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。 - */ - class ReplyIndex { - private val byIndex = LinkedHashMap() - private val indexByIds = HashMap() - private var counter = 0 - - fun add(record: ChatMessageRecord): Int { - // ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号 - val ids = record.ids - if (ids != null) { - indexByIds[ids]?.let { return it } - } - val i = ++counter - byIndex[i] = record - if (ids != null) { - indexByIds[ids] = i - } - return i - } - - fun get(index: Int): ChatMessageRecord? = byIndex[index] - fun indexOfIds(ids: String): Int? = indexByIds[ids] - } - - /** 各会话的回复索引,startChat 开始时重建,结束时清理 */ - private val replyIndexMap = ConcurrentMap() - - /** 各会话的图片索引,生命周期与回复索引、对话缓存一致。 */ - private val imageIndexMap = ConcurrentMap() - - /** 供发言工具按编号查找被引用的历史消息 */ internal fun lookupReplyTarget(subjectId: Long, index: Int): ChatMessageRecord? = - replyIndexMap[subjectId]?.get(index) + ConversationContext.lookupReplyTarget(subjectId, index) - /** 将从原消息图片取得的精确 URL 登记为短编号,供历史搜索等工具追加图片引用。 */ internal fun registerImage(subjectId: Long, imageId: String, imageUrl: String): Int? = - imageIndexMap[subjectId]?.add(imageId, imageUrl) + ConversationContext.registerImage(subjectId, imageId, imageUrl) - /** 按会话内短编号获取原消息解析出的 URL,避免根据 imageId 二次构造和查询。 */ internal fun lookupImageUrl(subjectId: Long, index: Int): String? = - imageIndexMap[subjectId]?.getUrl(index) + ConversationContext.lookupImageUrl(subjectId, index) - private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") - .withZone(ZoneOffset.systemDefault()) - - // 同一发言者连续消息默认省略时间以节省上下文;但间隔超过此阈值(秒)时仍补回时间, - // 避免模型把刚发的续行消息误判为很久以前发生。 - private const val CONTINUATION_TIME_GAP_SECONDS = 60L + fun toMessage(contact: Contact, content: String): Message = + ConversationContext.toMessage(contact, content) private suspend fun onMessage(event: MessageEvent) { - // 检查Token是否设置 if (LargeLanguageModels.chat == null) return - // 发送者是否有权限 if (!event.toCommandSender().hasPermission(chatPermission)) { if (event is GroupMessageEvent) { - if (PluginConfig.groupOpHasChatPermission && event.sender.isOperator()) { - // 允许管理员使用 - } else if (event.sender.active.temperature >= PluginConfig.temperaturePermission) { - // 允许活跃度达标成员使用 - } else { - // 其它情况阻止使用 - return + if (!PluginConfig.groupOpHasChatPermission || !event.sender.isOperator()) { + if (event.sender.active.temperature < PluginConfig.temperaturePermission) return } } - if (event is FriendMessageEvent) { - if (!PluginConfig.friendHasChatPermission) { - return - } - // TODO 检查好友上下文 - } + if (event is FriendMessageEvent && !PluginConfig.friendHasChatPermission) return } - // 如果没有 @bot 或者 触发关键字 或者 回复bot的消息 则直接结束 - if (!event.message.contains(At(event.bot)) - && keyword?.let { event.message.content.contains(it) } != true - && event.message[QuoteReply]?.source?.fromId != event.bot.id - ) return + val triggered = event.message.contains(At(event.bot)) || + keyword?.containsMatchIn(event.message.content) == true || + event.message[QuoteReply]?.source?.fromId == event.bot.id + if (!triggered) return - // 如果bot在群里被禁言,则无法发言,直接结束,避免浪费token if (event is GroupMessageEvent && event.group.botMuteRemaining > 0) { - logger.info("bot 在群 ${event.group.name}(${event.group.id}) 被禁言,剩余 ${event.group.botMuteRemaining} 秒,忽略消息") + logger.info( + "bot 在群 ${event.group.name}(${event.group.id}) 被禁言," + + "剩余 ${event.group.botMuteRemaining} 秒,忽略消息" + ) return } - // 好感度系统检查 - if (PluginConfig.enableFavorabilitySystem) { - val userId = event.sender.id - PluginData.userFavorability[userId]?.let { favorabilityInfo -> - val favorability = favorabilityInfo.value - if (favorability < 0) { - // 负好感度有一定概率不回复 - val probability = kotlin.math.abs(favorability).toDouble() / 100.0 - if (kotlin.random.Random.nextDouble() < probability) { - // 不回复此消息 - logger.info("根据好感度系统,用户 ${event.senderName}($userId) (好感度: $favorability) 的消息被忽略,忽略概率: ${probability * 100}%") - event.subject.sendMessage("[实验功能] 因好感度低,此消息已被忽略(${probability * 100}%)") - return - } - } - } - } - - startChat(event) + if (PluginConfig.enableFavorabilitySystem && shouldIgnoreForFavorability(event)) return + ConversationEngine.start(event) } - private var memePrompt: String? = null - - private fun getSystemPrompt(event: MessageEvent): String { - val now = OffsetDateTime.now() - val prompt = StringBuilder(LargeLanguageModels.systemPrompt) - fun replace(target: String, replacement: () -> String) { - val i = prompt.indexOf(target) - if (i != -1) { - prompt.replace(i, i + target.length, replacement()) - } - } - - replace("{time}") { - val solarTime = dateTimeFormatter.format(now) - val lunarInfo = LunarDateUtil.getFormattedLunarAndHoliday(now) - "$solarTime\n农历$lunarInfo" - } - - replace("{subject}") { - if (event is GroupMessageEvent) { - "\"${event.subject.name}\" 群聊中,你在本群的名片是:${getNameCard(event.subject.botAsMember)}" - } else { - "与 \"${event.senderName}\" 私聊中" - } - } - - replace("{memory}") { - val memoryText = PluginData.contactMemory[event.subject.id] - if (memoryText.isNullOrEmpty()) { - "暂无相关记忆" - } else memoryText - } - - replace("{skills}") { - if (PluginConfig.skillsEnabled) { - SkillStore.buildIndexPrompt() - } else "暂无技能" - } - - replace("{meme}") { - memePrompt?.let { return@replace it } - - if (PluginConfig.memeDir.isEmpty()) { - "" - } else { - buildString { - val dir = File(PluginConfig.memeDir) - if (dir.exists() && dir.isDirectory) { - append("memes文件夹地址为:") - append(PluginConfig.memeDir) - appendLine() - - val memes = dir.list() - - if (memes.isEmpty()) { - append("暂无表情包~") - } else { - for (name in memes) { - append("- ") - append(name) - appendLine() - } - appendLine() - append("表情包示例:![") - append(memes[0]) - append("](") - append(File(dir, memes[0]).absoluteFile) - append(")") - appendLine() - } - } else { - append("配置的meme路径不存在!") - } - }.also { - memePrompt = it - } - } - } - - return prompt.toString() - } - - // region - 历史消息相关 - - - /** - * 获取历史消息 - * @param event 消息事件 - * @return 如果未获取到则返回空字符串 - */ - private fun getHistory(event: MessageEvent): String { - val imageIndex = imageIndexMap.getOrPut(event.subject.id) { ImageIndex() } - if (!includeHistory) { - return formatRecordContent(event.message, event.subject, imageIndex) - } - val now = OffsetDateTime.now() - // 一段时间内的消息 - val beforeTimestamp = now.minusMinutes(PluginConfig.historyWindowMin.toLong()).toEpochSecond().toInt() - return getAfterHistory(beforeTimestamp, event) - } - - /** - * 获取指定时间后的历史消息 - * @param time Epoch时间戳 - * @param event 消息事件 - * @return 如果未获取到则返回空字符串 - */ - private fun getAfterHistory(time: Int, event: MessageEvent): String { - if (!includeHistory) { - return "" - } - // 现在时间 - val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt() - // 最近这段时间的历史对话 - val history = try { - ChatHistoryStore.query( - contact = event.subject, - start = time, - end = nowTimestamp, - limit = PluginConfig.historyMessageLimit, - ).sortedBy { it.time }.toMutableList() - } catch (e: Throwable) { - logger.warning("查询 SQLite 消息历史失败", e) - mutableListOf() - } - - // 有一定概率最后一条消息没加入,这里检查然后补充一下 - val msgIds = event.message.ids.joinToString(",") - if (!history.any { it.ids == msgIds }) { - history.add(ChatMessageRecord.fromSuccess(event.message.source, event.message)) - } - - // 构造历史消息 - val historyText = StringBuilder() - var lastId = 0L - var lastTime = 0L - // 本轮回复索引,逐条登记消息编号供 [n] 引用 - val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() } - val imageIndex = imageIndexMap.getOrPut(event.subject.id) { ImageIndex() } - if (event is GroupMessageEvent) { - if (PluginConfig.enableFavorabilitySystem) { - val knownUsers = history.asSequence() - .map { it.fromId } - .filter { it != event.bot.id } - .distinct() - .mapNotNull { PluginData.userFavorability[it] } - .filter { it.name.isNotEmpty() || it.tags.isNotEmpty() || it.impression.isNotEmpty() } - .sortedBy { it.userId } - .toList() - if (knownUsers.isNotEmpty()) { - historyText.appendLine("【你认识的群友】") - for (info in knownUsers) { - val displayName = if (info.name.isNotEmpty()) info.name - else getNameCard(event.subject, info.userId) - historyText.append("- ").append(displayName) - .append("(${info.userId})") - .append(" 好感度${if (info.value >= 0) "+" else ""}${info.value}") - if (info.tags.isNotEmpty()) historyText.append(" [${info.tags.joinToString(", ")}]") - if (info.impression.isNotEmpty()) historyText.append(" ${info.impression}") - historyText.appendLine() - } - historyText.appendLine() - } - } - - historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)") - for (record in history) { - // 同一人发言不要反复出现这人的名字,减少上下文 - val showSender = lastId != record.fromId - val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS - appendGroupMessageRecord(historyText, record, event, replyIndex, imageIndex, showSender, showTime) - lastId = record.fromId - lastTime = record.time.toLong() - } - } else { - if (PluginConfig.enableFavorabilitySystem) { - val favorabilityInfo = PluginData.userFavorability[event.sender.id] - if (favorabilityInfo != null && (favorabilityInfo.name.isNotEmpty() || favorabilityInfo.tags.isNotEmpty() || favorabilityInfo.impression.isNotEmpty())) { - val displayName = if (favorabilityInfo.name.isNotEmpty()) favorabilityInfo.name else event.senderName - historyText.appendLine("【你认识的对方】") - historyText.append("- ").append(displayName) - .append("(${event.sender.id})") - .append(" 好感度${if (favorabilityInfo.value >= 0) "+" else ""}${favorabilityInfo.value}") - if (favorabilityInfo.tags.isNotEmpty()) historyText.append(" [${favorabilityInfo.tags.joinToString(", ")}]") - if (favorabilityInfo.impression.isNotEmpty()) historyText.append(" ${favorabilityInfo.impression}") - historyText.appendLine().appendLine() - } - } - - historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)") - for (record in history) { - // 同一人发言不要反复出现这人的名字,减少上下文 - val showSender = lastId != record.fromId - val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS - appendMessageRecord(historyText, record, event, replyIndex, imageIndex, showSender, showTime) - lastId = record.fromId - lastTime = record.time.toLong() - } - } - - return historyText.toString() - } - - /** - * 添加群消息记录到历史上下文中 - * @param historyText 历史消息构造器 - * @param record 群消息记录 - * @param event 群消息事件 - */ - private fun appendGroupMessageRecord( - historyText: StringBuilder, - record: ChatMessageRecord, - event: GroupMessageEvent, - replyIndex: ReplyIndex, - imageIndex: ImageIndex, - showSender: Boolean, - showTime: Boolean, - ) { - val index = replyIndex.add(record) - val recordMessage = record.toMessageChain() - - historyText.append('[').append(index).append("] ") - if (showSender) { - // 新发言者:[n] 名称 时间 - if (event.bot.id == record.fromId) { - historyText.append("**你** ").append(getNameCard(event.subject.botAsMember)) - } else { - historyText.append(getNameCard(event.subject, record.fromId)) - } - historyText.append(' ') - .append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong()))) - .append(' ') - } else { - // 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生 - historyText.append(" └ ") - if (showTime) { - historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong()))) - .append(' ') - } - } - - // 引用:用编号指针替代内联原文,避免被误认为是本人发言 - recordMessage[QuoteReply.Key]?.let { - appendQuoteMarker(historyText, it, event.subject, replyIndex, imageIndex) - } - - historyText.appendLine(formatRecordContent(recordMessage, event.subject, imageIndex)) - } - - /** - * 序列化「引用回复」标记:被引用消息在窗口内时用 ↩[编号],否则内联简短原文并标注原作者。 - */ - private fun appendQuoteMarker( - sb: StringBuilder, - quote: QuoteReply, - contact: Contact, - replyIndex: ReplyIndex, - imageIndex: ImageIndex, - ) { - val srcIds = quote.source.ids.joinToString(",") - val idx = replyIndex.indexOfIds(srcIds) - if (idx != null) { - sb.append("↩[").append(idx).append("] ") - } else { - val author = if (contact is Group) { - contact[quote.source.fromId]?.nameCardOrNick ?: "未知(${quote.source.fromId})" - } else { - quote.source.fromId.toString() - } - val snippet = quote.source.originalMessage - .joinToString("") { singleMessageToText(it, imageIndex) } - .replace("\n", " ") - .let { if (it.length > 20) it.take(20) + "…" else it } - sb.append("↩(").append(author).append(":\"").append(snippet).append("\") ") - } - } - - /** - * 序列化消息正文(剔除引用/源元数据,@显示为名称,转发折叠)。 - */ - private fun formatRecordContent(chain: MessageChain, contact: Contact, imageIndex: ImageIndex): String = - chain.asSequence() - .filterNot { it is QuoteReply || it is MessageSource } - .joinToString("") { - when (it) { - is At -> if (contact is Group) it.getDisplay(contact) else it.content - else -> singleMessageToText(it, imageIndex) - } - } - - private fun getNameCard(group: Group, qq: Long): String { - val member = group[qq] - return if (member == null) { - "未知群员($qq)" - } else { - getNameCard(member) - } - } - - /** - * 添加消息记录到历史上下文中 - * @param historyText 历史消息构造器 - * @param record 消息记录 - * @param event 消息事件 - */ - private fun appendMessageRecord( - historyText: StringBuilder, - record: ChatMessageRecord, - event: MessageEvent, - replyIndex: ReplyIndex, - imageIndex: ImageIndex, - showSender: Boolean, - showTime: Boolean, - ) { - val index = replyIndex.add(record) - val recordMessage = record.toMessageChain() - - historyText.append('[').append(index).append("] ") - if (showSender) { - if (event.bot.id == record.fromId) { - historyText.append("**你** ").append(event.bot.nameCardOrNick) - } else { - historyText.append(event.senderName) - } - historyText.append(' ') - .append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong()))) - .append(' ') - } else { - // 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生 - historyText.append(" └ ") - if (showTime) { - historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong()))) - .append(' ') - } - } - - recordMessage[QuoteReply.Key]?.let { - appendQuoteMarker(historyText, it, event.subject, replyIndex, imageIndex) - } - - historyText.appendLine(formatRecordContent(recordMessage, event.subject, imageIndex)) - } - - private fun singleMessageToText(it: SingleMessage, imageIndex: ImageIndex): String { - return when (it) { - // 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断) - is ForwardMessage -> formatForward(it, 1, imageIndex) - - // 图片格式化 - is Image -> { - try { - val imageUrl = runBlocking { it.queryUrl() } - val index = imageIndex.add(it.imageId, imageUrl) - "[${if (it.isEmoji) "表情包" else "图片"}$index]" - } catch (e: Throwable) { - logger.warning("图片地址获取失败", e) - it.content - } - } - - else -> it.content - } - } - - /** - * 递归展开合并转发消息,用 Markdown 引用块表示:每加深一层嵌套多一个 `>`(>、>>、>>>…)。 - * @param depth 当前嵌套层级,从 1 开始 - */ - private fun formatForward(forward: ForwardMessage, depth: Int, imageIndex: ImageIndex): String = buildString { - val quote = ">".repeat(depth) + " " - append("[转发消息·").append(forward.nodeList.size).append("条") - if (forward.title.isNotEmpty()) append(':').append(forward.title) - append(']') - for (node in forward.nodeList) { - append('\n').append(quote) - .append(node.senderName).append(' ') - .append(shortTimeFormatter.format(Instant.ofEpochSecond(node.time.toLong()))) - .append(": ") - node.messageChain.forEach { sub -> - if (sub is ForwardMessage) { - // 嵌套转发:层级加深,自带更深的 `>` 前缀,无需再次缩进 - append(formatForward(sub, depth + 1, imageIndex)) - } else { - // 其它内容:多行正文对齐到当前引用层级 - append(singleMessageToText(sub, imageIndex).replace("\n", "\n$quote")) - } - } - } - } - - // endregion - 历史消息相关 - - - private val thinkRegex = Regex("[\\s\\S]*?") - - /** - * 截断过长的工具输出,并添加省略标记 - */ - private fun truncateToolOutput(content: String, maxLength: Int = PluginConfig.maxToolOutputLength): String { - if (content.length <= maxLength) return content - - val truncated = content.take(maxLength) - val marker = "\n\n[系统提示:因内容过长,部分内容已被省略]" - return truncated + marker - } - - private suspend fun startChat(event: MessageEvent) { - if (!requestMap.add(event.subject.id)) { - logger.warning("The current Contact is busy!") - return - } - - try { - // 尝试从缓存加载上下文 - val subjectId = event.subject.id - val cache = contextCache[subjectId] - val reuseCache = PluginConfig.enableContextCache - && cache != null - && !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60) - // 回复索引与对话上下文同寿命:复用缓存时沿用旧索引,保证 LLM 看到的 [n] 编号连续不串号; - // 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息) - val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex() - val imageIndex = if (reuseCache) cache!!.imageIndex else ImageIndex() - replyIndexMap[subjectId] = replyIndex - imageIndexMap[subjectId] = imageIndex - val history = if (reuseCache) { - // 缓存有效,复用历史 - logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息") - cache.history - } else { - // 缓存无效或不存在,创建新上下文 - mutableListOf() - } - - // 如果历史为空,添加系统提示词和聊天记录 - if (history.isEmpty() || cache == null) { - val prompt = getSystemPrompt(event) - if (PluginConfig.logPrompt) { - logger.info("Prompt: $prompt") - } - history.add(ChatMessage(ChatRole.System, prompt)) - - val historyText = getHistory(event) - logger.info("注入聊天记录:\n$historyText") - history.add(ChatMessage.User(historyText)) - } else { - val newMessages = getAfterHistory(cache.lastActivityAt, event) - logger.info("补充聊天记录:\n$newMessages") - history.add( - ChatMessage.User( - "## 以下是上次对话结束至今的新消息\n\n$newMessages" - ) - ) - } - - // 聊天接入点容灾:按健康度排序,主接入点故障冷却时备用接入点会自动排到前面 - val endpoints = LargeLanguageModels.orderedChatEndpoints() - if (endpoints.isEmpty()) throw NullPointerException("OpenAI Token 未设置,无法开始") - var endpointIndex = 0 - - var done: Boolean - // 至少循环3次 - var retry = max(PluginConfig.retryMax, 3) - do { - // 当前使用的接入点:失败重试时会前移到下一个备用接入点 - val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)] - // 标记本轮 LLM 流式调用是否成功完成,用于精确区分「LLM失败」与「后续工具失败」 - var streamingOk = false - try { - val startedAt = OffsetDateTime.now().toEpochSecond().toInt() - var lastCacheUsage: ModelService.CacheUsage? = null - val responseFlow = chatCompletions(history, endpoint) { lastCacheUsage = it } - var responseMessageBuilder: StringBuilder? = null - var reasoningContentBuilder: StringBuilder? = null - val responseToolCalls = mutableListOf() - val toolCallTasks = mutableListOf>() - var lastTokenUsage: Usage? = null - // 处理聊天流式响应 - responseFlow.collect { chunk -> - val delta = chunk.choices[0].delta ?: return@collect - - // 处理推理内容更新 - if (delta.reasoningContent != null) { - if (reasoningContentBuilder == null) { - reasoningContentBuilder = StringBuilder(delta.reasoningContent) - } else { - reasoningContentBuilder.append(delta.reasoningContent) - } - } - - // 处理内容更新 - if (delta.content != null) { - if (responseMessageBuilder == null) { - responseMessageBuilder = StringBuilder(delta.content) - } else { - responseMessageBuilder.append(delta.content) - } - } - - // 处理工具调用更新 - val toolCalls = delta.toolCalls - if (toolCalls != null) { - for (toolCallChunk in toolCalls) { - val index = toolCallChunk.index - val toolId = toolCallChunk.id - val function = toolCallChunk.function - // 新的请求 - if (index >= responseToolCalls.size) { - // 处理已完成的工具调用 - responseToolCalls.lastOrNull()?.let { toolCall -> - toolCallTasks.add(async { - val functionResponse = toolCall.execute(event) - ChatMessage( - role = ChatRole.Tool, - toolCallId = toolCall.id, - name = toolCall.function.name, - content = functionResponse - ) - }) - } - - // 加入新的工具调用 - if (toolId != null && function != null) { - responseToolCalls.add(ToolCall.Function(toolId, function)) - } - } else if (function != null) { - // 拼接函数名字 - if (function.nameOrNull != null) { - val currentTool = responseToolCalls[index] - responseToolCalls[index] = currentTool.copy( - function = currentTool.function.copy( - nameOrNull = currentTool.function.nameOrNull.orEmpty() + function.name - ) - ) - } - // 拼接函数参数 - if (function.argumentsOrNull != null) { - val currentTool = responseToolCalls[index] - responseToolCalls[index] = currentTool.copy( - function = currentTool.function.copy( - argumentsOrNull = currentTool.function.argumentsOrNull.orEmpty() + function.arguments - ) - ) - } - } - } - } - - // 捕获token使用量 - chunk.usage?.let { lastTokenUsage = it } - } - // LLM 流式调用成功完成,上报接入点健康(清除冷却) - streamingOk = true - LargeLanguageModels.reportSuccess(endpoint) - - // 移除思考内容 - val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim() - logger.info("LLM Response: $responseContent") - // 记录AI回答 - // reasoning_content仅在工具调用时需要回传(DeepSeek规范),否则丢弃 - // toolCalls空列表转null,避免序列化为"tool_calls":[]导致DeepSeek V4报400 - // explicitNulls=false确保null字段不会序列化到JSON中,兼容所有API - history.add( - ChatMessage( - role = ChatRole.Assistant, - content = responseContent, - toolCalls = responseToolCalls.ifEmpty { null }, - reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContentBuilder?.toString() else null - ) - ) - - // 记录token使用量(按日聚合,独立JSON文件) - lastTokenUsage?.let { usage -> - val now = OffsetDateTime.now().toEpochSecond() - val group = if (event is GroupMessageEvent) event.group else null - TokenUsageStore.record( - timestamp = now, - userId = event.sender.id, - userNickname = event.senderName, - groupId = group?.id, - groupName = group?.name, - promptTokens = usage.promptTokens ?: 0, - completionTokens = usage.completionTokens ?: 0, - totalTokens = usage.totalTokens ?: 0, - cachedTokens = lastCacheUsage?.hitTokens ?: 0 - ) - } - - // 处理最后一个工具调用 - if (responseToolCalls.size > toolCallTasks.size) { - val toolCallMessage = responseToolCalls.last().let { toolCall -> - val functionResponse = toolCall.execute(event) - ChatMessage( - role = ChatRole.Tool, - toolCallId = toolCall.id, - name = toolCall.function.name, - content = functionResponse - ) - } - - if (toolCallTasks.isNotEmpty()) { - // 等待之前的所有工具完成 - history.addAll(toolCallTasks.awaitAll()) - } - // 将最后一个也加入对话历史中 - history.add(toolCallMessage) - // 如果调用中包含结束对话工具则表示完成,反之则继续循环 - done = responseToolCalls.any { it.function.name == "endConversation" } - } else { - done = true - } - - if (!done) { - history.add( - ChatMessage.User( - buildString { - appendLine("## 系统提示") - append("本次运行最多还剩").append(retry - 1).appendLine("轮。") - appendLine("如果要多次发言,可以一次性调用多次发言工具。") - appendLine("如果没有什么要做的,可以提前结束。") - appendLine("当前时间:" + dateTimeFormatter.format(OffsetDateTime.now())) - - val newMessages = getAfterHistory(startedAt, event) - if (newMessages.isNotEmpty()) { - append("## 以下是上次运行至今的新消息\n\n$newMessages") - } - } - )) - } else { - // 保存对话上下文到缓存 - if (PluginConfig.enableContextCache) { - contextCache[subjectId] = ConversationCache( - history = history, - lastActivityAt = startedAt, - replyIndex = replyIndex, - imageIndex = imageIndex, - ) - logger.debug("已保存对话上下文到缓存") - } - } - } catch (e: Exception) { - // 仅当 LLM 流式调用本身失败时才上报接入点故障并切换; - // 若流式已成功、异常来自后续工具执行,则保持当前接入点不变 - if (!streamingOk) { - LargeLanguageModels.reportFailure(endpoint) - if (endpointIndex < endpoints.lastIndex) { - endpointIndex++ - logger.warning("接入点[${endpoint.label}]调用失败,切换备用接入点[${endpoints[endpointIndex].label}]重试", e) - } else { - logger.warning("接入点[${endpoint.label}]调用失败,无更多备用接入点,重试中", e) - } - } else { - logger.warning("调用llm后处理时发生异常,重试中", e) - } - if (retry <= 1) { - throw e - } else { - done = false - // event.subject.sendMessage("出错了...正在重试...") - } - } - } while (!done && 0 < --retry) - } catch (ex: Throwable) { - logger.warning(ex) - event.subject.sendMessage("很抱歉,发生异常,请稍后重试") - } finally { - // 清理本轮回复索引 - replyIndexMap.remove(event.subject.id) - imageIndexMap.remove(event.subject.id) - // 一段时间后才允许再次提问,防止高频对话 - launch { - delay(500.milliseconds) - requestMap.remove(event.subject.id) - } - } - } - - private val regexAtQq = Regex("""@(\d{5,12})""") - - private val regexImage = Regex("""!\[(.*?)]\(([^\s"']+).*?\)""") - - private data class MessageChunk(val range: IntRange, val content: Message) - - /** - * 将聊天内容转为聊天消息 - * - * @param contact 联系对象 - * @param content 文本内容 - * @return 构造的消息 - */ - fun toMessage(contact: Contact, content: String): Message { - return if (content.isEmpty()) { - PlainText("...") - } else if (content.length < 3) { - PlainText(content) - } else { - val t = mutableListOf() - // @某人 - regexAtQq.findAll(content).forEach { - val qq = it.groups[1]?.value?.toLongOrNull() - if (qq != null && contact is Group) { - contact[qq]?.let { member -> t.add(MessageChunk(it.range, At(member))) } - } - } - - // 图片 - regexImage.findAll(content).forEach { - // val placeholder = it.groupValues[1] - val url = it.groupValues[2] - t.add( - MessageChunk( - it.range, - Image(url) - ) - ) - } - - // 构造消息链 - buildMessageChain { - var index = 0 - for ((range, msg) in t.sortedBy { it.range.first }) { - if (index < range.first) { - append(content, index, range.first) - } - append(msg) - index = range.last + 1 - } - // 拼接后续消息 - if (index < content.length) { - append(content, index, content.length) - } - } - } - } - - /** - * 工具列表 - */ - private val myTools = listOf( - // 发送单条消息 - SendSingleMessageAgent(), - - // 发送组合消息 - SendCompositeMessage(), - - // 发送语音消息 - SendVoiceMessage(), - - // 发送LaTeX表达式 - SendLaTeXExpression(), - - // 结束循环 - StopLoopAgent(), - - // 记忆代理 - MemoryAppend(), - - // 记忆修改 - MemoryReplace(), - - // 技能:加载 - LoadSkill(), - - // 技能:沉淀/迭代 - SaveSkill(), - - // 技能:删除 - DeleteSkill(), - - // 搜索聊天历史 - SearchChatHistory(), - - // 网页搜索 - WebSearch(), - - // 访问网页 - VisitWeb(), - - // 运行代码 - RunCode(), - - // 推理代理 - ReasoningAgent(), - - // 视觉代理 - VisualAgent(), - - // 图像生成与编辑 - ImageAgent(), - - // 天气服务 - WeatherService(), - - // 好感度调整 - AdjustUserFavorabilityAgent(), - - // 请求主人帮助 - RequestOwner(), - - // Epic 免费游戏 - // EpicFreeGame(), - - // 群管代理 - GroupManageAgent(), - ) - -// private suspend fun chatCompletion( -// chatMessages: List, -// hasTools: Boolean = true -// ): ChatMessage { -// val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始") -// val availableTools = if (hasTools) { -// myTools.filter { it.isEnabled }.map { it.tool } -// } else null -// val request = ChatCompletionRequest( -// model = ModelId(PluginConfig.chatModel), -// temperature = PluginConfig.chatTemperature, -// messages = chatMessages, -// tools = availableTools, -// ) -// logger.info("API Requesting... Model=${PluginConfig.chatModel}") -// val response = llm.chatCompletion(request) -// val message = response.choices.first().message -// logger.info("Response: $message ${response.usage}") -// return message -// } - - private fun chatCompletions( - chatMessages: List, - endpoint: LargeLanguageModels.ChatEndpoint, - hasTools: Boolean = true, - onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null - ): Flow { - val availableTools = if (hasTools) { - myTools.filter { it.isEnabled }.map { it.tool } - } else null - val request = ChatCompletionRequest( - model = ModelId(endpoint.model), - temperature = endpoint.temperature, - messages = chatMessages, - tools = availableTools, + private suspend fun shouldIgnoreForFavorability(event: MessageEvent): Boolean { + val info = PluginData.userFavorability[event.sender.id] ?: return false + if (info.value >= 0) return false + val probability = kotlin.math.abs(info.value).toDouble() / 100.0 + if (Random.nextDouble() >= probability) return false + logger.info( + "根据好感度系统,用户 ${event.senderName}(${event.sender.id}) " + + "(好感度: ${info.value}) 的消息被忽略,忽略概率: ${probability * 100}%" ) - logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]") - return endpoint.service.chatCompletions(request, onCacheUsage) + event.subject.sendMessage("[实验功能] 因好感度低,此消息已被忽略(${probability * 100}%)") + return true } - private fun getNameCard(member: Member): String { - val nameCard = StringBuilder("【") - // 群活跃等级:active 依赖 OneBot 拉取群荣誉数据,繁忙/失败时会抛 "Error code: 2", - // 必须兜底,否则整次回复都会因取名片失败而中断。 - try { - nameCard.append("lv").append(member.active.temperature).append(' ') - } catch (e: Throwable) { - logger.warning("获取群活跃等级失败", e) - } - // 真实群身份:始终按实际权限显示,不会被专属头衔覆盖 - nameCard.append( - when (member.permission) { - OWNER -> "群主" - ADMINISTRATOR -> "管理员" - MEMBER -> "群员" - } - ) - // 头衔:有专属头衔则显示专属头衔(群主可任意赋予,可能与真实身份不符,故标注"头衔"以区分), - // 否则回退到聊天窗口可见的活跃等级称号 - try { - if (member.specialTitle.isNotEmpty()) { - nameCard.append(" 头衔\"").append(member.specialTitle).append('"') - } else if (member.temperatureTitle.isNotEmpty()) { - nameCard.append(' ').append(member.temperatureTitle) - } - } catch (e: Throwable) { - logger.warning("获取群头衔失败", e) - } - // 群名片 - nameCard.append("】\t\"").append(member.nameCardOrNick).append("\"\t(qq=").append(member.id).append(")") - return nameCard.toString() - } - - - private suspend fun ToolCall.Function.execute(event: MessageEvent): String { - val agent = myTools.find { it.tool.function.name == function.name } - ?: return "Function ${function.name} not found" - // 提示正在执行函数 - val receipt = if (PluginConfig.showToolCallingMessage && agent.loadingMessage.isNotEmpty()) { - event.subject.sendMessage(agent.loadingMessage) - } else null - // 执行函数 - val result = try { - // 提取参数 - val args = function.argumentsAsJsonOrNull() - logger.info("Calling ${function.name}(${args})") - agent.execute(args, event) - } catch (e: Throwable) { - logger.error("Failed to call ${function.name}", e) - "工具调用失败,请尝试自行回答用户,或如实告知。\n异常信息:${e.message}" - } - logger.info("Result=\"$result\"") - - // 截断过长的工具输出 - val truncatedResult = truncateToolOutput(result) - if (truncatedResult.length != result.length) { - logger.warning("工具 ${function.name} 返回内容过长,已从 ${result.length} 字符截断至 ${truncatedResult.length} 字符") - } - - // 过会撤回加载消息 - if (receipt != null) { - launch { - delay(3.seconds) - try { - receipt.recall() - } catch (e: Throwable) { - logger.error( - "消息撤回失败,调试信息:" + - "source.internalIds=${receipt.source.internalIds.joinToString()} " + - "source.ids= ${receipt.source.ids.joinToString()}", e - ) - } - } - } - return truncatedResult - } - - /** - * 好感度时间偏移处理函数 - * 使好感度逐渐向0回归,偏移速度与当前好感度绝对值相关 - */ - private fun shiftFavorabilityOverTime() { - logger.info("开始执行好感度时间偏移处理") - - val iterator = PluginData.userFavorability.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - val userId = entry.key - val favorabilityInfo = entry.value - val currentFavorability = favorabilityInfo.value - - // 计算偏移量 - // 偏移公式:偏移量 = sign(好感度) * (1 - (|好感度| / 100)^2) * 基础偏移速度 - val sign = sign(currentFavorability.toFloat()).toInt() - val absFavorability = kotlin.math.abs(currentFavorability) - val shiftAmount = sign * (1 - (absFavorability / 100.0).pow(2)) * PluginConfig.favorabilityBaseShiftSpeed - - // 更新好感度 - val newFavorability = (currentFavorability - shiftAmount).toInt().coerceIn(-100, 100) - - // 如果新的好感度为0,则移除该条目以节省空间 - if (newFavorability == 0) { - iterator.remove() - logger.info("用户 $userId 的好感度已回归0,移除记录") - } else { - // 创建新的好感度信息,保持原因和印象不变 - val newInfo = favorabilityInfo.copy(value = newFavorability) - PluginData.userFavorability[userId] = newInfo - logger.info("用户 $userId 的好感度 ($currentFavorability -> $newFavorability)") - } - } - - logger.info("好感度时间偏移处理完成") - } } diff --git a/src/main/kotlin/PluginCommands.kt b/src/main/kotlin/command/PluginCommands.kt similarity index 58% rename from src/main/kotlin/PluginCommands.kt rename to src/main/kotlin/command/PluginCommands.kt index 0ce091a..1f695c9 100644 --- a/src/main/kotlin/PluginCommands.kt +++ b/src/main/kotlin/command/PluginCommands.kt @@ -1,5 +1,7 @@ -package top.jie65535.mirai +package top.jie65535.mirai.command +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch import net.mamoe.mirai.console.command.CommandSender import net.mamoe.mirai.console.command.CompositeCommand import net.mamoe.mirai.console.permission.PermissionService.Companion.cancel @@ -9,8 +11,23 @@ import net.mamoe.mirai.contact.Contact import net.mamoe.mirai.contact.Group import net.mamoe.mirai.contact.Member import net.mamoe.mirai.contact.User +import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.JChatGPT.reload +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.PluginData +import top.jie65535.mirai.data.SkillStore +import top.jie65535.mirai.data.TokenUsageStore +import top.jie65535.mirai.llm.LargeLanguageModels +import top.jie65535.mirai.profile.ProfileAnalysisReport +import top.jie65535.mirai.profile.ProfileAutoMaintenance +import top.jie65535.mirai.profile.ProfilePromptStore +import top.jie65535.mirai.profile.UserProfileAnalysisService +import top.jie65535.mirai.profile.UserProfileSnapshot +import top.jie65535.mirai.profile.UserProfileStore +import java.time.Instant import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter object PluginCommands : CompositeCommand( JChatGPT, "jgpt", description = "J OpenAI ChatGPT" @@ -21,10 +38,53 @@ object PluginCommands : CompositeCommand( PluginConfig.reload() PluginData.reload() LargeLanguageModels.reload() + ProfilePromptStore.reload() + if (!PluginConfig.profileEnabled || !PluginConfig.profileAutoUpdateEnabled) { + ProfileAutoMaintenance.clear() + } SkillStore.reload() sendMessage("OK") } + @SubCommand + suspend fun CommandSender.profileAnalyze(userId: Long, batches: Int = 1) { + require(batches in 1..50) { "batches 必须在 1 到 50 之间" } + sendMessage("已启动用户 $userId 的画像分析,本次最多推进 $batches 个批次。") + JChatGPT.launch { + try { + val report = UserProfileAnalysisService.analyze(userId, batches) { progress -> + JChatGPT.logger.info( + "PROFILE_BATCH user=$userId batch=${progress.batchIndex}/$batches " + + "range=${progress.startTime}-${progress.endTime} " + + "messages=${progress.messageCount} operations=${progress.operationCount} " + + "tokens=${progress.usage.promptTokens}/${progress.usage.completionTokens} " + + "cached=${progress.usage.cachedTokens}" + ) + } + when { + report.alreadyRunning -> sendMessage("用户 $userId 已有画像分析任务在运行。") + report.profile == null -> sendMessage("聊天记录中没有找到用户 $userId 的群聊发言。") + else -> sendMessage(formatProfileReport(report)) + } + } catch (cause: CancellationException) { + throw cause + } catch (cause: Exception) { + JChatGPT.logger.error("用户 $userId 画像分析失败", cause) + sendMessage("用户 $userId 画像分析失败:${cause.message ?: cause::class.simpleName}") + } + } + } + + @SubCommand + suspend fun CommandSender.profileShow(userId: Long) { + if (!UserProfileStore.isAvailable) { + sendMessage("用户画像数据库不可用。") + return + } + val profile = UserProfileStore.load(userId) + sendMessage(profile?.let(::formatProfile) ?: "用户 $userId 尚无画像。") + } + @SubCommand suspend fun CommandSender.skills() { val all = SkillStore.all @@ -65,24 +125,6 @@ object PluginCommands : CompositeCommand( sendMessage("OK") } - @SubCommand - suspend fun CommandSender.setFavor(user: User, value: Int) { - // 限制好感度值在-100到100之间 - val clampedValue = value.coerceIn(-100, 100) - // 获取当前的好感度信息 - val currentInfo = PluginData.userFavorability[user.id] ?: FavorabilityInfo(user.id) - // 创建新的好感度信息,保持原因和印象不变 - val newInfo = currentInfo.copy(value = clampedValue) - PluginData.userFavorability[user.id] = newInfo - sendMessage("OK") - } - - @SubCommand - suspend fun CommandSender.clearFavor() { - PluginData.userFavorability.clear() - sendMessage("OK") - } - @SubCommand suspend fun CommandSender.clearContextCache() { JChatGPT.clearContextCache() @@ -223,7 +265,49 @@ object PluginCommands : CompositeCommand( private fun validateDays(days: Int) { require(days > 0) { "days must be positive: $days" } } + + private fun formatProfileReport(report: ProfileAnalysisReport): String = buildString { + appendLine( + "画像分析完成:${report.processedBatches} 批,${report.processedMessages} 条上下文消息," + + "${report.appliedOperations} 项变更" + ) + appendLine( + "Token:输入 ${formatNumber(report.usage.promptTokens)},输出 " + + "${formatNumber(report.usage.completionTokens)},缓存命中 " + + formatNumber(report.usage.cachedTokens) + ) + appendLine("状态:${if (report.caughtUp) "已追平当前快照" else "可继续推进"}") + append(formatProfile(checkNotNull(report.profile))) + }.trim() + + private fun formatProfile(profile: UserProfileSnapshot): String = buildString { + appendLine("用户 ${profile.userId} · 画像 v${profile.version}") + if (profile.cursorTime <= 0) { + appendLine("历史回顾:尚未开始(当前画像来自自动会话归纳)") + } else { + appendLine("历史回顾覆盖至 ${formatProfileTime(profile.cursorTime)}") + } + appendLine("摘要:${profile.summary.ifBlank { "(暂无)" }}") + if (profile.items.isEmpty()) { + append("条目:(暂无)") + } else { + appendLine("条目:") + profile.items.forEach { item -> + append("- [").append(item.category.name.lowercase()).append('/') + .append(item.confidence.name.lowercase()).append("] ") + .append(item.content) + item.relatedUserId?.let { append("(关联用户 ").append(it).append(')') } + appendLine() + } + } + }.trim() + + private fun formatProfileTime(epochSecond: Int): String = + PROFILE_TIME_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong())) } // 常量定义 -private const val TOP_LIMIT = 5 \ No newline at end of file +private const val TOP_LIMIT = 5 +private val PROFILE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/config/PluginConfig.kt similarity index 70% rename from src/main/kotlin/PluginConfig.kt rename to src/main/kotlin/config/PluginConfig.kt index dddc258..be07ad9 100644 --- a/src/main/kotlin/PluginConfig.kt +++ b/src/main/kotlin/config/PluginConfig.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.config import kotlinx.serialization.Serializable import net.mamoe.mirai.console.data.AutoSavePluginConfig @@ -57,6 +57,78 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("聊天模型备用接入点列表(容灾)。主接入点连续失败时按顺序切换;每项留空的字段会继承主接入点,例如只换API KEY就只填token,只换模型就只填model") val chatFallbacks: List by value() + @ValueDescription("是否启用实验性的历史用户画像分析") + val profileEnabled: Boolean by value(true) + + @ValueDescription("画像分析模型API。留空时继承聊天模型API") + val profileModelApi: String by value("") + + @ValueDescription("画像分析模型Token。留空时继承聊天模型Token") + val profileModelToken: String by value("") + + @ValueDescription("画像分析模型。留空时继承聊天模型") + val profileModel: String by value("") + + @ValueDescription("画像分析模型温度。留空时继承聊天模型温度") + val profileModelTemperature: Double? by value(null) + + @ValueDescription("画像分析模型额外请求体JSON。留空时继承聊天模型额外请求体") + val profileModelExtraBody: String by value("") + + @ValueDescription("画像分析提示词文件路径,相对于插件配置目录") + val profilePromptFile: String by value("ProfilePrompt.md") + + @ValueDescription("闭合群聊多人画像提示词文件路径,相对于插件配置目录") + val profileConversationPromptFile: String by value("ProfileConversationPrompt.md") + + @ValueDescription("画像分析使用的聊天记录SQLite路径。留空时使用插件自己的chat-history.sqlite;本地实验可填写历史库绝对路径") + val profileHistoryDatabasePath: String by value("") + + @ValueDescription("每个画像分析批次最多读取目标用户多少条消息") + val profileBatchTargetMessages: Int by value(120) + + @ValueDescription("每个画像分析批次最多包含多少个离散对话片段;同一秒的消息仍会一起处理") + val profileBatchMaxEpisodes: Int by value(16) + + @ValueDescription("目标用户相邻发言超过多少分钟时划分为新的对话片段") + val profileEpisodeGapMinutes: Int by value(60) + + @ValueDescription("每个对话片段最多附带多少条前置上下文") + val profileContextBeforeMessages: Int by value(30) + + @ValueDescription("每个对话片段最多附带多少条后续上下文") + val profileContextAfterMessages: Int by value(30) + + @ValueDescription("画像分析单批次最多保留多少条非目标用户上下文消息") + val profileContextCoreMessages: Int by value(300) + + @ValueDescription("画像分析中单条消息最多保留的字符数") + val profileMaxMessageChars: Int by value(1000) + + @ValueDescription("画像模型响应无效时的最大重试次数,取值0~3") + val profileRetryMax: Int by value(2) + + @ValueDescription("单条画像结论的最大字符数") + val profileSummaryMaxLength: Int by value(500) + + @ValueDescription("是否在群聊会话结束后静默自动维护相关用户画像") + val profileAutoUpdateEnabled: Boolean by value(true) + + @ValueDescription("自动画像分析一次闭合会话最多读取多少条最近消息") + val profileAutoConversationMessageLimit: Int by value(150) + + @ValueDescription("用户在会话中至少包含多少个本人文本字符才调用画像模型") + val profileAutoMinAuthoredTextChars: Int by value(20) + + @ValueDescription("是否在普通群聊上下文中自动注入相关用户的画像摘要") + val profileAutoInjectEnabled: Boolean by value(true) + + @ValueDescription("一次普通对话最多自动注入多少名相关用户的画像摘要") + val profileAutoInjectMaxUsers: Int by value(4) + + @ValueDescription("普通对话中每名用户的画像摘要最多注入多少字符") + val profileAutoInjectSummaryMaxChars: Int by value(300) + @ValueDescription("备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。设为0禁用,默认5分钟") val fallbackCooldownMinutes: Long by value(5L) @@ -129,6 +201,9 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("推理模型首块响应超时时间,单位毫秒,默认90秒。推理模型出首块前常有思考预热,比对话慢,故单独放宽") val reasoningFirstChunkTimeout: Long by value(90000L) + @ValueDescription("画像分析模型首块响应超时时间,单位毫秒,默认180秒") + val profileFirstChunkTimeout: Long by value(180000L) + @Deprecated("使用外部文件而不是在配置文件内保存提示词") @ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改") var prompt: String by value("你是一个乐于助人的助手") @@ -172,9 +247,6 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("是否启用好感度系统") val enableFavorabilitySystem by value(true) - @ValueDescription("好感度每日基础偏移速度(点/天)") - val favorabilityBaseShiftSpeed by value(2.0) - @ValueDescription("表情包路径,配置后会加载目录下的文件名,提示词中需要用{meme}来插入上下文") val memeDir: String by value("") diff --git a/src/main/kotlin/conversation/ConversationContext.kt b/src/main/kotlin/conversation/ConversationContext.kt new file mode 100644 index 0000000..9462309 --- /dev/null +++ b/src/main/kotlin/conversation/ConversationContext.kt @@ -0,0 +1,466 @@ +package top.jie65535.mirai.conversation + +import com.aallam.openai.api.chat.ChatMessage +import kotlinx.coroutines.runBlocking +import net.mamoe.mirai.contact.Contact +import net.mamoe.mirai.contact.Group +import net.mamoe.mirai.contact.Member +import net.mamoe.mirai.contact.MemberPermission.ADMINISTRATOR +import net.mamoe.mirai.contact.MemberPermission.MEMBER +import net.mamoe.mirai.contact.MemberPermission.OWNER +import net.mamoe.mirai.contact.nameCardOrNick +import net.mamoe.mirai.event.events.GroupMessageEvent +import net.mamoe.mirai.event.events.MessageEvent +import net.mamoe.mirai.message.data.At +import net.mamoe.mirai.message.data.ForwardMessage +import net.mamoe.mirai.message.data.Image +import net.mamoe.mirai.message.data.Image.Key.queryUrl +import net.mamoe.mirai.message.data.Message +import net.mamoe.mirai.message.data.MessageChain +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.message.data.PlainText +import net.mamoe.mirai.message.data.QuoteReply +import net.mamoe.mirai.message.data.SingleMessage +import net.mamoe.mirai.message.data.buildMessageChain +import net.mamoe.mirai.message.data.content +import net.mamoe.mirai.message.data.ids +import net.mamoe.mirai.message.data.source +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.ChatHistoryStore +import top.jie65535.mirai.data.ChatMessageRecord +import top.jie65535.mirai.data.PluginData +import top.jie65535.mirai.data.SkillStore +import top.jie65535.mirai.llm.LargeLanguageModels +import top.jie65535.mirai.media.ImageIndex +import top.jie65535.mirai.profile.UserProfileContextRenderer +import top.jie65535.mirai.profile.UserProfileStore +import util.LunarDateUtil +import java.io.File +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +internal data class ConversationCache( + val history: MutableList, + val lastActivityAt: Int, + val replyIndex: ReplyIndex, + val imageIndex: ImageIndex, +) { + fun isExpired(ttlSeconds: Int): Boolean = + OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds +} + +internal class ReplyIndex { + private val byIndex = LinkedHashMap() + private val indexByIds = HashMap() + private var counter = 0 + + fun add(record: ChatMessageRecord): Int { + record.ids?.let { ids -> indexByIds[ids]?.let { return it } } + val index = ++counter + byIndex[index] = record + record.ids?.let { indexByIds[it] = index } + return index + } + + fun get(index: Int): ChatMessageRecord? = byIndex[index] + + fun indexOfIds(ids: String): Int? = indexByIds[ids] +} + +internal object ConversationContext { + private val contextCache = mutableMapOf() + private val replyIndexes = mutableMapOf() + private val imageIndexes = mutableMapOf() + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss") + private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + .withZone(ZoneOffset.systemDefault()) + private var memePrompt: String? = null + + fun clearCache() { + synchronized(contextCache) { contextCache.clear() } + } + + fun clearAll() { + synchronized(contextCache) { contextCache.clear() } + synchronized(replyIndexes) { replyIndexes.clear() } + synchronized(imageIndexes) { imageIndexes.clear() } + } + + fun cache(subjectId: Long): ConversationCache? = synchronized(contextCache) { + contextCache[subjectId] + } + + fun saveCache(subjectId: Long, cache: ConversationCache) { + synchronized(contextCache) { contextCache[subjectId] = cache } + } + + fun activateReplyIndex(subjectId: Long, cached: ReplyIndex?): ReplyIndex = + (cached ?: ReplyIndex()).also { index -> + synchronized(replyIndexes) { replyIndexes[subjectId] = index } + } + + fun activateImageIndex(subjectId: Long, cached: ImageIndex?): ImageIndex = + (cached ?: ImageIndex()).also { index -> + synchronized(imageIndexes) { imageIndexes[subjectId] = index } + } + + fun releaseActiveIndexes(subjectId: Long) { + synchronized(replyIndexes) { replyIndexes.remove(subjectId) } + synchronized(imageIndexes) { imageIndexes.remove(subjectId) } + } + + fun lookupReplyTarget(subjectId: Long, index: Int): ChatMessageRecord? = + synchronized(replyIndexes) { replyIndexes[subjectId]?.get(index) } + + fun registerImage(subjectId: Long, imageId: String, imageUrl: String): Int? = + synchronized(imageIndexes) { imageIndexes[subjectId]?.add(imageId, imageUrl) } + + fun lookupImageUrl(subjectId: Long, index: Int): String? = + synchronized(imageIndexes) { imageIndexes[subjectId]?.getUrl(index) } + + fun getSystemPrompt(event: MessageEvent): String { + val now = OffsetDateTime.now() + val prompt = StringBuilder(LargeLanguageModels.systemPrompt) + fun replace(target: String, replacement: () -> String) { + val index = prompt.indexOf(target) + if (index != -1) prompt.replace(index, index + target.length, replacement()) + } + + replace("{time}") { + val solarTime = dateTimeFormatter.format(now) + "$solarTime\n农历${LunarDateUtil.getFormattedLunarAndHoliday(now)}" + } + replace("{subject}") { + if (event is GroupMessageEvent) { + "\"${event.subject.name}\" 群聊中,你在本群的名片是:${getNameCard(event.subject.botAsMember)}" + } else { + "与 \"${event.senderName}\" 私聊中" + } + } + replace("{memory}") { + PluginData.contactMemory[event.subject.id].orEmpty().ifEmpty { "暂无相关记忆" } + } + replace("{skills}") { + if (PluginConfig.skillsEnabled) SkillStore.buildIndexPrompt() else "暂无技能" + } + replace("{meme}") { buildMemePrompt() } + return prompt.toString() + } + + fun getHistory(event: MessageEvent): String { + val imageIndex = activeImageIndex(event.subject.id) + if (!JChatGPT.includeHistory) { + return formatRecordContent(event.message, event.subject, imageIndex) + } + val beforeTimestamp = OffsetDateTime.now() + .minusMinutes(PluginConfig.historyWindowMin.toLong()) + .toEpochSecond() + .toInt() + return getAfterHistory(beforeTimestamp, event) + } + + fun getAfterHistory(time: Int, event: MessageEvent): String { + if (!JChatGPT.includeHistory) return "" + val history = try { + ChatHistoryStore.query( + contact = event.subject, + start = time, + end = OffsetDateTime.now().toEpochSecond().toInt(), + limit = PluginConfig.historyMessageLimit, + ).sortedBy { it.time }.toMutableList() + } catch (cause: Throwable) { + JChatGPT.logger.warning("查询 SQLite 消息历史失败", cause) + mutableListOf() + } + + val messageIds = event.message.ids.joinToString(",") + if (history.none { it.ids == messageIds }) { + history += ChatMessageRecord.fromSuccess(event.message.source, event.message) + } + + val result = StringBuilder() + var lastUserId = 0L + var lastTime = 0L + val replyIndex = activeReplyIndex(event.subject.id) + val imageIndex = activeImageIndex(event.subject.id) + if (event is GroupMessageEvent) { + appendUserProfileContext(result, history, event) + result.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)") + history.forEach { record -> + val showSender = lastUserId != record.fromId + val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS + appendGroupMessageRecord(result, record, event, replyIndex, imageIndex, showSender, showTime) + lastUserId = record.fromId + lastTime = record.time.toLong() + } + } else { + appendPrivateFavorabilityContext(result, event) + result.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)") + history.forEach { record -> + val showSender = lastUserId != record.fromId + val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS + appendMessageRecord(result, record, event, replyIndex, imageIndex, showSender, showTime) + lastUserId = record.fromId + lastTime = record.time.toLong() + } + } + return result.toString() + } + + fun toMessage(contact: Contact, content: String): Message { + if (content.isEmpty()) return PlainText("...") + if (content.length < 3) return PlainText(content) + + val chunks = mutableListOf() + REGEX_AT_QQ.findAll(content).forEach { match -> + val qq = match.groups[1]?.value?.toLongOrNull() + if (qq != null && contact is Group) { + contact[qq]?.let { chunks += MessageChunk(match.range, At(it)) } + } + } + REGEX_IMAGE.findAll(content).forEach { match -> + chunks += MessageChunk(match.range, Image(match.groupValues[2])) + } + return buildMessageChain { + var index = 0 + chunks.sortedBy { it.range.first }.forEach { (range, message) -> + if (index < range.first) append(content, index, range.first) + append(message) + index = range.last + 1 + } + if (index < content.length) append(content, index, content.length) + } + } + + private fun activeReplyIndex(subjectId: Long): ReplyIndex = synchronized(replyIndexes) { + replyIndexes.getOrPut(subjectId) { ReplyIndex() } + } + + private fun activeImageIndex(subjectId: Long): ImageIndex = synchronized(imageIndexes) { + imageIndexes.getOrPut(subjectId) { ImageIndex() } + } + + private fun buildMemePrompt(): String { + memePrompt?.let { return it } + if (PluginConfig.memeDir.isEmpty()) return "" + return buildString { + val directory = File(PluginConfig.memeDir) + if (!directory.isDirectory) { + append("配置的meme路径不存在!") + return@buildString + } + append("memes文件夹地址为:").appendLine(PluginConfig.memeDir) + val memes = directory.list().orEmpty() + if (memes.isEmpty()) { + append("暂无表情包~") + } else { + memes.forEach { append("- ").appendLine(it) } + appendLine() + append("表情包示例:![").append(memes[0]).append("](") + .append(File(directory, memes[0]).absoluteFile).appendLine(")") + } + }.also { memePrompt = it } + } + + private fun appendUserProfileContext( + target: StringBuilder, + history: List, + event: GroupMessageEvent, + ) { + if (!PluginConfig.profileAutoInjectEnabled && !PluginConfig.enableFavorabilitySystem) return + val candidateIds = buildList { + add(event.sender.id) + history.asReversed().forEach { add(it.fromId) } + }.asSequence() + .filter { it != event.bot.id } + .distinct() + .take(PluginConfig.profileAutoInjectMaxUsers.coerceIn(1, 10)) + .toList() + val profiles = if (PluginConfig.profileEnabled && PluginConfig.profileAutoInjectEnabled && + UserProfileStore.isAvailable + ) { + candidateIds.mapNotNull { userId -> + runCatching { UserProfileStore.load(userId) } + .onFailure { JChatGPT.logger.warning("读取用户画像失败: user=$userId", it) } + .getOrNull() + } + } else emptyList() + val favorability = if (PluginConfig.enableFavorabilitySystem) { + candidateIds.mapNotNull { id -> PluginData.userFavorability[id]?.let { id to it } }.toMap() + } else emptyMap() + val names = candidateIds.associateWith { id -> event.group[id]?.nameCardOrNick ?: id.toString() } + target.append( + UserProfileContextRenderer.render( + profiles = profiles, + favorabilityByUserId = favorability, + displayNames = names, + activeUserIds = candidateIds.toSet(), + summaryMaxChars = PluginConfig.profileAutoInjectSummaryMaxChars, + ) + ) + } + + private fun appendPrivateFavorabilityContext(target: StringBuilder, event: MessageEvent) { + if (!PluginConfig.enableFavorabilitySystem) return + val info = PluginData.userFavorability[event.sender.id] ?: return + if (info.name.isEmpty() && info.tags.isEmpty() && info.impression.isEmpty()) return + val displayName = info.name.ifEmpty { event.senderName } + target.appendLine("【你认识的对方】") + .append("- ").append(displayName).append("(${event.sender.id})") + .append(" 好感度${if (info.value >= 0) "+" else ""}${info.value}") + if (info.tags.isNotEmpty()) target.append(" [${info.tags.joinToString(", ")}]") + if (info.impression.isNotEmpty()) target.append(" ${info.impression}") + target.appendLine().appendLine() + } + + private fun appendGroupMessageRecord( + target: StringBuilder, + record: ChatMessageRecord, + event: GroupMessageEvent, + replyIndex: ReplyIndex, + imageIndex: ImageIndex, + showSender: Boolean, + showTime: Boolean, + ) { + val chain = record.toMessageChain() + target.append('[').append(replyIndex.add(record)).append("] ") + if (showSender) { + if (event.bot.id == record.fromId) { + target.append("**你** ").append(getNameCard(event.subject.botAsMember)) + } else { + target.append(getNameCard(event.subject, record.fromId)) + } + target.append(' ').append(shortTime(record.time)).append(' ') + } else { + target.append(" └ ") + if (showTime) target.append(shortTime(record.time)).append(' ') + } + chain[QuoteReply.Key]?.let { appendQuoteMarker(target, it, event.subject, replyIndex, imageIndex) } + target.appendLine(formatRecordContent(chain, event.subject, imageIndex)) + } + + private fun appendMessageRecord( + target: StringBuilder, + record: ChatMessageRecord, + event: MessageEvent, + replyIndex: ReplyIndex, + imageIndex: ImageIndex, + showSender: Boolean, + showTime: Boolean, + ) { + val chain = record.toMessageChain() + target.append('[').append(replyIndex.add(record)).append("] ") + if (showSender) { + if (event.bot.id == record.fromId) target.append("**你** ").append(event.bot.nameCardOrNick) + else target.append(event.senderName) + target.append(' ').append(shortTime(record.time)).append(' ') + } else { + target.append(" └ ") + if (showTime) target.append(shortTime(record.time)).append(' ') + } + chain[QuoteReply.Key]?.let { appendQuoteMarker(target, it, event.subject, replyIndex, imageIndex) } + target.appendLine(formatRecordContent(chain, event.subject, imageIndex)) + } + + private fun appendQuoteMarker( + target: StringBuilder, + quote: QuoteReply, + contact: Contact, + replyIndex: ReplyIndex, + imageIndex: ImageIndex, + ) { + replyIndex.indexOfIds(quote.source.ids.joinToString(","))?.let { index -> + target.append("↩[").append(index).append("] ") + return + } + val author = if (contact is Group) { + contact[quote.source.fromId]?.nameCardOrNick ?: "未知(${quote.source.fromId})" + } else quote.source.fromId.toString() + val snippet = quote.source.originalMessage + .joinToString("") { singleMessageToText(it, imageIndex) } + .replace("\n", " ") + .let { if (it.length > 20) it.take(20) + "…" else it } + target.append("↩(").append(author).append(":\"").append(snippet).append("\") ") + } + + private fun formatRecordContent( + chain: MessageChain, + contact: Contact, + imageIndex: ImageIndex, + ): String = chain.asSequence() + .filterNot { it is QuoteReply || it is MessageSource } + .joinToString("") { message -> + when (message) { + is At -> if (contact is Group) message.getDisplay(contact) else message.content + else -> singleMessageToText(message, imageIndex) + } + } + + private fun singleMessageToText(message: SingleMessage, imageIndex: ImageIndex): String = when (message) { + is ForwardMessage -> formatForward(message, 1, imageIndex) + is Image -> try { + val url = runBlocking { message.queryUrl() } + val index = imageIndex.add(message.imageId, url) + "[${if (message.isEmoji) "表情包" else "图片"}$index]" + } catch (cause: Throwable) { + JChatGPT.logger.warning("图片地址获取失败", cause) + message.content + } + else -> message.content + } + + private fun formatForward(forward: ForwardMessage, depth: Int, imageIndex: ImageIndex): String = buildString { + val quote = ">".repeat(depth) + " " + append("[转发消息·").append(forward.nodeList.size).append("条") + if (forward.title.isNotEmpty()) append(':').append(forward.title) + append(']') + forward.nodeList.forEach { node -> + append('\n').append(quote).append(node.senderName).append(' ') + .append(shortTimeFormatter.format(Instant.ofEpochSecond(node.time.toLong()))) + .append(": ") + node.messageChain.forEach { child -> + if (child is ForwardMessage) append(formatForward(child, depth + 1, imageIndex)) + else append(singleMessageToText(child, imageIndex).replace("\n", "\n$quote")) + } + } + } + + private fun getNameCard(group: Group, qq: Long): String = + group[qq]?.let(::getNameCard) ?: "未知群员($qq)" + + private fun getNameCard(member: Member): String { + val result = StringBuilder("【") + try { + result.append("lv").append(member.active.temperature).append(' ') + } catch (cause: Throwable) { + JChatGPT.logger.warning("获取群活跃等级失败", cause) + } + result.append( + when (member.permission) { + OWNER -> "群主" + ADMINISTRATOR -> "管理员" + MEMBER -> "群员" + } + ) + try { + if (member.specialTitle.isNotEmpty()) result.append(" 头衔\"").append(member.specialTitle).append('"') + else if (member.temperatureTitle.isNotEmpty()) result.append(' ').append(member.temperatureTitle) + } catch (cause: Throwable) { + JChatGPT.logger.warning("获取群头衔失败", cause) + } + return result.append("】\t\"").append(member.nameCardOrNick) + .append("\"\t(qq=").append(member.id).append(')').toString() + } + + private fun shortTime(epochSecond: Int): String = + shortTimeFormatter.format(Instant.ofEpochSecond(epochSecond.toLong())) + + private data class MessageChunk(val range: IntRange, val content: Message) + + private const val CONTINUATION_TIME_GAP_SECONDS = 60L + private val REGEX_AT_QQ = Regex("""@(\d{5,12})""") + private val REGEX_IMAGE = Regex("""!\[(.*?)]\(([^\s"']+).*?\)""") +} diff --git a/src/main/kotlin/conversation/ConversationEngine.kt b/src/main/kotlin/conversation/ConversationEngine.kt new file mode 100644 index 0000000..f394d20 --- /dev/null +++ b/src/main/kotlin/conversation/ConversationEngine.kt @@ -0,0 +1,344 @@ +package top.jie65535.mirai.conversation + +import com.aallam.openai.api.chat.ChatCompletionChunk +import com.aallam.openai.api.chat.ChatCompletionRequest +import com.aallam.openai.api.chat.ChatMessage +import com.aallam.openai.api.chat.ChatRole +import com.aallam.openai.api.chat.ToolCall +import com.aallam.openai.api.core.Usage +import com.aallam.openai.api.model.ModelId +import io.ktor.util.collections.ConcurrentSet +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import net.mamoe.mirai.event.events.GroupMessageEvent +import net.mamoe.mirai.event.events.MessageEvent +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.TokenUsageStore +import top.jie65535.mirai.llm.LargeLanguageModels +import top.jie65535.mirai.llm.ModelService +import top.jie65535.mirai.profile.ProfileAutoMaintenance +import top.jie65535.mirai.tools.AdjustUserFavorabilityAgent +import top.jie65535.mirai.tools.BaseAgent +import top.jie65535.mirai.tools.DeleteSkill +import top.jie65535.mirai.tools.GroupManageAgent +import top.jie65535.mirai.tools.ImageAgent +import top.jie65535.mirai.tools.LoadSkill +import top.jie65535.mirai.tools.MemoryAppend +import top.jie65535.mirai.tools.MemoryReplace +import top.jie65535.mirai.tools.ReasoningAgent +import top.jie65535.mirai.tools.RequestOwner +import top.jie65535.mirai.tools.RunCode +import top.jie65535.mirai.tools.SaveSkill +import top.jie65535.mirai.tools.SearchChatHistory +import top.jie65535.mirai.tools.SendCompositeMessage +import top.jie65535.mirai.tools.SendLaTeXExpression +import top.jie65535.mirai.tools.SendSingleMessageAgent +import top.jie65535.mirai.tools.SendVoiceMessage +import top.jie65535.mirai.tools.StopLoopAgent +import top.jie65535.mirai.tools.VisitWeb +import top.jie65535.mirai.tools.VisualAgent +import top.jie65535.mirai.tools.WeatherService +import top.jie65535.mirai.tools.WebSearch +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.max +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +internal object ConversationEngine { + private val activeRequests = ConcurrentSet() + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss") + private val thinkRegex = Regex("[\\s\\S]*?") + private val tools: List = listOf( + SendSingleMessageAgent(), + SendCompositeMessage(), + SendVoiceMessage(), + SendLaTeXExpression(), + StopLoopAgent(), + MemoryAppend(), + MemoryReplace(), + LoadSkill(), + SaveSkill(), + DeleteSkill(), + SearchChatHistory(), + WebSearch(), + VisitWeb(), + RunCode(), + ReasoningAgent(), + VisualAgent(), + ImageAgent(), + WeatherService(), + AdjustUserFavorabilityAgent(), + RequestOwner(), + GroupManageAgent(), + ) + + fun clear() { + activeRequests.clear() + } + + suspend fun start(event: MessageEvent) { + val subjectId = event.subject.id + if (!activeRequests.add(subjectId)) { + JChatGPT.logger.warning("The current Contact is busy!") + return + } + + try { + val cache = ConversationContext.cache(subjectId) + val reuseCache = PluginConfig.enableContextCache && cache != null && + !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60) + val replyIndex = ConversationContext.activateReplyIndex( + subjectId, + cache?.replyIndex?.takeIf { reuseCache }, + ) + val imageIndex = ConversationContext.activateImageIndex( + subjectId, + cache?.imageIndex?.takeIf { reuseCache }, + ) + val history = if (reuseCache) { + JChatGPT.logger.info("使用缓存的对话上下文,包含 ${cache.history.size} 条互动消息") + cache.history + } else mutableListOf() + + if (history.isEmpty() || cache == null) { + val prompt = ConversationContext.getSystemPrompt(event) + if (PluginConfig.logPrompt) JChatGPT.logger.info("Prompt: $prompt") + history += ChatMessage(ChatRole.System, prompt) + val historyText = ConversationContext.getHistory(event) + JChatGPT.logger.info("注入聊天记录:\n$historyText") + history += ChatMessage.User(historyText) + } else { + val newMessages = ConversationContext.getAfterHistory(cache.lastActivityAt, event) + JChatGPT.logger.info("补充聊天记录:\n$newMessages") + history += ChatMessage.User("## 以下是上次对话结束至今的新消息\n\n$newMessages") + } + + val endpoints = LargeLanguageModels.orderedChatEndpoints() + if (endpoints.isEmpty()) error("OpenAI Token 未设置,无法开始") + var endpointIndex = 0 + var done: Boolean + var retry = max(PluginConfig.retryMax, 3) + do { + val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)] + var streamingOk = false + try { + val startedAt = OffsetDateTime.now().toEpochSecond().toInt() + var lastCacheUsage: ModelService.CacheUsage? = null + val responseFlow = chatCompletions(history, endpoint) { lastCacheUsage = it } + var responseContent: StringBuilder? = null + var reasoningContent: StringBuilder? = null + val responseToolCalls = mutableListOf() + val toolCallTasks = mutableListOf>() + var lastTokenUsage: Usage? = null + + responseFlow.collect { chunk -> + val delta = chunk.choices[0].delta ?: return@collect + delta.reasoningContent?.let { content -> + if (reasoningContent == null) reasoningContent = StringBuilder(content) + else reasoningContent.append(content) + } + delta.content?.let { content -> + if (responseContent == null) responseContent = StringBuilder(content) + else responseContent.append(content) + } + delta.toolCalls?.forEach { toolCallChunk -> + val index = toolCallChunk.index + val function = toolCallChunk.function + if (index >= responseToolCalls.size) { + responseToolCalls.lastOrNull()?.let { toolCall -> + toolCallTasks += JChatGPT.async { + toolCall.toResultMessage(event) + } + } + val id = toolCallChunk.id + if (id != null && function != null) { + responseToolCalls += ToolCall.Function(id, function) + } + } else if (function != null) { + val current = responseToolCalls[index] + var updated = current.function + function.nameOrNull?.let { name -> + updated = updated.copy(nameOrNull = updated.nameOrNull.orEmpty() + name) + } + function.argumentsOrNull?.let { arguments -> + updated = updated.copy( + argumentsOrNull = updated.argumentsOrNull.orEmpty() + arguments + ) + } + responseToolCalls[index] = current.copy(function = updated) + } + } + chunk.usage?.let { lastTokenUsage = it } + } + + streamingOk = true + LargeLanguageModels.reportSuccess(endpoint) + val answer = responseContent?.replace(thinkRegex, "")?.trim() + JChatGPT.logger.info("LLM Response: $answer") + history += ChatMessage( + role = ChatRole.Assistant, + content = answer, + toolCalls = responseToolCalls.ifEmpty { null }, + reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContent?.toString() else null, + ) + recordUsage(event, lastTokenUsage, lastCacheUsage) + + if (responseToolCalls.size > toolCallTasks.size) { + val finalToolResult = responseToolCalls.last().toResultMessage(event) + if (toolCallTasks.isNotEmpty()) history += toolCallTasks.awaitAll() + history += finalToolResult + done = responseToolCalls.any { it.function.name == "endConversation" } + } else { + done = true + } + + if (!done) { + history += ChatMessage.User(buildContinuationPrompt(retry, startedAt, event)) + } else { + if (PluginConfig.enableContextCache) { + ConversationContext.saveCache( + subjectId, + ConversationCache(history, startedAt, replyIndex, imageIndex), + ) + JChatGPT.logger.debug("已保存对话上下文到缓存") + } + if (event is GroupMessageEvent) { + ProfileAutoMaintenance.recordCompletedConversation(event, startedAt) + } + } + } catch (cause: Exception) { + if (!streamingOk) { + LargeLanguageModels.reportFailure(endpoint) + if (endpointIndex < endpoints.lastIndex) { + endpointIndex++ + JChatGPT.logger.warning( + "接入点[${endpoint.label}]调用失败,切换备用接入点[${endpoints[endpointIndex].label}]重试", + cause, + ) + } else { + JChatGPT.logger.warning("接入点[${endpoint.label}]调用失败,无更多备用接入点,重试中", cause) + } + } else { + JChatGPT.logger.warning("调用llm后处理时发生异常,重试中", cause) + } + if (retry <= 1) throw cause + done = false + } + } while (!done && 0 < --retry) + } catch (cause: Throwable) { + JChatGPT.logger.warning(cause) + event.subject.sendMessage("很抱歉,发生异常,请稍后重试") + } finally { + ConversationContext.releaseActiveIndexes(subjectId) + JChatGPT.launch { + delay(500.milliseconds) + activeRequests.remove(subjectId) + } + } + } + + private fun chatCompletions( + history: List, + endpoint: LargeLanguageModels.ChatEndpoint, + onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null, + ): Flow { + val availableTools = tools.filter { it.isEnabled }.map { it.tool } + val request = ChatCompletionRequest( + model = ModelId(endpoint.model), + temperature = endpoint.temperature, + messages = history, + tools = availableTools, + ) + JChatGPT.logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]") + return endpoint.service.chatCompletions(request, onCacheUsage) + } + + private suspend fun ToolCall.Function.toResultMessage(event: MessageEvent): ChatMessage = ChatMessage( + role = ChatRole.Tool, + toolCallId = id, + name = function.name, + content = execute(event), + ) + + private suspend fun ToolCall.Function.execute(event: MessageEvent): String { + val agent = tools.find { it.tool.function.name == function.name } + ?: return "Function ${function.name} not found" + val receipt = if (PluginConfig.showToolCallingMessage && agent.loadingMessage.isNotEmpty()) { + event.subject.sendMessage(agent.loadingMessage) + } else null + val result = try { + val arguments = function.argumentsAsJsonOrNull() + JChatGPT.logger.info("Calling ${function.name}($arguments)") + agent.execute(arguments, event) + } catch (cause: Throwable) { + JChatGPT.logger.error("Failed to call ${function.name}", cause) + "工具调用失败,请尝试自行回答用户,或如实告知。\n异常信息:${cause.message}" + } + JChatGPT.logger.info("Result=\"$result\"") + val truncated = truncateToolOutput(result) + if (truncated.length != result.length) { + JChatGPT.logger.warning( + "工具 ${function.name} 返回内容过长,已从 ${result.length} 字符截断至 ${truncated.length} 字符" + ) + } + if (receipt != null) { + JChatGPT.launch { + delay(3.seconds) + try { + receipt.recall() + } catch (cause: Throwable) { + JChatGPT.logger.error( + "消息撤回失败,调试信息:source.internalIds=${receipt.source.internalIds.joinToString()} " + + "source.ids=${receipt.source.ids.joinToString()}", + cause, + ) + } + } + } + return truncated + } + + private fun buildContinuationPrompt(retry: Int, startedAt: Int, event: MessageEvent): String = buildString { + appendLine("## 系统提示") + append("本次运行最多还剩").append(retry - 1).appendLine("轮。") + appendLine("如果要多次发言,可以一次性调用多次发言工具。") + appendLine("如果没有什么要做的,可以提前结束。") + appendLine("当前时间:${dateTimeFormatter.format(OffsetDateTime.now())}") + val messages = ConversationContext.getAfterHistory(startedAt, event) + if (messages.isNotEmpty()) append("## 以下是上次运行至今的新消息\n\n$messages") + } + + private fun recordUsage( + event: MessageEvent, + usage: Usage?, + cacheUsage: ModelService.CacheUsage?, + ) { + usage ?: return + val group = (event as? GroupMessageEvent)?.group + TokenUsageStore.record( + timestamp = OffsetDateTime.now().toEpochSecond(), + userId = event.sender.id, + userNickname = event.senderName, + groupId = group?.id, + groupName = group?.name, + promptTokens = usage.promptTokens ?: 0, + completionTokens = usage.completionTokens ?: 0, + totalTokens = usage.totalTokens ?: 0, + cachedTokens = cacheUsage?.hitTokens ?: 0, + ) + } + + private fun truncateToolOutput(content: String): String { + val maxLength = PluginConfig.maxToolOutputLength + return if (content.length <= maxLength) content + else content.take(maxLength) + "\n\n[系统提示:因内容过长,部分内容已被省略]" + } +} diff --git a/src/main/kotlin/ChatHistoryStore.kt b/src/main/kotlin/data/ChatHistoryStore.kt similarity index 95% rename from src/main/kotlin/ChatHistoryStore.kt rename to src/main/kotlin/data/ChatHistoryStore.kt index aabe050..58faacf 100644 --- a/src/main/kotlin/ChatHistoryStore.kt +++ b/src/main/kotlin/data/ChatHistoryStore.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.data import net.mamoe.mirai.Bot import net.mamoe.mirai.contact.Contact @@ -34,14 +34,22 @@ object ChatHistoryStore { private var initialized = false private lateinit var databaseFile: File private var writeConnection: Connection? = null + private var warningLogger: ((String, Throwable?) -> Unit)? = null val isAvailable: Boolean get() = initialized - fun init(dataFolder: File) { + val databaseFileOrNull: File? + get() = if (initialized) databaseFile else null + + fun init( + dataFolder: File, + onWarning: (String, Throwable?) -> Unit = { _, _ -> }, + ) { synchronized(lifecycleLock) { if (initialized) return + warningLogger = onWarning Class.forName("org.sqlite.JDBC") dataFolder.mkdirs() databaseFile = dataFolder.resolve("chat-history.sqlite") @@ -68,11 +76,12 @@ object ChatHistoryStore { connection.createStatement().use { statement -> statement.execute("PRAGMA wal_checkpoint(TRUNCATE)") } - }.onFailure { JChatGPT.logger.warning("SQLite WAL checkpoint 失败", it) } + }.onFailure { warn("SQLite WAL checkpoint 失败", it) } connection.close() } writeConnection = null initialized = false + warningLogger = null } } } @@ -139,7 +148,7 @@ object ChatHistoryStore { statement.setInt(8, event.messageTime) val updated = statement.executeUpdate() if (updated == 0) { - JChatGPT.logger.warning( + warn( "未在 SQLite 中找到撤回消息: bot=${event.bot.id}, " + "author=${event.authorId}, target=$targetId, ids=$messageIds, " + "internalIds=$messageInternalIds, " + @@ -365,4 +374,8 @@ object ChatHistoryStore { recalled = getInt("recalled"), ) } + + private fun warn(message: String, cause: Throwable? = null) { + runCatching { warningLogger?.invoke(message, cause) } + } } diff --git a/src/main/kotlin/ChatMessageRecord.kt b/src/main/kotlin/data/ChatMessageRecord.kt similarity index 98% rename from src/main/kotlin/ChatMessageRecord.kt rename to src/main/kotlin/data/ChatMessageRecord.kt index 4330f9c..30345a9 100644 --- a/src/main/kotlin/ChatMessageRecord.kt +++ b/src/main/kotlin/data/ChatMessageRecord.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.data import kotlinx.serialization.SerializationException import net.mamoe.mirai.Mirai diff --git a/src/main/kotlin/PluginData.kt b/src/main/kotlin/data/PluginData.kt similarity index 99% rename from src/main/kotlin/PluginData.kt rename to src/main/kotlin/data/PluginData.kt index f06150f..0a561c9 100644 --- a/src/main/kotlin/PluginData.kt +++ b/src/main/kotlin/data/PluginData.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.data import kotlinx.serialization.Serializable import net.mamoe.mirai.console.data.AutoSavePluginData diff --git a/src/main/kotlin/SkillStore.kt b/src/main/kotlin/data/SkillStore.kt similarity index 99% rename from src/main/kotlin/SkillStore.kt rename to src/main/kotlin/data/SkillStore.kt index e8c1d07..eb6d87e 100644 --- a/src/main/kotlin/SkillStore.kt +++ b/src/main/kotlin/data/SkillStore.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.data import java.io.File diff --git a/src/main/kotlin/TokenUsageStore.kt b/src/main/kotlin/data/TokenUsageStore.kt similarity index 99% rename from src/main/kotlin/TokenUsageStore.kt rename to src/main/kotlin/data/TokenUsageStore.kt index 9313bb7..bfd482c 100644 --- a/src/main/kotlin/TokenUsageStore.kt +++ b/src/main/kotlin/data/TokenUsageStore.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.data import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json diff --git a/src/main/kotlin/LargeLanguageModels.kt b/src/main/kotlin/llm/LargeLanguageModels.kt similarity index 83% rename from src/main/kotlin/LargeLanguageModels.kt rename to src/main/kotlin/llm/LargeLanguageModels.kt index e9c77b7..abe78ac 100644 --- a/src/main/kotlin/LargeLanguageModels.kt +++ b/src/main/kotlin/llm/LargeLanguageModels.kt @@ -1,8 +1,10 @@ -package top.jie65535.mirai +package top.jie65535.mirai.llm import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.config.PluginConfig import kotlin.time.Duration.Companion.milliseconds object LargeLanguageModels { @@ -25,6 +27,12 @@ object LargeLanguageModels { val label: String, ) + data class ProfileEndpoint( + val service: ModelService, + val model: String, + val temperature: Double?, + ) + /** * 聊天接入点列表:index 0 为主接入点,其余按配置顺序为备用接入点。 */ @@ -47,6 +55,10 @@ object LargeLanguageModels { */ var visual: ModelService? = null + /** 历史用户画像分析模型。 */ + var profile: ProfileEndpoint? = null + private set + /** * 接入点健康状态:记录各接入点的冷却截止时间戳(毫秒)。 * 失败的接入点进入冷却,期间在 [orderedChatEndpoints] 中被排到队尾, @@ -144,6 +156,28 @@ object LargeLanguageModels { } chatEndpoints = endpoints + profile = null + if (PluginConfig.profileEnabled) { + val api = PluginConfig.profileModelApi.ifBlank { PluginConfig.openAiApi } + val token = PluginConfig.profileModelToken.ifBlank { PluginConfig.openAiToken } + val model = PluginConfig.profileModel.ifBlank { PluginConfig.chatModel } + val extraBody = PluginConfig.profileModelExtraBody.ifBlank { PluginConfig.chatModelExtraBody } + if (api.isNotBlank() && token.isNotBlank() && model.isNotBlank()) { + val profileFirstChunk = PluginConfig.profileFirstChunkTimeout.milliseconds + profile = ProfileEndpoint( + service = ModelService( + baseUrl = api, + token = token, + timeout = maxOf(timeout, profileFirstChunk), + firstChunkTimeout = profileFirstChunk, + extraBody = parseExtraBody(extraBody), + ), + model = model, + temperature = PluginConfig.profileModelTemperature ?: PluginConfig.chatTemperature, + ) + } + } + // 初始化推理模型 if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) { // 推理模型出首块前常有思考预热,比对话慢,使用单独放宽的首块超时; diff --git a/src/main/kotlin/ModelService.kt b/src/main/kotlin/llm/ModelService.kt similarity index 91% rename from src/main/kotlin/ModelService.kt rename to src/main/kotlin/llm/ModelService.kt index dd0ec3e..611c908 100644 --- a/src/main/kotlin/ModelService.kt +++ b/src/main/kotlin/llm/ModelService.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.llm import com.aallam.openai.api.chat.ChatCompletionChunk import com.aallam.openai.api.chat.ChatCompletionRequest @@ -48,18 +48,19 @@ class ModelService( explicitNulls = false } - /** - * 一次响应的缓存命中用量。DeepSeek 在 usage 顶层返回的非标准字段, - * openai-kotlin 的 Usage 类不含这些字段,必须从原始 JSON 抠出来。 - */ + /** openai-kotlin 的 Usage 尚未暴露缓存明细,因此从原始 JSON 读取。 */ data class CacheUsage(val hitTokens: Int, val missTokens: Int) /** 从原始 data 行(已去掉 "data: " 前缀)解析缓存命中用量;无相关字段返回 null。 */ - private fun extractCacheUsage(rawJson: String): CacheUsage? { + internal fun extractCacheUsage(rawJson: String): CacheUsage? { return try { val usage = json.parseToJsonElement(rawJson).jsonObject["usage"]?.jsonObject ?: return null + val promptTokens = usage["prompt_tokens"]?.jsonPrimitive?.intOrNull + val promptDetails = usage["prompt_tokens_details"]?.jsonObject val hit = usage["prompt_cache_hit_tokens"]?.jsonPrimitive?.intOrNull + ?: promptDetails?.get("cached_tokens")?.jsonPrimitive?.intOrNull val miss = usage["prompt_cache_miss_tokens"]?.jsonPrimitive?.intOrNull + ?: if (hit != null && promptTokens != null) (promptTokens - hit).coerceAtLeast(0) else null if (hit == null && miss == null) null else CacheUsage(hit ?: 0, miss ?: 0) } catch (_: Exception) { null diff --git a/src/main/kotlin/ImageIndex.kt b/src/main/kotlin/media/ImageIndex.kt similarity index 96% rename from src/main/kotlin/ImageIndex.kt rename to src/main/kotlin/media/ImageIndex.kt index 3e50d6b..85d9b72 100644 --- a/src/main/kotlin/ImageIndex.kt +++ b/src/main/kotlin/media/ImageIndex.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.media /** * 会话内图片短索引:向 LLM 暴露递增整数,内部保留从原消息图片取得的精确 URL。 diff --git a/src/main/kotlin/LaTeXConverter.kt b/src/main/kotlin/media/LaTeXConverter.kt similarity index 96% rename from src/main/kotlin/LaTeXConverter.kt rename to src/main/kotlin/media/LaTeXConverter.kt index e7a1b8b..238d2ad 100644 --- a/src/main/kotlin/LaTeXConverter.kt +++ b/src/main/kotlin/media/LaTeXConverter.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.media import org.scilab.forge.jlatexmath.TeXConstants import org.scilab.forge.jlatexmath.TeXFormula @@ -29,4 +29,4 @@ object LaTeXConverter { ImageIO.write(image, format, stream) return stream.toByteArray() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/profile/ConversationProfileReducer.kt b/src/main/kotlin/profile/ConversationProfileReducer.kt new file mode 100644 index 0000000..a410828 --- /dev/null +++ b/src/main/kotlin/profile/ConversationProfileReducer.kt @@ -0,0 +1,46 @@ +package top.jie65535.mirai.profile + +object ConversationProfileReducer { + fun reduce( + profiles: Map, + batch: ConversationProfileBatch, + eligibleUserIds: Set, + response: ConversationProfileModelResponse, + model: String, + promptVersion: String, + summaryMaxLength: Int, + ): List { + val responsesByUserId = response.users.associate { userResponse -> + val userId = batch.aliasToUserId[userResponse.userAlias] + ?: throw IllegalArgumentException("模型返回了不存在的用户别名 ${userResponse.userAlias}") + require(userId in eligibleUserIds) { + "模型返回了非候选用户别名 ${userResponse.userAlias}" + } + require(userResponse.operations.size <= MAX_OPERATIONS_PER_USER) { + "模型为 ${userResponse.userAlias} 返回了超过 $MAX_OPERATIONS_PER_USER 项画像操作" + } + userId to userResponse + } + require(responsesByUserId.size == response.users.size) { + "模型对同一用户返回了多组结果" + } + + return eligibleUserIds.sorted().map { userId -> + val userResponse = responsesByUserId[userId] + UserProfileReducer.reduce( + current = checkNotNull(profiles[userId]) { "缺少用户 $userId 的当前画像" }, + batch = batch.forUser(userId), + response = ProfileModelResponse( + operations = userResponse?.operations.orEmpty(), + summary = userResponse?.summary.orEmpty(), + ), + model = model, + promptVersion = promptVersion, + summaryMaxLength = summaryMaxLength, + advanceBackfillCursor = false, + ) + } + } + + private const val MAX_OPERATIONS_PER_USER = 4 +} diff --git a/src/main/kotlin/profile/ProfileAutoMaintenance.kt b/src/main/kotlin/profile/ProfileAutoMaintenance.kt new file mode 100644 index 0000000..c6a9971 --- /dev/null +++ b/src/main/kotlin/profile/ProfileAutoMaintenance.kt @@ -0,0 +1,98 @@ +package top.jie65535.mirai.profile + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.mamoe.mirai.event.events.GroupMessageEvent +import net.mamoe.mirai.message.data.source +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.ChatHistoryStore +import java.util.concurrent.atomic.AtomicLong +import kotlin.time.Duration.Companion.seconds + +object ProfileAutoMaintenance { + private data class PendingConversation( + val generation: Long, + val groupId: Long, + val botId: Long, + val startTime: Int, + val endTime: Int, + val lastActivityAt: Int, + ) + + private val lock = Any() + private val generation = AtomicLong() + private val pending = mutableMapOf() + + fun recordCompletedConversation(event: GroupMessageEvent, lastActivityAt: Int) { + if (!PluginConfig.profileEnabled || !PluginConfig.profileAutoUpdateEnabled) return + if (!UserProfileStore.isAvailable || !ChatHistoryStore.isAvailable) return + + val subjectId = event.subject.id + val now = currentEpochSecond() + val currentGeneration = generation.incrementAndGet() + val triggerTime = event.message.source.time + val initialStart = (triggerTime.toLong() - PluginConfig.historyWindowMin.coerceAtLeast(0) * 60L) + .coerceAtLeast(0) + .toInt() + val conversation = synchronized(lock) { + val previous = pending[subjectId] + PendingConversation( + generation = currentGeneration, + groupId = event.group.id, + botId = event.bot.id, + startTime = minOf(previous?.startTime ?: initialStart, initialStart), + endTime = maxOf(previous?.endTime ?: now.safeNextSecond(), now.safeNextSecond()), + lastActivityAt = maxOf(previous?.lastActivityAt ?: lastActivityAt, lastActivityAt), + ).also { pending[subjectId] = it } + } + + val ttlSeconds = PluginConfig.contextCacheTimeoutMinutes.coerceAtLeast(1) * 60 + JChatGPT.launch { + val remainingSeconds = conversation.lastActivityAt.toLong() + ttlSeconds - currentEpochSecond() + if (remainingSeconds > 0) delay(remainingSeconds.seconds) + val closed = synchronized(lock) { + pending[subjectId] + ?.takeIf { it.generation == conversation.generation } + ?.copy(endTime = currentEpochSecond().safeNextSecond()) + ?.also { pending.remove(subjectId) } + } ?: return@launch + process(closed) + } + } + + fun clear() { + synchronized(lock) { pending.clear() } + } + + private suspend fun process(conversation: PendingConversation) { + try { + val report = UserProfileAnalysisService.analyzeConversation( + botId = conversation.botId, + groupId = conversation.groupId, + startTime = conversation.startTime, + endTime = conversation.endTime, + minAuthoredTextChars = PluginConfig.profileAutoMinAuthoredTextChars, + ) ?: return + JChatGPT.logger.info( + "PROFILE_AUTO group=${conversation.groupId} users=${report.analyzedUsers} " + + "messages=${report.processedMessages} operations=${report.appliedOperations} " + + "tokens=${report.usage.promptTokens}/${report.usage.completionTokens}" + ) + } catch (cause: CancellationException) { + throw cause + } catch (cause: Exception) { + JChatGPT.logger.warning( + "自动画像维护失败: group=${conversation.groupId}, " + + "range=[${conversation.startTime}, ${conversation.endTime})", + cause, + ) + } + } + + private fun currentEpochSecond(): Int = + (System.currentTimeMillis() / 1000L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + + private fun Int.safeNextSecond(): Int = if (this == Int.MAX_VALUE) this else this + 1 +} diff --git a/src/main/kotlin/profile/ProfileHistoryReader.kt b/src/main/kotlin/profile/ProfileHistoryReader.kt new file mode 100644 index 0000000..46452dd --- /dev/null +++ b/src/main/kotlin/profile/ProfileHistoryReader.kt @@ -0,0 +1,475 @@ +package top.jie65535.mirai.profile + +import net.mamoe.mirai.message.data.MessageSourceKind +import org.sqlite.SQLiteConfig +import top.jie65535.mirai.data.ChatMessageRecord +import java.io.File +import java.security.MessageDigest +import java.sql.Connection +import java.sql.DriverManager +import java.sql.ResultSet + +class ProfileHistoryReader(private val databaseFile: File) { + data class TimeBounds(val startTime: Int, val endTime: Int) + + private data class Episode( + val index: Int, + val groupId: Long, + val targetMessages: List, + ) + + init { + require(databaseFile.isFile) { "聊天历史数据库不存在: ${databaseFile.absolutePath}" } + Class.forName("org.sqlite.JDBC") + } + + fun findUserTimeBounds(userId: Long): TimeBounds? = openReadConnection().use { connection -> + connection.prepareStatement( + """ + SELECT MIN(time) AS min_time, MAX(time) AS max_time + FROM message_record + WHERE kind = ? AND recalled = 0 AND from_id = ? + """.trimIndent() + ).use { statement -> + statement.setInt(1, MessageSourceKind.GROUP.ordinal) + statement.setLong(2, userId) + statement.executeQuery().use { results -> + if (!results.next()) return@use null + val start = results.getInt("min_time") + if (results.wasNull()) return@use null + val max = results.getInt("max_time") + TimeBounds(start, max.safeNextSecond()) + } + } + } + + fun loadNextBatch( + userId: Long, + startTime: Int, + snapshotEndTime: Int, + targetMessageLimit: Int, + maxEpisodes: Int, + episodeGapSeconds: Int, + contextBeforeMessages: Int, + contextAfterMessages: Int, + contextCoreMessages: Int, + maxMessageChars: Int, + ): ProfileHistoryBatch? { + require(startTime <= snapshotEndTime) { "startTime must not be after snapshotEndTime" } + return openReadConnection().use { connection -> + val firstPage = queryTargetMessages( + connection, + userId, + startTime, + snapshotEndTime, + targetMessageLimit.coerceAtLeast(1), + ) + if (firstPage.isEmpty()) return@use null + + val pageEndTime = firstPage.last().time.safeNextSecond() + val pageTargetMessages = queryTargetMessages( + connection, + userId, + startTime, + pageEndTime, + Int.MAX_VALUE, + ) + val pageEpisodes = buildEpisodes(pageTargetMessages, episodeGapSeconds.coerceAtLeast(0)) + val initialEpisodes = pageEpisodes.take(maxEpisodes.coerceAtLeast(1)) + val lastSelectedTime = initialEpisodes.last().targetMessages.maxOf { it.time } + val episodes = pageEpisodes.takeWhile { episode -> + episode.targetMessages.first().time <= lastSelectedTime + } + val targetMessages = episodes.flatMap(Episode::targetMessages) + val endTime = targetMessages.maxOf { it.time }.safeNextSecond() + val recordsByFingerprint = linkedMapOf>() + val perEpisodeCoreLimit = (contextCoreMessages.coerceAtLeast(1) / episodes.size) + .coerceAtLeast(1) + + episodes.forEach { episode -> + val firstTargetTime = episode.targetMessages.minOf { it.time } + val lastTargetTime = episode.targetMessages.maxOf { it.time } + val records = buildList { + addAll(queryContextBefore(connection, episode.groupId, firstTargetTime, contextBeforeMessages)) + addAll( + queryContextCore( + connection, + episode.groupId, + firstTargetTime, + lastTargetTime.safeNextSecond(), + perEpisodeCoreLimit, + ) + ) + addAll(queryContextAfter(connection, episode.groupId, lastTargetTime, contextAfterMessages)) + addAll(episode.targetMessages) + } + records.forEach { record -> + recordsByFingerprint.putIfAbsent(record.fingerprint(), episode.index to record) + } + } + + val targetFingerprints = targetMessages.mapTo(hashSetOf()) { it.fingerprint() } + val targetRecords = recordsByFingerprint.values.filter { it.second.fingerprint() in targetFingerprints } + val contextRecords = recordsByFingerprint.values + .asSequence() + .filter { it.second.fingerprint() !in targetFingerprints } + .sortedWith( + compareBy>( + { candidate -> contextDistance(candidate.second, targetMessages) }, + { it.second.time }, + { it.second.targetId }, + { it.second.fromId }, + ) + ) + .take(contextCoreMessages.coerceAtLeast(0)) + .toList() + val records = (targetRecords + contextRecords).sortedWith( + compareBy>( + { it.second.time }, + { it.first }, + { it.second.targetId }, + { it.second.fromId }, + { it.second.code }, + ) + ) + createBatch(userId, startTime, endTime, records, maxMessageChars) + } + } + + fun loadConversationBatch( + botId: Long, + groupId: Long, + startTime: Int, + endTime: Int, + messageLimit: Int, + maxMessageChars: Int, + ): ConversationProfileBatch? { + require(startTime < endTime) { "startTime must be before endTime" } + return openReadConnection().use { connection -> + val records = queryConversationMessages( + connection = connection, + botId = botId, + groupId = groupId, + startTime = startTime, + endTime = endTime, + limit = messageLimit.coerceAtLeast(1), + ) + if (records.isEmpty()) return@use null + createConversationBatch(botId, groupId, startTime, endTime, records, maxMessageChars) + } + } + + private fun queryTargetMessages( + connection: Connection, + userId: Long, + startTime: Int, + endTime: Int, + limit: Int, + ): List { + val sql = buildString { + append( + """ + SELECT id, bot_id, from_id, target_id, ids, internal_ids, + time, kind, code, recalled + FROM message_record + WHERE kind = ? AND recalled = 0 AND from_id = ? + AND time >= ? AND time < ? + ORDER BY time ASC + """.trimIndent() + ) + if (limit != Int.MAX_VALUE) append(" LIMIT ?") + } + return connection.prepareStatement(sql).use { statement -> + statement.setInt(1, MessageSourceKind.GROUP.ordinal) + statement.setLong(2, userId) + statement.setInt(3, startTime) + statement.setInt(4, endTime) + if (limit != Int.MAX_VALUE) statement.setInt(5, limit) + statement.executeQuery().use(::readRecords) + } + } + + private fun queryConversationMessages( + connection: Connection, + botId: Long, + groupId: Long, + startTime: Int, + endTime: Int, + limit: Int, + ): List { + return connection.prepareStatement( + """ + SELECT id, bot_id, from_id, target_id, ids, internal_ids, + time, kind, code, recalled + FROM message_record + WHERE bot_id = ? AND target_id = ? AND kind = ? AND recalled = 0 + AND time >= ? AND time < ? + ORDER BY time DESC, id DESC + LIMIT ? + """.trimIndent() + ).use { statement -> + statement.setLong(1, botId) + statement.setLong(2, groupId) + statement.setInt(3, MessageSourceKind.GROUP.ordinal) + statement.setInt(4, startTime) + statement.setInt(5, endTime) + statement.setInt(6, limit) + statement.executeQuery().use(::readRecords).asReversed() + } + } + + private fun createConversationBatch( + botId: Long, + groupId: Long, + startTime: Int, + endTime: Int, + records: List, + maxMessageChars: Int, + ): ConversationProfileBatch { + val participantIds = buildSet { + add(botId) + records.forEach { record -> + add(record.fromId) + addAll(ProfileMessageRenderer.referencedUserIds(record)) + } + } + val aliases = buildMap { + put(botId, "BOT") + participantIds.asSequence() + .filter { it != botId } + .sorted() + .forEachIndexed { index, participantId -> put(participantId, "U${index + 1}") } + } + val promptMessages = records.mapIndexed { index, record -> + ProfilePromptMessage( + record = record, + text = ProfileMessageRenderer.render(record, aliases, maxMessageChars.coerceAtLeast(80)), + evidenceRef = index + 1, + episodeIndex = 1, + ) + } + return ConversationProfileBatch( + botId = botId, + groupId = groupId, + startTime = startTime, + endTime = endTime, + messages = promptMessages, + aliases = aliases, + inputHash = calculateInputHash(promptMessages), + ) + } + + private fun createBatch( + userId: Long, + startTime: Int, + endTime: Int, + records: List>, + maxMessageChars: Int, + ): ProfileHistoryBatch { + val participantIds = buildSet { + add(userId) + records.forEach { (_, record) -> + add(record.fromId) + addAll(ProfileMessageRenderer.referencedUserIds(record)) + } + } + val aliases = buildMap { + put(userId, "TARGET") + participantIds.asSequence() + .filter { it != userId } + .sorted() + .forEachIndexed { index, participantId -> put(participantId, "U${index + 1}") } + } + + var evidenceRef = 0 + val promptMessages = records.map { (episodeIndex, record) -> + val ref = if (record.time >= startTime && record.time < endTime) ++evidenceRef else null + ProfilePromptMessage( + record = record, + text = ProfileMessageRenderer.render(record, aliases, maxMessageChars.coerceAtLeast(80)), + evidenceRef = ref, + episodeIndex = episodeIndex, + ) + } + return ProfileHistoryBatch( + userId = userId, + startTime = startTime, + endTime = endTime, + messages = promptMessages, + aliases = aliases, + inputHash = calculateInputHash(promptMessages), + ) + } + + private fun contextDistance( + record: ChatMessageRecord, + targetMessages: List, + ): Long = targetMessages.asSequence() + .filter { it.targetId == record.targetId } + .minOfOrNull { target -> kotlin.math.abs(target.time.toLong() - record.time.toLong()) } + ?: Long.MAX_VALUE + + private fun buildEpisodes( + targetMessages: List, + gapSeconds: Int, + ): List { + val episodes = mutableListOf() + var current = mutableListOf() + var currentGroup = 0L + var lastTime = 0 + + fun flush() { + if (current.isNotEmpty()) { + episodes += Episode(episodes.size + 1, currentGroup, current.toList()) + current = mutableListOf() + } + } + + targetMessages.forEach { message -> + if (current.isNotEmpty() && + (message.targetId != currentGroup || message.time - lastTime > gapSeconds) + ) { + flush() + } + if (current.isEmpty()) currentGroup = message.targetId + current += message + lastTime = message.time + } + flush() + return episodes + } + + private fun queryContextBefore( + connection: Connection, + groupId: Long, + beforeTime: Int, + limit: Int, + ): List { + if (limit <= 0) return emptyList() + return queryGroupContext( + connection, + "target_id = ? AND kind = ? AND recalled = 0 AND time < ? ORDER BY time DESC LIMIT ?", + groupId, + beforeTime, + limit, + ).asReversed() + } + + private fun queryContextAfter( + connection: Connection, + groupId: Long, + afterTime: Int, + limit: Int, + ): List { + if (limit <= 0) return emptyList() + return queryGroupContext( + connection, + "target_id = ? AND kind = ? AND recalled = 0 AND time > ? ORDER BY time ASC LIMIT ?", + groupId, + afterTime, + limit, + ) + } + + private fun queryContextCore( + connection: Connection, + groupId: Long, + startTime: Int, + endTime: Int, + limit: Int, + ): List { + if (limit <= 0) return emptyList() + return connection.prepareStatement( + """ + SELECT id, bot_id, from_id, target_id, ids, internal_ids, + time, kind, code, recalled + FROM message_record + WHERE target_id = ? AND kind = ? AND recalled = 0 + AND time >= ? AND time < ? + ORDER BY time ASC + LIMIT ? + """.trimIndent() + ).use { statement -> + statement.setLong(1, groupId) + statement.setInt(2, MessageSourceKind.GROUP.ordinal) + statement.setInt(3, startTime) + statement.setInt(4, endTime) + statement.setInt(5, limit) + statement.executeQuery().use(::readRecords) + } + } + + private fun queryGroupContext( + connection: Connection, + predicate: String, + groupId: Long, + boundaryTime: Int, + limit: Int, + ): List = connection.prepareStatement( + """ + SELECT id, bot_id, from_id, target_id, ids, internal_ids, + time, kind, code, recalled + FROM message_record + WHERE $predicate + """.trimIndent() + ).use { statement -> + statement.setLong(1, groupId) + statement.setInt(2, MessageSourceKind.GROUP.ordinal) + statement.setInt(3, boundaryTime) + statement.setInt(4, limit) + statement.executeQuery().use(::readRecords) + } + + private fun readRecords(results: ResultSet): List = buildList { + while (results.next()) add(results.toRecord()) + } + + private fun ResultSet.toRecord(): ChatMessageRecord { + val kind = MessageSourceKind.values().getOrNull(getInt("kind")) + ?: throw IllegalStateException("未知的消息类型") + return ChatMessageRecord( + id = getLong("id"), + botId = getLong("bot_id"), + fromId = getLong("from_id"), + targetId = getLong("target_id"), + ids = getString("ids"), + internalIds = getString("internal_ids"), + time = getInt("time"), + kind = kind, + code = getString("code"), + recalled = getInt("recalled"), + ) + } + + private fun openReadConnection(): Connection { + val config = SQLiteConfig().apply { + setReadOnly(true) + setBusyTimeout(30_000) + } + return DriverManager.getConnection( + "jdbc:sqlite:${databaseFile.absolutePath}", + config.toProperties(), + ).also { connection -> + connection.createStatement().use { statement -> + statement.execute("PRAGMA query_only=ON") + } + } + } + + private fun ChatMessageRecord.fingerprint(): String = + "$botId|$fromId|$targetId|$time|${kind.ordinal}|$code".sha256() + + private fun calculateInputHash(messages: List): String = buildString { + messages.forEach { message -> + append(message.episodeIndex).append('|') + append(message.evidenceRef ?: 0).append('|') + append(message.record.fingerprint()).append('\n') + } + }.sha256() + + private fun String.sha256(): String = MessageDigest.getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } + + private fun Int.safeNextSecond(): Int = if (this == Int.MAX_VALUE) this else this + 1 +} diff --git a/src/main/kotlin/profile/ProfileMessageRenderer.kt b/src/main/kotlin/profile/ProfileMessageRenderer.kt new file mode 100644 index 0000000..ef0c2b7 --- /dev/null +++ b/src/main/kotlin/profile/ProfileMessageRenderer.kt @@ -0,0 +1,177 @@ +package top.jie65535.mirai.profile + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import net.mamoe.mirai.message.data.At +import net.mamoe.mirai.message.data.ForwardMessage +import net.mamoe.mirai.message.data.Image +import net.mamoe.mirai.message.data.MessageChain +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.message.data.PlainText +import net.mamoe.mirai.message.data.QuoteReply +import net.mamoe.mirai.message.data.SingleMessage +import net.mamoe.mirai.message.data.content +import top.jie65535.mirai.data.ChatMessageRecord + +object ProfileMessageRenderer { + private val json = Json { ignoreUnknownKeys = true } + + fun render(record: ChatMessageRecord, aliases: Map, maxChars: Int): String { + val content = runCatching { + renderJsonCode(record.code, aliases) + }.recoverCatching { + renderChain(record.toMessageChain(), aliases) + }.getOrElse { + "[消息内容解析失败]" + }.replace(Regex("[\\r\\n]+"), " ").trim() + + if (content.length <= maxChars) return content.ifEmpty { "[无文本消息]" } + return content.take(maxChars).trimEnd() + "...[截断]" + } + + fun referencedUserIds(record: ChatMessageRecord): Set = runCatching { + referencedUserIdsFromJson(record.code) + }.recoverCatching { + buildSet { + record.toMessageChain().forEach { message -> + when (message) { + is At -> add(message.target) + is QuoteReply -> add(message.source.fromId) + } + } + } + }.getOrDefault(emptySet()) + + fun authoredTextLength(record: ChatMessageRecord): Int = runCatching { + val messages = json.parseToJsonElement(record.code) as? JsonArray + ?: throw IllegalArgumentException("消息记录不是 JSON array") + messages.sumOf { element -> + val message = element as? JsonObject + if (message?.string("type") == "PlainText") message.string("content").orEmpty().trim().length else 0 + } + }.recoverCatching { + record.toMessageChain().filterIsInstance().sumOf { it.content.trim().length } + }.getOrDefault(0) + + private fun renderJsonCode(code: String, aliases: Map<Long, String>): String { + val messages = json.parseToJsonElement(code) as? JsonArray + ?: throw IllegalArgumentException("消息记录不是 JSON array") + return renderJsonMessages(messages, aliases) + } + + private fun renderJsonMessages(messages: JsonArray, aliases: Map<Long, String>): String = + messages.joinToString("") { element -> + val message = element as? JsonObject ?: return@joinToString "" + when (val type = message.string("type")) { + "PlainText" -> message.string("content").orEmpty() + "At" -> message.long("target")?.let { target -> + "@${aliases[target] ?: "用户"}" + }.orEmpty() + "AtAll" -> "@全体成员" + "Image", "FlashImage" -> if (message.boolean("isEmoji") == true) "[表情包]" else "[图片]" + "QuoteReply" -> renderJsonQuote(message, aliases) + "ForwardMessage" -> renderJsonForward(message, aliases) + "MessageOrigin", "ShowImageFlag" -> "" + "Face", "MarketFace", "VipFace" -> "[表情]" + "Audio" -> "[语音]" + "FileMessage" -> "[文件${message.string("name")?.let { ": $it" }.orEmpty()}]" + "LightApp", "SimpleServiceMessage", "MusicShare" -> "[卡片消息]" + "PokeMessage" -> "[戳一戳]" + null -> "" + else -> message.string("content") ?: "[$type]" + } + } + + private fun renderJsonQuote(message: JsonObject, aliases: Map<Long, String>): String { + val source = message["source"] as? JsonObject ?: return "[引用消息]" + val authorId = source.long("fromId") + val author = authorId?.let { aliases[it] } ?: "其他用户" + val original = (source["originalMessage"] as? JsonArray) + ?.let { renderJsonMessages(it, aliases) } + .orEmpty() + .replace(Regex("[\\r\\n]+"), " ") + .take(160) + return "[引用 $author: $original]" + } + + private fun renderJsonForward(message: JsonObject, aliases: Map<Long, String>): String = buildString { + append("[转发消息]") + val nodes = message["nodeList"] as? JsonArray ?: return@buildString + nodes.take(20).forEach { element -> + val node = element as? JsonObject ?: return@forEach + val sender = node.string("senderName") ?: "未知用户" + val chain = node["messageChain"] as? JsonArray + append(' ').append(sender).append(": ") + append(chain?.let { renderJsonMessages(it, aliases) }.orEmpty().take(200)) + } + if (nodes.size > 20) append(" ...[转发内容截断]") + } + + private fun referencedUserIdsFromJson(code: String): Set<Long> { + val messages = json.parseToJsonElement(code) as? JsonArray + ?: throw IllegalArgumentException("消息记录不是 JSON array") + return buildSet { collectReferencedUserIds(messages, this) } + } + + private fun collectReferencedUserIds(messages: JsonArray, destination: MutableSet<Long>) { + messages.forEach { element -> + val message = element as? JsonObject ?: return@forEach + when (message.string("type")) { + "At" -> message.long("target")?.let(destination::add) + "QuoteReply" -> { + val source = message["source"] as? JsonObject ?: return@forEach + source.long("fromId")?.let(destination::add) + (source["originalMessage"] as? JsonArray)?.let { + collectReferencedUserIds(it, destination) + } + } + "ForwardMessage" -> (message["nodeList"] as? JsonArray)?.forEach nodeLoop@ { nodeElement -> + val node = nodeElement as? JsonObject ?: return@nodeLoop + (node["messageChain"] as? JsonArray)?.let { + collectReferencedUserIds(it, destination) + } + } + } + } + } + + private fun JsonObject.string(key: String): String? = + (get(key) as? JsonPrimitive)?.contentOrNull + + private fun JsonObject.long(key: String): Long? = + (get(key) as? JsonPrimitive)?.longOrNull + + private fun JsonObject.boolean(key: String): Boolean? = + (get(key) as? JsonPrimitive)?.booleanOrNull + + private fun renderChain(chain: MessageChain, aliases: Map<Long, String>): String = + chain.joinToString("") { message -> renderSingle(message, aliases) } + + private fun renderSingle(message: SingleMessage, aliases: Map<Long, String>): String = when (message) { + is MessageSource -> "" + is PlainText -> message.content + is At -> "@${aliases[message.target] ?: "用户"}" + is Image -> if (message.isEmoji) "[表情包]" else "[图片]" + is QuoteReply -> { + val author = aliases[message.source.fromId] ?: "其他用户" + val quoted = renderChain(message.source.originalMessage, aliases) + .replace(Regex("[\\r\\n]+"), " ") + .take(160) + "[引用 $author: $quoted]" + } + is ForwardMessage -> buildString { + append("[转发消息]") + message.nodeList.take(20).forEach { node -> + append(" ").append(node.senderName).append(": ") + append(renderChain(node.messageChain, aliases).replace(Regex("[\\r\\n]+"), " ").take(200)) + } + if (message.nodeList.size > 20) append(" ...[转发内容截断]") + } + else -> message.content + } +} diff --git a/src/main/kotlin/profile/ProfileModelClient.kt b/src/main/kotlin/profile/ProfileModelClient.kt new file mode 100644 index 0000000..613f802 --- /dev/null +++ b/src/main/kotlin/profile/ProfileModelClient.kt @@ -0,0 +1,140 @@ +package top.jie65535.mirai.profile + +import com.aallam.openai.api.chat.ChatCompletionRequest +import com.aallam.openai.api.chat.ChatMessage +import com.aallam.openai.api.chat.ChatResponseFormat +import com.aallam.openai.api.chat.StreamOptions +import com.aallam.openai.api.core.Usage +import com.aallam.openai.api.model.ModelId +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import top.jie65535.mirai.llm.LargeLanguageModels +import top.jie65535.mirai.llm.ModelService + +interface ProfileModel { + val modelName: String + + suspend fun analyze( + profile: UserProfileSnapshot, + batch: ProfileHistoryBatch, + ): ProfileModelResult +} + +interface ConversationProfileModel { + val modelName: String + + suspend fun analyzeConversation( + profiles: Map<Long, UserProfileSnapshot>, + batch: ConversationProfileBatch, + eligibleUserIds: Set<Long>, + ): ConversationProfileModelResult +} + +class ProfileModelClient( + private val endpoint: LargeLanguageModels.ProfileEndpoint, +) : ProfileModel, ConversationProfileModel { + private val json = Json { + ignoreUnknownKeys = false + explicitNulls = false + } + + override val modelName: String + get() = endpoint.model + + override suspend fun analyze( + profile: UserProfileSnapshot, + batch: ProfileHistoryBatch, + ): ProfileModelResult { + val content = StringBuilder() + var lastUsage: Usage? = null + var cacheUsage: ModelService.CacheUsage? = null + endpoint.service.chatCompletions( + ChatCompletionRequest( + model = ModelId(endpoint.model), + temperature = endpoint.temperature, + responseFormat = ChatResponseFormat.JsonObject, + streamOptions = StreamOptions(includeUsage = true), + messages = listOf( + ChatMessage.System(ProfilePromptStore.systemPrompt), + ChatMessage.User(ProfilePromptStore.buildUserPrompt(profile, batch)), + ), + ) + ) { cacheUsage = it }.collect { chunk -> + chunk.choices.firstOrNull()?.delta?.content?.let(content::append) + chunk.usage?.let { lastUsage = it } + } + + val raw = content.toString().replace(THINK_REGEX, "").trim() + val response = parseResponse(raw) + return ProfileModelResult( + response = response, + rawResponse = raw, + usage = ProfileTokenUsage( + promptTokens = lastUsage?.promptTokens ?: 0, + completionTokens = lastUsage?.completionTokens ?: 0, + cachedTokens = cacheUsage?.hitTokens ?: 0, + ), + ) + } + + override suspend fun analyzeConversation( + profiles: Map<Long, UserProfileSnapshot>, + batch: ConversationProfileBatch, + eligibleUserIds: Set<Long>, + ): ConversationProfileModelResult { + val content = StringBuilder() + var lastUsage: Usage? = null + var cacheUsage: ModelService.CacheUsage? = null + endpoint.service.chatCompletions( + ChatCompletionRequest( + model = ModelId(endpoint.model), + temperature = endpoint.temperature, + responseFormat = ChatResponseFormat.JsonObject, + streamOptions = StreamOptions(includeUsage = true), + messages = listOf( + ChatMessage.System(ProfilePromptStore.conversationSystemPrompt), + ChatMessage.User( + ProfilePromptStore.buildConversationUserPrompt(profiles, batch, eligibleUserIds) + ), + ), + ) + ) { cacheUsage = it }.collect { chunk -> + chunk.choices.firstOrNull()?.delta?.content?.let(content::append) + chunk.usage?.let { lastUsage = it } + } + + val raw = content.toString().replace(THINK_REGEX, "").trim() + return ConversationProfileModelResult( + response = parseObject(raw), + rawResponse = raw, + usage = ProfileTokenUsage( + promptTokens = lastUsage?.promptTokens ?: 0, + completionTokens = lastUsage?.completionTokens ?: 0, + cachedTokens = cacheUsage?.hitTokens ?: 0, + ), + ) + } + + private fun parseResponse(raw: String): ProfileModelResponse { + return parseObject(raw) + } + + private inline fun <reified T> parseObject(raw: String): T { + val unfenced = raw + .removePrefix("```json").removePrefix("```") + .removeSuffix("```").trim() + val objectText = if (unfenced.startsWith('{') && unfenced.endsWith('}')) { + unfenced + } else { + val start = unfenced.indexOf('{') + val end = unfenced.lastIndexOf('}') + if (start < 0 || end <= start) throw SerializationException("模型响应中没有完整 JSON object") + unfenced.substring(start, end + 1) + } + return json.decodeFromString(objectText) + } + + companion object { + private val THINK_REGEX = Regex("<think>[\\s\\S]*?</think>") + } +} diff --git a/src/main/kotlin/profile/ProfilePromptStore.kt b/src/main/kotlin/profile/ProfilePromptStore.kt new file mode 100644 index 0000000..e8c3d28 --- /dev/null +++ b/src/main/kotlin/profile/ProfilePromptStore.kt @@ -0,0 +1,251 @@ +package top.jie65535.mirai.profile + +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.config.PluginConfig +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object ProfilePromptStore { + const val PROMPT_VERSION = "profile-v3.3" + + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + + var systemPrompt: String = DEFAULT_SYSTEM_PROMPT + private set + + var conversationSystemPrompt: String = DEFAULT_CONVERSATION_SYSTEM_PROMPT + private set + + fun reload() { + systemPrompt = loadPrompt( + configuredPath = PluginConfig.profilePromptFile, + defaultPrompt = DEFAULT_SYSTEM_PROMPT, + label = "定向画像", + ) + conversationSystemPrompt = loadPrompt( + configuredPath = PluginConfig.profileConversationPromptFile, + defaultPrompt = DEFAULT_CONVERSATION_SYSTEM_PROMPT, + label = "多人会话画像", + ) + } + + private fun loadPrompt(configuredPath: String, defaultPrompt: String, label: String): String { + if (configuredPath.isBlank()) return defaultPrompt + val file = JChatGPT.resolveConfigFile(configuredPath) + return try { + when { + file.exists() && file.readText().isNotBlank() -> file.readText() + else -> defaultPrompt.also { default -> + file.parentFile?.mkdirs() + file.writeText(default) + } + } + } catch (cause: Throwable) { + JChatGPT.logger.warning("载入${label}提示词失败,暂时使用内置提示词", cause) + defaultPrompt + } + } + + fun buildUserPrompt(profile: UserProfileSnapshot, batch: ProfileHistoryBatch): String = buildString { + appendLine("## 目标") + appendLine("目标用户别名: TARGET") + appendLine("本批时间范围: [${formatTime(batch.startTime)}, ${formatTime(batch.endTime)})") + appendLine("别名只在本批有效,不要输出 QQ 号或数据库消息 ID。") + appendLine() + + appendLine("## 当前画像(可修正状态,不是事实证据)") + if (profile.items.isEmpty()) { + appendLine("(尚无画像条目)") + } else { + profile.items.forEach { item -> + append("[P:").append(item.id).append("] ") + append(item.category.wireName()).append(" | ") + append(item.confidence.wireName()).append(" | ") + append(item.content) + item.relatedUserId?.let { related -> + append(" | related=").append(batch.aliases[related] ?: "历史用户") + } + append(" | ").append(formatTime(item.firstSeenAt)) + append(" ~ ").append(formatTime(item.lastConfirmedAt)) + appendLine() + } + } + appendLine("当前短摘要: ${profile.summary.ifBlank { "(空)" }}") + appendLine() + + appendLine("## 本批参与者别名") + batch.aliases.entries.sortedBy { it.value }.forEach { (_, alias) -> + appendLine("- $alias") + } + appendLine() + + appendLine("## 带上下文的原始群聊") + var currentEpisode = -1 + batch.messages.forEach { message -> + if (message.episodeIndex != currentEpisode) { + currentEpisode = message.episodeIndex + appendLine() + appendLine("### 对话片段 $currentEpisode / 群 ${message.record.targetId}") + } + val marker = message.evidenceRef?.let { "[e:$it]" } ?: "[context]" + val time = formatTime(message.record.time) + val alias = batch.aliases[message.record.fromId] ?: "其他用户" + append(marker).append('[').append(time).append(']') + .append('[').append(alias).append("] ") + .appendLine(message.text) + } + } + + fun buildConversationUserPrompt( + profiles: Map<Long, UserProfileSnapshot>, + batch: ConversationProfileBatch, + eligibleUserIds: Set<Long>, + ): String = buildString { + appendLine("## 任务") + appendLine("分析这一段已经闭合的群聊,一次性更新所有出现可靠长期信息的候选用户画像。") + appendLine("会话时间范围: [${formatTime(batch.startTime)}, ${formatTime(batch.endTime)})") + appendLine("别名只在本批有效,不要输出 QQ 号或数据库消息 ID。") + appendLine() + + appendLine("## 候选用户及当前画像") + eligibleUserIds.sortedBy { batch.aliases[it] }.forEach { userId -> + val alias = batch.aliases[userId] ?: return@forEach + val profile = profiles[userId] + appendLine("### $alias") + if (profile == null || profile.items.isEmpty()) { + appendLine("(尚无画像条目)") + } else { + profile.items.forEach { item -> + append("[P:").append(item.id).append("] ") + append(item.category.wireName()).append(" | ") + append(item.confidence.wireName()).append(" | ") + append(item.content) + item.relatedUserId?.let { related -> + append(" | related=").append(batch.aliases[related] ?: "历史用户") + } + append(" | ").append(formatTime(item.firstSeenAt)) + append(" ~ ").append(formatTime(item.lastConfirmedAt)) + appendLine() + } + } + appendLine("当前短摘要: ${profile?.summary?.ifBlank { "(空)" } ?: "(空)"}") + } + appendLine() + + appendLine("## 本批保留的闭合群聊消息") + batch.messages.forEach { message -> + val marker = message.evidenceRef?.let { "[e:$it]" } ?: "[context]" + val time = formatTime(message.record.time) + val alias = batch.aliases[message.record.fromId] ?: "其他用户" + append(marker).append('[').append(time).append(']') + .append('[').append(alias).append("] ") + .appendLine(message.text) + } + } + + private fun formatTime(epochSecond: Int): String = + dateTimeFormatter.format(Instant.ofEpochSecond(epochSecond.toLong())) + + private fun ProfileCategory.wireName(): String = name.lowercase() + private fun ProfileConfidence.wireName(): String = name.lowercase() + + private const val DEFAULT_SYSTEM_PROMPT = """你是保守、严谨的群聊人物画像归纳器。 + +你会收到一个目标用户的当前画像,以及一个带完整发言者、时间、回复引用和相邻消息的原始群聊批次。 +你的任务是判断本批信息是否应当 ADD、UPDATE、CONFIRM 或 DELETE 长期画像条目;没有可靠变化时返回空 operations。 + +画像只描述: +- notable_fact:本人明确披露且半年后仍有助于认识此人的事实 +- interest:持续关注或参与的领域 +- expertise_signal:反复表现出的具体知识或解决问题能力,不授予专家头衔 +- thinking_style:分析、判断和解决问题的方式 +- expression_style:稳定的措辞和表达方式 +- social_mode:一般群聊参与和互动方式 +- preference:本人明确表达的长期偏好 +- relationship_note:与某个具体用户反复出现的互动模式 + +严格原则: +1. 当前画像只是可修正状态,不是证据。所有操作必须引用本批 [e:n]。 +2. 每个操作至少引用一条 TARGET 自己的发言。其他人的消息只能帮助理解上下文和关系。 +3. 引用原文的作者不是回复者;不要把被引用者的话归给回复者。 +4. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断 TARGET 的事实。 +5. 一次技术回答或同一话题中的连续补充只算一个语境。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须由至少两个独立对话片段中的一致表现支持;单个片段最多用于 CONFIRM 已有条目。本人明确自述的 notable_fact 和 preference 不受此限制。 +6. 每个条目只表达一个主题。禁止把不同时间、不同领域的内容拼成一个所谓稳定特点。 +7. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述。 +8. ADD 不填写 item_id;UPDATE、CONFIRM、DELETE 必须填写当前画像中的 item_id。 +9. relationship_note 必须填写本批存在的 related_user_alias,只描述互动方式,不推断现实亲疏。 +10. 新证据与旧画像无关时不要勉强更新。未输出的旧条目由程序自动保留。 +11. DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定,不能因为本批没提到就删除。 +12. summary 是更新后的日常短摘要,必须自然、克制,不写证据编号、时间、QQ 号、进度或内部条目 ID。 + +只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式: +{ + "operations": [ + { + "action": "ADD|UPDATE|CONFIRM|DELETE", + "item_id": "UPDATE/CONFIRM/DELETE 时填写,ADD 为 null", + "category": "notable_fact|interest|expertise_signal|thinking_style|expression_style|social_mode|preference|relationship_note", + "content": "ADD/UPDATE 时填写的单一、克制结论;其余操作可为 null", + "confidence": "low|medium|high", + "related_user_alias": "仅 relationship_note 填写,否则 null", + "evidence_refs": [1, 2] + } + ], + "summary": "更新后的短摘要;没有画像时可以为空" +} +""" + + private const val DEFAULT_CONVERSATION_SYSTEM_PROMPT = """你是保守、严谨的群聊人物画像归纳器。 + +你会收到一段已经闭合的真实群聊、候选用户别名,以及他们各自的当前画像。你的任务是一次性判断这段对话是否足以 ADD、UPDATE、CONFIRM 或 DELETE 各候选用户的长期画像条目。没有可靠变化的用户不要输出,每名用户最多输出 4 个真正有长期价值的操作。 + +画像只描述: +- notable_fact:本人明确披露且半年后仍有助于认识此人的事实 +- interest:持续关注或参与的领域 +- expertise_signal:反复表现出的具体知识或解决问题能力,不授予专家头衔 +- thinking_style:分析、判断和解决问题的方式 +- expression_style:稳定的措辞和表达方式 +- social_mode:一般群聊参与和互动方式 +- preference:本人明确表达的长期偏好 +- relationship_note:与某个具体用户反复出现的互动模式 + +严格原则: +1. 当前画像只是可修正状态,不是事实证据。所有操作必须引用本批 [e:n]。 +2. 每个用户操作至少引用一条该 user_alias 本人说出的消息。其他人的消息只能帮助理解上下文和关系。 +3. 引用原文的作者不是回复者;不要把被引用者的话归给回复者。 +4. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断某人的事实。 +5. 一次明确自述可以支持 notable_fact 或 preference。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须在本会话中有多条分离的本人证据;证据不足时宁可不写。 +6. 每个条目只表达一个主题,禁止把不同人的特点或不同领域拼接在一起。 +7. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述。 +8. ADD 不填写 item_id;UPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 item_id,不能引用其他用户的条目。 +9. relationship_note 必须填写本批存在的 related_user_alias,只描述互动方式,不推断现实亲疏。 +10. 未输出的用户和旧条目由程序自动保留。DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定。 +11. summary 是该用户更新后的日常短摘要,必须自然、克制,不写证据编号、时间、QQ 号、进度或内部 ID。 +12. 不生成或修改好感度、代号、主观印象和标签;这些属于另一套 Bot 关系状态。 +13. 本批只是一段会话。除非当前画像已有同类条目且本批在确认它,否则不得使用“长期、持续、一贯、总是、通常”等跨时间措辞;只能描述本批确实支持的事实、关注点或表现。 +14. 对尚无同类旧条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有多个彼此分离的本人证据才可新增,并保持 low 或 medium 可信度;同一问答链中的连续补充不算多次独立表现。 + +只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式: +{ + "users": [ + { + "user_alias": "U1", + "operations": [ + { + "action": "ADD|UPDATE|CONFIRM|DELETE", + "item_id": "UPDATE/CONFIRM/DELETE 时填写,ADD 为 null", + "category": "notable_fact|interest|expertise_signal|thinking_style|expression_style|social_mode|preference|relationship_note", + "content": "ADD/UPDATE 时填写的单一、克制结论;其余操作可为 null", + "confidence": "low|medium|high", + "related_user_alias": "仅 relationship_note 填写,否则 null", + "evidence_refs": [1, 2] + } + ], + "summary": "该用户更新后的短摘要" + } + ] +} +""" +} diff --git a/src/main/kotlin/profile/UserProfileAnalysisService.kt b/src/main/kotlin/profile/UserProfileAnalysisService.kt new file mode 100644 index 0000000..d35b08c --- /dev/null +++ b/src/main/kotlin/profile/UserProfileAnalysisService.kt @@ -0,0 +1,303 @@ +package top.jie65535.mirai.profile + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import top.jie65535.mirai.JChatGPT +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.ChatHistoryStore +import top.jie65535.mirai.llm.LargeLanguageModels +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +object UserProfileAnalysisService { + private val runningUsers = ConcurrentHashMap.newKeySet<Long>() + private val concurrencyLimiter = Semaphore(1) + + suspend fun analyze( + userId: Long, + maxBatches: Int, + onProgress: suspend (ProfileAnalysisProgress) -> Unit = {}, + ): ProfileAnalysisReport { + require(userId > 0) { "userId 必须是正数" } + require(maxBatches > 0) { "maxBatches 必须是正数" } + check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } + check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } + + if (!runningUsers.add(userId)) { + return ProfileAnalysisReport( + userId = userId, + processedBatches = 0, + processedMessages = 0, + appliedOperations = 0, + usage = ProfileTokenUsage(), + profile = UserProfileStore.load(userId), + caughtUp = false, + alreadyRunning = true, + ) + } + + try { + return concurrencyLimiter.withPermit { + analyzeExclusive(userId, maxBatches, onProgress) + } + } finally { + runningUsers.remove(userId) + } + } + + private suspend fun analyzeExclusive( + userId: Long, + maxBatches: Int, + onProgress: suspend (ProfileAnalysisProgress) -> Unit, + ): ProfileAnalysisReport { + val endpoint = checkNotNull(LargeLanguageModels.profile) { + "画像分析模型未配置,请设置 profileModelApi/profileModelToken,或配置可继承的聊天模型接入点" + } + val model: ProfileModel = ProfileModelClient(endpoint) + val historyFile = resolveHistoryFile() + val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(historyFile) } + val bounds = withContext(Dispatchers.IO) { reader.findUserTimeBounds(userId) } + ?: return emptyReport(userId) + + var profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } + ?: UserProfileSnapshot( + userId = userId, + cursorTime = bounds.startTime, + snapshotEndTime = bounds.endTime, + ) + if (profile.cursorTime >= profile.snapshotEndTime && bounds.endTime > profile.snapshotEndTime) { + profile = profile.copy(snapshotEndTime = bounds.endTime) + } + + var processedBatches = 0 + var processedMessages = 0 + var appliedOperations = 0 + var totalUsage = ProfileTokenUsage() + var caughtUp = false + + while (processedBatches < maxBatches) { + val batch = withContext(Dispatchers.IO) { + reader.loadNextBatch( + userId = userId, + startTime = profile.cursorTime, + snapshotEndTime = profile.snapshotEndTime, + targetMessageLimit = PluginConfig.profileBatchTargetMessages.coerceAtLeast(1), + maxEpisodes = PluginConfig.profileBatchMaxEpisodes.coerceAtLeast(1), + episodeGapSeconds = PluginConfig.profileEpisodeGapMinutes.coerceAtLeast(0) * 60, + contextBeforeMessages = PluginConfig.profileContextBeforeMessages.coerceAtLeast(0), + contextAfterMessages = PluginConfig.profileContextAfterMessages.coerceAtLeast(0), + contextCoreMessages = PluginConfig.profileContextCoreMessages.coerceAtLeast(1), + maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80), + ) + } + if (batch == null) { + caughtUp = true + break + } + + val (result, reduction) = analyzeWithRetry(model, profile, batch) + withContext(Dispatchers.IO) { + UserProfileStore.commit( + reduction = reduction, + batch = batch, + usage = result.usage, + source = ProfileRevisionSource.BACKFILL, + ) + } + profile = reduction.profile + processedBatches++ + processedMessages += batch.messages.size + appliedOperations += reduction.operations.size + totalUsage += result.usage + onProgress( + ProfileAnalysisProgress( + batchIndex = processedBatches, + startTime = batch.startTime, + endTime = batch.endTime, + messageCount = batch.messages.size, + operationCount = reduction.operations.size, + usage = result.usage, + ) + ) + } + + if (!caughtUp && profile.cursorTime >= profile.snapshotEndTime) caughtUp = true + return ProfileAnalysisReport( + userId = userId, + processedBatches = processedBatches, + processedMessages = processedMessages, + appliedOperations = appliedOperations, + usage = totalUsage, + profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } ?: profile, + caughtUp = caughtUp, + ) + } + + suspend fun analyzeConversation( + botId: Long, + groupId: Long, + startTime: Int, + endTime: Int, + minAuthoredTextChars: Int, + ): ConversationProfileAnalysisReport? { + require(startTime < endTime) { "startTime must be before endTime" } + check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } + check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } + + return concurrencyLimiter.withPermit { + val endpoint = checkNotNull(LargeLanguageModels.profile) { "画像分析模型未配置" } + val model: ConversationProfileModel = ProfileModelClient(endpoint) + val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveLiveHistoryFile()) } + val batch = withContext(Dispatchers.IO) { + reader.loadConversationBatch( + botId = botId, + groupId = groupId, + startTime = startTime, + endTime = endTime, + messageLimit = PluginConfig.profileAutoConversationMessageLimit.coerceIn(20, 500), + maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80), + ) + } ?: return@withPermit null + val eligibleUserIds = batch.authoredTextCharsByUser + .filterValues { it >= minAuthoredTextChars.coerceAtLeast(1) } + .keys + if (eligibleUserIds.isEmpty()) return@withPermit null + if (withContext(Dispatchers.IO) { UserProfileStore.isConversationProcessed(batch.inputHash) }) { + return@withPermit null + } + val profiles = withContext(Dispatchers.IO) { + eligibleUserIds.associateWith { userId -> + UserProfileStore.load(userId) ?: UserProfileSnapshot( + userId = userId, + cursorTime = 0, + snapshotEndTime = 0, + ) + } + } + + val (result, reductions) = analyzeConversationWithRetry( + model = model, + profiles = profiles, + batch = batch, + eligibleUserIds = eligibleUserIds, + ) + withContext(Dispatchers.IO) { + UserProfileStore.commitConversation( + reductions = reductions.map { reduction -> reduction to batch.forUser(reduction.profile.userId) }, + usage = result.usage, + ) + } + ConversationProfileAnalysisReport( + analyzedUsers = eligibleUserIds.size, + processedMessages = batch.messages.size, + appliedOperations = reductions.sumOf { it.operations.size }, + usage = result.usage, + ) + } + } + + private suspend fun analyzeConversationWithRetry( + model: ConversationProfileModel, + profiles: Map<Long, UserProfileSnapshot>, + batch: ConversationProfileBatch, + eligibleUserIds: Set<Long>, + ): Pair<ConversationProfileModelResult, List<ProfileReduction>> { + val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1 + var lastFailure: Throwable? = null + repeat(attempts) { attempt -> + try { + val result = model.analyzeConversation(profiles, batch, eligibleUserIds) + val reductions = ConversationProfileReducer.reduce( + profiles = profiles, + batch = batch, + eligibleUserIds = eligibleUserIds, + response = result.response, + model = model.modelName, + promptVersion = ProfilePromptStore.PROMPT_VERSION, + summaryMaxLength = PluginConfig.profileSummaryMaxLength.coerceAtLeast(100), + ) + return result to reductions + } catch (cause: Exception) { + if (cause is CancellationException) throw cause + lastFailure = cause + JChatGPT.logger.warning( + "群 ${batch.groupId} 会话画像 [${batch.startTime}, ${batch.endTime}) " + + "第 ${attempt + 1}/$attempts 次分析失败", + cause, + ) + } + } + throw IllegalStateException( + "会话画像 [${batch.startTime}, ${batch.endTime}) 连续 $attempts 次分析失败,未提交任何结果", + lastFailure, + ) + } + + private suspend fun analyzeWithRetry( + model: ProfileModel, + profile: UserProfileSnapshot, + batch: ProfileHistoryBatch, + advanceBackfillCursor: Boolean = true, + ): Pair<ProfileModelResult, ProfileReduction> { + val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1 + var lastFailure: Throwable? = null + repeat(attempts) { attempt -> + try { + val result = model.analyze(profile, batch) + val reduction = UserProfileReducer.reduce( + current = profile, + batch = batch, + response = result.response, + model = model.modelName, + promptVersion = ProfilePromptStore.PROMPT_VERSION, + summaryMaxLength = PluginConfig.profileSummaryMaxLength.coerceAtLeast(100), + advanceBackfillCursor = advanceBackfillCursor, + ) + return result to reduction + } catch (cause: Exception) { + if (cause is CancellationException) throw cause + lastFailure = cause + JChatGPT.logger.warning( + "用户 ${batch.userId} 画像批次 [${batch.startTime}, ${batch.endTime}) " + + "第 ${attempt + 1}/$attempts 次分析失败", + cause, + ) + } + } + throw IllegalStateException( + "画像批次 [${batch.startTime}, ${batch.endTime}) 连续 $attempts 次分析失败,水位线未推进", + lastFailure, + ) + } + + private fun resolveHistoryFile(): File { + val configured = PluginConfig.profileHistoryDatabasePath.trim() + return if (configured.isNotEmpty()) { + File(configured).absoluteFile + } else { + checkNotNull(ChatHistoryStore.databaseFileOrNull) { "聊天记录数据库不可用" } + } + } + + private fun resolveLiveHistoryFile(): File = + checkNotNull(ChatHistoryStore.databaseFileOrNull) { "聊天记录数据库不可用" } + + private fun emptyReport(userId: Long) = ProfileAnalysisReport( + userId = userId, + processedBatches = 0, + processedMessages = 0, + appliedOperations = 0, + usage = ProfileTokenUsage(), + profile = null, + caughtUp = true, + ) + + private operator fun ProfileTokenUsage.plus(other: ProfileTokenUsage) = ProfileTokenUsage( + promptTokens = promptTokens + other.promptTokens, + completionTokens = completionTokens + other.completionTokens, + cachedTokens = cachedTokens + other.cachedTokens, + ) +} diff --git a/src/main/kotlin/profile/UserProfileContextRenderer.kt b/src/main/kotlin/profile/UserProfileContextRenderer.kt new file mode 100644 index 0000000..c2353de --- /dev/null +++ b/src/main/kotlin/profile/UserProfileContextRenderer.kt @@ -0,0 +1,62 @@ +package top.jie65535.mirai.profile + +import top.jie65535.mirai.data.FavorabilityInfo + +object UserProfileContextRenderer { + fun render( + profiles: List<UserProfileSnapshot>, + favorabilityByUserId: Map<Long, FavorabilityInfo>, + displayNames: Map<Long, String>, + activeUserIds: Set<Long>, + summaryMaxChars: Int, + ): String { + val profilesByUserId = profiles + .filter { it.reliable && it.summary.isNotBlank() } + .associateBy { it.userId } + val userIds = activeUserIds.filter { userId -> + userId in profilesByUserId || favorabilityByUserId[userId]?.hasVisibleContext() == true + } + if (userIds.isEmpty()) return "" + + val maxChars = summaryMaxChars.coerceAtLeast(50) + return buildString { + appendLine("## 你对相关群友的认识") + appendLine("好感度、代号和主观印象代表你的关系状态;长期认识来自可修正的历史归纳。仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。") + userIds.forEach { userId -> + val profile = profilesByUserId[userId] + val favorability = favorabilityByUserId[userId] + val name = favorability?.name.orEmpty().ifBlank { + displayNames[userId].orEmpty().ifBlank { userId.toString() } + } + append("- ").append(name).append('(').append(userId).append(')') + favorability?.takeIf { it.hasVisibleContext() }?.let { info -> + append(" | 好感度").append(if (info.value >= 0) "+" else "").append(info.value) + if (info.tags.isNotEmpty()) append(" | 标签:").append(info.tags.joinToString("、")) + if (info.impression.isNotBlank()) append(" | 主观印象:").append(info.impression.normalized()) + } + profile?.let { + append(" | 长期认识:").append(it.summary.normalized().take(maxChars)) + } + + profile?.items?.asSequence() + ?.filter { item -> + item.category == ProfileCategory.RELATIONSHIP_NOTE && + item.relatedUserId != null && item.relatedUserId in activeUserIds + } + ?.take(2) + ?.forEach { item -> + val relatedId = checkNotNull(item.relatedUserId) + val relatedName = displayNames[relatedId].orEmpty().ifBlank { relatedId.toString() } + append(";与").append(relatedName).append(":").append(item.content.normalized()) + } + appendLine() + } + appendLine() + } + } + + private fun FavorabilityInfo.hasVisibleContext(): Boolean = + value != 0 || name.isNotBlank() || tags.isNotEmpty() || impression.isNotBlank() + + private fun String.normalized(): String = trim().replace(Regex("\\s+"), " ") +} diff --git a/src/main/kotlin/profile/UserProfileModels.kt b/src/main/kotlin/profile/UserProfileModels.kt new file mode 100644 index 0000000..fbf864a --- /dev/null +++ b/src/main/kotlin/profile/UserProfileModels.kt @@ -0,0 +1,232 @@ +package top.jie65535.mirai.profile + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import top.jie65535.mirai.data.ChatMessageRecord + +@Serializable +enum class ProfileCategory { + @SerialName("notable_fact") + NOTABLE_FACT, + + @SerialName("interest") + INTEREST, + + @SerialName("expertise_signal") + EXPERTISE_SIGNAL, + + @SerialName("thinking_style") + THINKING_STYLE, + + @SerialName("expression_style") + EXPRESSION_STYLE, + + @SerialName("social_mode") + SOCIAL_MODE, + + @SerialName("preference") + PREFERENCE, + + @SerialName("relationship_note") + RELATIONSHIP_NOTE, +} + +@Serializable +enum class ProfileConfidence { + @SerialName("low") + LOW, + + @SerialName("medium") + MEDIUM, + + @SerialName("high") + HIGH, +} + +@Serializable +enum class ProfileOperationAction { + ADD, + UPDATE, + CONFIRM, + DELETE, +} + +enum class ProfileRevisionSource { + BACKFILL, + CONVERSATION, +} + +data class UserProfileItem( + val id: String, + val category: ProfileCategory, + val content: String, + val confidence: ProfileConfidence, + val relatedUserId: Long? = null, + val firstSeenAt: Int, + val lastConfirmedAt: Int, +) + +data class UserProfileSnapshot( + val userId: Long, + val summary: String = "", + val version: Int = 0, + val cursorTime: Int, + val snapshotEndTime: Int, + val reliable: Boolean = false, + val model: String = "", + val promptVersion: String = "", + val updatedAt: Long = 0, + val items: List<UserProfileItem> = emptyList(), +) + +@Serializable +data class ProfileModelOperation( + val action: ProfileOperationAction, + @SerialName("item_id") + val itemId: String? = null, + val category: ProfileCategory? = null, + val content: String? = null, + val confidence: ProfileConfidence? = null, + @SerialName("related_user_alias") + val relatedUserAlias: String? = null, + @SerialName("evidence_refs") + val evidenceRefs: List<Int> = emptyList(), +) + +@Serializable +data class ProfileModelResponse( + val operations: List<ProfileModelOperation> = emptyList(), + val summary: String = "", +) + +data class ProfilePromptMessage( + val record: ChatMessageRecord, + val text: String, + val evidenceRef: Int? = null, + val episodeIndex: Int, +) + +data class ProfileHistoryBatch( + val userId: Long, + val startTime: Int, + val endTime: Int, + val messages: List<ProfilePromptMessage>, + val aliases: Map<Long, String>, + val inputHash: String, +) { + val evidenceByRef: Map<Int, ProfilePromptMessage> = messages + .mapNotNull { message -> message.evidenceRef?.let { it to message } } + .toMap() + + val aliasToUserId: Map<String, Long> = aliases.entries.associate { (userId, alias) -> alias to userId } + + val targetMessageCount: Int = messages.count { it.record.fromId == userId && it.evidenceRef != null } + + val targetAuthoredTextChars: Int = messages.asSequence() + .filter { it.record.fromId == userId && it.evidenceRef != null } + .sumOf { ProfileMessageRenderer.authoredTextLength(it.record) } +} + +data class ProfileTokenUsage( + val promptTokens: Int = 0, + val completionTokens: Int = 0, + val cachedTokens: Int = 0, +) + +data class ProfileModelResult( + val response: ProfileModelResponse, + val rawResponse: String, + val usage: ProfileTokenUsage, +) + +@Serializable +data class ConversationProfileUserResponse( + @SerialName("user_alias") + val userAlias: String, + val operations: List<ProfileModelOperation> = emptyList(), + val summary: String = "", +) + +@Serializable +data class ConversationProfileModelResponse( + val users: List<ConversationProfileUserResponse> = emptyList(), +) + +data class ConversationProfileModelResult( + val response: ConversationProfileModelResponse, + val rawResponse: String, + val usage: ProfileTokenUsage, +) + +data class ConversationProfileBatch( + val botId: Long, + val groupId: Long, + val startTime: Int, + val endTime: Int, + val messages: List<ProfilePromptMessage>, + val aliases: Map<Long, String>, + val inputHash: String, +) { + val evidenceByRef: Map<Int, ProfilePromptMessage> = messages + .mapNotNull { message -> message.evidenceRef?.let { it to message } } + .toMap() + + val aliasToUserId: Map<String, Long> = aliases.entries.associate { (userId, alias) -> alias to userId } + + val authoredTextCharsByUser: Map<Long, Int> = messages.asSequence() + .filter { it.evidenceRef != null && it.record.fromId != botId } + .groupBy { it.record.fromId } + .mapValues { (_, authored) -> authored.sumOf { ProfileMessageRenderer.authoredTextLength(it.record) } } + + fun forUser(userId: Long): ProfileHistoryBatch = ProfileHistoryBatch( + userId = userId, + startTime = startTime, + endTime = endTime, + messages = messages, + aliases = aliases, + inputHash = inputHash, + ) +} + +@Serializable +data class AppliedProfileOperation( + val action: ProfileOperationAction, + val itemId: String, + val category: ProfileCategory, + val content: String, + val confidence: ProfileConfidence, + val relatedUserId: Long?, + val evidenceRefs: List<Int>, +) + +data class ProfileReduction( + val profile: UserProfileSnapshot, + val operations: List<AppliedProfileOperation>, +) + +data class ProfileAnalysisProgress( + val batchIndex: Int, + val startTime: Int, + val endTime: Int, + val messageCount: Int, + val operationCount: Int, + val usage: ProfileTokenUsage, +) + +data class ConversationProfileAnalysisReport( + val analyzedUsers: Int, + val processedMessages: Int, + val appliedOperations: Int, + val usage: ProfileTokenUsage, +) + +data class ProfileAnalysisReport( + val userId: Long, + val processedBatches: Int, + val processedMessages: Int, + val appliedOperations: Int, + val usage: ProfileTokenUsage, + val profile: UserProfileSnapshot?, + val caughtUp: Boolean, + val alreadyRunning: Boolean = false, +) diff --git a/src/main/kotlin/profile/UserProfileReducer.kt b/src/main/kotlin/profile/UserProfileReducer.kt new file mode 100644 index 0000000..463c929 --- /dev/null +++ b/src/main/kotlin/profile/UserProfileReducer.kt @@ -0,0 +1,192 @@ +package top.jie65535.mirai.profile + +import java.util.UUID + +object UserProfileReducer { + private val overclaimPattern = Regex("深厚|扎实|精通|专家|导师|领袖|天才|极强|全栈|核心成员|公认") + + fun reduce( + current: UserProfileSnapshot, + batch: ProfileHistoryBatch, + response: ProfileModelResponse, + model: String, + promptVersion: String, + summaryMaxLength: Int, + advanceBackfillCursor: Boolean = true, + ): ProfileReduction { + require(response.summary.length <= summaryMaxLength) { + "画像摘要超过 ${summaryMaxLength} 字符" + } + + val items = current.items.associateBy { it.id }.toMutableMap() + val applied = mutableListOf<AppliedProfileOperation>() + + response.operations.forEachIndexed { index, operation -> + val evidence = operation.evidenceRefs.distinct().map { ref -> + batch.evidenceByRef[ref] + ?: throw IllegalArgumentException("operations[$index] 引用了不存在的证据 e:$ref") + } + require(evidence.isNotEmpty()) { "operations[$index] 缺少证据" } + require(evidence.any { it.record.fromId == batch.userId }) { + "operations[$index] 没有目标用户自己的发言证据" + } + + val evidenceTime = evidence + .asSequence() + .filter { it.record.fromId == batch.userId } + .maxOf { it.record.time } + val firstEvidenceTime = evidence + .asSequence() + .filter { it.record.fromId == batch.userId } + .minOf { it.record.time } + + when (operation.action) { + ProfileOperationAction.ADD -> { + require(operation.itemId.isNullOrBlank()) { + "operations[$index] ADD 不能指定 item_id" + } + val category = requireNotNull(operation.category) { + "operations[$index] ADD 缺少 category" + } + val content = validateContent(index, operation.content) + val confidence = requireNotNull(operation.confidence) { + "operations[$index] ADD 缺少 confidence" + } + val relatedUserId = resolveRelatedUser(index, category, operation.relatedUserAlias, batch) + val duplicate = items.values.any { + it.category == category && normalize(it.content) == normalize(content) + } + if (duplicate) return@forEachIndexed + + val item = UserProfileItem( + id = UUID.randomUUID().toString(), + category = category, + content = content, + confidence = confidence, + relatedUserId = relatedUserId, + firstSeenAt = firstEvidenceTime, + lastConfirmedAt = evidenceTime, + ) + items[item.id] = item + applied += item.toApplied(operation.action, operation.evidenceRefs) + } + + ProfileOperationAction.UPDATE -> { + val old = requireExistingItem(index, operation, items) + val category = operation.category ?: old.category + val content = validateContent(index, operation.content) + val confidence = operation.confidence ?: old.confidence + val relatedUserId = resolveRelatedUser( + index, + category, + operation.relatedUserAlias, + batch, + fallback = old.relatedUserId, + ) + val updated = old.copy( + category = category, + content = content, + confidence = confidence, + relatedUserId = relatedUserId, + lastConfirmedAt = evidenceTime, + ) + items[old.id] = updated + applied += updated.toApplied(operation.action, operation.evidenceRefs) + } + + ProfileOperationAction.CONFIRM -> { + val old = requireExistingItem(index, operation, items) + val updated = old.copy( + confidence = operation.confidence ?: old.confidence, + lastConfirmedAt = evidenceTime, + ) + items[old.id] = updated + applied += updated.toApplied(operation.action, operation.evidenceRefs) + } + + ProfileOperationAction.DELETE -> { + val old = requireExistingItem(index, operation, items) + items.remove(old.id) + applied += old.toApplied(operation.action, operation.evidenceRefs) + } + } + } + + val summary = if (applied.isEmpty()) { + current.summary + } else { + response.summary.trim().ifEmpty { current.summary } + } + val changed = applied.isNotEmpty() || summary != current.summary + val profile = current.copy( + summary = summary, + version = current.version + if (changed) 1 else 0, + cursorTime = if (advanceBackfillCursor) batch.endTime else current.cursorTime, + reliable = items.isNotEmpty(), + model = model, + promptVersion = promptVersion, + updatedAt = System.currentTimeMillis(), + items = items.values.sortedWith( + compareBy<UserProfileItem>({ it.category.ordinal }, { it.firstSeenAt }, { it.id }) + ), + ) + return ProfileReduction(profile, applied) + } + + private fun validateContent(index: Int, raw: String?): String { + val content = raw?.trim().orEmpty() + require(content.isNotEmpty()) { "operations[$index] 缺少 content" } + require(content.length <= 120) { "operations[$index].content 超过 120 字符" } + require(!overclaimPattern.containsMatchIn(content)) { + "operations[$index].content 包含夸张身份或能力判断" + } + return content + } + + private fun requireExistingItem( + index: Int, + operation: ProfileModelOperation, + items: Map<String, UserProfileItem>, + ): UserProfileItem { + val itemId = operation.itemId?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("operations[$index] ${operation.action} 缺少 item_id") + return items[itemId] + ?: throw IllegalArgumentException("operations[$index] 指向不存在的画像条目 $itemId") + } + + private fun resolveRelatedUser( + index: Int, + category: ProfileCategory, + alias: String?, + batch: ProfileHistoryBatch, + fallback: Long? = null, + ): Long? { + if (category != ProfileCategory.RELATIONSHIP_NOTE) return null + if (alias.isNullOrBlank()) { + return fallback ?: throw IllegalArgumentException( + "operations[$index] relationship_note 缺少 related_user_alias" + ) + } + val related = batch.aliasToUserId[alias] + ?: throw IllegalArgumentException("operations[$index] 的关联用户别名不存在: $alias") + require(related != batch.userId) { "operations[$index] 不能建立指向自己的关系条目" } + return related + } + + private fun UserProfileItem.toApplied( + action: ProfileOperationAction, + evidenceRefs: List<Int>, + ) = AppliedProfileOperation( + action = action, + itemId = id, + category = category, + content = content, + confidence = confidence, + relatedUserId = relatedUserId, + evidenceRefs = evidenceRefs.distinct(), + ) + + private fun normalize(value: String): String = value + .lowercase() + .replace(Regex("[\\s\\p{Punct},。;、!?()【】‘’“”]+"), "") +} diff --git a/src/main/kotlin/profile/UserProfileStore.kt b/src/main/kotlin/profile/UserProfileStore.kt new file mode 100644 index 0000000..24f94c1 --- /dev/null +++ b/src/main/kotlin/profile/UserProfileStore.kt @@ -0,0 +1,473 @@ +package top.jie65535.mirai.profile + +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import java.io.File +import java.sql.Connection +import java.sql.DriverManager +import java.sql.ResultSet + +object UserProfileStore { + private const val SCHEMA_VERSION = 2 + private const val BUSY_TIMEOUT_MS = 30_000 + + private val lifecycleLock = Any() + private val writeLock = Any() + private val json = Json { encodeDefaults = true } + private val operationListSerializer = ListSerializer(AppliedProfileOperation.serializer()) + + @Volatile + private var initialized = false + private lateinit var databaseFile: File + private var writeConnection: Connection? = null + + val isAvailable: Boolean + get() = initialized + + fun init(dataFolder: File) { + synchronized(lifecycleLock) { + if (initialized) return + Class.forName("org.sqlite.JDBC") + dataFolder.mkdirs() + databaseFile = dataFolder.resolve("user-profile.sqlite") + val connection = openConnection() + try { + configureWriteConnection(connection) + createSchema(connection) + writeConnection = connection + initialized = true + } catch (cause: Throwable) { + connection.close() + throw cause + } + } + } + + fun close() { + synchronized(lifecycleLock) { + if (!initialized) return + synchronized(writeLock) { + writeConnection?.let { connection -> + runCatching { + connection.createStatement().use { statement -> + statement.execute("PRAGMA wal_checkpoint(TRUNCATE)") + } + } + connection.close() + } + writeConnection = null + initialized = false + } + } + } + + fun load(userId: Long): UserProfileSnapshot? { + check(initialized) { "用户画像数据库尚未初始化" } + return openReadConnection().use { connection -> + val profileRow = connection.prepareStatement( + """ + SELECT user_id, summary, version, cursor_time, snapshot_end_time, + reliable, model, prompt_version, updated_at + FROM user_profile + WHERE user_id = ? + """.trimIndent() + ).use { statement -> + statement.setLong(1, userId) + statement.executeQuery().use { results -> + if (results.next()) results.toProfileWithoutItems() else null + } + } ?: return@use null + + val items = connection.prepareStatement( + """ + SELECT item_id, category, content, confidence, related_user_id, + first_seen_at, last_confirmed_at + FROM profile_item + WHERE user_id = ? + ORDER BY category, first_seen_at, item_id + """.trimIndent() + ).use { statement -> + statement.setLong(1, userId) + statement.executeQuery().use { results -> + buildList { + while (results.next()) add(results.toProfileItem()) + } + } + } + profileRow.copy(items = items) + } + } + + fun commit( + reduction: ProfileReduction, + batch: ProfileHistoryBatch, + usage: ProfileTokenUsage, + source: ProfileRevisionSource = ProfileRevisionSource.BACKFILL, + ) = commitAll(listOf(reduction to batch), usage, source) + + fun commitConversation( + reductions: List<Pair<ProfileReduction, ProfileHistoryBatch>>, + usage: ProfileTokenUsage, + ) = commitAll(reductions, usage, ProfileRevisionSource.CONVERSATION) + + fun isConversationProcessed(inputHash: String): Boolean { + check(initialized) { "用户画像数据库尚未初始化" } + return openReadConnection().use { connection -> + connection.prepareStatement( + "SELECT 1 FROM profile_revision WHERE source = 'conversation' AND input_hash = ? LIMIT 1" + ).use { statement -> + statement.setString(1, inputHash) + statement.executeQuery().use { it.next() } + } + } + } + + private fun commitAll( + entries: List<Pair<ProfileReduction, ProfileHistoryBatch>>, + usage: ProfileTokenUsage, + source: ProfileRevisionSource, + ) { + check(initialized) { "用户画像数据库尚未初始化" } + if (entries.isEmpty()) return + withWriteConnection { connection -> + val oldAutoCommit = connection.autoCommit + connection.autoCommit = false + try { + entries.forEachIndexed { index, (reduction, batch) -> + persist( + connection = connection, + reduction = reduction, + batch = batch, + usage = if (index == 0) usage else ProfileTokenUsage(), + source = source, + ) + } + connection.commit() + } catch (cause: Throwable) { + connection.rollback() + throw cause + } finally { + connection.autoCommit = oldAutoCommit + } + } + } + + private fun persist( + connection: Connection, + reduction: ProfileReduction, + batch: ProfileHistoryBatch, + usage: ProfileTokenUsage, + source: ProfileRevisionSource, + ) { + val profile = reduction.profile + connection.prepareStatement( + """ + INSERT INTO user_profile( + user_id, summary, version, cursor_time, snapshot_end_time, + reliable, model, prompt_version, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + summary = excluded.summary, + version = excluded.version, + cursor_time = excluded.cursor_time, + snapshot_end_time = excluded.snapshot_end_time, + reliable = excluded.reliable, + model = excluded.model, + prompt_version = excluded.prompt_version, + updated_at = excluded.updated_at + """.trimIndent() + ).use { statement -> + statement.setLong(1, profile.userId) + statement.setString(2, profile.summary) + statement.setInt(3, profile.version) + statement.setInt(4, profile.cursorTime) + statement.setInt(5, profile.snapshotEndTime) + statement.setInt(6, if (profile.reliable) 1 else 0) + statement.setString(7, profile.model) + statement.setString(8, profile.promptVersion) + statement.setLong(9, profile.updatedAt) + statement.executeUpdate() + } + + connection.prepareStatement("DELETE FROM profile_item WHERE user_id = ?").use { statement -> + statement.setLong(1, profile.userId) + statement.executeUpdate() + } + connection.prepareStatement( + """ + INSERT INTO profile_item( + item_id, user_id, category, content, confidence, related_user_id, + first_seen_at, last_confirmed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { statement -> + profile.items.forEach { item -> + statement.setString(1, item.id) + statement.setLong(2, profile.userId) + statement.setString(3, item.category.toStorageValue()) + statement.setString(4, item.content) + statement.setString(5, item.confidence.toStorageValue()) + if (item.relatedUserId == null) statement.setNull(6, java.sql.Types.BIGINT) + else statement.setLong(6, item.relatedUserId) + statement.setInt(7, item.firstSeenAt) + statement.setInt(8, item.lastConfirmedAt) + statement.addBatch() + } + statement.executeBatch() + } + + connection.prepareStatement( + """ + INSERT INTO profile_support( + item_id, user_id, group_ids, start_time, end_time, + input_hash, action, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { statement -> + reduction.operations.forEach { operation -> + val evidence = operation.evidenceRefs.mapNotNull(batch.evidenceByRef::get) + val startTime = evidence.minOf { it.record.time } + val endTime = evidence.maxOf { it.record.time }.safeNextSecond() + val groupIds = evidence.map { it.record.targetId }.distinct().sorted().joinToString(",") + statement.setString(1, operation.itemId) + statement.setLong(2, profile.userId) + statement.setString(3, groupIds) + statement.setInt(4, startTime) + statement.setInt(5, endTime) + statement.setString(6, batch.inputHash) + statement.setString(7, operation.action.name) + statement.setLong(8, System.currentTimeMillis()) + statement.addBatch() + } + statement.executeBatch() + } + + connection.prepareStatement( + """ + INSERT INTO profile_revision( + user_id, profile_version, source, start_time, end_time, input_hash, + operations_json, summary, model, prompt_version, + prompt_tokens, completion_tokens, cached_tokens, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { statement -> + statement.setLong(1, profile.userId) + statement.setInt(2, profile.version) + statement.setString(3, source.name.lowercase()) + statement.setInt(4, batch.startTime) + statement.setInt(5, batch.endTime) + statement.setString(6, batch.inputHash) + statement.setString(7, json.encodeToString(operationListSerializer, reduction.operations)) + statement.setString(8, profile.summary) + statement.setString(9, profile.model) + statement.setString(10, profile.promptVersion) + statement.setInt(11, usage.promptTokens) + statement.setInt(12, usage.completionTokens) + statement.setInt(13, usage.cachedTokens) + statement.setLong(14, System.currentTimeMillis()) + statement.executeUpdate() + } + } + + fun lastRevisionAt(userId: Long, source: ProfileRevisionSource): Long? { + check(initialized) { "用户画像数据库尚未初始化" } + return openReadConnection().use { connection -> + connection.prepareStatement( + "SELECT MAX(created_at) FROM profile_revision WHERE user_id = ? AND source = ?" + ).use { statement -> + statement.setLong(1, userId) + statement.setString(2, source.name.lowercase()) + statement.executeQuery().use { results -> + if (!results.next()) return@use null + results.getLong(1).let { if (results.wasNull()) null else it } + } + } + } + } + + private fun createSchema(connection: Connection) { + val oldAutoCommit = connection.autoCommit + connection.autoCommit = false + try { + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS user_profile( + user_id INTEGER PRIMARY KEY, + summary TEXT NOT NULL, + version INTEGER NOT NULL, + cursor_time INTEGER NOT NULL, + snapshot_end_time INTEGER NOT NULL, + reliable INTEGER NOT NULL, + model TEXT NOT NULL, + prompt_version TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + """.trimIndent() + ) + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS profile_item( + item_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + category TEXT NOT NULL, + content TEXT NOT NULL, + confidence TEXT NOT NULL, + related_user_id INTEGER, + first_seen_at INTEGER NOT NULL, + last_confirmed_at INTEGER NOT NULL + ) + """.trimIndent() + ) + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_profile_item_user ON profile_item(user_id, category)" + ) + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS profile_support( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + group_ids TEXT NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + input_hash TEXT NOT NULL, + action TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """.trimIndent() + ) + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_profile_support_user ON profile_support(user_id, start_time)" + ) + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS profile_revision( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + profile_version INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'backfill', + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + input_hash TEXT NOT NULL, + operations_json TEXT NOT NULL, + summary TEXT NOT NULL, + model TEXT NOT NULL, + prompt_version TEXT NOT NULL, + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + cached_tokens INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(user_id, start_time, end_time, input_hash) + ) + """.trimIndent() + ) + ensureColumn( + connection = connection, + table = "profile_revision", + column = "source", + definition = "TEXT NOT NULL DEFAULT 'backfill'", + ) + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS user_profile_meta( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """.trimIndent() + ) + } + connection.prepareStatement( + "INSERT INTO user_profile_meta(key, value) VALUES ('schema_version', ?) " + + "ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ).use { statement -> + statement.setString(1, SCHEMA_VERSION.toString()) + statement.executeUpdate() + } + connection.commit() + } catch (cause: Throwable) { + connection.rollback() + throw cause + } finally { + connection.autoCommit = oldAutoCommit + } + } + + private fun configureWriteConnection(connection: Connection) { + connection.createStatement().use { statement -> + statement.execute("PRAGMA journal_mode=WAL") + statement.execute("PRAGMA synchronous=NORMAL") + statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS") + statement.execute("PRAGMA wal_autocheckpoint=1000") + } + } + + private fun ensureColumn( + connection: Connection, + table: String, + column: String, + definition: String, + ) { + val exists = connection.createStatement().use { statement -> + statement.executeQuery("PRAGMA table_info($table)").use { results -> + var found = false + while (results.next()) { + if (results.getString("name") == column) found = true + } + found + } + } + if (!exists) { + connection.createStatement().use { statement -> + statement.executeUpdate("ALTER TABLE $table ADD COLUMN $column $definition") + } + } + } + + private fun openConnection(): Connection = + DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}") + + private fun openReadConnection(): Connection = openConnection().also { connection -> + connection.createStatement().use { statement -> + statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS") + statement.execute("PRAGMA query_only=ON") + } + } + + private fun withWriteConnection(block: (Connection) -> Unit) { + synchronized(writeLock) { + val connection = writeConnection?.takeUnless(Connection::isClosed) + ?: openConnection().also { + configureWriteConnection(it) + writeConnection = it + } + block(connection) + } + } + + private fun ResultSet.toProfileWithoutItems() = UserProfileSnapshot( + userId = getLong("user_id"), + summary = getString("summary"), + version = getInt("version"), + cursorTime = getInt("cursor_time"), + snapshotEndTime = getInt("snapshot_end_time"), + reliable = getInt("reliable") != 0, + model = getString("model"), + promptVersion = getString("prompt_version"), + updatedAt = getLong("updated_at"), + ) + + private fun ResultSet.toProfileItem() = UserProfileItem( + id = getString("item_id"), + category = ProfileCategory.valueOf(getString("category").uppercase()), + content = getString("content"), + confidence = ProfileConfidence.valueOf(getString("confidence").uppercase()), + relatedUserId = getLong("related_user_id").let { if (wasNull()) null else it }, + firstSeenAt = getInt("first_seen_at"), + lastConfirmedAt = getInt("last_confirmed_at"), + ) + + private fun ProfileCategory.toStorageValue(): String = name.lowercase() + private fun ProfileConfidence.toStorageValue(): String = name.lowercase() + private fun Int.safeNextSecond(): Int = if (this == Int.MAX_VALUE) this else this + 1 +} diff --git a/src/main/kotlin/tools/AdjustUserFavorabilityAgent.kt b/src/main/kotlin/tools/AdjustUserFavorabilityAgent.kt index 2130480..45cb75e 100644 --- a/src/main/kotlin/tools/AdjustUserFavorabilityAgent.kt +++ b/src/main/kotlin/tools/AdjustUserFavorabilityAgent.kt @@ -15,8 +15,8 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginData -import top.jie65535.mirai.FavorabilityInfo +import top.jie65535.mirai.data.PluginData +import top.jie65535.mirai.data.FavorabilityInfo import java.time.OffsetDateTime import java.time.format.DateTimeFormatter diff --git a/src/main/kotlin/tools/DeleteSkill.kt b/src/main/kotlin/tools/DeleteSkill.kt index 3155b35..87ffaf6 100644 --- a/src/main/kotlin/tools/DeleteSkill.kt +++ b/src/main/kotlin/tools/DeleteSkill.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig -import top.jie65535.mirai.SkillStore +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.SkillStore /** * 删除一个过时或失效的技能。 diff --git a/src/main/kotlin/tools/GroupManageAgent.kt b/src/main/kotlin/tools/GroupManageAgent.kt index c4e0dd4..ce67d08 100644 --- a/src/main/kotlin/tools/GroupManageAgent.kt +++ b/src/main/kotlin/tools/GroupManageAgent.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.contact.MemberPermission import net.mamoe.mirai.event.events.GroupMessageEvent import net.mamoe.mirai.event.events.MessageEvent -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig import kotlin.time.Duration.Companion.seconds class GroupManageAgent : BaseAgent( @@ -65,4 +65,4 @@ class GroupManageAgent : BaseAgent( member.mute(duration.coerceIn(1, 10) * 60) return "已禁言目标" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/ImageAgent.kt b/src/main/kotlin/tools/ImageAgent.kt index 33bacd0..34402e6 100644 --- a/src/main/kotlin/tools/ImageAgent.kt +++ b/src/main/kotlin/tools/ImageAgent.kt @@ -22,7 +22,7 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig class ImageAgent : BaseAgent( tool = Tool.function( diff --git a/src/main/kotlin/tools/LoadSkill.kt b/src/main/kotlin/tools/LoadSkill.kt index 9a18375..45e4475 100644 --- a/src/main/kotlin/tools/LoadSkill.kt +++ b/src/main/kotlin/tools/LoadSkill.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig -import top.jie65535.mirai.SkillStore +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.SkillStore /** * 按需加载某个技能的正文进上下文。技能索引(name+简介)常驻系统提示词, diff --git a/src/main/kotlin/tools/MemoryAppend.kt b/src/main/kotlin/tools/MemoryAppend.kt index 18ceef8..6b7add4 100644 --- a/src/main/kotlin/tools/MemoryAppend.kt +++ b/src/main/kotlin/tools/MemoryAppend.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig -import top.jie65535.mirai.PluginData +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.PluginData class MemoryAppend : BaseAgent( tool = Tool.function( @@ -42,4 +42,4 @@ class MemoryAppend : BaseAgent( PluginData.appendContactMemory(contactId, memoryText) return "OK" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/MemoryReplace.kt b/src/main/kotlin/tools/MemoryReplace.kt index 98f1d27..a64815d 100644 --- a/src/main/kotlin/tools/MemoryReplace.kt +++ b/src/main/kotlin/tools/MemoryReplace.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig -import top.jie65535.mirai.PluginData +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.PluginData class MemoryReplace : BaseAgent( tool = Tool.function( @@ -48,4 +48,4 @@ class MemoryReplace : BaseAgent( PluginData.replaceContactMemory(contactId, oldMemoryText, newMemoryText) return "OK" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/ReasoningAgent.kt b/src/main/kotlin/tools/ReasoningAgent.kt index 76eecc5..c3a8c55 100644 --- a/src/main/kotlin/tools/ReasoningAgent.kt +++ b/src/main/kotlin/tools/ReasoningAgent.kt @@ -6,8 +6,8 @@ import com.aallam.openai.api.chat.Tool import com.aallam.openai.api.core.Parameters import com.aallam.openai.api.model.ModelId import kotlinx.serialization.json.* -import top.jie65535.mirai.LargeLanguageModels -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.llm.LargeLanguageModels class ReasoningAgent : BaseAgent( tool = Tool.function( @@ -63,4 +63,4 @@ class ReasoningAgent : BaseAgent( else -> "推理出错,结果为空" } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/RequestOwner.kt b/src/main/kotlin/tools/RequestOwner.kt index 728b1c5..b45160d 100644 --- a/src/main/kotlin/tools/RequestOwner.kt +++ b/src/main/kotlin/tools/RequestOwner.kt @@ -16,7 +16,7 @@ import net.mamoe.mirai.event.events.MessageEvent import net.mamoe.mirai.event.nextEvent import net.mamoe.mirai.message.data.content import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig import kotlin.collections.getValue class RequestOwner : BaseAgent( @@ -54,4 +54,4 @@ class RequestOwner : BaseAgent( JChatGPT.logger.info("主人回复:$response") return response } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/RunCode.kt b/src/main/kotlin/tools/RunCode.kt index 6d03cf2..c7c2d2f 100644 --- a/src/main/kotlin/tools/RunCode.kt +++ b/src/main/kotlin/tools/RunCode.kt @@ -6,7 +6,7 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.serialization.json.* -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig class RunCode : BaseAgent( tool = Tool.function( @@ -105,4 +105,4 @@ class RunCode : BaseAgent( } return response.bodyAsText() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/SaveSkill.kt b/src/main/kotlin/tools/SaveSkill.kt index 4f7ed6b..28f5117 100644 --- a/src/main/kotlin/tools/SaveSkill.kt +++ b/src/main/kotlin/tools/SaveSkill.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig -import top.jie65535.mirai.SkillStore +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.SkillStore /** * 新增或整篇覆盖一个技能(全局,跨群共享)。 diff --git a/src/main/kotlin/tools/SearchChatHistory.kt b/src/main/kotlin/tools/SearchChatHistory.kt index ea944e5..f1ed14c 100644 --- a/src/main/kotlin/tools/SearchChatHistory.kt +++ b/src/main/kotlin/tools/SearchChatHistory.kt @@ -12,9 +12,9 @@ import net.mamoe.mirai.message.data.Image.Key.queryUrl import net.mamoe.mirai.message.data.SingleMessage import net.mamoe.mirai.message.data.content import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig -import top.jie65535.mirai.ChatHistoryStore -import top.jie65535.mirai.ChatMessageRecord +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.data.ChatHistoryStore +import top.jie65535.mirai.data.ChatMessageRecord import java.time.Instant import java.time.LocalDateTime import java.time.OffsetDateTime diff --git a/src/main/kotlin/tools/SendCompositeMessage.kt b/src/main/kotlin/tools/SendCompositeMessage.kt index 8aebd8e..f2715ee 100644 --- a/src/main/kotlin/tools/SendCompositeMessage.kt +++ b/src/main/kotlin/tools/SendCompositeMessage.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.json.putJsonObject import net.mamoe.mirai.event.events.MessageEvent import net.mamoe.mirai.message.data.buildForwardMessage import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig import kotlin.collections.getValue class SendCompositeMessage : BaseAgent( @@ -47,4 +47,4 @@ class SendCompositeMessage : BaseAgent( ) return "OK" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/SendLaTeXExpression.kt b/src/main/kotlin/tools/SendLaTeXExpression.kt index 3fe8650..c50b069 100644 --- a/src/main/kotlin/tools/SendLaTeXExpression.kt +++ b/src/main/kotlin/tools/SendLaTeXExpression.kt @@ -4,7 +4,7 @@ import com.aallam.openai.api.chat.Tool import com.aallam.openai.api.core.Parameters import kotlinx.serialization.json.* import net.mamoe.mirai.event.events.MessageEvent -import top.jie65535.mirai.LaTeXConverter +import top.jie65535.mirai.media.LaTeXConverter import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource class SendLaTeXExpression : BaseAgent( @@ -43,4 +43,4 @@ class SendLaTeXExpression : BaseAgent( return "处理LaTeX表达式时发生异常: ${ex.message}" } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/SendVoiceMessage.kt b/src/main/kotlin/tools/SendVoiceMessage.kt index 30aacc7..0deffa9 100644 --- a/src/main/kotlin/tools/SendVoiceMessage.kt +++ b/src/main/kotlin/tools/SendVoiceMessage.kt @@ -10,7 +10,7 @@ import net.mamoe.mirai.contact.AudioSupported import net.mamoe.mirai.event.events.MessageEvent import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig import java.io.File import java.util.concurrent.TimeUnit import kotlin.time.measureTime @@ -146,4 +146,4 @@ class SendVoiceMessage : BaseAgent( JChatGPT.logger.info("转换音频耗时 $convertDuration") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/VisitWeb.kt b/src/main/kotlin/tools/VisitWeb.kt index 6837bc3..c13b242 100644 --- a/src/main/kotlin/tools/VisitWeb.kt +++ b/src/main/kotlin/tools/VisitWeb.kt @@ -7,7 +7,7 @@ import io.ktor.client.statement.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.serialization.json.* -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig class VisitWeb : BaseAgent( tool = Tool.function( @@ -70,4 +70,4 @@ class VisitWeb : BaseAgent( "Error fetching \"$url\": ${e.message}" } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/tools/VisualAgent.kt b/src/main/kotlin/tools/VisualAgent.kt index 43fa23b..8e984dd 100644 --- a/src/main/kotlin/tools/VisualAgent.kt +++ b/src/main/kotlin/tools/VisualAgent.kt @@ -23,8 +23,8 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.LargeLanguageModels -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig +import top.jie65535.mirai.llm.LargeLanguageModels import java.net.URI class VisualAgent : BaseAgent( diff --git a/src/main/kotlin/tools/WeatherService.kt b/src/main/kotlin/tools/WeatherService.kt index 51aaffa..1dbe652 100644 --- a/src/main/kotlin/tools/WeatherService.kt +++ b/src/main/kotlin/tools/WeatherService.kt @@ -14,7 +14,7 @@ import net.i2p.crypto.eddsa.EdDSAEngine import net.i2p.crypto.eddsa.EdDSAPrivateKey import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.security.spec.PKCS8EncodedKeySpec diff --git a/src/main/kotlin/tools/WebSearch.kt b/src/main/kotlin/tools/WebSearch.kt index 998e4ae..0c9b276 100644 --- a/src/main/kotlin/tools/WebSearch.kt +++ b/src/main/kotlin/tools/WebSearch.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.serialization.json.* import org.apache.commons.text.StringEscapeUtils import top.jie65535.mirai.JChatGPT -import top.jie65535.mirai.PluginConfig +import top.jie65535.mirai.config.PluginConfig class WebSearch : BaseAgent( tool = Tool.function( @@ -114,4 +114,4 @@ class WebSearch : BaseAgent( "Failed to search \"$q\": ${e.message}" } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/conversation/ConversationContextTest.kt b/src/test/kotlin/conversation/ConversationContextTest.kt new file mode 100644 index 0000000..4ac4333 --- /dev/null +++ b/src/test/kotlin/conversation/ConversationContextTest.kt @@ -0,0 +1,35 @@ +package top.jie65535.mirai.conversation + +import net.mamoe.mirai.message.data.MessageSourceKind +import top.jie65535.mirai.data.ChatMessageRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConversationContextTest { + @Test + fun replyIndexKeepsStableNumbersForStoredMessageIds() { + val index = ReplyIndex() + val first = record(ids = "10,20", time = 100) + val duplicate = record(ids = "10,20", time = 101) + val withoutIds = record(ids = null, time = 102) + + assertEquals(1, index.add(first)) + assertEquals(1, index.add(duplicate)) + assertEquals(2, index.add(withoutIds)) + assertEquals(first, index.get(1)) + assertEquals(1, index.indexOfIds("10,20")) + assertNull(index.indexOfIds("missing")) + } + + private fun record(ids: String?, time: Int) = ChatMessageRecord( + botId = 1, + fromId = 2, + targetId = 3, + ids = ids, + internalIds = null, + time = time, + kind = MessageSourceKind.GROUP, + code = "[]", + ) +} diff --git a/src/test/kotlin/ChatHistoryStoreTest.kt b/src/test/kotlin/data/ChatHistoryStoreTest.kt similarity index 98% rename from src/test/kotlin/ChatHistoryStoreTest.kt rename to src/test/kotlin/data/ChatHistoryStoreTest.kt index c1364c3..e734e11 100644 --- a/src/test/kotlin/ChatHistoryStoreTest.kt +++ b/src/test/kotlin/data/ChatHistoryStoreTest.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.data import java.nio.file.Files import java.sql.DriverManager diff --git a/src/test/kotlin/llm/ModelServiceTest.kt b/src/test/kotlin/llm/ModelServiceTest.kt new file mode 100644 index 0000000..71fd27b --- /dev/null +++ b/src/test/kotlin/llm/ModelServiceTest.kt @@ -0,0 +1,38 @@ +package top.jie65535.mirai.llm + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +class ModelServiceTest { + private val service = ModelService( + baseUrl = "http://localhost/", + token = "test", + timeout = 1.seconds, + firstChunkTimeout = 1.seconds, + ) + + @Test + fun readsStandardCachedPromptTokens() { + val usage = service.extractCacheUsage( + """{"usage":{"prompt_tokens":100,"prompt_tokens_details":{"cached_tokens":75}}}""" + ) + + assertEquals(ModelService.CacheUsage(hitTokens = 75, missTokens = 25), usage) + } + + @Test + fun readsDeepSeekCacheTokens() { + val usage = service.extractCacheUsage( + """{"usage":{"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}""" + ) + + assertEquals(ModelService.CacheUsage(hitTokens = 80, missTokens = 20), usage) + } + + @Test + fun ignoresResponsesWithoutCacheDetails() { + assertNull(service.extractCacheUsage("""{"usage":{"prompt_tokens":100}}""")) + } +} diff --git a/src/test/kotlin/ImageIndexTest.kt b/src/test/kotlin/media/ImageIndexTest.kt similarity index 96% rename from src/test/kotlin/ImageIndexTest.kt rename to src/test/kotlin/media/ImageIndexTest.kt index abe4bdb..f06f94e 100644 --- a/src/test/kotlin/ImageIndexTest.kt +++ b/src/test/kotlin/media/ImageIndexTest.kt @@ -1,4 +1,4 @@ -package top.jie65535.mirai +package top.jie65535.mirai.media import kotlin.test.Test import kotlin.test.assertEquals diff --git a/src/test/kotlin/profile/ConversationProfileLiveExperiment.kt b/src/test/kotlin/profile/ConversationProfileLiveExperiment.kt new file mode 100644 index 0000000..1a96a8a --- /dev/null +++ b/src/test/kotlin/profile/ConversationProfileLiveExperiment.kt @@ -0,0 +1,119 @@ +package top.jie65535.mirai.profile + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import top.jie65535.mirai.llm.LargeLanguageModels +import top.jie65535.mirai.llm.ModelService +import java.io.File +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class ConversationProfileLiveExperiment { + @Test + fun analyzeConfiguredHistoricalConversation() = runBlocking { + val apiKey = System.getenv("PROFILE_EXPERIMENT_API_KEY") ?: return@runBlocking + val source = File(checkNotNull(System.getenv("PROFILE_EXPERIMENT_SOURCE"))) + val botId = checkNotNull(System.getenv("PROFILE_EXPERIMENT_BOT_ID")).toLong() + val groupId = checkNotNull(System.getenv("PROFILE_EXPERIMENT_GROUP_ID")).toLong() + val startTime = checkNotNull(System.getenv("PROFILE_EXPERIMENT_START")).toInt() + val endTime = checkNotNull(System.getenv("PROFILE_EXPERIMENT_END")).toInt() + val output = File(checkNotNull(System.getenv("PROFILE_EXPERIMENT_OUTPUT"))) + val modelName = checkNotNull(System.getenv("PROFILE_EXPERIMENT_MODEL")) + val baseUrl = checkNotNull(System.getenv("PROFILE_EXPERIMENT_API")) + val messageLimit = System.getenv("PROFILE_EXPERIMENT_MESSAGE_LIMIT")?.toInt() ?: 150 + val firstChunkTimeout = System.getenv("PROFILE_EXPERIMENT_FIRST_CHUNK_TIMEOUT")?.toInt() ?: 240 + + val batch = checkNotNull( + ProfileHistoryReader(source).loadConversationBatch( + botId = botId, + groupId = groupId, + startTime = startTime, + endTime = endTime, + messageLimit = messageLimit, + maxMessageChars = 1_000, + ) + ) + val eligible = batch.authoredTextCharsByUser.filterValues { it >= 20 }.keys + check(eligible.isNotEmpty()) { "所选会话没有达到文本门槛的参与者" } + val profiles = eligible.associateWith { userId -> + UserProfileSnapshot(userId = userId, cursorTime = 0, snapshotEndTime = 0) + } + val service = ModelService( + baseUrl = baseUrl, + token = apiKey, + timeout = maxOf(180, firstChunkTimeout).seconds, + firstChunkTimeout = firstChunkTimeout.seconds, + ) + try { + val client = ProfileModelClient( + LargeLanguageModels.ProfileEndpoint(service, modelName, temperature = 0.1) + ) + val result = client.analyzeConversation(profiles, batch, eligible) + val reductions = ConversationProfileReducer.reduce( + profiles = profiles, + batch = batch, + eligibleUserIds = eligible, + response = result.response, + model = modelName, + promptVersion = ProfilePromptStore.PROMPT_VERSION, + summaryMaxLength = 500, + ) + val names = batch.aliases.mapValues { (userId, alias) -> "$alias/$userId" } + val injection = UserProfileContextRenderer.render( + profiles = reductions.map { it.profile }, + favorabilityByUserId = emptyMap(), + displayNames = names, + activeUserIds = eligible, + summaryMaxChars = 300, + ) + + output.parentFile?.mkdirs() + output.writeText( + buildString { + appendLine("# 多人会话画像真实数据实验") + appendLine() + appendLine("- group: $groupId") + appendLine("- range: [$startTime, $endTime)") + appendLine("- messages: ${batch.messages.size}") + appendLine("- eligible_users: ${eligible.size}") + appendLine("- operations: ${reductions.sumOf { it.operations.size }}") + appendLine("- tokens: ${result.usage.promptTokens}/${result.usage.completionTokens}, cached=${result.usage.cachedTokens}") + appendLine() + appendLine("## 模型结构化输出") + appendLine() + appendLine("```json") + appendLine(prettyJson.encodeToString(result.response)) + appendLine("```") + appendLine() + appendLine("## 证据核对") + reductions.flatMap { reduction -> + reduction.operations.map { operation -> reduction.profile.userId to operation } + }.forEach { (userId, operation) -> + appendLine() + appendLine("- ${batch.aliases[userId]}/$userId: ${operation.action} ${operation.category} ${operation.content}") + operation.evidenceRefs.forEach { ref -> + val evidence = checkNotNull(batch.evidenceByRef[ref]) + append(" - [e:").append(ref).append("][") + .append(batch.aliases[evidence.record.fromId]).append("] ") + .appendLine(evidence.text.replace('\n', ' ')) + } + } + appendLine() + appendLine("## 实际注入文本") + appendLine() + appendLine("```") + append(injection) + appendLine("```") + }, + Charsets.UTF_8, + ) + } finally { + service.httpClient.close() + } + } + + companion object { + private val prettyJson = Json { prettyPrint = true } + } +} diff --git a/src/test/kotlin/profile/ConversationProfileReducerTest.kt b/src/test/kotlin/profile/ConversationProfileReducerTest.kt new file mode 100644 index 0000000..6d2dc7b --- /dev/null +++ b/src/test/kotlin/profile/ConversationProfileReducerTest.kt @@ -0,0 +1,223 @@ +package top.jie65535.mirai.profile + +import net.mamoe.mirai.message.data.MessageSourceKind +import top.jie65535.mirai.data.ChatMessageRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ConversationProfileReducerTest { + @Test + fun reducesMultipleUsersFromOneConversation() { + val batch = batch() + val profiles = USERS.associateWith(::emptyProfile) + + val reductions = ConversationProfileReducer.reduce( + profiles = profiles, + batch = batch, + eligibleUserIds = USERS, + response = ConversationProfileModelResponse( + users = listOf( + ConversationProfileUserResponse( + userAlias = "U1", + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.NOTABLE_FACT, + content = "日常使用 Kotlin 开发", + confidence = ProfileConfidence.MEDIUM, + evidenceRefs = listOf(1), + ) + ), + summary = "日常使用 Kotlin 开发。", + ), + ConversationProfileUserResponse( + userAlias = "U2", + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.INTEREST, + content = "关注本地大模型", + confidence = ProfileConfidence.MEDIUM, + evidenceRefs = listOf(2), + ) + ), + summary = "关注本地大模型。", + ), + ) + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + + assertEquals(2, reductions.size) + assertEquals("日常使用 Kotlin 开发", reductions.single { it.profile.userId == USER_A }.profile.items.single().content) + assertEquals("关注本地大模型", reductions.single { it.profile.userId == USER_B }.profile.items.single().content) + assertTrue(reductions.all { it.profile.cursorTime == 0 }) + } + + @Test + fun rejectsEvidenceAuthoredByAnotherUser() { + val failure = assertFailsWith<IllegalArgumentException> { + ConversationProfileReducer.reduce( + profiles = USERS.associateWith(::emptyProfile), + batch = batch(), + eligibleUserIds = USERS, + response = ConversationProfileModelResponse( + users = listOf( + ConversationProfileUserResponse( + userAlias = "U1", + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.NOTABLE_FACT, + content = "关注本地大模型", + confidence = ProfileConfidence.MEDIUM, + evidenceRefs = listOf(2), + ) + ), + summary = "关注本地大模型。", + ) + ) + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + } + + assertTrue(failure.message.orEmpty().contains("目标用户")) + } + + @Test + fun confirmsAnExistingItemInsteadOfCreatingAnIndependentProfile() { + val oldItem = UserProfileItem( + id = "existing-item", + category = ProfileCategory.INTEREST, + content = "关注 Kotlin 开发", + confidence = ProfileConfidence.LOW, + firstSeenAt = 80, + lastConfirmedAt = 80, + ) + val existing = emptyProfile(USER_A).copy( + summary = "关注 Kotlin 开发。", + version = 1, + reliable = true, + items = listOf(oldItem), + ) + val profiles = mapOf(USER_A to existing, USER_B to emptyProfile(USER_B)) + + val reductions = ConversationProfileReducer.reduce( + profiles = profiles, + batch = batch(), + eligibleUserIds = USERS, + response = ConversationProfileModelResponse( + users = listOf( + ConversationProfileUserResponse( + userAlias = "U1", + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.CONFIRM, + itemId = oldItem.id, + confidence = ProfileConfidence.MEDIUM, + evidenceRefs = listOf(1), + ) + ), + summary = "关注 Kotlin 开发。", + ) + ) + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + + val updated = reductions.single { it.profile.userId == USER_A }.profile + assertEquals(2, updated.version) + assertEquals("existing-item", updated.items.single().id) + assertEquals(ProfileConfidence.MEDIUM, updated.items.single().confidence) + assertTrue( + ProfilePromptStore.buildConversationUserPrompt(profiles, batch(), USERS) + .contains("[P:existing-item]") + ) + } + + @Test + fun rejectsMoreThanFourOperationsForOneUser() { + val operations = (1..5).map { index -> + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.NOTABLE_FACT, + content = "事实 $index", + confidence = ProfileConfidence.LOW, + evidenceRefs = listOf(1), + ) + } + + val failure = assertFailsWith<IllegalArgumentException> { + ConversationProfileReducer.reduce( + profiles = USERS.associateWith(::emptyProfile), + batch = batch(), + eligibleUserIds = USERS, + response = ConversationProfileModelResponse( + users = listOf( + ConversationProfileUserResponse( + userAlias = "U1", + operations = operations, + summary = "包含过多事实。", + ) + ) + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + } + + assertTrue(failure.message.orEmpty().contains("超过 4 项")) + } + + private fun batch() = ConversationProfileBatch( + botId = BOT, + groupId = 10, + startTime = 100, + endTime = 200, + messages = listOf( + message(1, USER_A, "我平时用 Kotlin 写项目"), + message(2, USER_B, "我最近一直在研究本地大模型"), + ), + aliases = mapOf(BOT to "BOT", USER_A to "U1", USER_B to "U2"), + inputHash = "conversation-hash", + ) + + private fun emptyProfile(userId: Long) = UserProfileSnapshot( + userId = userId, + cursorTime = 0, + snapshotEndTime = 0, + ) + + private fun message(ref: Int, fromId: Long, text: String) = ProfilePromptMessage( + record = ChatMessageRecord( + botId = BOT, + fromId = fromId, + targetId = 10, + ids = null, + internalIds = null, + time = 120 + ref, + kind = MessageSourceKind.GROUP, + code = text, + ), + text = text, + evidenceRef = ref, + episodeIndex = 1, + ) + + companion object { + private const val BOT = 1L + private const val USER_A = 100L + private const val USER_B = 200L + private val USERS = setOf(USER_A, USER_B) + } +} diff --git a/src/test/kotlin/profile/ProfileHistoryReaderTest.kt b/src/test/kotlin/profile/ProfileHistoryReaderTest.kt new file mode 100644 index 0000000..1682d1d --- /dev/null +++ b/src/test/kotlin/profile/ProfileHistoryReaderTest.kt @@ -0,0 +1,122 @@ +package top.jie65535.mirai.profile + +import net.mamoe.mirai.message.data.MessageSourceKind +import java.nio.file.Files +import java.sql.DriverManager +import kotlin.io.path.absolutePathString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ProfileHistoryReaderTest { + @Test + fun keepsEqualTimestampTargetMessagesTogetherAndLoadsGroupContext() { + val directory = Files.createTempDirectory("jchatgpt-profile-history-test-") + val database = directory.resolve("history.sqlite") + try { + DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE message_record( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + from_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + ids TEXT, + internal_ids TEXT, + time INTEGER NOT NULL, + kind INTEGER NOT NULL, + code TEXT NOT NULL, + recalled INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + } + connection.prepareStatement( + "INSERT INTO message_record(" + + "bot_id, from_id, target_id, time, kind, code, recalled" + + ") VALUES (1, ?, ?, ?, ?, ?, 0)" + ).use { statement -> + fun insert(fromId: Long, groupId: Long, time: Int, text: String) { + statement.setLong(1, fromId) + statement.setLong(2, groupId) + statement.setInt(3, time) + statement.setInt(4, MessageSourceKind.GROUP.ordinal) + statement.setString(5, """[{"type":"PlainText","content":"$text"}]""") + statement.executeUpdate() + } + insert(TARGET, 10, 100, "目标发言一") + insert(OTHER, 10, 110, "用于理解语境的回复") + insert(TARGET, 10, 130, "目标发言二") + insert(TARGET, 20, 130, "同一秒的另一群发言") + insert(OTHER, 20, 140, "后续上下文") + insert(TARGET, 10, 200, "下一批目标发言") + } + } + + val reader = ProfileHistoryReader(database.toFile()) + val bounds = assertNotNull(reader.findUserTimeBounds(TARGET)) + assertEquals(100, bounds.startTime) + assertEquals(201, bounds.endTime) + + val first = assertNotNull( + reader.loadNextBatch( + userId = TARGET, + startTime = bounds.startTime, + snapshotEndTime = bounds.endTime, + targetMessageLimit = 2, + maxEpisodes = 1, + episodeGapSeconds = 60, + contextBeforeMessages = 2, + contextAfterMessages = 2, + contextCoreMessages = 20, + maxMessageChars = 200, + ) + ) + assertEquals(131, first.endTime) + assertEquals(3, first.evidenceByRef.values.count { it.record.fromId == TARGET }) + assertTrue(first.messages.any { it.record.fromId == OTHER }) + assertEquals("TARGET", first.aliases[TARGET]) + + val second = assertNotNull( + reader.loadNextBatch( + userId = TARGET, + startTime = first.endTime, + snapshotEndTime = bounds.endTime, + targetMessageLimit = 2, + maxEpisodes = 10, + episodeGapSeconds = 60, + contextBeforeMessages = 2, + contextAfterMessages = 2, + contextCoreMessages = 20, + maxMessageChars = 200, + ) + ) + assertEquals(201, second.endTime) + assertEquals(1, second.evidenceByRef.values.count { it.record.fromId == TARGET }) + + val conversation = assertNotNull( + reader.loadConversationBatch( + botId = 1, + groupId = 10, + startTime = 90, + endTime = 150, + messageLimit = 10, + maxMessageChars = 200, + ) + ) + assertTrue(conversation.authoredTextCharsByUser.getValue(TARGET) > 0) + assertTrue(conversation.messages.all { it.record.targetId == 10L }) + assertTrue(conversation.messages.all { it.record.time in 90 until 150 }) + } finally { + directory.toFile().deleteRecursively() + } + } + + companion object { + private const val TARGET = 100L + private const val OTHER = 200L + } +} diff --git a/src/test/kotlin/profile/ProfileMessageRendererTest.kt b/src/test/kotlin/profile/ProfileMessageRendererTest.kt new file mode 100644 index 0000000..dee14bf --- /dev/null +++ b/src/test/kotlin/profile/ProfileMessageRendererTest.kt @@ -0,0 +1,46 @@ +package top.jie65535.mirai.profile + +import net.mamoe.mirai.message.data.MessageSourceKind +import top.jie65535.mirai.data.ChatMessageRecord +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileMessageRendererTest { + @Test + fun rendersStoredJsonWithoutMiraiRuntime() { + val record = record( + """ + [ + {"type":"QuoteReply","source":{"fromId":200,"originalMessage":[{"type":"PlainText","content":"我在公明工作"}]}}, + {"type":"At","target":200}, + {"type":"PlainText","content":" 这说的是你,不是我"}, + {"type":"Image","isEmoji":false} + ] + """.trimIndent() + ) + + assertEquals( + "[引用 U1: 我在公明工作]@U1 这说的是你,不是我[图片]", + ProfileMessageRenderer.render(record, mapOf(100L to "TARGET", 200L to "U1"), 500), + ) + assertEquals(setOf(200L), ProfileMessageRenderer.referencedUserIds(record)) + } + + @Test + fun truncatesLongMessagesAfterRendering() { + val record = record("""[{"type":"PlainText","content":"1234567890"}]""") + + assertEquals("12345678...[截断]", ProfileMessageRenderer.render(record, emptyMap(), 8)) + } + + private fun record(code: String) = ChatMessageRecord( + botId = 1, + fromId = 100, + targetId = 10, + ids = null, + internalIds = null, + time = 100, + kind = MessageSourceKind.GROUP, + code = code, + ) +} diff --git a/src/test/kotlin/profile/UserProfileContextRendererTest.kt b/src/test/kotlin/profile/UserProfileContextRendererTest.kt new file mode 100644 index 0000000..90ad31d --- /dev/null +++ b/src/test/kotlin/profile/UserProfileContextRendererTest.kt @@ -0,0 +1,91 @@ +package top.jie65535.mirai.profile + +import top.jie65535.mirai.data.FavorabilityInfo +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse + +class UserProfileContextRendererTest { + @Test + fun rendersCompactSummariesAndOnlyRelationshipsInsideCurrentConversation() { + val profile = UserProfileSnapshot( + userId = 100, + summary = "长期关注 Kotlin 开发,也经常讨论大模型应用。", + cursorTime = 1, + snapshotEndTime = 2, + reliable = true, + items = listOf( + relationship("visible", 200, "经常互相讨论技术方案"), + relationship("hidden", 300, "曾共同讨论游戏"), + ), + ) + + val rendered = UserProfileContextRenderer.render( + profiles = listOf(profile), + favorabilityByUserId = mapOf( + 100L to FavorabilityInfo( + userId = 100, + value = 12, + name = "小明代号", + tags = listOf("老群友"), + impression = "聊天很直接", + ) + ), + displayNames = mapOf(100L to "小明", 200L to "小王", 300L to "小李"), + activeUserIds = setOf(100, 200), + summaryMaxChars = 50, + ) + + assertContains(rendered, "小明代号(100)") + assertContains(rendered, "好感度+12") + assertContains(rendered, "长期认识:长期关注 Kotlin 开发") + assertContains(rendered, "与小王:经常互相讨论技术方案") + assertFalse(rendered.contains("小李")) + assertFalse(rendered.contains("曾共同讨论游戏")) + } + + @Test + fun keepsFavorabilityVisibleWithoutAHistoricalProfile() { + val rendered = UserProfileContextRenderer.render( + profiles = emptyList(), + favorabilityByUserId = mapOf( + 100L to FavorabilityInfo(userId = 100, value = -8, impression = "偶尔喜欢抬杠") + ), + displayNames = mapOf(100L to "小明"), + activeUserIds = setOf(100), + summaryMaxChars = 300, + ) + + assertContains(rendered, "小明(100)") + assertContains(rendered, "好感度-8") + assertContains(rendered, "主观印象:偶尔喜欢抬杠") + assertFalse(rendered.contains("长期认识:")) + } + + @Test + fun keepsImpressionVisibleWhenFavorabilityIsZero() { + val rendered = UserProfileContextRenderer.render( + profiles = emptyList(), + favorabilityByUserId = mapOf( + 100L to FavorabilityInfo(userId = 100, value = 0, impression = "长期活跃的老群友") + ), + displayNames = mapOf(100L to "小明"), + activeUserIds = setOf(100), + summaryMaxChars = 300, + ) + + assertContains(rendered, "小明(100)") + assertContains(rendered, "好感度+0") + assertContains(rendered, "主观印象:长期活跃的老群友") + } + + private fun relationship(id: String, relatedUserId: Long, content: String) = UserProfileItem( + id = id, + category = ProfileCategory.RELATIONSHIP_NOTE, + content = content, + confidence = ProfileConfidence.MEDIUM, + relatedUserId = relatedUserId, + firstSeenAt = 1, + lastConfirmedAt = 1, + ) +} diff --git a/src/test/kotlin/profile/UserProfileReducerTest.kt b/src/test/kotlin/profile/UserProfileReducerTest.kt new file mode 100644 index 0000000..1b5e66a --- /dev/null +++ b/src/test/kotlin/profile/UserProfileReducerTest.kt @@ -0,0 +1,158 @@ +package top.jie65535.mirai.profile + +import net.mamoe.mirai.message.data.MessageSourceKind +import top.jie65535.mirai.data.ChatMessageRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class UserProfileReducerTest { + @Test + fun addsItemFromTargetAuthoredEvidence() { + val batch = batchOf( + message(ref = 1, fromId = TARGET, text = "我平时会写 Kotlin"), + message(ref = 2, fromId = OTHER, text = "确实"), + ) + val current = emptyProfile() + + val reduction = UserProfileReducer.reduce( + current = current, + batch = batch, + response = ProfileModelResponse( + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.INTEREST, + content = "持续关注 Kotlin 开发", + confidence = ProfileConfidence.MEDIUM, + evidenceRefs = listOf(1, 2), + ) + ), + summary = "关注 Kotlin 开发。", + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + + assertEquals(1, reduction.profile.items.size) + assertEquals("持续关注 Kotlin 开发", reduction.profile.items.single().content) + assertEquals(batch.endTime, reduction.profile.cursorTime) + assertEquals("关注 Kotlin 开发。", reduction.profile.summary) + } + + @Test + fun rejectsItemSupportedOnlyByAnotherUser() { + val batch = batchOf(message(ref = 1, fromId = OTHER, text = "我平时会写 Kotlin")) + + val failure = assertFailsWith<IllegalArgumentException> { + UserProfileReducer.reduce( + current = emptyProfile(), + batch = batch, + response = ProfileModelResponse( + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.NOTABLE_FACT, + content = "从事 Kotlin 开发", + confidence = ProfileConfidence.HIGH, + evidenceRefs = listOf(1), + ) + ), + summary = "从事 Kotlin 开发。", + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + } + + assertTrue(failure.message.orEmpty().contains("目标用户")) + } + + @Test + fun ignoresSummaryRewriteWhenThereIsNoValidOperation() { + val batch = batchOf(message(ref = 1, fromId = TARGET, text = "今天天气不错")) + val current = emptyProfile().copy(summary = "原摘要") + + val reduction = UserProfileReducer.reduce( + current = current, + batch = batch, + response = ProfileModelResponse(summary = "凭空出现的新摘要"), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + + assertEquals("原摘要", reduction.profile.summary) + assertEquals(0, reduction.profile.version) + assertEquals(batch.endTime, reduction.profile.cursorTime) + } + + @Test + fun conversationUpdateDoesNotAdvanceHistoricalCursor() { + val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我平时会写 Kotlin")) + val current = emptyProfile() + + val reduction = UserProfileReducer.reduce( + current = current, + batch = batch, + response = ProfileModelResponse( + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.INTEREST, + content = "持续关注 Kotlin 开发", + confidence = ProfileConfidence.MEDIUM, + evidenceRefs = listOf(1), + ) + ), + summary = "关注 Kotlin 开发。", + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + advanceBackfillCursor = false, + ) + + assertEquals(current.cursorTime, reduction.profile.cursorTime) + assertEquals(1, reduction.profile.version) + } + + private fun emptyProfile() = UserProfileSnapshot( + userId = TARGET, + cursorTime = 100, + snapshotEndTime = 1_000, + ) + + private fun batchOf(vararg messages: ProfilePromptMessage) = ProfileHistoryBatch( + userId = TARGET, + startTime = 100, + endTime = 200, + messages = messages.toList(), + aliases = mapOf(TARGET to "TARGET", OTHER to "U1"), + inputHash = "hash", + ) + + private fun message(ref: Int, fromId: Long, text: String) = ProfilePromptMessage( + record = ChatMessageRecord( + botId = 1, + fromId = fromId, + targetId = 10, + ids = null, + internalIds = null, + time = 120 + ref, + kind = MessageSourceKind.GROUP, + code = text, + ), + text = text, + evidenceRef = ref, + episodeIndex = 1, + ) + + companion object { + private const val TARGET = 100L + private const val OTHER = 200L + } +} diff --git a/src/test/kotlin/profile/UserProfileStoreTest.kt b/src/test/kotlin/profile/UserProfileStoreTest.kt new file mode 100644 index 0000000..591c3b4 --- /dev/null +++ b/src/test/kotlin/profile/UserProfileStoreTest.kt @@ -0,0 +1,234 @@ +package top.jie65535.mirai.profile + +import net.mamoe.mirai.message.data.MessageSourceKind +import top.jie65535.mirai.data.ChatMessageRecord +import java.nio.file.Files +import java.sql.DriverManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class UserProfileStoreTest { + @Test + fun commitsProfileItemsRevisionAndWaterline() { + val directory = Files.createTempDirectory("jchatgpt-profile-test-") + try { + UserProfileStore.init(directory.toFile()) + val batch = ProfileHistoryBatch( + userId = 100, + startTime = 100, + endTime = 200, + messages = listOf( + ProfilePromptMessage( + record = ChatMessageRecord( + botId = 1, + fromId = 100, + targetId = 300, + ids = null, + internalIds = null, + time = 150, + kind = MessageSourceKind.GROUP, + code = "message", + ), + text = "message", + evidenceRef = 1, + episodeIndex = 1, + ) + ), + aliases = mapOf(100L to "TARGET"), + inputHash = "input-hash", + ) + val item = UserProfileItem( + id = "item-1", + category = ProfileCategory.INTEREST, + content = "关注 Kotlin 开发", + confidence = ProfileConfidence.MEDIUM, + firstSeenAt = 150, + lastConfirmedAt = 150, + ) + val profile = UserProfileSnapshot( + userId = 100, + summary = "关注 Kotlin 开发。", + version = 1, + cursorTime = 200, + snapshotEndTime = 1_000, + reliable = true, + model = "test-model", + promptVersion = "test-prompt", + updatedAt = 123, + items = listOf(item), + ) + UserProfileStore.commit( + reduction = ProfileReduction( + profile = profile, + operations = listOf( + AppliedProfileOperation( + action = ProfileOperationAction.ADD, + itemId = item.id, + category = item.category, + content = item.content, + confidence = item.confidence, + relatedUserId = null, + evidenceRefs = listOf(1), + ) + ), + ), + batch = batch, + usage = ProfileTokenUsage(100, 20, 50), + source = ProfileRevisionSource.CONVERSATION, + ) + + val loaded = assertNotNull(UserProfileStore.load(100)) + assertEquals(200, loaded.cursorTime) + assertEquals(1_000, loaded.snapshotEndTime) + assertEquals("关注 Kotlin 开发。", loaded.summary) + assertEquals(item, loaded.items.single()) + assertTrue(loaded.reliable) + assertNotNull(UserProfileStore.lastRevisionAt(100, ProfileRevisionSource.CONVERSATION)) + assertTrue(UserProfileStore.isConversationProcessed("input-hash")) + } finally { + UserProfileStore.close() + directory.toFile().deleteRecursively() + } + } + + @Test + fun migratesRevisionSourceColumnWithoutRebuildingExistingDatabase() { + val directory = Files.createTempDirectory("jchatgpt-profile-migration-test-") + val database = directory.resolve("user-profile.sqlite") + try { + DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE profile_revision( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + profile_version INTEGER NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + input_hash TEXT NOT NULL, + operations_json TEXT NOT NULL, + summary TEXT NOT NULL, + model TEXT NOT NULL, + prompt_version TEXT NOT NULL, + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + cached_tokens INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(user_id, start_time, end_time, input_hash) + ) + """.trimIndent() + ) + } + } + + UserProfileStore.init(directory.toFile()) + UserProfileStore.close() + + DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection -> + val columns = connection.createStatement().use { statement -> + statement.executeQuery("PRAGMA table_info(profile_revision)").use { results -> + buildSet { + while (results.next()) add(results.getString("name")) + } + } + } + assertTrue("source" in columns) + val version = connection.createStatement().use { statement -> + statement.executeQuery( + "SELECT value FROM user_profile_meta WHERE key = 'schema_version'" + ).use { results -> + assertTrue(results.next()) + results.getString(1) + } + } + assertEquals("2", version) + } + } finally { + UserProfileStore.close() + directory.toFile().deleteRecursively() + } + } + + @Test + fun commitsAllConversationProfilesInOneTransactionAndCountsUsageOnce() { + val directory = Files.createTempDirectory("jchatgpt-profile-conversation-commit-test-") + try { + UserProfileStore.init(directory.toFile()) + val entries = listOf(100L, 200L).map { userId -> + val batch = batchFor(userId, "shared-conversation-hash") + val profile = UserProfileSnapshot( + userId = userId, + summary = "用户 $userId 的测试摘要", + version = 1, + cursorTime = 0, + snapshotEndTime = 0, + reliable = true, + model = "test-model", + promptVersion = "test-prompt", + items = listOf(itemFor("item-$userId")), + ) + ProfileReduction(profile, emptyList()) to batch + } + + UserProfileStore.commitConversation(entries, ProfileTokenUsage(100, 20, 50)) + + assertNotNull(UserProfileStore.load(100)) + assertNotNull(UserProfileStore.load(200)) + assertTrue(UserProfileStore.isConversationProcessed("shared-conversation-hash")) + val database = directory.resolve("user-profile.sqlite") + DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery( + "SELECT COUNT(*), SUM(prompt_tokens), SUM(completion_tokens) " + + "FROM profile_revision WHERE input_hash = 'shared-conversation-hash'" + ).use { results -> + assertTrue(results.next()) + assertEquals(2, results.getInt(1)) + assertEquals(100, results.getInt(2)) + assertEquals(20, results.getInt(3)) + } + } + } + } finally { + UserProfileStore.close() + directory.toFile().deleteRecursively() + } + } + + private fun batchFor(userId: Long, inputHash: String) = ProfileHistoryBatch( + userId = userId, + startTime = 100, + endTime = 200, + messages = listOf( + ProfilePromptMessage( + record = ChatMessageRecord( + botId = 1, + fromId = userId, + targetId = 300, + ids = null, + internalIds = null, + time = 150, + kind = MessageSourceKind.GROUP, + code = "message", + ), + text = "message", + evidenceRef = 1, + episodeIndex = 1, + ) + ), + aliases = mapOf(userId to "TARGET"), + inputHash = inputHash, + ) + + private fun itemFor(id: String) = UserProfileItem( + id = id, + category = ProfileCategory.INTEREST, + content = "关注 Kotlin 开发", + confidence = ProfileConfidence.MEDIUM, + firstSeenAt = 150, + lastConfirmedAt = 150, + ) +}