fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
710
server/services/ai-bot.service.ts
Normal file
710
server/services/ai-bot.service.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user