diff --git a/src/callback_commands/ai-cancel.ts b/src/callback_commands/ai-cancel.ts new file mode 100644 index 0000000..2fa7b69 --- /dev/null +++ b/src/callback_commands/ai-cancel.ts @@ -0,0 +1,142 @@ +import {CallbackCommand} from "../base/callback-command"; +import {CallbackQuery, InlineKeyboardMarkup, Message} from "typescript-telegram-bot-api"; +import {abortAiRequest, getAiCancelRequest} from "../ai/cancel-registry"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {Environment} from "../common/environment"; +import {AiProvider} from "../model/ai-provider"; +import {MessageStore} from "../common/message-store"; +import {bot} from "../index"; +import {buildCancelledGenerationText, logError} from "../util/utils"; +import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; +import {buildAiRegenerateCallbackData} from "../ai/regenerate-callback"; +import {isAiProviderConfigured, resolveEffectiveAiProviderForUser} from "../common/user-ai-settings"; + +const TELEGRAM_TEXT_LIMIT = 4096; +const TELEGRAM_CAPTION_LIMIT = 1024; + +export class AiCancel extends CallbackCommand { + data = "/cancel_ai"; + text = Environment.aiCancelCallbackText; + + requirements = Requirements.Build(Requirement.SAME_USER); + + async execute(query: CallbackQuery): Promise { + if (!query.message || !query.data) return; + + const parsed = this.parseCallbackData(query.data); + if (!parsed) return; + + const request = getAiCancelRequest(parsed.requestId); + if (!request) { + await this.markMessageAsCancelled(query, parsed.provider); + return; + } + if (request.fromId !== query.from.id && query.from.id !== Environment.CREATOR_ID) return; + + const cancelled = await abortAiRequest(parsed.requestId); + if (!cancelled) return; + } + + private parseCallbackData(data: string): { requestId: string; provider?: AiProvider } | null { + const [, requestId, provider] = data.split(/\s+/); + if (!requestId) return null; + + return { + requestId, + provider: Object.values(AiProvider).includes(provider as AiProvider) ? provider as AiProvider : undefined, + }; + } + + private async markMessageAsCancelled(query: CallbackQuery, providerFromCallback?: AiProvider): Promise { + const callbackMessage = query.message; + if (!callbackMessage || callbackMessage.date === 0) return; + const message = callbackMessage as Message; + + const stored = await MessageStore.get(message.chat.id, message.message_id).catch(e => { + logError(e); + return null; + }); + const sourceFromId = await this.resolveSourceFromId(message, stored).catch(e => { + logError(e); + return undefined; + }); + const regenerateProvider = providerFromCallback && isAiProviderConfigured(providerFromCallback) + ? providerFromCallback + : await resolveEffectiveAiProviderForUser(sourceFromId ?? query.from.id); + const providerName = (providerFromCallback ?? regenerateProvider).toLowerCase(); + const isCaption = this.isCaptionMessage(message); + const limit = isCaption ? TELEGRAM_CAPTION_LIMIT : TELEGRAM_TEXT_LIMIT; + const baseText = stored?.text ?? message.text ?? message.caption ?? ""; + const cancelledText = buildCancelledGenerationText(baseText, providerName, limit); + const replyMarkup = this.regenerateKeyboard(regenerateProvider); + const formatted = prepareTelegramMarkdownV2(cancelledText, {mode: "final"}); + + try { + const result = isCaption + ? await enqueueTelegramApiCall( + () => bot.editMessageCaption({ + chat_id: message.chat.id, + message_id: message.message_id, + caption: formatted, + parse_mode: "MarkdownV2", + reply_markup: replyMarkup, + }), + {method: "editMessageCaption", chatId: message.chat.id, chatType: message.chat.type} + ) + : await enqueueTelegramApiCall( + () => bot.editMessageText({ + chat_id: message.chat.id, + message_id: message.message_id, + text: formatted, + parse_mode: "MarkdownV2", + reply_markup: replyMarkup, + }), + {method: "editMessageText", chatId: message.chat.id, chatType: message.chat.type} + ); + + if (result && result !== true) { + await MessageStore.put({...result, text: cancelledText} as Message); + } else { + await MessageStore.put({ + chatId: message.chat.id, + id: message.message_id, + replyToMessageId: stored?.replyToMessageId ?? this.replyToMessageId(message), + fromId: message.from?.id ?? stored?.fromId ?? 0, + text: cancelledText, + date: message.date ?? stored?.date ?? Math.floor(Date.now() / 1000), + photoMaxSizeFilePath: stored?.photoMaxSizeFilePath, + attachments: stored?.attachments, + }); + } + } catch (e) { + logError(e); + } + } + + private regenerateKeyboard(provider: AiProvider): InlineKeyboardMarkup { + return { + inline_keyboard: [[{ + text: Environment.regenerateText, + callback_data: buildAiRegenerateCallbackData(provider), + }]], + }; + } + + private async resolveSourceFromId(message: Message, stored: Awaited>): Promise { + const reply = "reply_to_message" in message ? message.reply_to_message : undefined; + if (reply?.from?.id) return reply.from.id; + + const source = await MessageStore.get(message.chat.id, stored?.replyToMessageId); + return source?.fromId; + } + + private replyToMessageId(message: Message): number | undefined { + return "reply_to_message" in message ? message.reply_to_message?.message_id : undefined; + } + + private isCaptionMessage(message: Message): boolean { + return message.caption !== undefined; + } +} diff --git a/src/callback_commands/ai-regenerate.ts b/src/callback_commands/ai-regenerate.ts new file mode 100644 index 0000000..43eced2 --- /dev/null +++ b/src/callback_commands/ai-regenerate.ts @@ -0,0 +1,83 @@ +import {CallbackQuery, Message} from "typescript-telegram-bot-api"; +import {CallbackCommand} from "../base/callback-command"; +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {MessageStore} from "../common/message-store"; +import {StoredMessage} from "../model/stored-message"; +import {cutPrefixes, logError} from "../util/utils"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {AI_REGENERATE_CALLBACK, parseAiRegenerateCallbackData} from "../ai/regenerate-callback"; +import {isAiProviderConfigured, resolveEffectiveAiProviderForUser} from "../common/user-ai-settings"; +import {Environment} from "../common/environment"; + +export class AiRegenerate extends CallbackCommand { + data = AI_REGENERATE_CALLBACK; + text = Environment.aiRegenerateCallbackText; + + requirements = Requirements.Build(Requirement.SAME_USER); + + async execute(query: CallbackQuery): Promise { + if (!query.message || !query.data) return; + + const parsed = parseAiRegenerateCallbackData(query.data); + if (!parsed) return; + + const source = await this.resolveSourceMessage(query); + if (!source) return; + + const sourceFromId = source.stored?.fromId ?? source.message.from?.id; + if (!sourceFromId || (sourceFromId !== query.from.id && query.from.id !== Environment.CREATOR_ID)) return; + + const provider = isAiProviderConfigured(parsed.provider) + ? parsed.provider + : await resolveEffectiveAiProviderForUser(source.message.from?.id ?? query.from.id); + const text = cutPrefixes(source.stored ?? source.message) ?? ""; + + runUnifiedAi({ + provider, + msg: source.message, + text, + stream: true, + think: parsed.think, + targetMessage: query.message, + }).catch(logError); + } + + private async resolveSourceMessage(query: CallbackQuery): Promise<{ + message: Message; + stored: StoredMessage | null; + } | null> { + const responseMessage = query.message; + if (!responseMessage) return null; + + const directSource = "reply_to_message" in responseMessage ? responseMessage.reply_to_message : undefined; + if (directSource) { + const stored = await MessageStore.put(directSource).catch(e => { + logError(e); + return null; + }); + return {message: directSource, stored}; + } + + const storedResponse = await MessageStore.get(responseMessage.chat.id, responseMessage.message_id); + const storedSource = await MessageStore.get(responseMessage.chat.id, storedResponse?.replyToMessageId); + if (!storedSource) return null; + + return { + message: this.storedToMessage(storedSource, responseMessage, query), + stored: storedSource, + }; + } + + private storedToMessage(stored: StoredMessage, responseMessage: Message, query: CallbackQuery): Message { + return { + message_id: stored.id, + chat: responseMessage.chat, + date: stored.date, + from: query.from.id === stored.fromId + ? query.from + : {id: stored.fromId, is_bot: false, first_name: ""}, + text: stored.text ?? undefined, + } as Message; + } +} diff --git a/src/callback_commands/ollama-cancel.ts b/src/callback_commands/ollama-cancel.ts deleted file mode 100644 index 9f8b756..0000000 --- a/src/callback_commands/ollama-cancel.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {CallbackCommand} from "../base/callback-command"; -import {CallbackQuery} from "typescript-telegram-bot-api"; -import {abortOllamaRequest, bot, getOllamaRequest} from "../index"; -import {escapeMarkdownV2Text, logError} from "../util/utils"; -import {MessageStore} from "../common/message-store"; -import {StoredMessage} from "../model/stored-message"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Environment} from "../common/environment"; - -export class OllamaCancel extends CallbackCommand { - - data = "/cancel_ollama"; - text = "Cancel Ollama generation"; - - requirements = Requirements.Build(Requirement.SAME_USER); - - async execute(query: CallbackQuery): Promise { - if (!query.message || !query.data) return; - - const chatId = query.message.chat.id; - const fromId = query.from.id; - const messageId = query.message.message_id; - - const uuid = query.data.split(" ")[1]; - if (!uuid) return; - - const request = getOllamaRequest(uuid); - if (request) { - if (request.fromId !== fromId && fromId !== Environment.CREATOR_ID) return; - - const aborted = abortOllamaRequest(uuid); - console.log(`aborted request ${uuid}:`, aborted); - } else { - console.log(`no request with uuid "${uuid}" found`); - } - - let msg: StoredMessage | null = null; - try { - msg = await MessageStore.get(chatId, messageId); - } catch (e) { - logError(e); - } - - console.log(`Message for ${chatId}-${messageId}:`, msg); - - let content: string | null = null; - - if (msg?.text?.trim()?.length) { - content = msg?.text.trim(); - if (content.length + Environment.ollamaCancelledText.length > 4096) { - content = content.substring(0, 4096 - Environment.ollamaCancelledText.length - 2) + "\n"; - } - } - - const newText = `${content ? content : ""}${Environment.ollamaCancelledText}`; - - try { - await bot.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: escapeMarkdownV2Text(newText), - parse_mode: "MarkdownV2", - reply_markup: {inline_keyboard: []}, - }); - - if (msg) { - await MessageStore.put(msg); - } - } catch (e) { - logError(e); - } - } -} \ No newline at end of file diff --git a/src/commands/gemini-chat.ts b/src/commands/gemini-chat.ts index 9f6a9a4..3a1b36d 100644 --- a/src/commands/gemini-chat.ts +++ b/src/commands/gemini-chat.ts @@ -1,196 +1,25 @@ import {Message} from "typescript-telegram-bot-api"; -import {Environment} from "../common/environment"; -import {bot, googleAi} from "../index"; -import {MessageStore} from "../common/message-store"; +import {ChatCommand} from "../base/chat-command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; - -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - oldReplyToMessage, - replyToMessage, - startIntervalEditor -} from "../util/utils"; -import {ChatCommand} from "../base/chat-command"; -import {ApiError} from "@google/genai"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {Environment} from "../common/environment"; export class GeminiChat extends ChatCommand { - command = "gemini"; + command = ["gemini", "gemini-chat"]; argsMode = "required" as const; requirements = Requirements.Build(Requirement.BOT_CREATOR); - title = "/gemini"; - description = "Chat with AI (Gemini)"; + title = Environment.commandTitles.geminiChat; + description = Environment.commandDescriptions.geminiChat; async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); return this.executeGemini(msg, match?.[3] || ""); } - async executeGemini(msg: Message, text: string): Promise { - if (!text || !text.trim().length) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - return { - role: part.bot ? "assistant" : "user", - content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content - }; - }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) { - chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT}); - } - - let chatContent = ""; - for (const part of chatMessages) { - chatContent += `${part.role.toUpperCase()}:\n${part.content}\n\n`; - } - - chatContent = chatContent.trim(); - - const input = []; - input.push( - { - type: "text", - text: chatContent - } - ); - - // TODO: 12/02/2026, Danil Nikolaev: support for multiple images - if (messageParts.some(p => p.images?.length)) { - const firstImages = messageParts.find(p => p.images?.length)?.images ?? []; - firstImages.forEach(image => { - input.push({ - type: "image", - data: image, - mime_type: "image/png" - }); - }); - } - - let waitMessage: Message | null = null; - - const startTime = Date.now(); - - try { - const imagesCount = input.some(e => e.type === "image"); - - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: imagesCount ? Environment.analyzingPictureText : Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await googleAi.interactions.create({ - model: Environment.GEMINI_MODEL, - input: input as any, - stream: true - }); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - await bot.editMessageText( - { - chat_id: chatId, - message_id: waitMessage?.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2" - } - ).catch(logError); - - console.log("editMessageText", text); - - if (waitMessage) { - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } - }, - onStop: async () => { - } - }); - await editor.tick(); - - try { - for await (const event of stream) { - switch (event.event_type) { - case "content.delta": - switch (event.delta?.type) { - case "text": { - const text = event.delta.text; - currentText += text; - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - break; - } - break; - } - case "image": { - const image = event.delta.data; - console.log("image", image); - } - } - } - } - } finally { - await editor.tick(); - await editor.stop(); - - if (!shouldBreak) { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000.0; - console.log("time", diff); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - } - } catch (e: any) { - logError(e); - - if (waitMessage) { - if (e instanceof ApiError) { - if (e.status === 429) { - await oldReplyToMessage(waitMessage, "На сегодня всё, лимиты закончились.").catch(logError); - return; - } - } - - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${e.toString()}`).catch(logError); - } - } + async executeGemini(msg: Message, text: string, stream: boolean = true): Promise { + await runUnifiedAi({provider: AiProvider.GEMINI, msg, text, stream}); } -} \ No newline at end of file +} diff --git a/src/commands/gemini-generate-image.ts b/src/commands/gemini-generate-image.ts deleted file mode 100644 index ea451ab..0000000 --- a/src/commands/gemini-generate-image.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {Command} from "../base/command"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import {googleAi} from "../index"; -import {logError, replyToMessage} from "../util/utils"; -import {Environment} from "../common/environment"; - -export class GeminiGenerateImage extends Command { - command = "geminiGenImage"; - argsMode = "required" as const; - - title = "/geminiGenImage"; - description = "Generate image with Gemini"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); - - const prompt = match?.[3] || ""; - return this.executeGenImage(msg, prompt); - } - - async executeGenImage(msg: Message, text: string): Promise { - if (!text || !text.trim().length) return; - - let waitMessage: Message | null = null; - - try { - waitMessage = await replyToMessage({ - message: msg, - text: Environment.genImageText, - }); - - const interaction = await googleAi.interactions.create({ - model: Environment.GEMINI_IMAGE_MODEL, - response_modalities: ["image"], - input: text, - }); - - interaction.outputs?.forEach((output, index) => { - if (output.type === "image") { - // const image = output.data; - console.log(`Image output ${index + 1}:`, output); - } else { - console.log(`Output ${index + 1}: ${output}`); - } - }); - } catch (e: any) { - logError(e); - - if (waitMessage) { - await replyToMessage({ - message: waitMessage, - text: `Произошла ошибка!\n${e.toString()}`, - link_preview_options: {is_disabled: true} - }).catch(logError); - } - } - } -} \ No newline at end of file diff --git a/src/commands/gemini-get-model.ts b/src/commands/gemini-get-model.ts index 1f28f5d..b6c66b0 100644 --- a/src/commands/gemini-get-model.ts +++ b/src/commands/gemini-get-model.ts @@ -1,32 +1,15 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {formatRuntimeModelInfo} from "../ai/provider-model-runtime"; import {Environment} from "../common/environment"; -import {googleAi} from "../index"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; export class GeminiGetModel extends Command { - title = "/geminiGetModel"; - description = "Get current Gemini model"; + title = Environment.commandTitles.geminiGetModel; + description = Environment.commandDescriptions.geminiGetModel; async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: `Текущая модель: "${Environment.GEMINI_MODEL}"`}).catch(logError); + await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.GEMINI)}).catch(logError); } - - async getModelCapabilities(): Promise { - try { - const info = await googleAi.models.get({model: Environment.GEMINI_MODEL}); - console.log(info); - - return { - vision: {supported: true}, - ocr: undefined, - thinking: {supported: info.thinking}, - tools: undefined - }; - } catch (e) { - logError(e); - return null; - } - } -} \ No newline at end of file +} diff --git a/src/commands/gemini-list-models.ts b/src/commands/gemini-list-models.ts index 44c8321..7be16db 100644 --- a/src/commands/gemini-list-models.ts +++ b/src/commands/gemini-list-models.ts @@ -2,35 +2,27 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {googleAi} from "../index"; -import {logError, replyToMessage} from "../util/utils"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {listProviderModels} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class GeminiListModels extends Command { - title = "/geminiListModels"; - description = "List all Gemini models"; + title = Environment.commandTitles.geminiListModels; + description = Environment.commandDescriptions.geminiListModels; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message): Promise { try { - const listResponse = await googleAi.models.list(); - console.log(listResponse); + const models = (await listProviderModels(AiProvider.GEMINI)).sort((a, b) => a.localeCompare(b)); + const modelsString = escapeHtml(models.join("\n").substring(0, 4000)); + const text = Environment.modelListHeaderText + "
" + modelsString + "
"; - const modelsString = listResponse.page - .sort((a, b) => (a.name || "").localeCompare((b.name || ""))) - .map(e => `${e.name}`) - .join("\n"); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); + await replyToMessage({message: msg, text, parse_mode: "HTML"}); } catch (e) { logError(e); - await replyToMessage({message: msg, text: "Не получилось загрузить список моделей"}).catch(logError); + await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/gemini-set-model.ts b/src/commands/gemini-set-model.ts index 512d252..7242ad6 100644 --- a/src/commands/gemini-set-model.ts +++ b/src/commands/gemini-set-model.ts @@ -2,24 +2,28 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {Environment} from "../common/environment"; import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class GeminiSetModel extends Command { argsMode = "required" as const; - title = "/geminiSetModel"; - description = "Set Gemini model"; + title = Environment.commandTitles.geminiSetModel; + description = Environment.commandDescriptions.geminiSetModel; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - Environment.setGeminiModel(newModel || Environment.GEMINI_MODEL); + const newModel = match?.[3]?.trim(); + if (newModel) setRuntimeModel(AiProvider.GEMINI, newModel); - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.GEMINI_MODEL}".`; + const model = getRuntimeModel(AiProvider.GEMINI); + const text = newModel + ? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.GEMINI)) + : Environment.getModelIsNotSetCurrentText(model); - await replyToMessage({message: msg, text: text}).catch(logError); + await replyToMessage({message: msg, text}).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/info.ts b/src/commands/info.ts index d8c8405..05c6111 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -2,67 +2,76 @@ import {ChatCommand} from "../base/chat-command"; import {Message} from "typescript-telegram-bot-api"; import {callbackCommands, commands} from "../index"; import {Environment} from "../common/environment"; -import {boolToEmoji, getCurrentModel, getCurrentModelCapabilities, logError, replyToMessage} from "../util/utils"; +import {getCurrentModel, logError, replyToMessage} from "../util/utils"; import {AiModelCapabilities} from "../model/ai-model-capabilities"; import {AiProvider} from "../model/ai-provider"; import {Command} from "../base/command"; +import {formatRuntimeModelInfo, getRuntimeCapabilities} from "../ai/provider-model-runtime"; +import {getProviderTools} from "../ai/tool-mappers"; +import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; export class Info extends Command { command = ["info", "v"]; - title = "/info"; - description = "Info about bot"; + title = Environment.commandTitles.info; + description = Environment.commandDescriptions.info; async execute(msg: Message): Promise { const aiProvider = Environment.DEFAULT_AI_PROVIDER; const aiModel = getCurrentModel(); - let aiModelCapabilities: AiModelCapabilities | null = {}; + if (!aiModel) return; + let aiModelCapabilities: AiModelCapabilities | null = null; try { - aiModelCapabilities = await getCurrentModelCapabilities(); + aiModelCapabilities = await getRuntimeCapabilities(aiProvider, aiModel); } catch (e) { logError(e); - await replyToMessage({message: msg, text: `Произошла ошибка: ${e}`}).catch(logError); + await replyToMessage({message: msg, text: Environment.getErrorText(e)}).catch(logError); return; } + const supportedProvidersLength = Object.keys(AiProvider).filter(key => isNaN(Number(key))).length; - const aiInfo = "```" + - "AI\n" + - `supported providers: ${Object.keys(AiProvider).filter(key => isNaN(Number(key))).length}\n\n` + + const getAiInfo = async () => { + return Environment.getInfoAiBlockText( + supportedProvidersLength, + await formatRuntimeModelInfo(aiProvider, aiModel, aiModelCapabilities), + ); + }; - `provider: ${aiProvider.toLowerCase()}\n` + - `model: ${aiModel}\n\n` + - `vision${aiModelCapabilities?.vision?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities?.vision?.supported)}\n` + - `ocr${aiModelCapabilities?.ocr?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities?.ocr?.supported)}\n` + - `thinking${aiModelCapabilities?.thinking?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities?.thinking?.supported)}\n` + - `tools${aiModelCapabilities?.tools?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities?.tools?.supported)}\n` + - `audio${aiModelCapabilities?.audio?.external ? "(ext)" : ""}: ${boolToEmoji(aiModelCapabilities?.audio?.supported)}` + - "```"; + const getToolsInfo = async () => { + const tools = getProviderTools(aiProvider); + return Environment.getInfoToolsBlockText(tools.map(t => t.function.name)); + }; - const cmds = commands.filter(c => !(c instanceof ChatCommand)); - const chatCmds = commands.filter(c => c instanceof ChatCommand); - const callbackCmds = callbackCommands; + const getCommandsInfo = async () => { + const cmds = commands.filter(c => !(c instanceof ChatCommand)); + const chatCmds = commands.filter(c => c instanceof ChatCommand); + const callbackCmds = callbackCommands; + const publicCmdsLength = cmds.filter(c => c.requirements?.isPublic()).length; + const privateCmdsLength = cmds.length - publicCmdsLength; + const chatCmdsLength = chatCmds.length; + const callbackCmdsLength = callbackCmds.length; - const publicCmdsLength = cmds.filter(c => c.requirements?.isPublic()).length; - const privateCmdsLength = cmds.length - publicCmdsLength; + return Environment.getInfoCommandsBlockText({ + publicCommands: publicCmdsLength, + privateCommands: privateCmdsLength, + chatCommands: chatCmdsLength, + callbackCommands: callbackCmdsLength, + }); + }; - const chatCmdsLength = chatCmds.length; - const callbackCmdsLength = callbackCmds.length; + const finalText = [ + await getAiInfo(), + await getToolsInfo(), + await getCommandsInfo() + ].join("\n"); - const text = - aiInfo + "\n\n" + - - "```" + - "Commands\n" + - `Public: ${publicCmdsLength}\n` + - `Private: ${privateCmdsLength}\n` + - `Chat: ${chatCmdsLength}\n` + - `Callback: ${callbackCmdsLength}\n` + - "```" - ; - - await replyToMessage({message: msg, text: text, parse_mode: "Markdown"}).catch(logError); + await replyToMessage({ + message: msg, + text: prepareTelegramMarkdownV2(finalText, {mode: "final"}), + parse_mode: "MarkdownV2" + }).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/mistral-chat.ts b/src/commands/mistral-chat.ts index a076a65..fa0cf99 100644 --- a/src/commands/mistral-chat.ts +++ b/src/commands/mistral-chat.ts @@ -1,185 +1,25 @@ +import {Message} from "typescript-telegram-bot-api"; +import {ChatCommand} from "../base/chat-command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; -import {Message} from "typescript-telegram-bot-api"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - oldReplyToMessage, - replyToMessage, - startIntervalEditor -} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; import {Environment} from "../common/environment"; -import {bot, commands, mistralAi} from "../index"; -import {MessageStore} from "../common/message-store"; -import {ChatCommand} from "../base/chat-command"; -import {MistralGetModel} from "./mistral-get-model"; export class MistralChat extends ChatCommand { - command = "mistral"; + command = ["mistral", "mistral-chat"]; argsMode = "required" as const; requirements = Requirements.Build(Requirement.BOT_CREATOR); - title = "/mistral"; - description = "Chat with AI (Mistral)"; + title = Environment.commandTitles.mistralChat; + description = Environment.commandDescriptions.mistralChat; async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); return this.executeMistral(msg, match?.[3] || ""); } - async executeMistral(msg: Message, text: string): Promise { - if (!text || !text.trim().length) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - const content = []; - content.push({ - type: "text", - text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content, - }); - - for (const image of part.images) { - content.push({ - type: "image_url", - imageUrl: "data:image/jpeg;base64," + image - }); - } - - return { - role: part.bot ? "assistant" : "user", - content: content, - }; - }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) { - chatMessages.unshift({role: "system", content: [{type: "text", text: Environment.SYSTEM_PROMPT}]}); - } - - let waitMessage: Message | null = null; - - const startTime = Date.now(); - - try { - const imagesCount = chatMessages.reduce((total, curr) => { - return total + (curr.content.filter(c => c.type === "image_url")?.length ?? 0); - }, 0); - - if (imagesCount) { - try { - const modelInfo = await commands.find(c => c instanceof MistralGetModel)?.getModelCapabilities(); - if (modelInfo) { - if (!modelInfo.vision?.supported) { - await replyToMessage({ - message: msg, - text: "Моя текущая модель не умеет анализировать изображения 🥹" - }); - return; - } - } - } catch (e) { - logError(e); - } - } - - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: imagesCount ? - imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : Environment.waitThinkText, - - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await mistralAi.chat.stream({ - model: Environment.MISTRAL_MODEL, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - messages: chatMessages as any - }); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - await bot.editMessageText( - { - chat_id: chatId, - message_id: waitMessage?.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2" - } - ).catch(logError); - - console.log("editMessageText", text); - - if (waitMessage) { - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } - }, - onStop: async () => { - } - }); - await editor.tick(); - - try { - for await (const chunk of stream) { - console.log("chunk", chunk); - - const text = chunk.data.choices[0].delta.content; - currentText += text; - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - break; - } - } - } finally { - await editor.tick(); - await editor.stop(); - - if (!shouldBreak) { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000.0; - console.log("time", diff); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - } - } catch (e: any) { - logError(e); - - if (waitMessage) { - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${e.toString()}`).catch(logError); - } - } + async executeMistral(msg: Message, text: string, stream: boolean = true): Promise { + await runUnifiedAi({provider: AiProvider.MISTRAL, msg, text, stream}); } -} \ No newline at end of file +} diff --git a/src/commands/mistral-get-model.ts b/src/commands/mistral-get-model.ts index 1c18aaa..015bcb9 100644 --- a/src/commands/mistral-get-model.ts +++ b/src/commands/mistral-get-model.ts @@ -1,37 +1,15 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {formatRuntimeModelInfo} from "../ai/provider-model-runtime"; import {Environment} from "../common/environment"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {mistralAi} from "../index"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; export class MistralGetModel extends Command { - title = "/mistralGetModel"; - description = "Get current Mistral model"; - - requirements = Requirements.Build(Requirement.BOT_CREATOR); + title = Environment.commandTitles.mistralGetModel; + description = Environment.commandDescriptions.mistralGetModel; async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: `Текущая модель: "${Environment.MISTRAL_MODEL}"`}).catch(logError); + await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.MISTRAL)}).catch(logError); } - - async getModelCapabilities(): Promise { - try { - const info = await mistralAi.models.retrieve({modelId: Environment.MISTRAL_MODEL}) as any; - console.log(info); - - return { - vision: {supported: info.capabilities.vision}, - ocr: {supported: info.capabilities.ocr}, - thinking: undefined, - tools: {supported: info.capabilities.functionCalling}, - audio: {supported: info.capabilities.audioTranscription} - }; - } catch (e) { - logError(e); - return null; - } - } -} \ No newline at end of file +} diff --git a/src/commands/mistral-list-models.ts b/src/commands/mistral-list-models.ts index 68ca1c4..6ff22e9 100644 --- a/src/commands/mistral-list-models.ts +++ b/src/commands/mistral-list-models.ts @@ -2,76 +2,27 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {mistralAi} from "../index"; -import {logError, oldReplyToMessage, replyToMessage} from "../util/utils"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {listProviderModels} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class MistralListModels extends Command { - title = "/mistralListModels"; - description = "List all Mistral models"; + title = Environment.commandTitles.mistralListModels; + description = Environment.commandDescriptions.mistralListModels; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message): Promise { try { - const listResponse = await mistralAi.models.list() as { - object: string; - data: Array - }; - console.log(listResponse); + const models = (await listProviderModels(AiProvider.MISTRAL)).sort((a, b) => a.localeCompare(b)); + const modelsString = escapeHtml(models.join("\n").substring(0, 4000)); + const text = Environment.modelListHeaderText + "
" + modelsString + "
"; - const modelsString = listResponse.data - .sort((a, b) => a?.name?.localeCompare(b.name || "") || -1) - .map(e => `${e.id}`) - .join("\n"); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); + await replyToMessage({message: msg, text, parse_mode: "HTML"}); } catch (e) { logError(e); - await oldReplyToMessage(msg, "Не получилось загрузить список моделей").catch(logError); + await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError); } } } - -type BaseModelCard = { - id: string; - object: string; - created?: number | undefined; - ownedBy: string; - /** - * This is populated by Harmattan, but some fields have a name - * - * @remarks - * that we don't want to expose in the API. - */ - capabilities: ModelCapabilities; - name?: string | null | undefined; - description?: string | null | undefined; - maxContextLength: number; - aliases?: Array | undefined; - deprecation?: Date | null | undefined; - deprecationReplacementModel?: string | null | undefined; - defaultModelTemperature?: number | null | undefined; - type: "base"; -}; - -type ModelCapabilities = { - completionChat: boolean; - functionCalling: boolean; - reasoning: boolean; - completionFim: boolean; - fineTuning: boolean; - vision: boolean; - ocr: boolean; - classification: boolean; - moderation: boolean; - audio: boolean; - audioTranscription: boolean; - audioTranscriptionRealtime: boolean; - audioSpeech: boolean; -}; \ No newline at end of file diff --git a/src/commands/mistral-set-model.ts b/src/commands/mistral-set-model.ts index 432d57f..7c7551e 100644 --- a/src/commands/mistral-set-model.ts +++ b/src/commands/mistral-set-model.ts @@ -2,24 +2,28 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {Environment} from "../common/environment"; import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class MistralSetModel extends Command { argsMode = "required" as const; - title = "/mistralSetModel"; - description = "Set Mistral model"; + title = Environment.commandTitles.mistralSetModel; + description = Environment.commandDescriptions.mistralSetModel; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - Environment.setMistralModel(newModel || Environment.MISTRAL_MODEL); + const newModel = match?.[3]?.trim(); + if (newModel) setRuntimeModel(AiProvider.MISTRAL, newModel); - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.MISTRAL_MODEL}".`; + const model = getRuntimeModel(AiProvider.MISTRAL); + const text = newModel + ? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.MISTRAL)) + : Environment.getModelIsNotSetCurrentText(model); - await replyToMessage({message: msg, text: text}).catch(logError); + await replyToMessage({message: msg, text}).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/ollama-chat.ts b/src/commands/ollama-chat.ts index 8651b9f..94b88e6 100644 --- a/src/commands/ollama-chat.ts +++ b/src/commands/ollama-chat.ts @@ -1,2215 +1,25 @@ import {Message} from "typescript-telegram-bot-api"; -import {abortOllamaRequest, bot, commands, getOllamaRequest, ollama, ollamaRequests} from "../index"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - oldReplyToMessage, - replyToMessage, - runCommand, - startIntervalEditor -} from "../util/utils"; -import {Environment} from "../common/environment"; -import {MessageStore} from "../common/message-store"; -import {Cancel} from "../callback_commands/cancel"; -import {OllamaCancel} from "../callback_commands/ollama-cancel"; -import {OllamaGetModel} from "./ollama-get-model"; import {ChatCommand} from "../base/chat-command"; -import {MessagePart} from "../common/message-part"; -import {Options, Tool, ToolCall} from "ollama"; -import fs from "node:fs"; -import path from "node:path"; -import axios from "axios"; - -// TODO: 03/05/2026, Danil Nikolaev: cleanup - -const requireFileToolsRootDir = () => Environment.FILE_TOOLS_ROOT_DIR - -type ToolHandler = (args?: Record) => Promise | unknown; - -type ChatMessage = { - role: "system" | "user" | "assistant" | "tool"; - content: string; - images?: string[]; - thinking?: string; - tool_calls?: ToolCall[]; - tool_name?: string; -}; - -const MAX_TOOL_ROUNDS = 50; -const TELEGRAM_MESSAGE_LIMIT = 4096; - -const MAX_FILE_READ_BYTES = 128 * 1024 * 1024; -const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024 -const MAX_DIRECTORY_ENTRIES = 200; -const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024; -const MAX_COPY_ENTRIES = 500; - -type BraveSearchProfile = { - name?: string; - long_name?: string; - url?: string; - img?: string; -}; - -type BraveSearchMetaUrl = { - scheme?: string; - netloc?: string; - hostname?: string; - favicon?: string; - path?: string; -}; - -type BraveSearchThumbnail = { - src?: string; - original?: string; -}; - -type BraveSearchResult = { - type?: string; - title?: string; - url?: string; - description?: string; - age?: string; - page_age?: string; - language?: string; - family_friendly?: boolean; - is_source_local?: boolean; - is_source_both?: boolean; - profile?: BraveSearchProfile; - meta_url?: BraveSearchMetaUrl; - thumbnail?: BraveSearchThumbnail; - extra_snippets?: string[]; -}; - -type BraveSearchApiResponse = { - type?: string; - query?: { - original?: string; - show_strict_warning?: boolean; - is_navigational?: boolean; - is_news_breaking?: boolean; - spellcheck_off?: boolean; - country?: string; - bad_results?: boolean; - should_fallback?: boolean; - postal_code?: string; - city?: string; - header_country?: string; - more_results_available?: boolean; - state?: string; - altered?: string; - }; - - web?: { - type?: string; - results?: BraveSearchResult[]; - }; - - news?: { - type?: string; - results?: BraveSearchResult[]; - }; - - videos?: { - type?: string; - results?: BraveSearchResult[]; - }; - - discussions?: { - type?: string; - results?: BraveSearchResult[]; - }; - - faq?: unknown; - infobox?: unknown; - locations?: unknown; - mixed?: unknown; - summarizer?: unknown; -}; - -const braveSearchTool = { - type: "function", - function: { - name: "web_search", - description: - "Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.", - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: - "Search query. Must be non-empty. Maximum 400 characters and 50 words.", - }, - count: { - type: "number", - description: - "Number of web results to return. Min 1, max 20. Default is 5.", - }, - offset: { - type: "number", - description: - "Zero-based page offset. Min 0, max 9. Default is 0.", - }, - country: { - type: "string", - description: - "Optional 2-letter country code for result localization, for example US, RU, DE. Default is US.", - }, - searchLang: { - type: "string", - description: - "Optional search language code, for example en, ru, de. Default is en.", - }, - uiLang: { - type: "string", - description: - "Optional UI language, usually language-country format, for example en-US, ru-RU, de-DE.", - }, - safesearch: { - type: "string", - enum: ["off", "moderate", "strict"], - description: - "Adult content filter. Default is moderate.", - }, - freshness: { - type: "string", - description: - "Optional freshness filter: pd for last 24h, pw for last 7 days, pm for last 31 days, py for last 365 days, or YYYY-MM-DDtoYYYY-MM-DD.", - }, - resultFilter: { - type: "string", - description: - "Comma-separated result types. Examples: web, news, videos, discussions, faq, infobox, locations, query, summarizer. Default is web.", - }, - extraSnippets: { - type: "boolean", - description: - "Whether to request extra snippets. Default is false.", - }, - spellcheck: { - type: "boolean", - description: - "Whether Brave may spellcheck and alter the query. Default is true.", - }, - }, - required: ["query"], - }, - }, -} satisfies Tool; - -const evaluationTool = { - type: "function", - function: { - name: "evaluation", - description: "Execute command in a shell", - parameters: { - type: "object", - properties: { - cmd: { - type: "string", - description: "Actual command to execute in a shell" - } - }, - required: ["cmd"] - } - } -} satisfies Tool; - -const getCurrentDateTimeTool = { - type: "function", - function: { - name: "get_datetime", - description: - "Get the real current date and time. Use this tool before answering any request that depends on today, now, current time, current date, weekday, timestamp, timezone conversion, or relative dates like yesterday, tomorrow, next week, or 3 days ago.", - parameters: { - type: "object", - properties: { - timeZone: { - type: "string", - description: - "Optional IANA timezone, for example Europe/Moscow, Europe/Berlin, UTC. If omitted, system timezone is used.", - }, - locale: { - type: "string", - description: - "Optional locale, for example ru-RU or en-US. If omitted, system locale/default locale is used.", - }, - }, - required: [], - }, - }, -} satisfies Tool; - -const getWeatherTool = { - type: "function", - function: { - name: "get_weather", - type: "string", - description: "Get the current temperature for a city.", - parameters: { - type: "object", - properties: { - city: { - type: "string", - description: "The name of the city." - }, - lang: { - type: "string", - description: "language code for the response/content. Must be a valid ISO 639-1 two-letter language code, for example: \"en\", \"ru\", \"de\", \"fr\".Determine the value automatically from the language the user is using to communicate with the LLM. If the user explicitly requests a specific language, use that requested language instead. Do not use language names, locales, or regional variants such as \"English\", \"ru-RU\", or \"en_US\"; return only the ISO 639-1 code." - } - }, - required: ["city", "lang"], - } - } -} satisfies Tool; - -const readFileTool = { - type: "function", - function: { - name: "read_file", - description: - "Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative file path inside the root directory, for example notes/todo.txt.", - }, - maxBytes: { - type: "number", - description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`, - }, - }, - required: ["path"], - }, - }, -} satisfies Tool; - -const listDirectoryTool = { - type: "function", - function: { - name: "list_directory", - description: - "List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative directory path inside the root directory. Use . for root.", - }, - }, - required: [], - }, - }, -} satisfies Tool; - -const createFileTool = { - type: "function", - function: { - name: "create_file", - description: - "Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative file path inside the root directory.", - }, - content: { - type: "string", - description: "File content.", - }, - overwrite: { - type: "boolean", - description: "Whether to overwrite the file if it already exists. Default is false.", - }, - createParents: { - type: "boolean", - description: "Whether to create parent directories automatically. Default is true.", - }, - }, - required: ["path"], - }, - }, -} satisfies Tool; - -const createDirectoryTool = { - type: "function", - function: { - name: "create_directory", - description: - "Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative directory path inside the root directory.", - }, - recursive: { - type: "boolean", - description: "Whether to create parent directories automatically. Default is true.", - }, - }, - required: ["path"], - }, - }, -} satisfies Tool; - -const copyPathTool = { - type: "function", - function: { - name: "copy_path", - description: - "Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.", - parameters: { - type: "object", - properties: { - sourcePath: { - type: "string", - description: "Relative source file or directory path inside the root directory.", - }, - targetPath: { - type: "string", - description: "Relative target file or directory path inside the root directory.", - }, - recursive: { - type: "boolean", - description: "Required for copying directories. Default is false.", - }, - overwrite: { - type: "boolean", - description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.", - }, - createParents: { - type: "boolean", - description: "Whether to create target parent directories automatically. Default is true.", - }, - }, - required: ["sourcePath", "targetPath"], - }, - }, -} satisfies Tool; - -const updateFileTool = { - type: "function", - function: { - name: "update_file", - description: - "Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative file path inside the root directory.", - }, - content: { - type: "string", - description: "Content to write.", - }, - mode: { - type: "string", - enum: ["replace", "append", "prepend"], - description: "Update mode. Default is replace.", - }, - createIfMissing: { - type: "boolean", - description: "Whether to create the file if it does not exist. Default is false.", - }, - }, - required: ["path", "content"], - }, - }, -} satisfies Tool; - -const renamePathTool = { - type: "function", - function: { - name: "rename_path", - description: - "Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.", - parameters: { - type: "object", - properties: { - sourcePath: { - type: "string", - description: "Relative source path inside the root directory.", - }, - targetPath: { - type: "string", - description: "Relative target path inside the root directory.", - }, - overwrite: { - type: "boolean", - description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.", - }, - createParents: { - type: "boolean", - description: "Whether to create target parent directories automatically. Default is false.", - }, - }, - required: ["sourcePath", "targetPath"], - }, - }, -} satisfies Tool; - -const deletePathTool = { - type: "function", - function: { - name: "delete_path", - description: - "Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative file or directory path inside the root directory.", - }, - recursive: { - type: "boolean", - description: "Whether to delete non-empty directories recursively. Default is false.", - }, - }, - required: ["path"], - }, - }, -} satisfies Tool; - -const getTools = () => { - const tools: Tool[] = [ - getCurrentDateTimeTool, - ]; - - if (Environment.ENABLE_UNSAFE_EVAL) { - tools.push(evaluationTool) - } - - if (Environment.BRAVE_SEARCH_API_KEY) { - tools.push(braveSearchTool); - } - - if (Environment.OPEN_WEATHER_MAP_API_KEY) { - tools.push(getWeatherTool) - } - - if (Environment.FILE_TOOLS_ROOT_DIR) { - tools.push( - readFileTool, - listDirectoryTool, - createFileTool, - createDirectoryTool, - updateFileTool, - renamePathTool, - copyPathTool, - deletePathTool, - ) - } - - return tools; -} - -const dateTimeToolPrompt = [ - "Datetime tool rules:", - "- Use `get_datetime` whenever the answer depends on the real current date/time.", - "- Never guess the current date/time. Call the tool first.", - "", - "Arguments:", - "- `timeZone`: optional IANA timezone, e.g. `Europe/Moscow`, `Europe/Berlin`, `UTC`.", - "- `locale`: optional locale, e.g. `ru-RU`, `en-US`.", - "", - "After the tool returns:", - "- Base the answer on the returned value.", - "- Do not expose raw tool JSON unless asked.", -].join("\n"); - -const weatherToolPrompt = [ - "Weather tool rules:", - "- Use `get_weather` for current weather, current temperature, conditions, hot/cold/rainy/snowy questions, and weather follow-ups.", - "- Weather is live/current data. Never answer it from memory.", - "- A weather tool result is valid only for the exact city used in that tool call.", - "- If the user changes the city, call `get_weather` again.", - "- Follow-up questions like `а в Москве?`, `а для Краснодара?`, `а там?`, `what about Berlin?` inherit the previous weather intent and require a new tool call for the new city.", - "", - "Arguments:", - "- `city`: the city from the latest user request or resolved from the follow-up context.", - "- `lang`: ISO 639-1 two-letter language code only: `ru`, `en`, `de`, etc.", - "", - "Do not guess, compare, or reuse weather from another city.", - "If the city is missing or unclear, ask the user to specify it.", -].join("\n"); - -const evaluationToolPrompt = [ - "Shell tool rules:", - "- You have access to the `evaluation` tool.", - "- `evaluation` executes a shell command on the server.", - "- This tool is powerful and potentially dangerous.", - "- Use this tool only when command execution is actually necessary.", - "- Prefer specialized tools when available, for example filesystem tools for reading, creating, updating, copying, moving or deleting files.", - "", - "Platform awareness:", - "- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.", - "- Do not assume Bash/Linux commands are available.", - "- Do not assume Windows commands are available.", - "- If the current OS/shell is unknown, first run a safe environment inspection command.", - "- Safe OS inspection examples:", - " - Node.js: `node -p \"process.platform\"`", - " - Node.js: `node -p \"process.cwd()\"`", - " - Windows CMD: `ver`", - " - PowerShell: `$PSVersionTable.PSVersion`", - " - POSIX shell: `uname -a`", - "", - "Preferred safe commands:", - "- Prefer read-only commands.", - "- Prefer short, explicit and predictable commands.", - "- Cross-platform when Node.js is available:", - " - `node -p \"process.cwd()\"`", - " - `node -p \"process.platform\"`", - " - `node -e \"console.log(require('fs').readdirSync('.'))\"`", - "- POSIX examples:", - " - `pwd`, `ls`, `find`, `cat`, `head`, `tail`, `grep`, `sed -n`, `wc`, `stat`, `file`, `du`, `df`, `ps`.", - "- Windows CMD examples:", - " - `cd`, `dir`, `type`, `where`, `findstr`.", - "- PowerShell examples:", - " - `Get-Location`, `Get-ChildItem`, `Get-Content`, `Select-String`, `Measure-Object`, `Get-Item`, `Get-Process`.", - "", - "Filesystem restrictions:", - "- Work only inside the allowed project/root directory.", - "- Use relative paths when possible.", - "- Do not use absolute paths unless the user explicitly asks and it is safe.", - "- Do not use `..` to go to parent directories.", - "- Do not access files outside the allowed root directory.", - "- Do not follow or use symlinks to escape the allowed root directory.", - "", - "Forbidden actions unless the user explicitly asks and the action is clearly safe:", - "- Do not delete files or directories.", - "- Do not overwrite files.", - "- Do not move files.", - "- Do not change permissions.", - "- Do not change ownership.", - "- Do not install packages.", - "- Do not update the system.", - "- Do not start, stop or restart services.", - "- Do not run background processes.", - "- Do not run long-running commands.", - "- Do not run infinite loops.", - "- Do not use fork bombs.", - "- Do not use privilege escalation.", - "", - "Forbidden command examples:", - "- POSIX: `sudo`, `su`, `rm`, `rmdir`, `chmod`, `chown`, `dd`, `mkfs`, `mount`, `umount`, `kill`, `reboot`, `shutdown`.", - "- Windows CMD: `del`, `erase`, `rmdir`, `rd`, `format`, `shutdown`, `taskkill`.", - "- PowerShell: `Remove-Item`, `Move-Item`, `Set-ItemProperty`, `Stop-Process`, `Restart-Computer`, `Stop-Computer`.", - "", - "Network restrictions:", - "- Do not make network requests unless the user explicitly asks.", - "- Do not use `curl`, `wget`, `Invoke-WebRequest`, `Invoke-RestMethod`, `ssh`, `scp`, `rsync`, `nc`, `nmap` unless explicitly requested and safe.", - "", - "Secrets and privacy:", - "- Never read secrets, tokens, API keys, passwords, private keys, certificates, `.env` files, SSH keys, browser data or credential stores unless the user explicitly asks and it is necessary.", - "- If command output contains secrets, do not repeat them back to the user.", - "", - "Command construction:", - "- Do not execute untrusted user text directly as shell code.", - "- Quote paths and arguments safely.", - "- Avoid command chaining with `;`, `&&`, `||`, pipes, backticks or command substitution unless necessary.", - "- Avoid glob patterns that may affect too many files.", - "- If unsure whether a command is safe, do not run it.", - "", -].join("\n"); - -const braveSearchToolPrompt = [ - "Brave Search tool rules:", - "- You have access to `web_search`.", - "- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.", - "- Use `web_search` for normal web search results.", - "- Do not use `evaluation` for web search.", - "", - "How to query:", - "- Keep search queries short and focused.", - "- Prefer the user's original language unless another language is clearly better for the topic.", - "- Use `searchLang` based on the expected language of results: `ru` for Russian, `en` for English, `de` for German.", - "- Use `country` for localization when relevant, for example `RU`, `US`, `DE`.", - "- Use `count` between 3 and 10 by default.", - "- Use `resultFilter: \"web\"` for normal search.", - "- Use `resultFilter: \"news,web\"` for recent news/events.", - "- Use `resultFilter: \"videos\"` only when the user asks for videos.", - "- Use `resultFilter: \"discussions,web\"` when forum/community opinions are useful.", - "", - "Freshness:", - "- Use `freshness: \"pd\"` for last 24 hours.", - "- Use `freshness: \"pw\"` for last 7 days.", - "- Use `freshness: \"pm\"` for last 31 days.", - "- Use `freshness: \"py\"` for last 365 days.", - "- Use a custom range like `2025-01-01to2025-12-31` only when the user asks for a specific date range.", - "", - "Answering:", - "- Treat snippets as hints, not as full source documents.", - "- Do not invent details that are not present in the search results.", - "- When giving factual claims based on search results, mention the source title or URL.", - "- If results are weak, ambiguous or empty, say that the search result was insufficient.", - "", -].join("\n"); - -const fileToolsToolPrompt = [ - "Filesystem tool rules:", - "- You have access to filesystem tools working only inside the hardcoded root directory.", - "- All filesystem paths must be relative to the root directory.", - "- You may go into child directories.", - "- You must never go up to parent directories.", - "- Do not use ../ paths.", - "- Do not use absolute paths.", - "- Do not try to access symlinks.", - "- Use read_file for reading files.", - "- Use list_directory for reading directories.", - "- Use create_file for creating files.", - "- Use create_directory for creating directories.", - "- Use update_file for replacing, appending or prepending file content.", - "- Use rename_path for renaming or moving files/directories inside the root.", - "- Use delete_path for deleting files/directories inside the root.", - "" -].join("\n"); - -const getToolSystemPrompt = () => { - const lines = [ - "Internal/tool behavior:" + - "- Never mention tools, function calls, tool names, logs, cache, prompts, system messages, or implementation details unless the user explicitly asks about them." + - "- If the user says your previous answer is wrong, inconsistent, weird, or asks \"разве это похоже?\", do not explain it by tool behavior." + - "- Instead: briefly admit the issue, compare with the previous answer, correct the answer, and explain the actual user-facing mistake." + - "- Do not say \"I cannot guarantee identical tool results\" unless the user explicitly asks about tool determinism.", - "", - - dateTimeToolPrompt - ] - - if (Environment.OPEN_WEATHER_MAP_API_KEY) { - lines.push(weatherToolPrompt); - } - - if (Environment.ENABLE_UNSAFE_EVAL) { - // TODO: 03/05/2026, Danil Nikolaev: check security moments - lines.push(evaluationToolPrompt); - } - - if (Environment.BRAVE_SEARCH_API_KEY) { - lines.push(braveSearchToolPrompt,) - } - - if (Environment.FILE_TOOLS_ROOT_DIR) { - lines.push(fileToolsToolPrompt) - } - - return lines.join("\n"); -} - -function asIntegerInRange( - value: unknown, - fallback: number, - min: number, - max: number, -): number { - const parsed = typeof value === "number" - ? value - : typeof value === "string" - ? Number(value) - : NaN; - - if (!Number.isFinite(parsed)) return fallback; - - const int = Math.trunc(parsed); - - return Math.min(max, Math.max(min, int)); -} - -function asEnum( - value: unknown, - allowed: readonly T[], - fallback: T, -): T { - if (typeof value !== "string") return fallback; - - const normalized = value.trim(); - - return allowed.includes(normalized as T) - ? normalized as T - : fallback; -} - -function cleanSearchText(value: unknown): string | null { - if (typeof value !== "string") return null; - - return value - .replace(/<[^>]*>/g, "") - .replace(/"/g, "\"") - .replace(/'/g, "'") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/\s+/g, " ") - .trim() || null; -} - -function normalizeBraveResultFilter(value: unknown): string { - const allowed = new Set([ - "discussions", - "faq", - "infobox", - "news", - "query", - "summarizer", - "videos", - "web", - "locations", - ]); - - const raw = asNonEmptyString(value); - - if (!raw) return "web"; - - const parts = raw - .split(",") - .map(part => part.trim().toLowerCase()) - .filter(part => allowed.has(part)); - - return parts.length ? [...new Set(parts)].join(",") : "web"; -} - -function asNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : undefined; -} - -function normalizeToolArguments(args: unknown): Record { - if (!args) return {}; - - if (typeof args === "string") { - try { - const parsed = JSON.parse(args); - - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - return { - raw: args, - }; - } - - return {}; - } - - if (typeof args === "object" && !Array.isArray(args)) { - return args as Record; - } - - return {}; -} - -async function webSearch(args?: Record) { - console.log("braveSearch()"); - - try { - const query = asNonEmptyString(args?.query); - - if (!query) { - throw new Error("query is required"); - } - - if (query.length > 400) { - throw new Error("query is too long. Max allowed length is 400 characters."); - } - - const wordCount = query.split(/\s+/).filter(Boolean).length; - - if (wordCount > 50) { - throw new Error("query has too many words. Max allowed word count is 50."); - } - - const count = asIntegerInRange(args?.count, 5, 1, 20); - const offset = asIntegerInRange(args?.offset, 0, 0, 9); - - const country = asNonEmptyString(args?.country)?.toUpperCase() ?? "US"; - const searchLang = asNonEmptyString(args?.searchLang)?.toLowerCase() ?? "en"; - const uiLang = asNonEmptyString(args?.uiLang) ?? undefined; - - const safesearch = asEnum( - args?.safesearch, - ["off", "moderate", "strict"] as const, - "moderate", - ); - - const freshness = asNonEmptyString(args?.freshness); - const resultFilter = normalizeBraveResultFilter(args?.resultFilter); - - const extraSnippets = asBoolean(args?.extraSnippets, false); - const spellcheck = asBoolean(args?.spellcheck, true); - - const response = await axios.get( - "https://api.search.brave.com/res/v1/web/search", - { - timeout: 10_000, - params: { - q: query, - count, - offset, - country, - search_lang: searchLang, - safesearch, - result_filter: resultFilter, - text_decorations: false, - spellcheck, - extra_snippets: extraSnippets, - ...(uiLang ? {ui_lang: uiLang} : {}), - ...(freshness ? {freshness} : {}), - }, - headers: { - "Accept": "application/json", - "Accept-Encoding": "gzip", - "X-Subscription-Token": Environment.BRAVE_SEARCH_API_KEY, - "User-Agent": "TelegramBot/1.0", - }, - }, - ); - - const data = response.data; - - return { - ok: true, - query, - alteredQuery: data.query?.altered ?? null, - moreResultsAvailable: data.query?.more_results_available ?? null, - resultFilter, - count, - offset, - country, - searchLang, - safesearch, - freshness: freshness ?? null, - - web: data.web?.results?.map(mapBraveResult) ?? [], - news: data.news?.results?.map(mapBraveResult) ?? [], - videos: data.videos?.results?.map(mapBraveResult) ?? [], - discussions: data.discussions?.results?.map(mapBraveResult) ?? [], - - hasInfobox: Boolean(data.infobox), - hasFaq: Boolean(data.faq), - hasLocations: Boolean(data.locations), - hasSummarizer: Boolean(data.summarizer), - - note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.", - }; - } catch (e: any) { - logError(e); - - const status = e?.response?.status; - const data = e?.response?.data; - - return { - ok: false, - status: typeof status === "number" ? status : null, - error: e instanceof Error ? e.message : String(e), - response: data ?? null, - }; - } finally { - console.log("END: braveSearch()"); - } -} - -function mapBraveResult(result: BraveSearchResult) { - return { - title: cleanSearchText(result.title), - url: asNonEmptyString(result.url) ?? null, - description: cleanSearchText(result.description), - age: asNonEmptyString(result.age) ?? asNonEmptyString(result.page_age) ?? null, - language: asNonEmptyString(result.language) ?? null, - source: asNonEmptyString(result.profile?.name) - ?? asNonEmptyString(result.profile?.long_name) - ?? asNonEmptyString(result.meta_url?.hostname) - ?? null, - hostname: asNonEmptyString(result.meta_url?.hostname) ?? null, - thumbnail: asNonEmptyString(result.thumbnail?.src) - ?? asNonEmptyString(result.thumbnail?.original) - ?? null, - extraSnippets: Array.isArray(result.extra_snippets) - ? result.extra_snippets - .map(cleanSearchText) - .filter((value): value is string => Boolean(value)) - : [], - }; -} - -function getSystemTimeZone(): string { - return Intl.DateTimeFormat().resolvedOptions().timeZone; -} - -function asBoolean(value: unknown, defaultValue = false): boolean { - if (typeof value === "boolean") return value; - - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - - if (normalized === "true") return true; - if (normalized === "false") return false; - } - - return defaultValue; -} - -function asString(value: unknown, defaultValue = ""): string { - return typeof value === "string" ? value : defaultValue; -} - -function asPositiveInt(value: unknown, defaultValue: number, maxValue: number): number { - const n = typeof value === "number" - ? value - : typeof value === "string" - ? Number(value) - : NaN; - - if (!Number.isFinite(n) || n <= 0) return defaultValue; - - return Math.min(Math.floor(n), maxValue); -} - -async function ensureFileToolsRootExists(): Promise { - await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true}); - - const stat = await fs.promises.stat(requireFileToolsRootDir()); - - if (!stat.isDirectory()) { - throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`); - } -} - -function resolveSafeToolPath(inputPath: unknown, fallback = "."): { - absolutePath: string; - relativePath: string; -} { - const rawPath = asNonEmptyString(inputPath) ?? fallback; - - if (rawPath.includes("\0")) { - throw new Error("Path must not contain null bytes."); - } - - if ( - path.isAbsolute(rawPath) || - path.win32.isAbsolute(rawPath) || - path.posix.isAbsolute(rawPath) - ) { - throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory."); - } - - const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep); - - const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath); - const relativePath = path.relative(requireFileToolsRootDir(), absolutePath); - - if ( - relativePath.startsWith("..") || - path.isAbsolute(relativePath) - ) { - throw new Error("Path escapes the root directory. Going up is not allowed."); - } - - return { - absolutePath, - relativePath: relativePath || ".", - }; -} - -function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void { - const relative = path.relative(sourceAbsolutePath, targetAbsolutePath); - - if ( - relative === "" || - (!relative.startsWith("..") && !path.isAbsolute(relative)) - ) { - throw new Error("Cannot copy a directory into itself."); - } -} - -async function assertNoSymlinkInPath( - absolutePath: string, - options?: { - allowMissingTail?: boolean; - } -): Promise { - await ensureFileToolsRootExists(); - - const relativePath = path.relative(requireFileToolsRootDir(), absolutePath); - - if (!relativePath || relativePath === ".") { - return; - } - - const parts = relativePath.split(path.sep).filter(Boolean); - - let currentPath = requireFileToolsRootDir(); - - for (const part of parts) { - currentPath = path.join(currentPath, part); - - try { - const stat = await fs.promises.lstat(currentPath); - - if (stat.isSymbolicLink()) { - throw new Error("Symlinks are not allowed in file tool paths."); - } - } catch (e: any) { - if (e?.code === "ENOENT" && options?.allowMissingTail) { - return; - } - - throw e; - } - } -} - -async function pathExists(absolutePath: string): Promise { - try { - await fs.promises.lstat(absolutePath); - return true; - } catch (e: any) { - if (e?.code === "ENOENT") return false; - throw e; - } -} - -function assertNotRoot(relativePath: string): void { - if (relativePath === ".") { - throw new Error("Operation on the root directory itself is not allowed."); - } -} - -function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" { - if (stat.isSymbolicLink()) return "symlink"; - if (stat.isFile()) return "file"; - if (stat.isDirectory()) return "directory"; - return "other"; -} - -async function evaluation(args?: Record): Promise { - const cmd = asNonEmptyString(args?.cmd); - if (!cmd) return undefined; - - const {stdout, stderr} = await runCommand(cmd); - - return stdout ?? stderr; -} - -function getCurrentDateTime(args?: Record) { - const now = new Date(); - - const systemTimeZone = getSystemTimeZone(); - const requestedTimeZone = asNonEmptyString(args?.timeZone); - const requestedLocale = asNonEmptyString(args?.locale); - - const timeZone = requestedTimeZone ?? systemTimeZone; - const locale = requestedLocale ?? undefined; - - try { - const formatted = new Intl.DateTimeFormat(locale, { - timeZone, - dateStyle: "full", - timeStyle: "long", - }).format(now); - - return { - iso: now.toISOString(), - unixMs: now.getTime(), - timeZone, - systemTimeZone, - locale: locale ?? "system-default", - formatted, - }; - } catch (error) { - const formatted = new Intl.DateTimeFormat(undefined, { - timeZone: systemTimeZone, - dateStyle: "full", - timeStyle: "long", - }).format(now); - - return { - iso: now.toISOString(), - unixMs: now.getTime(), - timeZone: systemTimeZone, - systemTimeZone, - locale: "system-default", - formatted, - warning: "Invalid locale or timezone was provided. Fallback to system locale and system timezone was used.", - requestedTimeZone: requestedTimeZone ?? null, - requestedLocale: requestedLocale ?? null, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -async function getWeather(args?: Record): Promise { - console.log("getWeather()"); - try { - const city = asNonEmptyString(args?.city); - const lang = asNonEmptyString(args?.lang); - - if (!city) { - return null; - } - - const apiKey = Environment.OPEN_WEATHER_MAP_API_KEY; - - const geocodeResponse = (await axios.get(`https://api.openweathermap.org/geo/1.0/direct?q=${city}&limit=1&appid=${apiKey}`)).data[0]; - console.log("GEOCODE_RESPONSE", geocodeResponse); - const lat = geocodeResponse.lat; - const lon = geocodeResponse.lon; - - const response = (await axios.get(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${apiKey}` + (lang ? `&lang=${lang}` : ""))).data; - console.log("RESPONSE: getWeather(lang=" + lang + "): ", response); - - const main = response.main; - const sys = response.sys; - const wind = response.wind; - const weather = response.weather[0]; - - let date = new Date(sys.sunrise * 1000); - - const sunrise = [ - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds(), - ] - .map((v) => String(v).padStart(2, "0")) - .join(":"); - - date = new Date(sys.sunset * 1000); - - const sunset = [ - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds(), - ] - .map((v) => String(v).padStart(2, "0")) - .join(":"); - - return { - ok: true, - tool: "get_weather", - scope: { - city, - lang, - validOnlyForExactCity: true, - liveData: true, - note: "If the user asks about another city, call get_weather again.", - }, - weather: { - main: weather.main, - description: weather.description, - temperature: main.temp, - temperatureMax: main.temp_max, - temperatureMin: main.temp_min, - feelsLike: main.feels_like, - humidity: main.humidity, - pressure: main.pressure, - seaLevel: main.sea_level ?? null, - groundLevel: main.grnd_level ?? null, - sunriseUtc: sunrise, - sunsetUtc: sunset, - windDegree: wind.deg, - windSpeed: wind.speed, - }, - }; - } catch (e: any) { - logError(e); - return null; - } finally { - console.log("END: getWeather()"); - } -} - -async function readFile(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - await assertNoSymlinkInPath(absolutePath); - - const stat = await fs.promises.lstat(absolutePath); - - if (!stat.isFile()) { - throw new Error(`Path is not a file: ${relativePath}`); - } - - const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES); - - if (stat.size > maxBytes) { - throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`); - } - - const buffer = await fs.promises.readFile(absolutePath); - - if (buffer.includes(0)) { - throw new Error("Binary files are not supported."); - } - - return { - ok: true, - path: relativePath, - sizeBytes: stat.size, - content: buffer.toString("utf8"), - }; -} - -async function listDirectory(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, "."); - - await assertNoSymlinkInPath(absolutePath); - - const stat = await fs.promises.lstat(absolutePath); - - if (!stat.isDirectory()) { - throw new Error(`Path is not a directory: ${relativePath}`); - } - - const dirEntries = await fs.promises.readdir(absolutePath, { - withFileTypes: true, - }); - - const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES); - - const entries = await Promise.all(limitedEntries.map(async entry => { - const entryAbsolutePath = path.join(absolutePath, entry.name); - const entryRelativePath = relativePath === "." - ? entry.name - : path.join(relativePath, entry.name); - - const entryStat = await fs.promises.lstat(entryAbsolutePath); - - return { - name: entry.name, - path: entryRelativePath, - type: getEntryType(entryStat), - sizeBytes: entryStat.isFile() ? entryStat.size : null, - modifiedAt: entryStat.mtime.toISOString(), - }; - })); - - return { - ok: true, - path: relativePath, - entries, - totalEntries: dirEntries.length, - returnedEntries: entries.length, - truncated: dirEntries.length > entries.length, - }; -} - -async function createFile(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - assertNotRoot(relativePath); - - const content = asString(args?.content, ""); - const overwrite = asBoolean(args?.overwrite, false); - const createParents = asBoolean(args?.createParents, true); - - const contentSizeBytes = Buffer.byteLength(content, "utf8"); - - if (contentSizeBytes > MAX_FILE_WRITE_BYTES) { - throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`); - } - - const parentPath = path.dirname(absolutePath); - - if (createParents) { - await assertNoSymlinkInPath(parentPath, {allowMissingTail: true}); - await fs.promises.mkdir(parentPath, {recursive: true}); - } else { - await assertNoSymlinkInPath(parentPath); - } - - if (await pathExists(absolutePath)) { - const stat = await fs.promises.lstat(absolutePath); - - if (stat.isSymbolicLink()) { - throw new Error("Symlink targets are not allowed."); - } - - if (stat.isDirectory()) { - throw new Error(`Path is a directory, not a file: ${relativePath}`); - } - - if (!overwrite) { - throw new Error(`File already exists: ${relativePath}`); - } - } - - await fs.promises.writeFile(absolutePath, content, { - encoding: "utf8", - flag: overwrite ? "w" : "wx", - }); - - return { - ok: true, - path: relativePath, - sizeBytes: contentSizeBytes, - overwritten: overwrite, - }; -} - -type CopyPathStats = { - entries: number; - totalBytes: number; -}; - -async function copyPathRecursive(params: { - sourceAbsolutePath: string; - targetAbsolutePath: string; - overwrite: boolean; - stats: CopyPathStats; -}): Promise { - const { - sourceAbsolutePath, - targetAbsolutePath, - overwrite, - stats, - } = params; - - if (stats.entries >= MAX_COPY_ENTRIES) { - throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`); - } - - stats.entries++; - - const sourceStat = await fs.promises.lstat(sourceAbsolutePath); - - if (sourceStat.isSymbolicLink()) { - throw new Error("Symlinks are not allowed in copied paths."); - } - - if (sourceStat.isFile()) { - stats.totalBytes += sourceStat.size; - - if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) { - throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`); - } - - if (await pathExists(targetAbsolutePath)) { - const targetStat = await fs.promises.lstat(targetAbsolutePath); - - if (targetStat.isSymbolicLink()) { - throw new Error("Symlink targets are not allowed."); - } - - if (targetStat.isDirectory()) { - throw new Error("Cannot overwrite a directory with a file."); - } - - if (!overwrite) { - throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`); - } - } - - await fs.promises.copyFile( - sourceAbsolutePath, - targetAbsolutePath, - overwrite ? 0 : fs.constants.COPYFILE_EXCL, - ); - - return; - } - - if (sourceStat.isDirectory()) { - if (await pathExists(targetAbsolutePath)) { - const targetStat = await fs.promises.lstat(targetAbsolutePath); - - if (targetStat.isSymbolicLink()) { - throw new Error("Symlink targets are not allowed."); - } - - if (!targetStat.isDirectory()) { - throw new Error("Cannot overwrite a file with a directory."); - } - } else { - await fs.promises.mkdir(targetAbsolutePath); - } - - const entries = await fs.promises.readdir(sourceAbsolutePath); - - for (const entry of entries) { - const childSourcePath = path.join(sourceAbsolutePath, entry); - const childTargetPath = path.join(targetAbsolutePath, entry); - - await copyPathRecursive({ - sourceAbsolutePath: childSourcePath, - targetAbsolutePath: childTargetPath, - overwrite, - stats, - }); - } - - return; - } - - throw new Error("Only files and directories can be copied."); -} - -async function copyPath(args?: Record) { - const source = resolveSafeToolPath(args?.sourcePath); - const target = resolveSafeToolPath(args?.targetPath); - - assertNotRoot(source.relativePath); - assertNotRoot(target.relativePath); - - await assertNoSymlinkInPath(source.absolutePath); - - const sourceStat = await fs.promises.lstat(source.absolutePath); - - if (sourceStat.isSymbolicLink()) { - throw new Error("Symlink sources are not allowed."); - } - - const recursive = asBoolean(args?.recursive, false); - const overwrite = asBoolean(args?.overwrite, false); - const createParents = asBoolean(args?.createParents, true); - - if (sourceStat.isDirectory() && !recursive) { - throw new Error("Source is a directory. Set recursive=true to copy directories."); - } - - if (sourceStat.isDirectory()) { - assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath); - } - - const targetParentPath = path.dirname(target.absolutePath); - - if (createParents) { - await assertNoSymlinkInPath(targetParentPath, { - allowMissingTail: true, - }); - - await fs.promises.mkdir(targetParentPath, { - recursive: true, - }); - - await assertNoSymlinkInPath(targetParentPath); - } else { - await assertNoSymlinkInPath(targetParentPath); - } - - const stats: CopyPathStats = { - entries: 0, - totalBytes: 0, - }; - - await copyPathRecursive({ - sourceAbsolutePath: source.absolutePath, - targetAbsolutePath: target.absolutePath, - overwrite, - stats, - }); - - return { - ok: true, - from: source.relativePath, - to: target.relativePath, - recursive, - overwrite, - entriesCopied: stats.entries, - bytesCopied: stats.totalBytes, - }; -} - -async function createDirectory(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - const recursive = asBoolean(args?.recursive, true); - - await assertNoSymlinkInPath(absolutePath, { - allowMissingTail: true, - }); - - await fs.promises.mkdir(absolutePath, { - recursive, - }); - - return { - ok: true, - path: relativePath, - recursive, - }; -} - -async function updateFile(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - assertNotRoot(relativePath); - - const content = asString(args?.content, ""); - const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase(); - const createIfMissing = asBoolean(args?.createIfMissing, false); - - if (!["replace", "append", "prepend"].includes(mode)) { - throw new Error(`Unsupported update mode: ${mode}`); - } - - const contentSizeBytes = Buffer.byteLength(content, "utf8"); - - if (contentSizeBytes > MAX_FILE_WRITE_BYTES) { - throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`); - } - - const parentPath = path.dirname(absolutePath); - - await assertNoSymlinkInPath(parentPath); - - const exists = await pathExists(absolutePath); - - if (!exists && !createIfMissing) { - throw new Error(`File does not exist: ${relativePath}`); - } - - if (exists) { - await assertNoSymlinkInPath(absolutePath); - - const stat = await fs.promises.lstat(absolutePath); - - if (!stat.isFile()) { - throw new Error(`Path is not a file: ${relativePath}`); - } - } - - if (mode === "replace") { - await fs.promises.writeFile(absolutePath, content, { - encoding: "utf8", - flag: "w", - }); - } else if (mode === "append") { - await fs.promises.appendFile(absolutePath, content, { - encoding: "utf8", - }); - } else { - const oldContent = exists - ? await fs.promises.readFile(absolutePath, "utf8") - : ""; - - const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8"); - - if (resultSizeBytes > MAX_FILE_WRITE_BYTES) { - throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`); - } - - await fs.promises.writeFile(absolutePath, content + oldContent, { - encoding: "utf8", - flag: "w", - }); - } - - const newStat = await fs.promises.stat(absolutePath); - - return { - ok: true, - path: relativePath, - mode, - sizeBytes: newStat.size, - created: !exists, - }; -} - -async function renamePath(args?: Record) { - const source = resolveSafeToolPath(args?.sourcePath); - const target = resolveSafeToolPath(args?.targetPath); - - assertNotRoot(source.relativePath); - assertNotRoot(target.relativePath); - - await assertNoSymlinkInPath(source.absolutePath); - - const sourceStat = await fs.promises.lstat(source.absolutePath); - - if (sourceStat.isSymbolicLink()) { - throw new Error("Symlink targets are not allowed."); - } - - const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath); - - if ( - relativeTargetInsideSource === "" || - (!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource)) - ) { - throw new Error("Cannot move a directory into itself."); - } - - const overwrite = asBoolean(args?.overwrite, false); - const createParents = asBoolean(args?.createParents, false); - - const targetParentPath = path.dirname(target.absolutePath); - - if (createParents) { - await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true}); - await fs.promises.mkdir(targetParentPath, {recursive: true}); - } else { - await assertNoSymlinkInPath(targetParentPath); - } - - if (await pathExists(target.absolutePath)) { - const targetStat = await fs.promises.lstat(target.absolutePath); - - if (targetStat.isSymbolicLink()) { - throw new Error("Symlink targets are not allowed."); - } - - if (!overwrite) { - throw new Error(`Target already exists: ${target.relativePath}`); - } - - if (sourceStat.isDirectory() || targetStat.isDirectory()) { - throw new Error("Overwrite for directories is not supported."); - } - - await fs.promises.rm(target.absolutePath, { - force: false, - }); - } - - await fs.promises.rename(source.absolutePath, target.absolutePath); - - return { - ok: true, - from: source.relativePath, - to: target.relativePath, - overwrite, - }; -} - -async function deletePath(args?: Record) { - const {absolutePath, relativePath} = resolveSafeToolPath(args?.path); - - assertNotRoot(relativePath); - - await assertNoSymlinkInPath(absolutePath); - - const stat = await fs.promises.lstat(absolutePath); - - if (stat.isSymbolicLink()) { - throw new Error("Symlink targets are not allowed."); - } - - const recursive = asBoolean(args?.recursive, false); - - if (stat.isDirectory()) { - if (recursive) { - await fs.promises.rm(absolutePath, { - recursive: true, - force: false, - }); - } else { - await fs.promises.rmdir(absolutePath); - } - } else { - await fs.promises.rm(absolutePath, { - force: false, - }); - } - - return { - ok: true, - path: relativePath, - recursive, - deleted: true, - }; -} - -const getToolHandlers = () => { - let handlers: Record = { - get_datetime: getCurrentDateTime, - }; - - if (Environment.ENABLE_UNSAFE_EVAL) { - handlers = { - evaluation: evaluation, - ...handlers - } - } - - if (Environment.BRAVE_SEARCH_API_KEY) { - handlers = { - web_search: webSearch, - ...handlers - } - } - - if (Environment.OPEN_WEATHER_MAP_API_KEY) { - handlers = { - get_weather: getWeather, - ...handlers - } - } - - if (Environment.FILE_TOOLS_ROOT_DIR) { - handlers = { - read_file: readFile, - list_directory: listDirectory, - create_file: createFile, - create_directory: createDirectory, - update_file: updateFile, - rename_path: renamePath, - copy_path: copyPath, - delete_path: deletePath, - ...handlers - } - } - - return handlers; -}; - -function stringifyToolResult(result: unknown): string { - if (typeof result === "string") return result; - return JSON.stringify(result, null, 2); -} - -async function executeToolCall( - name: string, - args?: unknown, -): Promise { - const handler = getToolHandlers()[name as keyof typeof getToolHandlers]; - - if (!handler) { - return stringifyToolResult({ - error: `Unknown tool: ${name}`, - }); - } - - try { - const result = await handler(normalizeToolArguments(args)); - return stringifyToolResult(result); - } catch (error) { - return stringifyToolResult({ - error: error instanceof Error ? error.message : String(error), - }); - } -} - -function addToolSystemPrompt(chatMessages: ChatMessage[]): void { - const systemMessage = chatMessages.find(message => message.role === "system"); - - if (systemMessage) { - systemMessage.content = `${systemMessage.content}\n\n${getToolSystemPrompt()}`; - return; - } - - chatMessages.unshift({ - role: "system", - content: getToolSystemPrompt(), - images: [], - }); -} - -function cleanupThinkingContent(content: string, isThinking: boolean): { - visibleContent: string; - isThinking: boolean; -} { - let result = content; - let nextThinking = isThinking; - - if (result.includes("")) { - nextThinking = true; - result = result.slice(result.indexOf("") + "".length); - } - - if (nextThinking) { - if (result.includes("")) { - result = result.slice(result.indexOf("") + "".length); - nextThinking = false; - } else { - result = ""; - } - } - - return { - visibleContent: result, - isThinking: nextThinking, - }; -} +import {Requirements} from "../base/requirements"; +import {Requirement} from "../base/requirement"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {Environment} from "../common/environment"; export class OllamaChat extends ChatCommand { - command = ["ollamaThink", "ollama"]; + command = ["ollama", "ollama-chat", "think"]; argsMode = "required" as const; - title = "/ollama"; - description = "Chat with AI (Ollama)"; + requirements = Requirements.Build(Requirement.BOT_CREATOR); - async execute(msg: Message, match?: RegExpExecArray | null): Promise { - console.log("match", match); + title = Environment.commandTitles.ollamaChat; + description = Environment.commandDescriptions.ollamaChat; + + async execute(msg: Message, match?: RegExpExecArray): Promise { return this.executeOllama(msg, match?.[3] || "", match?.[1]?.toLowerCase()?.startsWith("think")); } - async executeOllama(msg: Message, text: string, think: boolean = false, voiceB64?: string | null): Promise { - if (!msg.from) return; - if ((!text || !text.trim().length) && !voiceB64) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - - let messageParts: MessagePart[] = []; - - if (!voiceB64) { - messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - } - - const chatMessages: ChatMessage[] = !voiceB64 ? messageParts.map(part => { - return { - role: part.bot ? "assistant" as const : "user" as const, - content: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `"${part.name}":\n` : "") + part.content, - images: part.images - }; - }) : [ - { - role: "user", - content: "Transcribe this audio file", - images: [voiceB64] - } - ]; - chatMessages.reverse(); - - console.log("PARTS", chatMessages); - - if (Environment.SYSTEM_PROMPT && !voiceB64 && Environment.USE_SYSTEM_PROMPT) { - chatMessages.unshift({role: "system", content: Environment.SYSTEM_PROMPT, images: []}); - } - - let waitMessage: Message | null = null; - - const startTime = Date.now(); - - try { - const imagesCount = chatMessages.reduce((total, curr) => { - return total + (curr.images?.length ?? 0); - }, 0); - - const enabledTools = !voiceB64 && imagesCount === 0 ? - msg.from.id === Environment.CREATOR_ID ? getTools() - : [ - getCurrentDateTimeTool, - getWeatherTool, - ] : undefined; - - if (enabledTools?.length) { - addToolSystemPrompt(chatMessages); - } - - if (!think && imagesCount) { - try { - const modelInfo = await commands.find(c => c instanceof OllamaGetModel)?.loadImageModelInfo(); - if (modelInfo) { - if (!modelInfo.vision?.supported) { - await replyToMessage({ - message: msg, - text: "Моя текущая модель не умеет анализировать изображения 🥹" - }); - return; - } - } - } catch (e) { - logError(e); - } - } - - if (think) { - try { - const modelInfo = await commands.find(c => c instanceof OllamaGetModel)?.loadThinkModelInfo(); - if (modelInfo) { - if (!modelInfo.thinking?.supported) { - await replyToMessage({ - message: msg, - text: "Моя текущая модель не умеет размышлять 🥹" - }); - return; - } - } - } catch (e) { - logError(e); - } - } - - const uuid = crypto.randomUUID(); - const emptyMarkup = {inline_keyboard: []}; - const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]}; - - const waitText = (!think && imagesCount && !voiceB64) ? - imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText - : voiceB64 ? Environment.transcribingAudioText : Environment.waitThinkText; - - waitMessage = await replyToMessage({ - message: msg, - text: waitText - }); - - let options: Partial | null = null; - try { - const optionsPath = path.join(Environment.DATA_PATH, "ollama_options.json"); - - if (fs.existsSync(optionsPath)) { - options = JSON.parse(fs.readFileSync(optionsPath).toString()); - } - } catch (e) { - logError(e); - } - - console.log("OPTIONS", options); - const model = (think ? Environment.OLLAMA_THINK_MODEL : imagesCount ? Environment.OLLAMA_IMAGE_MODEL : Environment.OLLAMA_MODEL); - - async function createStream() { - const stream = await ollama.chat({ - model: model, - stream: true, - // TODO: 01/05/2026, Danil Nikolaev: проверять на наличие think - think: think, - messages: chatMessages, - options: options ?? undefined, - // TODO: 01/05/2026, Danil Nikolaev: проверять на наличие tools - tools: enabledTools, - keep_alive: "60m" - }); - - const existingRequest = getOllamaRequest(uuid) as { stream?: unknown } | undefined; - - if (existingRequest) { - existingRequest.stream = stream; - } else { - const newRequest = { - uuid, - stream, - done: false, - fromId: msg.from!.id, - chatId: msg.chat.id, - }; - - console.log("Pushing new request", newRequest); - ollamaRequests.push(newRequest); - } - - return stream; - } - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - uuid: uuid, - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - if (getOllamaRequest(uuid)?.done) return; - if (!waitMessage) return; - - try { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text((!text || !text.trim().length) ? waitText : text), - parse_mode: "MarkdownV2", - reply_markup: cancelMarkup - }).catch(logError); - - console.log("editMessageText", text); - - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } catch (e) { - logError(e); - } - } - }); - await editor.tick(); - - // TODO: 01/05/2026, Danil Nikolaev: отображать thinking process - - try { - for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { - const stream = await createStream(); - - if (!waitMessage.reply_markup?.inline_keyboard?.length) { - await bot.editMessageReplyMarkup( - { - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: cancelMarkup - } - ).catch(logError); - waitMessage.reply_markup = cancelMarkup; - } - - let savedText = currentText; - let roundThinking = ""; - let roundText = ""; - let roundToolCalls: ToolCall[] = []; - let isThinking = false; - - for await (const chunk of stream) { - const message = chunk.message; - const content = message.content ?? ""; - - console.log("CHUNK", chunk); - - if (message.thinking) { - roundThinking += message.thinking; - - if (!isThinking) { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: escapeMarkdownV2Text("🧠 Размышляю..."), - parse_mode: "MarkdownV2", - reply_markup: cancelMarkup - }).catch(logError); - - if (waitMessage) { - waitMessage.text = escapeMarkdownV2Text("🧠 Размышляю..."); - waitMessage.reply_markup = cancelMarkup; - await MessageStore.put(waitMessage); - } - } - - isThinking = true; - } - - if (!message.thinking && !content.includes("")) { - isThinking = false; - } - - if (content) { - const cleaned = cleanupThinkingContent(content, isThinking); - console.log("CLEANED", cleaned); - isThinking = cleaned.isThinking; - - if (cleaned.visibleContent) { - roundText += cleaned.visibleContent; - currentText = savedText + "\n\n" + roundText; - console.log("ROUND_TEXT", roundText); - } - } - - if (message.tool_calls?.length) { - roundToolCalls.push(...message.tool_calls); - } - - if (currentText.length > TELEGRAM_MESSAGE_LIMIT) { - currentText = currentText.slice(0, TELEGRAM_MESSAGE_LIMIT - 3) + "..."; - shouldBreak = true; - } - - if (getOllamaRequest(uuid)?.done) { - shouldBreak = true; - } - - if (shouldBreak || chunk.done) { - console.log("messageText", currentText); - console.log("length", currentText.length); - - currentText = currentText.replaceAll(/^🔧 Использую инструмент.*$\r?\n?/gm, ""); - await editor.tick(); - - if (shouldBreak) { - console.log("break", true); - } else { - console.log("ended", true); - } - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - break; - } - } - - if (shouldBreak) { - break; - } - - if (roundToolCalls.length === 0) { - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - break; - } - - console.log("ROUND_TOOL_CALLS", roundToolCalls); - - chatMessages.push({ - role: "assistant", - content: roundText, - thinking: roundThinking || undefined, - tool_calls: roundToolCalls - }); - - for (const toolCall of roundToolCalls) { - const toolName = toolCall.function.name; - const toolArgs = toolCall.function.arguments ?? {}; - - currentText += "\n🔧 Использую инструмент " + `\`${toolName}\`` - await editor.tick(); - - const result = await executeToolCall(toolName, toolArgs); - - console.log("TOOL CALL", "Name: ", toolName, "; Args: " + JSON.stringify(toolArgs), "; Result: " + result); - - chatMessages.push({ - role: "tool", - tool_name: toolName, - content: result - }); - } - - if (round === MAX_TOOL_ROUNDS - 1) { - throw new Error("Too many tool calls"); - } - } - } finally { - const diff = Math.abs(Date.now() - startTime) / 1000; - - await editor.tick(); - await editor.stop(); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - - if ((waitMessage.reply_markup?.inline_keyboard?.length ?? 0) > 0) { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: emptyMarkup - }).catch(logError); - waitMessage.reply_markup = emptyMarkup; - } - - console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); - } - } catch (e: any) { - if (e.message.toLowerCase().includes("aborted")) return; - logError(e); - - if (waitMessage) { - if ((waitMessage.reply_markup?.inline_keyboard?.length ?? 0) > 0) { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - } - - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${e.toString()}`).catch(logError); - } - } + async executeOllama(msg: Message, text: string, think: boolean = false): Promise { + await runUnifiedAi({provider: AiProvider.OLLAMA, msg, text, stream: true, think: think}); } } diff --git a/src/commands/ollama-get-model.ts b/src/commands/ollama-get-model.ts index f4b25c3..bcf51a9 100644 --- a/src/commands/ollama-get-model.ts +++ b/src/commands/ollama-get-model.ts @@ -1,121 +1,15 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; -import {boolToEmoji, logError, replyToMessage} from "../util/utils"; +import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {formatRuntimeModelInfo} from "../ai/provider-model-runtime"; import {Environment} from "../common/environment"; -import {ollama} from "../index"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; export class OllamaGetModel extends Command { - title = "/ollamaGetModel"; - description = "Ollama model info"; + title = Environment.commandTitles.ollamaGetModel; + description = Environment.commandDescriptions.ollamaGetModel; async execute(msg: Message): Promise { - try { - const model = Environment.OLLAMA_MODEL; - const imageModel = Environment.OLLAMA_IMAGE_MODEL; - const thinkModel = Environment.OLLAMA_THINK_MODEL; - - const promises: (Promise | null)[] = [this.getModelCapabilities()]; - - if (imageModel && imageModel !== model) { - promises.push(this.loadImageModelInfo()); - } else { - promises.push(null); - } - - if (thinkModel && thinkModel !== model) { - promises.push(this.loadThinkModelInfo()); - } else { - promises.push(null); - } - - const infos = await Promise.all(promises); - - let modelInfo = infos[0]; - const modelText = "```Text\n" + this.getModelText(model, modelInfo) + "```"; - - modelInfo = infos[1]; - const imageModelText = modelInfo ? - "```Image\n" + this.getModelText(imageModel, modelInfo) + "```" : null; - - modelInfo = infos[2]; - const thinkModelText = modelInfo ? - "```Think\n" + this.getModelText(thinkModel, modelInfo) + "```" : null; - - const modelInfos = [modelText]; - if (imageModelText) { - modelInfos.push(imageModelText); - } - if (thinkModelText) { - modelInfos.push(thinkModelText); - } - - await replyToMessage({ - message: msg, - text: modelInfos.join("\n\n"), - parse_mode: "Markdown" - }).catch(logError); - - } catch (e: any) { - logError(e); - await replyToMessage({message: msg, text: e.toString()}).catch(logError); - } + await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.OLLAMA)}).catch(logError); } - - private getModelText(model: string | undefined, info: AiModelCapabilities | null): string { - return `model: ${model}\n\n` + - `vision: ${boolToEmoji(info?.vision?.supported)}\n` + - `ocr: ${boolToEmoji(info?.ocr?.supported)}\n` + - `thinking: ${boolToEmoji(info?.thinking?.supported)}\n` + - `tools: ${boolToEmoji(info?.tools?.supported)}\n` + - `audio: ${boolToEmoji(info?.audio?.supported)}`; - } - - async getModelCapabilities(model: string | undefined = Environment.OLLAMA_MODEL): Promise { - if (!model) return null; - - try { - const info = await ollama.show({model: model}); - console.log(info); - - return { - vision: { - supported: info.capabilities.includes("vision"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - ocr: { - supported: info.capabilities.includes("ocr"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - thinking: { - supported: info.capabilities.includes("thinking"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - tools: { - supported: info.capabilities.includes("tools"), - external: model !== Environment.OLLAMA_MODEL, - model: model - }, - audio: { - supported: info.capabilities.includes("audio"), - external: model !== Environment.OLLAMA_MODEL, - model: model - } - }; - } catch (e) { - logError(e); - return null; - } - } - - async loadImageModelInfo(): Promise { - return this.getModelCapabilities(Environment.OLLAMA_IMAGE_MODEL); - } - - async loadThinkModelInfo(): Promise { - return this.getModelCapabilities(Environment.OLLAMA_THINK_MODEL); - } -} \ No newline at end of file +} diff --git a/src/commands/ollama-list-models.ts b/src/commands/ollama-list-models.ts index cc3e974..8a5e1a0 100644 --- a/src/commands/ollama-list-models.ts +++ b/src/commands/ollama-list-models.ts @@ -1,36 +1,35 @@ import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {ollama} from "../index"; -import {logError, oldReplyToMessage, replyToMessage} from "../util/utils"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; +import {Message} from "typescript-telegram-bot-api"; +import {Environment} from "../common/environment"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {listProviderModels} from "../ai/provider-model-runtime"; +import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; export class OllamaListModels extends Command { - title = "/ollamaListModels"; - description = "List all Ollama models"; + title = Environment.commandTitles.ollamaListModels; + description = Environment.commandDescriptions.ollamaListModels; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message): Promise { try { - const listResponse = await ollama.list(); - console.log(listResponse); + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"); + const models = (await listProviderModels(AiProvider.OLLAMA)).sort((a, b) => a.localeCompare(b)); + const modelsString = escapeHtml(models.join("\n").substring(0, 4000)); + const loadedModels = ((await createOllamaClient(target).ps())?.models ?? []) + .map(model => model.model || model.name) + .filter((model): model is string => !!model); + const text = + Environment.getLoadedModelsText(loadedModels) + "\n\n" + + Environment.modelListHeaderText + "
" + modelsString + "
"; - const modelsString = listResponse.models - .sort((a, b) => a.name.localeCompare(b.name)) - .map(e => `${e.model}`) - .join("\n"); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); + await replyToMessage({message: msg, text, parse_mode: "HTML"}); } catch (e) { logError(e); - await oldReplyToMessage(msg, "Не получилось загрузить список моделей").catch(logError); + await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/ollama-prompt.ts b/src/commands/ollama-prompt.ts deleted file mode 100644 index 70199eb..0000000 --- a/src/commands/ollama-prompt.ts +++ /dev/null @@ -1,195 +0,0 @@ -import {Command} from "../base/command"; -import {Message} from "typescript-telegram-bot-api"; -import {abortOllamaRequest, bot, getOllamaRequest, ollama, ollamaRequests} from "../index"; -import {escapeMarkdownV2Text, logError, oldReplyToMessage, startIntervalEditor} from "../util/utils"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {Environment} from "../common/environment"; -import {Cancel} from "../callback_commands/cancel"; -import {OllamaCancel} from "../callback_commands/ollama-cancel"; -import {MessageStore} from "../common/message-store"; - -export class OllamaPrompt extends Command { - command = "ollamaPrompt"; - argsMode = "required" as const; - - title = "/ollamaPrompt"; - description = "Custom prompt for AI (Ollama)"; - - requirements = Requirements.Build(Requirement.BOT_ADMIN); - - async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("match", match); - return this.executeOllama(msg, match?.[3] || ""); - } - - async executeOllama(msg: Message, text: string): Promise { - if (!text || !text.trim().length) return; - if (!msg.from) return; - - const chatId = msg.chat.id; - - let waitMessage: Message | null = null; - - const startTime = Date.now(); - - try { - const uuid = crypto.randomUUID(); - const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]}; - - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await ollama.generate({ - model: Environment.OLLAMA_MODEL, - stream: true, - think: false, - prompt: text - }); - - const newRequest = { - uuid: uuid, - stream: stream, - done: false, - fromId: msg.from.id, - chatId: msg.chat.id, - }; - - console.log("Pushing new request", newRequest); - ollamaRequests.push(newRequest); - - await bot.editMessageReplyMarkup( - { - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: cancelMarkup - } - ).catch(logError); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - uuid: uuid, - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - if (getOllamaRequest(uuid)?.done) return; - - try { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage?.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "Markdown", - reply_markup: cancelMarkup - }).catch(logError); - - console.log("editMessageText", text); - - if (waitMessage) { - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } - } catch (e) { - logError(e); - } - } - }); - await editor.tick(); - - try { - let isThinking = false; - - for await (const chunk of stream) { - - const content = chunk.response; - - if (content === "" || chunk.thinking) { - if (!isThinking) { - await bot.editMessageText({ - chat_id: chatId, - message_id: waitMessage.message_id, - text: "🤔 Размышляю...", - parse_mode: "Markdown", - }).catch(logError); - } - - isThinking = true; - } - - if (!isThinking) { - currentText += content; - } - - if (isThinking && !chunk.thinking) { - currentText += content; - } - - if (content === "" || !chunk.thinking) { - isThinking = false; - } - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - if (getOllamaRequest(uuid)?.done) { - shouldBreak = true; - } - - if (shouldBreak || chunk.done) { - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - } else { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000; - - await editor.tick(); - await editor.stop(); - - console.log(`aborted request ${uuid}:`, abortOllamaRequest(uuid)); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - await oldReplyToMessage(waitMessage, `⏱️ ${diff}s`); - break; - } - } - } finally { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - } - } catch (e: any) { - if (e.message.toLowerCase().includes("aborted")) return; - logError(e); - - if (waitMessage) { - await bot.editMessageReplyMarkup({ - chat_id: chatId, - message_id: waitMessage.message_id, - reply_markup: {inline_keyboard: []} - }).catch(logError); - - await oldReplyToMessage(waitMessage, `Произошла ошибка!\n${e.toString()}`).catch(logError); - } - } - } -} \ No newline at end of file diff --git a/src/commands/ollama-search.ts b/src/commands/ollama-search.ts index 825236b..cc0b1a0 100644 --- a/src/commands/ollama-search.ts +++ b/src/commands/ollama-search.ts @@ -2,52 +2,39 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {bot, ollama} from "../index"; -import {WebSearchResponse} from "../model/web-search-response"; -import {logError, oldEditMessageText} from "../util/utils"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; import {Environment} from "../common/environment"; +import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; +import {AiProvider} from "../model/ai-provider"; export class OllamaSearch extends Command { command = ["s", "search"]; argsMode = "required" as const; - title = "/search"; - description = "Web search via Ollama"; + title = Environment.commandTitles.ollamaSearch; + description = Environment.commandDescriptions.ollamaSearch; override requirements = Requirements.Build(Requirement.BOT_ADMIN); async execute(msg: Message, match?: RegExpExecArray | null): Promise { - console.log("match", match); - const query = match?.[3] || ""; if (!query || !query.length) return; - const chatId = msg.chat.id; - try { - const wait = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - }, - parse_mode: "Markdown" + const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"); + const result = await createOllamaClient(target).webSearch({query, maxResults: 10}); + const body = (result.results ?? []) + .map((item, index) => `${index + 1}. ${item.content}`) + .join("\n\n"); + + await replyToMessage({ + message: msg, + text: Environment.searchResultsHeaderText + "
" + escapeHtml(body) + "
", + parse_mode: "HTML", }); - - const results = await ollama.webSearch({query: query}); - console.log("results", results); - - let message = "Результаты:\n\n"; - results.results.forEach((result, index) => { - const r = result as WebSearchResponse; - message += `${index + 1}. ${r.url}\n`; - }); - - await oldEditMessageText(chatId, wait.message_id, message); } catch (error) { logError(error); + await replyToMessage({message: msg, text: Environment.errorText}).catch(logError); } - return Promise.resolve(); } -} \ No newline at end of file +} diff --git a/src/commands/ollama-set-model.ts b/src/commands/ollama-set-model.ts index 4cccbf3..196868d 100644 --- a/src/commands/ollama-set-model.ts +++ b/src/commands/ollama-set-model.ts @@ -1,35 +1,29 @@ -import {Message} from "typescript-telegram-bot-api"; import {Command} from "../base/command"; -import {Environment} from "../common/environment"; -import {logError, replyToMessage} from "../util/utils"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; -import {ollama} from "../index"; +import {Message} from "typescript-telegram-bot-api"; +import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class OllamaSetModel extends Command { argsMode = "required" as const; - title = "/ollamaSetModel"; - description = "Set Ollama model"; + title = Environment.commandTitles.ollamaSetModel; + description = Environment.commandDescriptions.ollamaSetModel; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3] || ""; - if (!newModel || !newModel.length) return; + const newModel = match?.[3]?.trim(); + if (newModel) setRuntimeModel(AiProvider.OLLAMA, newModel); - try { - await ollama.show({model: newModel}); + const model = getRuntimeModel(AiProvider.OLLAMA); + const text = newModel + ? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.OLLAMA)) + : Environment.getModelIsNotSetCurrentText(model); - Environment.setOllamaModel(newModel || Environment.OLLAMA_MODEL); - - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.OLLAMA_MODEL}".`; - - await replyToMessage({message: msg, text: text}).catch(logError); - } catch (e: any) { - logError(e); - await replyToMessage({message: msg, text: e.toString()}).catch(logError); - } + await replyToMessage({message: msg, text}).catch(logError); } -} \ No newline at end of file +} diff --git a/src/commands/openai-chat.ts b/src/commands/openai-chat.ts index 342a93a..a30fa99 100644 --- a/src/commands/openai-chat.ts +++ b/src/commands/openai-chat.ts @@ -1,17 +1,10 @@ import {Message} from "typescript-telegram-bot-api"; -import {MessageStore} from "../common/message-store"; -import { - collectReplyChainText, - escapeMarkdownV2Text, - logError, - replyToMessage, - startIntervalEditor -} from "../util/utils"; -import {Environment} from "../common/environment"; -import {bot, openAi} from "../index"; +import {ChatCommand} from "../base/chat-command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; -import {ChatCommand} from "../base/chat-command"; +import {AiProvider} from "../model/ai-provider"; +import {runUnifiedAi} from "../ai/unified-ai-runner"; +import {Environment} from "../common/environment"; export class OpenAIChat extends ChatCommand { command = ["openai", "chatgpt"]; @@ -19,154 +12,14 @@ export class OpenAIChat extends ChatCommand { requirements = Requirements.Build(Requirement.BOT_CREATOR); - title = "/openAI"; - description = "Chat with AI (OpenAI)"; + title = Environment.commandTitles.openAiChat; + description = Environment.commandDescriptions.openAiChat; async execute(msg: Message, match?: RegExpExecArray): Promise { - console.log("OpenAI Chat: ", match); return this.executeOpenAI(msg, match?.[3] || ""); } - async executeOpenAI(msg: Message, text: string): Promise { - if (!text || !text.trim().length) return; - - const chatId = msg.chat.id; - - const storedMsg = await MessageStore.get(chatId, msg.message_id); - const messageParts = await collectReplyChainText(storedMsg); - console.log("MESSAGE PARTS", messageParts); - - const chatMessages = messageParts.map(part => { - const content = []; - content.push({ - type: part.bot ? "output_text" : "input_text", - text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER "${part.name}":\n` : "") + part.content, - }); - - // TODO: 03/02/2026, Danil Nikolaev: upload file then add here - // for (const image of part.images) { - // content.push({ - // type: "image_url", - // imageUrl: "data:image/jpeg;base64," + image - // }); - // } - - return { - role: part.bot ? "assistant" : "user", - content: content, - type: "message", - }; - }); - chatMessages.reverse(); - - if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) { - chatMessages.unshift({ - role: "system", - content: [{type: "input_text", text: Environment.SYSTEM_PROMPT}], - type: "message" - }); - } - - let waitMessage: Message | null = null; - - const startTime = Date.now(); - - try { - waitMessage = await bot.sendMessage({ - chat_id: chatId, - text: Environment.waitThinkText, - reply_parameters: { - chat_id: chatId, - message_id: msg.message_id - } - }); - - const stream = await openAi.responses.create({ - model: Environment.OPENAI_MODEL, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - input: chatMessages as any, - stream: true - }); - - let currentText = ""; - let shouldBreak = false; - - const editor = startIntervalEditor({ - intervalMs: 4500, - getText: () => currentText, - editFn: async (text) => { - await bot.editMessageText( - { - chat_id: chatId, - message_id: waitMessage?.message_id, - text: escapeMarkdownV2Text(text), - parse_mode: "MarkdownV2" - } - ).catch(logError); - - console.log("editMessageText", text); - - if (waitMessage) { - waitMessage.reply_to_message = msg; - waitMessage.text = text; - await MessageStore.put(waitMessage); - } - }, - onStop: async () => { - } - }); - await editor.tick(); - - try { - for await (const chunk of stream) { - console.log("chunk", chunk); - - if (chunk.type === "response.output_text.delta") { - const text = chunk.delta; - currentText += text; - - if (currentText.length > 4096) { - currentText = currentText.slice(0, 4093) + "..."; - shouldBreak = true; - } - - console.log("messageText", currentText); - console.log("length", currentText.length); - - if (shouldBreak) { - console.log("break", true); - break; - } - } - } - } finally { - await editor.tick(); - await editor.stop(); - - if (!shouldBreak) { - console.log("ended", true); - } - - const diff = Math.abs(Date.now() - startTime) / 1000.0; - console.log("time", diff); - - waitMessage.reply_to_message = msg; - waitMessage.text = currentText; - await MessageStore.put(waitMessage); - - if (Environment.SEND_TIME_TOOK) { - await replyToMessage({message: waitMessage, text: `⏱️ ${diff}s`}); - } - } - } catch (e: any) { - logError(e); - - if (waitMessage) { - await replyToMessage({ - message: waitMessage, - text: `Произошла ошибка!\n${e.toString()}` - }).catch(logError); - } - } + async executeOpenAI(msg: Message, text: string, stream: boolean = true): Promise { + await runUnifiedAi({provider: AiProvider.OPENAI, msg, text, stream}); } -} \ No newline at end of file +} diff --git a/src/commands/openai-gen-image.ts b/src/commands/openai-gen-image.ts deleted file mode 100644 index b559115..0000000 --- a/src/commands/openai-gen-image.ts +++ /dev/null @@ -1,117 +0,0 @@ -import {ChatCommand} from "../base/chat-command"; -import {Message} from "typescript-telegram-bot-api"; -import {Requirements} from "../base/requirements"; -import {Requirement} from "../base/requirement"; -import {bot, openAi, photoGenDir} from "../index"; -import fs from "node:fs"; -import path from "node:path"; -import {oldEditMessageText, logError, replyToMessage} from "../util/utils"; -import {Environment} from "../common/environment"; -import {APIError} from "openai"; - -export class OpenAIGenImage extends ChatCommand { - command = ["openAiGenImage", "chatGPTGenImage", "imgen"]; - - title = "/openAIGenImage"; - description = "Generate image from OpenAI"; - - argsMode = "required" as const; - requirements = Requirements.Build(Requirement.BOT_CREATOR); - - async execute(msg: Message, match?: RegExpExecArray): Promise { - const prompt = match?.[3]?.trim(); - if (!prompt?.length) return; - - let waitMessage: Message | null = null; - - try { - const totalParts = 3; - const model = Environment.OPENAI_IMAGE_MODEL; - const fileFullName = `${msg.chat.id}_${msg.message_id}.png`; - const getFileLocation = (fn: string) => { - return path.join(photoGenDir, fn); - }; - - waitMessage = await replyToMessage({message: msg, text: "🌈 Генерирую изображение..."}); - - const stream = await openAi.images.generate({ - model: model, - prompt: prompt, - n: 1, - size: "auto", - stream: true, - partial_images: totalParts, - moderation: "low", - output_format: "png", - }); - - const then = Date.now(); - - for await (const event of stream) { - switch (event.type) { - case "image_generation.partial_image": { - console.log(` Partial image ${event.partial_image_index + 1}/3 received`); - console.log(` Size: ${event.b64_json.length} characters (base64)`); - - const fileName = `partial_${event.partial_image_index + 1}_${fileFullName}`; - const imageBuffer = Buffer.from(event.b64_json, "base64"); - const fileLocation = getFileLocation(fileName); - fs.writeFileSync(fileLocation, imageBuffer); - console.log(` 💾 Saved to: ${path.resolve(fileLocation)}`); - - await bot.editMessageMedia({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - media: { - type: "photo", - media: imageBuffer, - caption: `🌈 Генерирую изображение (${(event.partial_image_index + 1)}/${totalParts})...` - } - }); - break; - } - case "image_generation.completed": { - console.log("\n✅ Final image completed!"); - console.log(` Size: ${event.b64_json.length} characters (base64)`); - - const imageBuffer = Buffer.from(event.b64_json, "base64"); - const fileLocation = getFileLocation(fileFullName); - fs.writeFileSync(fileLocation, imageBuffer); - console.log(` Saved to: ${path.resolve(fileLocation)}`); - - const diff = Date.now() - then; - await bot.editMessageMedia({ - chat_id: msg.chat.id, - message_id: waitMessage.message_id, - media: { - type: "photo", - media: imageBuffer, - caption: `🌈 Изображение по запросу "${prompt}" сгенерировано моделью "${model}" размеров ${event.size} за ${diff}ms` - } - }); - break; - } - default: - console.log(`❓ Unknown event: ${event}`); - } - } - } catch (e) { - logError(e); - - if (e instanceof APIError && e.error.code === "moderation_blocked") { - const text = "❌ Мне запрещено такое генерировать 😠"; - - if (waitMessage) { - await oldEditMessageText(msg.chat.id, waitMessage.message_id, text).catch(logError); - } else { - await replyToMessage({message: msg, text: text}).catch(logError); - } - } else { - await replyToMessage({ - message: waitMessage ? waitMessage : msg, - text: `Произошла ошибка: ${e}` - }).catch(logError); - } - } - } -} \ No newline at end of file diff --git a/src/commands/openai-get-model.ts b/src/commands/openai-get-model.ts index 8227e4c..3a13875 100644 --- a/src/commands/openai-get-model.ts +++ b/src/commands/openai-get-model.ts @@ -1,29 +1,15 @@ import {Command} from "../base/command"; import {Message} from "typescript-telegram-bot-api"; import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {formatRuntimeModelInfo} from "../ai/provider-model-runtime"; import {Environment} from "../common/environment"; -import {AiModelCapabilities} from "../model/ai-model-capabilities"; export class OpenAIGetModel extends Command { - title = "/openAIGetModel"; - description = "Get current OpenAI model"; + title = Environment.commandTitles.openAiGetModel; + description = Environment.commandDescriptions.openAiGetModel; async execute(msg: Message): Promise { - await replyToMessage({message: msg, text: `Текущая модель: "${Environment.OPENAI_MODEL}"`}).catch(logError); + await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.OPENAI)}).catch(logError); } - - async getModelCapabilities(): Promise { - // TODO: 12/02/2026, Danil Nikolaev: find solution - try { - return { - vision: {supported: true}, - ocr: undefined, - thinking: {supported: true}, - tools: {supported: true}, - }; - } catch (e) { - logError(e); - return null; - } - } -} \ No newline at end of file +} diff --git a/src/commands/openai-list-models.ts b/src/commands/openai-list-models.ts index 1760204..db6bf9e 100644 --- a/src/commands/openai-list-models.ts +++ b/src/commands/openai-list-models.ts @@ -2,36 +2,27 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {openAi} from "../index"; -import {logError, replyToMessage} from "../util/utils"; +import {escapeHtml, logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {listProviderModels} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class OpenAIListModels extends Command { - title = "/openAIListModels"; - description = "List all OpenAI models"; + title = Environment.commandTitles.openAiListModels; + description = Environment.commandDescriptions.openAiListModels; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message): Promise { try { - const listResponse = await openAi.models.list(); - console.log(listResponse); + const models = (await listProviderModels(AiProvider.OPENAI)).sort((a, b) => a.localeCompare(b)); + const modelsString = escapeHtml(models.join("\n").substring(0, 4000)); + const text = Environment.modelListHeaderText + "
" + modelsString + "
"; - const modelsString = listResponse.data - .map(e => `${e.id}`) - .sort((a, b) => a.localeCompare(b)) - .join("\n") - .substring(0, 4000); - - const text = "Доступные модели:\n\n" + "
" + modelsString + "
"; - - await replyToMessage({ - message: msg, - text: text, - parse_mode: "HTML" - }); + await replyToMessage({message: msg, text, parse_mode: "HTML"}); } catch (e) { logError(e); - await replyToMessage({message: msg, text: "Не получилось загрузить список моделей"}).catch(logError); + await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError); } } -} \ No newline at end of file +} diff --git a/src/commands/openai-set-model.ts b/src/commands/openai-set-model.ts index e2ba962..7198fe0 100644 --- a/src/commands/openai-set-model.ts +++ b/src/commands/openai-set-model.ts @@ -2,24 +2,28 @@ import {Command} from "../base/command"; import {Requirements} from "../base/requirements"; import {Requirement} from "../base/requirement"; import {Message} from "typescript-telegram-bot-api"; -import {Environment} from "../common/environment"; import {logError, replyToMessage} from "../util/utils"; +import {AiProvider} from "../model/ai-provider"; +import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime"; +import {Environment} from "../common/environment"; export class OpenAISetModel extends Command { argsMode = "required" as const; - title = "/openAISetModel"; - description = "Set OpenAI model"; + title = Environment.commandTitles.openAiSetModel; + description = Environment.commandDescriptions.openAiSetModel; requirements = Requirements.Build(Requirement.BOT_CREATOR); async execute(msg: Message, match?: RegExpExecArray | null): Promise { - const newModel = match?.[3]; - Environment.setOpenAIModel(newModel || Environment.OPENAI_MODEL); + const newModel = match?.[3]?.trim(); + if (newModel) setRuntimeModel(AiProvider.OPENAI, newModel); - const text = newModel ? `Выбрана модель "${newModel}"` - : `Модель не задана. Будет использоваться стандартная модель "${Environment.OPENAI_MODEL}".`; + const model = getRuntimeModel(AiProvider.OPENAI); + const text = newModel + ? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.OPENAI)) + : Environment.getModelIsNotSetCurrentText(model); - await replyToMessage({message: msg, text: text}).catch(logError); + await replyToMessage({message: msg, text}).catch(logError); } -} \ No newline at end of file +}