358 lines
16 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
} |