shitton
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
|
||||
export type AiRunnerLogLevel = "trace" | "debug" | "info" | "success" | "warn" | "error";
|
||||
|
||||
export type AiRunnerLogDetails = Record<string, unknown>;
|
||||
|
||||
export type AiLogToolCallLike = {
|
||||
id: string;
|
||||
name: string;
|
||||
argumentsText: string;
|
||||
};
|
||||
|
||||
const AI_RUNNER_LOG_PREFIX = "unified-ai-runner";
|
||||
const AI_RUNNER_LOG_MAX_STRING = 600;
|
||||
const AI_RUNNER_LOG_MAX_ARRAY = 8;
|
||||
|
||||
const LOG_LEVEL_WEIGHT: Record<AiRunnerLogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
success: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
};
|
||||
|
||||
const AI_RUNNER_LOG_COLORS: Record<AiRunnerLogLevel | "reset" | "bold" | "dim" | "label" | "key" | "value", string> = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
trace: "\x1b[90m",
|
||||
debug: "\x1b[90m",
|
||||
info: "\x1b[36m",
|
||||
success: "\x1b[32m",
|
||||
warn: "\x1b[33m",
|
||||
error: "\x1b[31m",
|
||||
label: "\x1b[35m",
|
||||
key: "\x1b[94m",
|
||||
value: "\x1b[97m",
|
||||
};
|
||||
|
||||
function envBool(name: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function aiRunnerLogsEnabled(): boolean {
|
||||
return envBool("AI_RUNNER_LOGS", true) && envBool("AI_LOG_ENABLED", true);
|
||||
}
|
||||
|
||||
function aiRunnerColorsEnabled(): boolean {
|
||||
return envBool("AI_RUNNER_LOG_COLORS", true) && !process.env.NO_COLOR;
|
||||
}
|
||||
|
||||
function configuredMinLevel(): AiRunnerLogLevel {
|
||||
const raw = process.env.AI_LOG_LEVEL?.trim().toLowerCase();
|
||||
if (raw && raw in LOG_LEVEL_WEIGHT) return raw as AiRunnerLogLevel;
|
||||
return "debug";
|
||||
}
|
||||
|
||||
function shouldWriteLevel(level: AiRunnerLogLevel): boolean {
|
||||
return LOG_LEVEL_WEIGHT[level] >= LOG_LEVEL_WEIGHT[configuredMinLevel()];
|
||||
}
|
||||
|
||||
function paintAiLog(value: string, color: keyof typeof AI_RUNNER_LOG_COLORS): string {
|
||||
if (!aiRunnerColorsEnabled()) return value;
|
||||
return `${AI_RUNNER_LOG_COLORS[color]}${value}${AI_RUNNER_LOG_COLORS.reset}`;
|
||||
}
|
||||
|
||||
function truncateAiLogString(value: string, max = AI_RUNNER_LOG_MAX_STRING): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}… (+${value.length - max} chars)`;
|
||||
}
|
||||
|
||||
function safeJsonParseObject(value?: string): Record<string, unknown> {
|
||||
if (!value?.trim()) return {};
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isSecretKey(keyPath: string): boolean {
|
||||
const normalized = keyPath.toLowerCase();
|
||||
return normalized.includes("token")
|
||||
|| normalized.includes("secret")
|
||||
|| normalized.includes("apikey")
|
||||
|| normalized.includes("api_key")
|
||||
|| normalized.includes("authorization")
|
||||
|| normalized.endsWith(".key")
|
||||
|| normalized === "key";
|
||||
}
|
||||
|
||||
function isPromptKey(keyPath: string): boolean {
|
||||
const normalized = keyPath.toLowerCase();
|
||||
return normalized.includes("prompt") || normalized.includes("systemprompt");
|
||||
}
|
||||
|
||||
function isTextPreviewKey(keyPath: string): boolean {
|
||||
const normalized = keyPath.toLowerCase();
|
||||
return normalized.includes("content")
|
||||
|| normalized.includes("message")
|
||||
|| normalized.includes("text")
|
||||
|| normalized.includes("preview")
|
||||
|| normalized.includes("input")
|
||||
|| normalized.includes("output");
|
||||
}
|
||||
|
||||
function isToolArgsKey(keyPath: string): boolean {
|
||||
const normalized = keyPath.toLowerCase();
|
||||
return normalized.endsWith("args")
|
||||
|| normalized.endsWith("arguments")
|
||||
|| normalized.includes("toolargs")
|
||||
|| normalized.includes("tool_args");
|
||||
}
|
||||
|
||||
function isDaoKey(keyPath: string): boolean {
|
||||
const normalized = keyPath.toLowerCase();
|
||||
return normalized.includes("dao")
|
||||
|| normalized.includes("database")
|
||||
|| normalized.includes("db.")
|
||||
|| normalized.includes("sql")
|
||||
|| normalized.includes("chunk");
|
||||
}
|
||||
|
||||
function shouldRedactKey(keyPath: string): boolean {
|
||||
if (isSecretKey(keyPath)) return true;
|
||||
if (isPromptKey(keyPath) && !envBool("AI_LOG_PROMPTS", false)) return true;
|
||||
if (isToolArgsKey(keyPath) && !envBool("AI_LOG_TOOL_ARGS", false)) return true;
|
||||
if (isDaoKey(keyPath) && !envBool("AI_LOG_DAO", false)) return true;
|
||||
if (isTextPreviewKey(keyPath) && !envBool("AI_LOG_TEXT_PREVIEW", false)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function primitiveToLogValue(value: unknown): unknown {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack?.split("\n").slice(0, 6).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string") return truncateAiLogString(value);
|
||||
if (typeof value === "number" || typeof value === "boolean" || value === null || value === undefined) return value;
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
|
||||
if (Buffer.isBuffer(value)) return `<Buffer ${(value as {length: number}).length} bytes>`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function flattenAiLogDetails(
|
||||
value: unknown,
|
||||
keyPath = "",
|
||||
depth = 0,
|
||||
seen = new WeakSet<object>(),
|
||||
): Record<string, unknown> {
|
||||
if (keyPath && shouldRedactKey(keyPath)) {
|
||||
return {[keyPath]: "<redacted>"};
|
||||
}
|
||||
|
||||
const primitive = primitiveToLogValue(value);
|
||||
if (primitive !== undefined || value === undefined) {
|
||||
return keyPath ? {[keyPath]: primitive} : {value: primitive};
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return keyPath ? {[keyPath]: String(value)} : {value: String(value)};
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return keyPath ? {[keyPath]: "[Circular]"} : {value: "[Circular]"};
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (depth >= 2) {
|
||||
return keyPath ? {[keyPath]: `[Array ${value.length}]`} : {value: `[Array ${value.length}]`};
|
||||
}
|
||||
|
||||
const entries: Record<string, unknown> = {};
|
||||
value.slice(0, AI_RUNNER_LOG_MAX_ARRAY).forEach((item, index) => {
|
||||
Object.assign(entries, flattenAiLogDetails(item, keyPath ? `${keyPath}.${index}` : String(index), depth + 1, seen));
|
||||
});
|
||||
if (value.length > AI_RUNNER_LOG_MAX_ARRAY) {
|
||||
entries[keyPath ? `${keyPath}.__more` : "__more"] = value.length - AI_RUNNER_LOG_MAX_ARRAY;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (depth >= 3) {
|
||||
return keyPath ? {[keyPath]: "[Object]"} : {value: "[Object]"};
|
||||
}
|
||||
|
||||
const entries: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
const childPath = keyPath ? `${keyPath}.${key}` : key;
|
||||
|
||||
if ((key.toLowerCase() === "data" || key.toLowerCase() === "image_url" || key.toLowerCase().endsWith("b64")) && typeof raw === "string") {
|
||||
entries[childPath] = `<${raw.length} chars>`;
|
||||
continue;
|
||||
}
|
||||
|
||||
Object.assign(entries, flattenAiLogDetails(raw, childPath, depth + 1, seen));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function redactLogValue(value: unknown): Record<string, unknown> {
|
||||
return flattenAiLogDetails(value);
|
||||
}
|
||||
|
||||
function formatAiLogDetails(details?: AiRunnerLogDetails): string {
|
||||
if (!details || !Object.keys(details).length) return "";
|
||||
|
||||
const flattened = flattenAiLogDetails(details);
|
||||
const chunks = Object.entries(flattened).map(([key, value]) => {
|
||||
const safeValue = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return `${paintAiLog(key, "key")}=${paintAiLog(safeValue ?? "undefined", "value")}`;
|
||||
});
|
||||
|
||||
return ` ${chunks.join(" ")}`;
|
||||
}
|
||||
|
||||
export function aiLog(level: AiRunnerLogLevel, event: string, details?: AiRunnerLogDetails): void {
|
||||
if (!aiRunnerLogsEnabled() || !shouldWriteLevel(level)) return;
|
||||
|
||||
const timestamp = paintAiLog(new Date().toISOString(), "dim");
|
||||
const prefix = paintAiLog(AI_RUNNER_LOG_PREFIX, "bold");
|
||||
const levelText = paintAiLog(level.toUpperCase().padEnd(7), level);
|
||||
const eventText = paintAiLog(event, "label");
|
||||
const line = `${timestamp} ${prefix} ${levelText} ${eventText}${formatAiLogDetails(details)}`;
|
||||
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else if (level === "warn") {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function aiLogDuration(startedAt: number): string {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
export function aiLogToolCall(toolCall: AiLogToolCallLike): Record<string, unknown> {
|
||||
return {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: safeJsonParseObject(toolCall.argumentsText),
|
||||
};
|
||||
}
|
||||
|
||||
export function aiLogMessageIdentity(msg: Message | undefined): Record<string, unknown> | undefined {
|
||||
if (!msg) return undefined;
|
||||
return {
|
||||
chatId: msg.chat?.id,
|
||||
chatType: msg.chat?.type,
|
||||
messageId: msg.message_id,
|
||||
fromId: msg.from?.id,
|
||||
username: msg.from?.username,
|
||||
};
|
||||
}
|
||||
|
||||
export function aiLogProviderTarget(target: {provider: string; purpose?: string; model?: string; baseUrl?: string; apiKey?: string} | undefined): Record<string, unknown> | undefined {
|
||||
if (!target) return undefined;
|
||||
return {
|
||||
provider: target.provider,
|
||||
purpose: target.purpose,
|
||||
model: target.model,
|
||||
baseUrl: target.baseUrl,
|
||||
apiKey: target.apiKey,
|
||||
};
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export function createMistralClient(target: AiRuntimeTarget): Mistral {
|
||||
|
||||
export function createOllamaClient(target: AiRuntimeTarget): Ollama {
|
||||
return new Ollama({
|
||||
host: target.baseUrl?.endsWith(":11434") ? target.baseUrl : target.baseUrl + ":11434",
|
||||
host: target.baseUrl,
|
||||
headers: target.apiKey ? {"Authorization": `Bearer ${target.apiKey}`} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,26 +65,5 @@ export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
|
||||
// }
|
||||
// }
|
||||
|
||||
/*
|
||||
const messages: any[] = ordered.map(part => {
|
||||
const content: any[] = [{
|
||||
type: "input_text",
|
||||
text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER \"${part.name}\":\n` : "") + part.content,
|
||||
}];
|
||||
|
||||
if (!part.bot) {
|
||||
for (const image of part.images ?? []) {
|
||||
content.push({type: "input_image", image_url: `data:image/jpeg;base64,${image}`, detail: "auto"});
|
||||
}
|
||||
}
|
||||
|
||||
return {role: part.bot ? "assistant" : "user", content};
|
||||
});
|
||||
|
||||
if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) {
|
||||
messages.unshift({role: "system", content: Environment.SYSTEM_PROMPT});
|
||||
}
|
||||
return {parts: messages, imageCount};
|
||||
*/
|
||||
|
||||
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
|
||||
|
||||
@@ -67,7 +67,7 @@ export type MistralContentChunk =
|
||||
|
||||
export type MistralFunctionCall = {
|
||||
name: string;
|
||||
arguments: { [k: string]: any } | string;
|
||||
arguments: Record<string, unknown> | string;
|
||||
};
|
||||
|
||||
export type MistralToolCall = {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {ResponseInputItem} from "openai/resources/responses/responses";
|
||||
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||
|
||||
export type OpenAIChatMessage = ResponseInputItem
|
||||
export type OpenAIChatMessage = {
|
||||
type: "message";
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | ResponseInputMessageContentList;
|
||||
};
|
||||
|
||||
@@ -295,26 +295,38 @@ export async function formatRuntimeModelInfo(
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
type NamedModel = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type ModelListResponse = {
|
||||
models?: NamedModel[];
|
||||
data?: NamedModel[];
|
||||
};
|
||||
|
||||
export async function listProviderModels(provider: AiProvider): Promise<string[]> {
|
||||
const target = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
|
||||
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA: {
|
||||
const ollama = createOllamaClient(target);
|
||||
const result: any = await ollama.list();
|
||||
return (result.models ?? []).map((m: any) => m.model || m.name).filter(Boolean);
|
||||
const result = await ollama.list() as ModelListResponse;
|
||||
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
|
||||
}
|
||||
case AiProvider.GEMINI: {
|
||||
const models: string[] = [];
|
||||
if (getGeminiApiMode(target) === "openai") {
|
||||
const geminiAi = createGeminiOpenAiClient(target);
|
||||
const iterable: any = await geminiAi.models.list();
|
||||
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
|
||||
for await (const model of iterable) models.push(model.name || model.id || String(model));
|
||||
return models;
|
||||
}
|
||||
|
||||
const geminiAi = createGoogleGenAiClient(target);
|
||||
const iterable: any = await geminiAi.models.list();
|
||||
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
|
||||
for await (const model of iterable) {
|
||||
const name = model.name || model.id || String(model);
|
||||
models.push(String(name).replace(/^models\//, ""));
|
||||
@@ -323,13 +335,14 @@ export async function listProviderModels(provider: AiProvider): Promise<string[]
|
||||
}
|
||||
case AiProvider.MISTRAL: {
|
||||
const mistralAi = createMistralClient(target);
|
||||
const result: any = await mistralAi.models.list();
|
||||
return (result.data ?? result.models ?? result ?? []).map((m: any) => m.id || m.name || String(m)).filter(Boolean);
|
||||
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
|
||||
const items = Array.isArray(result) ? result : result.data ?? result.models ?? [];
|
||||
return items.map(m => m.id || m.name || String(m)).filter((name): name is string => !!name);
|
||||
}
|
||||
case AiProvider.OPENAI: {
|
||||
const openAi = createOpenAiClient(target);
|
||||
const result: any = await openAi.models.list();
|
||||
return (result.data ?? []).map((m: any) => m.id).filter(Boolean);
|
||||
const result = await openAi.models.list() as ModelListResponse;
|
||||
return (result.data ?? []).map(m => m.id).filter((id): id is string => !!id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ export type AiRequestQueueTarget = {
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
type QueueEntry<T> = {
|
||||
type QueueEntry = {
|
||||
target: AiRequestQueueTarget;
|
||||
queueKey: string;
|
||||
run: () => Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
run: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
||||
signal?: AbortSignal;
|
||||
@@ -26,7 +26,7 @@ type EnqueueOptions<T> = {
|
||||
};
|
||||
|
||||
class AiProviderRequestQueue {
|
||||
private readonly waiting = new Map<string, Array<QueueEntry<any>>>();
|
||||
private readonly waiting = new Map<string, QueueEntry[]>();
|
||||
private readonly active = new Map<string, number>();
|
||||
|
||||
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
|
||||
@@ -36,11 +36,11 @@ class AiProviderRequestQueue {
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const queueKey = this.queueKey(target);
|
||||
const entry: QueueEntry<T> = {
|
||||
const entry: QueueEntry = {
|
||||
target,
|
||||
queueKey,
|
||||
run: options.run,
|
||||
resolve,
|
||||
resolve: value => resolve(value as T),
|
||||
reject,
|
||||
onPositionChange: options.onPositionChange,
|
||||
signal: options.signal,
|
||||
@@ -63,11 +63,11 @@ class AiProviderRequestQueue {
|
||||
});
|
||||
}
|
||||
|
||||
private getQueue(queueKey: string): Array<QueueEntry<any>> | undefined {
|
||||
private getQueue(queueKey: string): QueueEntry[] | undefined {
|
||||
return this.waiting.get(queueKey);
|
||||
}
|
||||
|
||||
private getOrCreateQueue(queueKey: string): Array<QueueEntry<any>> {
|
||||
private getOrCreateQueue(queueKey: string): QueueEntry[] {
|
||||
let queue = this.waiting.get(queueKey);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
@@ -104,7 +104,7 @@ class AiProviderRequestQueue {
|
||||
]);
|
||||
}
|
||||
|
||||
private removeWaitingEntry(entry: QueueEntry<any>): boolean {
|
||||
private removeWaitingEntry(entry: QueueEntry): boolean {
|
||||
const queue = this.getQueue(entry.queueKey);
|
||||
if (!queue) return false;
|
||||
|
||||
@@ -147,7 +147,7 @@ class AiProviderRequestQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private async runEntry(entry: QueueEntry<any>): Promise<void> {
|
||||
private async runEntry(entry: QueueEntry): Promise<void> {
|
||||
try {
|
||||
entry.resolve(await entry.run());
|
||||
} catch (e) {
|
||||
@@ -174,7 +174,7 @@ class AiProviderRequestQueue {
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
private deleteQueueIfIdle(queueKey: string, queue: Array<QueueEntry<any>>): void {
|
||||
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
|
||||
if (!queue.length && this.activeCount(queueKey) <= 0) {
|
||||
this.waiting.delete(queueKey);
|
||||
}
|
||||
|
||||
+12
-10
@@ -198,7 +198,7 @@ async function transcribeGeminiSpeech(audio: AiDownloadedFile, signal?: AbortSig
|
||||
temperature: 0,
|
||||
abortSignal: signal,
|
||||
},
|
||||
});
|
||||
}) as unknown as GeminiSpeechResponse;
|
||||
|
||||
return {
|
||||
provider: AiProvider.GEMINI,
|
||||
@@ -240,17 +240,19 @@ async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSig
|
||||
};
|
||||
}
|
||||
|
||||
function collectGeminiText(response: any): string {
|
||||
if (typeof response?.text === "string") return response.text;
|
||||
type GeminiSpeechResponse = {
|
||||
text?: string;
|
||||
candidates?: Array<{content?: {parts?: Array<{text?: string}>}}> ;
|
||||
};
|
||||
|
||||
const candidates = response?.candidates ?? [];
|
||||
const candidateText = candidates
|
||||
.flatMap((candidate: any) => candidate?.content?.parts ?? [])
|
||||
.map((part: any) => part?.text ?? "")
|
||||
function collectGeminiText(response: GeminiSpeechResponse): string {
|
||||
if (typeof response.text === "string") return response.text;
|
||||
|
||||
const candidateText = (response.candidates ?? [])
|
||||
.flatMap(candidate => candidate.content?.parts ?? [])
|
||||
.map(part => part.text ?? "")
|
||||
.join("");
|
||||
if (candidateText.trim()) return candidateText;
|
||||
|
||||
return (response?.candidates ?? [])
|
||||
.map((output: any) => typeof output === "string" ? output : output?.content?.parts?.[0]?.text ?? "")
|
||||
.join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ export class TelegramStreamMessage {
|
||||
}
|
||||
if (shouldRemoveKeyboard) await this.removeKeyboard();
|
||||
this.lastSent = next;
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
if (shouldRemoveKeyboard && this.isMessageNotModified(e)) {
|
||||
await this.removeKeyboard();
|
||||
this.lastSent = next;
|
||||
@@ -369,8 +369,9 @@ export class TelegramStreamMessage {
|
||||
if (result && result !== true) this.waitMessage = result;
|
||||
this.mediaMode = true;
|
||||
this.lastSent = next;
|
||||
} catch (e: any) {
|
||||
if (!String(e?.message ?? e).includes("message is not modified")) logError(e);
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (!message.includes("message is not modified")) logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
|
||||
if (target.model) request.model = target.model;
|
||||
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
|
||||
|
||||
const response: any = await mistralAi.audio.speech.complete(request);
|
||||
const response = await mistralAi.audio.speech.complete(request) as unknown as {audioData?: string; audio_data?: string};
|
||||
const audioData = response?.audioData ?? response?.audio_data;
|
||||
if (typeof audioData !== "string" || !audioData.trim()) {
|
||||
throw new Error(Environment.mistralTtsNoAudioDataText);
|
||||
@@ -192,7 +192,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
|
||||
async function synthesizeGeminiSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
|
||||
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "textToSpeech");
|
||||
const geminiAi = createGoogleGenAiClient(target);
|
||||
const response: any = await geminiAi.models.generateContent({
|
||||
const response = await geminiAi.models.generateContent({
|
||||
model: target.model,
|
||||
contents: text,
|
||||
config: {
|
||||
|
||||
@@ -15,7 +15,15 @@ export function getOpenAITools(): AiTool[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function getOpenAIResponsesTools(): any[] {
|
||||
export type OpenAiResponseTool = {
|
||||
type: "function";
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
strict: false;
|
||||
};
|
||||
|
||||
export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
|
||||
return getTools().map(tool => ({
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
|
||||
+2
-25
@@ -1,28 +1,5 @@
|
||||
|
||||
/*
|
||||
interface Tool {
|
||||
type: string;
|
||||
function: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
parameters?: {
|
||||
type?: string;
|
||||
$defs?: any;
|
||||
items?: any;
|
||||
required?: string[];
|
||||
properties?: {
|
||||
[key: string]: {
|
||||
type?: string | string[];
|
||||
items?: any;
|
||||
description?: string;
|
||||
enum?: any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
export type AiToolParameters = {
|
||||
type: "object";
|
||||
@@ -45,7 +22,7 @@ export type AiToolCall = {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: {
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -356,11 +356,12 @@ export async function webSearch(args?: Record<string, unknown>) {
|
||||
|
||||
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
|
||||
};
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
|
||||
const status = e?.response?.status;
|
||||
const data = e?.response?.data;
|
||||
const axiosLike = e as {response?: {status?: unknown; data?: unknown}};
|
||||
const status = axiosLike.response?.status;
|
||||
const data = axiosLike.response?.data;
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -327,8 +327,8 @@ async function assertNoSymlinkInPath(
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlinks are not allowed in file tool paths.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT" && options?.allowMissingTail) {
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,8 +341,8 @@ async function pathExists(absolutePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.lstat(absolutePath);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return false;
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,11 @@ export const marketRatesToolPrompt = [
|
||||
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
|
||||
].join("\n");
|
||||
|
||||
export async function getMarketRates(): Promise<any | undefined> {
|
||||
export async function getMarketRates(): Promise<unknown | undefined> {
|
||||
try {
|
||||
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
|
||||
return response.data;
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error("GET_MARKET_RATES", e);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ function parsePythonInterpreterArgs(
|
||||
|
||||
return {
|
||||
code,
|
||||
stdin,
|
||||
stdin: typeof stdin === "string" ? stdin : undefined,
|
||||
timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error("Error loading Ollama model:", model);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const weatherToolPrompt = [
|
||||
"If the city is missing or unclear, ask the user to specify it.",
|
||||
].join("\n");
|
||||
|
||||
export async function getWeather(args?: Record<string, unknown>): Promise<any | null> {
|
||||
export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
console.log("getWeather()");
|
||||
try {
|
||||
const city = asNonEmptyString(args?.city);
|
||||
@@ -137,7 +137,7 @@ export async function getWeather(args?: Record<string, unknown>): Promise<any |
|
||||
windSpeed: wind.speed,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Gemini provider runner extracted from unified-ai-runner.ts.
|
||||
import {getGeminiTools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
import {GeminiMessage} from "./gemini-chat-message";
|
||||
import {createGoogleGenAiClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
|
||||
|
||||
import {AsyncIterableStream, GeminiFunctionCallLike, GeminiResponseLike, MAX_TOOL_ROUNDS, RuntimeConfigSnapshot, ToolCallData, ToolExecutionMemory, executeToolBatch, roundStatus, safeJsonParseObject, GeminiGenerationRequest} from "./unified-ai-runner.shared";
|
||||
|
||||
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
|
||||
if (typeof response.text === "string") return response.text;
|
||||
|
||||
return (response.candidates ?? [])
|
||||
.flatMap(candidate => candidate.content?.parts ?? [])
|
||||
.map(part => part.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function collectGeminiFunctionCalls(response: GeminiResponseLike): ToolCallData[] {
|
||||
const calls = response.functionCalls
|
||||
?? (response.candidates ?? []).flatMap(candidate => {
|
||||
return (candidate.content?.parts ?? [])
|
||||
.map(part => part.functionCall)
|
||||
.filter((call): call is GeminiFunctionCallLike => !!call);
|
||||
});
|
||||
|
||||
return (calls ?? []).map((call, index) => ({
|
||||
id: call.id ?? `gemini_${index}_${call.name ?? "call"}`,
|
||||
name: call.name ?? "",
|
||||
argumentsText: JSON.stringify(call.args ?? {}),
|
||||
})).filter((call: ToolCallData) => call.name);
|
||||
}
|
||||
|
||||
function mergeGeminiFunctionCalls(existing: ToolCallData[], next: ToolCallData[]): ToolCallData[] {
|
||||
const merged = [...existing];
|
||||
for (const call of next) {
|
||||
const index = merged.findIndex(item => item.id === call.id);
|
||||
if (index === -1) {
|
||||
merged.push(call);
|
||||
} else {
|
||||
merged[index] = call;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[], results: string[]): void {
|
||||
messages.push({
|
||||
role: "model",
|
||||
parts: calls.map(call => ({
|
||||
functionCall: {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
args: safeJsonParseObject(call.argumentsText),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
parts: calls.map((call, index) => ({
|
||||
functionResponse: {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
response: {result: results[index] ?? ""},
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runGemini(
|
||||
messages: GeminiMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
firstRoundStatus: string,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
const geminiAi = createGoogleGenAiClient(config.geminiChatTarget);
|
||||
|
||||
aiLog("info", "gemini.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.geminiChatTarget),
|
||||
inputMessages: messages.length,
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "gemini.round.start", {round, messages: messages.length, stream});
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||
await streamMessage.flush();
|
||||
|
||||
const request: GeminiGenerationRequest = {
|
||||
model: config.geminiChatTarget.model,
|
||||
contents: messages,
|
||||
config: {
|
||||
tools: getGeminiTools(),
|
||||
temperature: messages.length <= 2 ? 0 : 0.6,
|
||||
abortSignal: signal,
|
||||
},
|
||||
};
|
||||
|
||||
if (!stream) {
|
||||
const response = await geminiAi.models.generateContent(request) as unknown as GeminiResponseLike & {
|
||||
text?: string
|
||||
};
|
||||
const text = collectGeminiResponseText(response);
|
||||
streamMessage.append(text);
|
||||
const calls = collectGeminiFunctionCalls(response);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: text.length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
|
||||
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await geminiAi.models.generateContentStream(request) as unknown as AsyncIterableStream<GeminiResponseLike & {
|
||||
text?: string
|
||||
}>;
|
||||
aiLog("debug", "gemini.stream.open", {round});
|
||||
let calls: ToolCallData[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
for await (const chunk of response) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
streamMessage.append(collectGeminiResponseText(chunk));
|
||||
calls = mergeGeminiFunctionCalls(calls, collectGeminiFunctionCalls(chunk));
|
||||
}
|
||||
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class GeminiProviderRunner {
|
||||
static run = runGemini;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Mistral provider runner extracted from unified-ai-runner.ts.
|
||||
import {Environment} from "../common/environment";
|
||||
import {getMistralTools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
import {MistralChatMessage} from "./mistral-chat-message";
|
||||
import {createMistralClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
|
||||
|
||||
import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared";
|
||||
|
||||
export async function runMistral(
|
||||
messages: MistralChatMessage[],
|
||||
documents: MistralDocumentReference[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
firstRoundStatus: string,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
const mistralAi = createMistralClient(config.mistralChatTarget);
|
||||
aiLog("info", "mistral.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.mistralChatTarget),
|
||||
inputMessages: messages.length,
|
||||
documents: documents.length,
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||
await streamMessage.flush();
|
||||
|
||||
if (!stream) {
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages,
|
||||
tools: getMistralTools(),
|
||||
documents: documents
|
||||
} as unknown as Parameters<typeof mistralAi.chat.complete>[0];
|
||||
const response = await mistralAi.chat.complete(request, {signal});
|
||||
const msg = response.choices?.[0]?.message;
|
||||
const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? "");
|
||||
streamMessage.append(text);
|
||||
const calls = normalizeMistralToolCalls(mistralToolCalls(msg));
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: text.length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: text,
|
||||
toolCalls: calls.map(call => ({
|
||||
id: call.id,
|
||||
function: {name: call.name, arguments: call.argumentsText},
|
||||
})),
|
||||
});
|
||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
||||
for (const [index, call] of calls.entries()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
name: call.name,
|
||||
toolCallId: call.id,
|
||||
content: toolResults[index] ?? "",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages,
|
||||
tools: getMistralTools(),
|
||||
documents: documents
|
||||
} as unknown as Parameters<typeof mistralAi.chat.stream>[0];
|
||||
const streamResponse = await mistralAi.chat.stream(request, {signal});
|
||||
aiLog("debug", "mistral.stream.open", {round});
|
||||
let calls: ToolCallData[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
|
||||
|
||||
for await (const event of streamResponse) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
const choice = event.data?.choices?.[0];
|
||||
const delta = choice?.delta;
|
||||
const mistralDelta = delta as MistralDeltaLike;
|
||||
|
||||
streamMessage.append(contentFromMistralDelta(mistralDelta));
|
||||
|
||||
const rawDeltaCalls = mistralToolCalls(mistralDelta);
|
||||
if (rawDeltaCalls.length) {
|
||||
calls = toolCallAccumulator.add(rawDeltaCalls);
|
||||
streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||
});
|
||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
||||
for (const [index, call] of calls.entries()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
name: call.name,
|
||||
toolCallId: call.id,
|
||||
content: toolResults[index] ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class MistralProviderRunner {
|
||||
static run = runMistral;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Ollama provider runner extracted from unified-ai-runner.ts.
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {Environment} from "../common/environment";
|
||||
import {bot, notesDir} from "../index";
|
||||
import {clamp, logError} from "../util/utils";
|
||||
import {getOllamaTools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {getModelCapabilities} from "./provider-model-runtime";
|
||||
import {ChatMessage} from "./chat-messages-types";
|
||||
import {ChatRequest, Tool} from "ollama";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||
import {getCurrentDateTimeTool} from "./tools/datetime";
|
||||
import {getMarketRatesTool} from "./tools/market-rates";
|
||||
import {getWeatherTool} from "./tools/weather";
|
||||
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
||||
import {createOllamaClient} from "./ai-runtime-target";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
|
||||
|
||||
import {DEFAULT_OLLAMA_CONTEXT_SIZE, MAX_OLLAMA_CONTEXT_SIZE, MAX_TOOL_ROUNDS, MIN_OLLAMA_CONTEXT_SIZE, RuntimeConfigSnapshot, Think, ToolCallData, ToolExecutionMemory, allToolSchemaNames, appendOllamaToolResults, dedupeToolCalls, executeToolBatch, normalizeOllamaToolCalls, roundStatus, safeJsonParseObject, isRecord, isOllamaModelActive, OllamaToolCallLike} from "./unified-ai-runner.shared";
|
||||
import {latestUserTextFromOllamaMessages, looksLikeToolRankerJson, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
|
||||
|
||||
export async function runOllama(
|
||||
msg: Message,
|
||||
messages: ChatMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
think: Think,
|
||||
firstRoundStatus: string,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
contextSize?: number,
|
||||
): Promise<void> {
|
||||
const fromId = msg.from?.id;
|
||||
const runnerStartedAt = Date.now();
|
||||
|
||||
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
|
||||
const videoNoteCount = messages.reduce((sum, m) => sum + (m.videoNotes?.length ?? 0), 0);
|
||||
const imageCount = messages.reduce((sum, m) => sum + (m.imageParts?.length || m.images?.length || 0), 0);
|
||||
|
||||
const target = (audioCount || videoNoteCount) ? config.ollamaAudioTarget :
|
||||
imageCount ? config.ollamaVisionTarget :
|
||||
think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
|
||||
const model = target.model;
|
||||
aiLog("info", "ollama.run.start", {
|
||||
stream,
|
||||
think,
|
||||
target: aiLogProviderTarget(target),
|
||||
requestedContextSize: contextSize,
|
||||
message: aiLogMessageIdentity(msg),
|
||||
counts: {messages: messages.length, images: imageCount, audio: audioCount, videoNotes: videoNoteCount},
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
const ollama = createOllamaClient(target);
|
||||
const modelInfo = await ollama.show({model});
|
||||
const modelInfoMap = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
|
||||
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
|
||||
// @ts-ignore
|
||||
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
|
||||
const parsedMaxContextLength =
|
||||
typeof rawMaxContextLength === "number"
|
||||
? rawMaxContextLength
|
||||
: typeof rawMaxContextLength === "string"
|
||||
? Number(rawMaxContextLength)
|
||||
: DEFAULT_OLLAMA_CONTEXT_SIZE;
|
||||
|
||||
const maxContextLength = Number.isFinite(parsedMaxContextLength)
|
||||
? parsedMaxContextLength
|
||||
: DEFAULT_OLLAMA_CONTEXT_SIZE;
|
||||
|
||||
const context = clamp(
|
||||
contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : contextSize ?? DEFAULT_OLLAMA_CONTEXT_SIZE,
|
||||
MIN_OLLAMA_CONTEXT_SIZE,
|
||||
maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE
|
||||
);
|
||||
aiLog("debug", "ollama.context.resolved", {model, contextKey, maxContextLength, context});
|
||||
|
||||
const modelsToLoad = [model];
|
||||
|
||||
try {
|
||||
const activeModels = (await ollama.ps()).models.map(m => m.model);
|
||||
const oldSet = new Set(activeModels);
|
||||
const newSet = new Set(modelsToLoad);
|
||||
|
||||
const added = modelsToLoad.filter(m => !oldSet.has(m));
|
||||
const removed = activeModels.filter(m => !newSet.has(m));
|
||||
const diff = [...added, ...removed];
|
||||
aiLog("debug", "ollama.models.active", {activeModels, requiredModels: modelsToLoad, added, removed});
|
||||
if (diff.length) {
|
||||
aiLog("info", "ollama.models.unload_extra", {keep: modelsToLoad, diff});
|
||||
await unloadAllOllamaModels(ollama, modelsToLoad);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
|
||||
if (!(await isOllamaModelActive(ollama, target))) {
|
||||
const loadStartedAt = Date.now();
|
||||
aiLog("info", "ollama.model.load.start", {model, context});
|
||||
const currentStatus = streamMessage.getStatus();
|
||||
streamMessage.setStatus(Environment.getLoadingModelText(model));
|
||||
await streamMessage.flush();
|
||||
if (await loadOllamaModel(model, ollama, context)) {
|
||||
aiLog("success", "ollama.model.load.done", {model, duration: aiLogDuration(loadStartedAt)});
|
||||
streamMessage.setStatus(currentStatus ?? Environment.waitThinkText);
|
||||
await streamMessage.flush();
|
||||
}
|
||||
} else {
|
||||
aiLog("debug", "ollama.model.already_loaded", {model});
|
||||
}
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (!stream) {
|
||||
let typingInFlight = false;
|
||||
const applyTyping = async () => {
|
||||
if (typingInFlight) return;
|
||||
typingInFlight = true;
|
||||
try {
|
||||
await enqueueTelegramApiCall(
|
||||
() => bot.sendChatAction({chat_id: msg.chat.id, action: "typing"}),
|
||||
{method: "sendChatAction", chatId: msg.chat.id, chatType: msg.chat.type}
|
||||
).catch(logError);
|
||||
} finally {
|
||||
typingInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
await applyTyping();
|
||||
interval = setInterval(() => {
|
||||
applyTyping().catch(logError);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
|
||||
try {
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "ollama.round.start", {
|
||||
round,
|
||||
context,
|
||||
messages: messages.length,
|
||||
stream,
|
||||
think: audioCount ? false : think,
|
||||
});
|
||||
|
||||
const request: ChatRequest = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
think: audioCount ? false : think,
|
||||
options: {
|
||||
temperature: 0.6,
|
||||
num_ctx: context,
|
||||
}
|
||||
};
|
||||
|
||||
let activeToolNames: string[] = [];
|
||||
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||
const availableOllamaTools: Tool[] = fromId !== Environment.CREATOR_ID
|
||||
? [getCurrentDateTimeTool, getMarketRatesTool, getWeatherTool]
|
||||
: getOllamaTools() as Tool[];
|
||||
|
||||
aiLog("debug", "ollama.tools.available", {
|
||||
round,
|
||||
tools: allToolSchemaNames(availableOllamaTools),
|
||||
rankerEnabled: !!config.ollamaToolRankerTarget,
|
||||
});
|
||||
|
||||
const rankerSelection = await new OllamaToolRanker(config).selectTools({
|
||||
userQuery: latestUserTextFromOllamaMessages(messages),
|
||||
availableTools: availableOllamaTools,
|
||||
round,
|
||||
signal,
|
||||
});
|
||||
|
||||
activeToolNames = rankerSelection.selectedNames;
|
||||
if (rankerSelection.tools.length > 0) {
|
||||
request.tools = rankerSelection.tools;
|
||||
} else {
|
||||
delete request.tools;
|
||||
}
|
||||
|
||||
aiLog("debug", "ollama.tools.selected", {
|
||||
round,
|
||||
tools: activeToolNames,
|
||||
count: activeToolNames.length,
|
||||
usedRanker: rankerSelection.usedRanker,
|
||||
missing: rankerSelection.missing,
|
||||
});
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
const response = await ollama.chat({
|
||||
...request,
|
||||
stream: false
|
||||
});
|
||||
|
||||
const message = response.message;
|
||||
const rawContent = message?.content ?? "";
|
||||
|
||||
const nativeCalls = dedupeToolCalls(
|
||||
normalizeOllamaToolCalls(
|
||||
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
||||
round,
|
||||
),
|
||||
);
|
||||
|
||||
const responseText = rawContent;
|
||||
|
||||
if (looksLikeToolRankerJson(responseText)) {
|
||||
aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
|
||||
round,
|
||||
preview: responseText.slice(0, 800),
|
||||
target: aiLogProviderTarget(target),
|
||||
});
|
||||
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
|
||||
}
|
||||
|
||||
streamMessage.append(responseText);
|
||||
|
||||
aiLog("debug", "ollama.response.received", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
textChars: responseText.length,
|
||||
nativeToolCallCount: nativeCalls.length,
|
||||
});
|
||||
|
||||
if (!nativeCalls.length) {
|
||||
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
||||
break;
|
||||
}
|
||||
|
||||
const calls = nativeCalls;
|
||||
|
||||
aiLog("info", "ollama.tool_calls", {
|
||||
round,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
tool_calls: calls.map(c => ({
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: safeJsonParseObject(c.argumentsText),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
appendOllamaToolResults(
|
||||
messages,
|
||||
calls,
|
||||
await executeToolBatch(calls, streamMessage, toolContext, toolMemory),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await ollama.chat({
|
||||
...request,
|
||||
stream: true
|
||||
});
|
||||
|
||||
aiLog("debug", "ollama.stream.open", {round});
|
||||
const calls: ToolCallData[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
const abortOllamaResponse = () => response.abort?.();
|
||||
signal.addEventListener("abort", abortOllamaResponse, {once: true});
|
||||
if (signal.aborted) abortOllamaResponse();
|
||||
try {
|
||||
for await (const chunk of response) {
|
||||
const localToolCalls: ToolCallData[] = [];
|
||||
|
||||
localToolCalls.push(...normalizeOllamaToolCalls(
|
||||
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
||||
round,
|
||||
));
|
||||
|
||||
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
||||
const previousStatus = streamMessage.getStatus();
|
||||
if (newStatus && newStatus !== Environment.waitThinkText) {
|
||||
streamMessage.setStatus(newStatus);
|
||||
} else {
|
||||
streamMessage.clearStatus();
|
||||
}
|
||||
|
||||
if (streamMessage.getStatus() !== previousStatus && previousStatus && newStatus !== Environment.waitThinkText) {
|
||||
await streamMessage.flush();
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
response.abort?.();
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
|
||||
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
||||
streamMessage.append(chunk.message?.content ?? "");
|
||||
}
|
||||
|
||||
calls.push(...normalizeOllamaToolCalls(
|
||||
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
||||
round,
|
||||
));
|
||||
|
||||
if (chunk.done) {
|
||||
aiLog("debug", "ollama.stream.done", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
toolCallCount: calls.length,
|
||||
});
|
||||
await streamMessage.flush(streamMessage.regenerateKeyboard(), true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortOllamaResponse);
|
||||
}
|
||||
|
||||
const streamedRoundText = streamMessage.getText().slice(roundTextStart);
|
||||
if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) {
|
||||
streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart));
|
||||
aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
|
||||
round,
|
||||
preview: streamedRoundText.slice(0, 800),
|
||||
target: aiLogProviderTarget(target),
|
||||
});
|
||||
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
|
||||
}
|
||||
|
||||
if (!calls.length) {
|
||||
aiLog("success", "ollama.run.done", {
|
||||
round,
|
||||
duration: aiLogDuration(runnerStartedAt),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
||||
|
||||
aiLog("info", "ollama.tool_calls", {
|
||||
round,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
|
||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
tool_calls: calls.map(c => ({
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: safeJsonParseObject(c.argumentsText),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
||||
|
||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||
|
||||
for (const toolResult of toolResults) {
|
||||
try {
|
||||
const raw = JSON.parse(toolResult);
|
||||
const res = GetNoteFileResultSchema.safeParse(raw);
|
||||
|
||||
if (res.success && res.data.success) {
|
||||
successGetNoteFileResult = res.data;
|
||||
}
|
||||
} catch {
|
||||
// Not every tool result is JSON.
|
||||
}
|
||||
}
|
||||
|
||||
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
|
||||
await bot.sendDocument({
|
||||
chat_id: msg.chat.id,
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
},
|
||||
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
|
||||
}).catch(logError);
|
||||
}
|
||||
|
||||
appendOllamaToolResults(messages, calls, toolResults);
|
||||
}
|
||||
} finally {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class OllamaProviderRunner {
|
||||
static run = runOllama;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import {Environment} from "../common/environment";
|
||||
import {getOpenAITools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
import type {ResponseCreateParamsNonStreaming, ResponseCreateParamsStreaming, ResponseInputItem, ResponseStreamEvent} from "openai/resources/responses/responses";
|
||||
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming} from "openai/resources/chat/completions";
|
||||
import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
|
||||
|
||||
import {AsyncIterableStream, MAX_TOOL_ROUNDS, OPENAI_IMAGE_PARTIALS, OpenAiChatCompletionResponseLike, OpenAiChatToolCallLike, OpenAiCompatibleChatMessage, OpenAiCompatibleContentPart, OpenAiResponseLike, OpenAiResponseOutputItem, RuntimeConfigSnapshot, ToolCallData, StreamingToolCallAccumulator, collectOpenAiResponseFunctionCalls, collectOpenAiResponseImages, collectOpenAiResponseText, executeToolBatch, getOpenAIResponsesToolsWithImage, openAiResponseItemCallId, safeJsonParseObject, showOpenAiGeneratedImage, ToolExecutionMemory, isRecord, roundStatus, OpenAiChatCompletionStreamChunkLike} from "./unified-ai-runner.shared";
|
||||
|
||||
export async function runOpenAi(
|
||||
messages: OpenAIChatMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
firstRoundStatus: string,
|
||||
sourceMessage: Message,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
): Promise<void> {
|
||||
// TODO: 13.05.2026: remove
|
||||
firstRoundStatus;
|
||||
const runnerStartedAt = Date.now();
|
||||
let responseInput: unknown[] = [...messages];
|
||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||
|
||||
aiLog("info", "openai.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.openAiChatTarget),
|
||||
imageTarget: aiLogProviderTarget(config.openAiImageTarget),
|
||||
inputMessages: messages.length,
|
||||
sourceMessage: aiLogMessageIdentity(sourceMessage),
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
|
||||
|
||||
if (!stream) {
|
||||
const request: ResponseCreateParamsNonStreaming = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
// TODO: 13.05.2026, Danil Nikolaev: fix
|
||||
tools: getOpenAIResponsesToolsWithImage(config) as any,
|
||||
instructions: config.systemPrompt,
|
||||
};
|
||||
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
|
||||
|
||||
const responseText = collectOpenAiResponseText(response);
|
||||
streamMessage.append(responseText);
|
||||
aiLog("debug", "openai.response.received", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
textChars: responseText.length,
|
||||
outputItems: response?.output?.length ?? 0,
|
||||
});
|
||||
const images = collectOpenAiResponseImages(response);
|
||||
if (images.length) {
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
images[images.length - 1],
|
||||
`final_${round}`,
|
||||
Environment.getImageGenDoneText(config.openAiImageTarget.model),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const calls = collectOpenAiResponseFunctionCalls(response);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
calls: calls.map(call => ({
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
arguments: safeJsonParseObject(call.argumentsText)
|
||||
})),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
|
||||
const toolCalls = calls.map(call => ({
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
argumentsText: call.argumentsText,
|
||||
}));
|
||||
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
|
||||
const toolOutputs = calls.map((call, index) => ({
|
||||
type: "function_call_output" as const,
|
||||
call_id: call.callId,
|
||||
output: toolResults[index] ?? "",
|
||||
}));
|
||||
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
|
||||
continue;
|
||||
}
|
||||
|
||||
let completedResponse: OpenAiResponseLike | null = null;
|
||||
const request: ResponseCreateParamsStreaming = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
stream: true,
|
||||
// TODO: 13.05.2026, Danil Nikolaev: fix
|
||||
tools: getOpenAIResponsesToolsWithImage(config) as any,
|
||||
};
|
||||
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
|
||||
|
||||
aiLog("debug", "openai.stream.open", {round});
|
||||
|
||||
let localToolCalls: ToolCallData[] = [];
|
||||
for await (const event of response) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
switch (event.type) {
|
||||
case "response.output_text.delta":
|
||||
streamMessage.append(event.delta ?? "");
|
||||
break;
|
||||
case "response.image_generation_call.in_progress":
|
||||
streamMessage.setStatus(Environment.startingImageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.image_generation_call.generating":
|
||||
streamMessage.setStatus(Environment.imageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.image_generation_call.partial_image": {
|
||||
const iteration = (event.partial_image_index ?? 0) + 1;
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
event.partial_image_b64,
|
||||
`partial_${round}_${iteration}`,
|
||||
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "response.image_generation_call.completed":
|
||||
streamMessage.setStatus(Environment.finalizingImageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.output_item.added":
|
||||
if (event.item.type === "function_call" && event.item.name) {
|
||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
||||
localToolCalls.push({
|
||||
id: openAiResponseItemCallId(item),
|
||||
name: item.name ?? "",
|
||||
argumentsText: item.arguments ?? "{}",
|
||||
});
|
||||
|
||||
aiLog("info", "openai.stream.tool_call.added", {
|
||||
round,
|
||||
toolCalls: localToolCalls.map(aiLogToolCall)
|
||||
});
|
||||
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
break;
|
||||
case "response.output_item.done":
|
||||
if (event.item.type === "function_call" && event.item.name) {
|
||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
||||
const itemId = openAiResponseItemCallId(item);
|
||||
const index = localToolCalls.findIndex(c => c.id === itemId);
|
||||
if (index !== -1) {
|
||||
localToolCalls.splice(index, 1);
|
||||
if (localToolCalls.length === 0) {
|
||||
streamMessage.clearStatus();
|
||||
} else {
|
||||
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
|
||||
}
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "response.function_call_arguments.delta":
|
||||
break;
|
||||
case "response.function_call_arguments.done":
|
||||
break;
|
||||
|
||||
case "response.completed":
|
||||
completedResponse = event.response as unknown as OpenAiResponseLike;
|
||||
break;
|
||||
case "response.failed":
|
||||
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
|
||||
case "error":
|
||||
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
|
||||
|
||||
aiLog("debug", "openai.stream.completed", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
outputItems: completedResponse?.output?.length ?? 0,
|
||||
});
|
||||
|
||||
const images = collectOpenAiResponseImages(completedResponse);
|
||||
if (images.length) {
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
images[images.length - 1],
|
||||
`final_${round}`,
|
||||
Environment.getImageGenDoneText(config.openAiImageTarget.model),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const calls = collectOpenAiResponseFunctionCalls(completedResponse);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
calls: calls.map(call => ({
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
arguments: safeJsonParseObject(call.argumentsText)
|
||||
})),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
|
||||
const toolCalls = calls.map(call => ({
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
argumentsText: call.argumentsText,
|
||||
}));
|
||||
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
|
||||
const toolOutputs = calls.map((call, index) => ({
|
||||
type: "function_call_output",
|
||||
call_id: call.callId,
|
||||
output: toolResults[index] ?? "",
|
||||
}));
|
||||
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openAiResponseContentToText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
|
||||
}
|
||||
|
||||
function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
|
||||
return messages.map((message): OpenAiCompatibleChatMessage => {
|
||||
if (message.role === "system" || message.role === "assistant") {
|
||||
return {
|
||||
role: message.role,
|
||||
content: openAiResponseContentToText(message.content),
|
||||
};
|
||||
}
|
||||
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content.map((part): OpenAiCompatibleContentPart => {
|
||||
if (isRecord(part) && part.type === "input_image") {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {url: String(part.image_url ?? "")},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
|
||||
};
|
||||
})
|
||||
: message.content;
|
||||
|
||||
return {role: "user", content};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
|
||||
return toolCalls.map((call, i) => ({
|
||||
id: call.id || `openai_chat_${Date.now()}_${i}`,
|
||||
name: call.function?.name || call.name || "",
|
||||
argumentsText: typeof call.function?.arguments === "string"
|
||||
? call.function.arguments
|
||||
: JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
|
||||
})).filter(call => call.name);
|
||||
}
|
||||
|
||||
async function appendOpenAiChatToolResults(
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
calls: ToolCallData[],
|
||||
results: string[],
|
||||
): Promise<void> {
|
||||
for (const [index, call] of calls.entries()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: results[index] ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenAiCompatibleChat(
|
||||
messages: OpenAIChatMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
firstRoundStatus: string,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget);
|
||||
const chatMessages = openAiResponseMessagesToChatCompletions(messages);
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
|
||||
aiLog("info", "openai_compatible.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.geminiChatTarget),
|
||||
inputMessages: messages.length,
|
||||
chatMessages: chatMessages.length,
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream});
|
||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||
await streamMessage.flush();
|
||||
|
||||
if (!stream) {
|
||||
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||
model: config.geminiChatTarget.model,
|
||||
messages: chatMessages,
|
||||
tools: getOpenAITools(),
|
||||
temperature: 0.6,
|
||||
};
|
||||
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
|
||||
const message = response.choices?.[0]?.message;
|
||||
streamMessage.append(message?.content ?? "");
|
||||
const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: message?.content?.length ?? 0,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
|
||||
chatMessages.push({
|
||||
role: "assistant",
|
||||
content: message?.content ?? "",
|
||||
tool_calls: calls.map(call => ({
|
||||
id: call.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.argumentsText,
|
||||
},
|
||||
})),
|
||||
});
|
||||
await appendOpenAiChatToolResults(chatMessages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
|
||||
continue;
|
||||
}
|
||||
|
||||
const request: ChatCompletionCreateParamsStreaming = {
|
||||
model: config.geminiChatTarget.model,
|
||||
messages: chatMessages,
|
||||
tools: getOpenAITools(),
|
||||
temperature: 0.6,
|
||||
stream: true,
|
||||
};
|
||||
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
|
||||
|
||||
aiLog("debug", "openai_compatible.stream.open", {round});
|
||||
// const streamToolCalls: OpenAiChatToolCallLike[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
|
||||
let calls: ToolCallData[] = [];
|
||||
|
||||
for await (const chunk of response) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
streamMessage.append(delta?.content ?? "");
|
||||
|
||||
if (delta?.tool_calls?.length) {
|
||||
calls = toolCallAccumulator.add(delta.tool_calls);
|
||||
streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
|
||||
// const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
|
||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
chatMessages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
tool_calls: calls.map(call => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.argumentsText,
|
||||
},
|
||||
})),
|
||||
});
|
||||
await appendOpenAiChatToolResults(chatMessages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class OpenAiProviderRunner {
|
||||
static run = runOpenAi;
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProviderRunner {
|
||||
static run = runOpenAiCompatibleChat;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
import {Tool} from "ollama";
|
||||
import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget} from "./ai-logger";
|
||||
import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared";
|
||||
|
||||
type RankedToolStep = {
|
||||
t: string | string[];
|
||||
h?: string;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
type RankedToolPlan = {
|
||||
s?: RankedToolStep[];
|
||||
m?: string;
|
||||
};
|
||||
|
||||
export type ToolRankerSelection = {
|
||||
tools: Tool[];
|
||||
selectedNames: string[];
|
||||
missing: string;
|
||||
raw: string;
|
||||
usedRanker: boolean;
|
||||
};
|
||||
|
||||
export class OllamaToolRanker {
|
||||
constructor(private readonly config: RuntimeConfigSnapshot) {}
|
||||
|
||||
async selectTools(args: {
|
||||
userQuery: string;
|
||||
availableTools: Tool[];
|
||||
round: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<ToolRankerSelection> {
|
||||
const {availableTools, round, signal, userQuery} = args;
|
||||
const target = this.config.ollamaToolRankerTarget;
|
||||
|
||||
if (!availableTools.length) {
|
||||
return {tools: [], selectedNames: [], missing: "", raw: "", usedRanker: false};
|
||||
}
|
||||
|
||||
// Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
|
||||
if (!target?.model) {
|
||||
return {
|
||||
tools: availableTools,
|
||||
selectedNames: allToolSchemaNames(availableTools),
|
||||
missing: "",
|
||||
raw: "",
|
||||
usedRanker: false,
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const availableNames = new Set(allToolSchemaNames(availableTools));
|
||||
const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
|
||||
const toolsForPrompt = availableTools.map(tool => ({
|
||||
names: toolSchemaNames(tool),
|
||||
schema: tool,
|
||||
}));
|
||||
|
||||
aiLog("debug", "ollama.tool_ranker.start", {
|
||||
round,
|
||||
target: aiLogProviderTarget(target),
|
||||
queryChars: userQuery.length,
|
||||
availableTools: [...availableNames],
|
||||
});
|
||||
|
||||
try {
|
||||
const ollama = createOllamaClient(target as AiRuntimeTarget);
|
||||
const response = await ollama.chat({
|
||||
model: target.model,
|
||||
messages: [
|
||||
{role: "system", content: prompt},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
q: userQuery,
|
||||
tools: toolsForPrompt,
|
||||
}),
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0,
|
||||
num_ctx: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
const raw = response.message?.content?.trim() ?? "";
|
||||
const plan = parseToolRankerPlan(raw);
|
||||
const selectedNames = normalizeToolRankerNames(plan, availableNames);
|
||||
const selectedNameSet = new Set(selectedNames);
|
||||
const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
|
||||
const missing = typeof plan?.m === "string" ? plan.m.trim() : "";
|
||||
|
||||
aiLog("debug", "ollama.tool_ranker.done", {
|
||||
round,
|
||||
duration: aiLogDuration(startedAt),
|
||||
selectedNames,
|
||||
selectedCount: tools.length,
|
||||
missing,
|
||||
rawPreview: raw.slice(0, 800),
|
||||
});
|
||||
|
||||
// Important: if the plan is valid and empty, use NO tools. Do not fall back to all tools.
|
||||
return {tools, selectedNames, missing, raw, usedRanker: true};
|
||||
} catch (error) {
|
||||
if (String(error).includes("Aborted")) throw error;
|
||||
|
||||
aiLog("warn", "ollama.tool_ranker.failed.fallback_all_allowed", {
|
||||
round,
|
||||
target: aiLogProviderTarget(target),
|
||||
duration: aiLogDuration(startedAt),
|
||||
error,
|
||||
});
|
||||
|
||||
// Ranker transport/model failure is different from "ranker returned empty plan".
|
||||
// In that case, preserve availability rather than silently disabling tools.
|
||||
return {
|
||||
tools: availableTools,
|
||||
selectedNames: allToolSchemaNames(availableTools),
|
||||
missing: "",
|
||||
raw: "",
|
||||
usedRanker: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function latestUserTextFromOllamaMessages(messages: readonly { role?: string; content?: unknown }[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message?.role !== "user") continue;
|
||||
if (typeof message.content === "string") return message.content;
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function looksLikeToolRankerJson(text: string): boolean {
|
||||
const parsed = safeJsonParseObject(extractJsonObjectText(text) ?? text);
|
||||
return Array.isArray(parsed.s) && typeof parsed.m === "string";
|
||||
}
|
||||
|
||||
function parseToolRankerPlan(raw: string): RankedToolPlan | undefined {
|
||||
const jsonText = extractJsonObjectText(raw);
|
||||
if (!jsonText) return undefined;
|
||||
|
||||
const parsed = safeJsonParseObject(jsonText) as JsonObject;
|
||||
if (!Array.isArray(parsed.s)) return undefined;
|
||||
|
||||
return parsed as RankedToolPlan;
|
||||
}
|
||||
|
||||
function normalizeToolRankerNames(plan: RankedToolPlan | undefined, availableNames: Set<string>): string[] {
|
||||
if (!plan?.s?.length) return [];
|
||||
|
||||
const result: string[] = [];
|
||||
for (const step of plan.s) {
|
||||
const rawNames = Array.isArray(step.t) ? step.t : [step.t];
|
||||
for (const rawName of rawNames) {
|
||||
if (typeof rawName !== "string") continue;
|
||||
const name = rawName.trim();
|
||||
if (availableNames.has(name) && !result.includes(name)) {
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractJsonObjectText(raw: string): string | undefined {
|
||||
const text = raw.trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
const start = text.indexOf("{");
|
||||
if (start === -1) return undefined;
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) continue;
|
||||
|
||||
if (ch === "{") depth++;
|
||||
if (ch === "}") depth--;
|
||||
|
||||
if (depth === 0) {
|
||||
return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_TOOL_RANKER_PROMPT = `You are a tool router. Return strict compact JSON only.
|
||||
Schema: {"s":[{"t":"tool_name","h":"short input hint","from":"previous_tool.output_or_empty"}],"m":""}
|
||||
Use tools only when they are needed. If no tool is needed, return {"s":[],"m":""}.
|
||||
Never answer the user. Never explain. Never use markdown.`;
|
||||
+128
-2067
File diff suppressed because it is too large
Load Diff
@@ -96,8 +96,8 @@ export class AiCancel extends CallbackCommand {
|
||||
{method: "editMessageText", chatId: message.chat.id, chatType: message.chat.type}
|
||||
);
|
||||
|
||||
if (result && result !== true) {
|
||||
await MessageStore.put({...result, text: cancelledText} as Message);
|
||||
if (result) {
|
||||
await MessageStore.put({...(result as object), text: cancelledText} as Message);
|
||||
} else {
|
||||
await MessageStore.put({
|
||||
chatId: message.chat.id,
|
||||
|
||||
+8
-6
@@ -21,8 +21,9 @@ export class Ae extends Command {
|
||||
try {
|
||||
let result = this.executeEvaluation(match);
|
||||
await oldSendMessage(msg, result).catch(async () => await errorPlaceholder(msg));
|
||||
} catch (e: any) {
|
||||
const text = e.message.toString();
|
||||
} catch (e: unknown) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
const text = error.message.toString();
|
||||
|
||||
if (text.includes("is not defined")) {
|
||||
await oldSendMessage(msg, Environment.variableNotDefinedText).catch(logError);
|
||||
@@ -30,7 +31,7 @@ export class Ae extends Command {
|
||||
}
|
||||
|
||||
logError(`${text}
|
||||
* Stacktrace: ${e.stack}`);
|
||||
* Stacktrace: ${error.stack}`);
|
||||
|
||||
await oldSendMessage(msg, text).catch(logError);
|
||||
}
|
||||
@@ -43,15 +44,16 @@ export class Ae extends Command {
|
||||
e = ((typeof e == "string") ? e : JSON.stringify(e));
|
||||
|
||||
return e;
|
||||
} catch (e: any) {
|
||||
const text = e.message.toString();
|
||||
} catch (e: unknown) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
const text = error.message.toString();
|
||||
|
||||
if (text.includes("is not defined")) {
|
||||
return Environment.evaluationVariableNotDefinedText;
|
||||
}
|
||||
|
||||
logError(`${text}
|
||||
* Stacktrace: ${e.stack}`);
|
||||
* Stacktrace: ${error.stack}`);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class Distort extends Command {
|
||||
}),
|
||||
{method: "sendPhoto", chatId, chatType: msg.chat.type}
|
||||
);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
await oldReplyToMessage(
|
||||
msg, Environment.getDistortFailedText(e)
|
||||
).catch(logError);
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export class Qr extends Command {
|
||||
}),
|
||||
{method: "sendPhoto", chatId, chatType: msg.chat.type}
|
||||
);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
await replyToMessage({
|
||||
message: msg,
|
||||
text: Environment.getQrCodeFailedText(e)
|
||||
|
||||
@@ -1636,8 +1636,8 @@ export class Environment {
|
||||
private static getFileMtimeMs(filePath: string): number | undefined {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") {
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ function normalizeLanguageCode(value: string | undefined | null): string | undef
|
||||
function readMtimeMs(filePath: string): number | undefined {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return undefined;
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export type OllamaRequest = {
|
||||
uuid: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stream: any;
|
||||
stream: unknown;
|
||||
done: boolean;
|
||||
fromId: number;
|
||||
chatId: number;
|
||||
|
||||
@@ -68,11 +68,12 @@ export class ShellCommandRunner {
|
||||
}
|
||||
|
||||
return {stdout, stderr};
|
||||
} catch (error: any) {
|
||||
console.error("Error code:", error.code);
|
||||
console.error("Stderr:", error.stderr);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Partial<Error & {code: unknown; stdout: string; stderr: string}>;
|
||||
console.error("Error code:", err.code);
|
||||
console.error("Stderr:", err.stderr);
|
||||
|
||||
return {stdout: error.stdout ?? null, stderr: error.stderr ?? error.message};
|
||||
return {stdout: err.stdout ?? null, stderr: err.stderr ?? err.message ?? String(error)};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-11
@@ -1,4 +1,5 @@
|
||||
import * as si from "systeminformation";
|
||||
import {redactLogValue} from "../ai/ai-logger";
|
||||
import {Command} from "../base/command";
|
||||
import {CallbackCommand} from "../base/callback-command";
|
||||
import {
|
||||
@@ -65,7 +66,7 @@ export const ignoreIfMarkupFailed = (e: Error | TelegramError) => {
|
||||
};
|
||||
|
||||
export const logError = (e: Error | TelegramError | string | unknown) => {
|
||||
console.error(e);
|
||||
console.error(redactLogValue(e));
|
||||
};
|
||||
|
||||
export const errorPlaceholder = async (msg: Message) => {
|
||||
@@ -307,13 +308,13 @@ export async function editMessageText(options: EditOptions, retries = 1) {
|
||||
}
|
||||
);
|
||||
return Promise.resolve(message);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
|
||||
if (isMarkupFailed(e)) {
|
||||
if (isMarkupFailed(e as Error | TelegramError)) {
|
||||
return Promise.resolve(true);
|
||||
} else if (isTooManyRequests(e) && retries > 0) {
|
||||
const retryAfter = Number(e.message.split("retry after ")[1]) || 30;
|
||||
} else if (isTooManyRequests(e as Error | TelegramError) && retries > 0) {
|
||||
const retryAfter = Number((e instanceof Error ? e.message : String(e)).split("retry after ")[1]) || 30;
|
||||
await delay(retryAfter * 1000);
|
||||
return editMessageText(options, retries - 1);
|
||||
} else {
|
||||
@@ -1836,9 +1837,10 @@ export function startIntervalEditor(params: {
|
||||
try {
|
||||
await params.editFn(next);
|
||||
lastSent = next;
|
||||
} catch (e: any) {
|
||||
if ((e?.description ?? e?.message ?? "").includes("message is not modified")) return;
|
||||
logError("edit failed: " + e);
|
||||
} catch (e: unknown) {
|
||||
const description = e instanceof Error ? e.message : String(e);
|
||||
if (description.includes("message is not modified")) return;
|
||||
logError("edit failed: " + description);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1896,7 +1898,6 @@ type RuntimeInfo =
|
||||
| { runtime: "unknown"; version: string };
|
||||
|
||||
export function getRuntimeInfo(): RuntimeInfo {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const v = process.versions ?? {};
|
||||
|
||||
if (typeof v.bun === "string") {
|
||||
@@ -1906,7 +1907,6 @@ export function getRuntimeInfo(): RuntimeInfo {
|
||||
return {runtime: "node", version: v.node};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {runtime: "unknown", version: String(process.version ?? "")};
|
||||
}
|
||||
|
||||
@@ -1957,7 +1957,6 @@ export async function imageToBase64(filePath: string, withMimeType: boolean = fa
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function ifTrue(exp?: string | number | boolean): boolean {
|
||||
if (!exp) return false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user