- Добавлены SSE-события conv_typing_started/stopped и их обработка в Chat.tsx - AI-бот публикует typing events перед/после генерации ответа - Боты могут отправлять сообщения с кнопками через /api/bot/conversations/:id/messages - Добавлен callback /api/messenger/messages/:id/callback для нажатий кнопок - VPN-бот использует кнопки Android/iOS вместо текстового prompt - Оптимистичное обновление unread count, ускоренный reportActiveChat, polling fallback, логирование SSE, push suppression для bot_direct
718 lines
30 KiB
TypeScript
718 lines
30 KiB
TypeScript
/**
|
||
* 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 { publishConvTypingEvent } from '../routes/messenger.helpers';
|
||
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;
|
||
}
|
||
|
||
try {
|
||
await publishConvTypingEvent(conversationId, bot.name, organizationId, true);
|
||
|
||
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,
|
||
});
|
||
} finally {
|
||
await publishConvTypingEvent(conversationId, bot.name, organizationId, false).catch(() => {});
|
||
}
|
||
} 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,
|
||
},
|
||
},
|
||
});
|
||
}
|