- /clear в личке с ботом показывает чаты с контекстом диалога (название и число реплик), дальше подтверждение - /clear <ID чата> в личке — сразу подтверждение для этого чата; в группе /clear работает как раньше - Общий хелпер chatTitle для /archive и /clear Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
7.1 KiB
TypeScript
140 lines
7.1 KiB
TypeScript
import { Agent } from "@mastra/core/agent";
|
||
import { ProviderHistoryCompat, ToolCallFilter } from "@mastra/core/processors";
|
||
import { RequestContext } from "@mastra/core/request-context";
|
||
import type { Memory } from "@mastra/memory";
|
||
import { readPrompt } from "../../libs/prompts.js";
|
||
import type { Persona } from "../../types.js";
|
||
import type { ChatHistoryService } from "../chat-history-service.js";
|
||
import type { ChatMemberService } from "../chat-member-service.js";
|
||
import { stripPastReasoning } from "./history-compat.js";
|
||
import { chatIdOfThread, chatThreadId } from "./memory.js";
|
||
import { buildInstructions, buildUserText, type Question } from "./prompts.js";
|
||
import { createListChatMembersTool } from "./tools/list-chat-members-tool.js";
|
||
import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js";
|
||
import { createReadMemberMessagesTool } from "./tools/read-member-messages-tool.js";
|
||
import { assistantRequestContextSchema, type AssistantRequestContext } from "./tools/request-context.js";
|
||
|
||
export type { Question } from "./prompts.js";
|
||
|
||
interface AssistantServiceDeps {
|
||
model: string;
|
||
/** Контекст диалога — Mastra Memory (см. memory.ts). */
|
||
memory: Memory;
|
||
/** Максимальная длина ответа в токенах. */
|
||
answerTokenLimit: number;
|
||
/** Умеет ли модель смотреть изображения; если нет — фото ей не отправляем. */
|
||
imageInput: boolean;
|
||
chatHistoryService: ChatHistoryService;
|
||
chatMemberService: ChatMemberService;
|
||
}
|
||
|
||
/** Шаги агента: несколько на чтение архива и участников, последний — всегда текстовый ответ. */
|
||
const MAX_AGENT_STEPS = 6;
|
||
|
||
/**
|
||
* Картинки уходят через `context`: модель видит их в этом запросе, но в память они не сохраняются —
|
||
* иначе байты фото тащились бы в каждый следующий вопрос.
|
||
*/
|
||
const imagesContext = (question: Question) =>
|
||
question.images.length === 0
|
||
? []
|
||
: [
|
||
{
|
||
role: "user" as const,
|
||
content: question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
|
||
},
|
||
];
|
||
|
||
export const createAssistantService = ({
|
||
model,
|
||
memory,
|
||
answerTokenLimit,
|
||
imageInput,
|
||
chatHistoryService,
|
||
chatMemberService,
|
||
}: AssistantServiceDeps) => {
|
||
const systemRules = readPrompt("system.md");
|
||
|
||
const agent = new Agent({
|
||
id: "boltun",
|
||
name: "Boltun",
|
||
requestContextSchema: assistantRequestContextSchema,
|
||
// Одинаковы для всех вопросов чата — провайдер может кешировать этот префикс.
|
||
instructions: ({ requestContext }) =>
|
||
buildInstructions(systemRules, requestContext.get("persona"), requestContext.get("chatTitle")),
|
||
model,
|
||
memory,
|
||
defaultOptions: {
|
||
maxSteps: MAX_AGENT_STEPS,
|
||
modelSettings: { maxOutputTokens: answerTokenLimit },
|
||
// На последнем шаге инструменты запрещены: иначе модель может потратить все шаги на чтение архива
|
||
// и так и не ответить (в ответе бота было бы пусто).
|
||
prepareStep: ({ stepNumber }) => (stepNumber >= MAX_AGENT_STEPS - 1 ? { toolChoice: "none" } : undefined),
|
||
// Провайдеры (OpenRouter/Gemini) присылают ошибку частью ответа — без этого в логе только «finishReason: other».
|
||
onError: ({ error }) => console.error("Ошибка провайдера модели:", error),
|
||
},
|
||
// Результаты инструментов (сотни сообщений архива) нужны только в том ответе, где их запросили:
|
||
// из прошлых реплик их убираем. Внутри ответа не трогаем — иначе модель забывает прочитанное и читает снова.
|
||
// Прошлые размышления модели тоже убираем (см. history-compat.ts) — иначе Gemini ломается на старых подписях.
|
||
inputProcessors: [new ToolCallFilter(), new ProviderHistoryCompat({ additionalRules: [stripPastReasoning] })],
|
||
tools: {
|
||
readChatMessages: createReadChatMessagesTool(chatHistoryService),
|
||
listChatMembers: createListChatMembersTool(chatMemberService),
|
||
readMemberMessages: createReadMemberMessagesTool({ chatHistoryService, chatMemberService }),
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Ответ на вопрос в образе личности. Прошлые вопросы и ответы чата подтягивает Mastra Memory,
|
||
* архив переписки агент читает инструментами, когда нужно.
|
||
*/
|
||
const answer = async (question: Question, persona: Persona): Promise<string> => {
|
||
const requestContext = new RequestContext<AssistantRequestContext>();
|
||
|
||
requestContext.set("chatId", question.chatId);
|
||
requestContext.set("chatTitle", question.chatTitle);
|
||
requestContext.set("persona", persona);
|
||
|
||
const thread = chatThreadId(question.chatId);
|
||
|
||
const result = await agent.generate(buildUserText(question), {
|
||
context: imagesContext(question),
|
||
memory: { thread, resource: thread },
|
||
requestContext,
|
||
});
|
||
|
||
const text = result.text.trim();
|
||
|
||
if (!text) console.warn(`Пустой ответ модели: finishReason=${result.finishReason}, шагов=${result.steps.length}`);
|
||
|
||
return text;
|
||
};
|
||
|
||
/** Сколько сообщений в контексте диалога чата: recall отдаёт `total`, не загружая весь тред. */
|
||
const dialogSize = async (chatId: number): Promise<number> =>
|
||
(await memory.recall({ threadId: chatThreadId(chatId), perPage: 1 })).total;
|
||
|
||
/** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */
|
||
const clearDialog = (chatId: number): Promise<void> => memory.deleteThread(chatThreadId(chatId));
|
||
|
||
/** Чаты, где есть контекст диалога, и сколько в нём реплик — для выбора в личке с ботом. */
|
||
const dialogChats = async (): Promise<{ chatId: number; messages: number }[]> => {
|
||
const { threads } = await memory.listThreads({ perPage: false });
|
||
|
||
const chatIds = threads.flatMap((thread) => {
|
||
const chatId = chatIdOfThread(thread.id);
|
||
|
||
return chatId === undefined ? [] : [chatId];
|
||
});
|
||
|
||
const chats = await Promise.all(chatIds.map(async (chatId) => ({ chatId, messages: await dialogSize(chatId) })));
|
||
|
||
return chats.filter((chat) => chat.messages > 0).sort((a, b) => b.messages - a.messages);
|
||
};
|
||
|
||
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
|
||
return { answer, dialogSize, clearDialog, dialogChats, canSeeImages: imageInput };
|
||
};
|
||
|
||
export type AssistantService = ReturnType<typeof createAssistantService>;
|