diff --git a/src/AI/context.ts b/src/AI/context.ts index 7c3b81a..aca7b63 100644 --- a/src/AI/context.ts +++ b/src/AI/context.ts @@ -7,6 +7,8 @@ import { AI, AIManager, GroupInfo, UserInfo } from "./AI"; import { logger } from "../logger"; import { netExists, getFriendList, getGroupList, getGroupMemberInfo, getGroupMemberList, getStrangerInfo } from "../utils/utils_ob11"; import { revive } from "../utils/utils"; +import { sendContextCompressRequest } from "../service"; +import { buildRequestMessages } from "../utils/utils_message"; export interface MessageInfo { msgId: string; @@ -35,6 +37,7 @@ export class Context { lastReply: string; counter: number; timer: number; + compressingContext: boolean; constructor() { this.messages = []; @@ -43,6 +46,7 @@ export class Context { this.lastReply = ''; this.counter = 0; this.timer = null; + this.compressingContext = false; } reviveMessages() { @@ -156,6 +160,9 @@ export class Context { //更新记忆权重 ai.memory.updateRelatedMemoryWeight(ctx, ai.context, content, role); + // 压缩早期上下文 + await this.compressMessagesIfNeeded(ctx); + //删除多余的上下文 this.limitMessages(); } @@ -228,6 +235,93 @@ export class Context { } } + async compressMessagesIfNeeded(ctx: seal.MsgContext) { + const { isContextCompress, contextCompressLength, contextCompressPromptTemplate, maxRounds, isPrefix, showNumber, showMsgId, showTime } = ConfigManager.message; + const { localImagePathMap, receiveImage, condition } = ConfigManager.image; + const messages = this.messages; + if (!isContextCompress) return; + if (this.compressingContext) return; + if (maxRounds <= 0 || contextCompressLength <= 0) return; + + const round = messages.filter(message => message.role === 'user' && !message.name.startsWith('_')).length; + if (round < maxRounds) return; + + let compressCount = Math.min(contextCompressLength, messages.length - 1); + if (compressCount <= 0) return; + + // 避免切断 tool_call,向前移动 + while (compressCount > 0) { + const left = messages[compressCount - 1]; + const right = messages[compressCount]; + const leftIsTool = left?.role === 'tool'; + const rightIsTool = right?.role === 'tool'; + const leftIsToolCall = left?.role === 'assistant' && left?.tool_calls && left.tool_calls.length > 0; + if (!(leftIsTool || rightIsTool || leftIsToolCall)) { + break; + } + compressCount--; + } + if (compressCount <= 0 || compressCount >= messages.length) return; + + const compressMessages = messages.slice(0, compressCount); + const requestMessages = buildRequestMessages(compressMessages); + if (requestMessages.length === 0) return; + + const sandableImagesPrompt: string = Object.keys(localImagePathMap) + .map((id, index) => `${index + 1}. ${id}`) + .join('\n'); + + this.compressingContext = true; + try { + const prompt = contextCompressPromptTemplate({ + "平台": ctx.endPoint.platform, + "私聊": ctx.isPrivate, + "展示号码": showNumber, + "用户名称": ctx.player.name, + "用户号码": ctx.player.userId.replace(/^.+:/, ''), + "群聊名称": ctx.group.groupName, + "群聊号码": ctx.group.groupId.replace(/^.+:/, ''), + "添加前缀": isPrefix, + "展示消息ID": showMsgId, + "展示时间": showTime, + "接收图片": receiveImage, + "图片条件不为零": condition !== '0', + "可发送图片不为空": sandableImagesPrompt, + "可发送图片列表": sandableImagesPrompt + }); + logger.info(`上下文压缩prompt:\n`, prompt); + + const summary = (await sendContextCompressRequest([ + { role: 'system', content: prompt }, + ...requestMessages + ])).trim(); + if (!summary) { + logger.warning(`上下文压缩返回为空,跳过本次压缩`); + return; + } + + const now = Math.floor(Date.now() / 1000); + const summaryMessage: Message = { + role: 'user', + uid: '', + name: '_历史上下文摘要', + images: [], + msgArray: [{ + msgId: '', + time: now, + content: `以下为历史对话摘要:\n${summary}` + }] + }; + + messages.splice(0, compressCount, summaryMessage); + logger.info(`上下文压缩完成,压缩条数:${compressCount},压缩后条数:${messages.length}`); + } catch (e) { + logger.error(`上下文压缩失败: ${e.message}`); + } finally { + this.compressingContext = false; + } + } + async findUserInfo(ctx: seal.MsgContext, name: string | number, findInFriendList: boolean = false): Promise { name = String(name); if (!name) return null; diff --git a/src/config/config_message.ts b/src/config/config_message.ts index 2f122e5..1c30ce1 100644 --- a/src/config/config_message.ts +++ b/src/config/config_message.ts @@ -98,6 +98,57 @@ export class MessageConfig { seal.ext.registerBoolConfig(MessageConfig.ext, "是否合并user content", false, "在不支持连续多个role为user的情况下开启,可用于适配deepseek-reasoner"); seal.ext.registerIntConfig(MessageConfig.ext, "存储上下文对话限制轮数", 15, "出现一次user视作一轮"); seal.ext.registerIntConfig(MessageConfig.ext, "上下文插入system message间隔轮数", 0, "需要小于限制轮数的二分之一才能生效,为0时不生效,示例对话不计入轮数"); + + seal.ext.registerBoolConfig(MessageConfig.ext, "是否启用上下文压缩", false, ''); + seal.ext.registerIntConfig(MessageConfig.ext, "每次压缩上下文条数", 10, '优先压缩最早的上下文'); + seal.ext.registerStringConfig(MessageConfig.ext, "上下文压缩 url地址", "", '为空时默认使用对话接口'); + seal.ext.registerStringConfig(MessageConfig.ext, "上下文压缩 API Key", "你的API Key", '若使用对话接口无需填写'); + seal.ext.registerTemplateConfig(MessageConfig.ext, "上下文压缩 body", [ + `"model":"deepseek-chat"`, + `"max_tokens":1024`, + `"stop":null`, + `"stream":false` + ], "messages不存在时,将会自动替换"); + seal.ext.registerTemplateConfig(MessageConfig.ext, "上下文压缩prompt模板", [ + `你是QQ群聊对话压缩助手。请将后续给出的历史消息压缩为可供后续继续对话的一段摘要。 + +## 聊天相关 + - 当前平台:{{{平台}}} +{{#if 私聊}} + - 当前私聊:<{{{用户名称}}}>{{#if 展示号码}}({{{用户号码}}}){{/if}} +{{else}} + - 当前群聊:<{{{群聊名称}}}>{{#if 展示号码}}({{{群聊号码}}}){{/if}} + - <|at:xxx|>表示@某个群成员 + - <|poke:xxx|>表示戳一戳某个群成员 +{{/if}} +{{#if 添加前缀}} + - <|from:xxx|>表示消息来源,不要在生成的回复中使用 +{{/if}} +{{#if 展示消息ID}} + - <|msg_id:xxx|>表示消息ID,仅用于调用函数时使用,不要在生成的回复中提及或使用 + - <|quote:xxx|>表示引用消息,xxx为对应的消息ID + - <|face:xxx|>表示使用某个表情,xxx为表情名称,注意与img表情包区分 +{{/if}} +{{#if 展示时间}} + - <|time:xxxx-xx-xx xx:xx:xx|>表示消息发送时间,不要在生成的回复中提及或使用 +{{/if}} + - \\f用于分割多条消息 + +## 图片相关 +{{#if 接收图片}} +{{#if 图片条件不为零}} + - <|img:xxxxxx:yyy|>为图片,其中xxxxxx为6位的图片id,yyy为图片描述(可能没有),如果要发送出现过的图片请使用<|img:xxxxxx|>的格式 +{{else}} + - <|img:xxxxxx|>为图片,其中xxxxxx为6位的图片id,如果要发送出现过的图片请使用<|img:xxxxxx|>的格式 +{{/if}} +{{/if}} + +## 输出要求 +1. 保留人物关系、主要话题、关键事实、明确结论、未完成事项、后续约定。 +2. 需要体现发言归属,避免把不同人的观点混淆。 +3. 忽略闲聊、重复、噪声内容,但不要丢失约束信息。 +4. 不要编造,不要解释,不要使用JSON,只输出摘要正文。` + ], ""); } static get() { @@ -112,7 +163,13 @@ export class MessageConfig { showTime: seal.ext.getBoolConfig(MessageConfig.ext, "是否在消息内添加发送时间"), isMerge: seal.ext.getBoolConfig(MessageConfig.ext, "是否合并user content"), maxRounds: seal.ext.getIntConfig(MessageConfig.ext, "存储上下文对话限制轮数"), - insertCount: seal.ext.getIntConfig(MessageConfig.ext, "上下文插入system message间隔轮数") + insertCount: seal.ext.getIntConfig(MessageConfig.ext, "上下文插入system message间隔轮数"), + isContextCompress: seal.ext.getBoolConfig(MessageConfig.ext, "是否启用上下文压缩"), + contextCompressLength: seal.ext.getIntConfig(MessageConfig.ext, "每次压缩上下文条数"), + contextCompressUrl: seal.ext.getStringConfig(MessageConfig.ext, "上下文压缩 url地址"), + contextCompressApiKey: seal.ext.getStringConfig(MessageConfig.ext, "上下文压缩 API Key"), + contextCompressBodyTemplate: seal.ext.getTemplateConfig(MessageConfig.ext, "上下文压缩 body"), + contextCompressPromptTemplate: ConfigManager.getHandlebarsTemplateConfig(MessageConfig.ext, "上下文压缩prompt模板") } } -} \ No newline at end of file +} diff --git a/src/service.ts b/src/service.ts index 1ed8e0e..d93b6e0 100644 --- a/src/service.ts +++ b/src/service.ts @@ -78,6 +78,44 @@ export async function sendITTRequest(messages: { } } +export async function sendContextCompressRequest(messages: { + role: string, + content: string +}[]): Promise { + const { timeout, url: chatUrl, apiKey: chatApiKey } = ConfigManager.request; + const { contextCompressUrl, contextCompressApiKey, contextCompressBodyTemplate } = ConfigManager.message; + + let url = chatUrl; + let apiKey = chatApiKey; + if (contextCompressUrl.trim()) { + url = contextCompressUrl; + apiKey = contextCompressApiKey; + } + + try { + const bodyObject = parseBody(contextCompressBodyTemplate, messages, [], "none"); + const time = Date.now(); + + const data = await withTimeout(() => fetchData(url, apiKey, bodyObject), timeout); + + if (data.choices && data.choices.length > 0) { + AIManager.updateUsage(data.model, data.usage); + + const message = data.choices[0].message; + const content = message.content || ''; + + logger.info(`上下文压缩响应内容:`, content, '\nlatency:', Date.now() - time, 'ms'); + + return content; + } else { + throw new Error(`服务器响应中没有choices或choices为空\n响应体:${JSON.stringify(data, null, 2)}`); + } + } catch (e) { + logger.error("在sendContextCompressRequest中请求出错:", e.message); + return ''; + } +} + const vectorCache: { text: string, vector: number[] } = { text: '', vector: [] }; export async function getEmbedding(text: string): Promise { diff --git a/src/utils/utils_message.ts b/src/utils/utils_message.ts index 1f812ba..1371407 100644 --- a/src/utils/utils_message.ts +++ b/src/utils/utils_message.ts @@ -156,28 +156,36 @@ function buildContextMessages(systemMessage: Message, messages: Message[]): Mess } export async function handleMessages(ctx: seal.MsgContext, ai: AI) { - const { isMerge } = ConfigManager.message; - const systemMessage = await buildSystemMessage(ctx, ai); const samplesMessages = buildSamplesMessages(ctx); const contextMessages = buildContextMessages(systemMessage, ai.context.messages); const messages = [systemMessage, ...samplesMessages, ...contextMessages]; + return buildRequestMessages(messages); +} + +export function buildRequestMessages(messages: Message[]) { + const { isMerge } = ConfigManager.message; + + const copiedMessages = messages.map(message => ({ + ...message, + tool_calls: message.tool_calls ? message.tool_calls.slice() : undefined + })); // 处理 tool_calls 并过滤无效项 - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; + for (let i = 0; i < copiedMessages.length; i++) { + const message = copiedMessages[i]; if (!message?.tool_calls) { continue; } // 获取tool_calls消息后面的所有tool_call_id const tool_call_id_set = new Set(); - for (let j = i + 1; j < messages.length; j++) { - if (messages[j].role !== 'tool') { + for (let j = i + 1; j < copiedMessages.length; j++) { + if (copiedMessages[j].role !== 'tool') { break; } - tool_call_id_set.add(messages[j].tool_call_id); + tool_call_id_set.add(copiedMessages[j].tool_call_id); } // 过滤无对应 tool_call_id 的 tool_calls @@ -191,7 +199,7 @@ export async function handleMessages(ctx: seal.MsgContext, ai: AI) { // 如果 tool_calls 为空则移除消息 if (message.tool_calls.length === 0) { - messages.splice(i, 1); + copiedMessages.splice(i, 1); i--; // 调整索引 } } @@ -199,8 +207,8 @@ export async function handleMessages(ctx: seal.MsgContext, ai: AI) { // 处理前缀并合并消息(如果有) let processedMessages = []; let last_role = ''; - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; + for (let i = 0; i < copiedMessages.length; i++) { + const message = copiedMessages[i]; if (isMerge && message.role === last_role && message.role !== 'tool') { processedMessages[processedMessages.length - 1].content += '\f' + buildContent(message); @@ -317,4 +325,4 @@ export function getRoleSetting(ctx: seal.MsgContext) { if (exists2 && roleIndex2 >= 0 && roleIndex2 < roleSettingTemplate.length) roleIndex = roleIndex2; } return { roleName, roleIndex, roleSetting: roleSettingTemplate[roleIndex] } -} \ No newline at end of file +}