Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/AI/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,6 +37,7 @@ export class Context {
lastReply: string;
counter: number;
timer: number;
compressingContext: boolean;

constructor() {
this.messages = [];
Expand All @@ -43,6 +46,7 @@ export class Context {
this.lastReply = '';
this.counter = 0;
this.timer = null;
this.compressingContext = false;
}

reviveMessages() {
Expand Down Expand Up @@ -156,6 +160,9 @@ export class Context {
//更新记忆权重
ai.memory.updateRelatedMemoryWeight(ctx, ai.context, content, role);

// 压缩早期上下文
await this.compressMessagesIfNeeded(ctx);

//删除多余的上下文
this.limitMessages();
}
Expand Down Expand Up @@ -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<UserInfo> {
name = String(name);
if (!name) return null;
Expand Down
61 changes: 59 additions & 2 deletions src/config/config_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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模板")
}
}
}
}
38 changes: 38 additions & 0 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,44 @@ export async function sendITTRequest(messages: {
}
}

export async function sendContextCompressRequest(messages: {
role: string,
content: string
}[]): Promise<string> {
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<number[]> {
Expand Down
30 changes: 19 additions & 11 deletions src/utils/utils_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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
Expand All @@ -191,16 +199,16 @@ 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--; // 调整索引
}
}

// 处理前缀并合并消息(如果有)
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);
Expand Down Expand Up @@ -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] }
}
}