Files
tg-chat-bot/src/ai/unified-ai-runner.ts
T
2026-05-14 20:55:48 +03:00

358 lines
16 KiB
TypeScript

// 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<void> {
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,
});
}
}