This commit is contained in:
2026-05-13 05:10:51 +03:00
parent 3848dd82d9
commit cd8d2683c0
10 changed files with 1173 additions and 53 deletions
+163 -40
View File
@@ -1,9 +1,9 @@
import {Message} from "typescript-telegram-bot-api";
import fs, {openAsBlob} from "node:fs";
import fs, {createReadStream, openAsBlob} from "node:fs";
import path from "node:path";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {bot, photoGenDir} from "../index";
import {bot, notesDir, photoGenDir} from "../index";
import {clamp, collectReplyChainText, delay, ifTrue, logError, replyToMessage} from "../util/utils";
import {MessageStore} from "../common/message-store";
import {
@@ -20,7 +20,7 @@ import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "
import {getModelCapabilities, getRuntimeCapabilities} from "./provider-model-runtime";
import {StoredAttachment} from "../model/stored-attachment";
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
import {ChatRequest, ListResponse, Tool} from "ollama";
import {ChatRequest, ListResponse, Ollama, Tool} from "ollama";
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
import {MessageImagePart, MessagePart} from "../common/message-part";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
@@ -29,7 +29,7 @@ import {getCurrentDateTimeTool} from "./tools/datetime";
import {getMarketRatesTool} from "./tools/market-rates";
import {getWeatherTool} from "./tools/weather";
import {aiProviderRequestQueue, type AiRequestQueueTarget} from "./provider-request-queue";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {loadOllamaModel, RouterPlanSchema, unloadAllOllamaModels} from "./tools/utils";
import {prepareOllamaDocumentRag} from "./ollama-rag";
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
import {
@@ -47,12 +47,6 @@ import {
resolveSpeechToTextProviderForUser,
transcribeSpeechDownloads,
} from "./speech-to-text";
import {
isTextToSpeechConfigured,
resolveTextToSpeechProviderForUser,
sendSynthesizedSpeech,
synthesizeSpeech,
} from "./text-to-speech";
import {OpenAIChatMessage} from "./openai-chat-message";
import {ResponseInputMessageContentList} from "openai/resources/responses/responses.js";
import {MistralChatMessage} from "./mistral-chat-message";
@@ -70,6 +64,7 @@ import {
getGeminiApiMode,
resolveAiRuntimeTarget,
} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
const TELEGRAM_LIMIT = 4096;
const MAX_TOOL_ROUNDS = 20;
@@ -135,8 +130,10 @@ type RuntimeConfigSnapshot = {
useNamesInPrompt: boolean;
useSystemPrompt: boolean;
systemPrompt?: string;
rankerToolPrompt?: string;
ollamaChatTarget: AiRuntimeTarget;
ollamaToolRankerTarget?: AiRuntimeTarget;
ollamaVisionTarget: AiRuntimeTarget;
ollamaThinkingTarget: AiRuntimeTarget;
ollamaAudioTarget: AiRuntimeTarget;
@@ -165,8 +162,15 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
useSystemPrompt: Environment.USE_SYSTEM_PROMPT,
systemPrompt: Environment.SYSTEM_PROMPT,
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
ollamaToolRankerTarget: {
provider: AiProvider.OLLAMA,
purpose: "tools",
model: "gemma4:e2b",
baseUrl: "http://meloda-zen.lan:11434"
},
ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"),
ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"),
ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"),
@@ -379,8 +383,8 @@ export function ollamaModelNames(response: ListResponse): string[] {
.filter((value): value is string => typeof value === "string" && value.length > 0);
}
async function isOllamaModelActive(target: AiRuntimeTarget): Promise<boolean> {
const active = await createOllamaClient(target).ps();
async function isOllamaModelActive(ollama: Ollama, target: AiRuntimeTarget): Promise<boolean> {
const active = await ollama.ps();
return ollamaModelNames(active).includes(target.model);
}
@@ -916,23 +920,21 @@ function appendTranscriptToChatMessages(chatMessages: AiChatMessage[], provider:
}
}
async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise<void> {
if (!downloads.some(isTranscribableAudioDownload)) return;
if (!options.msg.from?.id) return;
const trimmed = text.trim();
if (!trimmed) return;
try {
const provider = isTextToSpeechConfigured(options.provider)
? options.provider
: (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider;
const speech = await synthesizeSpeech({provider, text: trimmed});
await sendSynthesizedSpeech(options.msg, speech);
} catch (e) {
logError(e);
}
}
// async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise<void> {
// if (!downloads.some(isTranscribableAudioDownload)) return;
// if (!options.msg.from?.id) return;
//
// const trimmed = text.trim();
// if (!trimmed) return;
//
// try {
// const provider = (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider;
// const speech = await synthesizeSpeech({provider, text: trimmed});
// await sendSynthesizedSpeech(options.msg, speech);
// } catch (e) {
// logError(e);
// }
// }
async function deleteMistralLibrary(libraryId: string | undefined, target: AiRuntimeTarget): Promise<void> {
if (!libraryId) return;
@@ -1429,9 +1431,24 @@ async function runOllama(
const maxContextLength = contextKey ? <number>modelInfo?.model_info?.[contextKey] : DEFAULT_OLLAMA_CONTEXT_SIZE;
const context = clamp(contextSize ?? contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : DEFAULT_OLLAMA_CONTEXT_SIZE, MIN_OLLAMA_CONTEXT_SIZE, maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE);
await unloadAllOllamaModels(ollama, [model, config.ollamaDocumentsTarget.model]);
const modelsToLoad = [model];
if (!(await isOllamaModelActive(target))) {
try {
const activeModels = (await ollama.ps()).models.map(m => m.model);
const oldSet = new Set(activeModels);
const newSet = new Set(modelsToLoad);
const added = modelsToLoad.filter(m => !oldSet.has(m));
const removed = activeModels.filter(m => !newSet.has(m));
const diff = [...added, ...removed];
if (diff.length) {
await unloadAllOllamaModels(ollama, modelsToLoad);
}
} catch (e) {
logError(e);
}
if (!(await isOllamaModelActive(ollama, target))) {
const currentStatus = streamMessage.getStatus();
streamMessage.setStatus(Environment.getLoadingModelText(model));
await streamMessage.flush();
@@ -1468,27 +1485,97 @@ async function runOllama(
try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
console.log("CONTENT_LENGTH", context);
console.log("CONTEXT_LENGTH", context);
const request: ChatRequest = {
model: model,
messages: messages,
think: audioCount ? false : think,
options: {
temperature: messages.length <= 2 ? 0 : 0.6,
temperature: messages.length <= 2 ? 0.6 : 0.6,
num_ctx: context,
}
};
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
if (fromId !== Environment.CREATOR_ID) {
request.tools = [
const ollamaTools: Tool[] = fromId !== Environment.CREATOR_ID ?
[
getCurrentDateTimeTool,
getMarketRatesTool,
getWeatherTool
];
] : getOllamaTools() as Tool[];
if (!config.ollamaToolRankerTarget) {
request.tools = ollamaTools;
} else {
request.tools = getOllamaTools() as Tool[];
try {
const toolRankerPrompt = config.rankerToolPrompt
?.replace(
"{{user_request}}",
messages.reverse().find(m => m.role === "user")?.content ?? ""
)
?.replace(
"{{tools}}",
ollamaTools.map(t => JSON.stringify(t)).join("\n")
);
streamMessage.setStatus("🦽 Подбираю лучшие инструменты...");
await streamMessage.flush();
const client = createOllamaClient(config.ollamaToolRankerTarget);
const modelsToLoad = [config.ollamaToolRankerTarget.model];
const activeModels = (await client.ps()).models.map(m => m.model);
const oldSet = new Set(activeModels);
const newSet = new Set(modelsToLoad);
const added = modelsToLoad.filter(m => !oldSet.has(m));
const removed = activeModels.filter(m => !newSet.has(m));
const diff = [...added, ...removed];
if (diff.length) {
await unloadAllOllamaModels(client, modelsToLoad);
}
const result = await client.generate({
model: config.ollamaToolRankerTarget.model,
stream: false,
prompt: toolRankerPrompt ?? ""
});
console.log("TOOL_RANKER_RESULT: ", result.response);
const raw = JSON.parse(result.response);
const res = RouterPlanSchema.safeParse(raw);
if (res.success) {
const toolNames = res.data.s.flatMap(s => s.t);
const tools = toolNames.map(n => ollamaTools.find(t => t.function.name === n) as Tool);
// const tools = ollamaTools.filter(t => toolNames.includes(t.function.name ?? ""));
request.tools = tools;
const m = messages.reverse().find(m => m.role === "user");
let content = m?.content ?? "";
content += "\n" + JSON.stringify({
toolPlan: {
steps: res.data.s.map(s => {
return {
tool_name: s.t,
hint: s.h,
from: s.from,
};
}),
missing_info: res.data.m
}
});
if (m) m.content = content;
// messages.reverse().find(m => m.role === "user")?.content += "";
// messages[messages.length - 1].content = content;
}
} catch (e: unknown) {
logError(e);
}
}
}
@@ -1616,7 +1703,42 @@ async function runOllama(
}))
});
appendOllamaToolResults(messages, calls, await executeToolBatch(calls, streamMessage, toolContext));
const toolResults = await executeToolBatch(calls, streamMessage, toolContext);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch (e: unknown) {
await replyToMessage({message: msg, text: e + ""}).catch(logError);
}
}
if (successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id
},
document: createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath))
}).catch(logError);
}
const reversedMessages = messages.reverse();
let lastUserMessageIndex = reversedMessages.findIndex(m => m.role === "user");
if (lastUserMessageIndex >= 0) {
lastUserMessageIndex = messages.indexOf(reversedMessages[lastUserMessageIndex]);
if (lastUserMessageIndex >= 0) {
messages[lastUserMessageIndex].content += "\n\nLast tool_call result: " + JSON.stringify(toolResults);
}
}
appendOllamaToolResults(messages, calls, toolResults);
}
} finally {
if (interval) clearInterval(interval);
@@ -1990,7 +2112,8 @@ async function executeUnifiedAiRequest(
if (transcript.trim()) {
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
streamMessage.replaceText(transcript.trim());
// TODO: 12.05.2026, Danil Nikolaev: extract to string
streamMessage.replaceText(`[Расшифровка]\n${transcript.trim()}`);
await streamMessage.finish();
return {mistralLibraryId};
}
@@ -2057,7 +2180,7 @@ async function executeUnifiedAiRequest(
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
}
await streamMessage.finish();
await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
// await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
return {mistralLibraryId};
} catch (e) {