Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78932e82af | |||
| a411c6874a | |||
| bd548a9f43 |
@@ -119,6 +119,8 @@ export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRunti
|
|||||||
}
|
}
|
||||||
case AiProvider.GEMINI:
|
case AiProvider.GEMINI:
|
||||||
switch (purpose) {
|
switch (purpose) {
|
||||||
|
case "vision":
|
||||||
|
case "ocr":
|
||||||
case "outputImages":
|
case "outputImages":
|
||||||
return Environment.GEMINI_IMAGE_MODEL;
|
return Environment.GEMINI_IMAGE_MODEL;
|
||||||
case "speechToText":
|
case "speechToText":
|
||||||
|
|||||||
@@ -164,14 +164,14 @@ export async function getModelCapabilities(
|
|||||||
case AiProvider.GEMINI: {
|
case AiProvider.GEMINI: {
|
||||||
const chatLike = lowerModelName(model).startsWith("gemini-") && !isGeminiNonChatModel(model);
|
const chatLike = lowerModelName(model).startsWith("gemini-") && !isGeminiNonChatModel(model);
|
||||||
const reasoningModel = lowerModelName(model).includes("2.5") || lowerModelName(model).includes("thinking");
|
const reasoningModel = lowerModelName(model).includes("2.5") || lowerModelName(model).includes("thinking");
|
||||||
const imageTarget = resolveAiRuntimeTarget(provider, "outputImages");
|
const imageTarget = resolveAiRuntimeTarget(provider, "vision");
|
||||||
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||||
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||||
|
|
||||||
return buildCapabilities({
|
return buildCapabilities({
|
||||||
chat: capability(true, target, runtimeTarget),
|
chat: capability(true, target, runtimeTarget),
|
||||||
vision: capability(chatLike, target, runtimeTarget),
|
vision: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
|
||||||
ocr: capability(chatLike, target, runtimeTarget),
|
ocr: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
|
||||||
thinking: capability(reasoningModel, target, runtimeTarget),
|
thinking: capability(reasoningModel, target, runtimeTarget),
|
||||||
extendedThinking: capability(reasoningModel, target, runtimeTarget),
|
extendedThinking: capability(reasoningModel, target, runtimeTarget),
|
||||||
tools: capability(chatLike, target, runtimeTarget),
|
tools: capability(chatLike, target, runtimeTarget),
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import {appLogger} from "../../logging/logger";
|
||||||
|
|
||||||
|
export const toolsLogger = appLogger.child("ai-tools");
|
||||||
@@ -6,7 +6,19 @@ import {GeminiMessage} from "./gemini-chat-message";
|
|||||||
import {createGoogleGenAiClient} from "./ai-runtime-target";
|
import {createGoogleGenAiClient} from "./ai-runtime-target";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
|
|
||||||
import {AsyncIterableStream, GeminiFunctionCallLike, GeminiResponseLike, MAX_TOOL_ROUNDS, RuntimeConfigSnapshot, ToolCallData, ToolExecutionMemory, executeToolBatch, roundStatus, safeJsonParseObject, GeminiGenerationRequest} from "./unified-ai-runner.shared";
|
import {
|
||||||
|
AsyncIterableStream,
|
||||||
|
executeToolBatch,
|
||||||
|
GeminiFunctionCallLike,
|
||||||
|
GeminiGenerationRequest,
|
||||||
|
GeminiResponseLike,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
roundStatus,
|
||||||
|
RuntimeConfigSnapshot,
|
||||||
|
safeJsonParseObject,
|
||||||
|
ToolCallData,
|
||||||
|
ToolExecutionMemory
|
||||||
|
} from "./unified-ai-runner.shared";
|
||||||
|
|
||||||
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
|
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
|
||||||
if (typeof response.text === "string") return response.text;
|
if (typeof response.text === "string") return response.text;
|
||||||
@@ -88,6 +100,14 @@ export async function runGemini(
|
|||||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: 13.05.2026, Danil Nikolaev: find a better way?
|
||||||
|
const imageCount = messages.reduce((sum, m) => {
|
||||||
|
return sum + (m.parts.filter(p => "inlineData" in p && "mimeType" in p.inlineData && p.inlineData.mimeType.startsWith("image")).length)
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const target = imageCount ? config.geminiImageTarget : config.geminiChatTarget;
|
||||||
|
const model = target.model;
|
||||||
|
|
||||||
const toolMemory: ToolExecutionMemory = new Map();
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||||
@@ -99,7 +119,7 @@ export async function runGemini(
|
|||||||
await streamMessage.flush();
|
await streamMessage.flush();
|
||||||
|
|
||||||
const request: GeminiGenerationRequest = {
|
const request: GeminiGenerationRequest = {
|
||||||
model: config.geminiChatTarget.model,
|
model: model,
|
||||||
contents: messages,
|
contents: messages,
|
||||||
config: {
|
config: {
|
||||||
tools: getGeminiTools(),
|
tools: getGeminiTools(),
|
||||||
|
|||||||
@@ -5,14 +5,53 @@ import {getOpenAITools} from "./tool-mappers";
|
|||||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import {ToolRuntimeContext} from "./tools/runtime";
|
import {ToolRuntimeContext} from "./tools/runtime";
|
||||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
import type {ResponseCreateParamsNonStreaming, ResponseCreateParamsStreaming, ResponseInputItem, ResponseStreamEvent} from "openai/resources/responses/responses";
|
import type {
|
||||||
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming} from "openai/resources/chat/completions";
|
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 {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
|
||||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/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";
|
import {
|
||||||
|
AsyncIterableStream,
|
||||||
|
collectOpenAiResponseFunctionCalls,
|
||||||
|
collectOpenAiResponseImages,
|
||||||
|
collectOpenAiResponseText,
|
||||||
|
executeToolBatch,
|
||||||
|
getOpenAIResponsesToolsWithImage,
|
||||||
|
isRecord,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
OPENAI_IMAGE_PARTIALS,
|
||||||
|
OpenAiChatCompletionResponseLike,
|
||||||
|
OpenAiChatCompletionStreamChunkLike,
|
||||||
|
OpenAiChatToolCallLike,
|
||||||
|
OpenAiCompatibleChatMessage,
|
||||||
|
OpenAiCompatibleContentPart,
|
||||||
|
openAiResponseItemCallId,
|
||||||
|
OpenAiResponseLike,
|
||||||
|
OpenAiResponseOutputItem,
|
||||||
|
roundStatus,
|
||||||
|
RuntimeConfigSnapshot,
|
||||||
|
safeJsonParseObject,
|
||||||
|
showOpenAiGeneratedImage,
|
||||||
|
StreamingToolCallAccumulator,
|
||||||
|
ToolCallData,
|
||||||
|
ToolExecutionMemory
|
||||||
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
|
||||||
|
import {bot, notesDir} from "../index";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {logError} from "../util/utils";
|
||||||
|
|
||||||
export async function runOpenAi(
|
export async function runOpenAi(
|
||||||
|
msg: Message,
|
||||||
messages: OpenAIChatMessage[],
|
messages: OpenAIChatMessage[],
|
||||||
streamMessage: TelegramStreamMessage,
|
streamMessage: TelegramStreamMessage,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
@@ -90,6 +129,32 @@ export async function runOpenAi(
|
|||||||
argumentsText: call.argumentsText,
|
argumentsText: call.argumentsText,
|
||||||
}));
|
}));
|
||||||
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
|
const toolResults = await executeToolBatch(toolCalls, 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);
|
||||||
|
}
|
||||||
|
|
||||||
const toolOutputs = calls.map((call, index) => ({
|
const toolOutputs = calls.map((call, index) => ({
|
||||||
type: "function_call_output" as const,
|
type: "function_call_output" as const,
|
||||||
call_id: call.callId,
|
call_id: call.callId,
|
||||||
@@ -228,6 +293,32 @@ export async function runOpenAi(
|
|||||||
argumentsText: call.argumentsText,
|
argumentsText: call.argumentsText,
|
||||||
}));
|
}));
|
||||||
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
|
const toolResults = await executeToolBatch(toolCalls, 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);
|
||||||
|
}
|
||||||
|
|
||||||
const toolOutputs = calls.map((call, index) => ({
|
const toolOutputs = calls.map((call, index) => ({
|
||||||
type: "function_call_output",
|
type: "function_call_output",
|
||||||
call_id: call.callId,
|
call_id: call.callId,
|
||||||
@@ -298,6 +389,7 @@ async function appendOpenAiChatToolResults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runOpenAiCompatibleChat(
|
export async function runOpenAiCompatibleChat(
|
||||||
|
msg: Message,
|
||||||
messages: OpenAIChatMessage[],
|
messages: OpenAIChatMessage[],
|
||||||
streamMessage: TelegramStreamMessage,
|
streamMessage: TelegramStreamMessage,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
@@ -356,7 +448,35 @@ export async function runOpenAiCompatibleChat(
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
await appendOpenAiChatToolResults(chatMessages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,15 +530,34 @@ export async function runOpenAiCompatibleChat(
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
await appendOpenAiChatToolResults(chatMessages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class OpenAiProviderRunner {
|
|
||||||
static run = runOpenAi;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpenAiCompatibleProviderRunner {
|
|
||||||
static run = runOpenAiCompatibleChat;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
transcribeSpeechDownloads
|
transcribeSpeechDownloads
|
||||||
} from "./speech-to-text";
|
} from "./speech-to-text";
|
||||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
import type {ResponseInputContent, ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||||
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
import type {GenerateContentParameters} from "@google/genai";
|
import type {GenerateContentParameters} from "@google/genai";
|
||||||
import {MistralChatMessage} from "./mistral-chat-message";
|
import {MistralChatMessage} from "./mistral-chat-message";
|
||||||
@@ -279,6 +279,7 @@ export type RuntimeConfigSnapshot = {
|
|||||||
ollamaRagMaxArchiveDepth: number;
|
ollamaRagMaxArchiveDepth: number;
|
||||||
|
|
||||||
geminiChatTarget: AiRuntimeTarget;
|
geminiChatTarget: AiRuntimeTarget;
|
||||||
|
geminiImageTarget: AiRuntimeTarget;
|
||||||
|
|
||||||
mistralChatTarget: AiRuntimeTarget;
|
mistralChatTarget: AiRuntimeTarget;
|
||||||
|
|
||||||
@@ -310,6 +311,7 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
|||||||
ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH,
|
ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH,
|
||||||
|
|
||||||
geminiChatTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "chat"),
|
geminiChatTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "chat"),
|
||||||
|
geminiImageTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "vision"),
|
||||||
|
|
||||||
mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"),
|
mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"),
|
||||||
|
|
||||||
@@ -536,13 +538,13 @@ export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set<A
|
|||||||
if (msg.video) kinds.add("video");
|
if (msg.video) kinds.add("video");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectStoredReplyChainAttachments(msg: Message): Promise<StoredAttachment[]> {
|
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 1): Promise<StoredAttachment[]> {
|
||||||
const attachments: StoredAttachment[] = [];
|
const attachments: StoredAttachment[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let current = await MessageStore.get(msg.chat.id, msg.message_id);
|
let current = await MessageStore.get(msg.chat.id, msg.message_id);
|
||||||
|
|
||||||
for (let i = 0; current && i < 40; i++) {
|
for (let i = 0; current && i < limit; i++) {
|
||||||
for (const attachment of current.attachments ?? []) {
|
for (const attachment of current?.attachments ?? []) {
|
||||||
const key = [
|
const key = [
|
||||||
attachment.kind,
|
attachment.kind,
|
||||||
attachment.fileUniqueId || attachment.fileId,
|
attachment.fileUniqueId || attachment.fileId,
|
||||||
@@ -641,6 +643,7 @@ export async function rejectUnsupportedAttachments(
|
|||||||
if (!unsupported) return false;
|
if (!unsupported) return false;
|
||||||
|
|
||||||
if (!kinds.has("audio")) {
|
if (!kinds.has("audio")) {
|
||||||
|
// TODO: 13.05.2026, Danil Nikolaev: add "Regenerate" button
|
||||||
await replyToMessage({
|
await replyToMessage({
|
||||||
message: msg,
|
message: msg,
|
||||||
text: unsupportedAttachmentText(provider, effectiveModel, unsupported),
|
text: unsupportedAttachmentText(provider, effectiveModel, unsupported),
|
||||||
@@ -796,8 +799,8 @@ export function normalizeOllamaToolCalls(calls: readonly OllamaToolCallLike[] =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildOpenAiResponseMessage(part: MessagePart, getContent: (part: MessagePart) => string): OpenAIChatMessage {
|
export function buildOpenAiResponseMessage(part: MessagePart, getContent: (part: MessagePart) => string): OpenAIChatMessage {
|
||||||
const content: ResponseInputMessageContentList = [{
|
const content: Array<ResponseInputContent | any> = [{
|
||||||
type: "input_text",
|
type: part.bot ? "output_text" : "input_text",
|
||||||
text: getContent(part),
|
text: getContent(part),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ async function executeUnifiedAiRequest(
|
|||||||
|
|
||||||
switch (options.provider) {
|
switch (options.provider) {
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
await runOpenAi(chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext);
|
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext);
|
||||||
break;
|
break;
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
const currentModel = config.ollamaChatTarget.model;
|
const currentModel = config.ollamaChatTarget.model;
|
||||||
@@ -154,7 +154,7 @@ async function executeUnifiedAiRequest(
|
|||||||
break;
|
break;
|
||||||
case AiProvider.GEMINI:
|
case AiProvider.GEMINI:
|
||||||
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
|
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
|
||||||
await runOpenAiCompatibleChat(chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
||||||
} else {
|
} else {
|
||||||
await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
import {Command} from "../base/command";
|
||||||
|
import {Requirement} from "../base/requirement";
|
||||||
|
import {Requirements} from "../base/requirements";
|
||||||
|
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
|
||||||
|
import {formatRuntimeModelInfo, getRuntimeModel, listProviderModels, setRuntimeModel} from "../ai/provider-model-runtime";
|
||||||
|
import {Environment} from "../common/environment";
|
||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {appLogger} from "../logging/logger";
|
||||||
|
import {escapeHtml, logError, replyToMessage} from "../util/utils";
|
||||||
|
|
||||||
|
const logger = appLogger.child("commands:models");
|
||||||
|
|
||||||
|
type ProviderModelCommandOptions = {
|
||||||
|
provider: AiProvider;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export abstract class ProviderModelCommand extends Command {
|
||||||
|
protected readonly provider: AiProvider;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
protected constructor(options: ProviderModelCommandOptions) {
|
||||||
|
super();
|
||||||
|
this.provider = options.provider;
|
||||||
|
this.title = options.title;
|
||||||
|
this.description = options.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderGetModelCommand extends ProviderModelCommand {
|
||||||
|
async execute(msg: Message): Promise<void> {
|
||||||
|
logger.debug("get_model", {provider: this.provider, chatId: msg.chat?.id, messageId: msg.message_id});
|
||||||
|
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(this.provider)}).catch(logError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderSetModelCommand extends ProviderModelCommand {
|
||||||
|
argsMode = "required" as const;
|
||||||
|
requirements = Requirements.Build(Requirement.BOT_CREATOR);
|
||||||
|
|
||||||
|
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
|
||||||
|
const newModel = match?.[3]?.trim();
|
||||||
|
logger.info("set_model.request", {provider: this.provider, hasModel: !!newModel, chatId: msg.chat?.id, messageId: msg.message_id});
|
||||||
|
|
||||||
|
if (newModel) setRuntimeModel(this.provider, newModel);
|
||||||
|
|
||||||
|
const model = getRuntimeModel(this.provider);
|
||||||
|
const text = newModel
|
||||||
|
? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(this.provider))
|
||||||
|
: Environment.getModelIsNotSetCurrentText(model);
|
||||||
|
|
||||||
|
logger.debug("set_model.reply", {provider: this.provider, model});
|
||||||
|
await replyToMessage({message: msg, text}).catch(logError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderListModelsCommand extends ProviderModelCommand {
|
||||||
|
requirements = Requirements.Build(Requirement.BOT_CREATOR);
|
||||||
|
|
||||||
|
async execute(msg: Message): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info("list_models.request", {provider: this.provider, chatId: msg.chat?.id, messageId: msg.message_id});
|
||||||
|
const models = (await listProviderModels(this.provider)).sort((a, b) => a.localeCompare(b));
|
||||||
|
const modelsString = escapeHtml(models.join("\n").substring(0, 4000));
|
||||||
|
const text = await this.buildListText(modelsString);
|
||||||
|
|
||||||
|
logger.debug("list_models.reply", {provider: this.provider, count: models.length, textChars: text.length});
|
||||||
|
await replyToMessage({message: msg, text, parse_mode: "HTML"});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("list_models.failed", {provider: this.provider, error: e});
|
||||||
|
logError(e);
|
||||||
|
await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildListText(modelsString: string): Promise<string> {
|
||||||
|
if (this.provider !== AiProvider.OLLAMA) {
|
||||||
|
return Environment.modelListHeaderText + "<blockquote expandable>" + modelsString + "</blockquote>";
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat");
|
||||||
|
const loadedModels = ((await createOllamaClient(target).ps())?.models ?? [])
|
||||||
|
.map(model => model.model || model.name)
|
||||||
|
.filter((model): model is string => !!model);
|
||||||
|
|
||||||
|
logger.debug("list_models.loaded", {provider: this.provider, loaded: loadedModels.length});
|
||||||
|
return Environment.getLoadedModelsText(loadedModels)
|
||||||
|
+ "\n\n"
|
||||||
|
+ Environment.modelListHeaderText
|
||||||
|
+ "<blockquote expandable>"
|
||||||
|
+ modelsString
|
||||||
|
+ "</blockquote>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger";
|
||||||
|
|
||||||
|
export type AiRunnerLogLevel = LogLevel;
|
||||||
|
export type AiRunnerLogDetails = LogDetails;
|
||||||
|
|
||||||
|
export type AiLogToolCallLike = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
argumentsText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiRunnerLogger = createLogger("unified-ai-runner", {
|
||||||
|
envPrefix: "AI",
|
||||||
|
defaultLevel: "debug",
|
||||||
|
enabledEnvNames: ["AI_RUNNER_LOGS", "AI_LOG_ENABLED"],
|
||||||
|
colorsEnvNames: ["AI_RUNNER_LOG_COLORS", "AI_LOG_COLORS"],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiLog(level: AiRunnerLogLevel, event: string, details?: AiRunnerLogDetails): void {
|
||||||
|
aiRunnerLogger[level](event, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiLogDuration(startedAt: number): string {
|
||||||
|
return formatDuration(startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
export type LogLevel = "trace" | "debug" | "info" | "success" | "warn" | "error";
|
||||||
|
|
||||||
|
export type LogDetails = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type LoggerOptions = {
|
||||||
|
envPrefix?: string;
|
||||||
|
defaultLevel?: LogLevel;
|
||||||
|
enabledEnvNames?: readonly string[];
|
||||||
|
colorsEnvNames?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Logger = {
|
||||||
|
scope: string;
|
||||||
|
trace(event: string, details?: LogDetails): void;
|
||||||
|
debug(event: string, details?: LogDetails): void;
|
||||||
|
info(event: string, details?: LogDetails): void;
|
||||||
|
success(event: string, details?: LogDetails): void;
|
||||||
|
warn(event: string, details?: LogDetails): void;
|
||||||
|
error(event: string, details?: LogDetails): void;
|
||||||
|
child(scope: string, options?: LoggerOptions): Logger;
|
||||||
|
duration(startedAt: number): string;
|
||||||
|
enabled(level?: LogLevel): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_STRING = 600;
|
||||||
|
const DEFAULT_MAX_ARRAY = 8;
|
||||||
|
const DEFAULT_MAX_DEPTH = 3;
|
||||||
|
|
||||||
|
const LOG_LEVEL_WEIGHT: Record<LogLevel, number> = {
|
||||||
|
trace: 10,
|
||||||
|
debug: 20,
|
||||||
|
info: 30,
|
||||||
|
success: 30,
|
||||||
|
warn: 40,
|
||||||
|
error: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOG_COLORS: Record<LogLevel | "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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALSE_VALUES = new Set(["0", "false", "no", "off", "disable", "disabled"]);
|
||||||
|
const TRUE_VALUES = new Set(["1", "true", "yes", "on", "enable", "enabled"]);
|
||||||
|
|
||||||
|
export function envBool(name: string, defaultValue: boolean): boolean {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined) return defaultValue;
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (FALSE_VALUES.has(normalized)) return false;
|
||||||
|
if (TRUE_VALUES.has(normalized)) return true;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envNumber(name: string, defaultValue: number): number {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (!raw?.trim()) return defaultValue;
|
||||||
|
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredMaxString(): number {
|
||||||
|
return envNumber("LOG_MAX_STRING", DEFAULT_MAX_STRING);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredMaxArray(): number {
|
||||||
|
return envNumber("LOG_MAX_ARRAY", DEFAULT_MAX_ARRAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredMaxDepth(): number {
|
||||||
|
return envNumber("LOG_MAX_DEPTH", DEFAULT_MAX_DEPTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLogLevel(level: string): level is LogLevel {
|
||||||
|
return level in LOG_LEVEL_WEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedEnvName(prefix: string | undefined, suffix: string): string | undefined {
|
||||||
|
if (!prefix?.trim()) return undefined;
|
||||||
|
return `${prefix.trim().toUpperCase()}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredMinLevel(options: LoggerOptions): LogLevel {
|
||||||
|
const scoped = scopedEnvName(options.envPrefix, "LOG_LEVEL");
|
||||||
|
const raw = (scoped ? process.env[scoped] : undefined) ?? process.env.LOG_LEVEL;
|
||||||
|
const normalized = raw?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (normalized && isValidLogLevel(normalized)) return normalized;
|
||||||
|
return options.defaultLevel ?? "debug";
|
||||||
|
}
|
||||||
|
|
||||||
|
function envChainEnabled(names: readonly string[], defaultValue: boolean): boolean {
|
||||||
|
return names.every(name => envBool(name, defaultValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function logsEnabled(options: LoggerOptions): boolean {
|
||||||
|
const scoped = scopedEnvName(options.envPrefix, "LOG_ENABLED");
|
||||||
|
const names = [
|
||||||
|
"LOG_ENABLED",
|
||||||
|
"APP_LOG_ENABLED",
|
||||||
|
...(scoped ? [scoped] : []),
|
||||||
|
...(options.enabledEnvNames ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return envChainEnabled(names, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorsEnabled(options: LoggerOptions): boolean {
|
||||||
|
if (process.env.NO_COLOR) return false;
|
||||||
|
|
||||||
|
const scoped = scopedEnvName(options.envPrefix, "LOG_COLORS");
|
||||||
|
const names = [
|
||||||
|
"LOG_COLORS",
|
||||||
|
...(scoped ? [scoped] : []),
|
||||||
|
...(options.colorsEnvNames ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return envChainEnabled(names, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldWriteLevel(level: LogLevel, options: LoggerOptions): boolean {
|
||||||
|
return LOG_LEVEL_WEIGHT[level] >= LOG_LEVEL_WEIGHT[configuredMinLevel(options)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(value: string, color: keyof typeof LOG_COLORS, options: LoggerOptions): string {
|
||||||
|
if (!colorsEnabled(options)) return value;
|
||||||
|
return `${LOG_COLORS[color]}${value}${LOG_COLORS.reset}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateLogString(value: string, max = configuredMaxString()): string {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return `${value.slice(0, max)}… (+${value.length - max} chars)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecretKey(keyPath: string): boolean {
|
||||||
|
const normalized = keyPath.toLowerCase();
|
||||||
|
return normalized.includes("token")
|
||||||
|
|| normalized.includes("secret")
|
||||||
|
|| normalized.includes("password")
|
||||||
|
|| normalized.includes("passwd")
|
||||||
|
|| normalized.includes("apikey")
|
||||||
|
|| normalized.includes("api_key")
|
||||||
|
|| normalized.includes("authorization")
|
||||||
|
|| normalized.includes("cookie")
|
||||||
|
|| normalized.includes("session")
|
||||||
|
|| 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")
|
||||||
|
|| normalized.includes("transcript");
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 8).join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === "string") return truncateLogString(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.length} bytes>`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLargeBinaryKey(key: string): boolean {
|
||||||
|
const normalized = key.toLowerCase();
|
||||||
|
return normalized === "data"
|
||||||
|
|| normalized === "image_url"
|
||||||
|
|| normalized.endsWith("b64")
|
||||||
|
|| normalized.endsWith("base64")
|
||||||
|
|| normalized.includes("binary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenLogDetails(
|
||||||
|
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 >= configuredMaxDepth()) {
|
||||||
|
return keyPath ? {[keyPath]: `[Array ${value.length}]`} : {value: `[Array ${value.length}]`};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: Record<string, unknown> = {};
|
||||||
|
value.slice(0, configuredMaxArray()).forEach((item, index) => {
|
||||||
|
Object.assign(entries, flattenLogDetails(item, keyPath ? `${keyPath}.${index}` : String(index), depth + 1, seen));
|
||||||
|
});
|
||||||
|
if (value.length > configuredMaxArray()) {
|
||||||
|
entries[keyPath ? `${keyPath}.__more` : "__more"] = value.length - configuredMaxArray();
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= configuredMaxDepth()) {
|
||||||
|
return keyPath ? {[keyPath]: "[Object]"} : {value: "[Object]"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: Record<string, unknown> = {};
|
||||||
|
for (const [key, raw] of Object.entries(value)) {
|
||||||
|
const childPath = keyPath ? `${keyPath}.${key}` : key;
|
||||||
|
if (looksLikeLargeBinaryKey(key) && typeof raw === "string") {
|
||||||
|
entries[childPath] = `<${raw.length} chars>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(entries, flattenLogDetails(raw, childPath, depth + 1, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactLogValue(value: unknown): Record<string, unknown> {
|
||||||
|
return flattenLogDetails(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails(details: LogDetails | undefined, options: LoggerOptions): string {
|
||||||
|
if (!details || !Object.keys(details).length) return "";
|
||||||
|
|
||||||
|
const flattened = flattenLogDetails(details);
|
||||||
|
const chunks = Object.entries(flattened).map(([key, value]) => {
|
||||||
|
const safeValue = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
return `${paint(key, "key", options)}=${paint(safeValue ?? "undefined", "value", options)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ` ${chunks.join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLine(level: LogLevel, line: string): void {
|
||||||
|
if (level === "error") {
|
||||||
|
console.error(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === "warn") {
|
||||||
|
console.warn(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(startedAt: number): string {
|
||||||
|
const ms = Date.now() - startedAt;
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(scope: string, options: LoggerOptions = {}): Logger {
|
||||||
|
const normalizedScope = scope.trim() || "app";
|
||||||
|
const resolvedOptions = {...options};
|
||||||
|
|
||||||
|
const log = (level: LogLevel, event: string, details?: LogDetails): void => {
|
||||||
|
if (!logsEnabled(resolvedOptions) || !shouldWriteLevel(level, resolvedOptions)) return;
|
||||||
|
|
||||||
|
const timestamp = paint(new Date().toISOString(), "dim", resolvedOptions);
|
||||||
|
const prefix = paint(normalizedScope, "bold", resolvedOptions);
|
||||||
|
const levelText = paint(level.toUpperCase().padEnd(7), level, resolvedOptions);
|
||||||
|
const eventText = paint(event, "label", resolvedOptions);
|
||||||
|
writeLine(level, `${timestamp} ${prefix} ${levelText} ${eventText}${formatDetails(details, resolvedOptions)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: normalizedScope,
|
||||||
|
trace: (event, details) => log("trace", event, details),
|
||||||
|
debug: (event, details) => log("debug", event, details),
|
||||||
|
info: (event, details) => log("info", event, details),
|
||||||
|
success: (event, details) => log("success", event, details),
|
||||||
|
warn: (event, details) => log("warn", event, details),
|
||||||
|
error: (event, details) => log("error", event, details),
|
||||||
|
child: (childScope, childOptions) => createLogger(`${normalizedScope}:${childScope}`, {...resolvedOptions, ...childOptions}),
|
||||||
|
duration: formatDuration,
|
||||||
|
enabled: (level = "debug") => logsEnabled(resolvedOptions) && shouldWriteLevel(level, resolvedOptions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appLogger = createLogger("app", {envPrefix: "APP", defaultLevel: "debug"});
|
||||||
Reference in New Issue
Block a user