Files
boltun-ai/src/services/assistant/assistant-service.ts
yunogasai 08108f5e1f /clear из лички: выбор чата кнопками с подтверждением
- /clear в личке с ботом показывает чаты с контекстом диалога (название и число реплик), дальше подтверждение
- /clear <ID чата> в личке — сразу подтверждение для этого чата; в группе /clear работает как раньше
- Общий хелпер chatTitle для /archive и /clear

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:17:18 +02:00

140 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;