Контекст диалога отдельно от архива чата

- Новая таблица dialog_messages: последние DIALOG_LIMIT пар «вопрос — ответ», подкладываются в каждый запрос
- /clear сбрасывает только контекст диалога, архив (включая импорт) не трогает; без подтверждения
- /context показывает контекст и архив по отдельности

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 20:42:14 +02:00
parent c5f87f3195
commit b8f09227b0
17 changed files with 167 additions and 53 deletions

View File

@@ -1,10 +1,11 @@
import { Agent } from "@mastra/core/agent";
import { RequestContext } from "@mastra/core/request-context";
import { readPrompt } from "../../libs/prompts.js";
import type { Persona } from "../../types.js";
import type { DialogTurn, Persona } from "../../types.js";
import type { ChatHistoryService } from "../chat-history-service.js";
import type { ChatMemberService } from "../chat-member-service.js";
import { buildInstructions, buildPrompt, type Question } from "./prompts.js";
import type { DialogService } from "../dialog-service.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";
@@ -18,6 +19,7 @@ interface AssistantServiceDeps {
imageInput: boolean;
chatHistoryService: ChatHistoryService;
chatMemberService: ChatMemberService;
dialogService: DialogService;
}
const MAX_AGENT_STEPS = 6;
@@ -26,16 +28,23 @@ const MAX_AGENT_STEPS = 6;
const buildUserMessage = (question: Question) => ({
role: "user" as const,
content: [
{ type: "text" as const, text: buildPrompt(question) },
{ type: "text" as const, text: buildUserText(question) },
...question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
],
});
/** Реплика прошлого диалога в формате сообщений Mastra (роль должна быть литералом). */
const toModelMessage = (turn: DialogTurn) =>
turn.role === "user"
? { role: "user" as const, content: turn.content }
: { role: "assistant" as const, content: turn.content };
export const createAssistantService = ({
model,
imageInput,
chatHistoryService,
chatMemberService,
dialogService,
}: AssistantServiceDeps) => {
const systemRules = readPrompt("system.md");
@@ -51,19 +60,29 @@ export const createAssistantService = ({
},
});
/** Ответ на вопрос в образе личности; агент сам решает, какую историю чата подгрузить. */
/**
* Ответ на вопрос в образе личности. Контекст диалога (прошлые вопросы и ответы) подкладываем сами,
* архив чата агент подгружает инструментами, когда нужно.
*/
const answer = async (question: Question, persona: Persona): Promise<string> => {
const requestContext = new RequestContext<AssistantRequestContext>();
requestContext.set("chatId", question.chatId);
const result = await agent.generate([buildUserMessage(question)], {
const history = dialogService.recent(question.chatId).map(toModelMessage);
const result = await agent.generate([...history, buildUserMessage(question)], {
instructions: buildInstructions(systemRules, question, persona),
requestContext,
maxSteps: MAX_AGENT_STEPS,
});
return result.text.trim();
const text = result.text.trim();
// Картинки в контекст не кладём — только текст вопроса; пустой ответ не запоминаем.
if (text) dialogService.remember(question.chatId, buildUserText(question), text);
return text;
};
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */

View File

@@ -66,3 +66,6 @@ export const buildPrompt = (question: Question): string => {
return [replied, imagesNote(question), replied ? `Вопрос: ${asked}` : asked].filter(Boolean).join("\n\n");
};
/** Текст реплики пользователя — с именем, потому что в чате боту пишут разные админы. */
export const buildUserText = (question: Question): string => `${question.askerName}:\n${buildPrompt(question)}`;

View File

@@ -21,10 +21,7 @@ export const createChatHistoryService = ({ messageRepository, historyLimit }: Ch
const stats = (chatId: number) => ({ ...messageRepository.statsByChat(chatId), limit: historyLimit });
/** Забыть переписку чата: модель больше не увидит сообщения, сохранённые до этого момента. */
const clear = (chatId: number): number => messageRepository.deleteByChat(chatId);
return { record, latest, latestByUser, find, stats, clear };
return { record, latest, latestByUser, find, stats };
};
export type ChatHistoryService = ReturnType<typeof createChatHistoryService>;

View File

@@ -0,0 +1,27 @@
import type { DialogRepository } from "../repositories/dialog-repository.js";
import type { DialogTurn } from "../types.js";
interface DialogServiceDeps {
dialogRepository: DialogRepository;
/** Сколько последних пар «вопрос — ответ» модель видит в каждом запросе. */
exchangesLimit: number;
}
/** Контекст диалога с ботом — то, что модель «помнит» между вопросами. Архив чата живёт отдельно. */
export const createDialogService = ({ dialogRepository, exchangesLimit }: DialogServiceDeps) => {
const turnsLimit = exchangesLimit * 2;
const recent = (chatId: number): DialogTurn[] => dialogRepository.latest(chatId, turnsLimit);
const remember = (chatId: number, question: string, answer: string): void =>
dialogRepository.appendExchangeAndPrune(chatId, question, answer, turnsLimit);
const stats = (chatId: number) => ({ exchanges: Math.floor(dialogRepository.countByChat(chatId) / 2), exchangesLimit });
/** Начать диалог с чистого листа; архив сообщений чата не трогаем. Возвращает, сколько пар забыто. */
const clear = (chatId: number): number => Math.floor(dialogRepository.deleteByChat(chatId) / 2);
return { recent, remember, stats, clear };
};
export type DialogService = ReturnType<typeof createDialogService>;