Files
iistwin/server/services/ai-bot.service.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

711 lines
30 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.

/**
* AI-бот сервис: обрабатывает упоминания AI-ассистентов в чате задачи и прямых диалогах.
* Алгоритм: RAG-поиск → MCP-инструменты → LLM-ответ → публикация в чат.
*/
import { storage } from '../storage';
import { decrypt } from '../crypto';
import { semanticSearch } from './embedding.service';
import { McpClient } from '../utils/mcp-client';
import { eventBus } from '../routes/shared';
import { getOllamaNumThread, getOllamaNumCtx } from '../utils/ollama-config';
import type { Bot, Task, User } from '@shared/schema';
import { db } from '../db';
import { conversationMessages, bots } from '@shared/schema';
import { eq, and, desc } from 'drizzle-orm';
const GLOBAL_OPENAI_API_KEY = process.env.OPENAI_API_KEY;
/**
* Extracts the first complete JSON object from a string starting at `fromIndex`.
* Uses balanced-brace counting to handle nested objects correctly.
* Returns the raw JSON string, or null if not found.
*/
function extractFirstJsonBlock(text: string, fromIndex = 0): string | null {
const start = text.indexOf('{', fromIndex);
if (start === -1) return null;
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escape) { escape = false; continue; }
if (ch === '\\' && inString) { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return text.slice(start, i + 1);
}
}
return null;
}
interface AiBotContext {
bot: Bot;
task: Task;
message: string;
attachments?: Array<{ url: string; name: string; size: number; mimeType?: string }>;
user: User;
organizationId: number;
}
interface LlmMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
async function resolveOrgLlmConfig(organizationId: number, bot?: Bot): Promise<{ provider: string; apiKey: string | null; baseUrl: string | null; chatModel: string; customHeaders: Record<string, string>; botId?: number }> {
// Если у бота задан свой провайдер из реестра — используем его
if (bot?.llmProviderId) {
try {
const provider = await storage.getLlmProvider(bot.llmProviderId, organizationId);
if (provider && provider.isActive) {
const apiKey = provider.apiKey ? decrypt(provider.apiKey) : null;
const resolvedKey = apiKey || (provider.providerType === 'openai' ? GLOBAL_OPENAI_API_KEY || null : null);
const chatModel = (bot.llmModel as string | null) ??
(provider.enabledModels && provider.enabledModels.length > 0 ? provider.enabledModels[0] : null) ??
(provider.providerType === 'ollama' ? 'llama3' : 'gpt-4o-mini');
return {
provider: provider.providerType,
apiKey: resolvedKey,
baseUrl: provider.baseUrl ?? null,
chatModel,
customHeaders: (provider.customHeaders ?? {}) as Record<string, string>,
};
}
} catch (err) {
console.warn('[AI-Bot] resolveOrgLlmConfig: failed to load llmProvider', bot.llmProviderId, err);
}
}
const settings = await storage.getRagSettings(organizationId);
if (settings) {
const effectiveChatProviderId = settings.chatProviderId ?? settings.embeddingProviderId;
if (effectiveChatProviderId) {
try {
const provider = await storage.getLlmProvider(effectiveChatProviderId, organizationId);
if (provider && provider.isActive) {
const apiKey = provider.apiKey ? decrypt(provider.apiKey) : null;
const resolvedKey = apiKey || (provider.providerType === 'openai' ? GLOBAL_OPENAI_API_KEY || null : null);
const chatModel = settings.chatModel ??
(provider.enabledModels && provider.enabledModels.length > 0 ? provider.enabledModels[0] : null) ??
(provider.providerType === 'ollama' ? 'llama3' : 'gpt-4o-mini');
return {
provider: provider.providerType,
apiKey: resolvedKey,
baseUrl: provider.baseUrl ?? null,
chatModel,
customHeaders: (provider.customHeaders ?? {}) as Record<string, string>,
};
}
} catch (err) {
console.warn('[AI-Bot] resolveOrgLlmConfig: failed to load chatProvider', effectiveChatProviderId, err);
}
}
const apiKey = settings.apiKey ? decrypt(settings.apiKey) : null;
const provider = settings.provider ?? 'openai';
const baseUrl = settings.baseUrl ?? null;
const chatModel = settings.chatModel ?? (provider === 'ollama' ? 'llama3' : 'gpt-4o-mini');
return { provider, apiKey: apiKey || GLOBAL_OPENAI_API_KEY || null, baseUrl, chatModel, customHeaders: {} };
}
return {
provider: 'openai',
apiKey: GLOBAL_OPENAI_API_KEY || null,
baseUrl: null,
chatModel: 'gpt-4o-mini',
customHeaders: {},
};
}
function resolveApiBase(provider: string, baseUrl: string | null): string {
if (provider === 'ollama') return (baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434').replace(/\/$/, '');
if (provider === 'openai_compatible' && baseUrl) return baseUrl.replace(/\/$/, '');
return 'https://api.openai.com';
}
function apiUrl(apiBase: string, path: string): string {
const hasVersion = /\/v\d+$/.test(apiBase);
return hasVersion ? `${apiBase}${path}` : `${apiBase}/v1${path}`;
}
async function callLlm(
messages: LlmMessage[],
config: { provider: string; apiKey: string | null; baseUrl: string | null; chatModel: string; customHeaders: Record<string, string>; botId?: number }
): Promise<string | null> {
const apiBase = resolveApiBase(config.provider, config.baseUrl);
if (config.provider === 'ollama') {
try {
const res = await fetch(`${apiBase}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...config.customHeaders },
body: JSON.stringify({ model: config.chatModel, messages, stream: false, options: { num_thread: getOllamaNumThread(), num_ctx: getOllamaNumCtx() } }),
signal: AbortSignal.timeout(300000),
});
if (!res.ok) return null;
const data = await res.json() as { message?: { content?: string } };
return data.message?.content?.trim() ?? null;
} catch (err) {
console.error('[AI-Bot] Ollama chat error:', err);
return null;
}
}
if (!config.apiKey) return null;
try {
const res = await fetch(apiUrl(apiBase, '/chat/completions'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
...config.customHeaders,
},
body: JSON.stringify({
model: config.chatModel,
messages,
max_tokens: 2000,
temperature: 0.7,
...(config.botId ? { bot_id: String(config.botId) } : {}),
}),
signal: AbortSignal.timeout(60000),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error('[AI-Bot] LLM error:', res.status, body);
if (res.status === 429) {
return '⚠️ Модель временно недоступна из-за ограничения частоты запросов (429). Попробуйте позже или добавьте свой API-ключ в провайдере.';
}
return null;
}
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
return data.choices[0]?.message?.content?.trim() ?? null;
} catch (err) {
console.error('[AI-Bot] LLM request failed:', err);
return null;
}
}
export function checkBotAccess(bot: Bot, user: User): boolean {
const policy = bot.accessPolicy;
if (!policy || policy.mode === 'all') return true;
if (policy.mode === 'roles') {
return (policy.roles ?? []).includes(user.appRole);
}
if (policy.mode === 'users') {
return (policy.userIds ?? []).includes(user.id);
}
return false;
}
const HISTORY_MAX_TOKENS = 3000;
const CHARS_PER_TOKEN = 3; // приближение для русского текста
const HISTORY_MAX_CHARS = HISTORY_MAX_TOKENS * CHARS_PER_TOKEN;
/**
* Загружает до 10 последних сообщений чата задачи (исключая текущее),
* конвертирует в формат LlmMessage и обрезает по токенам.
*/
async function buildChatHistory(
taskId: number,
organizationId: number,
currentMessage: string
): Promise<LlmMessage[]> {
try {
const allMessages = await storage.getTaskMessages(taskId, organizationId);
// Исключаем текущее сообщение (последнее совпадающее по тексту)
// getTaskMessages возвращает TaskMessage & { author, bot, ... } — поля message/messageType на верхнем уровне
const lastIdx = allMessages.findLastIndex(m => m.message === currentMessage);
const historyMessages = lastIdx !== -1
? allMessages.slice(0, lastIdx)
: allMessages;
// Берём последние 10
const recent = historyMessages.slice(-10);
// Конвертируем в LlmMessage
const llmHistory: LlmMessage[] = recent
.filter(m => m.messageType === 'text' || m.messageType === 'bot')
.map(m => {
if (m.messageType === 'bot') {
return { role: 'assistant' as const, content: m.message };
}
const authorName = m.author
? `${m.author.firstName} ${m.author.middleName || ''} ${m.author.lastName}`.trim()
: 'Пользователь';
return { role: 'user' as const, content: `${authorName}: ${m.message}` };
});
// Обрезаем по токенам (убираем самые старые сообщения, пока не уложимся)
let totalChars = llmHistory.reduce((sum, m) => sum + m.content.length, 0);
while (totalChars > HISTORY_MAX_CHARS && llmHistory.length > 0) {
const removed = llmHistory.shift();
if (removed) totalChars -= removed.content.length;
}
return llmHistory;
} catch (err) {
console.warn('[AI-Bot] buildChatHistory error:', err);
return [];
}
}
interface RagEntry {
content: string;
score: number | null;
}
/**
* RAG-поиск: сначала пробует семантический поиск (pgvector эмбеддинги),
* при отсутствии конфигурации эмбеддингов — fallback на ILIKE по заголовку/описанию задач.
*/
async function ragSearch(organizationId: number, query: string, topK: number): Promise<RagEntry[]> {
try {
const semanticResults = await semanticSearch(organizationId, query, undefined, topK);
if (semanticResults.length > 0) {
return semanticResults.map(r => ({ content: r.content, score: r.score }));
}
} catch (err) {
console.warn('[AI-Bot] semanticSearch failed, falling back to keyword search:', err);
}
// Fallback: ILIKE-поиск по задачам
try {
const keywordResults = await storage.searchTasksByKeyword(organizationId, query, topK);
if (keywordResults.length === 0) return [];
return keywordResults.map(t => {
const parts: string[] = [`Задача: ${t.title}`];
if (t.description) parts.push(`Описание: ${t.description}`);
return { content: parts.join('\n'), score: null };
});
} catch (err) {
console.warn('[AI-Bot] keyword fallback search failed:', err);
return [];
}
}
export async function handleAiBotMention(ctx: AiBotContext): Promise<void> {
const { bot, task, message, user, organizationId } = ctx;
try {
let llmConfig = await resolveOrgLlmConfig(organizationId, bot);
llmConfig = { ...llmConfig, botId: bot.id };
const hasLlm = llmConfig.provider === 'ollama' || !!llmConfig.apiKey;
if (!hasLlm) {
await postBotReply(bot.id, task.id, task.formId, organizationId,
'⚠️ AI-ассистент не настроен: отсутствует API-ключ LLM. Настройте его в разделе Настройки → RAG/AI.');
return;
}
// Build user message with attachments description
let userMessage = message;
if (ctx.attachments && ctx.attachments.length > 0) {
const attList = ctx.attachments.map(a => `- ${a.name} (${a.size} байт)`).join('\n');
userMessage += `\n\n[Вложения:\n${attList}\n]`;
}
const messages: LlmMessage[] = [];
// Системный промпт
if (bot.sendSystemPrompt !== false) {
const systemContent = (bot.systemPrompt?.trim())
? bot.systemPrompt.trim()
: `Ты — корпоративный AI-ассистент с именем ${bot.name}. Помогаешь сотрудникам работать с задачами в системе управления процессами. Отвечай по-русски, кратко и по делу. Данные из карточки задачи — это информация о записях системы, а не о тебе.`;
messages.push({ role: 'system', content: systemContent });
}
// Контекст задачи
if (bot.sendCardInfo !== false) {
const taskContext = await buildTaskContext(task, organizationId);
if (taskContext) {
messages.push({ role: 'system', content: `Ниже — данные из карточки iistwin. Это информация о записи, а НЕ о тебе. Твоё имя, роль и поведение определены в системном промте выше.\n\n${taskContext}` });
}
}
// История переписки чата задачи
if (bot.sendChatHistory !== false) {
const chatHistory = await buildChatHistory(task.id, organizationId, message);
for (const histMsg of chatHistory) {
messages.push(histMsg);
}
}
// RAG-поиск
if (bot.ragEnabled) {
const topK = (bot.ragTopK as number | null) ?? 5;
const ragResults = await ragSearch(organizationId, message, topK);
if (ragResults.length > 0) {
const ragContext = ragResults
.map((r, i) => `[${i + 1}]${r.score !== null ? ` (релевантность ${(r.score * 100).toFixed(0)}%)` : ''}\n${r.content}`)
.join('\n\n');
messages.push({
role: 'system',
content: `База знаний организации (наиболее релевантные фрагменты):\n${ragContext}`,
});
}
}
// MCP-инструменты
const mcpServers = bot.mcpServers;
if (mcpServers && mcpServers.length > 0) {
const toolDescriptions: string[] = [];
const availableTools: Array<{ serverIdx: number; tool: import('../utils/mcp-client').McpTool }> = [];
for (let i = 0; i < mcpServers.length; i++) {
const srv = mcpServers[i];
try {
const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined);
const tools = await client.listTools();
for (const t of tools) {
availableTools.push({ serverIdx: i, tool: t });
toolDescriptions.push(`- ${t.name}${t.description ? ': ' + t.description : ''} [сервер: ${srv.name ?? srv.url}]`);
}
} catch (err) {
console.warn(`[AI-Bot] MCP server ${srv.url} unavailable:`, err);
}
}
if (toolDescriptions.length > 0) {
messages.push({
role: 'system',
content: `Доступные MCP-инструменты:\n${toolDescriptions.join('\n')}\n\nЕсли для ответа нужен инструмент, укажи в своём ответе его вызов в формате: TOOL_CALL: {"name": "tool_name", "args": {...}}`,
});
// Первый проход LLM для определения нужных инструментов
messages.push({ role: 'user', content: userMessage });
const firstPass = await callLlm(messages, llmConfig);
if (firstPass && firstPass.includes('TOOL_CALL:')) {
const toolCallJson = extractFirstJsonBlock(firstPass, firstPass.indexOf('TOOL_CALL:') + 'TOOL_CALL:'.length);
if (toolCallJson !== null) {
try {
const toolCall = JSON.parse(toolCallJson) as { name: string; args?: Record<string, unknown> };
const toolEntry = availableTools.find(t => t.tool.name === toolCall.name);
if (toolEntry) {
const srv = mcpServers[toolEntry.serverIdx];
const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined);
const toolResult = await client.callTool(toolCall.name, toolCall.args ?? {});
const resultText = toolResult.content.map(c => c.text ?? '').join('\n').trim();
messages.push({ role: 'assistant', content: firstPass });
messages.push({ role: 'user', content: `Результат инструмента ${toolCall.name}:\n${resultText}\n\nТеперь ответь на исходный вопрос пользователя, используя результат.` });
const finalAnswer = await callLlm(messages, llmConfig);
if (finalAnswer) {
await postBotReply(bot.id, task.id, task.formId, organizationId, finalAnswer);
return;
}
}
} catch (err) {
console.warn('[AI-Bot] MCP tool call parse error:', err);
}
}
}
// Если инструмент не нужен или вызов не удался — используем первый проход
if (firstPass) {
// Remove only the TOOL_CALL marker and its JSON payload, leaving other content intact
const cleanedAnswer = firstPass.replace(/TOOL_CALL:\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, '').trim();
if (cleanedAnswer) {
await postBotReply(bot.id, task.id, task.formId, organizationId, cleanedAnswer);
return;
}
}
// Убираем сообщение пользователя из messages перед повторным добавлением
const userMsgIdx = messages.findLastIndex(m => m.role === 'user' && m.content === userMessage);
if (userMsgIdx !== -1) messages.splice(userMsgIdx, 1);
}
}
// Финальный LLM-запрос (без MCP или если MCP не использовался)
messages.push({ role: 'user', content: userMessage });
const answer = await callLlm(messages, llmConfig);
if (answer) {
await postBotReply(bot.id, task.id, task.formId, organizationId, answer);
} else {
await postBotReply(bot.id, task.id, task.formId, organizationId,
'⚠️ Не удалось получить ответ от LLM. Проверьте настройки AI в разделе RAG/AI.');
}
} catch (err) {
console.error('[AI-Bot] handleAiBotMention error:', err);
try {
await postBotReply(bot.id, task.id, task.formId, organizationId,
'⚠️ Внутренняя ошибка AI-ассистента. Попробуйте позже.');
} catch (e2) {
console.error('[AI-Bot] Failed to post error reply:', e2);
}
}
}
async function buildTaskContext(task: Task, organizationId: number): Promise<string | null> {
try {
const parts: string[] = [];
parts.push(`Задача: ${task.title}`);
if (task.description) parts.push(`Описание: ${task.description}`);
const form = await storage.getForm(task.formId, organizationId);
if (form) parts.push(`Форма: ${form.name}`);
const statuses = await storage.getFormStatuses(task.formId, organizationId);
const status = statuses.find(s => s.id === task.currentStatusId);
if (status) parts.push(`Статус: ${status.name}`);
if (task.assignedTo) {
const assignee = await storage.getUser(task.assignedTo);
if (assignee) parts.push(`Ответственный: ${assignee.firstName} ${assignee.middleName || ''} ${assignee.lastName}`);
}
const fieldValues = await storage.getTaskFieldValues(task.id, organizationId);
if (fieldValues.length > 0) {
const fields = await storage.getFormFields(task.formId, organizationId);
const fieldMap = new Map(fields.map(f => [f.id, f]));
const lines: string[] = [];
for (const fv of fieldValues) {
const f = fieldMap.get(fv.fieldId);
if (!f || fv.value === null || fv.value === undefined || fv.value === '') continue;
if (['file', 'table'].includes(f.type)) continue;
const displayVal = typeof fv.value === 'object' ? JSON.stringify(fv.value) : String(fv.value);
lines.push(`${f.name}: ${displayVal}`);
}
if (lines.length > 0) parts.push(`\nПоля карточки:\n${lines.join('\n')}`);
}
return parts.join('\n');
} catch (err) {
console.warn('[AI-Bot] buildTaskContext error:', err);
return null;
}
}
/**
* Обрабатывает прямое сообщение пользователя боту в bot_direct диалоге мессенджера.
* Сохраняет ответ бота в conversationMessages с authorId=null, botId=bot.id
*/
export async function handleAiBotDirectMessage(ctx: {
bot: Bot;
conversationId: number;
message: string;
user: User;
organizationId: number;
}): Promise<void> {
const { bot, conversationId, message, user, organizationId } = ctx;
try {
let llmConfig = await resolveOrgLlmConfig(organizationId, bot);
llmConfig = { ...llmConfig, botId: bot.id };
const hasLlm = llmConfig.provider === 'ollama' || !!llmConfig.apiKey;
if (!hasLlm) {
await db.insert(conversationMessages).values({
conversationId,
authorId: null,
botId: bot.id,
message: '⚠️ AI-ассистент не настроен: отсутствует API-ключ LLM. Настройте его в разделе Настройки → RAG/AI.',
replyToId: null,
mentionedUserIds: null,
attachments: null,
});
return;
}
const messages: LlmMessage[] = [];
if (bot.sendSystemPrompt !== false) {
const systemContent = bot.systemPrompt?.trim()
? bot.systemPrompt.trim()
: `Ты — корпоративный AI-ассистент с именем ${bot.name}. Помогаешь сотрудникам работать с задачами в системе управления процессами. Отвечай по-русски, кратко и по делу. Данные из карточки задачи — это информация о записях системы, а не о тебе.`;
messages.push({ role: 'system', content: systemContent });
}
// История диалога в мессенджере
if (bot.sendChatHistory !== false) {
const historyRows = await db
.select({ message: conversationMessages.message, botId: conversationMessages.botId, authorId: conversationMessages.authorId })
.from(conversationMessages)
.where(and(
eq(conversationMessages.conversationId, conversationId),
eq(conversationMessages.isDeleted, false),
))
.orderBy(desc(conversationMessages.id))
.limit(11); // last 10 + current
const history = historyRows.reverse().slice(0, -1); // exclude current message
let totalChars = 0;
const llmHistory: LlmMessage[] = [];
for (const h of history) {
const role: 'assistant' | 'user' = h.botId ? 'assistant' : 'user';
const content = h.message;
totalChars += content.length;
if (totalChars > HISTORY_MAX_CHARS) break;
llmHistory.push({ role, content });
}
for (const h of llmHistory) messages.push(h);
}
// RAG
if (bot.ragEnabled) {
const topK = (bot.ragTopK as number | null) ?? 5;
const ragResults = await ragSearch(organizationId, message, topK);
if (ragResults.length > 0) {
const ragContext = ragResults
.map((r, i) => `[${i + 1}]${r.score !== null ? ` (релевантность ${(r.score * 100).toFixed(0)}%)` : ''}\n${r.content}`)
.join('\n\n');
messages.push({ role: 'system', content: `База знаний организации:\n${ragContext}` });
}
}
// MCP-инструменты
const mcpServers = bot.mcpServers;
if (mcpServers && mcpServers.length > 0) {
const toolDescriptions: string[] = [];
const availableTools: Array<{ serverIdx: number; tool: import('../utils/mcp-client').McpTool }> = [];
for (let i = 0; i < mcpServers.length; i++) {
const srv = mcpServers[i];
try {
const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined);
const tools = await client.listTools();
for (const t of tools) {
availableTools.push({ serverIdx: i, tool: t });
toolDescriptions.push(`- ${t.name}${t.description ? ': ' + t.description : ''} [сервер: ${srv.name ?? srv.url}]`);
}
} catch (err) {
console.warn(`[AI-Bot Direct] MCP server ${srv.url} unavailable:`, err);
}
}
if (toolDescriptions.length > 0) {
messages.push({
role: 'system',
content: `Доступные MCP-инструменты:\n${toolDescriptions.join('\n')}\n\nЕсли для ответа нужен инструмент, укажи в своём ответе его вызов в формате: TOOL_CALL: {"name": "tool_name", "args": {...}}`,
});
messages.push({ role: 'user', content: message });
const firstPass = await callLlm(messages, llmConfig);
if (firstPass && firstPass.includes('TOOL_CALL:')) {
const toolCallJson = extractFirstJsonBlock(firstPass, firstPass.indexOf('TOOL_CALL:') + 'TOOL_CALL:'.length);
if (toolCallJson !== null) {
try {
const toolCall = JSON.parse(toolCallJson) as { name: string; args?: Record<string, unknown> };
const toolEntry = availableTools.find(t => t.tool.name === toolCall.name);
if (toolEntry) {
const srv = mcpServers[toolEntry.serverIdx];
const client = new McpClient(srv.url, srv.apiKey ? decrypt(srv.apiKey) : undefined);
const toolResult = await client.callTool(toolCall.name, toolCall.args ?? {});
const resultText = toolResult.content.map(c => c.text ?? '').join('\n').trim();
messages.push({ role: 'assistant', content: firstPass });
messages.push({ role: 'user', content: `Результат инструмента ${toolCall.name}:\n${resultText}\n\nТеперь ответь на исходный вопрос пользователя, используя результат.` });
const finalAnswer = await callLlm(messages, llmConfig);
if (finalAnswer) {
await db.insert(conversationMessages).values({
conversationId,
authorId: null,
botId: bot.id,
message: finalAnswer,
replyToId: null,
mentionedUserIds: null,
attachments: null,
});
return;
}
}
} catch (err) {
console.warn('[AI-Bot Direct] MCP tool call parse error:', err);
}
}
}
if (firstPass) {
const cleanedAnswer = firstPass.replace(/TOOL_CALL:\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, '').trim();
if (cleanedAnswer) {
await db.insert(conversationMessages).values({
conversationId,
authorId: null,
botId: bot.id,
message: cleanedAnswer,
replyToId: null,
mentionedUserIds: null,
attachments: null,
});
return;
}
}
const userMsgIdx = messages.findLastIndex(m => m.role === 'user' && m.content === message);
if (userMsgIdx !== -1) messages.splice(userMsgIdx, 1);
}
}
// Финальный LLM-запрос (без MCP или если MCP не использовался)
messages.push({ role: 'user', content: message });
const reply = await callLlm(messages, llmConfig);
const replyText = reply?.trim() || '⚠️ Бот не смог сгенерировать ответ.';
await db.insert(conversationMessages).values({
conversationId,
authorId: null,
botId: bot.id,
message: replyText,
replyToId: null,
mentionedUserIds: null,
attachments: null,
});
} catch (err) {
console.error('[AI-Bot Direct] Error:', err);
await db.insert(conversationMessages).values({
conversationId,
authorId: null,
botId: bot.id,
message: '⚠️ Произошла ошибка при обработке запроса.',
replyToId: null,
mentionedUserIds: null,
attachments: null,
}).catch(() => {});
}
}
async function postBotReply(
botId: number,
taskId: number,
formId: number,
organizationId: number,
text: string
): Promise<void> {
const botMessage = await storage.createTaskMessage({
taskId,
formId,
authorId: null,
botId,
replyToMessageId: null,
message: text,
messageType: 'bot',
mentionedUserIds: null,
attachments: null,
} as Parameters<typeof storage.createTaskMessage>[0], organizationId);
const bot = await storage.getBot(botId, organizationId);
eventBus.publishEvent({
type: 'message_created',
organizationId,
taskId,
data: {
taskId,
message: {
...botMessage,
bot: bot ? {
id: bot.id,
name: bot.name,
avatarUrl: bot.avatarUrl ?? null,
} : null,
},
},
});
}