// Facade extracted from unified-ai-runner.ts. import {AiProvider} from "../model/ai-provider"; import {Environment} from "../common/environment"; import {ifTrue, logError, replyToMessage} from "../util/utils"; import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry"; import {TelegramStreamMessage} from "./telegram-stream-message"; import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments"; import {ChatMessage} from "./chat-messages-types"; import {aiProviderRequestQueue} from "./provider-request-queue"; import {prepareOllamaDocumentRag} from "./ollama-rag"; import { AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE, resolveAiContextSizeForUser, resolveAiResponseLanguageForUser, resolveAiVoiceModeForUser } from "../common/user-ai-settings"; import {isTranscribableAudioDownload} from "./speech-to-text"; import {OpenAIChatMessage} from "./openai-chat-message"; import {MistralChatMessage} from "./mistral-chat-message"; import {OllamaChatMessage} from "./ollama-chat-message"; import {GeminiMessage} from "./gemini-chat-message"; import {buildAiRegenerateCallbackData} from "./regenerate-callback"; import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger"; import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai"; import {runOllama} from "./unified-ai-runner.ollama"; import {runMistral} from "./unified-ai-runner.mistral"; import {runGemini} from "./unified-ai-runner.gemini"; import { AI_REQUEST_TIMEOUT_MS, appendTranscriptToChatMessages, collectCachedMessageAttachments, collectRequestedAttachmentKinds, collectTextMessages, deleteMistralLibrary, hasAudioAttachmentKind, initialStatus, isAbortError, prepareMistralDocuments, providerName, rejectUnsupportedAttachments, resolveAiRequestQueueTarget, RuntimeConfigSnapshot, snapshotModel, snapshotRuntimeConfig, stripAudioFromRunnerMessages, TELEGRAM_LIMIT, toolRuntimeContextFromDownloads, transcribeAudioIfNeeded, UnifiedRunOptions } from "./unified-ai-runner.shared"; export type {ToolCallData} from "./unified-ai-runner.shared"; export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; async function executeUnifiedAiRequest( options: UnifiedRunOptions, config: RuntimeConfigSnapshot, downloads: AiDownloadedFile[], controller: AbortController, streamMessage: TelegramStreamMessage, ): Promise<{ mistralLibraryId?: string }> { const requestStartedAt = Date.now(); aiLog("info", "request.execute.start", { provider: providerName(options.provider), stream: options.stream ?? true, think: options.think, responseLanguage: options.responseLanguage, contextSize: options.contextSize, voiceMode: options.voiceMode, message: aiLogMessageIdentity(options.msg), downloads: downloads.map(d => ({ kind: d.kind, fileName: d.fileName, mimeType: d.mimeType, sizeBytes: d.buffer.length })), }); const { chatMessages, imageCount } = await collectTextMessages( options.msg, options.text, options.provider, downloads, config, options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE, ); const firstRoundStatus = initialStatus(downloads, imageCount); const toolContext = toolRuntimeContextFromDownloads(downloads); aiLog("debug", "request.messages.collected", { provider: providerName(options.provider), chatMessages: chatMessages.length, imageCount, firstRoundStatus, hasToolInputFiles: !!toolContext.pythonInputFiles?.length, }); streamMessage.setStatus(firstRoundStatus); await streamMessage.flush(); const hasDocument = downloads.some(d => d.kind === "document"); if (hasDocument && options.provider !== AiProvider.MISTRAL && options.provider !== AiProvider.OLLAMA) { aiLog("warn", "request.documents.unsupported_provider", {provider: providerName(options.provider)}); throw new Error(Environment.documentsUnifiedRunnerUnsupportedText); } let mistralLibraryId: string | undefined; const transcript = await transcribeAudioIfNeeded(options.provider, options.msg.from?.id, downloads, streamMessage, controller.signal).catch(e => { if (downloads.some(isTranscribableAudioDownload)) throw e; return ""; }); if (transcript.trim()) { if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) { // TODO: 12.05.2026: extract to string streamMessage.replaceText(`[Расшифровка]\n${transcript.trim()}`); await streamMessage.finish(); return {mistralLibraryId}; } appendTranscriptToChatMessages(chatMessages, options.provider, transcript); stripAudioFromRunnerMessages(chatMessages); aiLog("debug", "request.transcript.appended", { provider: providerName(options.provider), transcriptChars: transcript.length, chatMessages: chatMessages.length, }); } try { const preparedMistral = options.provider === AiProvider.MISTRAL ? await prepareMistralDocuments(downloads, chatMessages as MistralChatMessage[], streamMessage, config.mistralChatTarget, controller.signal) : {documents: []}; const documents = preparedMistral.documents; mistralLibraryId = preparedMistral.libraryId; if (options.provider === AiProvider.OLLAMA) { await prepareOllamaDocumentRag({ downloads, messages: chatMessages as OllamaChatMessage[], userQuery: options.text, message: streamMessage, config: { embeddingModel: config.ollamaDocumentsTarget.model, embeddingClient: createOllamaClient(config.ollamaDocumentsTarget), chunkSize: config.ollamaRagChunkSize, chunkOverlap: config.ollamaRagChunkOverlap, topK: config.ollamaRagTopK, maxContextChars: config.ollamaRagMaxContextChars, minScore: config.ollamaRagMinScore, maxArchiveFiles: config.ollamaRagMaxArchiveFiles, maxArchiveBytes: config.ollamaRagMaxArchiveBytes, maxArchiveDepth: config.ollamaRagMaxArchiveDepth, }, }); } aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)}); switch (options.provider) { case AiProvider.OPENAI: await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext, !!options.think); break; case AiProvider.OLLAMA: const currentModel = config.ollamaChatTarget.model; if (currentModel?.includes("gpt-oss")) { if (options.think) { options.think = "high"; } } await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize); break; case AiProvider.MISTRAL: await runMistral(chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); break; case AiProvider.GEMINI: if (getGeminiApiMode(config.geminiChatTarget) === "openai") { await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); } else { await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); } break; } if (streamMessage.getText().length > TELEGRAM_LIMIT) { streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "..."); } await streamMessage.finish(); // await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText()); aiLog("success", "request.execute.done", { provider: providerName(options.provider), duration: aiLogDuration(requestStartedAt), responseChars: streamMessage.getText().length, mistralLibraryId, }); return {mistralLibraryId}; } catch (e) { aiLog("error", "request.execute.failed", { provider: providerName(options.provider), duration: aiLogDuration(requestStartedAt), error: e, }); if (mistralLibraryId) { await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget); } throw e; } } export async function runUnifiedAi(options: UnifiedRunOptions): Promise { const startedAt = Date.now(); const config = snapshotRuntimeConfig(); options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id); options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id); options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id); const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg); aiLog("info", "run.start", { provider: providerName(options.provider), model: snapshotModel(options.provider, config), message: aiLogMessageIdentity(options.msg), targetMessage: aiLogMessageIdentity(options.targetMessage), isGuestMsg: options.isGuestMsg, stream: options.stream, think: options.think, responseLanguage: options.responseLanguage, contextSize: options.contextSize, voiceMode: options.voiceMode, requestedAttachmentKinds: [...requestedAttachmentKinds], textChars: options.text.length, }); if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) { aiLog("warn", "run.rejected.unsupported_attachment", { provider: providerName(options.provider), requestedAttachmentKinds: [...requestedAttachmentKinds], }); return; } const cached = await collectCachedMessageAttachments(options.msg); aiLog("debug", "run.attachments.cache", { attachments: cached.attachments.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), }); if (cached.missing.length) { await replyToMessage({ message: options.msg, text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName), }).catch(logError); aiLog("warn", "run.rejected.missing_attachment_cache", { missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), }); return; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); const cancel = createAiCancelRequest({ chatId: options.msg.chat.id, fromId: options.msg.from?.id ?? 0, provider: providerName(options.provider), controller }); const streamMessage = new TelegramStreamMessage( options.msg, cancel.id, ifTrue(options.stream), options.voiceMode === AI_VOICE_MODE_TRANSCRIPT && hasAudioAttachmentKind(requestedAttachmentKinds) ? undefined : buildAiRegenerateCallbackData(options.provider, !!options.think), options.targetMessage, options.provider, options.isGuestMsg ); cancel.onCancel = () => streamMessage.cancel(cancel.provider); let mistralLibraryId: string | undefined; const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds); aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id}); try { const queueMessage = await streamMessage.start(Environment.waitThinkText); setAiCancelMessageId(cancel.id, queueMessage.message_id); aiLog("info", "run.queue.enter", { cancelId: cancel.id, queueMessageId: queueMessage.message_id, target: aiLogProviderTarget(queueTarget), }); await aiProviderRequestQueue.enqueue(queueTarget, { signal: controller.signal, onPositionChange: async requestsBefore => { aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore}); streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore)); await streamMessage.flush(); }, run: async () => { const queueWaitFinishedAt = Date.now(); aiLog("info", "run.queue.dequeued", {cancelId: cancel.id}); const downloads = attachmentsToDownloadedFiles(cached.attachments); aiLog("debug", "run.downloads.ready", { count: downloads.length, downloads: downloads.map(d => ({ kind: d.kind, fileName: d.fileName, mimeType: d.mimeType, path: d.path, sizeBytes: d.buffer.length })), }); try { const result = await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage); mistralLibraryId = result.mistralLibraryId; aiLog("success", "run.queue.task.done", { cancelId: cancel.id, duration: aiLogDuration(queueWaitFinishedAt), mistralLibraryId, }); } finally { cleanupDownloads(downloads); aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length}); } }, }); } catch (e) { if (controller.signal.aborted || isAbortError(e)) { aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e}); streamMessage.replaceText(streamMessage.getText()); await streamMessage.finish(); } else { aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e}); await streamMessage.fail(e); logError(e); } } finally { clearTimeout(timeout); finishAiRequest(cancel.id); if (mistralLibraryId) { aiLog("debug", "run.mistral_library.cleanup", {mistralLibraryId}); await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget); } aiLog("success", "run.finished", { cancelId: cancel.id, provider: providerName(options.provider), duration: aiLogDuration(startedAt), aborted: controller.signal.aborted, }); } }