From 6a29dd45bdf7b4699e39b8d742c4c94e5468b420 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Mon, 17 Aug 2026 18:09:37 +0800 Subject: [PATCH] chore: prepare 4.15.2 release --- README.md | 5 +- ...71\347\233\256\346\246\202\350\247\210.md" | 4 +- ...66\346\236\204\350\256\276\350\256\241.md" | 2 + ...41\345\235\227\350\257\246\350\247\243.md" | 4 +- ...45\345\205\267\347\263\273\347\273\237.md" | 1 + ...44\344\270\216\351\205\215\347\275\256.md" | 4 +- ...70\350\247\201\351\227\256\351\242\230.md" | 2 +- header.txt | 2 +- package.json | 2 +- sealpack/info.toml | 2 +- src/config/configs/received.ts | 2 +- src/config/static_config/meta.ts | 2 +- src/memory/memory.ts | 14 +- src/memory/session_memory.ts | 6 +- src/pipeline.ts | 144 ++++++++++++++++-- src/prompt/builder.ts | 18 --- src/prompt/templates.ts | 41 +---- src/resource/image.ts | 8 +- src/tool/tool.ts | 39 ++++- src/tool/tools/resource/tool_resource.ts | 45 +++++- src/tool/tools/resource/tool_voice.ts | 2 +- src/update.ts | 19 +++ src/utils/message.ts | 18 +-- src/utils/string.ts | 59 +++++-- src/utils/utils.ts | 28 ++++ 25 files changed, 353 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index f8e79cf..58c6124 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ - 让你的骰娘活起来 ![License](https://img.shields.io/badge/License-MIT-blue) -![Version](https://img.shields.io/badge/Version-4.15.1-green) +![Version](https://img.shields.io/badge/Version-4.15.2-green) ## 快速开始 @@ -201,7 +201,7 @@ max_tokens = 2048 | 设置项 | 说明 | |:---:|:---| -| 接收图片 | 是否接收并识别图片消息,关闭后不处理任何图片消息 | +| 接收图片 | 是否接收图片消息并将图片 URL 记录到上下文;是否自动识别由图片识别条件和图片模型配置决定 | | 接收指令消息 | 是否将指令消息计入上下文(指令仍会执行) | | 接收骰子发送的消息 | 是否处理机器人自己发送的消息 | | 忽略私聊消息 | 开启后私聊消息不触发 AI | @@ -237,6 +237,7 @@ max_tokens = 2048 | 禁止调用的函数 | 每行一个,设置后将不被允许开启 | | 默认关闭的函数 | 每行一个,AI 在新会话中默认无法调用,需 `.ai tool on <函数名>` 开启 | | 提供给AI的牌堆名称 | 每行一个牌堆名,用于 `draw_deck` 工具;没有的话建议把 `draw_deck` 加入禁止调用 | +| 是否启用MCP | MCP 功能总开关,默认关闭;开启后才会解析并连接下方的 MCP 服务器,未安装对应 MCP 后端时建议保持关闭 | | MCP服务器配置 | 仅支持标准 `mcpServers` JSON 格式(Claude/Cursor/.mcp.json 可直接粘贴);stdio 服务器会跳过;配置增删自动生效。默认包含三个服务器:mcp-files-exec(文件执行)、web-read(网页读取)、md-html-render(Markdown/HTML 渲染)。格式定义见 [MCP 官方规范](https://modelcontextprotocol.io/specification/latest) | | 技能配置 | 仅支持标准 SKILL.md 格式(frontmatter 的 name/description 自动解析,正文为技能内容),可直接粘贴其他 agent 的技能文件。格式定义见 [agentskills.io 规范](https://agentskills.io/specification) | | ai语音使用的音色 | 预设音色需要支持 AI 语音的协议端,自定义音色需要生成音频依赖(tts)和 ffmpeg | diff --git "a/docs/01-\351\241\271\347\233\256\346\246\202\350\247\210.md" "b/docs/01-\351\241\271\347\233\256\346\246\202\350\247\210.md" index f9bbdf7..80fdcfa 100644 --- "a/docs/01-\351\241\271\347\233\256\346\246\202\350\247\210.md" +++ "b/docs/01-\351\241\271\347\233\256\346\246\202\350\247\210.md" @@ -29,9 +29,9 @@ AI 骰娘4(`aiplugin4`)是运行在 SealDice 上的 JavaScript 插件,为骰娘 ## 版本信息 -- `src/config/static_config/meta.ts` 中 `VERSION = "4.15.1"`,作者 `baiyu&错误`,插件名 `aiplugin4`。 +- `src/config/static_config/meta.ts` 中 `VERSION = "4.15.2"`,作者 `baiyu&错误`,插件名 `aiplugin4`。 - 开发遵循「分支 + squash PR」流程(见 [07-开发指南](07-开发指南.md));近期工作围绕:模型配置 TOML 化、Provider 统一请求(超时/重试/用量上报)、智能体编排重构、MCP/技能、日志治理、消息管线、配套后端独立仓库化等。 -- README 顶部徽章与 `VERSION` 保持一致(当前 4.15.1)(详见 [09-注意事项与常见问题](09-注意事项与常见问题.md))。 +- README 顶部徽章与 `VERSION` 保持一致(当前 4.15.2)(详见 [09-注意事项与常见问题](09-注意事项与常见问题.md))。 ## 环境要求 diff --git "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" index 8412eff..3e13208 100644 --- "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -77,6 +77,8 @@ ``` - ob11 额外接收:核心原生(milky)路径会丢弃卡片/视频/文件/语音/合并转发等消息段;安装 ob11 网络连接依赖后,`subscribeOb11Receive` 订阅其事件分发(`net.getEventDispatcher`),只处理含这些额外段的消息,构造 ctx/msg 后走同一套 `handleNonCommand`(黑名单/忽略/触发/待机全部生效),并按段类型过滤避免与核心回调重复处理;合并转发经 `get_forward_msg` 展开为可读文本,过长走压缩。 +- 消息接收优先级固定为“核心原生消息 → ob11 依赖补充”:核心回调已经提供的文本、图片、表情、at、reply、poke 等内容只从 `msg.message`/核心事件入库;ob11 依赖只补充核心过滤掉的文件、视频、语音、卡片、音乐和消息节点。依赖事件先到时会等待核心事件,避免同一条消息重复入库;没有核心事件时才允许完整兜底。 +- Milky 核心事件的原生段直接从 `msg.segments` 转换为内部渲染标签,不先转 CQ 码;OB11 依赖事件仍使用其事件数组。消息 ID、引用 ID 和发送者信息在转换阶段统一补齐,负数 ID 也按字符串保留。 - 多模态消息:对话模型命中「图片模型」列表(或本身就是 use=chat 的图片模型条目)时按多模态处理,`handleMessages` 把用户消息里的 `[img:...]`/`[avatar:...]`/`[group_avatar:...]` 标签转成 `image_url` 内容块直接传给模型;URL 图片优先转 base64(10s 超时兜底)。渲染标签统一为方括号格式(4.14.0 起首次对话自动迁移历史 `<|...|>` 数据,解析层不再兼容旧标签)。 ### 2. 指令消息(handleCommand) diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index f1b352e..5874cc4 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -31,7 +31,7 @@ ### static_config/(静态常量) -- `VERSION`(当前 4.15.1)、`AUTHOR`、`NAME`、`STORAGE_VERSION`、`CONFIG_CACHE_TTL`、`CQ_TYPES_ALLOW`。 +- `VERSION`(当前 4.15.2)、`AUTHOR`、`NAME`、`STORAGE_VERSION`、`CONFIG_CACHE_TTL`、`CQ_TYPES_ALLOW`。 - `PRIVILEGE_LEVEL_MAP`:master=100 / whitelist=70 / owner=60 / admin=50 / inviter=40 / user=0 / blacklist=-30。 - `HELP_MAP`、`ALIAS_MAP`(命令别名表)、`FACE_MAP`(QQ 表情编号 → 名称)。 - `PROVIDER_MAP`:10 家厂商的 OpenAI 兼容 base_url。 @@ -99,6 +99,8 @@ - `types.ts`:消息类型定义:`UserMessageItem`、`SystemUserMessageItem`、`AssistantMessageItem`、`ToolCallsMessage`、`ToolCallbackMessage`、`RequestMessage`。 - `message.ts`:`Message` 静态方法判断消息类型;`handleMessages` 把内部消息转为 API 请求格式,元数据(时间/用户/消息ID/系统来源)存在消息项字段中,多模态模式下把 `[img:...]` 等图片标签转成 `image_url` 内容块直接传给模型。 +- `pipeline.ts` 的接收适配遵循核心优先、依赖补充原则:核心 `msg.message`/Milky `msg.segments` 直接进入统一转换器;ob11 依赖只补充核心不接收的 file/video/record/json/music/node 等段,并通过唯一消息 ID 去重。转换器输出统一的 `[from:...]`、`[time:...]`、`[msg_id:...]`、`[img:...]`、`[at:...]`、`[reply:...]`、`[face:...]`、`[poke:...]` 标签。 +- `handleMessages` 在组装 system、示例、历史和工具定义后进行整包 token 估算;system 与示例不裁剪,历史从最早消息开始裁剪。长对话中按 `INSERT_COUNT` 插入短提醒,不会重复复制完整 system prompt。 - `context.ts`:`Context` 类: - 添加用户消息(自动改名、超长消息交给 `compress_agent` 压缩)、助手消息(触发记忆访问与总结检查)、系统用户消息、工具调用/回调消息。 - `limitMessages()` 按 `MAX_ROUNDS`(出现一次 user 视作一轮)裁剪;`handleMessages` 另有 token 预算裁剪。 diff --git "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" index f948648..e595995 100644 --- "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" +++ "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" @@ -98,6 +98,7 @@ - 协议:Streamable HTTP 上的 JSON-RPC(`initialize` → `notifications/initialized` → `tools/list` → `tools/call`),带 `Mcp-Session-Id` 会话头,兼容 SSE 响应。 - 工具以 `<服务器名>_<工具名>` 注册为敏感工具,参数透传 `inputSchema`。 - 工具列表有 60 秒 TTL 缓存;会话失效(`会话不存在/已失效/session`)时重新 initialize 并重试一次。 +- MCP 配置修改后会按服务器名重建会话并同步工具差集;删除服务器或服务器返回的工具会从注册表移除。MCP 总开关关闭时不建立连接,system prompt 也不会展示不可调用的 MCP/技能入口。 - 错误信息把 JSON-RPC 错误码映射为可读中文。 ## 技能(Skills,src/tool/skills.ts) diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index 929595e..5fcb741 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -103,9 +103,9 @@ | 基础 | 日志级别、请求超时时限、请求并发上限、请求队列上限、是否开启全局待机 | 全局开关 | | 模型 | 对话模型/图片模型/嵌入模型、是否开启图片模型、是否开启嵌入模型 | TOML 每行一个;`name`/`api_key`/`use` 必填,`use` 可选项:`chat`/`compression`/`summarization`(对话)、`image-understanding`(图片)、`text-embedding`(嵌入);`provider`/`base_url` 可自动识别;默认对话模型取列表第一项;可加 `[body]` 覆盖请求参数;嵌入模型输出维度取 `[body] dimensions`(默认 1024);各平台模型示例见 [MODELS-chat](MODELS-chat.md)/[MODELS-image](MODELS-image.md)/[MODELS-embedding](MODELS-embedding.md) | | 对话 | 角色扮演设定、示例对话、对话保存轮数、上下文最大token、消息压缩阈值 | 每条设定第一行为角色设定名称(>20 截断),其余为设定;`$gSYSPROMPT` 选择 | -| 消息接收 | 接收图片/指令消息/骰子发送的消息、忽略消息豹语条件、忽略消息正则表达式 | | +| 消息接收 | 接收图片/指令消息/骰子发送的消息、忽略消息豹语条件、忽略消息正则表达式 | 接收图片只负责记录图片 URL;自动识别仍由图片识别条件和图片模型配置决定 | | 消息触发 | 触发正则表达式、触发需要满足的条件、触发次数上限、触发次数补充间隔 | 令牌桶配额 | -| 工具 | 开启调用函数功能、切换为提示词工程、允许连续调用函数次数、工具响应压缩触发字数、禁止/默认关闭的函数、MCP服务器配置、技能配置、ai语音使用的音色 | MCP 仅支持标准 mcpServers JSON(默认含 mcp-files-exec/web-read/md-html-render,格式见 modelcontextprotocol.io/specification/latest),技能仅支持标准 SKILL.md(格式见 agentskills.io/specification) | +| 工具 | 开启调用函数功能、切换为提示词工程、允许连续调用函数次数、工具响应压缩触发字数、禁止/默认关闭的函数、是否启用MCP、MCP服务器配置、技能配置、ai语音使用的音色 | 「是否启用MCP」默认关闭,开启后才会解析并连接 MCP 服务器;MCP 仅支持标准 mcpServers JSON(默认含 mcp-files-exec/web-read/md-html-render,格式见 modelcontextprotocol.io/specification/latest),技能仅支持标准 SKILL.md(格式见 agentskills.io/specification) | | 记忆 | 启用长期/总结/知识库记忆、长期记忆上限/展示数量、总结记忆上限/间隔/参与轮数、短期记忆上限、知识库注入阈值(字符)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 kb 工具/指令检索;修改后自动生效(缓存最多 1 分钟);Markdown 语法见 commonmark.org/help/ | | 回复 | 回复引用、回复最大字数、分段发送延时/基础延时/含图额外延时、禁止回复复读 | 回复过滤规则已内置硬编码 | | 后端 | 流式输出、图片转base64、联网搜索、用量图表、论坛地址/API Token/签名密钥 | 可自建,见 [08-相关后端项目](08-相关后端项目.md);网页读取与 md/html 渲染(MCP)在「工具 → MCP服务器配置」 | diff --git "a/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" "b/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" index 4b74100..e00b868 100644 --- "a/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" +++ "b/docs/09-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\345\270\270\350\247\201\351\227\256\351\242\230.md" @@ -14,7 +14,7 @@ ## 代码中的已知不一致 -- `src/config/static_config/meta.ts` 的 `VERSION` 与 `src/update.ts` 的 `updateInfo` 最新条目需保持一致(当前 4.15.1);`checkUpdate` 按 `VERSION` 比较输出对应更新日志。 +- `src/config/static_config/meta.ts` 的 `VERSION` 与 `src/update.ts` 的 `updateInfo` 最新条目需保持一致(当前 4.15.2);`checkUpdate` 按 `VERSION` 比较输出对应更新日志。 - `package.json` 的 `main`/`typings` 指向 `build/index.js`/`build/index.d.ts`,但实际构建产物是 `dist/aiplugin4.js`(esbuild),`build/` 目录是陈旧的 tsc 输出,与构建流程无关。 - `engines.node >= 10` 是模板遗留,实际构建依赖 esbuild,建议使用较新 Node。 - `sub_cmd/sample.ts` 与 `agent/agents/samples.ts` 是开发示例,未注册到运行时。 diff --git a/header.txt b/header.txt index 912a0b5..c1e6ad7 100644 --- a/header.txt +++ b/header.txt @@ -1,7 +1,7 @@ // ==UserScript== // @name AI骰娘4 // @author 错误、白鱼 -// @version 4.15.1 +// @version 4.15.2 // @description 适用于大部分OpenAI API兼容格式AI的模型插件,测试环境为 Deepseek AI (https://platform.deepseek.com/),用于与 AI 进行对话,并根据特定关键词触发回复。使用.ai help查看使用方法。具体配置查看插件配置项。\nopenai标准下的function calling功能已进行适配,选用模型若不支持该功能,可以开启迁移到提示词工程的开关,即可使用调用函数功能。\n交流答疑QQ群:143412516 // @timestamp 1733387279 // 2024-12-05 16:27:59 diff --git a/package.json b/package.json index 9323d9a..121e8fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aiplugin4", - "version": "4.15.1", + "version": "4.15.2", "description": "ai plugin for sealdice", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/sealpack/info.toml b/sealpack/info.toml index 1a0ff01..8cd4377 100644 --- a/sealpack/info.toml +++ b/sealpack/info.toml @@ -7,7 +7,7 @@ format_version = "1.0.0" [package] id = "error2913/aiplugin4" name = "AI骰娘4" -version = "4.15.1" +version = "4.15.2" authors = ["错误、白鱼"] license = "MIT" description = "适用于大部分 OpenAI API 兼容格式 AI 的 Sealdice 模型插件,支持对话、记忆、工具调用、MCP/Skills 等。" diff --git a/src/config/configs/received.ts b/src/config/configs/received.ts index a1e389b..c97a2fc 100644 --- a/src/config/configs/received.ts +++ b/src/config/configs/received.ts @@ -4,7 +4,7 @@ import { getRegexConfig } from "../config"; export default class ReceivedConfig { static register() { - seal.ext.registerBoolConfig(ext, "接收图片", true, "开启后接收图片消息;图片识别需要额外配置并开启图片模型", "消息接收"); + seal.ext.registerBoolConfig(ext, "接收图片", true, "开启后接收图片消息并将图片 URL 记录到上下文;是否自动识别图片由图片识别条件和图片模型配置决定", "消息接收"); seal.ext.registerBoolConfig(ext, "接收指令消息", false, "开启后指令消息也会计入上下文(指令仍会执行)", "消息接收"); seal.ext.registerBoolConfig(ext, "接收骰子发送的消息", false, "开启后记录非本插件发送的机器人回复(要计入上下文需同时开启待机)", "消息接收"); seal.ext.registerBoolConfig(ext, "忽略私聊消息", false, "开启后私聊消息不触发 AI", "消息接收"); diff --git a/src/config/static_config/meta.ts b/src/config/static_config/meta.ts index 4f73e49..34fcce5 100644 --- a/src/config/static_config/meta.ts +++ b/src/config/static_config/meta.ts @@ -1,5 +1,5 @@ // 静态常量:版本/作者/模型映射表/权限等级/别名/表情表等 -export const VERSION = "4.15.1"; +export const VERSION = "4.15.2"; export const AUTHOR = "baiyu&错误"; export const NAME = "aiplugin4"; diff --git a/src/memory/memory.ts b/src/memory/memory.ts index af517f4..44acff8 100644 --- a/src/memory/memory.ts +++ b/src/memory/memory.ts @@ -9,13 +9,15 @@ import { MEMORY_TEMPLATE } from "../prompt/templates"; import Image from "../resource/image"; import { Session } from "../session/session"; import { GroupInfo, SessionInfo, UserInfo } from "../session/types"; -import { stripInternalTags } from "../utils/string"; +import { stripInternalTags, truncateText } from "../utils/string"; import { generateId, getCommonItem, revive, TypeDescriptor } from "../utils/utils"; import MemoryItem from "./memory_item"; import { bumpMemoryRevision } from "./revision"; import { MemorySource, searchOptions } from "./types"; +const MEMORY_RENDER_LIMIT = 1000; + export default class MemoryService { static validKeysMap: { [key in keyof MemoryService]?: TypeDescriptor } = { memoryMap: { array: MemoryItem }, @@ -166,7 +168,7 @@ export default class MemoryService { this.limitMemories(1); } - buildMemory(si: SessionInfo, ml: MemoryItem[]): string { + buildMemory(si: SessionInfo, ml: Array<{ id: string, content: string }>): string { if (ml.length === 0) return ''; const listText = ml.map((m, i) => (i + 1) + '. [' + m.id + '] ' + m.content @@ -231,17 +233,21 @@ export default class MemoryService { if (this.persona && this.persona !== '无') { s += `${ctx.isPrivate ? '个人设定' : '群聊设定'}: ${this.persona}\n`; } + const promptMemories = memories.map(memory => ({ + id: memory.id, + content: truncateText(memory.content, MEMORY_RENDER_LIMIT) + })); s += ctx.isPrivate ? this.buildMemory({ isPrivate: true, id: ctx.endPoint.userId, name: seal.formatTmpl(ctx, '核心:骰子名字') - }, memories) + }, promptMemories) : this.buildMemory({ isPrivate: false, id: ctx.group!.groupId, name: ctx.group!.groupName - }, memories); + }, promptMemories); return s; } diff --git a/src/memory/session_memory.ts b/src/memory/session_memory.ts index 5e08529..29d7c43 100644 --- a/src/memory/session_memory.ts +++ b/src/memory/session_memory.ts @@ -8,13 +8,15 @@ import { Session } from "../session/session"; import { GroupInfo, UserInfo } from "../session/types"; import User from "../session/user"; import { buildContent } from "../utils/message"; -import { stripInternalTags } from "../utils/string"; +import { stripInternalTags, truncateText } from "../utils/string"; import { TypeDescriptor } from "../utils/utils"; import MemoryService from "./memory"; import MemoryItem from "./memory_item"; import { bumpSummaryRevision } from "./revision"; +const MEMORY_RENDER_LIMIT = 1000; + export default class SessionMemoryService extends MemoryService { static validKeysMap: { [key in keyof SessionMemoryService]?: TypeDescriptor } = { memoryMap: { array: MemoryItem }, @@ -220,7 +222,7 @@ export default class SessionMemoryService extends MemoryService { const { SUMMARY } = Config.memory; return SUMMARY_TEMPLATE({ "SUMMARY": SUMMARY, - "summaries": this.summaries + "summaries": this.summaries.map(summary => truncateText(summary, MEMORY_RENDER_LIMIT)) }); } } diff --git a/src/pipeline.ts b/src/pipeline.ts index 3df9083..3ae007f 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -8,13 +8,82 @@ import Tool from "./tool/tool"; import { triggerConditionMap } from "./tool/tools/core/tool_trigger"; import { expandForwardMessage } from "./utils/ob11"; import { createCtx, createMsg } from "./utils/seal"; -import { expandMilkySegments, MessageSegment, parseCardToText, parseMusicToText, transformTextToArray } from "./utils/string"; +import { expandMilkySegments, MessageSegment, parseCardToText, parseMusicToText, transformTextToArray, truncateText } from "./utils/string"; +import { getRecordMessageId, transformMsgId } from "./utils/utils"; + +/** 核心原生 milky 路径通常过滤掉的段,只由 ob11 依赖补充。 */ +const OB11_SUPPLEMENT_SEGMENT_TYPES = new Set(['record', 'json', 'video', 'file', 'node', 'forward', 'music', 'xml', 'markdown', 'market_face']); +const CORE_MESSAGE_TTL_MS = 2000; +const DEPENDENCY_WAIT_MS = 100; + +interface CoreMessageState { + expiresAt: number; + recorded: boolean; + types: Set; +} + +const coreMessageStates = new Map(); + +function pruneCoreMessageStates(now: number = Date.now()): void { + coreMessageStates.forEach((state, key) => { + if (state.expiresAt <= now) coreMessageStates.delete(key); + }); +} + +function getMessageKey(ctx: seal.MsgContext, msg: seal.Message): string { + const sessionId = ctx.isPrivate ? ctx.player?.userId || '' : ctx.group?.groupId || ''; + const messageId = getRecordMessageId(ctx, msg) || transformMsgId(msg.rawId); + return `${ctx.endPoint.userId}|${sessionId}|${messageId}`; +} + +function rememberCoreMessage(ctx: seal.MsgContext, msg: seal.Message, messageArray: MessageSegment[]): string { + const key = getMessageKey(ctx, msg); + pruneCoreMessageStates(); + coreMessageStates.set(key, { + expiresAt: Date.now() + CORE_MESSAGE_TTL_MS, + recorded: false, + types: new Set(messageArray + .filter(item => item.type !== 'text') + .map(item => item.type === 'at' && item.data && item.data.qq === 'all' ? 'at:all' : item.type)) + }); + return key; +} + +function markCoreMessageRecorded(key: string): void { + const state = coreMessageStates.get(key); + if (state) state.recorded = true; +} + +function getCoreMessageState(key: string): CoreMessageState | undefined { + pruneCoreMessageStates(); + return coreMessageStates.get(key); +} + +/** 依赖事件只筛选核心缺失段;face 由核心是否已收到动态决定。 */ +export function filterOb11SupplementSegments(message: any[], coreTypes?: Set): any[] { + return message.filter((seg: any) => { + if (!seg || typeof seg !== 'object' || !seg.type) return false; + if (OB11_SUPPLEMENT_SEGMENT_TYPES.has(seg.type)) return true; + if (seg.type === 'at' && seg.data && seg.data.qq === 'all') return !coreTypes?.has('at:all'); + return seg.type === 'face' && !coreTypes?.has('face'); + }); +} + +function waitForCoreEvent(): Promise { + return new Promise(resolve => setTimeout(resolve, DEPENDENCY_WAIT_MS)); +} -/** 海豹核心原生 milky 接收路径会过滤掉的段类型,只能通过 ob11 依赖的事件分发(milky → OB11 转接)收到 */ -const OB11_EXTRA_SEGMENT_TYPES = new Set(['record', 'json', 'video', 'file', 'node', 'forward', 'music', 'xml', 'markdown', 'market_face']); /** 消息节点/合并转发展开的最大嵌套深度,防止恶意或异常嵌套导致无限递归 */ const MAX_FORWARD_DEPTH = 5; +/** + * 判断某个 OB11 消息段是否属于“核心原生 milky 适配器收不到、只能靠 ob11 依赖补收”的段。 + * mention_all 会被 ob11 依赖转成 at(qq=all),而原生 milky 适配器只处理 mention,会丢弃 mention_all。 + */ +function isOb11ExtraSegment(seg: any): boolean { + return filterOb11SupplementSegments([seg]).length > 0; +} + export class MessagePipeline { /** ob11 数组消息段 → MessageSegment[]:把卡片/视频/音乐/文件/消息节点/合并转发展开为文本段,其余段保留 */ private static async expandOb11Segments(ctx: seal.MsgContext, segs: any[], depth: number = 0): Promise { @@ -49,6 +118,21 @@ export class MessagePipeline { result.push({ type: 'text', data: { text: parseMusicToText(data) } }); break; } + case 'market_face': { + // 商城表情/超级表情:milky 转接只透传 emoji 元数据,这里转为可读占位,避免段静默丢失 + result.push({ type: 'text', data: { text: data.summary ? `【表情】${data.summary}` : '【表情】' } }); + break; + } + case 'xml': { + // XML 卡片/公众号消息:保留关键内容,过长时截断避免污染上下文 + result.push({ type: 'text', data: { text: data.data ? `【XML消息】${truncateText(String(data.data), 500)}` : '【XML消息】' } }); + break; + } + case 'markdown': { + // Markdown 消息:milky 转接成 OB11 markdown 段,转成文本进入上下文 + result.push({ type: 'text', data: { text: data.content ? `【Markdown】${truncateText(String(data.content), 500)}` : '【Markdown】' } }); + break; + } case 'node': { result.push({ type: 'text', data: { text: await MessagePipeline.parseNodeToText(ctx, data, depth + 1) } }); break; @@ -121,7 +205,7 @@ export class MessagePipeline { trySubscribe(0); } - /** ob11 事件消息(OneBot 消息段数组):仅处理核心原生路径收不到的段类型,避免与核心回调重复处理 */ + /** ob11 事件消息(OneBot 消息段数组):核心优先,依赖只补充核心未收到的段 */ private static async handleOb11Event(event: any): Promise { if (!event || event.post_type !== 'message') return; const message = event.message; @@ -134,7 +218,7 @@ export class MessagePipeline { return; } const segTypes = message.filter((seg: any) => seg && seg.type).map((seg: any) => seg.type); - if (!segTypes.some((t: string) => OB11_EXTRA_SEGMENT_TYPES.has(t))) { + if (!message.some((seg: any) => isOb11ExtraSegment(seg))) { logger.debug(`ob11 事件消息无额外段(types=[${segTypes.join(',')}]),由核心原生路径处理,跳过`); return; } @@ -167,11 +251,30 @@ export class MessagePipeline { return; } logger.debug(`[debug] ob11 事件 ctx 构建完成 isPrivate=${ctx.isPrivate} player=${ctx.player && ctx.player.userId} group=${ctx.group && ctx.group.groupId}`); - await MessagePipeline.handleNonCommand(ctx, msg, message); + + await waitForCoreEvent(); + const state = getCoreMessageState(getMessageKey(ctx, msg)); + if (state && !state.recorded) { + logger.debug(`[debug] ob11 补充消息对应核心消息未入库,跳过依赖补充: id=${msg.rawId}`); + return; + } + + const supplementSegments = filterOb11SupplementSegments(message, state?.types); + if (state) { + if (supplementSegments.length === 0) { + logger.debug(`[debug] ob11 补充消息无核心缺失段,跳过: id=${msg.rawId}`); + return; + } + await MessagePipeline.handleNonCommand(ctx, msg, supplementSegments, { supplementOnly: true }); + return; + } + + // 核心没有对应回调时,依赖事件作为完整消息兜底,不能只保留额外段。 + await MessagePipeline.handleNonCommand(ctx, msg, message, { source: 'dependency' }); } /** 非指令消息:过滤后进入会话,由智能体决定是否触发回复 */ - static async handleNonCommand(ctx: seal.MsgContext, msg: seal.Message, ob11Segments?: any[]): Promise { + static async handleNonCommand(ctx: seal.MsgContext, msg: seal.Message, ob11Segments?: any[], options?: { source?: 'core' | 'dependency'; supplementOnly?: boolean }): Promise { const { IGNORE_PRIVATE: disabledInPrivate, IGNORE_REGEX: ignoreRegex, IGNORE_CONDITION } = Config.received; const { TRIGGER_REGEX: triggerRegex, TRIGGER_CONDITION: triggerCondition } = Config.trigger; @@ -203,16 +306,13 @@ export class MessagePipeline { session.checkActiveTimer(ctx); const message = msg.message; - // 消息段来源优先级: - // 1. ob11 事件段数组(卡片/视频/音乐/文件/消息节点/合并转发等核心收不到的段) - // 2. milky 原生消息段 msg.segment(非空时直接映射,不转 CQ 码) - // 3. 其他平台原生 CQ 码字符串 msg.message + // 核心消息优先:Milky 原生段 → 核心 OB11/CQ 字符串;依赖事件仅在显式传入时使用。 const milkySegments = (msg as any).segment; const hasMilkySegments = Array.isArray(milkySegments) && milkySegments.length > 0; const messageArray = Array.isArray(ob11Segments) ? await this.expandOb11Segments(ctx, ob11Segments) : hasMilkySegments - ? expandMilkySegments(milkySegments) + ? expandMilkySegments(ctx, milkySegments) : Array.isArray(message) ? await this.expandOb11Segments(ctx, message) : transformTextToArray(message); @@ -224,13 +324,26 @@ export class MessagePipeline { logger.debug(`[debug] milky 消息段展开: ${messageText.slice(0, 200)}`); } + const isCoreMessage = options?.source !== 'dependency' && !options?.supplementOnly; + const coreMessageKey = isCoreMessage ? rememberCoreMessage(ctx, msg, messageArray) : ''; + + // 依赖补充只入库,不重新执行正则、计数器、概率、计时器或 AI 触发。 + if (options?.supplementOnly) { + if (messageArray.length === 0) return; + const supplementTypes = messageArray.filter(item => item.type !== 'text').map(item => item.type); + if (supplementTypes.some(type => !CQ_TYPES_ALLOW.includes(type))) return; + return session.handleReceipt(ctx, msg, messageArray).then(() => session.save()); + } + // 忽略条件(豹语表达式)命中时直接忽略 if (parseInt(seal.format(ctx, `{${IGNORE_CONDITION}}`)) === 1) { + if (coreMessageKey) coreMessageStates.delete(coreMessageKey); logger.info('忽略消息条件命中,跳过'); return; } if (ignoreRegex.test(messageText)) { + if (coreMessageKey) coreMessageStates.delete(coreMessageKey); logger.info(`非指令消息忽略:${messageText}`); return; } @@ -245,6 +358,7 @@ export class MessagePipeline { if (session.setting.regexTrigger && triggerRegex.test(messageText)) { const fmtCondition = parseInt(seal.format(ctx, `{${triggerCondition}}`)); if (fmtCondition === 1) { + markCoreMessageRecorded(coreMessageKey); return session.handleReceipt(ctx, msg, messageArray) .then(() => session.chat(ctx, msg, '非指令')); } @@ -271,6 +385,7 @@ export class MessagePipeline { continue; } + markCoreMessageRecorded(coreMessageKey); return session.handleReceipt(ctx, msg, messageArray) .then(() => session.context.addSystemUserMessage(condition.reason, '触发原因提示')) .then(() => triggerConditionMap[sid].splice(i, 1)) @@ -281,6 +396,7 @@ export class MessagePipeline { // 开启任一模式时 const setting = session.setting; if (setting.standby || Config.base.GLOBAL_STANDBY) { + markCoreMessageRecorded(coreMessageKey); return session.handleReceipt(ctx, msg, messageArray) .then((): void | Promise => { if (setting.counter > -1) { @@ -330,7 +446,7 @@ export class MessagePipeline { const message = msg.message; const milkySegments = (msg as any).segment; const hasMilkySegments = Array.isArray(milkySegments) && milkySegments.length > 0; - const messageArray = hasMilkySegments ? expandMilkySegments(milkySegments) : transformTextToArray(message); + const messageArray = hasMilkySegments ? expandMilkySegments(ctx, milkySegments) : transformTextToArray(message); const CQTypes = messageArray.filter(item => item.type !== 'text').map(item => item.type); if (CQTypes.length === 0 || CQTypes.every(item => CQ_TYPES_ALLOW.includes(item))) { @@ -355,7 +471,7 @@ export class MessagePipeline { const message = msg.message; const milkySegments = (msg as any).segment; const hasMilkySegments = Array.isArray(milkySegments) && milkySegments.length > 0; - const messageArray = hasMilkySegments ? expandMilkySegments(milkySegments) : transformTextToArray(message); + const messageArray = hasMilkySegments ? expandMilkySegments(ctx, milkySegments) : transformTextToArray(message); const messageText = hasMilkySegments ? messageArray.map(item => item.type === 'text' ? ((item.data && item.data.text) || '') : `[${item.type}]`).join('') : message; diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index 1e1803c..0faf30b 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -31,15 +31,6 @@ function signature(parts: Array): string { return parts.map(String).join('|'); } -function localResourceSignature(): string { - return signature([ - (Config.resource.LOCAL_IMAGES || []).map(img => img.imageId).join(','), - (Config.resource.LOCAL_AUDIOS || []).map(a => a.audioId).join(','), - (Config.resource.LOCAL_FILES || []).map(f => f.fileId).join(','), - (Config.resource.LOCAL_VIDEOS || []).map(v => v.videoId).join(',') - ]); -} - function toolStateSignature(session: Session): string { return Object.keys(session.toolState) .sort() @@ -94,7 +85,6 @@ export async function buildSystemPromptContent( ctx.isPrivate ? ctx.player!.name : ctx.group!.groupName, ctx.isPrivate ? ctx.player!.userId : ctx.group!.groupId, RECEIVE_IMAGE, - localResourceSignature(), STATUS, PROMPT_ENGINEERING, Config.tool.BLOCKED.join(','), @@ -104,10 +94,6 @@ export async function buildSystemPromptContent( ]); const frame = await getCachedString(staticKey, STATIC_FRAME_TTL, () => { const skillSummaries = getSkillSummaries(); - const localImages = (Config.resource.LOCAL_IMAGES || []).map(img => ({ imageId: img.imageId })); - const localAudios = Config.resource.LOCAL_AUDIOS || []; - const localFiles = (Config.resource.LOCAL_FILES || []).map(f => ({ fileId: f.fileId })); - const localVideos = (Config.resource.LOCAL_VIDEOS || []).map(v => ({ videoId: v.videoId })); const toolPrompt = STATUS && PROMPT_ENGINEERING ? Tool.getToolsInfoPrompt(session) : ''; let content = SYSTEM_MESSAGE_TEMPLATE({ @@ -117,10 +103,6 @@ export async function buildSystemPromptContent( sessionName: ctx.isPrivate ? ctx.player!.name : ctx.group!.groupName, sessionId: ctx.isPrivate ? ctx.player!.userId : ctx.group!.groupId, RECEIVE_IMAGE, - LOCAL_IMAGES: localImages, - LOCAL_AUDIOS: localAudios, - LOCAL_FILES: localFiles, - LOCAL_VIDEOS: localVideos, toolPrompt }); diff --git a/src/prompt/templates.ts b/src/prompt/templates.ts index 65cb6c9..1c24e32 100644 --- a/src/prompt/templates.ts +++ b/src/prompt/templates.ts @@ -32,42 +32,14 @@ const TEMPLATES: { [key: string]: string } = { {{/if}} - 可使用[avatar:xxxxxx]发送用户头像,其中xxxxxx为用户名称或用户ID - 可使用[group_avatar:xxxxxx]发送群聊头像,其中xxxxxx为群聊名称或群聊ID -{{#if LOCAL_IMAGES}} -- 可使用[img:图片ID]发送本地图片,本地图片列表如下: - {{#each LOCAL_IMAGES}} -{{{imageId}}}{{#unless @last}}、{{/unless}} - {{else}} -暂无本地图片 - {{/each}} -{{/if}} +- 可使用[img:图片ID]发送本地图片,可用名称先通过 list_resources(type=image) 查询 ## 音频相关 -{{#if LOCAL_AUDIOS}} -- 可使用[audio:音频ID]发送本地音频,本地音频列表如下: - {{#each LOCAL_AUDIOS}} -{{{audioId}}}{{#unless @last}}、{{/unless}} - {{else}} -暂无本地音频 - {{/each}} -{{/if}} +- 可使用[audio:音频ID]发送本地音频,可用名称先通过 list_resources(type=audio) 查询 ## 文件与视频相关 -{{#if LOCAL_FILES}} -- 可使用send_file工具发送本地文件,本地文件列表如下: - {{#each LOCAL_FILES}} -{{{fileId}}}{{#unless @last}}、{{/unless}} - {{else}} -暂无本地文件 - {{/each}} -{{/if}} -{{#if LOCAL_VIDEOS}} -- 可使用send_video工具发送本地视频,本地视频列表如下: - {{#each LOCAL_VIDEOS}} -{{{videoId}}}{{#unless @last}}、{{/unless}} - {{else}} -暂无本地视频 - {{/each}} -{{/if}} +- 可使用send_file工具发送本地文件,可用名称先通过 list_resources(type=file) 查询 +- 可使用send_video工具发送本地视频,可用名称先通过 list_resources(type=video) 查询 **DYNAMIC_SECTIONS** @@ -115,8 +87,9 @@ const TEMPLATES: { [key: string]: string } = { {{#each tools}} {{index @index}}. 名称:{{{name}}} - 描述:{{{description}}} - - 参数信息:{{{json_stringify parameters.properties}}} - - 必需参数:{{#each parameters.required}}{{{this}}}{{#unless @last}}, {{/unless}}{{/each}} + {{#if parameterText}} + - 参数:{{{parameterText}}} + {{/if}} {{else}} 暂无可用函数。 {{/each}} diff --git a/src/resource/image.ts b/src/resource/image.ts index d65fa9e..042b921 100644 --- a/src/resource/image.ts +++ b/src/resource/image.ts @@ -187,6 +187,7 @@ export default class Image { img.sourceSessionId = sourceSessionId; img.url = url; this.imageMap[imageId] = img; + this.save(img); return img; } @@ -261,7 +262,12 @@ ${img.CQCode}`; let content = ''; const images: Image[] = []; try { - const file = seg.data.url || seg.data.file || ''; + const rawFile = seg.data.url || seg.data.file || ''; + // Milky 原生 image 的 file 是 FileElement 对象,OB11 通常是字符串; + // 统一取 URL/本地文件字段,避免把对象写入 Image.url 后在 type getter 中报错。 + const file = typeof rawFile === 'object' && rawFile !== null + ? ((rawFile as any).url || (rawFile as any).file || '') + : String(rawFile); if (!file) return { content: '', images: [] }; const image = this.createUrlImage(getSessionId(ctx), file); diff --git a/src/tool/tool.ts b/src/tool/tool.ts index 113d38d..86a31e3 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -11,7 +11,7 @@ import { withTimeout } from "../utils/utils"; import { registerMCPTools } from "./mcp"; import { registerSkills } from "./skills"; import { registerTools } from "./tools/init"; -import { ExtCmdInfo, ToolCall, ToolCallResult, ToolInfo, ToolListen } from "./types"; +import { ExtCmdInfo, ToolCall, ToolCallResult, ToolInfo, ToolInfoObject, ToolListen } from "./types"; export const toolMap: { [key: string]: Tool } = {}; @@ -28,6 +28,26 @@ export const CORE_TOOL_NAMES: string[] = [ 'get_cmd_help' // 指令帮助 ]; +const ON_DEMAND_PROMPT_LIMIT = 20; + +function flattenText(text: string, maxLength: number): string { + const flattened = String(text || '').replace(/\s+/g, ' ').trim(); + return flattened.length > maxLength ? flattened.slice(0, maxLength) + '...' : flattened; +} + +function formatParameterText(parameters: ToolInfoObject): string { + const properties = parameters && parameters.properties ? parameters.properties : {}; + const required = Array.isArray(parameters.required) ? parameters.required : []; + const lines = Object.keys(properties).map(key => { + const prop = properties[key]; + const type = prop && typeof prop.type === 'string' ? prop.type : 'string'; + const isRequired = required.indexOf(key) !== -1; + const desc = prop && typeof prop.description === 'string' ? flattenText(prop.description, 80) : ''; + return `${key}:${type}(${isRequired ? '必填' : '可选'})${desc ? ` - ${desc}` : ''}`; + }); + return lines.join('\n'); +} + export default class Tool { toolInfo: ToolInfo; ExtCmdInfo: ExtCmdInfo; // 海豹指令信息 @@ -329,11 +349,11 @@ export default class Tool { const tools = this.getToolsInfo(session); let s = ''; if (tools && tools.length > 0) { - // 模板按扁平结构读取(name/description/parameters),从 function 字段映射后传入 + // 模板按扁平结构读取 name/description/parameterText,从 function 字段映射后传入 const flatTools = tools.map(t => ({ name: t.function.name, - description: t.function.description, - parameters: t.function.parameters + description: flattenText(t.function.description, 120), + parameterText: formatParameterText(t.function.parameters) })); s = TOOLS_PROMPT_TEMPLATE({ "PROMPT_ENGINEERING": PROMPT_ENGINEERING, @@ -344,12 +364,15 @@ export default class Tool { // 按需工具:只给名称 + 一行描述,详细参数通过 search_tools 获取,控制 token 占用 const onDemand = this.getOnDemandTools(session); if (onDemand.length > 0) { - const summaries = onDemand.map((t, i) => { - const desc = t.function.description.replace(/\s+/g, ' ').trim(); - const truncated = desc.length > 120 ? desc.slice(0, 120) + '…' : desc; - return `${i + 1}. ${t.function.name}:${truncated}`; + const summaries = onDemand.slice(0, ON_DEMAND_PROMPT_LIMIT).map((t, i) => { + const desc = flattenText(t.function.description, 120); + return `${i + 1}. ${t.function.name}:${desc}`; }); s += `\n\n## 其他可用工具(按需加载)\n${summaries.join('\n')}\n需要使用上述工具时,先调用 search_tools 获取参数说明,再通过 call_tool 执行。`; + const hiddenCount = onDemand.length - ON_DEMAND_PROMPT_LIMIT; + if (hiddenCount > 0) { + s += `\n其余 ${hiddenCount} 个工具请通过 search_tools 查询。`; + } } return s; } diff --git a/src/tool/tools/resource/tool_resource.ts b/src/tool/tools/resource/tool_resource.ts index c3df4fc..0732832 100644 --- a/src/tool/tools/resource/tool_resource.ts +++ b/src/tool/tools/resource/tool_resource.ts @@ -15,6 +15,23 @@ function buildPathMap(items: { [key: string]: string }[], key: string): { [key: return pathMap; } +function buildResourceList(type: string): string { + const sections: Array<[string, string[]]> = []; + if (type === 'all' || type === 'image') { + sections.push(['本地图片', (Config.resource.LOCAL_IMAGES || []).map(img => img.imageId)]); + } + if (type === 'all' || type === 'audio') { + sections.push(['本地音频', (Config.resource.LOCAL_AUDIOS || []).map(a => a.audioId)]); + } + if (type === 'all' || type === 'file') { + sections.push(['本地文件', (Config.resource.LOCAL_FILES || []).map(f => f.fileId)]); + } + if (type === 'all' || type === 'video') { + sections.push(['本地视频', (Config.resource.LOCAL_VIDEOS || []).map(v => v.videoId)]); + } + return sections.map(([label, names]) => `${label}:${names.length > 0 ? names.join('、') : '暂无'}`).join('\n'); +} + function registerResourceTool( name: string, desc: string, @@ -25,7 +42,7 @@ function registerResourceTool( type: 'function', function: { name, - description: `${desc}。可传 name(已登记资源,资源名以调用时的报错提示为准)或 path(本地绝对路径,或相对海豹 data 目录的路径)`, + description: `${desc}。可传 name(已登记资源,先通过 list_resources 查询可用名称)或 path(本地绝对路径,或相对海豹 data 目录的路径)`, parameters: { type: 'object', properties: { @@ -86,4 +103,30 @@ function registerResourceTool( export function registerResourceTools() { registerResourceTool('send_file', '发送本地文件', 'fileId', 'file'); registerResourceTool('send_video', '发送本地视频', 'videoId', 'video'); + + const listTool = new Tool({ + type: 'function', + function: { + name: 'list_resources', + description: '查询当前已配置的本地资源名称。type 可选 image/audio/file/video/all。图片和音频分别使用 [img:名称] 和 [audio:名称];文件和视频使用 send_file/send_video 的 name 参数。', + parameters: { + type: 'object', + properties: { + type: { + type: 'string', + description: '资源类型', + enum: ['image', 'audio', 'file', 'video', 'all'] + } + }, + required: [] + } + } + }); + listTool.solve = async (_, __, ___, args) => { + const type = args && typeof args.type === 'string' ? args.type : 'all'; + if (!['image', 'audio', 'file', 'video', 'all'].includes(type)) { + return `未知资源类型:${type},可选值为 image/audio/file/video/all`; + } + return buildResourceList(type); + }; } diff --git a/src/tool/tools/resource/tool_voice.ts b/src/tool/tools/resource/tool_voice.ts index 6b9c1f2..1e746e5 100644 --- a/src/tool/tools/resource/tool_voice.ts +++ b/src/tool/tools/resource/tool_voice.ts @@ -36,7 +36,7 @@ export function registerRecord() { type: "function", function: { name: "record", - description: `发送本地语音,语音名以调用时的报错提示为准`, + description: `发送本地语音,语音名先通过 list_resources(type=audio) 查询`, parameters: { type: "object", properties: { diff --git a/src/update.ts b/src/update.ts index 9793f21..87613e6 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,6 +1,25 @@ // 版本更新日志(updateInfo),供启动时展示更新说明 // 版本更新日志,格式为 "版本号": "更新内容",版本号格式为 "x.y.z",按照时间顺序从新到旧排列。 export const updateInfo: { [version: string]: string } = { + "4.15.2": `## 修复 +- 修复提示词分层缓存:静态提示词、长期记忆、总结记忆和知识库按会话与版本缓存,记忆写入/删除/总结后主动失效,避免同一轮工具循环重复检索和嵌入 +- 修复上下文 token 预算只裁剪历史消息的问题:现在统一估算 system、示例对话、历史消息和工具定义,优先保留 system/示例并从最早历史开始裁剪 +- 修复上下文消息净化:清理空 tool_calls、孤立 tool 消息和无匹配工具结果,避免上游 API 因非法消息结构返回 400 +- 修复示例对话中的空行导致 user/assistant 角色错位;修复定时器、指令入库、批量改名等异步调用缺少 rejection 兜底 +- 修复图片消息中 Milky file 对象被误当成字符串的问题,并持久化通过 URL 创建的图片资源 + +## 消息接收 +- 核心消息优先:直接使用 SealDice 原生 msg.message/消息事件,保留文本、图片、表情、at、reply、poke 等渲染标签 +- 补充 OB11 依赖消息:仅处理核心未接收的文件、视频、语音、卡片、音乐和合并转发等消息,避免重复入库 +- 完善 Milky 消息段转换:按 msg.segments 类型展开文本、图片、at、reply、face、file、video、record、music、json、消息节点等内容,并将 reply 序号转换为可引用的消息 ID;异常段只占位,不中断整条消息 +- 修复负数消息 ID、未知表情、全体 at、私聊消息和嵌套消息节点进入上下文时的标签与发送者信息 + +## 工具与图片 +- 增加 MCP 总开关与图生图能力:text_to_image 支持图片 ID、头像、URL、data URL 和 base64,适配 aiplugin4-dependencies 新接口 +- 按需工具摘要限制数量并提供参数概览,减少 system prompt 中函数列表占用的 token + +## 配置项更改 +- 无新增或删除配置项;本版本主要为消息接收、提示词缓存、工具调用和图片处理的实现修复。升级后无需恢复默认配置。`, "4.15.1": `## 修复 - 修复 MCP 服务器配置默认值 mcp-files-exec 的 url 缺少 /mcp 路径的问题:默认值修正为 http://127.0.0.1:3910/mcp,与后端 Streamable HTTP 实际路径一致(旧默认值初始化会 404,面板实测 list_dir / read_file 调用正常) - 自动改名(昵称/群名片)增加 5 秒超时保护:ob11 网络连接依赖请求异常或未响应时不再让整条消息处理链路挂起,超时记 warning 后继续处理 diff --git a/src/utils/message.ts b/src/utils/message.ts index d0dc76c..8cce003 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -41,6 +41,8 @@ interface ContextMessage { tool_call_id?: string; } +const SYSTEM_REMINDER_TEXT = '请继续遵守上方角色设定、上下文标记和工具调用规范。'; + /** * 无依赖的 token 估算:ASCII 约 4 字符/token,非 ASCII(中文等)约 1 字符/token。 * 用于「上下文最大token」的整包预算估算,避免依赖外部 tokenizer。 @@ -83,19 +85,19 @@ function buildSamplesMessages(ctx: seal.MsgContext): ContextMessage[] { })); } -function buildContextMessages(systemMessage: ContextMessage, messages: ContextMessage[]): ContextMessage[] { +function buildContextMessages(messages: ContextMessage[]): ContextMessage[] { const { INSERT_COUNT } = Config.message; const contextMessages = messages.slice(); if (INSERT_COUNT <= 0) return contextMessages; - // 顺序遍历:在第 INSERT_COUNT+1 条及之后每隔 INSERT_COUNT 条用户消息前插入 system message, - // 让模型在长对话中周期性重新看到角色设定;示例对话不计入插入轮数。 + // 顺序遍历:在第 INSERT_COUNT+1 条及之后每隔 INSERT_COUNT 条用户消息前插入短提醒, + // 避免把完整角色设定、资源说明和工具说明按轮数反复复制进上下文。 let userCount = 0; const result: ContextMessage[] = []; for (const m of contextMessages) { if (m.role === 'user' && userCount > 0 && userCount % INSERT_COUNT === 0) { - result.push(systemMessage); + result.push({ role: 'system', text: SYSTEM_REMINDER_TEXT }); } result.push(m); if (m.role === 'user') userCount++; @@ -133,7 +135,7 @@ export async function handleMessages( ): Promise { const system = systemMessage ?? await buildSystemMessage(ctx, session); const samplesMessages = buildSamplesMessages(ctx); - const contextMessages = buildContextMessages(system, session.context.messages as ContextMessage[]); + const contextMessages = buildContextMessages(session.context.messages as ContextMessage[]); const messages: ContextMessage[] = applyTokenBudget( [system, ...samplesMessages, ...contextMessages], @@ -240,11 +242,9 @@ export function buildContent(message: ContextMessage): string { + (item.text || ''); }).join('\f'); } - // 非用户消息:assistant 补时间与消息 ID;system 是角色设定 prompt 容器, - // 不算对话消息,不渲染 time/msg_id + // 非用户消息:只保留消息 ID;时间顺序由消息数组本身表达,去掉重复时间前缀。 return message.contentItems.map(item => - (message.role !== 'system' && item.time ? `[time:${fmtDate(item.time)}]` : '') - + (item.messageId ? `[msg_id:${item.messageId}]` : '') + (item.messageId ? `[msg_id:${item.messageId}]` : '') + (item.text || '') ).join('\f'); } diff --git a/src/utils/string.ts b/src/utils/string.ts index 34d1364..02887a7 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -8,7 +8,12 @@ import { logger } from "../logger"; import Image from "../resource/image"; import { getCtxAndMsg } from "./seal"; -import { resolveLocalPath, transformMsgId, transformMsgIdBack } from "./utils"; +import { getMilkyReplyQuoteId, resolveLocalPath, transformMsgId, transformMsgIdBack } from "./utils"; + +export function truncateText(text: string, maxLength: number): string { + if (!text || maxLength <= 0 || text.length <= maxLength) return text || ''; + return text.slice(0, maxLength) + '...'; +} /* 先丢这一坨东西在这。之所以不用是因为被类型检查整烦了 @@ -123,7 +128,7 @@ export interface MessageSegment { * 项目内部统一段格式。不经过 CQ 码:milky 下 msg.message 只有纯文本拼接, * at/图片/回复等富文本信息必须从 segment 取。 */ -export function expandMilkySegments(segments: seal.MessageSegment[]): MessageSegment[] { +export function expandMilkySegments(ctx: seal.MsgContext, segments: seal.MessageSegment[]): MessageSegment[] { const result: MessageSegment[] = []; for (const seg of segments) { if (!seg || typeof seg !== 'object' || typeof seg.type !== 'function') continue; @@ -141,12 +146,12 @@ export function expandMilkySegments(segments: seal.MessageSegment[]): MessageSeg switch (typeValue) { case 0: { // 文本 Text const content = 'content' in seg ? seg.content : ''; - result.push({ type: 'text', data: { text: content || '' } }); + result.push({ type: 'text', data: { text: content === undefined || content === null ? '' : String(content) } }); break; } case 1: { // 艾特 At const target = 'target' in seg ? seg.target : ''; - result.push({ type: 'at', data: { qq: target || '' } }); + result.push({ type: 'at', data: { qq: target === undefined || target === null ? '' : String(target) } }); break; } case 2: { // 文件 File @@ -158,18 +163,24 @@ export function expandMilkySegments(segments: seal.MessageSegment[]): MessageSeg } case 3: { // 图片 Image const url = 'url' in seg ? seg.url : ''; - const file = 'file' in seg && seg.file && typeof seg.file === 'object' ? (seg.file.url || seg.file.file) : ''; + const file = 'file' in seg + ? (typeof seg.file === 'string' + ? seg.file + : seg.file && typeof seg.file === 'object' + ? ((seg.file as any).url || (seg.file as any).file || '') + : '') + : ''; result.push({ type: 'image', data: { url: url || file || '' } }); break; } case 4: { // 文字转语音 TTS const content = 'content' in seg ? seg.content : ''; - result.push({ type: 'text', data: { text: content || '' } }); + result.push({ type: 'text', data: { text: content === undefined || content === null ? '' : String(content) } }); break; } case 5: { // 回复 Reply const replySeq = 'replySeq' in seg ? seg.replySeq : ''; - result.push({ type: 'reply', data: { id: replySeq || '' } }); + result.push({ type: 'reply', data: { id: getMilkyReplyQuoteId(ctx, replySeq) } }); break; } case 6: { // 语音 Record @@ -178,12 +189,12 @@ export function expandMilkySegments(segments: seal.MessageSegment[]): MessageSeg } case 7: { // 表情 Face const faceID = 'faceID' in seg ? seg.faceID : ''; - result.push({ type: 'face', data: { id: faceID || '' } }); + result.push({ type: 'face', data: { id: faceID === undefined || faceID === null ? '' : String(faceID) } }); break; } case 8: { // 戳一戳 Poke const target = 'target' in seg ? seg.target : ''; - result.push({ type: 'poke', data: { qq: target || '' } }); + result.push({ type: 'poke', data: { qq: target === undefined || target === null ? '' : String(target) } }); break; } default: { @@ -303,8 +314,20 @@ export async function transformArrayToContent(ctx: seal.MsgContext, messageArray const gid = ctx.group ? ctx.group.groupId : ''; const prefix = epId.includes(':') ? epId.slice(0, epId.indexOf(':')) : 'QQ'; const uid = `${prefix}:${seg.data.qq || ''}`; - ({ ctx } = getCtxAndMsg(epId, uid, gid)); - const name = ctx.player!.name || '未知用户'; + if (seg.data.qq === 'all') { + content += '[at:全体成员]'; + break; + } + // OB11 段通常直接带 name;优先使用它,避免为每个 at 额外请求群成员信息。 + let name = seg.data.name || '未知用户'; + try { + if (!seg.data.name) { + const targetCtx = getCtxAndMsg(epId, uid, gid).ctx; + name = targetCtx.player?.name || name; + } + } catch (_e) { + // 目标用户上下文创建失败(如目标不在当前会话)时保留 at 标签,不中断整条消息转换 + } content += `[at:${name}]`; break; } @@ -313,8 +336,13 @@ export async function transformArrayToContent(ctx: seal.MsgContext, messageArray const gid = ctx.group ? ctx.group.groupId : ''; const prefix = epId.includes(':') ? epId.slice(0, epId.indexOf(':')) : 'QQ'; const uid = `${prefix}:${seg.data.qq || ''}`; - ({ ctx } = getCtxAndMsg(epId, uid, gid)); - const name = ctx.player!.name || '未知用户'; + let name = '未知用户'; + try { + const targetCtx = getCtxAndMsg(epId, uid, gid).ctx; + name = targetCtx.player?.name || name; + } catch (_e) { + // 目标用户上下文创建失败时保留 poke 标签,不中断整条消息转换 + } content += `[poke:${name}]`; break; } @@ -330,8 +358,9 @@ export async function transformArrayToContent(ctx: seal.MsgContext, messageArray break; } case 'face': { - const faceName = FACE_MAP[seg.data.id] || ''; - content += faceName ? `[face:${faceName}]` : ''; + const faceName = FACE_MAP[String(seg.data.id)] || ''; + // 未知 face id 也保留占位,避免用户表情静默丢失;名称作为可见文案的一部分 + content += `[face:${faceName || `未知表情${String(seg.data.id)}`}]`; break; } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7144ba4..95b615a 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -51,6 +51,34 @@ export function getRecordMessageId(ctx: seal.MsgContext, msg: seal.Message): str return transformMsgId(msg.rawId); } +/** + * 把 milky 原生消息段的 replySeq(会话内 message_seq)转成 OB11 唯一 message_id。 + * 依赖未安装、参数不合法或转换失败时回退原始 replySeq,保持与无 ob11 依赖时 + * 上下文里 transformMsgId(rawId) 记录方式一致。 + */ +export function getMilkyReplyQuoteId(ctx: seal.MsgContext, replySeq: string | number): string { + const seq = String(replySeq ?? ''); + if (!seq) return ''; + const net = (globalThis as any).net; + if (net && typeof net.messageId === 'function') { + try { + const peerStr = ctx.isPrivate ? (ctx.player && ctx.player.userId) || '' : (ctx.group && ctx.group.groupId) || ''; + const peerMatch = /(\d+)$/.exec(peerStr); + if (peerMatch && /^\d+$/.test(seq)) { + const mid = net.messageId({ + scene: ctx.isPrivate ? 'friend' : 'group', + id: Number(peerMatch[1]), + msgid: Number(seq) + }); + if (mid !== null && mid !== undefined) return String(mid); + } + } catch (e) { + logger.warning(`milky 回复消息 ID 转换失败,回退原始 replySeq:${e instanceof Error ? e.message : String(e)}`); + } + } + return seq; +} + export function generateId() { const timestamp = Date.now().toString(36); // 将时间戳转换为36进制字符串 const random = Math.random().toString(36).substring(2, 6); // 随机数部分