Контекст диалога отдельно от архива чата
- Новая таблица dialog_messages: последние DIALOG_LIMIT пар «вопрос — ответ», подкладываются в каждый запрос - /clear сбрасывает только контекст диалога, архив (включая импорт) не трогает; без подтверждения - /context показывает контекст и архив по отдельности Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ OPENROUTER_API_KEY=
|
||||
# Необязательно: умеет ли модель смотреть фото. Для своей AI_MODEL по умолчанию false — включи, если модель видит картинки
|
||||
# AI_IMAGE_INPUT=true
|
||||
|
||||
# Путь к SQLite-базе и сколько сообщений на чат хранить
|
||||
# Путь к SQLite-базе и сколько сообщений на чат хранить в архиве
|
||||
DB_PATH=data/bot.db
|
||||
HISTORY_LIMIT=5000
|
||||
|
||||
# Сколько последних пар «вопрос — ответ» бот помнит в контексте диалога
|
||||
DIALOG_LIMIT=10
|
||||
|
||||
@@ -31,6 +31,7 @@ src/
|
||||
prompts/ — тексты промптов: system.md (правила бота) и personas/<id>.md
|
||||
```
|
||||
|
||||
- Память разделена: архив чата (`messages`, читается инструментами) и контекст диалога (`dialog_messages`, подкладывается в каждый запрос). `/clear` чистит только контекст.
|
||||
- Промпты храним только в `prompts/*.md`, не в коде.
|
||||
- Личности не редактируются из чата: источник правды — код и `prompts/`, в БД их кладёт `seed`.
|
||||
- Изменения схемы БД — через `migrate` в `libs/db.ts` (у пользователей уже есть базы).
|
||||
|
||||
@@ -9,7 +9,10 @@ Telegram-бот для групповых чатов с AI-личностями.
|
||||
- **Контекст из чата**: бот сохраняет сообщения чата в SQLite (последние `HISTORY_LIMIT` на чат). Попросите «посмотри последние 100 сообщений и перескажи» — агент сам вызовет инструмент `read_chat_messages` и загрузит переписку в контекст. Если задать `/ask` ответом на чьё-то сообщение, оно тоже попадёт в контекст.
|
||||
- **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени.
|
||||
- **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет.
|
||||
- **Память чата**: `/context` — сколько сообщений и участников бот помнит, ID чата и видит ли он всю переписку; `/clear` — забыть сохранённую переписку (с подтверждением, имена участников остаются); `/import` — загрузить старую переписку из экспорта (см. ниже).
|
||||
- **Память — две части**:
|
||||
- *Контекст диалога* — последние `DIALOG_LIMIT` вопросов к боту и его ответов в этом чате. Подкладывается в каждый запрос, поэтому можно уточнять «а подробнее?». `/clear` сбрасывает только его.
|
||||
- *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает.
|
||||
- `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже).
|
||||
- **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D).
|
||||
- `/personas` — список и выбор кнопками
|
||||
- `/persona <id>` — выбрать
|
||||
@@ -63,7 +66,8 @@ pnpm dev
|
||||
|
||||
\* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте.
|
||||
| `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) |
|
||||
| `HISTORY_LIMIT` | нет | сколько сообщений на чат хранить, по умолчанию 5000 |
|
||||
| `HISTORY_LIMIT` | нет | сколько сообщений на чат хранить в архиве, по умолчанию 5000 |
|
||||
| `DIALOG_LIMIT` | нет | сколько последних пар «вопрос — ответ» бот помнит в контексте, по умолчанию 10 |
|
||||
|
||||
## Деплой (Dokploy)
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Будь кратким, если не просят подробностей.
|
||||
Обращайся к людям по имени, а не по нику. Если говоришь о другом участнике — тоже называй его по имени.
|
||||
|
||||
Память:
|
||||
- Предыдущие вопросы к тебе и твои ответы в этом чате уже есть выше в диалоге — это твой контекст.
|
||||
- Остальная переписка чата лежит в архиве — читай её инструментами, когда нужно.
|
||||
|
||||
Инструменты:
|
||||
- read_chat_messages — последние сообщения чата. Когда просят посмотреть/прочитать/пересказать переписку или вопрос касается того, что обсуждали.
|
||||
- list_chat_members — участники чата с именами и никами. Когда человека называют по имени или прозвищу и нужно понять, кто это.
|
||||
|
||||
@@ -2,8 +2,8 @@ export const BOT_COMMANDS = [
|
||||
{ command: "ask", description: "Задать вопрос боту" },
|
||||
{ command: "personas", description: "Список личностей и выбор" },
|
||||
{ command: "persona", description: "Выбрать личность: /persona <id>" },
|
||||
{ command: "context", description: "Что бот помнит о чате и видит ли переписку" },
|
||||
{ command: "clear", description: "Очистить сохранённую переписку чата" },
|
||||
{ command: "context", description: "Что бот помнит: контекст диалога и архив чата" },
|
||||
{ command: "clear", description: "Сбросить контекст диалога (архив остаётся)" },
|
||||
{ command: "import", description: "Загрузить старую переписку из экспорта Telegram" },
|
||||
{ command: "help", description: "Справка" },
|
||||
];
|
||||
@@ -22,8 +22,10 @@ export const helpText = (username: string) => `Я отвечаю только а
|
||||
Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт — но их можно загрузить из экспорта.
|
||||
|
||||
Память:
|
||||
• /context — что я помню о чате и вижу ли всю переписку
|
||||
• /clear — забыть сохранённую переписку
|
||||
• Помню последние вопросы ко мне и свои ответы — можно уточнять «а подробнее?».
|
||||
• Всю переписку чата храню отдельно, в архиве, и читаю её, когда нужно.
|
||||
• /context — что сейчас в контексте и в архиве, вижу ли я всю переписку
|
||||
• /clear — сбросить контекст диалога (архив не трогаю)
|
||||
• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON)
|
||||
|
||||
Личности:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Bot, type Context } from "grammy";
|
||||
import type { AssistantService } from "../services/assistant/assistant-service.js";
|
||||
import type { ChatHistoryService } from "../services/chat-history-service.js";
|
||||
import type { ChatMemberService } from "../services/chat-member-service.js";
|
||||
import type { DialogService } from "../services/dialog-service.js";
|
||||
import type { HistoryImportService } from "../services/history-import-service.js";
|
||||
import type { PersonaService } from "../services/persona-service.js";
|
||||
import { createContextHandlers } from "./handlers/context-handlers.js";
|
||||
@@ -17,6 +18,7 @@ interface BotDeps {
|
||||
personaService: PersonaService;
|
||||
chatHistoryService: ChatHistoryService;
|
||||
chatMemberService: ChatMemberService;
|
||||
dialogService: DialogService;
|
||||
historyImportService: HistoryImportService;
|
||||
assistantService: AssistantService;
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export const createBot = ({
|
||||
personaService,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
historyImportService,
|
||||
assistantService,
|
||||
}: BotDeps): Bot => {
|
||||
@@ -42,7 +45,7 @@ export const createBot = ({
|
||||
|
||||
admin.use(createHelpHandlers());
|
||||
admin.use(createPersonaHandlers(personaService));
|
||||
admin.use(createContextHandlers({ chatHistoryService, chatMemberService }));
|
||||
admin.use(createContextHandlers({ chatHistoryService, chatMemberService, dialogService }));
|
||||
admin.use(createImportHandlers(historyImportService));
|
||||
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Composer, InlineKeyboard, type Context } from "grammy";
|
||||
import { Composer, type Context } from "grammy";
|
||||
import { formatUtc } from "../../libs/time.js";
|
||||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||
|
||||
const CLEAR_CONFIRM = "clear:yes";
|
||||
|
||||
const CLEAR_CANCEL = "clear:no";
|
||||
import type { DialogService } from "../../services/dialog-service.js";
|
||||
|
||||
interface ContextHandlersDeps {
|
||||
chatHistoryService: ChatHistoryService;
|
||||
chatMemberService: ChatMemberService;
|
||||
dialogService: DialogService;
|
||||
}
|
||||
|
||||
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
|
||||
@@ -27,48 +25,36 @@ const visibilityText = async (ctx: Context): Promise<string> => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Что бот помнит о чате и очистка этой памяти. Подключать только за фильтром администратора. */
|
||||
export const createContextHandlers = ({ chatHistoryService, chatMemberService }: ContextHandlersDeps) => {
|
||||
/** Что бот помнит о чате: контекст диалога и архив переписки. Подключать только за фильтром администратора. */
|
||||
export const createContextHandlers = ({ chatHistoryService, chatMemberService, dialogService }: ContextHandlersDeps) => {
|
||||
const composer = new Composer();
|
||||
|
||||
composer.command("context", async (ctx) => {
|
||||
const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id);
|
||||
const dialog = dialogService.stats(ctx.chat.id);
|
||||
const archive = chatHistoryService.stats(ctx.chat.id);
|
||||
const members = chatMemberService.list(ctx.chat.id).length;
|
||||
|
||||
const since = oldest === null ? "" : `, с ${formatUtc(oldest)}`;
|
||||
const since = archive.oldest === null ? "" : `, с ${formatUtc(archive.oldest)}`;
|
||||
|
||||
const lines = [
|
||||
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
|
||||
`Контекст диалога: ${dialog.exchanges} из ${dialog.exchangesLimit} последних вопросов и ответов. Сбросить — /clear.`,
|
||||
`Архив чата: ${archive.count} сообщений из ${archive.limit} возможных${since}. Читаю его, когда нужно.`,
|
||||
`Знаю участников: ${members}.`,
|
||||
await visibilityText(ctx),
|
||||
`ID чата: ${ctx.chat.id}`,
|
||||
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт — загрузи экспорт через /import.",
|
||||
"Очистить переписку: /clear",
|
||||
"Сообщения до моего добавления Telegram не отдаёт — загрузи экспорт через /import.",
|
||||
];
|
||||
|
||||
await ctx.reply(lines.join("\n"));
|
||||
});
|
||||
|
||||
composer.command("clear", async (ctx) => {
|
||||
const { count } = chatHistoryService.stats(ctx.chat.id);
|
||||
const forgotten = dialogService.clear(ctx.chat.id);
|
||||
|
||||
const keyboard = new InlineKeyboard().text("🗑 Да, очистить", CLEAR_CONFIRM).text("Отмена", CLEAR_CANCEL);
|
||||
|
||||
await ctx.reply(`Забыть сохранённую переписку этого чата (${count} сообщений)? Имена участников останутся.`, {
|
||||
reply_markup: keyboard,
|
||||
});
|
||||
});
|
||||
|
||||
composer.callbackQuery(CLEAR_CONFIRM, async (ctx) => {
|
||||
const removed = ctx.chat ? chatHistoryService.clear(ctx.chat.id) : 0;
|
||||
|
||||
await ctx.answerCallbackQuery({ text: "Контекст очищен" });
|
||||
await ctx.editMessageText(`Готово: забыл ${removed} сообщений. Начинаю с чистого листа.`).catch(() => {});
|
||||
});
|
||||
|
||||
composer.callbackQuery(CLEAR_CANCEL, async (ctx) => {
|
||||
await ctx.answerCallbackQuery({ text: "Отменено" });
|
||||
await ctx.editMessageText("Очистка отменена.").catch(() => {});
|
||||
await ctx.reply(
|
||||
`Контекст диалога сброшен (забыл ${forgotten} вопросов и ответов), начинаю с чистого листа. ` +
|
||||
"Архив переписки чата на месте.",
|
||||
);
|
||||
});
|
||||
|
||||
return composer;
|
||||
|
||||
@@ -96,4 +96,6 @@ export const loadBotConfig = () => ({
|
||||
botToken: required("BOT_TOKEN"),
|
||||
adminIds: parseAdminIds(required("ADMIN_IDS")),
|
||||
ai: parseModel(),
|
||||
/** Сколько последних пар «вопрос — ответ» модель помнит в каждом чате. */
|
||||
dialogLimit: positiveInt("DIALOG_LIMIT", 10),
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ import { DB_PATH, HISTORY_LIMIT, loadBotConfig } from "./config.js";
|
||||
import { createDb } from "./libs/db.js";
|
||||
import { createChatMemberRepository } from "./repositories/chat-member-repository.js";
|
||||
import { createChatSettingsRepository } from "./repositories/chat-settings-repository.js";
|
||||
import { createDialogRepository } from "./repositories/dialog-repository.js";
|
||||
import { createMessageRepository } from "./repositories/message-repository.js";
|
||||
import { createPersonaRepository } from "./repositories/persona-repository.js";
|
||||
import { createAssistantService } from "./services/assistant/assistant-service.js";
|
||||
import { createChatHistoryService } from "./services/chat-history-service.js";
|
||||
import { createChatMemberService } from "./services/chat-member-service.js";
|
||||
import { createDialogService } from "./services/dialog-service.js";
|
||||
import { createHistoryImportService } from "./services/history-import-service.js";
|
||||
import { createPersonaService } from "./services/persona-service.js";
|
||||
|
||||
@@ -31,6 +33,11 @@ const chatHistoryService = createChatHistoryService({ messageRepository, history
|
||||
|
||||
const chatMemberService = createChatMemberService({ chatMemberRepository });
|
||||
|
||||
const dialogService = createDialogService({
|
||||
dialogRepository: createDialogRepository(db),
|
||||
exchangesLimit: config.dialogLimit,
|
||||
});
|
||||
|
||||
const historyImportService = createHistoryImportService({
|
||||
messageRepository,
|
||||
chatMemberRepository,
|
||||
@@ -42,6 +49,7 @@ const assistantService = createAssistantService({
|
||||
imageInput: config.ai.imageInput,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
});
|
||||
|
||||
const bot = createBot({
|
||||
@@ -50,6 +58,7 @@ const bot = createBot({
|
||||
personaService,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
historyImportService,
|
||||
assistantService,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,17 @@ const SCHEMA = `
|
||||
last_name TEXT,
|
||||
PRIMARY KEY (chat_id, user_id)
|
||||
);
|
||||
|
||||
-- Контекст диалога с ботом (вопросы и ответы) — отдельно от архива чата, /clear чистит только его.
|
||||
CREATE TABLE IF NOT EXISTS dialog_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dialog_by_chat ON dialog_messages (chat_id, id);
|
||||
`;
|
||||
|
||||
const hasColumn = (db: Db, table: string, column: string): boolean =>
|
||||
|
||||
41
src/repositories/dialog-repository.ts
Normal file
41
src/repositories/dialog-repository.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Db } from "../libs/db.js";
|
||||
import type { DialogTurn } from "../types.js";
|
||||
|
||||
export const createDialogRepository = (db: Db) => {
|
||||
const statements = {
|
||||
insert: db.prepare("INSERT INTO dialog_messages (chat_id, role, content, created_at) VALUES (?, ?, ?, ?)"),
|
||||
pruneChat: db.prepare(`
|
||||
DELETE FROM dialog_messages WHERE chat_id = ? AND id <= (
|
||||
SELECT id FROM dialog_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?
|
||||
)
|
||||
`),
|
||||
latest: db.prepare("SELECT role, content FROM dialog_messages WHERE chat_id = ? ORDER BY id DESC LIMIT ?"),
|
||||
countByChat: db.prepare("SELECT COUNT(*) AS count FROM dialog_messages WHERE chat_id = ?"),
|
||||
deleteByChat: db.prepare("DELETE FROM dialog_messages WHERE chat_id = ?"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Записывает вопрос и ответ парой и оставляет в чате не больше `keep` последних реплик.
|
||||
* Пары пишутся атомарно, поэтому при чётном `keep` контекст всегда начинается с вопроса.
|
||||
*/
|
||||
const appendExchangeAndPrune = db.transaction((chatId: number, question: string, answer: string, keep: number) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
statements.insert.run(chatId, "user", question, now);
|
||||
statements.insert.run(chatId, "assistant", answer, now);
|
||||
statements.pruneChat.run(chatId, chatId, keep);
|
||||
});
|
||||
|
||||
/** Последние `limit` реплик диалога в хронологическом порядке. */
|
||||
const latest = (chatId: number, limit: number): DialogTurn[] =>
|
||||
(statements.latest.all(chatId, limit) as DialogTurn[]).reverse();
|
||||
|
||||
const countByChat = (chatId: number): number => (statements.countByChat.get(chatId) as { count: number }).count;
|
||||
|
||||
/** Забыть диалог чата; возвращает, сколько реплик удалено. */
|
||||
const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes;
|
||||
|
||||
return { appendExchangeAndPrune, latest, countByChat, deleteByChat };
|
||||
};
|
||||
|
||||
export type DialogRepository = ReturnType<typeof createDialogRepository>;
|
||||
@@ -44,7 +44,6 @@ export const createMessageRepository = (db: Db) => {
|
||||
),
|
||||
findById: db.prepare("SELECT * FROM messages WHERE chat_id = ? AND message_id = ?"),
|
||||
statsByChat: db.prepare("SELECT COUNT(*) AS count, MIN(date) AS oldest FROM messages WHERE chat_id = ?"),
|
||||
deleteByChat: db.prepare("DELETE FROM messages WHERE chat_id = ?"),
|
||||
};
|
||||
|
||||
const toChronological = (rows: unknown[]): ChatMessage[] => (rows as MessageRow[]).reverse().map(toMessage);
|
||||
@@ -85,10 +84,7 @@ export const createMessageRepository = (db: Db) => {
|
||||
const statsByChat = (chatId: number): { count: number; oldest: number | null } =>
|
||||
statements.statsByChat.get(chatId) as { count: number; oldest: number | null };
|
||||
|
||||
/** Удаляет всю сохранённую историю чата; возвращает, сколько сообщений удалено. */
|
||||
const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes;
|
||||
|
||||
return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat, deleteByChat };
|
||||
return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat };
|
||||
};
|
||||
|
||||
export type MessageRepository = ReturnType<typeof createMessageRepository>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
27
src/services/dialog-service.ts
Normal file
27
src/services/dialog-service.ts
Normal 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>;
|
||||
@@ -42,3 +42,9 @@ export interface ImageAttachment {
|
||||
mimeType: string;
|
||||
source: ImageSource;
|
||||
}
|
||||
|
||||
/** Реплика диалога с ботом: вопрос пользователя или ответ бота. */
|
||||
export interface DialogTurn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user