Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a99605e6 | |||
| 321d185592 | |||
| a3f19f0413 | |||
| c613c636e1 | |||
| 7f5011b871 | |||
| 5b67e23060 | |||
| a143d512ab | |||
| d47e2288d6 | |||
| 7b2bc93bc1 | |||
| 75253534d8 | |||
| 53e9798193 | |||
| df39d89ea8 | |||
| 1773b44edd | |||
| 507b15aa5f | |||
| d163d72a0b | |||
| 57985ce87b | |||
| 9a105caf0b | |||
| 13df2a1c23 | |||
| 9352ade19f | |||
| 9d6cdb008b | |||
| e520c412af | |||
| 58f5a645fd | |||
| c3481dfcfe | |||
| b16c213afb | |||
| 8aede4b053 | |||
| 8cff086a8e |
+45
-13
@@ -11,6 +11,18 @@ BOT_TOKEN=your_bot_token_here
|
|||||||
# To get your ID: send /id command to the bot and use the "from id" value
|
# To get your ID: send /id command to the bot and use the "from id" value
|
||||||
CREATOR_ID=your_user_id_here
|
CREATOR_ID=your_user_id_here
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
# Leave empty for local SQLite in ~/.local/share/tg-chat-bot/database.db.
|
||||||
|
# Set DATA_PATH=data if you want to keep files inside the repo.
|
||||||
|
# Set to postgres://... for PostgreSQL.
|
||||||
|
# Set to :memory: for ephemeral in-memory SQLite.
|
||||||
|
DATABASE_URL=
|
||||||
|
DATA_PATH=
|
||||||
|
|
||||||
|
# Docker Compose image tag override
|
||||||
|
# Used by docker-compose.yml when pulling ghcr.io/melod1n/tg-chat-bot
|
||||||
|
IMAGE_TAG=1.0.0
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# BOT SETTINGS (Optional)
|
# BOT SETTINGS (Optional)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -31,9 +43,27 @@ ONLY_FOR_CREATOR_MODE=false
|
|||||||
# Use user names in AI prompts
|
# Use user names in AI prompts
|
||||||
USE_NAMES_IN_PROMPT=true
|
USE_NAMES_IN_PROMPT=true
|
||||||
|
|
||||||
|
# Disable all built-in local tools and keep only MCP tools
|
||||||
|
DISABLE_LOCAL_TOOLS=false
|
||||||
|
|
||||||
|
# Filter built-in local tools by name.
|
||||||
|
# LOCAL_TOOL_ALLOWLIST lets through only the listed tools.
|
||||||
|
# LOCAL_TOOL_DENYLIST removes the listed tools.
|
||||||
|
# Examples:
|
||||||
|
# LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
|
||||||
|
# LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
|
||||||
|
LOCAL_TOOL_ALLOWLIST=
|
||||||
|
LOCAL_TOOL_DENYLIST=
|
||||||
|
|
||||||
# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md)
|
# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md)
|
||||||
SYSTEM_PROMPT=
|
SYSTEM_PROMPT=
|
||||||
|
|
||||||
|
# Tool ranker fallback policy:
|
||||||
|
# MAIN_MODEL - rank tools through the provider's chat model if a dedicated ranker target is missing or fails
|
||||||
|
# ALL_TOOLS - skip ranker fallback and allow all tools
|
||||||
|
# NO_TOOLS - skip ranker fallback and allow no tools
|
||||||
|
TOOL_RANKER_FALLBACK_POLICY=ALL_TOOLS
|
||||||
|
|
||||||
# Maximum photo size in pixels
|
# Maximum photo size in pixels
|
||||||
MAX_PHOTO_SIZE=1280
|
MAX_PHOTO_SIZE=1280
|
||||||
|
|
||||||
@@ -44,17 +74,6 @@ LOCALES_DIR=locales
|
|||||||
# AI MODELS CONFIGURATION (Optional)
|
# AI MODELS CONFIGURATION (Optional)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# Google Gemini
|
|
||||||
GEMINI_API_KEY=
|
|
||||||
# google: official Gemini API via @google/genai; openai: OpenAI-compatible Gemini endpoint; auto: infer from GEMINI_BASE_URL
|
|
||||||
GEMINI_API_MODE=google
|
|
||||||
GEMINI_MODEL=gemini-2.5-flash
|
|
||||||
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
|
|
||||||
GEMINI_TRANSCRIPTION_MODEL=gemini-2.5-flash
|
|
||||||
GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts
|
|
||||||
GEMINI_TTS_VOICE=Kore
|
|
||||||
GEMINI_MAX_CONCURRENT_REQUESTS=3
|
|
||||||
|
|
||||||
# Mistral AI
|
# Mistral AI
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
MISTRAL_MODEL=mistral-small-latest
|
MISTRAL_MODEL=mistral-small-latest
|
||||||
@@ -84,6 +103,10 @@ OLLAMA_MAX_CONCURRENT_REQUESTS=1
|
|||||||
# OpenAI
|
# OpenAI
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
|
# Backend mode:
|
||||||
|
# official = OpenAI responses API
|
||||||
|
# compatible = OpenAI-compatible chat.completions servers like llama.cpp
|
||||||
|
OPENAI_BACKEND=official
|
||||||
OPENAI_MODEL=gpt-4.1-nano
|
OPENAI_MODEL=gpt-4.1-nano
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1-mini
|
OPENAI_IMAGE_MODEL=gpt-image-1-mini
|
||||||
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
|
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
|
||||||
@@ -92,20 +115,29 @@ OPENAI_TTS_VOICE=alloy
|
|||||||
OPENAI_TTS_INSTRUCTIONS=
|
OPENAI_TTS_INSTRUCTIONS=
|
||||||
OPENAI_MAX_CONCURRENT_REQUESTS=3
|
OPENAI_MAX_CONCURRENT_REQUESTS=3
|
||||||
|
|
||||||
|
# MCP servers
|
||||||
|
# JSON array or {"mcpServers": {"name": {...}}}
|
||||||
|
# Stdio example:
|
||||||
|
# MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
|
||||||
|
# HTTP example:
|
||||||
|
# MCP_SERVERS=[{"name":"remote-tools","transport":"http","url":"https://example.com/mcp"}]
|
||||||
|
MCP_SERVERS=
|
||||||
|
|
||||||
# Per-capability AI endpoint overrides
|
# Per-capability AI endpoint overrides
|
||||||
# Pattern:
|
# Pattern:
|
||||||
# <PROVIDER>_<CAPABILITY>_MODEL=
|
# <PROVIDER>_<CAPABILITY>_MODEL=
|
||||||
# <PROVIDER>_<CAPABILITY>_BASE_URL=
|
# <PROVIDER>_<CAPABILITY>_BASE_URL=
|
||||||
# <PROVIDER>_<CAPABILITY>_API_KEY=
|
# <PROVIDER>_<CAPABILITY>_API_KEY=
|
||||||
#
|
#
|
||||||
# Providers: OLLAMA, GEMINI, MISTRAL, OPENAI
|
# Providers: OLLAMA, MISTRAL, OPENAI
|
||||||
# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO,
|
# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO,
|
||||||
# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH
|
# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH
|
||||||
# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS.
|
# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS.
|
||||||
# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL,
|
# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL,
|
||||||
# GEMINI_BASE_URL, OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
# OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
||||||
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
|
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
|
||||||
# TRANSCRIPTION, STT, TTS.
|
# TRANSCRIPTION, STT, TTS.
|
||||||
|
# Backend override: OPENAI_BACKEND=official|compatible.
|
||||||
#
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
|
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
node:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.19.0
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
bun:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# OPENAI Compatible Target Implementation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Add a separate execution path for OpenAI-compatible backends such as `llama.cpp`, while keeping the current official OpenAI path unchanged.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] Add explicit OpenAI backend mode in config
|
||||||
|
- [x] Route OpenAI requests to separate official and compatible runners
|
||||||
|
- [x] Keep official OpenAI on `responses.create(...)`
|
||||||
|
- [x] Add compatible `chat.completions.create(...)` runner
|
||||||
|
- [x] Add compatible tool-call extractors
|
||||||
|
- [x] Add backend selection tests
|
||||||
|
- [x] Add basic memory/config regression coverage
|
||||||
|
- [x] Normalize compatible streaming tool-call assembly
|
||||||
|
- [x] Preserve file upload behavior in compatible backend
|
||||||
|
- [x] Guard unsupported OpenAI-only tools for compatible backend
|
||||||
|
- [x] Add environment docs and example config entries
|
||||||
|
- [x] Add real-server integration coverage for compatible backend
|
||||||
|
- [x] Revisit shared orchestration extraction for further deduplication
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
1. Do not change the current official OpenAI `responses.create(...)` behavior.
|
||||||
|
2. Do not auto-switch behavior only because `OPENAI_BASE_URL` is set.
|
||||||
|
3. Do not merge compatible backend quirks into the official OpenAI runner.
|
||||||
|
4. Do not remove or weaken existing tool ranking, memory, RAG, logging, or upload behavior.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
1. `src/ai/unified-ai-runner.openai.ts` currently uses the official `responses` API.
|
||||||
|
2. `src/ai/provider-adapters.ts` already has provider-specific adapters and tool/result mapping.
|
||||||
|
3. `src/ai/provider-adapter-contract.ts` already contains `responses`-style extractors.
|
||||||
|
4. `src/ai/openai-chat-message.ts` currently models `responses`-style messages, not `chat.completions` tool messages.
|
||||||
|
5. `src/ai/unified-ai-request-pipeline.ts` prepares chat context and runtime state before the model call.
|
||||||
|
6. `src/ai/ai-runtime-target.ts` resolves provider targets, base URLs, models, and keys.
|
||||||
|
7. `src/ai/unified-ai-runner.tool-ranker.ts` already uses a `chat.completions`-style call path, which is closer to compatible backends.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
1. Official OpenAI backend stays on `responses.create(...)`.
|
||||||
|
2. Compatible OpenAI backend uses `chat.completions.create(...)`.
|
||||||
|
3. Backend selection is explicit through config, for example `OPENAI_BACKEND=official|compatible`.
|
||||||
|
4. Shared preparation logic remains common.
|
||||||
|
5. Transport-specific request formatting and response parsing are split.
|
||||||
|
|
||||||
|
## Configuration Design
|
||||||
|
|
||||||
|
1. Add a new config value `OPENAI_BACKEND`.
|
||||||
|
2. Allowed values should be `official` and `compatible`.
|
||||||
|
3. Default must be `official`.
|
||||||
|
4. Keep `OPENAI_BASE_URL` as a transport setting only.
|
||||||
|
5. `OPENAI_BASE_URL` must not imply compatible mode by itself.
|
||||||
|
6. Extend environment schema and runtime config to expose this value.
|
||||||
|
7. Update env docs and example env files.
|
||||||
|
|
||||||
|
## Step 1: Config and Target Selection
|
||||||
|
|
||||||
|
1. Update `src/common/environment.ts`.
|
||||||
|
2. Add a new environment field for backend mode.
|
||||||
|
3. Add setters if the codebase uses runtime env mutation in tests.
|
||||||
|
4. Update the startup schema and runtime snapshot.
|
||||||
|
5. Add tests for default value and explicit `compatible` selection.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Official OpenAI stays unchanged by default.
|
||||||
|
- Explicit `OPENAI_BACKEND=compatible` selects the new execution path.
|
||||||
|
|
||||||
|
## Step 2: Split Runner Selection
|
||||||
|
|
||||||
|
1. Update the unified AI execution entry point.
|
||||||
|
2. Add a small backend selector for OpenAI targets.
|
||||||
|
3. Route official mode to the current runner.
|
||||||
|
4. Route compatible mode to a new compatible runner.
|
||||||
|
5. Keep other providers untouched.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- One codepath for official OpenAI.
|
||||||
|
- One codepath for OpenAI-compatible servers.
|
||||||
|
|
||||||
|
## Step 3: Shared Orchestration Extraction
|
||||||
|
|
||||||
|
1. Identify logic that is identical for both OpenAI branches.
|
||||||
|
2. Extract common orchestration into a shared helper where possible.
|
||||||
|
3. Keep these pieces shared:
|
||||||
|
- memory prompt injection
|
||||||
|
- tool ranking
|
||||||
|
- tool loop control
|
||||||
|
- logging and timing
|
||||||
|
- cancellation handling
|
||||||
|
- file upload post-processing
|
||||||
|
- document RAG preparation and cleanup
|
||||||
|
4. Keep transport-specific pieces separate:
|
||||||
|
- request shape
|
||||||
|
- response parsing
|
||||||
|
- tool result message shape
|
||||||
|
- streaming event parsing
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Less duplicate logic.
|
||||||
|
- Cleaner separation between official and compatible behavior.
|
||||||
|
|
||||||
|
## Step 4: Compatible Message Model
|
||||||
|
|
||||||
|
1. Update `src/ai/openai-chat-message.ts` or create a sibling type file for compatible chat messages.
|
||||||
|
2. Model `system`, `user`, `assistant`, and `tool` roles explicitly.
|
||||||
|
3. Support `tool_calls` on assistant messages.
|
||||||
|
4. Support `tool_call_id` on tool result messages.
|
||||||
|
5. Preserve support for text and multimodal user content where the backend supports it.
|
||||||
|
6. Avoid forcing `responses` output types into `chat.completions`.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Compatible runner can build valid `chat.completions` message arrays.
|
||||||
|
|
||||||
|
## Step 5: Compatible Contract Extractors
|
||||||
|
|
||||||
|
1. Extend `src/ai/provider-adapter-contract.ts`.
|
||||||
|
2. Add extractors for `chat.completions` tool calls.
|
||||||
|
3. Add extractors for `chat.completions` streaming tool call deltas.
|
||||||
|
4. Keep existing `responses` extractors intact.
|
||||||
|
5. Normalize tool call IDs, names, and argument text the same way as existing extractors.
|
||||||
|
6. Ensure arguments are always represented as JSON text for the tool loop.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Compatible runner can parse tool calls from both normal and streaming responses.
|
||||||
|
|
||||||
|
## Step 6: Compatible Provider Adapter
|
||||||
|
|
||||||
|
1. Update `src/ai/provider-adapters.ts`.
|
||||||
|
2. Add a separate adapter or branch for OpenAI-compatible chat.completions behavior.
|
||||||
|
3. Reuse existing tool ranking where safe.
|
||||||
|
4. Make `appendToolResults(...)` emit `role: "tool"` messages with `tool_call_id`.
|
||||||
|
5. Keep official OpenAI adapter outputting `function_call_output`.
|
||||||
|
6. Keep Mistral and Ollama unchanged.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Each backend uses the tool result shape it expects.
|
||||||
|
|
||||||
|
## Step 7: Compatible Runner Implementation
|
||||||
|
|
||||||
|
1. Create a new file such as `src/ai/unified-ai-runner.openai-compatible.ts`.
|
||||||
|
2. Use `openai.chat.completions.create(...)`.
|
||||||
|
3. Pass `messages`, `tools`, `model`, `stream`, and `signal`.
|
||||||
|
4. Map system prompt and memory prompt into the `messages` array correctly.
|
||||||
|
5. Keep the tool loop structure from the current runner.
|
||||||
|
6. Append assistant tool-call messages and tool result messages between rounds.
|
||||||
|
7. Continue until no tool calls remain or max rounds is reached.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Compatible backends can complete multi-round tool flows.
|
||||||
|
|
||||||
|
## Step 8: Tool Call Loop Semantics
|
||||||
|
|
||||||
|
1. Preserve `MAX_TOOL_ROUNDS`.
|
||||||
|
2. Preserve tool ranking before each round.
|
||||||
|
3. Preserve memory tool selection.
|
||||||
|
4. Preserve file search injection when document RAG is active.
|
||||||
|
5. Preserve file upload post-processing.
|
||||||
|
6. Preserve max-rounds warnings and continuation decisions.
|
||||||
|
7. Keep the final text visible in the stream message exactly as today.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Compatible backend behaves like the current runner from the user’s perspective.
|
||||||
|
|
||||||
|
## Step 9: Streaming Behavior
|
||||||
|
|
||||||
|
1. Implement streaming event handling for `chat.completions`.
|
||||||
|
2. Parse text deltas and append them to `TelegramStreamMessage`.
|
||||||
|
3. Parse `delta.tool_calls` and keep incremental tool-call state.
|
||||||
|
4. Update status text when tool usage starts and ends.
|
||||||
|
5. Keep image generation and file-search status handling if the backend emits compatible signals.
|
||||||
|
6. Finalize the stream only after the terminal completion event.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Streaming works without losing tool call state.
|
||||||
|
|
||||||
|
## Step 10: Tool Result Handling
|
||||||
|
|
||||||
|
1. After each tool execution round, append tool results using the compatible message format.
|
||||||
|
2. Ensure each tool result keeps the correct `tool_call_id`.
|
||||||
|
3. Preserve the existing file upload hook.
|
||||||
|
4. If upload fails, convert the failure into a tool result error string.
|
||||||
|
5. Preserve the same tool memory map behavior.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- The backend receives a valid message history for the next round.
|
||||||
|
|
||||||
|
## Step 11: Prompt and Memory Injection
|
||||||
|
|
||||||
|
1. Keep `buildSystemInstruction(...)` as the source of system prompt assembly.
|
||||||
|
2. Keep `buildUserMemoryPrompt(...)` injected as a separate block.
|
||||||
|
3. Preserve the explicit separation between assistant memory and user memory.
|
||||||
|
4. Preserve the `user.md` and `system.md` memory layout.
|
||||||
|
5. Ensure compatible backend receives the same semantic prompt content.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Memory behavior stays identical across official and compatible backends.
|
||||||
|
|
||||||
|
## Step 12: Tool Ranking Compatibility
|
||||||
|
|
||||||
|
1. Review `src/ai/unified-ai-runner.tool-ranker.ts`.
|
||||||
|
2. Verify whether the current JSON response handling is safe for compatible backends.
|
||||||
|
3. If a backend cannot guarantee strict JSON mode, add a fallback parser.
|
||||||
|
4. Keep ranking inputs and outputs consistent across both branches.
|
||||||
|
5. Do not weaken tool selection heuristics.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Tool ranking remains deterministic enough for both branches.
|
||||||
|
|
||||||
|
## Step 13: File Search and RAG
|
||||||
|
|
||||||
|
1. Keep document RAG preparation in the request pipeline.
|
||||||
|
2. Keep vector store preparation for official OpenAI.
|
||||||
|
3. Decide whether compatible backend supports file search or needs a no-op fallback.
|
||||||
|
4. If unsupported, guard the tool list so the compatible backend never receives unsupported tools.
|
||||||
|
5. Keep cleanup behavior for temporary artifacts.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Compatible backend does not receive tools it cannot execute.
|
||||||
|
|
||||||
|
## Step 14: Error Handling
|
||||||
|
|
||||||
|
1. Preserve abort handling.
|
||||||
|
2. Preserve response failure handling.
|
||||||
|
3. Preserve stream error handling.
|
||||||
|
4. Surface backend-specific incompatibilities as explicit errors.
|
||||||
|
5. Do not silently fall back from compatible to official mode.
|
||||||
|
6. Keep logs actionable.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Failures are obvious and debuggable.
|
||||||
|
|
||||||
|
## Step 15: Logging and Observability
|
||||||
|
|
||||||
|
1. Keep the current AI logs and duration tracking.
|
||||||
|
2. Add backend mode to log metadata.
|
||||||
|
3. Log tool calls, tool outputs, and round transitions in both branches.
|
||||||
|
4. Preserve existing observability hooks.
|
||||||
|
5. Add explicit labels for official vs compatible runs.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Debugging remains easy after the split.
|
||||||
|
|
||||||
|
## Step 16: Tests
|
||||||
|
|
||||||
|
1. Add unit tests for backend selection.
|
||||||
|
2. Add unit tests for compatible message conversion.
|
||||||
|
3. Add unit tests for compatible tool call extraction.
|
||||||
|
4. Add integration tests for a tool-call round trip using mocked `chat.completions`.
|
||||||
|
5. Add tests proving the official `responses` path is unchanged.
|
||||||
|
6. Add tests for streaming tool call parsing if the backend supports it.
|
||||||
|
7. Add tests for fallback behavior in the tool ranker if needed.
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
- Both branches are covered and regressions are visible quickly.
|
||||||
|
|
||||||
|
## Step 17: Suggested File Changes
|
||||||
|
|
||||||
|
1. `src/common/environment.ts`
|
||||||
|
2. `src/ai/ai-runtime-target.ts`
|
||||||
|
3. `src/ai/unified-ai-request-pipeline.ts`
|
||||||
|
4. `src/ai/unified-ai-runner.openai.ts`
|
||||||
|
5. `src/ai/unified-ai-runner.openai-compatible.ts`
|
||||||
|
6. `src/ai/provider-adapter-contract.ts`
|
||||||
|
7. `src/ai/provider-adapters.ts`
|
||||||
|
8. `src/ai/openai-chat-message.ts`
|
||||||
|
9. `src/ai/unified-ai-runner.tool-ranker.ts`
|
||||||
|
10. `test/*.test.mjs`
|
||||||
|
11. `.env.example`
|
||||||
|
12. Documentation files for backend selection
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. [x] Add config flag and wire it through environment parsing.
|
||||||
|
2. [x] Add backend selection logic.
|
||||||
|
3. [x] Add compatible message and extractor support.
|
||||||
|
4. [x] Create the compatible runner.
|
||||||
|
5. [x] Reuse shared orchestration where possible.
|
||||||
|
6. [x] Wire tests.
|
||||||
|
7. [x] Verify official behavior is unchanged.
|
||||||
|
8. [x] Verify compatible backend works with a real OpenAI-compatible server.
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
1. Run unit tests.
|
||||||
|
2. Run integration tests.
|
||||||
|
3. Verify official OpenAI path still uses `responses.create(...)`.
|
||||||
|
4. Verify compatible path uses `chat.completions.create(...)`.
|
||||||
|
5. Verify a `llama.cpp`-style server can complete a tool loop.
|
||||||
|
6. Verify memory tools still work.
|
||||||
|
7. Verify document RAG and file upload behavior do not regress.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
1. Some OpenAI-compatible servers do not support every official OpenAI feature.
|
||||||
|
2. Streaming tool call deltas may differ across providers.
|
||||||
|
3. JSON-mode assumptions in the ranker may not hold for all compatible servers.
|
||||||
|
4. Tool schema filtering may need backend-specific allowlists.
|
||||||
|
5. Message conversion mistakes can break tool loops silently if not tested.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. Official OpenAI behavior is unchanged.
|
||||||
|
2. Compatible backend can run a full chat loop with tools.
|
||||||
|
3. Tool calls are correctly extracted and executed.
|
||||||
|
4. Tool results are appended in the correct format.
|
||||||
|
5. Memory injection still works.
|
||||||
|
6. Document RAG and file upload behavior remain functional or fail explicitly.
|
||||||
|
7. Tests cover both branches.
|
||||||
|
|
||||||
|
## Final Note
|
||||||
|
|
||||||
|
The key design rule is simple: keep official OpenAI `responses` behavior intact, and introduce OpenAI-compatible `chat.completions` behavior as a separate backend mode with its own parsing and message shape.
|
||||||
@@ -1,12 +1,50 @@
|
|||||||
# Telegram Chat Bot
|
# Telegram Chat Bot
|
||||||
|
|
||||||
Bot for Telegram with a lot of commands and AI (Ollama/Gemini/Mistral) written in TypeScript + NodeJS/Bun runtime + Drizzle ORM (SQLite DB)
|
Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written in TypeScript + NodeJS/Bun runtime + SQLite/PostgreSQL/in-memory storage
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (GEMINI_API_KEY, MISTRAL_API_KEY, OLLAMA_ADDRESS)
|
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (MISTRAL_API_KEY, OPENAI_API_KEY, OLLAMA_ADDRESS)
|
||||||
|
# For OpenAI-compatible servers (llama.cpp, etc.), set OPENAI_BACKEND=compatible and OPENAI_BASE_URL.
|
||||||
|
# Optional: set DATABASE_URL to postgres://... for PostgreSQL or :memory: for ephemeral SQLite.
|
||||||
|
# Optional: set DATA_PATH if you want to override the default local storage directory.
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Bun (Recommended):**
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run build && bun start
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Node.js:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The bot initializes and migrates its database schema automatically on startup.
|
||||||
|
`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup.
|
||||||
|
`/importdb` restores the database from the JSON backup format.
|
||||||
|
|
||||||
|
MCP tool servers can be configured through `MCP_SERVERS` in `.env`. Use a JSON array with `stdio` or `http` transports. Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to disable all built-in local tools and use only MCP tools, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DISABLE_LOCAL_TOOLS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want a partial filter instead, use tool names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
|
||||||
|
LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
|
||||||
```
|
```
|
||||||
|
|
||||||
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
|
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
|
||||||
@@ -16,24 +54,19 @@ ollama pull nomic-embed-text
|
|||||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
```
|
```
|
||||||
|
|
||||||
**With Bun (Recommended):**
|
Tool ranker fallback is configurable via `TOOL_RANKER_FALLBACK_POLICY`:
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
bunx drizzle-kit generate && bunx drizzle-kit migrate
|
|
||||||
bun run build && bun start
|
|
||||||
```
|
|
||||||
|
|
||||||
**With Node.js:**
|
- `MAIN_MODEL` - use the provider's main chat model to rank tools if a dedicated ranker target is missing or fails
|
||||||
```bash
|
- `ALL_TOOLS` - skip tool ranking fallback and allow all tools
|
||||||
npm install
|
- `NO_TOOLS` - skip tool ranking fallback and allow no tools
|
||||||
npx drizzle-kit generate && npx drizzle-kit migrate
|
|
||||||
npm run build && npm start
|
The default is `ALL_TOOLS`.
|
||||||
```
|
|
||||||
|
|
||||||
**With Docker Compose:**
|
**With Docker Compose:**
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
Set `IMAGE_TAG` in `.env` if you want to override the pinned release tag used by `docker-compose.yml`.
|
||||||
|
|
||||||
**With Docker:**
|
**With Docker:**
|
||||||
```bash
|
```bash
|
||||||
@@ -49,13 +82,13 @@ docker run -d --env-file .env -v $(pwd)/data:/config/data tg-bot-bun
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js >= 20 OR Bun >= 1.0
|
- Node.js >= 20.19 OR Bun >= 1.0
|
||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- AI chat (Gemini, Mistral, Ollama)
|
- AI chat (Mistral, Ollama, OpenAI)
|
||||||
- Local document RAG for Ollama without third-party providers
|
- Local document RAG for Ollama without third-party providers
|
||||||
- Custom answers and commands
|
- Custom answers and commands
|
||||||
- Admin management
|
- Admin management
|
||||||
|
|||||||
@@ -5,17 +5,17 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "tg-chat-bot",
|
"name": "tg-chat-bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^2.0.0",
|
|
||||||
"@libsql/client": "^0.17.3",
|
"@libsql/client": "^0.17.3",
|
||||||
"@mistralai/mistralai": "^2.2.1",
|
"@mistralai/mistralai": "^2.2.1",
|
||||||
"@napi-rs/canvas": "^1.0.0",
|
"@napi-rs/canvas": "^1.0.0",
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.38.0",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"systeminformation": "^5.31.6",
|
"systeminformation": "^5.31.6",
|
||||||
@@ -24,77 +24,48 @@
|
|||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.13",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.6.1",
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"drizzle-kit": "^0.31.10",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.59.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
|
||||||
|
|
||||||
"@google/genai": ["@google/genai@2.0.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-6XpO+YbGutXkm5QgR7NZktISxSz0dw3pSs9NtCUQwvhJc1eyA3KhdKhE/0Uaxp3a6eul3LC0SKau1bXymjOKUg=="],
|
|
||||||
|
|
||||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||||
|
|
||||||
@@ -146,8 +117,6 @@
|
|||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
|
||||||
|
|
||||||
"@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="],
|
"@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="],
|
||||||
|
|
||||||
"@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="],
|
"@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="],
|
||||||
@@ -202,70 +171,76 @@
|
|||||||
|
|
||||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
|
||||||
|
|
||||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
|
||||||
|
|
||||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
|
||||||
|
|
||||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
|
||||||
|
|
||||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
|
||||||
|
|
||||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
|
||||||
|
|
||||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
|
||||||
|
|
||||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
|
||||||
|
|
||||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
|
||||||
|
|
||||||
"@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="],
|
"@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.6.1", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||||
|
|
||||||
|
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
||||||
|
|
||||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||||
|
|
||||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
|
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
|
||||||
|
|
||||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha1-x57Zf380y48robyXkLzDZkdLS3k="],
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha1-x57Zf380y48robyXkLzDZkdLS3k="],
|
||||||
|
|
||||||
"axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
|
"axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
|
||||||
|
|
||||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -274,14 +249,16 @@
|
|||||||
|
|
||||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||||
|
|
||||||
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -290,16 +267,10 @@
|
|||||||
|
|
||||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
|
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
|
||||||
|
|
||||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
@@ -310,53 +281,65 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
|
|
||||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|
||||||
|
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
|
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||||
|
|
||||||
|
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
|
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
|
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||||
|
|
||||||
|
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||||
|
|
||||||
"fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="],
|
"fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="],
|
||||||
|
|
||||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||||
|
|
||||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
|
||||||
|
|
||||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||||
|
|
||||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
|
||||||
|
|
||||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
|
||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
|
|
||||||
|
|
||||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
|
||||||
|
|
||||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
@@ -364,31 +347,43 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
||||||
|
|
||||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||||
|
|
||||||
"jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="],
|
"jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="],
|
||||||
|
|
||||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
|
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
|
||||||
|
|
||||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
@@ -396,57 +391,73 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@2.1.29", "", { "dependencies": { "mime-db": "1.46.0" } }, "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ=="],
|
"mime-types": ["mime-types@2.1.29", "", { "dependencies": { "mime-db": "1.46.0" } }, "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
|
||||||
|
|
||||||
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
|
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
|
||||||
|
|
||||||
"openai": ["openai@6.37.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ=="],
|
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
|
||||||
|
|
||||||
|
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
|
||||||
|
|
||||||
|
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
|
||||||
|
|
||||||
|
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
|
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
|
||||||
|
|
||||||
|
"pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
|
||||||
|
|
||||||
|
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||||
|
|
||||||
|
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||||
|
|
||||||
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
|
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||||
|
|
||||||
|
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||||
|
|
||||||
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
||||||
|
|
||||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||||
|
|
||||||
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||||
|
|
||||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
|
||||||
|
|
||||||
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
|
|
||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
|
||||||
|
|
||||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
@@ -458,39 +469,41 @@
|
|||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
|
||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
"systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
|
"systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"twemoji": ["twemoji@14.0.2", "", { "dependencies": { "fs-extra": "^8.0.1", "jsonfile": "^5.0.0", "twemoji-parser": "14.0.0", "universalify": "^0.1.2" } }, "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA=="],
|
"twemoji": ["twemoji@14.0.2", "", { "dependencies": { "fs-extra": "^8.0.1", "jsonfile": "^5.0.0", "twemoji-parser": "14.0.0", "universalify": "^0.1.2" } }, "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA=="],
|
||||||
|
|
||||||
"twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="],
|
"twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="],
|
||||||
|
|
||||||
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
|
"typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="],
|
||||||
|
|
||||||
"typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="],
|
"typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|
||||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
|
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
|
||||||
|
|
||||||
@@ -498,29 +511,27 @@
|
|||||||
|
|
||||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
|
||||||
|
|
||||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||||
|
|
||||||
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
|
|
||||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||||
|
|
||||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
|
||||||
|
|
||||||
"@libsql/isomorphic-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
"@libsql/isomorphic-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||||
|
|
||||||
@@ -528,11 +539,19 @@
|
|||||||
|
|
||||||
"@types/fluent-ffmpeg/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
"@types/fluent-ffmpeg/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||||
|
|
||||||
|
"@types/pg/@types/node": ["@types/node@25.6.1", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g=="],
|
||||||
|
|
||||||
"@types/qrcode/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
"@types/qrcode/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||||
|
|
||||||
"@types/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
"@types/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
@@ -540,124 +559,30 @@
|
|||||||
|
|
||||||
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||||
|
|
||||||
"protobufjs/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
|
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
|
||||||
|
|
||||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"typescript-telegram-bot-api/axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
|
||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
"tsx/get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
|
||||||
|
|
||||||
"@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"@types/pg/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||||
|
|
||||||
"@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
tgchatbot:
|
tgchatbot:
|
||||||
container_name: tgchatbot
|
container_name: tgchatbot
|
||||||
image: ghcr.io/melod1n/tg-chat-bot:latest
|
image: ghcr.io/melod1n/tg-chat-bot:${IMAGE_TAG:-1.0.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import {defineConfig} from "drizzle-kit";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const dataPath = process.env.DATA_PATH
|
|
||||||
?? (process.env.IS_DOCKER === "true" ? "/" + path.join("config", "data") : "data");
|
|
||||||
const dbFileName = process.env.DB_FILE_NAME ?? "database.db";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
out: "./drizzle",
|
|
||||||
schema: "./src/db/schema.ts",
|
|
||||||
dialect: "sqlite",
|
|
||||||
dbCredentials: {
|
|
||||||
url: process.env.DB_FILE_NAME
|
|
||||||
// url: process.env.DB_PATH ? "file:" + process.env.DB_PATH : "file:" + path.join(dataPath, dbFileName),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+26
-15
@@ -1,31 +1,42 @@
|
|||||||
const tsParser = require("@typescript-eslint/parser");
|
import js from "@eslint/js";
|
||||||
const tsPlugin = require("@typescript-eslint/eslint-plugin");
|
import {defineConfig} from "eslint/config";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
module.exports = [
|
export default defineConfig(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"dist/**",
|
"dist/**",
|
||||||
|
"data/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
|
"**/*.tsbuildinfo",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
files: ["**/*.ts"],
|
files: ["src/**/*.ts"],
|
||||||
languageOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
linterOptions: {
|
linterOptions: {
|
||||||
reportUnusedDisableDirectives: "off",
|
reportUnusedDisableDirectives: "off",
|
||||||
},
|
},
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": tsPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
|
"no-console": "error",
|
||||||
|
"no-control-regex": "off",
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"no-extra-boolean-cast": "off",
|
||||||
|
"quotes": ["error", "double", {avoidEscape: true}],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"prefer-const": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"quotes": "warn",
|
|
||||||
"semi": "error",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
files: ["src/logging/logger.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
+16
-4
@@ -8,6 +8,13 @@
|
|||||||
},
|
},
|
||||||
"providerChoice.default": "Default",
|
"providerChoice.default": "Default",
|
||||||
"errorText": "⚠️ An error occurred.",
|
"errorText": "⚠️ An error occurred.",
|
||||||
|
"pipelineFallback.generic": "⚠️ I had to skip part of the request, but I can continue.",
|
||||||
|
"pipelineFallback.notifyUser": "⚠️ I hit a problem and need to continue with a fallback.",
|
||||||
|
"pipelineFallback.failRequest": "⚠️ I could not finish this request.",
|
||||||
|
"pipelineFallback.documentRag": "⚠️ Document retrieval failed, so I will answer without RAG.",
|
||||||
|
"pipelineFallback.speechToText": "⚠️ Speech transcription failed, so I will continue without the audio transcript.",
|
||||||
|
"pipelineFallback.textToSpeech": "⚠️ Text-to-speech failed, so I will continue without audio output.",
|
||||||
|
"pipelineFallback.toolLoop": "⚠️ Tool execution failed, so I will continue without that tool.",
|
||||||
"waitThinkText": "⏳ Let me think...",
|
"waitThinkText": "⏳ Let me think...",
|
||||||
"analyzingPictureText": "🔍 Analyzing the image...",
|
"analyzingPictureText": "🔍 Analyzing the image...",
|
||||||
"analyzingPicturesText": "🔍 Analyzing the images...",
|
"analyzingPicturesText": "🔍 Analyzing the images...",
|
||||||
@@ -62,18 +69,21 @@
|
|||||||
"userSettingsResponseLanguageSelectionTitle": "Response Language Selection",
|
"userSettingsResponseLanguageSelectionTitle": "Response Language Selection",
|
||||||
"userSettingsContextSizeSelectionTitle": "Context Size Selection",
|
"userSettingsContextSizeSelectionTitle": "Context Size Selection",
|
||||||
"userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection",
|
"userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection",
|
||||||
|
"userSettingsImageOutputSelectionTitle": "Image Output Mode Selection",
|
||||||
"userSettingsTierLabel": "Tier",
|
"userSettingsTierLabel": "Tier",
|
||||||
"userSettingsAiProviderLabel": "AI provider",
|
"userSettingsAiProviderLabel": "AI provider",
|
||||||
"userSettingsInterfaceLanguageLabel": "Interface language",
|
"userSettingsInterfaceLanguageLabel": "Interface language",
|
||||||
"userSettingsResponseLanguageLabel": "LLM response language",
|
"userSettingsResponseLanguageLabel": "LLM response language",
|
||||||
"userSettingsContextSizeLabel": "Context size",
|
"userSettingsContextSizeLabel": "Context size",
|
||||||
"userSettingsVoiceModeLabel": "Voice messages",
|
"userSettingsVoiceModeLabel": "Voice messages",
|
||||||
|
"userSettingsImageOutputLabel": "Image output",
|
||||||
"userSettingsBackButtonText": "Back",
|
"userSettingsBackButtonText": "Back",
|
||||||
"userSettingsAiProviderButtonPrefix": "AI provider",
|
"userSettingsAiProviderButtonPrefix": "AI provider",
|
||||||
"userSettingsInterfaceLanguageButtonPrefix": "Interface language",
|
"userSettingsInterfaceLanguageButtonPrefix": "Interface language",
|
||||||
"userSettingsResponseLanguageButtonPrefix": "Response language",
|
"userSettingsResponseLanguageButtonPrefix": "Response language",
|
||||||
"userSettingsContextSizeButtonPrefix": "Context",
|
"userSettingsContextSizeButtonPrefix": "Context",
|
||||||
"userSettingsVoiceModeButtonPrefix": "Voice",
|
"userSettingsVoiceModeButtonPrefix": "Voice",
|
||||||
|
"userSettingsImageOutputButtonPrefix": "Image output",
|
||||||
"userSettingsCreatorTierText": "Creator",
|
"userSettingsCreatorTierText": "Creator",
|
||||||
"userSettingsAdminTierText": "Admin",
|
"userSettingsAdminTierText": "Admin",
|
||||||
"userSettingsUserTierText": "User",
|
"userSettingsUserTierText": "User",
|
||||||
@@ -81,6 +91,8 @@
|
|||||||
"userSettingsContextSizeDefaultText": "Default",
|
"userSettingsContextSizeDefaultText": "Default",
|
||||||
"userSettingsVoiceModeExecuteText": "Run through AI",
|
"userSettingsVoiceModeExecuteText": "Run through AI",
|
||||||
"userSettingsVoiceModeTranscriptText": "Show transcript only",
|
"userSettingsVoiceModeTranscriptText": "Show transcript only",
|
||||||
|
"userSettingsImageOutputPhotoText": "As photo",
|
||||||
|
"userSettingsImageOutputDocumentText": "As document",
|
||||||
"startingImageGenText": "🌈 Starting image generation...",
|
"startingImageGenText": "🌈 Starting image generation...",
|
||||||
"imageGenText": "🌈 Generating image...",
|
"imageGenText": "🌈 Generating image...",
|
||||||
"finalizingImageGenText": "🌈 Finalizing image generation...",
|
"finalizingImageGenText": "🌈 Finalizing image generation...",
|
||||||
@@ -136,6 +148,7 @@
|
|||||||
"getPreparingRAGText.default": "🔍 Preparing RAG for the document...",
|
"getPreparingRAGText.default": "🔍 Preparing RAG for the document...",
|
||||||
"getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`",
|
"getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`",
|
||||||
"getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}",
|
"getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}",
|
||||||
|
"getSelectingToolsText": "🧩 Choosing the right tools...",
|
||||||
"getBuildingRAGIndexText.default": "🧠 Building RAG index...",
|
"getBuildingRAGIndexText.default": "🧠 Building RAG index...",
|
||||||
"getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.",
|
"getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.",
|
||||||
"queueNoneText": "none",
|
"queueNoneText": "none",
|
||||||
@@ -170,6 +183,9 @@
|
|||||||
"getWhenPluralUnitText": "{unit}s",
|
"getWhenPluralUnitText": "{unit}s",
|
||||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||||
"commandDescriptions": {
|
"commandDescriptions": {
|
||||||
|
"aiAudit": "Inspect AI request audit and artifacts",
|
||||||
|
"aiMetrics": "Show AI observability counters",
|
||||||
|
"aiRequests": "Show recent AI requests",
|
||||||
"ae": "evaluation",
|
"ae": "evaluation",
|
||||||
"adminsAdd": "Add user to admins",
|
"adminsAdd": "Add user to admins",
|
||||||
"adminsRemove": "Remove user from admins",
|
"adminsRemove": "Remove user from admins",
|
||||||
@@ -179,10 +195,6 @@
|
|||||||
"debug": "Returns msg (or reply) as json",
|
"debug": "Returns msg (or reply) as json",
|
||||||
"dice": "Sends random or specific dice",
|
"dice": "Sends random or specific dice",
|
||||||
"distort": "Distortion of picture",
|
"distort": "Distortion of picture",
|
||||||
"geminiChat": "Chat with AI (Gemini)",
|
|
||||||
"geminiGetModel": "Get current Gemini model",
|
|
||||||
"geminiListModels": "List all Gemini models",
|
|
||||||
"geminiSetModel": "Set Gemini model",
|
|
||||||
"help": "Show list of commands",
|
"help": "Show list of commands",
|
||||||
"id": "ID of chat, user and reply (if replied to any message)",
|
"id": "ID of chat, user and reply (if replied to any message)",
|
||||||
"ignore": "Bot will ignore user",
|
"ignore": "Bot will ignore user",
|
||||||
|
|||||||
+16
-4
@@ -8,6 +8,13 @@
|
|||||||
},
|
},
|
||||||
"providerChoice.default": "По умолчанию",
|
"providerChoice.default": "По умолчанию",
|
||||||
"errorText": "⚠️ Произошла ошибка.",
|
"errorText": "⚠️ Произошла ошибка.",
|
||||||
|
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
|
||||||
|
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
|
||||||
|
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
|
||||||
|
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
|
||||||
|
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
|
||||||
|
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
|
||||||
|
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
|
||||||
"waitThinkText": "⏳ Дайте-ка подумать...",
|
"waitThinkText": "⏳ Дайте-ка подумать...",
|
||||||
"analyzingPictureText": "🔍 Анализирую изображение...",
|
"analyzingPictureText": "🔍 Анализирую изображение...",
|
||||||
"analyzingPicturesText": "🔍 Анализирую изображения...",
|
"analyzingPicturesText": "🔍 Анализирую изображения...",
|
||||||
@@ -88,18 +95,21 @@
|
|||||||
"userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов",
|
"userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов",
|
||||||
"userSettingsContextSizeSelectionTitle": "Выбор размера контекста",
|
"userSettingsContextSizeSelectionTitle": "Выбор размера контекста",
|
||||||
"userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений",
|
"userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений",
|
||||||
|
"userSettingsImageOutputSelectionTitle": "Режим отправки изображений",
|
||||||
"userSettingsTierLabel": "Уровень",
|
"userSettingsTierLabel": "Уровень",
|
||||||
"userSettingsAiProviderLabel": "AI-провайдер",
|
"userSettingsAiProviderLabel": "AI-провайдер",
|
||||||
"userSettingsInterfaceLanguageLabel": "Язык интерфейса",
|
"userSettingsInterfaceLanguageLabel": "Язык интерфейса",
|
||||||
"userSettingsResponseLanguageLabel": "Язык ответов LLM",
|
"userSettingsResponseLanguageLabel": "Язык ответов LLM",
|
||||||
"userSettingsContextSizeLabel": "Размер контекста",
|
"userSettingsContextSizeLabel": "Размер контекста",
|
||||||
"userSettingsVoiceModeLabel": "Голосовые сообщения",
|
"userSettingsVoiceModeLabel": "Голосовые сообщения",
|
||||||
|
"userSettingsImageOutputLabel": "Изображения",
|
||||||
"userSettingsBackButtonText": "Назад",
|
"userSettingsBackButtonText": "Назад",
|
||||||
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
|
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
|
||||||
"userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса",
|
"userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса",
|
||||||
"userSettingsResponseLanguageButtonPrefix": "Язык ответов",
|
"userSettingsResponseLanguageButtonPrefix": "Язык ответов",
|
||||||
"userSettingsContextSizeButtonPrefix": "Контекст",
|
"userSettingsContextSizeButtonPrefix": "Контекст",
|
||||||
"userSettingsVoiceModeButtonPrefix": "Голосовые",
|
"userSettingsVoiceModeButtonPrefix": "Голосовые",
|
||||||
|
"userSettingsImageOutputButtonPrefix": "Изображения",
|
||||||
"userSettingsCreatorTierText": "Создатель",
|
"userSettingsCreatorTierText": "Создатель",
|
||||||
"userSettingsAdminTierText": "Админ",
|
"userSettingsAdminTierText": "Админ",
|
||||||
"userSettingsUserTierText": "Пользователь",
|
"userSettingsUserTierText": "Пользователь",
|
||||||
@@ -107,6 +117,8 @@
|
|||||||
"userSettingsContextSizeDefaultText": "По умолчанию",
|
"userSettingsContextSizeDefaultText": "По умолчанию",
|
||||||
"userSettingsVoiceModeExecuteText": "Выполнять через ИИ",
|
"userSettingsVoiceModeExecuteText": "Выполнять через ИИ",
|
||||||
"userSettingsVoiceModeTranscriptText": "Только расшифровка",
|
"userSettingsVoiceModeTranscriptText": "Только расшифровка",
|
||||||
|
"userSettingsImageOutputPhotoText": "Как фото",
|
||||||
|
"userSettingsImageOutputDocumentText": "Как документ",
|
||||||
"startingImageGenText": "🌈 Запускаю генерацию изображения...",
|
"startingImageGenText": "🌈 Запускаю генерацию изображения...",
|
||||||
"imageGenText": "🌈 Генерирую изображение...",
|
"imageGenText": "🌈 Генерирую изображение...",
|
||||||
"finalizingImageGenText": "🌈 Завершаю генерацию изображения...",
|
"finalizingImageGenText": "🌈 Завершаю генерацию изображения...",
|
||||||
@@ -162,6 +174,7 @@
|
|||||||
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
|
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
|
||||||
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
|
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
|
||||||
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
|
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
|
||||||
|
"getSelectingToolsText": "🧩 Выбираю подходящие инструменты...",
|
||||||
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
|
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
|
||||||
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
|
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
|
||||||
"queueNoneText": "нет",
|
"queueNoneText": "нет",
|
||||||
@@ -196,6 +209,9 @@
|
|||||||
"getWhenPluralUnitText": "{unit}",
|
"getWhenPluralUnitText": "{unit}",
|
||||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||||
"commandDescriptions": {
|
"commandDescriptions": {
|
||||||
|
"aiRequests": "Показать последние AI-запросы",
|
||||||
|
"aiAudit": "Показать аудит AI-запроса и артефакты",
|
||||||
|
"aiMetrics": "Показать счётчики AI-обсервабилити",
|
||||||
"ae": "вычисление",
|
"ae": "вычисление",
|
||||||
"adminsAdd": "Добавить пользователя в администраторы",
|
"adminsAdd": "Добавить пользователя в администраторы",
|
||||||
"adminsRemove": "Удалить пользователя из администраторов",
|
"adminsRemove": "Удалить пользователя из администраторов",
|
||||||
@@ -205,10 +221,6 @@
|
|||||||
"debug": "Вернуть msg или reply в JSON",
|
"debug": "Вернуть msg или reply в JSON",
|
||||||
"dice": "Отправить случайный или конкретный дайс",
|
"dice": "Отправить случайный или конкретный дайс",
|
||||||
"distort": "Искажение изображения",
|
"distort": "Искажение изображения",
|
||||||
"geminiChat": "Чат с AI (Gemini)",
|
|
||||||
"geminiGetModel": "Показать текущую модель Gemini",
|
|
||||||
"geminiListModels": "Показать все модели Gemini",
|
|
||||||
"geminiSetModel": "Установить модель Gemini",
|
|
||||||
"help": "Показать список команд",
|
"help": "Показать список команд",
|
||||||
"id": "ID чата, пользователя и ответа",
|
"id": "ID чата, пользователя и ответа",
|
||||||
"ignore": "Бот будет игнорировать пользователя",
|
"ignore": "Бот будет игнорировать пользователя",
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
},
|
},
|
||||||
"providerChoice.default": "За замовчуванням",
|
"providerChoice.default": "За замовчуванням",
|
||||||
"errorText": "⚠️ Сталася помилка.",
|
"errorText": "⚠️ Сталася помилка.",
|
||||||
|
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
|
||||||
|
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
|
||||||
|
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
|
||||||
|
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
|
||||||
|
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
|
||||||
|
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
|
||||||
|
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
|
||||||
"waitThinkText": "⏳ Дайте-но подумати...",
|
"waitThinkText": "⏳ Дайте-но подумати...",
|
||||||
"analyzingPictureText": "🔍 Аналізую зображення...",
|
"analyzingPictureText": "🔍 Аналізую зображення...",
|
||||||
"analyzingPicturesText": "🔍 Аналізую зображення...",
|
"analyzingPicturesText": "🔍 Аналізую зображення...",
|
||||||
@@ -87,18 +94,21 @@
|
|||||||
"userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей",
|
"userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей",
|
||||||
"userSettingsContextSizeSelectionTitle": "Вибір розміру контексту",
|
"userSettingsContextSizeSelectionTitle": "Вибір розміру контексту",
|
||||||
"userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень",
|
"userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень",
|
||||||
|
"userSettingsImageOutputSelectionTitle": "Режим надсилання зображень",
|
||||||
"userSettingsTierLabel": "Рівень",
|
"userSettingsTierLabel": "Рівень",
|
||||||
"userSettingsAiProviderLabel": "AI-провайдер",
|
"userSettingsAiProviderLabel": "AI-провайдер",
|
||||||
"userSettingsInterfaceLanguageLabel": "Мова інтерфейсу",
|
"userSettingsInterfaceLanguageLabel": "Мова інтерфейсу",
|
||||||
"userSettingsResponseLanguageLabel": "Мова відповідей LLM",
|
"userSettingsResponseLanguageLabel": "Мова відповідей LLM",
|
||||||
"userSettingsContextSizeLabel": "Розмір контексту",
|
"userSettingsContextSizeLabel": "Розмір контексту",
|
||||||
"userSettingsVoiceModeLabel": "Голосові повідомлення",
|
"userSettingsVoiceModeLabel": "Голосові повідомлення",
|
||||||
|
"userSettingsImageOutputLabel": "Зображення",
|
||||||
"userSettingsBackButtonText": "Назад",
|
"userSettingsBackButtonText": "Назад",
|
||||||
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
|
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
|
||||||
"userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу",
|
"userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу",
|
||||||
"userSettingsResponseLanguageButtonPrefix": "Мова відповідей",
|
"userSettingsResponseLanguageButtonPrefix": "Мова відповідей",
|
||||||
"userSettingsContextSizeButtonPrefix": "Контекст",
|
"userSettingsContextSizeButtonPrefix": "Контекст",
|
||||||
"userSettingsVoiceModeButtonPrefix": "Голосові",
|
"userSettingsVoiceModeButtonPrefix": "Голосові",
|
||||||
|
"userSettingsImageOutputButtonPrefix": "Зображення",
|
||||||
"userSettingsCreatorTierText": "Творець",
|
"userSettingsCreatorTierText": "Творець",
|
||||||
"userSettingsAdminTierText": "Адмін",
|
"userSettingsAdminTierText": "Адмін",
|
||||||
"userSettingsUserTierText": "Користувач",
|
"userSettingsUserTierText": "Користувач",
|
||||||
@@ -106,6 +116,8 @@
|
|||||||
"userSettingsContextSizeDefaultText": "За замовчуванням",
|
"userSettingsContextSizeDefaultText": "За замовчуванням",
|
||||||
"userSettingsVoiceModeExecuteText": "Виконувати через AI",
|
"userSettingsVoiceModeExecuteText": "Виконувати через AI",
|
||||||
"userSettingsVoiceModeTranscriptText": "Лише розшифровка",
|
"userSettingsVoiceModeTranscriptText": "Лише розшифровка",
|
||||||
|
"userSettingsImageOutputPhotoText": "Як фото",
|
||||||
|
"userSettingsImageOutputDocumentText": "Як документ",
|
||||||
"startingImageGenText": "🌈 Запускаю генерацію зображення...",
|
"startingImageGenText": "🌈 Запускаю генерацію зображення...",
|
||||||
"imageGenText": "🌈 Генерую зображення...",
|
"imageGenText": "🌈 Генерую зображення...",
|
||||||
"finalizingImageGenText": "🌈 Завершую генерацію зображення...",
|
"finalizingImageGenText": "🌈 Завершую генерацію зображення...",
|
||||||
@@ -161,6 +173,7 @@
|
|||||||
"getPreparingRAGText.default": "🔍 Готую RAG для документа...",
|
"getPreparingRAGText.default": "🔍 Готую RAG для документа...",
|
||||||
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
|
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
|
||||||
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
|
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
|
||||||
|
"getSelectingToolsText": "🧩 Вибираю підхожі інструменти...",
|
||||||
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
|
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
|
||||||
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
|
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
|
||||||
"queueNoneText": "немає",
|
"queueNoneText": "немає",
|
||||||
@@ -195,6 +208,9 @@
|
|||||||
"getWhenPluralUnitText": "{unit}",
|
"getWhenPluralUnitText": "{unit}",
|
||||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||||
"commandDescriptions": {
|
"commandDescriptions": {
|
||||||
|
"aiRequests": "Показати останні AI-запити",
|
||||||
|
"aiAudit": "Показати аудит AI-запиту та артефакти",
|
||||||
|
"aiMetrics": "Показати лічильники AI-спостережуваності",
|
||||||
"help": "Показати список команд",
|
"help": "Показати список команд",
|
||||||
"settings": "Налаштування користувача",
|
"settings": "Налаштування користувача",
|
||||||
"start": "Запустити бота",
|
"start": "Запустити бота",
|
||||||
|
|||||||
Generated
+1495
-1872
File diff suppressed because it is too large
Load Diff
+18
-11
@@ -4,35 +4,42 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsgo -p tsconfig.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"test": "npm run build && node --test test/*.test.mjs",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"bun:start": "bun run src/index.ts"
|
"bun:start": "bun run src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^2.0.0",
|
|
||||||
"@mistralai/mistralai": "^2.2.1",
|
|
||||||
"openai": "^6.37.0",
|
|
||||||
"ollama": "^0.6.3",
|
|
||||||
"typescript-telegram-bot-api": "^0.16.0",
|
|
||||||
"@libsql/client": "^0.17.3",
|
"@libsql/client": "^0.17.3",
|
||||||
|
"@mistralai/mistralai": "^2.2.1",
|
||||||
"@napi-rs/canvas": "^1.0.0",
|
"@napi-rs/canvas": "^1.0.0",
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"ollama": "^0.6.3",
|
||||||
|
"openai": "^6.38.0",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"systeminformation": "^5.31.6",
|
"systeminformation": "^5.31.6",
|
||||||
"twemoji": "^14.0.2",
|
"twemoji": "^14.0.2",
|
||||||
|
"typescript-telegram-bot-api": "^0.16.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.13",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.6.1",
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"drizzle-kit": "^0.31.10",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.59.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-48
@@ -1,13 +1,12 @@
|
|||||||
import {Mistral} from "@mistralai/mistralai";
|
import {Mistral} from "@mistralai/mistralai";
|
||||||
import {GoogleGenAI} from "@google/genai";
|
|
||||||
import {Ollama} from "ollama";
|
import {Ollama} from "ollama";
|
||||||
import {OpenAI} from "openai";
|
import {OpenAI} from "openai";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment.js";
|
||||||
import {AiModelCapabilities} from "../model/ai-model-capabilities";
|
import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
|
||||||
export type AiCapabilityName = keyof AiModelCapabilities;
|
export type AiCapabilityName = keyof AiModelCapabilities;
|
||||||
export type AiRuntimePurpose = AiCapabilityName | "chat";
|
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
|
||||||
|
|
||||||
export type AiRuntimeTarget = {
|
export type AiRuntimeTarget = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
@@ -15,12 +14,9 @@ export type AiRuntimeTarget = {
|
|||||||
model: string;
|
model: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
systemPromptAdditions?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeminiApiMode = "google" | "openai";
|
|
||||||
|
|
||||||
const GEMINI_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
|
|
||||||
|
|
||||||
const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
|
const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
|
||||||
chat: ["CHAT"],
|
chat: ["CHAT"],
|
||||||
vision: ["VISION", "IMAGE"],
|
vision: ["VISION", "IMAGE"],
|
||||||
@@ -28,6 +24,7 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
|
|||||||
thinking: ["THINKING", "THINK"],
|
thinking: ["THINKING", "THINK"],
|
||||||
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
|
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
|
||||||
tools: ["TOOLS", "CHAT"],
|
tools: ["TOOLS", "CHAT"],
|
||||||
|
memoryCompress: ["MEMORY_COMPRESS"],
|
||||||
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
||||||
audio: ["AUDIO"],
|
audio: ["AUDIO"],
|
||||||
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
||||||
@@ -72,13 +69,18 @@ function modelEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[
|
|||||||
return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_MODEL`);
|
return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_MODEL`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function systemPromptEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
|
||||||
|
const prefix = providerPrefix(provider);
|
||||||
|
return PURPOSE_SUFFIXES[purpose].flatMap(suffix => [
|
||||||
|
`${prefix}_${suffix}_SYSTEM_PROMPT_ADDITIONS`,
|
||||||
|
`${prefix}_${suffix}_SYSTEM_PROMPT`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProviderBaseUrl(provider: AiProvider): string | undefined {
|
export function getProviderBaseUrl(provider: AiProvider): string | undefined {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return env("OLLAMA_ADDRESS");
|
return env("OLLAMA_ADDRESS");
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return env("GEMINI_BASE_URL") ?? env("GEMINI_ENDPOINT")
|
|
||||||
?? (Environment.GEMINI_API_MODE === "openai" ? GEMINI_OPENAI_BASE_URL : undefined);
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT");
|
return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT");
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
@@ -90,8 +92,6 @@ export function getProviderApiKey(provider: AiProvider): string | undefined {
|
|||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return Environment.OLLAMA_API_KEY;
|
return Environment.OLLAMA_API_KEY;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return Environment.GEMINI_API_KEY;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return Environment.MISTRAL_API_KEY;
|
return Environment.MISTRAL_API_KEY;
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
@@ -118,19 +118,6 @@ export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRunti
|
|||||||
default:
|
default:
|
||||||
return Environment.OLLAMA_CHAT_MODEL;
|
return Environment.OLLAMA_CHAT_MODEL;
|
||||||
}
|
}
|
||||||
case AiProvider.GEMINI:
|
|
||||||
switch (purpose) {
|
|
||||||
case "vision":
|
|
||||||
case "ocr":
|
|
||||||
case "outputImages":
|
|
||||||
return Environment.GEMINI_IMAGE_MODEL;
|
|
||||||
case "speechToText":
|
|
||||||
return Environment.GEMINI_TRANSCRIPTION_MODEL;
|
|
||||||
case "textToSpeech":
|
|
||||||
return Environment.GEMINI_TTS_MODEL;
|
|
||||||
default:
|
|
||||||
return Environment.GEMINI_MODEL;
|
|
||||||
}
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
switch (purpose) {
|
switch (purpose) {
|
||||||
case "speechToText":
|
case "speechToText":
|
||||||
@@ -164,8 +151,28 @@ export function resolveAiRuntimeTarget(
|
|||||||
?? getDefaultModelForPurpose(provider, purpose);
|
?? getDefaultModelForPurpose(provider, purpose);
|
||||||
const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider);
|
const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider);
|
||||||
const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider);
|
const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider);
|
||||||
|
const systemPromptAdditions = firstEnv(systemPromptEnvNames(provider, purpose));
|
||||||
|
|
||||||
return {provider, purpose, model, baseUrl, apiKey};
|
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitTargetConfig(provider: AiProvider, purpose: AiRuntimePurpose): boolean {
|
||||||
|
const prefix = providerPrefix(provider);
|
||||||
|
return [
|
||||||
|
...endpointEnvNames(provider, purpose),
|
||||||
|
...apiKeyEnvNames(provider, purpose),
|
||||||
|
...modelEnvNames(provider, purpose),
|
||||||
|
...systemPromptEnvNames(provider, purpose),
|
||||||
|
].some(name => !!env(name)) || !!env(`${prefix}_${PURPOSE_SUFFIXES[purpose][0]}_MODEL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOptionalAiRuntimeTarget(
|
||||||
|
provider: AiProvider,
|
||||||
|
purpose: AiRuntimePurpose,
|
||||||
|
modelOverride?: string,
|
||||||
|
): AiRuntimeTarget | undefined {
|
||||||
|
if (!hasExplicitTargetConfig(provider, purpose)) return undefined;
|
||||||
|
return resolveAiRuntimeTarget(provider, purpose, modelOverride);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
|
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
|
||||||
@@ -181,26 +188,6 @@ export function createOpenAiClient(target: AiRuntimeTarget): OpenAI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGeminiApiMode(target?: AiRuntimeTarget): GeminiApiMode {
|
|
||||||
if (Environment.GEMINI_API_MODE === "openai") return "openai";
|
|
||||||
if (Environment.GEMINI_API_MODE === "google") return "google";
|
|
||||||
if ((target?.baseUrl ?? "").includes("/openai")) return "openai";
|
|
||||||
return "google";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGeminiOpenAiClient(target: AiRuntimeTarget): OpenAI {
|
|
||||||
return createOpenAiClient({
|
|
||||||
...target,
|
|
||||||
baseUrl: target.baseUrl ?? GEMINI_OPENAI_BASE_URL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGoogleGenAiClient(target: AiRuntimeTarget): GoogleGenAI {
|
|
||||||
return new GoogleGenAI({
|
|
||||||
apiKey: target.apiKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMistralClient(target: AiRuntimeTarget): Mistral {
|
export function createMistralClient(target: AiRuntimeTarget): Mistral {
|
||||||
return new Mistral({
|
return new Mistral({
|
||||||
apiKey: target.apiKey,
|
apiKey: target.apiKey,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {AiToolCall} from "./tool-types";
|
import {AiToolCall} from "./tool-types";
|
||||||
import {OllamaChatMessage} from "./ollama-chat-message";
|
import {OllamaChatMessage} from "./ollama-chat-message";
|
||||||
import {GeminiMessage} from "./gemini-chat-message";
|
|
||||||
import {MistralChatMessage} from "./mistral-chat-message";
|
import {MistralChatMessage} from "./mistral-chat-message";
|
||||||
import {MessageAudioPart, MessageImagePart} from "../common/message-part";
|
import {MessageAudioPart, MessageImagePart} from "../common/message-part";
|
||||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
@@ -31,27 +30,6 @@ export function asOllamaChatMessage(message: ChatMessage): OllamaChatMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// export function asGeminiChatMessage(message: ChatMessage): GeminiMessage {
|
|
||||||
// if (message.images) {
|
|
||||||
// return {
|
|
||||||
// role: message.role,
|
|
||||||
// content: message.images.map(() => {
|
|
||||||
// return {
|
|
||||||
// type: "image",
|
|
||||||
// };
|
|
||||||
// })
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return {
|
|
||||||
// role: message.role,
|
|
||||||
// content: {
|
|
||||||
// type: "text",
|
|
||||||
// text: message.content,
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
|
export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
|
||||||
return {
|
return {
|
||||||
role: message.role,
|
role: message.role,
|
||||||
@@ -64,6 +42,4 @@ export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
|
|||||||
//
|
//
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
export type AiChatMessage = OpenAIChatMessage | OllamaChatMessage | MistralChatMessage;
|
||||||
|
|
||||||
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
|
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
import type {
|
||||||
|
ResponseInputMessageContentList,
|
||||||
|
ResponseOutputMessage,
|
||||||
|
ResponseOutputText,
|
||||||
|
} from "openai/resources/responses/responses";
|
||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {MessageStore} from "../common/message-store";
|
||||||
|
import {collectReplyChainText} from "../util/utils";
|
||||||
|
import type {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import type {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part";
|
||||||
|
import type {UserAiResponseLanguage} from "../common/user-ai-settings";
|
||||||
|
import {getResponseLanguageInstruction} from "../common/user-ai-settings";
|
||||||
|
import {pythonInterpreterToolPrompt} from "./tools/python-interpretator";
|
||||||
|
import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared";
|
||||||
|
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
|
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
|
import type {OllamaChatMessage} from "./ollama-chat-message";
|
||||||
|
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||||
|
|
||||||
|
export type ConversationAttachment = {
|
||||||
|
kind: AttachmentKind;
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationTurn = {
|
||||||
|
bot: boolean;
|
||||||
|
name?: string;
|
||||||
|
langCode?: string;
|
||||||
|
userName?: string;
|
||||||
|
content: string;
|
||||||
|
deletedByBotAt?: number | null;
|
||||||
|
attachments: ConversationAttachment[];
|
||||||
|
documentNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationSnapshot = {
|
||||||
|
turns: ConversationTurn[];
|
||||||
|
imageCount: number;
|
||||||
|
systemInstruction: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildAttachmentFromImage(image: MessageImagePart): ConversationAttachment {
|
||||||
|
return {
|
||||||
|
kind: "image",
|
||||||
|
data: image.data,
|
||||||
|
mimeType: image.mimeType || "image/jpeg",
|
||||||
|
fileName: "image.jpg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentFromAudio(audio: MessageAudioPart): ConversationAttachment {
|
||||||
|
return {
|
||||||
|
kind: "audio",
|
||||||
|
data: audio.data,
|
||||||
|
mimeType: audio.mimeType || "audio/mpeg",
|
||||||
|
fileName: "audio.bin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationAttachments(part: MessagePart): ConversationAttachment[] {
|
||||||
|
const attachments: ConversationAttachment[] = [];
|
||||||
|
|
||||||
|
for (const image of part.imageParts ?? []) {
|
||||||
|
attachments.push(buildAttachmentFromImage(image));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const audio of part.audioParts ?? []) {
|
||||||
|
attachments.push(buildAttachmentFromAudio(audio));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const document of part.documents ?? []) {
|
||||||
|
attachments.push({
|
||||||
|
kind: "document",
|
||||||
|
data: document,
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
fileName: "document.bin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const video of part.videos ?? []) {
|
||||||
|
attachments.push({
|
||||||
|
kind: "video",
|
||||||
|
data: video,
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
fileName: "video.mp4",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const videoNote of part.videoNotes ?? []) {
|
||||||
|
attachments.push({
|
||||||
|
kind: "video-note",
|
||||||
|
data: videoNote,
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
fileName: "video-note.mp4",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentCounts(attachments: ConversationAttachment[]): Record<AttachmentKind, number> {
|
||||||
|
return attachments.reduce<Record<AttachmentKind, number>>((counts, attachment) => {
|
||||||
|
counts[attachment.kind] += 1;
|
||||||
|
return counts;
|
||||||
|
}, {
|
||||||
|
image: 0,
|
||||||
|
document: 0,
|
||||||
|
audio: 0,
|
||||||
|
video: 0,
|
||||||
|
"video-note": 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentSummary(attachments: ConversationAttachment[]): string {
|
||||||
|
const counts = attachmentCounts(attachments);
|
||||||
|
const lines = Object.entries(counts)
|
||||||
|
.filter(([, count]) => count > 0)
|
||||||
|
.map(([kind, count]) => `- ${kind}: ${count}`);
|
||||||
|
|
||||||
|
if (!lines.length) return "";
|
||||||
|
|
||||||
|
return ["[attachments]:", ...lines].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function namesSummary(kind: string, names: string[]): string {
|
||||||
|
const filtered = names.map(name => name.trim()).filter(Boolean);
|
||||||
|
if (!filtered.length) return "";
|
||||||
|
|
||||||
|
return [`[${kind}]:`, ...filtered.map(name => `- ${name}`)].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> {
|
||||||
|
if (bot) return new Set<AttachmentKind>();
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case AiProvider.OPENAI:
|
||||||
|
return new Set<AttachmentKind>(["image", "audio", "document", "video", "video-note"]);
|
||||||
|
case AiProvider.MISTRAL:
|
||||||
|
return new Set<AttachmentKind>(["image"]);
|
||||||
|
case AiProvider.OLLAMA:
|
||||||
|
return new Set<AttachmentKind>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set<AttachmentKind>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContentText(
|
||||||
|
turn: ConversationTurn,
|
||||||
|
provider: AiProvider,
|
||||||
|
includeNames: boolean,
|
||||||
|
): string {
|
||||||
|
const parts = [turn.content.trim()];
|
||||||
|
const supported = supportedAttachmentKinds(provider, turn.bot);
|
||||||
|
const unsupported = turn.attachments.filter(attachment => !supported.has(attachment.kind));
|
||||||
|
|
||||||
|
if (includeNames && !turn.bot) {
|
||||||
|
parts.unshift([
|
||||||
|
"[user_info]:",
|
||||||
|
`name: ${turn.name ?? ""}`.trimEnd(),
|
||||||
|
`username: @${turn.userName ?? ""}`.trimEnd(),
|
||||||
|
"",
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turn.bot && turn.deletedByBotAt) {
|
||||||
|
parts.push("[message_state]: deleted_by_bot");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turn.documentNames?.length) {
|
||||||
|
parts.push(namesSummary("documents", turn.documentNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsupported.length) {
|
||||||
|
parts.push(attachmentSummary(unsupported));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.filter(part => part.trim().length > 0).join("\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenAiOutputText(text: string): ResponseOutputText {
|
||||||
|
return {
|
||||||
|
type: "output_text",
|
||||||
|
text,
|
||||||
|
annotations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenAiInputMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage {
|
||||||
|
const text = renderContentText(turn, provider, includeNames);
|
||||||
|
const content: ResponseInputMessageContentList = [
|
||||||
|
{
|
||||||
|
type: "input_text",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const attachment of turn.attachments.filter(item => item.kind === "image")) {
|
||||||
|
content.push({
|
||||||
|
type: "input_image",
|
||||||
|
image_url: `data:${attachment.mimeType};base64,${attachment.data}`,
|
||||||
|
detail: "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenAiAssistantMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): ResponseOutputMessage {
|
||||||
|
const text = renderContentText(turn, provider, includeNames);
|
||||||
|
return {
|
||||||
|
id: `msg_${Date.now()}`,
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
phase: "final_answer",
|
||||||
|
content: [buildOpenAiOutputText(text)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenAiMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage {
|
||||||
|
return turn.bot
|
||||||
|
? buildOpenAiAssistantMessage(turn, provider, includeNames)
|
||||||
|
: buildOpenAiInputMessage(turn, provider, includeNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMistralMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): MistralChatMessage {
|
||||||
|
const text = renderContentText(turn, provider, includeNames);
|
||||||
|
|
||||||
|
if (turn.bot) {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{type: "text", text}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{type: "text", text},
|
||||||
|
...turn.attachments
|
||||||
|
.filter(attachment => attachment.kind === "image")
|
||||||
|
.map(attachment => ({
|
||||||
|
type: "image_url" as const,
|
||||||
|
imageUrl: `data:${attachment.mimeType};base64,${attachment.data}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOllamaMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OllamaChatMessage {
|
||||||
|
const text = renderContentText(turn, provider, includeNames);
|
||||||
|
return {
|
||||||
|
role: turn.bot ? "assistant" : "user",
|
||||||
|
content: text,
|
||||||
|
images: turn.bot ? undefined : turn.attachments.filter(attachment => attachment.kind === "image").map(attachment => attachment.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemInstruction(
|
||||||
|
config: RuntimeConfigSnapshot,
|
||||||
|
responseLanguage: UserAiResponseLanguage,
|
||||||
|
includePythonToolPrompt: boolean,
|
||||||
|
additions?: string | null,
|
||||||
|
memoryInstruction?: string | null,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||||
|
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||||
|
additions?.trim() ? additions.trim() : null,
|
||||||
|
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
|
||||||
|
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
||||||
|
].filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildConversationSnapshot(
|
||||||
|
msg: Message,
|
||||||
|
textOverride: string,
|
||||||
|
downloads: AiDownloadedFile[],
|
||||||
|
config: RuntimeConfigSnapshot,
|
||||||
|
runtimeTarget: AiRuntimeTarget,
|
||||||
|
responseLanguage: UserAiResponseLanguage,
|
||||||
|
includePythonToolPrompt: boolean,
|
||||||
|
): Promise<ConversationSnapshot> {
|
||||||
|
const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id);
|
||||||
|
const messageParts = await collectReplyChainText({triggerMsg: storedMsg ?? msg, downloads});
|
||||||
|
|
||||||
|
if (messageParts.length && textOverride.trim()) {
|
||||||
|
const latest = messageParts[0];
|
||||||
|
if (!latest.bot) latest.content = textOverride.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const turns = messageParts
|
||||||
|
.reverse()
|
||||||
|
.map(part => ({
|
||||||
|
bot: part.bot,
|
||||||
|
name: part.name,
|
||||||
|
langCode: part.langCode,
|
||||||
|
userName: part.userName,
|
||||||
|
content: part.content,
|
||||||
|
deletedByBotAt: part.deletedByBotAt,
|
||||||
|
attachments: buildConversationAttachments(part),
|
||||||
|
documentNames: part.documentNames,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const imageCount = turns.reduce((sum, turn) => {
|
||||||
|
if (turn.bot) return sum;
|
||||||
|
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
|
||||||
|
}, 0);
|
||||||
|
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
turns,
|
||||||
|
imageCount,
|
||||||
|
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeConversationSnapshot(
|
||||||
|
snapshot: ConversationSnapshot,
|
||||||
|
provider: AiProvider,
|
||||||
|
includeNames: boolean,
|
||||||
|
): { chatMessages: Array<OpenAIChatMessage | MistralChatMessage | OllamaChatMessage>; imageCount: number } {
|
||||||
|
switch (provider) {
|
||||||
|
case AiProvider.OPENAI: {
|
||||||
|
const messages = snapshot.turns.map(turn => buildOpenAiMessage(turn, provider, includeNames));
|
||||||
|
if (snapshot.systemInstruction) {
|
||||||
|
messages.unshift({role: "system", content: snapshot.systemInstruction, type: "message"});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {chatMessages: messages, imageCount: snapshot.imageCount};
|
||||||
|
}
|
||||||
|
case AiProvider.MISTRAL: {
|
||||||
|
const messages = snapshot.turns.map(turn => buildMistralMessage(turn, provider, includeNames));
|
||||||
|
if (snapshot.systemInstruction) {
|
||||||
|
messages.unshift({role: "system", content: snapshot.systemInstruction});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {chatMessages: messages, imageCount: snapshot.imageCount};
|
||||||
|
}
|
||||||
|
case AiProvider.OLLAMA: {
|
||||||
|
const messages = snapshot.turns.map(turn => buildOllamaMessage(turn, provider, includeNames));
|
||||||
|
if (snapshot.systemInstruction) {
|
||||||
|
messages.unshift({role: "system", content: snapshot.systemInstruction});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {chatMessages: messages, imageCount: snapshot.imageCount};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {chatMessages: [], imageCount: snapshot.imageCount};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
|
import {deleteMistralLibrary, RuntimeConfigSnapshot, MistralDocumentReference, prepareMistralDocuments} from "./unified-ai-runner.shared";
|
||||||
|
import {MistralChatMessage} from "./mistral-chat-message";
|
||||||
|
import {OllamaChatMessage} from "./ollama-chat-message";
|
||||||
|
import {prepareOllamaDocumentRag} from "./ollama-rag";
|
||||||
|
import type {OllamaRagArtifactDetails} from "./ollama-rag";
|
||||||
|
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
|
import {createOpenAiClient, createOllamaClient} from "./ai-runtime-target";
|
||||||
|
import {prepareOpenAiDocumentRag} from "./unified-ai-runner.openai";
|
||||||
|
|
||||||
|
export type PreparedDocumentRag =
|
||||||
|
| {
|
||||||
|
provider: AiProvider.OPENAI;
|
||||||
|
vectorStoreIds: string[];
|
||||||
|
uploadedFileIds: string[];
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: AiProvider.MISTRAL;
|
||||||
|
documents: MistralDocumentReference[];
|
||||||
|
libraryId?: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: AiProvider.OLLAMA;
|
||||||
|
prepared: boolean;
|
||||||
|
artifact?: OllamaRagArtifactDetails;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function prepareDocumentRag(
|
||||||
|
provider: AiProvider,
|
||||||
|
downloads: AiDownloadedFile[],
|
||||||
|
messages: Array<OpenAIChatMessage | MistralChatMessage | OllamaChatMessage>,
|
||||||
|
streamMessage: TelegramStreamMessage,
|
||||||
|
config: RuntimeConfigSnapshot,
|
||||||
|
signal: AbortSignal,
|
||||||
|
userQuery: string,
|
||||||
|
): Promise<PreparedDocumentRag | undefined> {
|
||||||
|
const documents = downloads.filter(download => download.kind === "document");
|
||||||
|
if (!documents.length) return undefined;
|
||||||
|
|
||||||
|
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case AiProvider.OPENAI: {
|
||||||
|
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||||
|
const prepared = await prepareOpenAiDocumentRag(openAi, documents);
|
||||||
|
if (!prepared) {
|
||||||
|
throw new Error("OpenAI document RAG preparation returned no context.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
vectorStoreIds: prepared.vectorStoreIds,
|
||||||
|
uploadedFileIds: prepared.uploadedFileIds,
|
||||||
|
cleanup: prepared.cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case AiProvider.MISTRAL: {
|
||||||
|
const prepared = await prepareMistralDocuments(documents, messages as MistralChatMessage[], streamMessage, config.mistralChatTarget, signal);
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
documents: prepared.documents,
|
||||||
|
libraryId: prepared.libraryId,
|
||||||
|
cleanup: async () => {
|
||||||
|
await deleteMistralLibrary(prepared.libraryId, config.mistralChatTarget);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case AiProvider.OLLAMA: {
|
||||||
|
const prepared = await prepareOllamaDocumentRag({
|
||||||
|
downloads,
|
||||||
|
messages: messages as OllamaChatMessage[],
|
||||||
|
userQuery,
|
||||||
|
message: streamMessage,
|
||||||
|
config: {
|
||||||
|
embeddingModel: config.ollamaDocumentsTarget.model,
|
||||||
|
embeddingClient: createOllamaClient(config.ollamaDocumentsTarget),
|
||||||
|
chunkSize: config.ollamaRagChunkSize,
|
||||||
|
chunkOverlap: config.ollamaRagChunkOverlap,
|
||||||
|
topK: config.ollamaRagTopK,
|
||||||
|
maxContextChars: config.ollamaRagMaxContextChars,
|
||||||
|
minScore: config.ollamaRagMinScore,
|
||||||
|
maxArchiveFiles: config.ollamaRagMaxArchiveFiles,
|
||||||
|
maxArchiveBytes: config.ollamaRagMaxArchiveBytes,
|
||||||
|
maxArchiveDepth: config.ollamaRagMaxArchiveDepth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
prepared: prepared.prepared,
|
||||||
|
artifact: prepared.artifact,
|
||||||
|
cleanup: async () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import type {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
||||||
|
|
||||||
|
export async function persistFinalTextArtifactAttachment(params: {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
text: string;
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
}): Promise<StoredAttachment | undefined> {
|
||||||
|
const text = params.text.trim();
|
||||||
|
if (!text) return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
return await persistInternalJsonArtifactAttachment({
|
||||||
|
artifactKind: "final_text",
|
||||||
|
fileNamePrefix: "final-text",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
payload: {
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
textChars: text.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistErrorArtifactAttachment(params: {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
message: string;
|
||||||
|
recoverable: boolean;
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
}): Promise<StoredAttachment> {
|
||||||
|
return await persistInternalJsonArtifactAttachment({
|
||||||
|
artifactKind: "error",
|
||||||
|
fileNamePrefix: "error",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
payload: {
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
message: params.message,
|
||||||
|
recoverable: params.recoverable,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
recoverable: params.recoverable,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
export type GeminiUserInputStep = {
|
|
||||||
type: "user_input";
|
|
||||||
content?: Array<GeminiContent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiModelOutputStep = {
|
|
||||||
type: "model_output";
|
|
||||||
content?: Array<GeminiContent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiFunctionCallStep = {
|
|
||||||
id: string;
|
|
||||||
arguments: {
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
name: string;
|
|
||||||
type: "function_call";
|
|
||||||
signature?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiFunctionResultStep = {
|
|
||||||
call_id: string;
|
|
||||||
result: unknown | Array<GeminiTextContent | GeminiImageContent> | string;
|
|
||||||
type: "function_result";
|
|
||||||
is_error?: boolean;
|
|
||||||
name?: string;
|
|
||||||
signature?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiStep =
|
|
||||||
| GeminiUserInputStep
|
|
||||||
| GeminiModelOutputStep
|
|
||||||
| GeminiFunctionCallStep
|
|
||||||
| GeminiFunctionResultStep;
|
|
||||||
|
|
||||||
export type GeminiTextContent = {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiInlineContent = {
|
|
||||||
inlineData: {
|
|
||||||
data: string;
|
|
||||||
mimeType: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiImageContent = GeminiInlineContent;
|
|
||||||
export type GeminiAudioContent = GeminiInlineContent;
|
|
||||||
export type GeminiDocumentContent = GeminiInlineContent;
|
|
||||||
export type GeminiVideoContent = GeminiInlineContent;
|
|
||||||
|
|
||||||
export type GeminiFunctionCallContent = {
|
|
||||||
functionCall: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
args?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiFunctionResponseContent = {
|
|
||||||
functionResponse: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
response: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiContent =
|
|
||||||
| GeminiTextContent
|
|
||||||
| GeminiInlineContent
|
|
||||||
| GeminiFunctionCallContent
|
|
||||||
| GeminiFunctionResponseContent;
|
|
||||||
|
|
||||||
export type GeminiTurn = {
|
|
||||||
content?: Array<GeminiContent> | GeminiContent;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeminiInput = string | Array<GeminiStep> | Array<GeminiContent> | GeminiContent;
|
|
||||||
|
|
||||||
export type GeminiMessage = {
|
|
||||||
role: "user" | "model";
|
|
||||||
parts: GeminiContent[];
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {createHash} from "node:crypto";
|
||||||
|
import {Environment} from "../common/environment";
|
||||||
|
import {ArtifactStore} from "../common/artifact-store";
|
||||||
|
import type {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PipelineArtifactKind} from "./user-request-pipeline";
|
||||||
|
|
||||||
|
export type InternalArtifactAttachmentInput = {
|
||||||
|
artifactKind: PipelineArtifactKind;
|
||||||
|
fileNamePrefix: string;
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
requestId?: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERNAL_ARTIFACT_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function sha256(buffer: Buffer): string {
|
||||||
|
return createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileNamePart(value: string): string {
|
||||||
|
return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 80) || "artifact";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistInternalJsonArtifactAttachment(input: InternalArtifactAttachmentInput): Promise<StoredAttachment> {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const buffer = Buffer.from(JSON.stringify({
|
||||||
|
artifactKind: input.artifactKind,
|
||||||
|
createdAt,
|
||||||
|
...input.payload,
|
||||||
|
}, null, 2), "utf8");
|
||||||
|
|
||||||
|
if (buffer.length > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||||
|
throw new Error(`Internal ${input.artifactKind} artifact is larger than ${PIPELINE_ATTACHMENT_LIMIT_BYTES} bytes.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.join(Environment.DATA_PATH, "cache", "internal-artifacts", input.artifactKind);
|
||||||
|
fs.mkdirSync(dir, {recursive: true});
|
||||||
|
|
||||||
|
const digest = sha256(buffer);
|
||||||
|
const fileName = `${safeFileNamePart(input.fileNamePrefix)}-${input.chatId}-${input.messageId}-${Date.now()}.json`;
|
||||||
|
const cachePath = path.join(dir, fileName);
|
||||||
|
fs.writeFileSync(cachePath, buffer);
|
||||||
|
|
||||||
|
const attachment: StoredAttachment = {
|
||||||
|
kind: "document",
|
||||||
|
fileId: cachePath,
|
||||||
|
fileUniqueId: digest,
|
||||||
|
fileName,
|
||||||
|
mimeType: "application/json",
|
||||||
|
cachePath,
|
||||||
|
sizeBytes: buffer.length,
|
||||||
|
sha256: digest,
|
||||||
|
scope: "internal_artifact",
|
||||||
|
artifactKind: input.artifactKind,
|
||||||
|
metadata: input.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
await ArtifactStore.put({
|
||||||
|
id: "",
|
||||||
|
requestId: input.requestId ?? `message:${input.chatId}:${input.messageId}:${input.artifactKind}`,
|
||||||
|
messageChatId: input.chatId,
|
||||||
|
messageId: input.messageId,
|
||||||
|
kind: input.artifactKind,
|
||||||
|
stage: input.artifactKind,
|
||||||
|
attachmentId: cachePath,
|
||||||
|
payload: {
|
||||||
|
artifactKind: input.artifactKind,
|
||||||
|
createdAt,
|
||||||
|
...input.payload,
|
||||||
|
},
|
||||||
|
createdAt,
|
||||||
|
attachment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupInternalArtifactCache(now = Date.now()): void {
|
||||||
|
const root = path.join(Environment.DATA_PATH, "cache", "internal-artifacts");
|
||||||
|
if (!fs.existsSync(root)) return;
|
||||||
|
|
||||||
|
const cutoff = now - INTERNAL_ARTIFACT_RETENTION_MS;
|
||||||
|
for (const artifactKind of fs.readdirSync(root, {withFileTypes: true})) {
|
||||||
|
if (!artifactKind.isDirectory()) continue;
|
||||||
|
|
||||||
|
const dir = path.join(root, artifactKind.name);
|
||||||
|
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
|
||||||
|
const filePath = path.join(dir, entry.name);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.mtimeMs < cutoff) {
|
||||||
|
fs.rmSync(filePath, {force: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process";
|
||||||
|
import type {BoundaryValue} from "../../common/boundary-types.js";
|
||||||
|
import {toolsLogger} from "../tools/tool-logger.js";
|
||||||
|
import type {McpServerConfig} from "./mcp-config.js";
|
||||||
|
|
||||||
|
const logger = toolsLogger.child("mcp");
|
||||||
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export type McpToolDefinition = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: BoundaryValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRpcRequest = {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: BoundaryValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRpcNotification = {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
method: string;
|
||||||
|
params?: BoundaryValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRpcResponse = {
|
||||||
|
jsonrpc?: "2.0";
|
||||||
|
id?: BoundaryValue;
|
||||||
|
result?: BoundaryValue;
|
||||||
|
error?: {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: BoundaryValue;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface JsonRpcTransport {
|
||||||
|
request(method: string, params?: BoundaryValue): Promise<BoundaryValue>;
|
||||||
|
notify(method: string, params?: BoundaryValue): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJsonRpcResponse(value: BoundaryValue): JsonRpcResponse | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
if (value.jsonrpc !== undefined && value.jsonrpc !== "2.0") return undefined;
|
||||||
|
return value as JsonRpcResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonRpcResult(response: BoundaryValue, expectedId?: number): BoundaryValue {
|
||||||
|
const parsed = toJsonRpcResponse(response);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("Invalid JSON-RPC response from MCP server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(parsed.error.message || "MCP server returned an error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedId !== undefined && parsed.id !== undefined && parsed.id !== expectedId) {
|
||||||
|
throw new Error(`Unexpected JSON-RPC response id from MCP server. Expected ${expectedId}, got ${String(parsed.id)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.result ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSsePayload(text: string): BoundaryValue[] {
|
||||||
|
const events: string[] = [];
|
||||||
|
let current: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
if (current.length) {
|
||||||
|
events.push(current.join("\n"));
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
current.push(line.slice(5).replace(/^ /, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length) {
|
||||||
|
events.push(current.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.map(event => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(event) as BoundaryValue;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}).filter((event): event is BoundaryValue => event !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutPromise<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
||||||
|
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = new Promise<T>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timeout]).finally(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StdioJsonRpcTransport implements JsonRpcTransport {
|
||||||
|
private readonly process: ChildProcessWithoutNullStreams;
|
||||||
|
private readonly pending = new Map<number, {resolve: (value: BoundaryValue) => void; reject: (error: Error) => void;}>();
|
||||||
|
private buffer = "";
|
||||||
|
private nextId = 1;
|
||||||
|
|
||||||
|
constructor(private readonly config: McpServerConfig) {
|
||||||
|
if (!config.command) {
|
||||||
|
throw new Error(`MCP stdio server '${config.name}' is missing command.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.process = spawn(config.command, config.args ?? [], {
|
||||||
|
cwd: config.cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...config.env,
|
||||||
|
},
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout.on("data", chunk => this.handleStdout(chunk));
|
||||||
|
this.process.stderr.on("data", chunk => {
|
||||||
|
const text = chunk.toString("utf8").trim();
|
||||||
|
if (text) logger.debug("stdio.stderr", {server: config.name, text});
|
||||||
|
});
|
||||||
|
this.process.on("error", error => this.failAll(error));
|
||||||
|
this.process.on("exit", code => this.failAll(new Error(`MCP stdio server '${config.name}' exited with code ${code ?? "unknown"}.`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStdout(chunk: Buffer): void {
|
||||||
|
this.buffer += chunk.toString("utf8");
|
||||||
|
|
||||||
|
let newlineIndex = this.buffer.indexOf("\n");
|
||||||
|
while (newlineIndex !== -1) {
|
||||||
|
const line = this.buffer.slice(0, newlineIndex).trim();
|
||||||
|
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||||
|
newlineIndex = this.buffer.indexOf("\n");
|
||||||
|
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line) as JsonRpcResponse | JsonRpcNotification;
|
||||||
|
if ("id" in message && message.id !== undefined) {
|
||||||
|
const pending = this.pending.get(Number(message.id));
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(Number(message.id));
|
||||||
|
if ("error" in message && message.error) {
|
||||||
|
pending.reject(new Error(message.error.message || "MCP stdio request failed."));
|
||||||
|
} else {
|
||||||
|
pending.resolve((message as JsonRpcResponse).result ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("method" in message) {
|
||||||
|
logger.debug("stdio.notification", {server: this.config.name, method: message.method});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("stdio.parse_failed", {
|
||||||
|
server: this.config.name,
|
||||||
|
line: line.slice(0, 500),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAll(error: Error): void {
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
|
||||||
|
if (this.process.exitCode !== null) {
|
||||||
|
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.nextId++;
|
||||||
|
const request: JsonRpcRequest = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = new Promise<BoundaryValue>((resolve, reject) => {
|
||||||
|
this.pending.set(id, {resolve, reject});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdin.write(`${JSON.stringify(request)}\n`);
|
||||||
|
return timeoutPromise(result, this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, `${this.config.name}.${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify(method: string, params?: BoundaryValue): Promise<void> {
|
||||||
|
if (this.process.exitCode !== null) {
|
||||||
|
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification: JsonRpcNotification = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.process.stdin.write(`${JSON.stringify(notification)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.failAll(new Error(`MCP stdio server '${this.config.name}' closed.`));
|
||||||
|
if (!this.process.killed) {
|
||||||
|
this.process.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpJsonRpcTransport implements JsonRpcTransport {
|
||||||
|
private nextId = 1;
|
||||||
|
private sessionId?: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: McpServerConfig) {
|
||||||
|
if (!config.url) {
|
||||||
|
throw new Error(`MCP HTTP server '${config.name}' is missing url.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(body: BoundaryValue): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs = this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(this.config.url!, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
...(this.sessionId ? {"Mcp-Session-Id": this.sessionId} : {}),
|
||||||
|
...(this.config.headers ?? {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => clearTimeout(timeoutId));
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
|
||||||
|
const id = this.nextId++;
|
||||||
|
const request: JsonRpcRequest = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.post(request);
|
||||||
|
const sessionId = response.headers.get("Mcp-Session-Id");
|
||||||
|
if (sessionId) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => "");
|
||||||
|
throw new Error(`MCP HTTP server '${this.config.name}' returned ${response.status}: ${errorText || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
let payload: BoundaryValue;
|
||||||
|
|
||||||
|
if (contentType.includes("text/event-stream")) {
|
||||||
|
const text = await response.text();
|
||||||
|
const messages = parseSsePayload(text);
|
||||||
|
const responseMessage = messages.map(toJsonRpcResponse).find(message => message?.id === id && (message.result !== undefined || message.error));
|
||||||
|
payload = extractJsonRpcResult(responseMessage ?? messages[0] ?? {}, id);
|
||||||
|
} else {
|
||||||
|
payload = extractJsonRpcResult(await response.json() as BoundaryValue, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify(method: string, params?: BoundaryValue): Promise<void> {
|
||||||
|
const response = await this.post({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = response.headers.get("Mcp-Session-Id");
|
||||||
|
if (sessionId) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 202) {
|
||||||
|
const errorText = await response.text().catch(() => "");
|
||||||
|
throw new Error(`MCP HTTP notification failed for '${this.config.name}' with ${response.status}: ${errorText || response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransport(config: McpServerConfig): JsonRpcTransport {
|
||||||
|
return config.transport === "stdio"
|
||||||
|
? new StdioJsonRpcTransport(config)
|
||||||
|
: new HttpJsonRpcTransport(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolResultContent(content: BoundaryValue): string {
|
||||||
|
if (content === undefined || content === null) return "";
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (typeof content === "number" || typeof content === "boolean") return String(content);
|
||||||
|
if (Array.isArray(content)) return content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
|
||||||
|
if (!isRecord(content)) return JSON.stringify(content);
|
||||||
|
|
||||||
|
if (content.type === "text" && typeof content.text === "string") return content.text;
|
||||||
|
if (content.type === "image") {
|
||||||
|
return `[image ${typeof content.mimeType === "string" ? content.mimeType : "unknown"}]`;
|
||||||
|
}
|
||||||
|
if (content.type === "resource" && isRecord(content.resource)) {
|
||||||
|
if (typeof content.resource.text === "string") return content.resource.text;
|
||||||
|
return JSON.stringify(content.resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class McpClient {
|
||||||
|
private readonly transport: JsonRpcTransport;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(readonly config: McpServerConfig) {
|
||||||
|
this.transport = createTransport(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
await this.transport.request("initialize", {
|
||||||
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||||
|
clientInfo: {
|
||||||
|
name: "tg-chat-bot",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
capabilities: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.transport.notify("notifications/initialized");
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<McpToolDefinition[]> {
|
||||||
|
await this.initialize();
|
||||||
|
const result = await this.transport.request("tools/list");
|
||||||
|
|
||||||
|
if (!isRecord(result)) return [];
|
||||||
|
|
||||||
|
const tools = Array.isArray(result.tools) ? result.tools : [];
|
||||||
|
return tools.flatMap(tool => {
|
||||||
|
if (!isRecord(tool) || typeof tool.name !== "string") return [];
|
||||||
|
return [{
|
||||||
|
name: tool.name,
|
||||||
|
description: typeof tool.description === "string" ? tool.description : undefined,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args?: BoundaryValue): Promise<string> {
|
||||||
|
await this.initialize();
|
||||||
|
const result = await this.transport.request("tools/call", {
|
||||||
|
name,
|
||||||
|
arguments: args ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isRecord(result)) {
|
||||||
|
return normalizeToolResultContent(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Array.isArray(result.content) ? result.content : [];
|
||||||
|
const text = content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
return text ? `[MCP error] ${text}` : "[MCP error]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text || JSON.stringify(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.transport.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type {BoundaryValue} from "../../common/boundary-types.js";
|
||||||
|
|
||||||
|
export type McpTransport = "stdio" | "http";
|
||||||
|
|
||||||
|
export type McpServerConfig = {
|
||||||
|
name: string;
|
||||||
|
transport: McpTransport;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: BoundaryValue): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringRecord(value: BoundaryValue): Record<string, string> | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") {
|
||||||
|
result[key] = String(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(value: BoundaryValue): string[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const items = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||||
|
.map(item => item.trim());
|
||||||
|
return items.length ? items : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPositiveInt(value: BoundaryValue): number | undefined {
|
||||||
|
const n = typeof value === "number"
|
||||||
|
? value
|
||||||
|
: typeof value === "string"
|
||||||
|
? Number(value)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerConfig(value: BoundaryValue, fallbackName?: string): McpServerConfig | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
|
||||||
|
const name = asString(value.name) ?? fallbackName;
|
||||||
|
const transportRaw = asString(value.transport);
|
||||||
|
const transport = transportRaw === "http" || transportRaw === "stdio" ? transportRaw : undefined;
|
||||||
|
|
||||||
|
if (!name || !transport) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
transport,
|
||||||
|
command: asString(value.command),
|
||||||
|
args: toStringArray(value.args),
|
||||||
|
cwd: asString(value.cwd),
|
||||||
|
env: toStringRecord(value.env),
|
||||||
|
url: asString(value.url),
|
||||||
|
headers: toStringRecord(value.headers),
|
||||||
|
timeoutMs: toPositiveInt(value.timeoutMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMcpServerConfigs(raw: string | undefined): McpServerConfig[] {
|
||||||
|
if (!raw?.trim()) return [];
|
||||||
|
|
||||||
|
let parsed: BoundaryValue;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as BoundaryValue;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid MCP_SERVERS JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed.servers)) {
|
||||||
|
return parsed.servers.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(parsed.mcpServers)) {
|
||||||
|
return Object.entries(parsed.mcpServers).flatMap(([name, item]) => normalizeServerConfig(item, name) ? [normalizeServerConfig(item, name)!] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = normalizeServerConfig(parsed);
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type {AiJsonValue, AiToolParameters} from "../tool-types.js";
|
||||||
|
import type {BoundaryValue} from "../../common/boundary-types.js";
|
||||||
|
|
||||||
|
type JsonSchemaRecord = Record<string, BoundaryValue>;
|
||||||
|
|
||||||
|
function isRecord(value: BoundaryValue): value is JsonSchemaRecord {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAiJsonValue(value: BoundaryValue): AiJsonValue | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === null) return null;
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => toAiJsonValue(item) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
|
||||||
|
const result: Record<string, AiJsonValue> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
const normalized = toAiJsonValue(entry);
|
||||||
|
if (normalized !== undefined) {
|
||||||
|
result[key] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(value: BoundaryValue): AiToolParameters["type"] | undefined {
|
||||||
|
const candidates = Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === "string")
|
||||||
|
: typeof value === "string"
|
||||||
|
? [value]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const prioritized = candidates.find(item => item !== "null") ?? candidates[0];
|
||||||
|
if (!prioritized) return undefined;
|
||||||
|
|
||||||
|
switch (prioritized) {
|
||||||
|
case "object":
|
||||||
|
case "string":
|
||||||
|
case "number":
|
||||||
|
case "integer":
|
||||||
|
case "boolean":
|
||||||
|
case "array":
|
||||||
|
return prioritized;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertJsonSchemaToToolParameters(schema: BoundaryValue): AiToolParameters | undefined {
|
||||||
|
if (!isRecord(schema)) return undefined;
|
||||||
|
|
||||||
|
const declaredType = normalizeType(schema.type);
|
||||||
|
const inferredType = declaredType
|
||||||
|
?? (schema.properties !== undefined || schema.additionalProperties !== undefined ? "object" : undefined)
|
||||||
|
?? (schema.items !== undefined ? "array" : undefined)
|
||||||
|
?? "object";
|
||||||
|
|
||||||
|
const result: AiToolParameters = {
|
||||||
|
type: inferredType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const description = typeof schema.description === "string" && schema.description.trim().length > 0
|
||||||
|
? schema.description.trim()
|
||||||
|
: undefined;
|
||||||
|
if (description) result.description = description;
|
||||||
|
|
||||||
|
const defaultValue = toAiJsonValue(schema.default);
|
||||||
|
if (defaultValue !== undefined) result.default = defaultValue;
|
||||||
|
|
||||||
|
if (Array.isArray(schema.enum)) {
|
||||||
|
const enumValues = schema.enum
|
||||||
|
.filter((item): item is string => typeof item === "string" && item.length > 0);
|
||||||
|
if (enumValues.length) result.enum = enumValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof schema.minItems === "number") result.minItems = schema.minItems;
|
||||||
|
if (typeof schema.maxItems === "number") result.maxItems = schema.maxItems;
|
||||||
|
if (typeof schema.minimum === "number") result.minimum = schema.minimum;
|
||||||
|
if (typeof schema.maximum === "number") result.maximum = schema.maximum;
|
||||||
|
|
||||||
|
if (Array.isArray(schema.required)) {
|
||||||
|
const required = schema.required.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||||||
|
if (required.length) result.required = required;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inferredType === "object" || schema.properties !== undefined || schema.additionalProperties !== undefined) {
|
||||||
|
if (isRecord(schema.properties)) {
|
||||||
|
const properties: Record<string, AiToolParameters> = {};
|
||||||
|
for (const [key, value] of Object.entries(schema.properties)) {
|
||||||
|
const converted = convertJsonSchemaToToolParameters(value);
|
||||||
|
if (converted) properties[key] = converted;
|
||||||
|
}
|
||||||
|
if (Object.keys(properties).length) result.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.additionalProperties !== undefined) {
|
||||||
|
result.additionalProperties = typeof schema.additionalProperties === "boolean"
|
||||||
|
? schema.additionalProperties
|
||||||
|
: convertJsonSchemaToToolParameters(schema.additionalProperties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inferredType === "array" || schema.items !== undefined) {
|
||||||
|
if (Array.isArray(schema.items)) {
|
||||||
|
const firstItem = schema.items[0];
|
||||||
|
if (firstItem !== undefined) {
|
||||||
|
const converted = convertJsonSchemaToToolParameters(firstItem);
|
||||||
|
if (converted) result.items = converted;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const converted = convertJsonSchemaToToolParameters(schema.items);
|
||||||
|
if (converted) result.items = converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import {Environment} from "../../common/environment.js";
|
||||||
|
import type {AiTool} from "../tool-types.js";
|
||||||
|
import type {ToolHandler} from "../tools/types.js";
|
||||||
|
import {normalizeToolArguments} from "../tools/utils.js";
|
||||||
|
import {toolsLogger} from "../tools/tool-logger.js";
|
||||||
|
import {convertJsonSchemaToToolParameters} from "./mcp-json-schema.js";
|
||||||
|
import {McpClient, type McpToolDefinition} from "./mcp-client.js";
|
||||||
|
import {parseMcpServerConfigs, type McpServerConfig} from "./mcp-config.js";
|
||||||
|
|
||||||
|
const logger = toolsLogger.child("mcp-registry");
|
||||||
|
|
||||||
|
type McpToolBinding = {
|
||||||
|
server: McpServerConfig;
|
||||||
|
client: McpClient;
|
||||||
|
remoteToolName: string;
|
||||||
|
localToolName: string;
|
||||||
|
tool: AiTool;
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpInitSummary = {
|
||||||
|
servers: number;
|
||||||
|
loadedServers: number;
|
||||||
|
tools: number;
|
||||||
|
failedServers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolBindings = new Map<string, McpToolBinding>();
|
||||||
|
const clients = new Map<string, McpClient>();
|
||||||
|
let initPromise: Promise<McpInitSummary> | undefined;
|
||||||
|
|
||||||
|
function sanitizeSegment(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9_]+/g, "_")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "") || "tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalToolName(serverName: string, toolName: string): string {
|
||||||
|
return `mcp__${sanitizeSegment(serverName)}__${sanitizeSegment(toolName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTool(serverName: string, tool: McpToolDefinition): AiTool {
|
||||||
|
const localName = buildLocalToolName(serverName, tool.name);
|
||||||
|
const description = tool.description?.trim()
|
||||||
|
? `[MCP ${serverName}] ${tool.description.trim()}`
|
||||||
|
: `[MCP ${serverName}] ${tool.name}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: localName,
|
||||||
|
description,
|
||||||
|
parameters: convertJsonSchemaToToolParameters(tool.inputSchema),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServer(config: McpServerConfig): Promise<{loaded: boolean; tools: number}> {
|
||||||
|
const client = new McpClient(config);
|
||||||
|
clients.set(config.name, client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remoteTools = await client.listTools();
|
||||||
|
let loaded = 0;
|
||||||
|
|
||||||
|
for (const remoteTool of remoteTools) {
|
||||||
|
const localName = buildLocalToolName(config.name, remoteTool.name);
|
||||||
|
if (toolBindings.has(localName)) {
|
||||||
|
logger.warn("tool.duplicate", {
|
||||||
|
server: config.name,
|
||||||
|
tool: remoteTool.name,
|
||||||
|
localName,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding: McpToolBinding = {
|
||||||
|
server: config,
|
||||||
|
client,
|
||||||
|
remoteToolName: remoteTool.name,
|
||||||
|
localToolName: localName,
|
||||||
|
tool: buildTool(config.name, remoteTool),
|
||||||
|
};
|
||||||
|
|
||||||
|
toolBindings.set(localName, binding);
|
||||||
|
loaded += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("server.loaded", {
|
||||||
|
server: config.name,
|
||||||
|
transport: config.transport,
|
||||||
|
tools: loaded,
|
||||||
|
});
|
||||||
|
return {loaded: true, tools: loaded};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("server.failed", {
|
||||||
|
server: config.name,
|
||||||
|
transport: config.transport,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
clients.delete(config.name);
|
||||||
|
return {loaded: false, tools: 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeMcpTools(): Promise<McpInitSummary> {
|
||||||
|
if (initPromise) return initPromise;
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
toolBindings.clear();
|
||||||
|
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
|
||||||
|
clients.clear();
|
||||||
|
|
||||||
|
const configs = parseMcpServerConfigs(Environment.MCP_SERVERS);
|
||||||
|
const results = await Promise.all(configs.map(config => loadServer(config)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers: configs.length,
|
||||||
|
loadedServers: results.filter(result => result.loaded).length,
|
||||||
|
tools: [...results].reduce((sum, result) => sum + result.tools, 0),
|
||||||
|
failedServers: configs.filter((_, index) => !results[index]?.loaded).map(config => config.name),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await initPromise;
|
||||||
|
logger.info("init.done", summary);
|
||||||
|
return summary;
|
||||||
|
} catch (error) {
|
||||||
|
initPromise = undefined;
|
||||||
|
logger.error("init.failed", {error: error instanceof Error ? error.message : String(error)});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpTools(): AiTool[] {
|
||||||
|
return [...toolBindings.values()].map(binding => binding.tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpToolHandlers(): Record<string, ToolHandler> {
|
||||||
|
const handlers: Record<string, ToolHandler> = {};
|
||||||
|
|
||||||
|
for (const binding of toolBindings.values()) {
|
||||||
|
handlers[binding.localToolName] = async args => {
|
||||||
|
const normalized = normalizeToolArguments(args, undefined);
|
||||||
|
return binding.client.callTool(binding.remoteToolName, normalized);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpToolPrompts(_toolNames: string[]): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownMcpTools(): Promise<void> {
|
||||||
|
initPromise = undefined;
|
||||||
|
toolBindings.clear();
|
||||||
|
|
||||||
|
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
|
||||||
|
clients.clear();
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ export type MistralContentChunk =
|
|||||||
|
|
||||||
export type MistralFunctionCall = {
|
export type MistralFunctionCall = {
|
||||||
name: string;
|
name: string;
|
||||||
arguments: Record<string, unknown> | string;
|
arguments: AiJsonObject | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MistralToolCall = {
|
export type MistralToolCall = {
|
||||||
@@ -110,3 +110,4 @@ export type MistralChatMessage =
|
|||||||
| MistralSystemMessage
|
| MistralSystemMessage
|
||||||
| MistralToolMessage
|
| MistralToolMessage
|
||||||
| MistralUserMessage
|
| MistralUserMessage
|
||||||
|
import {AiJsonObject} from "./tool-types";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export async function runSingleModelRequest<T>(params: {
|
||||||
|
execute: () => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
return await params.execute();
|
||||||
|
}
|
||||||
+88
-9
@@ -476,6 +476,40 @@ type ExtractedRagDocument = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OllamaRagArtifactDetails = {
|
||||||
|
query: string;
|
||||||
|
extractedDocuments: Array<{
|
||||||
|
documentIndex: number;
|
||||||
|
fileName: string;
|
||||||
|
textChars: number;
|
||||||
|
}>;
|
||||||
|
selectedChunks: Array<{
|
||||||
|
sourceId: string;
|
||||||
|
documentIndex: number;
|
||||||
|
documentName: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkCount: number;
|
||||||
|
textChars: number;
|
||||||
|
score?: number;
|
||||||
|
}>;
|
||||||
|
skippedDocuments: Array<{
|
||||||
|
documentIndex: number;
|
||||||
|
fileName: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
providerState: {
|
||||||
|
embeddingModel: string;
|
||||||
|
topK: number;
|
||||||
|
chunkSize: number;
|
||||||
|
chunkOverlap: number;
|
||||||
|
maxContextChars: number;
|
||||||
|
minScore: number;
|
||||||
|
maxArchiveFiles: number;
|
||||||
|
maxArchiveBytes: number;
|
||||||
|
maxArchiveDepth: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ArchiveSkippedDocument = {
|
type ArchiveSkippedDocument = {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -587,7 +621,7 @@ function reserveArchiveFile(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: unknown): void {
|
function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: Error | string | object | null | undefined): void {
|
||||||
state.skipped.push({
|
state.skipped.push({
|
||||||
fileName,
|
fileName,
|
||||||
reason: reason instanceof Error ? reason.message : String(reason),
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
@@ -607,7 +641,7 @@ function extractArchiveChildDocuments(
|
|||||||
try {
|
try {
|
||||||
return extractRagDocumentsFromFile(child, config, state, depth + 1);
|
return extractRagDocumentsFromFile(child, config, state, depth + 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushArchiveSkip(state, child.fileName, e);
|
pushArchiveSkip(state, child.fileName, e instanceof Error ? e : String(e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -641,7 +675,7 @@ function extractZipArchiveDocuments(
|
|||||||
const buffer = readZipEntry(doc.buffer, entry);
|
const buffer = readZipEntry(doc.buffer, entry);
|
||||||
documents.push(...extractArchiveChildDocuments(doc, normalizedName, buffer, config, state, depth));
|
documents.push(...extractArchiveChildDocuments(doc, normalizedName, buffer, config, state, depth));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushArchiveSkip(state, displayName, e);
|
pushArchiveSkip(state, displayName, e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1269,48 @@ function formatRagContext(chunks: DocumentChunk[], totalChunks: number, document
|
|||||||
].filter(line => line.length > 0).join("\n");
|
].filter(line => line.length > 0).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOllamaRagArtifactDetails(
|
||||||
|
query: string,
|
||||||
|
documents: SourceDocument[],
|
||||||
|
selected: DocumentChunk[],
|
||||||
|
skippedDocuments: SkippedDocument[],
|
||||||
|
config: OllamaDocumentRagConfig,
|
||||||
|
): OllamaRagArtifactDetails {
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
extractedDocuments: documents.map(document => ({
|
||||||
|
documentIndex: document.documentIndex,
|
||||||
|
fileName: document.fileName,
|
||||||
|
textChars: document.text.length,
|
||||||
|
})),
|
||||||
|
selectedChunks: selected.map(chunk => ({
|
||||||
|
sourceId: chunk.sourceId,
|
||||||
|
documentIndex: chunk.documentIndex,
|
||||||
|
documentName: chunk.documentName,
|
||||||
|
chunkIndex: chunk.chunkIndex,
|
||||||
|
chunkCount: chunk.chunkCount,
|
||||||
|
textChars: chunk.text.length,
|
||||||
|
score: chunk.score,
|
||||||
|
})),
|
||||||
|
skippedDocuments: skippedDocuments.map(document => ({
|
||||||
|
documentIndex: document.documentIndex,
|
||||||
|
fileName: document.fileName,
|
||||||
|
reason: document.reason,
|
||||||
|
})),
|
||||||
|
providerState: {
|
||||||
|
embeddingModel: config.embeddingModel,
|
||||||
|
topK: config.topK,
|
||||||
|
chunkSize: config.chunkSize,
|
||||||
|
chunkOverlap: config.chunkOverlap,
|
||||||
|
maxContextChars: config.maxContextChars,
|
||||||
|
minScore: config.minScore,
|
||||||
|
maxArchiveFiles: config.maxArchiveFiles,
|
||||||
|
maxArchiveBytes: config.maxArchiveBytes,
|
||||||
|
maxArchiveDepth: config.maxArchiveDepth,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function injectOllamaRagContext(messages: OllamaChatMessage[], context: string): void {
|
function injectOllamaRagContext(messages: OllamaChatMessage[], context: string): void {
|
||||||
const systemIndex = messages.findIndex(message => message.role === "system");
|
const systemIndex = messages.findIndex(message => message.role === "system");
|
||||||
|
|
||||||
@@ -1258,7 +1334,7 @@ export async function buildOllamaDocumentRagContext(params: {
|
|||||||
userQuery: string;
|
userQuery: string;
|
||||||
config: OllamaDocumentRagConfig;
|
config: OllamaDocumentRagConfig;
|
||||||
onStatus?: (status: string) => Promise<void> | void;
|
onStatus?: (status: string) => Promise<void> | void;
|
||||||
}): Promise<string | null> {
|
}): Promise<{context: string; artifact: OllamaRagArtifactDetails} | null> {
|
||||||
const docs = params.downloads.filter(download => download.kind === "document");
|
const docs = params.downloads.filter(download => download.kind === "document");
|
||||||
if (!docs.length) return null;
|
if (!docs.length) return null;
|
||||||
|
|
||||||
@@ -1333,7 +1409,10 @@ export async function buildOllamaDocumentRagContext(params: {
|
|||||||
throw new Error(Environment.localRagNoSuitableFragmentsText);
|
throw new Error(Environment.localRagNoSuitableFragmentsText);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatRagContext(selected, chunks.length, documents, skippedDocuments);
|
return {
|
||||||
|
context: formatRagContext(selected, chunks.length, documents, skippedDocuments),
|
||||||
|
artifact: buildOllamaRagArtifactDetails(buildRetrievalQuery(params.userQuery, params.messages), documents, selected, skippedDocuments, params.config),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareOllamaDocumentRag(params: {
|
export async function prepareOllamaDocumentRag(params: {
|
||||||
@@ -1342,7 +1421,7 @@ export async function prepareOllamaDocumentRag(params: {
|
|||||||
userQuery: string;
|
userQuery: string;
|
||||||
message: TelegramStreamMessage;
|
message: TelegramStreamMessage;
|
||||||
config: OllamaDocumentRagConfig;
|
config: OllamaDocumentRagConfig;
|
||||||
}): Promise<boolean> {
|
}): Promise<{prepared: boolean; artifact?: OllamaRagArtifactDetails}> {
|
||||||
const context = await buildOllamaDocumentRagContext({
|
const context = await buildOllamaDocumentRagContext({
|
||||||
downloads: params.downloads,
|
downloads: params.downloads,
|
||||||
messages: params.messages,
|
messages: params.messages,
|
||||||
@@ -1354,7 +1433,7 @@ export async function prepareOllamaDocumentRag(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!context) return false;
|
if (!context) return {prepared: false};
|
||||||
injectOllamaRagContext(params.messages, context);
|
injectOllamaRagContext(params.messages, context.context);
|
||||||
return true;
|
return {prepared: true, artifact: context.artifact};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {isRecord} from "./unified-ai-runner.shared.js";
|
||||||
|
import type {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message.js";
|
||||||
|
import type {ToolCallData} from "./unified-ai-runner.shared.js";
|
||||||
|
|
||||||
|
export function responseContentToText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAICompatibleChatMessage[] {
|
||||||
|
return messages.map((message): OpenAICompatibleChatMessage => {
|
||||||
|
if (message.role === "system") {
|
||||||
|
return {role: "system", content: responseContentToText(message.content)};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const text = responseContentToText(message.content);
|
||||||
|
return text.length
|
||||||
|
? {role: "assistant", content: text}
|
||||||
|
: {role: "assistant", content: null};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content)
|
||||||
|
? (() => {
|
||||||
|
const parts = message.content.map((part): {type: "text"; text: string} | {type: "image_url"; image_url: {url: string}} => {
|
||||||
|
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 : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return parts.every(part => part.type === "text")
|
||||||
|
? parts.map(part => part.text).join("")
|
||||||
|
: parts;
|
||||||
|
})()
|
||||||
|
: message.content;
|
||||||
|
|
||||||
|
return {role: "user", content};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssistantToolMessage(calls: ToolCallData[], text: string): OpenAICompatibleChatMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: text,
|
||||||
|
tool_calls: calls.map(call => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.argumentsText,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
import type {
|
||||||
|
ResponseInputMessageContentList,
|
||||||
|
ResponseOutputMessage,
|
||||||
|
} from "openai/resources/responses/responses";
|
||||||
|
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
|
|
||||||
export type OpenAIChatMessage = {
|
type OpenAIInputChatMessage = {
|
||||||
type: "message";
|
type: "message";
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user";
|
||||||
content: string | ResponseInputMessageContentList;
|
content: string | ResponseInputMessageContentList;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OpenAIOutputChatMessage = {
|
||||||
|
type: "message";
|
||||||
|
role: "assistant";
|
||||||
|
content: ResponseOutputMessage["content"];
|
||||||
|
phase?: ResponseOutputMessage["phase"];
|
||||||
|
} & Pick<ResponseOutputMessage, "id" | "status">;
|
||||||
|
|
||||||
|
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
|
||||||
|
|
||||||
|
export type OpenAICompatibleChatMessage = ChatCompletionMessageParam;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {bot} from "../index.js";
|
||||||
|
import {Environment} from "../common/environment.js";
|
||||||
|
import {logError} from "../util/utils.js";
|
||||||
|
import {errorMessage} from "./unified-ai-runner.shared.js";
|
||||||
|
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files.js";
|
||||||
|
|
||||||
|
export async function tryToUploadFiles(
|
||||||
|
msg: Message,
|
||||||
|
toolResults: string[]
|
||||||
|
): Promise<
|
||||||
|
| { found: false }
|
||||||
|
| { found: true, uploaded: true }
|
||||||
|
| { found: boolean, uploaded: false, error: string, toolIndex: number }
|
||||||
|
> {
|
||||||
|
let sendFileAttachment: {
|
||||||
|
result: SendFileAttachmentResult & { success: true },
|
||||||
|
toolIndex: number
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [index, toolResult] of toolResults.entries()) {
|
||||||
|
const raw = JSON.parse(toolResult);
|
||||||
|
const res = SendFileAttachmentResultSchema.safeParse(raw);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
found = true;
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
sendFileAttachment = {result: res.data, toolIndex: index};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return {found: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
|
||||||
|
const attachmentPath = attachmentRoot
|
||||||
|
? path.join(
|
||||||
|
attachmentRoot,
|
||||||
|
String(msg.from?.id),
|
||||||
|
sendFileAttachment?.result?.attachment?.relativePath ?? "",
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!fs.existsSync(attachmentPath)) {
|
||||||
|
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.sendDocument({
|
||||||
|
chat_id: msg.chat.id,
|
||||||
|
reply_parameters: {
|
||||||
|
message_id: msg.message_id,
|
||||||
|
},
|
||||||
|
document: fs.createReadStream(attachmentPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {found: true, uploaded: true};
|
||||||
|
} catch (e) {
|
||||||
|
logError(e instanceof Error ? e : String(e));
|
||||||
|
return {
|
||||||
|
found: found,
|
||||||
|
uploaded: false,
|
||||||
|
error: errorMessage(e instanceof Error ? e : String(e)),
|
||||||
|
toolIndex: sendFileAttachment?.toolIndex ?? -1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type {ToolCallData} from "./unified-ai-runner.shared.js";
|
||||||
|
import type {ResponseStreamEvent} from "openai/resources/responses/responses";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallId(value: unknown, fallback: string): string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolArguments(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return JSON.stringify(value ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolArgumentsChunk(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
|
||||||
|
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
|
||||||
|
|
||||||
|
return output
|
||||||
|
.filter(item => isRecord(item) && item.type === "function_call" && (typeof item.call_id === "string" || typeof item.name === "string"))
|
||||||
|
.map((item, index) => ({
|
||||||
|
id: normalizeToolCallId(item.call_id, `openai_${index}`),
|
||||||
|
name: typeof item.name === "string" ? item.name : "",
|
||||||
|
argumentsText: normalizeToolArguments(item.arguments),
|
||||||
|
}))
|
||||||
|
.filter(call => call.name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAiTextDelta(input: unknown): string {
|
||||||
|
const event = input as ResponseStreamEvent | undefined;
|
||||||
|
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAiChatTextDelta(input: unknown): string {
|
||||||
|
const event = isRecord(input) ? input : undefined;
|
||||||
|
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
|
||||||
|
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
|
||||||
|
const content = delta && typeof delta.content === "string" ? delta.content : "";
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStreamingTextDelta(existingText: string, deltaText: string): string {
|
||||||
|
if (!deltaText) return "";
|
||||||
|
if (!existingText) return deltaText;
|
||||||
|
|
||||||
|
if (deltaText.startsWith(existingText)) {
|
||||||
|
return deltaText.slice(existingText.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deltaText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAiChatToolCalls(response: unknown): ToolCallData[] {
|
||||||
|
const record = isRecord(response) ? response : undefined;
|
||||||
|
const choice = record && Array.isArray(record.choices) && isRecord(record.choices[0]) ? record.choices[0] : undefined;
|
||||||
|
const message = isRecord(choice?.message) ? choice.message : undefined;
|
||||||
|
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||||
|
|
||||||
|
return toolCalls
|
||||||
|
.filter((item, index) => isRecord(item) && ((typeof item.id === "string") || typeof item.index === "number" || index >= 0))
|
||||||
|
.map((item, index) => {
|
||||||
|
const call = isRecord(item) ? item : {};
|
||||||
|
const fn = isRecord(call.function) ? call.function : undefined;
|
||||||
|
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
|
||||||
|
return {
|
||||||
|
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
|
||||||
|
name,
|
||||||
|
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(call => call.name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAiChatStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
const event = isRecord(input) ? input : undefined;
|
||||||
|
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
|
||||||
|
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
|
||||||
|
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
|
||||||
|
|
||||||
|
return toolCalls
|
||||||
|
.map((item, index) => {
|
||||||
|
const call = isRecord(item) ? item : {};
|
||||||
|
const fn = isRecord(call.function) ? call.function : undefined;
|
||||||
|
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
|
||||||
|
return {
|
||||||
|
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
|
||||||
|
name,
|
||||||
|
argumentsText: normalizeToolArgumentsChunk(fn?.arguments ?? call.arguments),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(call => call.id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeToolCallChunks(existing: ToolCallData[], chunks: ToolCallData[]): ToolCallData[] {
|
||||||
|
const merged = new Map<string, ToolCallData>(existing.map(call => [call.id, {...call}]));
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const current = merged.get(chunk.id);
|
||||||
|
if (!current) {
|
||||||
|
merged.set(chunk.id, {...chunk});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.set(chunk.id, {
|
||||||
|
id: current.id,
|
||||||
|
name: current.name || chunk.name,
|
||||||
|
argumentsText: current.argumentsText + (chunk.argumentsText ?? ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
const event = input as ResponseStreamEvent | undefined;
|
||||||
|
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
|
||||||
|
return extractOpenAiToolCalls({
|
||||||
|
output: [{
|
||||||
|
type: "function_call",
|
||||||
|
call_id: event.item.call_id ?? event.item.id,
|
||||||
|
name: event.item.name,
|
||||||
|
arguments: event.item.arguments,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMistralToolCalls(calls: unknown): ToolCallData[] {
|
||||||
|
const normalized = Array.isArray(calls)
|
||||||
|
? calls
|
||||||
|
: isRecord(calls) && (Array.isArray(calls.toolCalls) || Array.isArray(calls.tool_calls))
|
||||||
|
? (calls.toolCalls ?? calls.tool_calls)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!Array.isArray(normalized)) return [];
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
.map((item, index) => {
|
||||||
|
const call = isRecord(item) ? item : {};
|
||||||
|
const fn = isRecord(call.function) ? call.function : undefined;
|
||||||
|
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
|
||||||
|
return {
|
||||||
|
id: normalizeToolCallId(call.id, `mistral_${index}`),
|
||||||
|
name,
|
||||||
|
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(call => call.name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMistralTextDelta(input: unknown): string {
|
||||||
|
const delta = isRecord(input) ? input : {};
|
||||||
|
const content = delta.content;
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOllamaToolCalls(calls: unknown): ToolCallData[] {
|
||||||
|
const normalized = Array.isArray(calls)
|
||||||
|
? calls
|
||||||
|
: isRecord(calls) && Array.isArray(calls.tool_calls)
|
||||||
|
? calls.tool_calls
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!Array.isArray(normalized)) return [];
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
.map((item, index) => {
|
||||||
|
const call = isRecord(item) ? item : {};
|
||||||
|
const fn = isRecord(call.function) ? call.function : undefined;
|
||||||
|
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
|
||||||
|
return {
|
||||||
|
id: normalizeToolCallId(call.id, `ollama_${index}`),
|
||||||
|
name,
|
||||||
|
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(call => call.name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOllamaTextDelta(input: unknown): string {
|
||||||
|
const chunk = isRecord(input) ? input.message : undefined;
|
||||||
|
return isRecord(chunk) && typeof chunk.content === "string" ? chunk.content : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
import type {BoundaryValue} from "../common/boundary-types.js";
|
||||||
|
import type {RuntimeConfigSnapshot, ToolCallData} from "./unified-ai-runner.shared.js";
|
||||||
|
import {getMistralTools, getOllamaTools, getOpenAIResponsesTools, getOpenAICodeInterpreterTool} from "./tool-mappers.js";
|
||||||
|
import type {MistralChatMessage as MistralMessageType} from "./mistral-chat-message.js";
|
||||||
|
import type {OpenAIChatMessage as OpenAiMessageType} from "./openai-chat-message.js";
|
||||||
|
import type {Message as OllamaMessage} from "ollama";
|
||||||
|
import {
|
||||||
|
extractMistralTextDelta,
|
||||||
|
extractMistralToolCalls,
|
||||||
|
extractOllamaTextDelta,
|
||||||
|
extractOllamaToolCalls,
|
||||||
|
extractOpenAiTextDelta,
|
||||||
|
extractOpenAiStreamingToolCalls,
|
||||||
|
extractOpenAiToolCalls,
|
||||||
|
} from "./provider-adapter-contract.js";
|
||||||
|
|
||||||
|
export type ProviderRankToolOptions = {
|
||||||
|
forCreator?: boolean;
|
||||||
|
vectorStoreIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AiProviderAdapter {
|
||||||
|
readonly provider: AiProvider;
|
||||||
|
mapMessages(messages: readonly unknown[]): unknown[];
|
||||||
|
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[];
|
||||||
|
callModel<T>(request: unknown, execute: () => Promise<T>): Promise<T>;
|
||||||
|
extractTextDelta(input: unknown): string;
|
||||||
|
extractToolCalls(input: unknown): ToolCallData[];
|
||||||
|
extractStreamingToolCalls(input: unknown): ToolCallData[];
|
||||||
|
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void;
|
||||||
|
finalize(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOllamaToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
|
||||||
|
for (const [index, call] of calls.entries()) {
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
content: results[index] ?? "",
|
||||||
|
tool_name: call.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenAiProviderAdapter implements AiProviderAdapter {
|
||||||
|
readonly provider = AiProvider.OPENAI;
|
||||||
|
|
||||||
|
mapMessages(messages: readonly unknown[]): unknown[] {
|
||||||
|
return messages as OpenAiMessageType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
|
||||||
|
const tools: BoundaryValue[] = [
|
||||||
|
...getOpenAIResponsesTools(options?.forCreator) as BoundaryValue[],
|
||||||
|
getOpenAICodeInterpreterTool() as BoundaryValue,
|
||||||
|
{
|
||||||
|
type: "image_generation",
|
||||||
|
model: config.openAiImageTarget.model,
|
||||||
|
size: "auto",
|
||||||
|
moderation: "low",
|
||||||
|
output_format: "png",
|
||||||
|
partial_images: 3,
|
||||||
|
},
|
||||||
|
{type: "web_search"},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.vectorStoreIds?.length) {
|
||||||
|
tools.unshift({
|
||||||
|
type: "file_search",
|
||||||
|
vector_store_ids: options.vectorStoreIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
|
||||||
|
return execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
extractTextDelta(input: unknown): string {
|
||||||
|
return extractOpenAiTextDelta(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
return extractOpenAiToolCalls(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
return extractOpenAiStreamingToolCalls(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
|
||||||
|
for (const [index, call] of calls.entries()) {
|
||||||
|
messages.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: results[index] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MistralProviderAdapter implements AiProviderAdapter {
|
||||||
|
readonly provider = AiProvider.MISTRAL;
|
||||||
|
|
||||||
|
mapMessages(messages: readonly unknown[]): unknown[] {
|
||||||
|
return messages as MistralMessageType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
|
||||||
|
return getMistralTools(options?.forCreator) as BoundaryValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
|
||||||
|
return execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
extractTextDelta(input: unknown): string {
|
||||||
|
return extractMistralTextDelta(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
return extractMistralToolCalls(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
return this.extractToolCalls(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
|
||||||
|
for (const [index, call] of calls.entries()) {
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
name: call.name,
|
||||||
|
toolCallId: call.id,
|
||||||
|
content: results[index] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OllamaProviderAdapter implements AiProviderAdapter {
|
||||||
|
readonly provider = AiProvider.OLLAMA;
|
||||||
|
|
||||||
|
mapMessages(messages: readonly unknown[]): unknown[] {
|
||||||
|
return messages as OllamaMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
|
||||||
|
return getOllamaTools(options?.forCreator) as BoundaryValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
|
||||||
|
return execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
extractTextDelta(input: unknown): string {
|
||||||
|
return extractOllamaTextDelta(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
return extractOllamaToolCalls(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
return this.extractToolCalls(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
|
||||||
|
appendOllamaToolResults(messages, calls, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderAdapter(provider: AiProvider): AiProviderAdapter {
|
||||||
|
switch (provider) {
|
||||||
|
case AiProvider.OPENAI:
|
||||||
|
return new OpenAiProviderAdapter();
|
||||||
|
case AiProvider.MISTRAL:
|
||||||
|
return new MistralProviderAdapter();
|
||||||
|
case AiProvider.OLLAMA:
|
||||||
|
return new OllamaProviderAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
|
||||||
|
const PROVIDER_ALIASES = new Map<string, AiProvider>([
|
||||||
|
["openai", AiProvider.OPENAI],
|
||||||
|
["chatgpt", AiProvider.OPENAI],
|
||||||
|
["gpt", AiProvider.OPENAI],
|
||||||
|
["mistral", AiProvider.MISTRAL],
|
||||||
|
["ollama", AiProvider.OLLAMA],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseProviderToken(token: string | undefined): AiProvider | undefined {
|
||||||
|
if (!token) return undefined;
|
||||||
|
return PROVIDER_ALIASES.get(token.toLowerCase().replace(/:$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerDisplayName(provider: AiProvider): string {
|
||||||
|
return provider.charAt(0) + provider.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
@@ -7,12 +7,9 @@ import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
|
|||||||
import {
|
import {
|
||||||
AiCapabilityName,
|
AiCapabilityName,
|
||||||
AiRuntimeTarget,
|
AiRuntimeTarget,
|
||||||
createGeminiOpenAiClient,
|
|
||||||
createGoogleGenAiClient,
|
|
||||||
createMistralClient,
|
createMistralClient,
|
||||||
createOllamaClient,
|
createOllamaClient,
|
||||||
createOpenAiClient,
|
createOpenAiClient,
|
||||||
getGeminiApiMode,
|
|
||||||
resolveAiRuntimeTarget,
|
resolveAiRuntimeTarget,
|
||||||
sameRuntimeEndpoint,
|
sameRuntimeEndpoint,
|
||||||
} from "./ai-runtime-target";
|
} from "./ai-runtime-target";
|
||||||
@@ -35,8 +32,6 @@ export function getRuntimeModel(provider: AiProvider): string {
|
|||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return Environment.OLLAMA_CHAT_MODEL;
|
return Environment.OLLAMA_CHAT_MODEL;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return Environment.GEMINI_MODEL;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return Environment.MISTRAL_MODEL;
|
return Environment.MISTRAL_MODEL;
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
@@ -49,9 +44,6 @@ export function setRuntimeModel(provider: AiProvider, model: string): void {
|
|||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
Environment.OLLAMA_CHAT_MODEL = model;
|
Environment.OLLAMA_CHAT_MODEL = model;
|
||||||
break;
|
break;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
Environment.GEMINI_MODEL = model;
|
|
||||||
break;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
Environment.MISTRAL_MODEL = model;
|
Environment.MISTRAL_MODEL = model;
|
||||||
break;
|
break;
|
||||||
@@ -111,12 +103,6 @@ function isOpenAiReasoningModel(model: string): boolean {
|
|||||||
return /^o\d/.test(name) || name.startsWith("gpt-5");
|
return /^o\d/.test(name) || name.startsWith("gpt-5");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: OpenAI image input rollout
|
|
||||||
// 1. Keep OpenAI chat payload building `input_image` parts from `MessagePart.imageParts`.
|
|
||||||
// 2. Replace name-only vision detection with a dedicated OpenAI vision-capability helper.
|
|
||||||
// 3. Use allowlist/denylist heuristics for `vision` and `ocr`, with a probe/cache fallback later if needed.
|
|
||||||
// 4. Gate `rejectUnsupportedAttachments()` on the resolved vision capability, not on `OPENAI_IMAGE_MODEL`.
|
|
||||||
// 5. Add tests for supported/unsupported model names and the resulting `input_image` payload shape.
|
|
||||||
function isOpenAiVisionModel(model: string): boolean {
|
function isOpenAiVisionModel(model: string): boolean {
|
||||||
const name = lowerModelName(model);
|
const name = lowerModelName(model);
|
||||||
if (!isOpenAiTextModel(model)) return false;
|
if (!isOpenAiTextModel(model)) return false;
|
||||||
@@ -125,16 +111,6 @@ function isOpenAiVisionModel(model: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGeminiNonChatModel(model: string): boolean {
|
|
||||||
const name = lowerModelName(model);
|
|
||||||
return name.includes("lyria") || name.includes("-tts") || name.includes("image-preview") || name.endsWith("-image");
|
|
||||||
}
|
|
||||||
|
|
||||||
function geminiSupportsAudioInput(model: string): boolean {
|
|
||||||
const name = lowerModelName(model);
|
|
||||||
return name.startsWith("gemini-") && !isGeminiNonChatModel(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getModelCapabilities(
|
export async function getModelCapabilities(
|
||||||
provider: AiProvider,
|
provider: AiProvider,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -167,26 +143,6 @@ export async function getModelCapabilities(
|
|||||||
speechToText: capability(audioSupported, target, runtimeTarget),
|
speechToText: capability(audioSupported, target, runtimeTarget),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case AiProvider.GEMINI: {
|
|
||||||
const chatLike = lowerModelName(model).startsWith("gemini-") && !isGeminiNonChatModel(model);
|
|
||||||
const reasoningModel = lowerModelName(model).includes("2.5") || lowerModelName(model).includes("thinking");
|
|
||||||
const imageTarget = resolveAiRuntimeTarget(provider, "vision");
|
|
||||||
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
|
||||||
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
|
||||||
|
|
||||||
return buildCapabilities({
|
|
||||||
chat: capability(true, target, runtimeTarget),
|
|
||||||
vision: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
|
|
||||||
ocr: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
|
|
||||||
thinking: capability(reasoningModel, target, runtimeTarget),
|
|
||||||
extendedThinking: capability(reasoningModel, target, runtimeTarget),
|
|
||||||
tools: capability(chatLike, target, runtimeTarget),
|
|
||||||
audio: capability(geminiSupportsAudioInput(model), target, runtimeTarget),
|
|
||||||
speechToText: capability(!!speechTarget.apiKey && geminiSupportsAudioInput(speechTarget.model), speechTarget, runtimeTarget),
|
|
||||||
outputImages: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
|
|
||||||
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case AiProvider.MISTRAL: {
|
case AiProvider.MISTRAL: {
|
||||||
const mistral = createMistralClient(target);
|
const mistral = createMistralClient(target);
|
||||||
const info = await mistral.models.retrieve({modelId: model});
|
const info = await mistral.models.retrieve({modelId: model});
|
||||||
@@ -220,6 +176,7 @@ export async function getModelCapabilities(
|
|||||||
thinking: capability(reasoningModel, target, runtimeTarget),
|
thinking: capability(reasoningModel, target, runtimeTarget),
|
||||||
extendedThinking: capability(reasoningModel, target, runtimeTarget),
|
extendedThinking: capability(reasoningModel, target, runtimeTarget),
|
||||||
tools: capability(textModel, target, runtimeTarget),
|
tools: capability(textModel, target, runtimeTarget),
|
||||||
|
documents: capability(textModel, target, runtimeTarget),
|
||||||
outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget),
|
outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget),
|
||||||
speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget),
|
speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget),
|
||||||
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
|
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
|
||||||
@@ -228,7 +185,7 @@ export async function getModelCapabilities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,9 +196,14 @@ export async function getRuntimeCapabilities(
|
|||||||
target?: AiRuntimeTarget
|
target?: AiRuntimeTarget
|
||||||
): Promise<AiModelCapabilities> {
|
): Promise<AiModelCapabilities> {
|
||||||
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
|
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
|
||||||
const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
|
const targetPurpose = target?.purpose && target.purpose !== "memoryCompress" ? target.purpose : "chat";
|
||||||
|
const result = await getModelCapabilities(provider, runtimeTarget.model, targetPurpose) ?? buildCapabilities({});
|
||||||
|
|
||||||
for (const capabilityName of CAPABILITY_NAMES) {
|
for (const capabilityName of CAPABILITY_NAMES) {
|
||||||
|
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const target = resolveAiRuntimeTarget(provider, capabilityName);
|
const target = resolveAiRuntimeTarget(provider, capabilityName);
|
||||||
if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue;
|
if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue;
|
||||||
|
|
||||||
@@ -322,23 +284,6 @@ export async function listProviderModels(provider: AiProvider): Promise<string[]
|
|||||||
const result = await ollama.list() as ModelListResponse;
|
const result = await ollama.list() as ModelListResponse;
|
||||||
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
|
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 = 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 = 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\//, ""));
|
|
||||||
}
|
|
||||||
return models;
|
|
||||||
}
|
|
||||||
case AiProvider.MISTRAL: {
|
case AiProvider.MISTRAL: {
|
||||||
const mistralAi = createMistralClient(target);
|
const mistralAi = createMistralClient(target);
|
||||||
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
|
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {appLogger} from "../logging/logger";
|
import {appLogger} from "../logging/logger";
|
||||||
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
|
|
||||||
const logger = appLogger.child("ai-provider-queue");
|
const logger = appLogger.child("ai-provider-queue");
|
||||||
|
|
||||||
@@ -13,16 +14,16 @@ export type AiRequestQueueTarget = {
|
|||||||
type QueueEntry = {
|
type QueueEntry = {
|
||||||
target: AiRequestQueueTarget;
|
target: AiRequestQueueTarget;
|
||||||
queueKey: string;
|
queueKey: string;
|
||||||
run: () => Promise<unknown>;
|
run: () => Promise<BoundaryValue>;
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: BoundaryValue) => void;
|
||||||
reject: (reason?: unknown) => void;
|
reject: (reason?: Error | string | BoundaryValue | null | undefined) => void;
|
||||||
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
abortHandler?: () => void;
|
abortHandler?: () => void;
|
||||||
started: boolean;
|
started: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EnqueueOptions<T> = {
|
type EnqueueOptions<T extends BoundaryValue> = {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
||||||
run: () => Promise<T>;
|
run: () => Promise<T>;
|
||||||
@@ -32,7 +33,7 @@ class AiProviderRequestQueue {
|
|||||||
private readonly waiting = new Map<string, QueueEntry[]>();
|
private readonly waiting = new Map<string, QueueEntry[]>();
|
||||||
private readonly active = new Map<string, number>();
|
private readonly active = new Map<string, number>();
|
||||||
|
|
||||||
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
|
enqueue<T extends BoundaryValue>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
|
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
|
||||||
return Promise.reject(new Error("Aborted"));
|
return Promise.reject(new Error("Aborted"));
|
||||||
@@ -160,8 +161,9 @@ class AiProviderRequestQueue {
|
|||||||
entry.resolve(await entry.run());
|
entry.resolve(await entry.run());
|
||||||
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
|
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error: e});
|
const error = e instanceof Error ? e : String(e);
|
||||||
entry.reject(e);
|
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error});
|
||||||
|
entry.reject(error);
|
||||||
} finally {
|
} finally {
|
||||||
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
|
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
|
||||||
this.schedule(entry.target);
|
this.schedule(entry.target);
|
||||||
@@ -178,10 +180,10 @@ class AiProviderRequestQueue {
|
|||||||
})).then(results => {
|
})).then(results => {
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === "rejected") {
|
if (result.status === "rejected") {
|
||||||
logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason});
|
logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason instanceof Error ? result.reason : String(result.reason)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error}));
|
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error: error instanceof Error ? error : String(error)}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
|
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type {AiProvider} from "../model/ai-provider";
|
||||||
|
|
||||||
|
export type RagArtifactSource = {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
sha256?: string;
|
||||||
|
uploadedFileId?: string;
|
||||||
|
documentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RagArtifactPayload = {
|
||||||
|
artifactKind: "rag";
|
||||||
|
provider: AiProvider;
|
||||||
|
createdAt: string;
|
||||||
|
sources: RagArtifactSource[];
|
||||||
|
providerState:
|
||||||
|
| {
|
||||||
|
provider: AiProvider.OPENAI;
|
||||||
|
vectorStoreIds: string[];
|
||||||
|
uploadedFileIds: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: AiProvider.MISTRAL;
|
||||||
|
libraryId?: string;
|
||||||
|
documentCount: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: AiProvider.OLLAMA;
|
||||||
|
prepared: boolean;
|
||||||
|
embeddingModel?: string;
|
||||||
|
topK?: number;
|
||||||
|
chunkSize?: number;
|
||||||
|
chunkOverlap?: number;
|
||||||
|
maxContextChars?: number;
|
||||||
|
extractedDocuments: Array<{
|
||||||
|
documentIndex: number;
|
||||||
|
fileName: string;
|
||||||
|
textChars: number;
|
||||||
|
}>;
|
||||||
|
selectedChunks: Array<{
|
||||||
|
sourceId: string;
|
||||||
|
documentIndex: number;
|
||||||
|
documentName: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkCount: number;
|
||||||
|
textChars: number;
|
||||||
|
score?: number;
|
||||||
|
}>;
|
||||||
|
skippedDocuments: Array<{
|
||||||
|
documentIndex: number;
|
||||||
|
fileName: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
query: string;
|
||||||
|
minScore: number;
|
||||||
|
maxArchiveFiles: number;
|
||||||
|
maxArchiveBytes: number;
|
||||||
|
maxArchiveDepth: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildRagArtifactPayload(params: {
|
||||||
|
provider: AiProvider;
|
||||||
|
createdAt?: string;
|
||||||
|
sources: RagArtifactSource[];
|
||||||
|
providerState: RagArtifactPayload["providerState"];
|
||||||
|
}): RagArtifactPayload {
|
||||||
|
return {
|
||||||
|
artifactKind: "rag",
|
||||||
|
provider: params.provider,
|
||||||
|
createdAt: params.createdAt ?? new Date().toISOString(),
|
||||||
|
sources: params.sources,
|
||||||
|
providerState: params.providerState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import type {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import type {PreparedDocumentRag} from "./document-rag-pipeline";
|
||||||
|
import type {OllamaRagArtifactDetails} from "./ollama-rag";
|
||||||
|
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
||||||
|
import {buildRagArtifactPayload, type RagArtifactPayload} from "./rag-artifact-payload";
|
||||||
|
|
||||||
|
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
|
||||||
|
switch (prepared.provider) {
|
||||||
|
case AiProvider.OPENAI:
|
||||||
|
return {
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
vectorStoreIds: prepared.vectorStoreIds,
|
||||||
|
uploadedFileIds: prepared.uploadedFileIds,
|
||||||
|
};
|
||||||
|
case AiProvider.MISTRAL:
|
||||||
|
return {
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
|
libraryId: prepared.libraryId,
|
||||||
|
documentCount: prepared.documents.length,
|
||||||
|
};
|
||||||
|
case AiProvider.OLLAMA:
|
||||||
|
return {
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
prepared: prepared.prepared,
|
||||||
|
embeddingModel: details?.embeddingModel,
|
||||||
|
topK: details?.topK,
|
||||||
|
chunkSize: details?.chunkSize,
|
||||||
|
chunkOverlap: details?.chunkOverlap,
|
||||||
|
maxContextChars: details?.maxContextChars,
|
||||||
|
extractedDocuments: details?.artifact?.extractedDocuments ?? [],
|
||||||
|
selectedChunks: details?.artifact?.selectedChunks ?? [],
|
||||||
|
skippedDocuments: details?.artifact?.skippedDocuments ?? [],
|
||||||
|
query: details?.artifact?.query ?? "",
|
||||||
|
minScore: details?.artifact?.providerState?.minScore ?? 0,
|
||||||
|
maxArchiveFiles: details?.artifact?.providerState?.maxArchiveFiles ?? 0,
|
||||||
|
maxArchiveBytes: details?.artifact?.providerState?.maxArchiveBytes ?? 0,
|
||||||
|
maxArchiveDepth: details?.artifact?.providerState?.maxArchiveDepth ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistRagArtifactAttachment(params: {
|
||||||
|
provider: AiProvider;
|
||||||
|
prepared: PreparedDocumentRag | undefined;
|
||||||
|
downloads: AiDownloadedFile[];
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
details?: {
|
||||||
|
uploadedFileIds?: string[];
|
||||||
|
sourceDocuments?: Array<{
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
sha256?: string;
|
||||||
|
uploadedFileId?: string;
|
||||||
|
documentId?: string;
|
||||||
|
}>;
|
||||||
|
embeddingModel?: string;
|
||||||
|
topK?: number;
|
||||||
|
chunkSize?: number;
|
||||||
|
chunkOverlap?: number;
|
||||||
|
maxContextChars?: number;
|
||||||
|
artifact?: OllamaRagArtifactDetails;
|
||||||
|
};
|
||||||
|
}): Promise<StoredAttachment | undefined> {
|
||||||
|
if (!params.prepared) return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
const sources = params.downloads
|
||||||
|
.filter(download => download.kind === "document")
|
||||||
|
.map((download, index) => ({
|
||||||
|
fileId: download.fileId,
|
||||||
|
fileName: download.fileName,
|
||||||
|
mimeType: download.mimeType,
|
||||||
|
sizeBytes: download.sizeBytes ?? download.buffer.length,
|
||||||
|
sha256: download.sha256,
|
||||||
|
uploadedFileId: params.details?.uploadedFileIds?.[index],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!sources.length) return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
const payload = buildRagArtifactPayload({
|
||||||
|
provider: params.provider,
|
||||||
|
sources,
|
||||||
|
providerState: providerState(params.prepared, params.details),
|
||||||
|
});
|
||||||
|
return await persistInternalJsonArtifactAttachment({
|
||||||
|
artifactKind: "rag",
|
||||||
|
fileNamePrefix: "rag",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
payload,
|
||||||
|
metadata: {
|
||||||
|
sourceFileNames: sources.map(source => source.fileName),
|
||||||
|
...payload.providerState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type {RagArtifactPayload} from "./rag-artifact-payload";
|
||||||
|
|
||||||
|
export type ArtifactLike = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RagCleanupTarget = {
|
||||||
|
artifactId: string;
|
||||||
|
createdAt: string;
|
||||||
|
provider: RagArtifactPayload["providerState"]["provider"];
|
||||||
|
vectorStoreIds?: string[];
|
||||||
|
uploadedFileIds?: string[];
|
||||||
|
libraryId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RagCleanupPlan = {
|
||||||
|
cutoffAt: string;
|
||||||
|
targets: RagCleanupTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseRagArtifactPayload(payload: string): RagArtifactPayload | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload) as Partial<RagArtifactPayload>;
|
||||||
|
if (!parsed || parsed.artifactKind !== "rag" || !parsed.providerState) return null;
|
||||||
|
return parsed as RagArtifactPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStaleRagCleanupPlan(
|
||||||
|
artifacts: ArtifactLike[],
|
||||||
|
retentionDays = 14,
|
||||||
|
now = new Date(),
|
||||||
|
): RagCleanupPlan {
|
||||||
|
const cutoffAt = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const targets: RagCleanupTarget[] = [];
|
||||||
|
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
if (artifact.createdAt > cutoffAt) continue;
|
||||||
|
|
||||||
|
const payload = parseRagArtifactPayload(artifact.payload);
|
||||||
|
if (!payload || payload.artifactKind !== "rag") continue;
|
||||||
|
|
||||||
|
switch (payload.providerState.provider) {
|
||||||
|
case "OPENAI":
|
||||||
|
if (payload.providerState.vectorStoreIds.length || payload.providerState.uploadedFileIds.length) {
|
||||||
|
targets.push({
|
||||||
|
artifactId: artifact.id,
|
||||||
|
createdAt: artifact.createdAt,
|
||||||
|
provider: payload.providerState.provider,
|
||||||
|
vectorStoreIds: [...payload.providerState.vectorStoreIds],
|
||||||
|
uploadedFileIds: [...payload.providerState.uploadedFileIds],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "MISTRAL":
|
||||||
|
if (payload.providerState.libraryId) {
|
||||||
|
targets.push({
|
||||||
|
artifactId: artifact.id,
|
||||||
|
createdAt: artifact.createdAt,
|
||||||
|
provider: payload.providerState.provider,
|
||||||
|
libraryId: payload.providerState.libraryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "OLLAMA":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {cutoffAt, targets};
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {appLogger} from "../logging/logger.js";
|
||||||
|
import {DatabaseManager} from "../db/database-manager.js";
|
||||||
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
import {createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
|
||||||
|
import {deleteMistralLibrary} from "./unified-ai-runner.shared.js";
|
||||||
|
import {buildStaleRagCleanupPlan} from "./rag-retention-planner.js";
|
||||||
|
|
||||||
|
const logger = appLogger.child("rag-retention");
|
||||||
|
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return [...new Set(values.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupOpenAiRag(vectorStoreIds: string[], uploadedFileIds: string[]): Promise<void> {
|
||||||
|
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "documents");
|
||||||
|
const client = createOpenAiClient(target);
|
||||||
|
|
||||||
|
for (const vectorStoreId of unique(vectorStoreIds)) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
logger.info("openai.vector_store.cleanup.start", {vectorStoreId});
|
||||||
|
try {
|
||||||
|
await client.vectorStores.delete(vectorStoreId);
|
||||||
|
logger.success("openai.vector_store.cleanup.done", {vectorStoreId, duration: `${Date.now() - startedAt}ms`});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("openai.vector_store.cleanup.failed", {
|
||||||
|
vectorStoreId,
|
||||||
|
duration: `${Date.now() - startedAt}ms`,
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fileId of unique(uploadedFileIds)) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
logger.info("openai.file.cleanup.start", {fileId});
|
||||||
|
try {
|
||||||
|
await client.files.delete(fileId);
|
||||||
|
logger.success("openai.file.cleanup.done", {fileId, duration: `${Date.now() - startedAt}ms`});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("openai.file.cleanup.failed", {
|
||||||
|
fileId,
|
||||||
|
duration: `${Date.now() - startedAt}ms`,
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupMistralRag(libraryId: string): Promise<void> {
|
||||||
|
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "documents");
|
||||||
|
const startedAt = Date.now();
|
||||||
|
logger.info("mistral.library.cleanup.start", {libraryId});
|
||||||
|
try {
|
||||||
|
await deleteMistralLibrary(libraryId, target);
|
||||||
|
logger.success("mistral.library.cleanup.done", {libraryId, duration: `${Date.now() - startedAt}ms`});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("mistral.library.cleanup.failed", {
|
||||||
|
libraryId,
|
||||||
|
duration: `${Date.now() - startedAt}ms`,
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupStaleRagProviderState(retentionDays = 14): Promise<{
|
||||||
|
scannedArtifacts: number;
|
||||||
|
cleanupTargets: number;
|
||||||
|
openaiTargets: number;
|
||||||
|
mistralTargets: number;
|
||||||
|
}> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const artifacts = await DatabaseManager.getAllArtifacts().catch(() => []);
|
||||||
|
const plan = buildStaleRagCleanupPlan(artifacts, retentionDays);
|
||||||
|
|
||||||
|
logger.info("cleanup.start", {
|
||||||
|
retentionDays,
|
||||||
|
scannedArtifacts: artifacts.length,
|
||||||
|
cleanupTargets: plan.targets.length,
|
||||||
|
cutoffAt: plan.cutoffAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
let openaiTargets = 0;
|
||||||
|
let mistralTargets = 0;
|
||||||
|
|
||||||
|
for (const target of plan.targets) {
|
||||||
|
switch (target.provider) {
|
||||||
|
case "OPENAI":
|
||||||
|
openaiTargets += 1;
|
||||||
|
await cleanupOpenAiRag(target.vectorStoreIds ?? [], target.uploadedFileIds ?? []);
|
||||||
|
break;
|
||||||
|
case "MISTRAL":
|
||||||
|
mistralTargets += 1;
|
||||||
|
if (target.libraryId) {
|
||||||
|
await cleanupMistralRag(target.libraryId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "OLLAMA":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success("cleanup.done", {
|
||||||
|
retentionDays,
|
||||||
|
scannedArtifacts: artifacts.length,
|
||||||
|
cleanupTargets: plan.targets.length,
|
||||||
|
openaiTargets,
|
||||||
|
mistralTargets,
|
||||||
|
duration: `${Date.now() - startedAt}ms`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
scannedArtifacts: artifacts.length,
|
||||||
|
cleanupTargets: plan.targets.length,
|
||||||
|
openaiTargets,
|
||||||
|
mistralTargets,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type {AiDownloadedFile} from "./telegram-attachments.js";
|
||||||
|
|
||||||
|
function downloadKey(download: AiDownloadedFile): string {
|
||||||
|
return [
|
||||||
|
download.kind,
|
||||||
|
download.fileId,
|
||||||
|
download.sha256 ?? "",
|
||||||
|
download.fileName,
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeReplyChainDownloads(
|
||||||
|
currentDownloads: readonly AiDownloadedFile[],
|
||||||
|
replyChainDownloads: readonly AiDownloadedFile[],
|
||||||
|
): AiDownloadedFile[] {
|
||||||
|
const result: AiDownloadedFile[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const download of [...currentDownloads, ...replyChainDownloads]) {
|
||||||
|
const key = downloadKey(download);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldPreferCurrentDownloads(text: string, currentDownloads: readonly AiDownloadedFile[]): boolean {
|
||||||
|
if (!currentDownloads.length) return false;
|
||||||
|
|
||||||
|
const normalized = text.trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
return normalized.includes("this file")
|
||||||
|
|| normalized.includes("this document")
|
||||||
|
|| normalized.includes("этот файл")
|
||||||
|
|| normalized.includes("этот документ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
|
||||||
|
|
||||||
|
export type NormalizedModelOutput = {
|
||||||
|
text: string;
|
||||||
|
toolExecutions: TelegramToolExecutionRecord[];
|
||||||
|
outputAttachments: TelegramOutputAttachmentRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function summarizeModelOutput(params: {
|
||||||
|
text: string;
|
||||||
|
toolExecutions: readonly TelegramToolExecutionRecord[];
|
||||||
|
outputAttachments: readonly TelegramOutputAttachmentRecord[];
|
||||||
|
}): NormalizedModelOutput {
|
||||||
|
return {
|
||||||
|
text: params.text.trim(),
|
||||||
|
toolExecutions: [...params.toolExecutions],
|
||||||
|
outputAttachments: [...params.outputAttachments],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,19 +2,13 @@ import fs, {openAsBlob} from "node:fs";
|
|||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {
|
import {
|
||||||
getAvailableAiProviderChoices,
|
getAvailableAiProviderChoices,
|
||||||
getProviderChoiceLabel,
|
|
||||||
normalizeAiProviderChoice,
|
normalizeAiProviderChoice,
|
||||||
resolveEffectiveAiProviderForUser,
|
resolveEffectiveAiProviderForUser,
|
||||||
} from "../common/user-ai-settings";
|
} from "../common/user-ai-settings";
|
||||||
|
import {providerDisplayName} from "./provider-aliases";
|
||||||
import {AiDownloadedFile} from "./telegram-attachments";
|
import {AiDownloadedFile} from "./telegram-attachments";
|
||||||
import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
|
import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
|
||||||
import {
|
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
||||||
createGoogleGenAiClient,
|
|
||||||
createMistralClient,
|
|
||||||
createOllamaClient,
|
|
||||||
createOpenAiClient,
|
|
||||||
resolveAiRuntimeTarget
|
|
||||||
} from "./ai-runtime-target";
|
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
|
|
||||||
export type TranscribedSpeech = {
|
export type TranscribedSpeech = {
|
||||||
@@ -39,10 +33,6 @@ export type SpeechToTextResolveOptions = {
|
|||||||
allowFallback?: boolean;
|
allowFallback?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function providerName(provider: AiProvider): string {
|
|
||||||
return getProviderChoiceLabel(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean {
|
export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean {
|
||||||
if (download.kind === "audio") return true;
|
if (download.kind === "audio") return true;
|
||||||
return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav"));
|
return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav"));
|
||||||
@@ -53,9 +43,6 @@ export function isSpeechToTextConfigured(provider: AiProvider): boolean {
|
|||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||||
return !!openAiTarget.apiKey && !!openAiTarget.model;
|
return !!openAiTarget.apiKey && !!openAiTarget.model;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
const geminiTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
|
||||||
return !!geminiTarget.apiKey && !!geminiTarget.model;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||||
return !!mistralTarget.apiKey && !!mistralTarget.model;
|
return !!mistralTarget.apiKey && !!mistralTarget.model;
|
||||||
@@ -78,7 +65,7 @@ export async function resolveSpeechToTextProviderForUser(
|
|||||||
|
|
||||||
if (preferredProvider) {
|
if (preferredProvider) {
|
||||||
if (!allowedProviders.includes(preferredProvider)) {
|
if (!allowedProviders.includes(preferredProvider)) {
|
||||||
throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(preferredProvider)));
|
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(preferredProvider)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSpeechToTextConfigured(preferredProvider)) {
|
if (isSpeechToTextConfigured(preferredProvider)) {
|
||||||
@@ -86,7 +73,7 @@ export async function resolveSpeechToTextProviderForUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!allowFallback) {
|
if (!allowFallback) {
|
||||||
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerName(preferredProvider)));
|
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerDisplayName(preferredProvider)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +99,6 @@ export async function transcribeSpeech(request: SpeechToTextRequest): Promise<Tr
|
|||||||
switch (request.provider) {
|
switch (request.provider) {
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
return transcribeOpenAiSpeech(request.audio, request.signal);
|
return transcribeOpenAiSpeech(request.audio, request.signal);
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return transcribeGeminiSpeech(request.audio, request.signal);
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return transcribeMistralSpeech(request.audio, request.signal);
|
return transcribeMistralSpeech(request.audio, request.signal);
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
@@ -177,37 +162,6 @@ async function transcribeMistralSpeech(audio: AiDownloadedFile, signal?: AbortSi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transcribeGeminiSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
|
|
||||||
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "speechToText");
|
|
||||||
const geminiAi = createGoogleGenAiClient(target);
|
|
||||||
const response = await geminiAi.models.generateContent({
|
|
||||||
model: target.model,
|
|
||||||
contents: [{
|
|
||||||
role: "user",
|
|
||||||
parts: [
|
|
||||||
{text: "Transcribe the attached audio verbatim. Reply only with the transcription text. Do not answer the speaker."},
|
|
||||||
{
|
|
||||||
inlineData: {
|
|
||||||
data: audio.buffer.toString("base64"),
|
|
||||||
mimeType: audio.mimeType || "audio/wav",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
config: {
|
|
||||||
temperature: 0,
|
|
||||||
abortSignal: signal,
|
|
||||||
},
|
|
||||||
}) as unknown as GeminiSpeechResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: AiProvider.GEMINI,
|
|
||||||
model: target.model,
|
|
||||||
text: collectGeminiText(response),
|
|
||||||
fileName: audio.fileName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
|
async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
|
||||||
if (signal?.aborted) throw new Error("Aborted");
|
if (signal?.aborted) throw new Error("Aborted");
|
||||||
|
|
||||||
@@ -239,20 +193,3 @@ async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSig
|
|||||||
fileName: audio.fileName,
|
fileName: audio.fileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiSpeechResponse = {
|
|
||||||
text?: string;
|
|
||||||
candidates?: Array<{content?: {parts?: Array<{text?: string}>}}> ;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
|
|||||||
+210
-16
@@ -9,6 +9,8 @@ import {performFFmpeg} from "../util/ffmpeg";
|
|||||||
import ffmpeg from "fluent-ffmpeg";
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
|
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
|
||||||
import {appLogger} from "../logging/logger";
|
import {appLogger} from "../logging/logger";
|
||||||
|
import {createHash} from "node:crypto";
|
||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline/types";
|
||||||
|
|
||||||
export type AiDownloadedFile = {
|
export type AiDownloadedFile = {
|
||||||
kind: StoredAttachmentKind;
|
kind: StoredAttachmentKind;
|
||||||
@@ -17,6 +19,33 @@ export type AiDownloadedFile = {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
path: string;
|
path: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
sha256?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RejectedTelegramAttachment = {
|
||||||
|
kind: StoredAttachmentKind;
|
||||||
|
fileId: string;
|
||||||
|
fileUniqueId?: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
limitBytes: number;
|
||||||
|
reason: "too_large";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramAttachmentDescriptor = {
|
||||||
|
kind: StoredAttachmentKind;
|
||||||
|
fileId: string;
|
||||||
|
fileUniqueId?: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageAttachmentCacheResult = {
|
||||||
|
attachments: StoredAttachment[];
|
||||||
|
rejected: RejectedTelegramAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const cachePathLocks = new KeyedAsyncLock();
|
const cachePathLocks = new KeyedAsyncLock();
|
||||||
@@ -91,7 +120,113 @@ function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefin
|
|||||||
return path.join(cacheDirFor(kind), `${base}${ext || ""}`);
|
return path.join(cacheDirFor(kind), `${base}${ext || ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> {
|
function fileSha256(location: string): string | undefined {
|
||||||
|
if (!fs.existsSync(location)) return undefined;
|
||||||
|
return createHash("sha256").update(fs.readFileSync(location)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectIfTooLarge(
|
||||||
|
rejected: RejectedTelegramAttachment[],
|
||||||
|
kind: StoredAttachmentKind,
|
||||||
|
fileId: string,
|
||||||
|
fileName: string,
|
||||||
|
mimeType?: string,
|
||||||
|
sizeBytes?: number,
|
||||||
|
fileUniqueId?: string,
|
||||||
|
): boolean {
|
||||||
|
if (!sizeBytes || sizeBytes <= PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected.push({
|
||||||
|
kind,
|
||||||
|
fileId,
|
||||||
|
fileUniqueId,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes,
|
||||||
|
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
|
||||||
|
reason: "too_large",
|
||||||
|
});
|
||||||
|
logger.warn("message.cache.rejected.too_large", {kind, fileId, fileName, mimeType, sizeBytes});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectTelegramAttachmentDescriptors(msg: Message): TelegramAttachmentDescriptor[] {
|
||||||
|
const attachments: TelegramAttachmentDescriptor[] = [];
|
||||||
|
|
||||||
|
if (msg.photo?.length) {
|
||||||
|
const size = msg.photo[msg.photo.length - 1]!;
|
||||||
|
attachments.push({
|
||||||
|
kind: "image",
|
||||||
|
fileId: size.file_id,
|
||||||
|
fileUniqueId: size.file_unique_id,
|
||||||
|
fileName: `${size.file_unique_id || size.file_id}.jpg`,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
sizeBytes: size.file_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.document) {
|
||||||
|
const doc = msg.document;
|
||||||
|
attachments.push({
|
||||||
|
kind: doc.mime_type?.startsWith("image/")
|
||||||
|
? "image"
|
||||||
|
: doc.mime_type?.startsWith("audio/")
|
||||||
|
? "audio"
|
||||||
|
: "document",
|
||||||
|
fileId: doc.file_id,
|
||||||
|
fileUniqueId: doc.file_unique_id,
|
||||||
|
fileName: doc.file_name || `${doc.file_unique_id || doc.file_id}`,
|
||||||
|
mimeType: doc.mime_type,
|
||||||
|
sizeBytes: doc.file_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.voice) {
|
||||||
|
attachments.push({
|
||||||
|
kind: "audio",
|
||||||
|
fileId: msg.voice.file_id,
|
||||||
|
fileUniqueId: msg.voice.file_unique_id,
|
||||||
|
fileName: `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`,
|
||||||
|
mimeType: msg.voice.mime_type || "audio/ogg",
|
||||||
|
sizeBytes: msg.voice.file_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.audio) {
|
||||||
|
attachments.push({
|
||||||
|
kind: "audio",
|
||||||
|
fileId: msg.audio.file_id,
|
||||||
|
fileUniqueId: msg.audio.file_unique_id,
|
||||||
|
fileName: msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`,
|
||||||
|
mimeType: msg.audio.mime_type,
|
||||||
|
sizeBytes: msg.audio.file_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.video_note) {
|
||||||
|
attachments.push({
|
||||||
|
kind: "video-note",
|
||||||
|
fileId: msg.video_note.file_id,
|
||||||
|
fileUniqueId: msg.video_note.file_unique_id,
|
||||||
|
fileName: `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`,
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
sizeBytes: msg.video_note.file_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadToCache(
|
||||||
|
kind: StoredAttachmentKind,
|
||||||
|
fileId: string,
|
||||||
|
fileName: string,
|
||||||
|
mimeType?: string,
|
||||||
|
fileUniqueId?: string,
|
||||||
|
sizeBytes?: number,
|
||||||
|
): Promise<StoredAttachment | null> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.debug("download.start", {kind, fileId, fileName, mimeType});
|
logger.debug("download.start", {kind, fileId, fileName, mimeType});
|
||||||
const file = await bot.getFile({file_id: fileId});
|
const file = await bot.getFile({file_id: fileId});
|
||||||
@@ -117,7 +252,17 @@ async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileN
|
|||||||
logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)});
|
logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {kind, fileId, fileUniqueId, fileName: finalFileName, mimeType, cachePath: location};
|
const resolvedSizeBytes = sizeBytes ?? (fs.existsSync(location) ? fs.statSync(location).size : undefined);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
fileId,
|
||||||
|
fileUniqueId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
cachePath: location,
|
||||||
|
sizeBytes: resolvedSizeBytes,
|
||||||
|
sha256: fileSha256(location),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> {
|
async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> {
|
||||||
@@ -153,24 +298,29 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
|
|||||||
if (fs.existsSync(tempOutput)) {
|
if (fs.existsSync(tempOutput)) {
|
||||||
fs.rmSync(tempOutput, {force: true});
|
fs.rmSync(tempOutput, {force: true});
|
||||||
}
|
}
|
||||||
logger.error("audio.convert.failed", {input, output, error: e});
|
logger.error("audio.convert.failed", {input, output, error: e instanceof Error ? e : String(e)});
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
|
export async function cacheMessageAttachmentsWithRejections(msg: Message): Promise<MessageAttachmentCacheResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const result: StoredAttachment[] = [];
|
const result: StoredAttachment[] = [];
|
||||||
|
const rejected: RejectedTelegramAttachment[] = [];
|
||||||
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
|
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (msg.photo?.length) {
|
if (msg.photo?.length) {
|
||||||
const size = msg.photo[msg.photo.length - 1]!;
|
const size = msg.photo[msg.photo.length - 1]!;
|
||||||
const file = await downloadToCache("image", size.file_id, `${size.file_unique_id || size.file_id}.jpg`, "image/jpeg", size.file_unique_id);
|
const fileName = `${size.file_unique_id || size.file_id}.jpg`;
|
||||||
|
const mimeType = "image/jpeg";
|
||||||
|
if (!rejectIfTooLarge(rejected, "image", size.file_id, fileName, mimeType, size.file_size, size.file_unique_id)) {
|
||||||
|
const file = await downloadToCache("image", size.file_id, fileName, mimeType, size.file_unique_id, size.file_size);
|
||||||
if (file) result.push(file);
|
if (file) result.push(file);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.document) {
|
if (msg.document) {
|
||||||
const doc = msg.document;
|
const doc = msg.document;
|
||||||
@@ -179,12 +329,19 @@ export async function cacheMessageAttachments(msg: Message): Promise<StoredAttac
|
|||||||
: doc.mime_type?.startsWith("audio/")
|
: doc.mime_type?.startsWith("audio/")
|
||||||
? "audio"
|
? "audio"
|
||||||
: "document";
|
: "document";
|
||||||
const file = await downloadToCache(kind, doc.file_id, doc.file_name || `${doc.file_unique_id || doc.file_id}`, doc.mime_type, doc.file_unique_id);
|
const fileName = doc.file_name || `${doc.file_unique_id || doc.file_id}`;
|
||||||
|
if (!rejectIfTooLarge(rejected, kind, doc.file_id, fileName, doc.mime_type, doc.file_size, doc.file_unique_id)) {
|
||||||
|
const file = await downloadToCache(kind, doc.file_id, fileName, doc.mime_type, doc.file_unique_id, doc.file_size);
|
||||||
if (file) result.push(file);
|
if (file) result.push(file);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.voice) {
|
if (msg.voice) {
|
||||||
const file = await downloadToCache("audio", msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`, msg.voice.mime_type || "audio/ogg", msg.voice.file_unique_id);
|
const fileName = `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`;
|
||||||
|
const mimeType = msg.voice.mime_type || "audio/ogg";
|
||||||
|
const file = rejectIfTooLarge(rejected, "audio", msg.voice.file_id, fileName, mimeType, msg.voice.file_size, msg.voice.file_unique_id)
|
||||||
|
? null
|
||||||
|
: await downloadToCache("audio", msg.voice.file_id, fileName, mimeType, msg.voice.file_unique_id, msg.voice.file_size);
|
||||||
if (file) {
|
if (file) {
|
||||||
const output = cachePathFor("audio", msg.voice.file_unique_id, msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.wav`);
|
const output = cachePathFor("audio", msg.voice.file_unique_id, msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.wav`);
|
||||||
try {
|
try {
|
||||||
@@ -192,8 +349,10 @@ export async function cacheMessageAttachments(msg: Message): Promise<StoredAttac
|
|||||||
file.cachePath = output;
|
file.cachePath = output;
|
||||||
file.fileName = file?.fileName?.replace(".ogg", ".wav");
|
file.fileName = file?.fileName?.replace(".ogg", ".wav");
|
||||||
file.mimeType = "audio/wav";
|
file.mimeType = "audio/wav";
|
||||||
|
file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes;
|
||||||
|
file.sha256 = fileSha256(output);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +360,19 @@ export async function cacheMessageAttachments(msg: Message): Promise<StoredAttac
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.audio) {
|
if (msg.audio) {
|
||||||
const file = await downloadToCache("audio", msg.audio.file_id, msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`, msg.audio.mime_type, msg.audio.file_unique_id);
|
const fileName = msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`;
|
||||||
|
if (!rejectIfTooLarge(rejected, "audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_size, msg.audio.file_unique_id)) {
|
||||||
|
const file = await downloadToCache("audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_unique_id, msg.audio.file_size);
|
||||||
if (file) result.push(file);
|
if (file) result.push(file);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.video_note) {
|
if (msg.video_note) {
|
||||||
const file = await downloadToCache("video-note", msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`, "video/mp4", msg.video_note.file_unique_id);
|
const fileName = `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`;
|
||||||
|
const mimeType = "video/mp4";
|
||||||
|
const file = rejectIfTooLarge(rejected, "video-note", msg.video_note.file_id, fileName, mimeType, msg.video_note.file_size, msg.video_note.file_unique_id)
|
||||||
|
? null
|
||||||
|
: await downloadToCache("video-note", msg.video_note.file_id, fileName, mimeType, msg.video_note.file_unique_id, msg.video_note.file_size);
|
||||||
if (file) {
|
if (file) {
|
||||||
const output = cachePathFor("audio", msg.video_note.file_unique_id, msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.wav`);
|
const output = cachePathFor("audio", msg.video_note.file_unique_id, msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.wav`);
|
||||||
try {
|
try {
|
||||||
@@ -214,33 +380,61 @@ export async function cacheMessageAttachments(msg: Message): Promise<StoredAttac
|
|||||||
file.cachePath = output;
|
file.cachePath = output;
|
||||||
file.fileName = file?.fileName?.replace(".mp4", ".wav");
|
file.fileName = file?.fileName?.replace(".mp4", ".wav");
|
||||||
file.mimeType = "audio/wav";
|
file.mimeType = "audio/wav";
|
||||||
|
file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes;
|
||||||
|
file.sha256 = fileSha256(output);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file) result.push(file);
|
if (file) result.push(file);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("message.cache.done", {chatId: msg.chat?.id, messageId: msg.message_id, attachments: result.length, duration: logger.duration(startedAt)});
|
logger.debug("message.cache.done", {
|
||||||
return result;
|
chatId: msg.chat?.id,
|
||||||
|
messageId: msg.message_id,
|
||||||
|
attachments: result.length,
|
||||||
|
rejected: rejected.length,
|
||||||
|
duration: logger.duration(startedAt),
|
||||||
|
});
|
||||||
|
return {attachments: result, rejected};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
|
||||||
|
const {attachments} = await cacheMessageAttachmentsWithRejections(msg);
|
||||||
|
return attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
|
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
|
||||||
logger.trace("downloaded_files.build", {attachments: attachments.length});
|
logger.trace("downloaded_files.build", {attachments: attachments.length});
|
||||||
return attachments
|
return attachments
|
||||||
.filter(attachment => fs.existsSync(attachment.cachePath))
|
.filter(attachment => fs.existsSync(attachment.cachePath))
|
||||||
.map(attachment => ({
|
.flatMap(attachment => {
|
||||||
|
const sizeBytes = attachment.sizeBytes ?? fs.statSync(attachment.cachePath).size;
|
||||||
|
if (sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||||
|
logger.warn("downloaded_files.skipped.too_large", {
|
||||||
|
kind: attachment.kind,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
sizeBytes,
|
||||||
|
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
kind: attachment.kind,
|
kind: attachment.kind,
|
||||||
fileId: attachment.fileId,
|
fileId: attachment.fileId,
|
||||||
fileName: attachment.fileName,
|
fileName: attachment.fileName,
|
||||||
mimeType: attachment.mimeType,
|
mimeType: attachment.mimeType,
|
||||||
buffer: fs.readFileSync(attachment.cachePath),
|
buffer: fs.readFileSync(attachment.cachePath),
|
||||||
path: attachment.cachePath,
|
path: attachment.cachePath,
|
||||||
}));
|
sizeBytes,
|
||||||
|
sha256: attachment.sha256,
|
||||||
|
}];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupDownloads(files: AiDownloadedFile[]): void {
|
export function cleanupDownloads(files: AiDownloadedFile[]): void {
|
||||||
|
|||||||
@@ -5,17 +5,22 @@ import {Environment} from "../common/environment";
|
|||||||
import {MessageStore} from "../common/message-store";
|
import {MessageStore} from "../common/message-store";
|
||||||
import {createQueuedFunction} from "../util/async-lock";
|
import {createQueuedFunction} from "../util/async-lock";
|
||||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||||
|
import {appLogger} from "../logging/logger";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
|
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
|
||||||
import {StoredMessage} from "../model/stored-message";
|
import {StoredMessage} from "../model/stored-message";
|
||||||
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings";
|
||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
|
||||||
|
import {recordToolCall} from "../common/ai-observability.js";
|
||||||
|
|
||||||
const TELEGRAM_LIMIT = 4096;
|
const TELEGRAM_LIMIT = 4096;
|
||||||
const TELEGRAM_CAPTION_LIMIT = 1024;
|
const TELEGRAM_CAPTION_LIMIT = 1024;
|
||||||
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
||||||
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
|
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||||
const EDIT_INTERVAL_MS = 4500;
|
const EDIT_INTERVAL_MS = 4500;
|
||||||
|
const logger = appLogger.child("telegram-stream-message");
|
||||||
|
|
||||||
export type TelegramArtifactFile = {
|
export type TelegramArtifactFile = {
|
||||||
kind: "image" | "file";
|
kind: "image" | "file";
|
||||||
@@ -25,6 +30,23 @@ export type TelegramArtifactFile = {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TelegramToolExecutionRecord = {
|
||||||
|
toolName: string;
|
||||||
|
callId: string;
|
||||||
|
argumentsText: string;
|
||||||
|
resultChars: number;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramOutputAttachmentRecord = {
|
||||||
|
artifactKind: "generated_file" | "tts_audio";
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
messageId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class TelegramStreamMessage {
|
export class TelegramStreamMessage {
|
||||||
private waitMessage: Message | null = null;
|
private waitMessage: Message | null = null;
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
@@ -34,8 +56,11 @@ export class TelegramStreamMessage {
|
|||||||
private mediaMode = false;
|
private mediaMode = false;
|
||||||
private cancelled = false;
|
private cancelled = false;
|
||||||
private cancelledProvider = "";
|
private cancelledProvider = "";
|
||||||
|
private readonly sendImagesAsDocuments: boolean;
|
||||||
private readonly startedAt = Date.now();
|
private readonly startedAt = Date.now();
|
||||||
private readonly enqueueEdit = createQueuedFunction();
|
private readonly enqueueEdit = createQueuedFunction();
|
||||||
|
private readonly toolExecutions: TelegramToolExecutionRecord[] = [];
|
||||||
|
private readonly outputAttachments: TelegramOutputAttachmentRecord[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sourceMessage: Message,
|
private readonly sourceMessage: Message,
|
||||||
@@ -45,7 +70,9 @@ export class TelegramStreamMessage {
|
|||||||
private readonly targetMessage?: Message,
|
private readonly targetMessage?: Message,
|
||||||
private readonly cancelProvider?: AiProvider,
|
private readonly cancelProvider?: AiProvider,
|
||||||
private readonly isGuest?: boolean,
|
private readonly isGuest?: boolean,
|
||||||
|
imageOutputMode: UserAiImageOutputMode = "photo",
|
||||||
) {
|
) {
|
||||||
|
this.sendImagesAsDocuments = imageOutputMode === AI_IMAGE_OUTPUT_MODE_DOCUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
keyboard(): InlineKeyboardMarkup {
|
keyboard(): InlineKeyboardMarkup {
|
||||||
@@ -74,18 +101,8 @@ export class TelegramStreamMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isMessageNotModified(error: unknown): boolean {
|
private isMessageNotModified(message: string): boolean {
|
||||||
const textToLookUp = "message is not modified";
|
return message.includes("message is not modified");
|
||||||
|
|
||||||
if (error && error instanceof Error) {
|
|
||||||
return String(error.message).includes(textToLookUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && error instanceof String) {
|
|
||||||
return error.includes(textToLookUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise<void> {
|
private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise<void> {
|
||||||
@@ -105,7 +122,8 @@ export class TelegramStreamMessage {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!this.isMessageNotModified(e)) logError(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +184,8 @@ export class TelegramStreamMessage {
|
|||||||
this.startFlushTimer();
|
this.startFlushTimer();
|
||||||
return this.waitMessage;
|
return this.waitMessage;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (this.isMessageNotModified(e)) {
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
if (this.isMessageNotModified(message)) {
|
||||||
this.lastSent = rawText;
|
this.lastSent = rawText;
|
||||||
await this.updateKeyboard(this.keyboard());
|
await this.updateKeyboard(this.keyboard());
|
||||||
await this.store();
|
await this.store();
|
||||||
@@ -174,7 +193,7 @@ export class TelegramStreamMessage {
|
|||||||
return this.waitMessage;
|
return this.waitMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
logError(e);
|
logError(e instanceof Error ? e : message);
|
||||||
this.waitMessage = null;
|
this.waitMessage = null;
|
||||||
this.mediaMode = false;
|
this.mediaMode = false;
|
||||||
}
|
}
|
||||||
@@ -220,6 +239,44 @@ export class TelegramStreamMessage {
|
|||||||
return this.text;
|
return this.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordToolExecution(record: TelegramToolExecutionRecord): void {
|
||||||
|
this.toolExecutions.push(record);
|
||||||
|
recordToolCall();
|
||||||
|
logger.debug("tool.execution.recorded", {
|
||||||
|
requestId: this.cancelRequestId,
|
||||||
|
toolName: record.toolName,
|
||||||
|
callId: record.callId,
|
||||||
|
resultChars: record.resultChars,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolExecutions(): TelegramToolExecutionRecord[] {
|
||||||
|
return [...this.toolExecutions];
|
||||||
|
}
|
||||||
|
|
||||||
|
recordOutputAttachment(record: TelegramOutputAttachmentRecord): void {
|
||||||
|
this.outputAttachments.push(record);
|
||||||
|
logger.debug("output_attachment.recorded", {
|
||||||
|
requestId: this.cancelRequestId,
|
||||||
|
artifactKind: record.artifactKind,
|
||||||
|
fileName: record.fileName,
|
||||||
|
sizeBytes: record.sizeBytes,
|
||||||
|
messageId: record.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputAttachments(): TelegramOutputAttachmentRecord[] {
|
||||||
|
return [...this.outputAttachments];
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceChatId(): number {
|
||||||
|
return this.sourceMessage.chat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceMessageId(): number {
|
||||||
|
return this.sourceMessage.message_id;
|
||||||
|
}
|
||||||
|
|
||||||
async flush(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> {
|
async flush(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> {
|
||||||
return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end));
|
return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end));
|
||||||
}
|
}
|
||||||
@@ -292,13 +349,14 @@ export class TelegramStreamMessage {
|
|||||||
}
|
}
|
||||||
if (shouldRemoveKeyboard) await this.removeKeyboard();
|
if (shouldRemoveKeyboard) await this.removeKeyboard();
|
||||||
this.lastSent = next;
|
this.lastSent = next;
|
||||||
} catch (e: unknown) {
|
} catch (e) {
|
||||||
if (shouldRemoveKeyboard && this.isMessageNotModified(e)) {
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
if (shouldRemoveKeyboard && this.isMessageNotModified(message)) {
|
||||||
await this.removeKeyboard();
|
await this.removeKeyboard();
|
||||||
this.lastSent = next;
|
this.lastSent = next;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.isMessageNotModified(e)) logError(e);
|
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,22 +371,27 @@ export class TelegramStreamMessage {
|
|||||||
await this.store();
|
await this.store();
|
||||||
}
|
}
|
||||||
|
|
||||||
async showImage(image: Buffer): Promise<void> {
|
async showImage(image: Buffer, attachment?: StoredAttachment): Promise<void> {
|
||||||
return this.enqueueEdit(() => this.showImageUnsafe(image));
|
return this.enqueueEdit(() => this.showImageUnsafe(image, attachment));
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> {
|
async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> {
|
||||||
return this.enqueueEdit(() => this.sendArtifactUnsafe(file));
|
return this.enqueueEdit(() => this.sendArtifactUnsafe(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showImageUnsafe(image: Buffer): Promise<void> {
|
private async showImageUnsafe(image: Buffer, attachment?: StoredAttachment): Promise<void> {
|
||||||
if (this.cancelled) return;
|
if (this.cancelled) return;
|
||||||
const next = this.visibleCaption();
|
const next = this.visibleCaption();
|
||||||
|
const useDocument = this.sendImagesAsDocuments;
|
||||||
|
|
||||||
if (!this.waitMessage) {
|
if (!this.waitMessage) {
|
||||||
if (this.stream) return;
|
if (this.stream) return;
|
||||||
|
|
||||||
this.waitMessage = await enqueueTelegramApiCall(
|
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
|
||||||
|
try {
|
||||||
|
this.waitMessage = useDocument
|
||||||
|
? await this.sendImageAsDocument(upload!, next)
|
||||||
|
: await enqueueTelegramApiCall(
|
||||||
() => bot.sendPhoto({
|
() => bot.sendPhoto({
|
||||||
chat_id: this.sourceMessage.chat.id,
|
chat_id: this.sourceMessage.chat.id,
|
||||||
photo: image,
|
photo: image,
|
||||||
@@ -342,17 +405,29 @@ export class TelegramStreamMessage {
|
|||||||
chatType: this.sourceMessage.chat.type,
|
chatType: this.sourceMessage.chat.type,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
if (upload) this.destroyUpload(upload);
|
||||||
|
}
|
||||||
this.mediaMode = true;
|
this.mediaMode = true;
|
||||||
this.lastSent = next;
|
this.lastSent = next;
|
||||||
|
await this.storeMediaMessage(this.waitMessage, attachment);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
|
||||||
try {
|
try {
|
||||||
const result = await enqueueTelegramApiCall(
|
const result = await enqueueTelegramApiCall(
|
||||||
() => bot.editMessageMedia({
|
() => bot.editMessageMedia({
|
||||||
chat_id: this.waitMessage!.chat.id,
|
chat_id: this.waitMessage!.chat.id,
|
||||||
message_id: this.waitMessage!.message_id,
|
message_id: this.waitMessage!.message_id,
|
||||||
media: {
|
media: useDocument
|
||||||
|
? {
|
||||||
|
type: "document",
|
||||||
|
media: upload!,
|
||||||
|
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||||
|
parse_mode: "MarkdownV2",
|
||||||
|
}
|
||||||
|
: {
|
||||||
type: "photo",
|
type: "photo",
|
||||||
media: image,
|
media: image,
|
||||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||||
@@ -369,19 +444,50 @@ export class TelegramStreamMessage {
|
|||||||
if (result && result !== true) this.waitMessage = result;
|
if (result && result !== true) this.waitMessage = result;
|
||||||
this.mediaMode = true;
|
this.mediaMode = true;
|
||||||
this.lastSent = next;
|
this.lastSent = next;
|
||||||
} catch (e: unknown) {
|
await this.storeMediaMessage(this.waitMessage, attachment);
|
||||||
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
if (!message.includes("message is not modified")) logError(e);
|
if (useDocument) {
|
||||||
|
try {
|
||||||
|
this.waitMessage = await this.sendImageAsDocument(upload!, next);
|
||||||
|
this.mediaMode = true;
|
||||||
|
this.lastSent = next;
|
||||||
|
await this.storeMediaMessage(this.waitMessage, attachment);
|
||||||
|
return;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
logError(fallbackError instanceof Error ? fallbackError : String(fallbackError));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!message.includes("message is not modified")) logError(e instanceof Error ? e : message);
|
||||||
|
} finally {
|
||||||
|
if (upload) this.destroyUpload(upload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storeMediaMessage(sent: Message | null, attachment?: StoredAttachment): Promise<void> {
|
||||||
|
if (!sent || !attachment) return;
|
||||||
|
|
||||||
|
const stored: StoredMessage = {
|
||||||
|
chatId: sent.chat.id,
|
||||||
|
id: sent.message_id,
|
||||||
|
replyToMessageId: sent.reply_to_message?.message_id ?? this.sourceMessage.message_id,
|
||||||
|
fromId: sent.from?.id ?? 0,
|
||||||
|
text: sent.caption ?? this.visibleText(),
|
||||||
|
date: sent.date ?? Math.floor(Date.now() / 1000),
|
||||||
|
attachments: [attachment],
|
||||||
|
};
|
||||||
|
|
||||||
|
await MessageStore.put(stored);
|
||||||
|
}
|
||||||
|
|
||||||
private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise<Message | null> {
|
private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise<Message | null> {
|
||||||
if (this.cancelled) return null;
|
if (this.cancelled) return null;
|
||||||
|
|
||||||
if (file.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
|
if (file.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||||
throw new Error(Environment.getTelegramFileTooLargeText(
|
throw new Error(Environment.getTelegramFileTooLargeText(
|
||||||
file.fileName,
|
file.fileName,
|
||||||
TELEGRAM_FILE_LIMIT_BYTES / 1024 / 1024,
|
PIPELINE_ATTACHMENT_LIMIT_BYTES / 1024 / 1024,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +530,7 @@ export class TelegramStreamMessage {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
sent = await this.sendArtifactAsDocument(file, caption);
|
sent = await this.sendArtifactAsDocument(file, caption);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -432,15 +538,37 @@ export class TelegramStreamMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.storeArtifactMessage(sent, file);
|
await this.storeArtifactMessage(sent, file);
|
||||||
|
this.recordOutputAttachment({
|
||||||
|
artifactKind: "generated_file",
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
sizeBytes: file.sizeBytes,
|
||||||
|
messageId: sent.message_id,
|
||||||
|
});
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isPhotoArtifact(file: TelegramArtifactFile): boolean {
|
private isPhotoArtifact(file: TelegramArtifactFile): boolean {
|
||||||
|
if (this.sendImagesAsDocuments) return false;
|
||||||
return file.kind === "image"
|
return file.kind === "image"
|
||||||
&& file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES
|
&& file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES
|
||||||
&& ["image/jpeg", "image/png", "image/webp"].includes((file.mimeType || "").toLowerCase());
|
&& ["image/jpeg", "image/png", "image/webp"].includes((file.mimeType || "").toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createImageUpload(image: Buffer, attachment?: StoredAttachment): FileOptions {
|
||||||
|
if (attachment?.cachePath && fs.existsSync(attachment.cachePath)) {
|
||||||
|
return new FileOptions(fs.createReadStream(attachment.cachePath), {
|
||||||
|
filename: attachment.fileName || path.basename(attachment.cachePath),
|
||||||
|
contentType: attachment.mimeType || "application/octet-stream",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileOptions(image, {
|
||||||
|
filename: attachment?.fileName ?? `image_${Date.now()}.png`,
|
||||||
|
contentType: attachment?.mimeType || "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private createArtifactUpload(file: TelegramArtifactFile): FileOptions {
|
private createArtifactUpload(file: TelegramArtifactFile): FileOptions {
|
||||||
return new FileOptions(fs.createReadStream(file.path), {
|
return new FileOptions(fs.createReadStream(file.path), {
|
||||||
filename: file.fileName,
|
filename: file.fileName,
|
||||||
@@ -454,6 +582,23 @@ export class TelegramStreamMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendImageAsDocument(upload: FileOptions, caption: string): Promise<Message> {
|
||||||
|
return enqueueTelegramApiCall(
|
||||||
|
() => bot.sendDocument({
|
||||||
|
chat_id: this.sourceMessage.chat.id,
|
||||||
|
document: upload,
|
||||||
|
caption: prepareTelegramMarkdownV2(caption, {mode: "final"}),
|
||||||
|
parse_mode: "MarkdownV2",
|
||||||
|
reply_parameters: {message_id: this.sourceMessage.message_id},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "sendDocument",
|
||||||
|
chatId: this.sourceMessage.chat.id,
|
||||||
|
chatType: this.sourceMessage.chat.type,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async sendArtifactAsDocument(file: TelegramArtifactFile, caption: string): Promise<Message> {
|
private async sendArtifactAsDocument(file: TelegramArtifactFile, caption: string): Promise<Message> {
|
||||||
return enqueueTelegramApiCall(
|
return enqueueTelegramApiCall(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -487,6 +632,9 @@ export class TelegramStreamMessage {
|
|||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
cachePath: file.path,
|
cachePath: file.path,
|
||||||
|
sizeBytes: file.sizeBytes,
|
||||||
|
scope: "bot_output",
|
||||||
|
artifactKind: "generated_file",
|
||||||
};
|
};
|
||||||
|
|
||||||
const stored: StoredMessage = {
|
const stored: StoredMessage = {
|
||||||
@@ -502,6 +650,44 @@ export class TelegramStreamMessage {
|
|||||||
await MessageStore.put(stored);
|
await MessageStore.put(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async storeInternalAttachment(attachment: StoredAttachment): Promise<void> {
|
||||||
|
if (!this.waitMessage) return;
|
||||||
|
|
||||||
|
const stored = await MessageStore.get(this.waitMessage.chat.id, this.waitMessage.message_id);
|
||||||
|
await MessageStore.put({
|
||||||
|
chatId: this.waitMessage.chat.id,
|
||||||
|
id: this.waitMessage.message_id,
|
||||||
|
replyToMessageId: this.waitMessage.reply_to_message?.message_id ?? this.sourceMessage.message_id,
|
||||||
|
fromId: this.waitMessage.from?.id ?? 0,
|
||||||
|
text: this.visibleText(),
|
||||||
|
date: this.waitMessage.date ?? Math.floor(Date.now() / 1000),
|
||||||
|
attachments: [
|
||||||
|
...(stored?.attachments ?? []),
|
||||||
|
attachment,
|
||||||
|
],
|
||||||
|
pipelineAudit: stored?.pipelineAudit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async storePipelineAudit(events: StoredMessage["pipelineAudit"]): Promise<void> {
|
||||||
|
if (!this.waitMessage || !events?.length) return;
|
||||||
|
|
||||||
|
const stored = await MessageStore.get(this.waitMessage.chat.id, this.waitMessage.message_id);
|
||||||
|
await MessageStore.put({
|
||||||
|
chatId: this.waitMessage.chat.id,
|
||||||
|
id: this.waitMessage.message_id,
|
||||||
|
replyToMessageId: this.waitMessage.reply_to_message?.message_id ?? this.sourceMessage.message_id,
|
||||||
|
fromId: this.waitMessage.from?.id ?? 0,
|
||||||
|
text: this.visibleText(),
|
||||||
|
date: this.waitMessage.date ?? Math.floor(Date.now() / 1000),
|
||||||
|
attachments: stored?.attachments,
|
||||||
|
pipelineAudit: [
|
||||||
|
...(stored?.pipelineAudit ?? []),
|
||||||
|
...events,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async finish(removeKeyboard = true): Promise<void> {
|
async finish(removeKeyboard = true): Promise<void> {
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
@@ -523,7 +709,7 @@ export class TelegramStreamMessage {
|
|||||||
await this.store();
|
await this.store();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fail(error: unknown): Promise<void> {
|
async fail(error: Error | string | object | null | undefined): Promise<void> {
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.status = "";
|
this.status = "";
|
||||||
@@ -536,7 +722,7 @@ export class TelegramStreamMessage {
|
|||||||
try {
|
try {
|
||||||
await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message);
|
await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-143
@@ -7,20 +7,20 @@ import {Environment} from "../common/environment";
|
|||||||
import {bot} from "../index";
|
import {bot} from "../index";
|
||||||
import {
|
import {
|
||||||
getAvailableAiProviderChoices,
|
getAvailableAiProviderChoices,
|
||||||
getProviderChoiceLabel,
|
|
||||||
normalizeAiProviderChoice,
|
normalizeAiProviderChoice,
|
||||||
resolveEffectiveAiProviderForUser,
|
resolveEffectiveAiProviderForUser,
|
||||||
} from "../common/user-ai-settings";
|
} from "../common/user-ai-settings";
|
||||||
|
import {providerDisplayName} from "./provider-aliases";
|
||||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||||
import {MessageStore} from "../common/message-store";
|
import {MessageStore} from "../common/message-store";
|
||||||
import {StoredAttachment} from "../model/stored-attachment";
|
import {StoredAttachment} from "../model/stored-attachment";
|
||||||
import {StoredMessage} from "../model/stored-message";
|
import {StoredMessage} from "../model/stored-message";
|
||||||
import {logError} from "../util/utils";
|
import {logError} from "../util/utils";
|
||||||
import {SpeechRequest} from "@mistralai/mistralai/models/components";
|
import {SpeechRequest} from "@mistralai/mistralai/models/components";
|
||||||
import {createGoogleGenAiClient, createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
import {createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
|
||||||
|
|
||||||
const MAX_TTS_TEXT_CHARS = 4096;
|
const MAX_TTS_TEXT_CHARS = 4096;
|
||||||
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm";
|
export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm";
|
||||||
|
|
||||||
@@ -54,10 +54,6 @@ function ttsCacheDir(): string {
|
|||||||
return path.join(Environment.DATA_PATH, "cache", "audio");
|
return path.join(Environment.DATA_PATH, "cache", "audio");
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerName(provider: AiProvider): string {
|
|
||||||
return getProviderChoiceLabel(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertText(text: string): string {
|
function assertText(text: string): string {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -76,9 +72,6 @@ export function isTextToSpeechConfigured(provider: AiProvider): boolean {
|
|||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||||
return !!openAiTarget.apiKey && !!openAiTarget.model;
|
return !!openAiTarget.apiKey && !!openAiTarget.model;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
const geminiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
|
||||||
return !!geminiTarget.apiKey && !!geminiTarget.model;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||||
return !!mistralTarget.apiKey && !!mistralTarget.model;
|
return !!mistralTarget.apiKey && !!mistralTarget.model;
|
||||||
@@ -98,11 +91,11 @@ export async function resolveTextToSpeechProviderForUser(
|
|||||||
|
|
||||||
if (explicitProvider) {
|
if (explicitProvider) {
|
||||||
if (!allowedProviders.includes(explicitProvider)) {
|
if (!allowedProviders.includes(explicitProvider)) {
|
||||||
throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(explicitProvider)));
|
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(explicitProvider)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isTextToSpeechConfigured(explicitProvider)) {
|
if (!isTextToSpeechConfigured(explicitProvider)) {
|
||||||
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerName(explicitProvider)));
|
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerDisplayName(explicitProvider)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {provider: explicitProvider, fallback: false};
|
return {provider: explicitProvider, fallback: false};
|
||||||
@@ -127,8 +120,6 @@ export async function synthesizeSpeech(request: TextToSpeechRequest): Promise<Sy
|
|||||||
switch (request.provider) {
|
switch (request.provider) {
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
return synthesizeOpenAiSpeech(text, request.voice);
|
return synthesizeOpenAiSpeech(text, request.voice);
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return synthesizeGeminiSpeech(text, request.voice);
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return synthesizeMistralSpeech(text, request.voice);
|
return synthesizeMistralSpeech(text, request.voice);
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
@@ -171,7 +162,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
|
|||||||
if (target.model) request.model = target.model;
|
if (target.model) request.model = target.model;
|
||||||
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
|
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
|
||||||
|
|
||||||
const response = await mistralAi.audio.speech.complete(request) as unknown as {audioData?: string; audio_data?: string};
|
const response = await mistralAi.audio.speech.complete(request) as {audioData?: string; audio_data?: string};
|
||||||
const audioData = response?.audioData ?? response?.audio_data;
|
const audioData = response?.audioData ?? response?.audio_data;
|
||||||
if (typeof audioData !== "string" || !audioData.trim()) {
|
if (typeof audioData !== "string" || !audioData.trim()) {
|
||||||
throw new Error(Environment.mistralTtsNoAudioDataText);
|
throw new Error(Environment.mistralTtsNoAudioDataText);
|
||||||
@@ -189,130 +180,6 @@ 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 = await geminiAi.models.generateContent({
|
|
||||||
model: target.model,
|
|
||||||
contents: text,
|
|
||||||
config: {
|
|
||||||
responseModalities: ["AUDIO"],
|
|
||||||
speechConfig: {
|
|
||||||
voiceConfig: {
|
|
||||||
prebuiltVoiceConfig: {
|
|
||||||
voiceName: voice || Environment.GEMINI_TTS_VOICE,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioPart = findGeminiAudioPart(response);
|
|
||||||
if (!audioPart) {
|
|
||||||
throw new Error(Environment.geminiTextToSpeechUnsupportedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = decodeGeminiAudio(audioPart.data, audioPart.mimeType);
|
|
||||||
|
|
||||||
return writeSpeechFile({
|
|
||||||
provider: AiProvider.GEMINI,
|
|
||||||
model: target.model,
|
|
||||||
voice: voice || Environment.GEMINI_TTS_VOICE,
|
|
||||||
buffer: decoded.buffer,
|
|
||||||
format: decoded.format,
|
|
||||||
mimeType: decoded.mimeType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGeminiAudioPart(value: unknown): { data: string; mimeType?: string } | null {
|
|
||||||
if (!value || typeof value !== "object") return null;
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
|
|
||||||
const inlineData = record.inlineData ?? record.inline_data;
|
|
||||||
if (inlineData && typeof inlineData === "object") {
|
|
||||||
const inlineRecord = inlineData as Record<string, unknown>;
|
|
||||||
const data = inlineRecord.data;
|
|
||||||
const mimeType = inlineRecord.mimeType ?? inlineRecord.mime_type;
|
|
||||||
|
|
||||||
if (typeof data === "string" && (!mimeType || String(mimeType).startsWith("audio/"))) {
|
|
||||||
return {data, mimeType: typeof mimeType === "string" ? mimeType : undefined};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of Object.values(record)) {
|
|
||||||
if (Array.isArray(child)) {
|
|
||||||
for (const item of child) {
|
|
||||||
const found = findGeminiAudioPart(item);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
} else if (child && typeof child === "object") {
|
|
||||||
const found = findGeminiAudioPart(child);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeGeminiAudio(data: string, mimeType = "audio/wav"): {
|
|
||||||
buffer: Buffer;
|
|
||||||
format: TextToSpeechFormat;
|
|
||||||
mimeType: string;
|
|
||||||
} {
|
|
||||||
const normalizedMime = mimeType.toLowerCase();
|
|
||||||
const raw = Buffer.from(data, "base64");
|
|
||||||
|
|
||||||
if (normalizedMime.includes("mpeg") || normalizedMime.includes("mp3")) {
|
|
||||||
return {buffer: raw, format: "mp3", mimeType: "audio/mpeg"};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedMime.includes("wav") || raw.subarray(0, 4).toString("ascii") === "RIFF") {
|
|
||||||
return {buffer: raw, format: "wav", mimeType: "audio/wav"};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedMime.includes("flac")) {
|
|
||||||
return {buffer: raw, format: "flac", mimeType: "audio/flac"};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedMime.includes("opus")) {
|
|
||||||
return {buffer: raw, format: "opus", mimeType: "audio/opus"};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedMime.includes("aac")) {
|
|
||||||
return {buffer: raw, format: "aac", mimeType: "audio/aac"};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleRate = Number(/rate=(\d+)/i.exec(mimeType)?.[1]) || 24_000;
|
|
||||||
return {
|
|
||||||
buffer: wrapPcm16InWav(raw, sampleRate, 1),
|
|
||||||
format: "wav",
|
|
||||||
mimeType: "audio/wav",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapPcm16InWav(pcm: Buffer, sampleRate: number, channels: number): Buffer {
|
|
||||||
const bitsPerSample = 16;
|
|
||||||
const byteRate = sampleRate * channels * bitsPerSample / 8;
|
|
||||||
const blockAlign = channels * bitsPerSample / 8;
|
|
||||||
const header = Buffer.alloc(44);
|
|
||||||
|
|
||||||
header.write("RIFF", 0);
|
|
||||||
header.writeUInt32LE(36 + pcm.length, 4);
|
|
||||||
header.write("WAVE", 8);
|
|
||||||
header.write("fmt ", 12);
|
|
||||||
header.writeUInt32LE(16, 16);
|
|
||||||
header.writeUInt16LE(1, 20);
|
|
||||||
header.writeUInt16LE(channels, 22);
|
|
||||||
header.writeUInt32LE(sampleRate, 24);
|
|
||||||
header.writeUInt32LE(byteRate, 28);
|
|
||||||
header.writeUInt16LE(blockAlign, 32);
|
|
||||||
header.writeUInt16LE(bitsPerSample, 34);
|
|
||||||
header.write("data", 36);
|
|
||||||
header.writeUInt32LE(pcm.length, 40);
|
|
||||||
|
|
||||||
return Buffer.concat([header, pcm]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech {
|
function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech {
|
||||||
fs.mkdirSync(ttsCacheDir(), {recursive: true});
|
fs.mkdirSync(ttsCacheDir(), {recursive: true});
|
||||||
|
|
||||||
@@ -346,11 +213,11 @@ function destroyUpload(upload: FileOptions): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> {
|
export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> {
|
||||||
if (speech.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
|
if (speech.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||||
throw new Error(Environment.speechFileTooLargeText);
|
throw new Error(Environment.speechFileTooLargeText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const caption = Environment.getTextToSpeechCaption(providerName(speech.provider), speech.model, speech.voice);
|
const caption = Environment.getTextToSpeechCaption(providerDisplayName(speech.provider), speech.model, speech.voice);
|
||||||
|
|
||||||
await enqueueTelegramApiCall(
|
await enqueueTelegramApiCall(
|
||||||
() => bot.sendChatAction({
|
() => bot.sendChatAction({
|
||||||
@@ -374,13 +241,13 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
|
|||||||
reply_parameters: {message_id: sourceMessage.message_id},
|
reply_parameters: {message_id: sourceMessage.message_id},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
destroyUpload(upload);
|
// destroyUpload(upload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
|
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
sent = await sendSpeechDocument(sourceMessage, speech, caption);
|
sent = await sendSpeechDocument(sourceMessage, speech, caption);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -391,6 +258,16 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
|
|||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function speechToOutputAttachmentRecord(speech: SynthesizedSpeech, messageId?: number) {
|
||||||
|
return {
|
||||||
|
artifactKind: "tts_audio" as const,
|
||||||
|
fileName: speech.fileName,
|
||||||
|
mimeType: speech.mimeType,
|
||||||
|
sizeBytes: speech.sizeBytes,
|
||||||
|
messageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function sendSpeechDocument(sourceMessage: Message, speech: SynthesizedSpeech, caption: string): Promise<Message> {
|
async function sendSpeechDocument(sourceMessage: Message, speech: SynthesizedSpeech, caption: string): Promise<Message> {
|
||||||
return enqueueTelegramApiCall(
|
return enqueueTelegramApiCall(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -419,6 +296,15 @@ async function storeSpeechMessage(sent: Message, sourceMessage: Message, speech:
|
|||||||
fileName: speech.fileName,
|
fileName: speech.fileName,
|
||||||
mimeType: speech.mimeType,
|
mimeType: speech.mimeType,
|
||||||
cachePath: speech.path,
|
cachePath: speech.path,
|
||||||
|
sizeBytes: speech.sizeBytes,
|
||||||
|
scope: "bot_output",
|
||||||
|
artifactKind: "tts_audio",
|
||||||
|
metadata: {
|
||||||
|
provider: speech.provider,
|
||||||
|
model: speech.model,
|
||||||
|
voice: speech.voice,
|
||||||
|
format: speech.format,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stored: StoredMessage = {
|
const stored: StoredMessage = {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type {AiProviderAdapter} from "./provider-adapters.js";
|
||||||
|
import {executeToolBatch, type ToolCallData, type ToolExecutionMemory} from "./unified-ai-runner.shared.js";
|
||||||
|
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
|
||||||
|
import type {ToolRuntimeContext} from "./tools/runtime.js";
|
||||||
|
|
||||||
|
export async function executeToolBatchWithAdapter(params: {
|
||||||
|
userId: number | undefined | null;
|
||||||
|
toolCalls: ToolCallData[];
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
toolContext: ToolRuntimeContext;
|
||||||
|
toolMemory: ToolExecutionMemory;
|
||||||
|
adapter: AiProviderAdapter;
|
||||||
|
appendTargets?: unknown[][];
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const results = await executeToolBatch(
|
||||||
|
params.userId,
|
||||||
|
params.toolCalls,
|
||||||
|
params.streamMessage,
|
||||||
|
params.toolContext,
|
||||||
|
params.toolMemory,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const target of params.appendTargets ?? []) {
|
||||||
|
params.adapter.appendToolResults(target, params.toolCalls, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
|
||||||
|
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
||||||
|
|
||||||
|
export async function persistToolLoopSummaryArtifactAttachment(params: {
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
text: string;
|
||||||
|
executions: readonly TelegramToolExecutionRecord[];
|
||||||
|
outputAttachments: readonly TelegramOutputAttachmentRecord[];
|
||||||
|
}): Promise<StoredAttachment | undefined> {
|
||||||
|
if (!params.executions.length) return undefined;
|
||||||
|
|
||||||
|
return await persistInternalJsonArtifactAttachment({
|
||||||
|
artifactKind: "tool_result",
|
||||||
|
fileNamePrefix: "tool-loop-summary",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
payload: {
|
||||||
|
stage: "tool_loop",
|
||||||
|
text: params.text.trim(),
|
||||||
|
executions: params.executions.map(execution => ({
|
||||||
|
toolName: execution.toolName,
|
||||||
|
callId: execution.callId,
|
||||||
|
argumentsText: execution.argumentsText,
|
||||||
|
resultChars: execution.resultChars,
|
||||||
|
startedAt: execution.startedAt,
|
||||||
|
finishedAt: execution.finishedAt,
|
||||||
|
})),
|
||||||
|
outputAttachments: params.outputAttachments,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
stage: "tool_loop",
|
||||||
|
toolExecutions: params.executions.length,
|
||||||
|
outputAttachments: params.outputAttachments.length,
|
||||||
|
textChars: params.text.trim().length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type {ToolCallData} from "./unified-ai-runner.shared.js";
|
||||||
|
|
||||||
|
export type ToolLoopStopReason = "no_tool_calls" | "max_rounds_reached";
|
||||||
|
|
||||||
|
export type ToolLoopContinuation = {
|
||||||
|
continue: boolean;
|
||||||
|
reason?: ToolLoopStopReason;
|
||||||
|
remainingRounds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decideToolLoopContinuation(params: {
|
||||||
|
round: number;
|
||||||
|
maxRounds: number;
|
||||||
|
toolCalls: readonly ToolCallData[];
|
||||||
|
}): ToolLoopContinuation {
|
||||||
|
const remainingRounds = Math.max(params.maxRounds - params.round - 1, 0);
|
||||||
|
|
||||||
|
if (!params.toolCalls.length) {
|
||||||
|
return {
|
||||||
|
continue: false,
|
||||||
|
reason: "no_tool_calls",
|
||||||
|
remainingRounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingRounds === 0) {
|
||||||
|
return {
|
||||||
|
continue: false,
|
||||||
|
reason: "max_rounds_reached",
|
||||||
|
remainingRounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
continue: true,
|
||||||
|
remainingRounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type ToolLoopRoundOutcome = {
|
||||||
|
shouldContinue: boolean;
|
||||||
|
maxRoundsReached?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runToolLoopRounds(params: {
|
||||||
|
maxRounds: number;
|
||||||
|
onRound: (round: number) => Promise<ToolLoopRoundOutcome>;
|
||||||
|
onMaxRoundsReached?: (round: number) => Promise<void> | void;
|
||||||
|
}): Promise<void> {
|
||||||
|
for (let round = 0; round < params.maxRounds; round++) {
|
||||||
|
const outcome = await params.onRound(round);
|
||||||
|
if (!outcome.shouldContinue) {
|
||||||
|
if (outcome.maxRoundsReached) {
|
||||||
|
await params.onMaxRoundsReached?.(round);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await params.onMaxRoundsReached?.(params.maxRounds - 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {PipelineArtifact} from "./user-request-pipeline/types.js";
|
||||||
|
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
|
||||||
|
import {summarizeModelOutput} from "./response-model-output.js";
|
||||||
|
|
||||||
|
export type ToolLoopSummary = {
|
||||||
|
status: "succeeded" | "skipped";
|
||||||
|
fallbackAction?: "continue_without_stage";
|
||||||
|
details: {
|
||||||
|
modelOutput: ReturnType<typeof summarizeModelOutput>;
|
||||||
|
count: number;
|
||||||
|
tools: Array<{
|
||||||
|
toolName: string;
|
||||||
|
callId: string;
|
||||||
|
resultChars: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
artifacts?: PipelineArtifact[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function summarizeToolLoop(params: {
|
||||||
|
text: string;
|
||||||
|
executions: readonly TelegramToolExecutionRecord[];
|
||||||
|
outputAttachments: readonly TelegramOutputAttachmentRecord[];
|
||||||
|
}): ToolLoopSummary {
|
||||||
|
const count = params.executions.length;
|
||||||
|
const tools = params.executions.map(execution => ({
|
||||||
|
toolName: execution.toolName,
|
||||||
|
callId: execution.callId,
|
||||||
|
resultChars: execution.resultChars,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: count ? "succeeded" : "skipped",
|
||||||
|
fallbackAction: count ? undefined : "continue_without_stage",
|
||||||
|
details: {
|
||||||
|
modelOutput: summarizeModelOutput({
|
||||||
|
text: params.text,
|
||||||
|
toolExecutions: params.executions,
|
||||||
|
outputAttachments: params.outputAttachments,
|
||||||
|
}),
|
||||||
|
count,
|
||||||
|
tools,
|
||||||
|
},
|
||||||
|
artifacts: count ? [{
|
||||||
|
kind: "tool_result",
|
||||||
|
stage: "tool_loop",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
toolName: "summary",
|
||||||
|
callId: "tool_loop_summary",
|
||||||
|
resultText: JSON.stringify({
|
||||||
|
count,
|
||||||
|
tools,
|
||||||
|
}),
|
||||||
|
}] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
+43
-40
@@ -1,36 +1,42 @@
|
|||||||
import {AiTool} from "./tool-types";
|
import {AiTool} from "./tool-types";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {getTools} from "./tools/registry";
|
import {getTools} from "./tools/registry.js";
|
||||||
import {WEB_SEARCH_TOOL_NAME} from "./tools/brave-search";
|
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
|
||||||
|
import {toolSchemaNames} from "./tool-schema-utils.js";
|
||||||
|
|
||||||
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
|
export type AiProviderName = "ollama" | "openai" | "mistral";
|
||||||
|
|
||||||
export function getOllamaTools(): AiTool[] {
|
export function getOllamaTools(forCreator?: boolean): AiTool[] {
|
||||||
return getTools();
|
return getTools(forCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAiForbiddenTools = [
|
const openAiForbiddenTools = [
|
||||||
WEB_SEARCH_TOOL_NAME,
|
WEB_SEARCH_TOOL_NAME,
|
||||||
PYTHON_INTERPRETER_TOOL_NAME
|
PYTHON_INTERPRETER_TOOL_NAME
|
||||||
]
|
];
|
||||||
|
|
||||||
function allowedOpenAiTool(tool: AiTool): boolean {
|
function allowedOpenAiTool(tool: AiTool): boolean {
|
||||||
return !openAiForbiddenTools.includes(tool.function.name)
|
return !openAiForbiddenTools.includes(tool.function.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOpenAITools(): AiTool[] {
|
export function getOpenAITools(forCreator?: boolean): AiTool[] {
|
||||||
return getTools().filter(allowedOpenAiTool).map(tool => ({
|
return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
|
||||||
type: "function",
|
type: "function",
|
||||||
function: tool.function,
|
function: tool.function,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOpenAICompatibleTools(forCreator?: boolean): AiTool[] {
|
||||||
|
// The compatible chat.completions backend only accepts plain function tools.
|
||||||
|
return getOpenAITools(forCreator);
|
||||||
|
}
|
||||||
|
|
||||||
export type OpenAiResponseTool = {
|
export type OpenAiResponseTool = {
|
||||||
type: "function";
|
type: "function";
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
parameters?: unknown;
|
parameters?: object;
|
||||||
strict: false;
|
strict: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,8 +49,8 @@ export type OpenAiCodeInterpreterTool = {
|
|||||||
} | string;
|
} | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
|
export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] {
|
||||||
return getTools().filter(allowedOpenAiTool).map(tool => ({
|
return getOpenAITools(forCreator).map(tool => ({
|
||||||
type: "function",
|
type: "function",
|
||||||
name: tool.function.name,
|
name: tool.function.name,
|
||||||
description: tool.function.description,
|
description: tool.function.description,
|
||||||
@@ -62,40 +68,37 @@ export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMistralTools(): AiTool[] {
|
export function getMistralTools(forCreator?: boolean): AiTool[] {
|
||||||
return getTools().map(tool => ({
|
return getTools(forCreator).map(tool => ({
|
||||||
type: "function",
|
type: "function",
|
||||||
function: tool.function,
|
function: tool.function,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GeminiTool = {
|
export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] {
|
||||||
functionDeclarations: Array<{
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
parametersJsonSchema?: unknown;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGeminiTools(): GeminiTool[] {
|
|
||||||
const functionDeclarations = getTools().map(tool => ({
|
|
||||||
name: tool.function.name,
|
|
||||||
description: tool.function.description,
|
|
||||||
parametersJsonSchema: tool.function.parameters,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return functionDeclarations.length ? [{functionDeclarations}] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderTools(provider: AiProvider): AiTool[] {
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return getOllamaTools();
|
return getOllamaTools(forCreator);
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return getTools();
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return getMistralTools();
|
return getMistralTools(forCreator);
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
return getOpenAITools();
|
return getOpenAITools(forCreator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ensureToolsSelected<T>(availableTools: readonly T[], selectedTools: readonly T[], toolNames: readonly string[]): T[] {
|
||||||
|
const selected = [...selectedTools];
|
||||||
|
const selectedNames = new Set(selected.flatMap(tool => toolSchemaNames(tool as never)));
|
||||||
|
|
||||||
|
for (const toolName of toolNames) {
|
||||||
|
if (selectedNames.has(toolName)) continue;
|
||||||
|
|
||||||
|
const extraTool = availableTools.find(tool => toolSchemaNames(tool as never).includes(toolName));
|
||||||
|
if (extraTool) {
|
||||||
|
selected.unshift(extraTool);
|
||||||
|
selectedNames.add(toolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
import type {BoundaryValue} from "../common/boundary-types.js";
|
||||||
|
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
|
||||||
|
import type {RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
|
||||||
|
import {allToolSchemaNames, toolSchemaNames} from "./tool-schema-utils.js";
|
||||||
|
import type {ToolRanker} from "./unified-ai-runner.tool-ranker.js";
|
||||||
|
import type {PipelineAuditEvent} from "./user-request-pipeline/types.js";
|
||||||
|
|
||||||
|
function latestUserText(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 => typeof part === "object" && part !== null && "text" in part && typeof (part as { text?: unknown }).text === "string"
|
||||||
|
? (part as { text: string }).text
|
||||||
|
: "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runToolRankStage(params: {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
round: number;
|
||||||
|
config: RuntimeConfigSnapshot;
|
||||||
|
availableTools: readonly BoundaryValue[];
|
||||||
|
messages: readonly { role?: string; content?: unknown }[];
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
signal: AbortSignal;
|
||||||
|
toolRanker?: ToolRanker;
|
||||||
|
storeAudit?: (params: {
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
round: number;
|
||||||
|
startedAt: number;
|
||||||
|
startedAtIso: string;
|
||||||
|
availableTools: string[];
|
||||||
|
selectedTools?: string[];
|
||||||
|
usedRanker?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}): Promise<{
|
||||||
|
filteredTools: BoundaryValue[];
|
||||||
|
selectedToolNames: string[];
|
||||||
|
usedRanker: boolean;
|
||||||
|
}> {
|
||||||
|
const toolRanker = params.toolRanker ?? new (await import("./unified-ai-runner.tool-ranker.js")).ToolRanker(params.config);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const startedAtIso = new Date().toISOString();
|
||||||
|
const filterSelectedTools = (selectedToolNames: readonly string[]): BoundaryValue[] => {
|
||||||
|
const selected = new Set(selectedToolNames);
|
||||||
|
return params.availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
|
||||||
|
};
|
||||||
|
const storeAudit = params.storeAudit ?? (async (auditParams: {
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
round: number;
|
||||||
|
startedAt: number;
|
||||||
|
startedAtIso: string;
|
||||||
|
availableTools: string[];
|
||||||
|
selectedTools?: string[];
|
||||||
|
usedRanker?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}) => {
|
||||||
|
const event: PipelineAuditEvent = {
|
||||||
|
stage: "tool_rank",
|
||||||
|
status: auditParams.error ? "failed" : "succeeded",
|
||||||
|
startedAt: auditParams.startedAtIso,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
durationMs: Date.now() - auditParams.startedAt,
|
||||||
|
provider: auditParams.provider,
|
||||||
|
model: auditParams.model,
|
||||||
|
details: {
|
||||||
|
round: auditParams.round,
|
||||||
|
availableTools: auditParams.availableTools,
|
||||||
|
selectedTools: auditParams.selectedTools ?? [],
|
||||||
|
usedRanker: auditParams.usedRanker ?? false,
|
||||||
|
toolRankDecision: {
|
||||||
|
provider: auditParams.provider,
|
||||||
|
round: auditParams.round,
|
||||||
|
availableTools: auditParams.availableTools,
|
||||||
|
selectedTools: auditParams.selectedTools ?? [],
|
||||||
|
usedRanker: auditParams.usedRanker ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: auditParams.error instanceof Error ? auditParams.error.message : auditParams.error ? String(auditParams.error) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditParams.streamMessage.storePipelineAudit([event]);
|
||||||
|
});
|
||||||
|
|
||||||
|
params.streamMessage.setStatus("🧩 Выбираю подходящие инструменты...");
|
||||||
|
await params.streamMessage.flush();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selection = await toolRanker.selectTools({
|
||||||
|
provider: params.provider,
|
||||||
|
userQuery: latestUserText(params.messages),
|
||||||
|
availableTools: params.availableTools,
|
||||||
|
round: params.round,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
params.streamMessage.clearStatus();
|
||||||
|
await params.streamMessage.flush();
|
||||||
|
await storeAudit({
|
||||||
|
streamMessage: params.streamMessage,
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
round: params.round,
|
||||||
|
startedAt,
|
||||||
|
startedAtIso,
|
||||||
|
availableTools: allToolSchemaNames(params.availableTools),
|
||||||
|
selectedTools: selection.toolNames,
|
||||||
|
usedRanker: selection.usedRanker,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filteredTools: filterSelectedTools(selection.toolNames),
|
||||||
|
selectedToolNames: selection.toolNames,
|
||||||
|
usedRanker: selection.usedRanker,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
params.streamMessage.clearStatus();
|
||||||
|
await params.streamMessage.flush();
|
||||||
|
await storeAudit({
|
||||||
|
streamMessage: params.streamMessage,
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
round: params.round,
|
||||||
|
startedAt,
|
||||||
|
startedAtIso,
|
||||||
|
availableTools: allToolSchemaNames(params.availableTools),
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
|
import {decidePipelineFallback, type PipelineFallbackDecision} from "./user-request-pipeline/fallback-executor.js";
|
||||||
|
|
||||||
|
export type ToolRankerFallbackSelection = {
|
||||||
|
toolNames: string[];
|
||||||
|
usedRanker: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolRankerFallbackDecision = PipelineFallbackDecision & ToolRankerFallbackSelection;
|
||||||
|
|
||||||
|
function fallbackActionForPolicy(policy: ToolRankerFallbackPolicy) {
|
||||||
|
return policy === ToolRankerFallbackPolicy.MAIN_MODEL
|
||||||
|
? "use_alternate_target"
|
||||||
|
: "continue_without_stage";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideToolRankerFallback(params: {
|
||||||
|
fallbackPolicy: ToolRankerFallbackPolicy;
|
||||||
|
availableToolNames: readonly string[];
|
||||||
|
reason: "unavailable" | "failed";
|
||||||
|
}): ToolRankerFallbackDecision {
|
||||||
|
const action = fallbackActionForPolicy(params.fallbackPolicy);
|
||||||
|
const decision = decidePipelineFallback({
|
||||||
|
stage: "tool_rank",
|
||||||
|
reason: params.reason,
|
||||||
|
policies: [{
|
||||||
|
stage: "tool_rank",
|
||||||
|
onUnavailable: action,
|
||||||
|
onFailed: action,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...decision,
|
||||||
|
toolNames: params.fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS
|
||||||
|
? []
|
||||||
|
: [...params.availableToolNames],
|
||||||
|
usedRanker: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolRankerFallbackSelection(params: {
|
||||||
|
fallbackPolicy: ToolRankerFallbackPolicy;
|
||||||
|
availableToolNames: readonly string[];
|
||||||
|
}): ToolRankerFallbackSelection {
|
||||||
|
const decision = decideToolRankerFallback({
|
||||||
|
fallbackPolicy: params.fallbackPolicy,
|
||||||
|
availableToolNames: params.availableToolNames,
|
||||||
|
reason: "failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolNames: decision.toolNames,
|
||||||
|
usedRanker: decision.usedRanker,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
|
|
||||||
|
export type ToolRankerExample = {
|
||||||
|
user: string;
|
||||||
|
toolNames: string[];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolRankerToolInfo = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rankerHint: string;
|
||||||
|
examples?: ToolRankerExample[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool = (
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
rankerHint: string,
|
||||||
|
examples: ToolRankerExample[] = [],
|
||||||
|
): ToolRankerToolInfo => ({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
rankerHint,
|
||||||
|
examples: examples.length ? examples : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const example = (user: string, toolNames: string[], note?: string): ToolRankerExample => ({
|
||||||
|
user,
|
||||||
|
toolNames,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TOOL_RANKER_TOOL_INFOS = {
|
||||||
|
no_tool: tool(
|
||||||
|
"no_tool",
|
||||||
|
"No tool action is needed.",
|
||||||
|
"Use for normal answers, explanations, advice, planning, code writing without execution, rewriting, translation, and general conversation.",
|
||||||
|
[
|
||||||
|
example("объясни docker volumes", ["no_tool"]),
|
||||||
|
example("напиши промпт для Claude", ["no_tool"]),
|
||||||
|
example("как лучше спроектировать эту архитектуру?", ["no_tool"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
get_datetime: tool(
|
||||||
|
"get_datetime",
|
||||||
|
"Get the current date, time, or timezone-aware moment.",
|
||||||
|
"Use for current date/time, today/tomorrow/yesterday, timezone-aware time, and calculations based on the current moment.",
|
||||||
|
[
|
||||||
|
example("какое сегодня число?", ["get_datetime"]),
|
||||||
|
example("который час?", ["get_datetime"]),
|
||||||
|
example("что будет через 10 дней?", ["get_datetime"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
get_financial_market_data: tool(
|
||||||
|
"get_financial_market_data",
|
||||||
|
"Get current market, price, currency, or ticker data.",
|
||||||
|
"Use for current/recent stocks, crypto, fiat exchange rates, commodities, indices, futures, and market prices.",
|
||||||
|
[
|
||||||
|
example("сколько сейчас BTC?", ["get_financial_market_data"]),
|
||||||
|
example("курс USD/RUB", ["get_financial_market_data"]),
|
||||||
|
example("цена золота сейчас", ["get_financial_market_data"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
get_weather: tool(
|
||||||
|
"get_weather",
|
||||||
|
"Get current weather or forecast data.",
|
||||||
|
"Use for weather, rain, snow, wind, temperature, forecast, and weather-dependent planning.",
|
||||||
|
[
|
||||||
|
example("погода завтра", ["get_weather"]),
|
||||||
|
example("будет дождь сегодня?", ["get_weather"]),
|
||||||
|
example("можно сегодня на велике?", ["get_weather"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
read_file: tool(
|
||||||
|
"read_file",
|
||||||
|
"Read a known local file path.",
|
||||||
|
"Use when the user asks to read, open, inspect, or summarize a known local file path.",
|
||||||
|
[
|
||||||
|
example("прочитай src/index.ts", ["read_file"]),
|
||||||
|
example("посмотри package.json", ["read_file"]),
|
||||||
|
example("открой этот файл", ["read_file"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
list_directory: tool(
|
||||||
|
"list_directory",
|
||||||
|
"List files or folders in a local path.",
|
||||||
|
"Use when the user asks to list files/folders, inspect a directory, show project structure, or see what exists in a path.",
|
||||||
|
[
|
||||||
|
example("покажи структуру проекта", ["list_directory"]),
|
||||||
|
example("что лежит в src?", ["list_directory"]),
|
||||||
|
example("выведи список файлов", ["list_directory"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
search_files: tool(
|
||||||
|
"search_files",
|
||||||
|
"Search local files by name, content, symbol, or keyword.",
|
||||||
|
"Use when the exact file path is unknown and the user wants to find files, usages, TODOs, symbols, classes, functions, or error messages.",
|
||||||
|
[
|
||||||
|
example("найди где используется sendMessage", ["search_files"]),
|
||||||
|
example("найди все TODO", ["search_files"]),
|
||||||
|
example("где определён BotService?", ["search_files"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
read_user_info: tool(
|
||||||
|
"read_user_info",
|
||||||
|
"Read persistent user memory from user.md.",
|
||||||
|
"Use before editing or when the user asks what you remember about them.",
|
||||||
|
[
|
||||||
|
example("что ты помнишь обо мне?", ["read_user_info"]),
|
||||||
|
example("покажи мою память", ["read_user_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
read_system_info: tool(
|
||||||
|
"read_system_info",
|
||||||
|
"Read persistent assistant memory from system.md.",
|
||||||
|
"Use before editing or when the user asks what instructions you remember about yourself.",
|
||||||
|
[
|
||||||
|
example("что ты помнишь о себе?", ["read_system_info"]),
|
||||||
|
example("покажи память о тебе", ["read_system_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
add_user_info: tool(
|
||||||
|
"add_user_info",
|
||||||
|
"Append a durable fact about the user to persistent memory.",
|
||||||
|
"Use when the user asks to remember a new fact, preference, identity detail, or profile information about themselves.",
|
||||||
|
[
|
||||||
|
example("запомни, что меня зовут Иван", ["add_user_info"]),
|
||||||
|
example("запомни, что я люблю чай", ["add_user_info"]),
|
||||||
|
example("remember that I like short answers", ["add_user_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
add_system_info: tool(
|
||||||
|
"add_system_info",
|
||||||
|
"Append a durable instruction about the assistant to persistent memory.",
|
||||||
|
"Use when the user asks to remember a new assistant identity, style, or behavior instruction.",
|
||||||
|
[
|
||||||
|
example("тебя зовут Евлампий", ["add_system_info"]),
|
||||||
|
example("ты ИИ помощник", ["add_system_info"]),
|
||||||
|
example("remember you are a concise assistant", ["add_system_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
remove_user_info: tool(
|
||||||
|
"remove_user_info",
|
||||||
|
"Remove a specific user fact from persistent memory.",
|
||||||
|
"Use when the user asks to forget, delete, or remove a specific fact about themselves.",
|
||||||
|
[
|
||||||
|
example("забудь, что я люблю кофе", ["remove_user_info"]),
|
||||||
|
example("удали из памяти, что я живу в Москве", ["remove_user_info"]),
|
||||||
|
example("forget that I work at ACME", ["remove_user_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
remove_system_info: tool(
|
||||||
|
"remove_system_info",
|
||||||
|
"Remove a specific assistant instruction from persistent memory.",
|
||||||
|
"Use when the user asks to forget or remove a specific instruction about the assistant.",
|
||||||
|
[
|
||||||
|
example("забудь, что тебя зовут Евлампий", ["remove_system_info"]),
|
||||||
|
example("убери правило отвечать коротко", ["remove_system_info"]),
|
||||||
|
example("forget that you are a concise assistant", ["remove_system_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
replace_user_info: tool(
|
||||||
|
"replace_user_info",
|
||||||
|
"Replace the full user memory with a new compact version.",
|
||||||
|
"Use when the user wants to overwrite all remembered user info, for example when they say to forget everything and keep only the new fact.",
|
||||||
|
[
|
||||||
|
example("забудь всё обо мне и запиши только это: меня зовут Иван", ["replace_user_info"]),
|
||||||
|
example("замени всю память обо мне на: люблю чай и короткие ответы", ["replace_user_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
replace_system_info: tool(
|
||||||
|
"replace_system_info",
|
||||||
|
"Replace the full assistant memory with a new compact version.",
|
||||||
|
"Use when the user wants to overwrite all remembered assistant info or instructions.",
|
||||||
|
[
|
||||||
|
example("забудь всё о себе и запиши только это: тебя зовут Евлампий", ["replace_system_info"]),
|
||||||
|
example("замени инструкцию о себе на: ты краткий ИИ помощник", ["replace_system_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
delete_user_info: tool(
|
||||||
|
"delete_user_info",
|
||||||
|
"Delete user.md entirely.",
|
||||||
|
"Use when the user explicitly asks to delete all remembered user info, not just a fragment.",
|
||||||
|
[
|
||||||
|
example("удали всю память обо мне", ["delete_user_info"]),
|
||||||
|
example("forget all user memory", ["delete_user_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
delete_system_info: tool(
|
||||||
|
"delete_system_info",
|
||||||
|
"Delete system.md entirely.",
|
||||||
|
"Use when the user explicitly asks to delete all remembered assistant info, not just a fragment.",
|
||||||
|
[
|
||||||
|
example("удали всю память о себе", ["delete_system_info"]),
|
||||||
|
example("forget all assistant memory", ["delete_system_info"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
create_file: tool(
|
||||||
|
"create_file",
|
||||||
|
"Create a new small file.",
|
||||||
|
"Use when the user asks to create a new file with specific content.",
|
||||||
|
[
|
||||||
|
example("создай README.md", ["create_file"]),
|
||||||
|
example("создай .env.example", ["create_file"]),
|
||||||
|
example("сделай docker-compose.yml", ["create_file"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
update_file: tool(
|
||||||
|
"update_file",
|
||||||
|
"Replace an existing file completely.",
|
||||||
|
"Use only for full file replacement or overwrite.",
|
||||||
|
[
|
||||||
|
example("полностью перезапиши config.json", ["update_file"]),
|
||||||
|
example("замени файл этой версией", ["update_file"]),
|
||||||
|
example("overwrite this file", ["update_file"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
edit_file_patch: tool(
|
||||||
|
"edit_file_patch",
|
||||||
|
"Apply a targeted patch to an existing file.",
|
||||||
|
"Use for targeted edits, patches, diffs, refactors, and changes that should preserve most of the file.",
|
||||||
|
[
|
||||||
|
example("исправь этот баг патчем", ["edit_file_patch"]),
|
||||||
|
example("добавь эту опцию в существующий конфиг", ["edit_file_patch"]),
|
||||||
|
example("измени только эту функцию", ["edit_file_patch"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
create_directory: tool(
|
||||||
|
"create_directory",
|
||||||
|
"Create directories or folder trees.",
|
||||||
|
"Use when the user asks to create folders or directory structures.",
|
||||||
|
[
|
||||||
|
example("создай папку src/services", ["create_directory"]),
|
||||||
|
example("создай структуру директорий", ["create_directory"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
copy_path: tool(
|
||||||
|
"copy_path",
|
||||||
|
"Copy a file or folder path.",
|
||||||
|
"Use when the user asks to copy or duplicate a file or folder.",
|
||||||
|
[
|
||||||
|
example("скопируй config.example.json в config.json", ["copy_path"]),
|
||||||
|
example("дублируй эту папку", ["copy_path"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
rename_path: tool(
|
||||||
|
"rename_path",
|
||||||
|
"Rename or move a file or folder.",
|
||||||
|
"Use when the user asks to rename or move a file or folder.",
|
||||||
|
[
|
||||||
|
example("переименуй файл", ["rename_path"]),
|
||||||
|
example("перемести notes.md в archive", ["rename_path"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
delete_path: tool(
|
||||||
|
"delete_path",
|
||||||
|
"Delete a file or folder.",
|
||||||
|
"Use only when the user clearly asks to delete or remove something.",
|
||||||
|
[
|
||||||
|
example("удали папку dist", ["delete_path"]),
|
||||||
|
example("remove node_modules", ["delete_path"]),
|
||||||
|
example("delete this file", ["delete_path"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
send_file_as_attachment: tool(
|
||||||
|
"send_file_as_attachment",
|
||||||
|
"Send a local file as an attachment.",
|
||||||
|
"Use when the user wants to receive, export, send, attach, or download a local file as an attachment.",
|
||||||
|
[
|
||||||
|
example("пришли мне этот файл", ["send_file_as_attachment"]),
|
||||||
|
example("отправь заметку файлом", ["send_file_as_attachment"]),
|
||||||
|
example("export this as attachment", ["send_file_as_attachment"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
begin_file_write: tool(
|
||||||
|
"begin_file_write",
|
||||||
|
"Start a large chunked file write.",
|
||||||
|
"Use with write_file_chunk and finish_file_write for large file creation or writing.",
|
||||||
|
[
|
||||||
|
example("создай большой markdown отчёт", ["begin_file_write", "write_file_chunk", "finish_file_write"]),
|
||||||
|
example("запиши большой файл чанками", ["begin_file_write", "write_file_chunk", "finish_file_write"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
write_file_chunk: tool(
|
||||||
|
"write_file_chunk",
|
||||||
|
"Append a chunk to an active large file write.",
|
||||||
|
"Use together with begin_file_write and finish_file_write for chunked file writing.",
|
||||||
|
),
|
||||||
|
finish_file_write: tool(
|
||||||
|
"finish_file_write",
|
||||||
|
"Complete an active large file write.",
|
||||||
|
"Use together with begin_file_write and write_file_chunk to finish chunked file writing.",
|
||||||
|
),
|
||||||
|
cancel_file_write: tool(
|
||||||
|
"cancel_file_write",
|
||||||
|
"Cancel an active large file write.",
|
||||||
|
"Use when the user asks to cancel an active file write operation.",
|
||||||
|
[
|
||||||
|
example("отмени запись файла", ["cancel_file_write"]),
|
||||||
|
example("cancel file write", ["cancel_file_write"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
shell_execute: tool(
|
||||||
|
"shell_execute",
|
||||||
|
"Run shell commands in the workspace environment.",
|
||||||
|
"Use for terminal commands, tests, builds, docker, git, npm, pnpm, bun, gradle, diagnostics, logs, install commands, or system inspection.",
|
||||||
|
[
|
||||||
|
example("запусти npm test", ["shell_execute"]),
|
||||||
|
example("собери проект", ["shell_execute"]),
|
||||||
|
example("проверь docker logs", ["shell_execute"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
python_interpreter: tool(
|
||||||
|
"python_interpreter",
|
||||||
|
"Execute Python code.",
|
||||||
|
"Use when the user explicitly asks to run Python code, execute Python, calculate with Python, or test a Python script.",
|
||||||
|
[
|
||||||
|
example("выполни этот python код", ["python_interpreter"]),
|
||||||
|
example("посчитай это питоном", ["python_interpreter"]),
|
||||||
|
example("напиши и запусти python скрипт", ["python_interpreter"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
code_interpreter: tool(
|
||||||
|
"code_interpreter",
|
||||||
|
"Run sandboxed code and data analysis.",
|
||||||
|
"Use for sandbox computation, data/file analysis, CSV processing, archive processing, charts, tables, and generated reports.",
|
||||||
|
[
|
||||||
|
example("проанализируй CSV", ["code_interpreter"]),
|
||||||
|
example("построй график", ["code_interpreter"]),
|
||||||
|
example("обработай архив", ["code_interpreter"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
image_generation: tool(
|
||||||
|
"image_generation",
|
||||||
|
"Generate or edit an image.",
|
||||||
|
"Use when the user asks to generate, create, edit, transform, restyle, enhance, remove, add, replace, recolor, upscale, or alter an image.",
|
||||||
|
[
|
||||||
|
example("сделай его лысым", ["image_generation"]),
|
||||||
|
example("убери фон", ["image_generation"]),
|
||||||
|
example("сделай в стиле аниме", ["image_generation"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
web_search: tool(
|
||||||
|
"web_search",
|
||||||
|
"Search the public web for current, recent, or external information.",
|
||||||
|
"Use only for current/recent/public online information, search, verification, links, documentation, comparisons, or external data.",
|
||||||
|
[
|
||||||
|
example("найди актуальную документацию OpenAI API", ["web_search"]),
|
||||||
|
example("проверь, вышел ли Kotlin 2.3", ["web_search"]),
|
||||||
|
example("какие сейчас цены на VPS?", ["web_search"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
file_search: tool(
|
||||||
|
"file_search",
|
||||||
|
"Search uploaded documents or indexed vector-store files.",
|
||||||
|
"Use for uploaded documents, vector stores, PDFs/docs already indexed or attached to the assistant context.",
|
||||||
|
[
|
||||||
|
example("найди в моих документах про MCP", ["file_search"]),
|
||||||
|
example("что в загруженном PDF написано про оплату?", ["file_search"]),
|
||||||
|
example("поищи в базе знаний", ["file_search"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
} as const satisfies Record<string, ToolRankerToolInfo>;
|
||||||
|
|
||||||
|
export type ToolRankerToolName = keyof typeof TOOL_RANKER_TOOL_INFOS;
|
||||||
|
|
||||||
|
function isString(value: BoundaryValue): value is string {
|
||||||
|
return typeof value === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolNames(names: readonly string[]): string[] {
|
||||||
|
const unique: string[] = [];
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
if (!name || unique.includes(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unique.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonCandidate(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||||
|
if (fenced?.[1]) {
|
||||||
|
return fenced[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstObjectStart = trimmed.indexOf("{");
|
||||||
|
const lastObjectEnd = trimmed.lastIndexOf("}");
|
||||||
|
if (firstObjectStart !== -1 && lastObjectEnd !== -1 && lastObjectEnd > firstObjectStart) {
|
||||||
|
return trimmed.slice(firstObjectStart, lastObjectEnd + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstArrayStart = trimmed.indexOf("[");
|
||||||
|
const lastArrayEnd = trimmed.lastIndexOf("]");
|
||||||
|
if (firstArrayStart !== -1 && lastArrayEnd !== -1 && lastArrayEnd > firstArrayStart) {
|
||||||
|
return trimmed.slice(firstArrayStart, lastArrayEnd + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSelectionValue(value: BoundaryValue): string[] {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(isString);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
const rawToolNames = (value as Record<string, BoundaryValue>).toolNames;
|
||||||
|
return parseSelectionValue(rawToolNames as BoundaryValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asOptionalString(value: BoundaryValue): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolNamesFromTool(tool: BoundaryValue): string[] {
|
||||||
|
if (!isRecord(tool)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionValue = isRecord(tool.function) ? tool.function : undefined;
|
||||||
|
const directName = functionValue?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
|
||||||
|
const name = asOptionalString(directName);
|
||||||
|
|
||||||
|
return name ? [name] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackToolInfoFromTool(toolValue: BoundaryValue, name: string): ToolRankerToolInfo | undefined {
|
||||||
|
if (!isRecord(toolValue)) return undefined;
|
||||||
|
|
||||||
|
const fn = isRecord(toolValue.function) ? toolValue.function : undefined;
|
||||||
|
const description = asOptionalString(fn?.description ?? toolValue.description)
|
||||||
|
?? `Tool ${name}.`;
|
||||||
|
|
||||||
|
return tool(
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
"Use when the tool description matches the user's request.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
|
||||||
|
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolRankerToolInfos(names: readonly string[]): ToolRankerToolInfo[] {
|
||||||
|
return normalizeToolNames(names)
|
||||||
|
.map(name => getToolRankerToolInfo(name))
|
||||||
|
.filter((tool): tool is ToolRankerToolInfo => !!tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
|
||||||
|
const infos = new Map<string, ToolRankerToolInfo>();
|
||||||
|
|
||||||
|
infos.set("no_tool", TOOL_RANKER_TOOL_INFOS.no_tool);
|
||||||
|
|
||||||
|
for (const tool of availableTools) {
|
||||||
|
for (const name of toolNamesFromTool(tool)) {
|
||||||
|
if (infos.has(name)) continue;
|
||||||
|
|
||||||
|
const known = getToolRankerToolInfo(name);
|
||||||
|
const fallback = fallbackToolInfoFromTool(tool, name);
|
||||||
|
if (known) {
|
||||||
|
infos.set(name, known);
|
||||||
|
} else if (fallback) {
|
||||||
|
infos.set(name, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...infos.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string {
|
||||||
|
if (compact) {
|
||||||
|
return `- ${tool.name}: ${tool.rankerHint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `- ${tool.name}: ${tool.description}\n ${tool.rankerHint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExamples(tool: ToolRankerToolInfo, maxExamplesPerTool: number): string[] {
|
||||||
|
if (!tool.examples?.length || maxExamplesPerTool <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.examples.slice(0, maxExamplesPerTool).flatMap(example => {
|
||||||
|
const lines = [
|
||||||
|
`User: ${JSON.stringify(example.user)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (example.note?.trim()) {
|
||||||
|
lines.push(`Note: ${example.note.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(JSON.stringify({toolNames: example.toolNames}));
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
|
||||||
|
const names = new Set(tools.map(tool => tool.name));
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
const pushIfAvailable = (name: string, line: string): void => {
|
||||||
|
if (names.has(name)) {
|
||||||
|
lines.push(`- ${line}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pushIfAvailable("get_datetime", "current date/time -> get_datetime");
|
||||||
|
pushIfAvailable("get_financial_market_data", "market prices, currency, crypto, stocks -> get_financial_market_data");
|
||||||
|
pushIfAvailable("get_weather", "weather or forecast -> get_weather");
|
||||||
|
pushIfAvailable("image_generation", "image creation or editing -> image_generation");
|
||||||
|
pushIfAvailable("file_search", "uploaded/vector documents -> file_search");
|
||||||
|
pushIfAvailable("read_file", "known local file path -> read_file");
|
||||||
|
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
|
||||||
|
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
|
||||||
|
pushIfAvailable("read_user_info", "inspect remembered user info -> read_user_info");
|
||||||
|
pushIfAvailable("read_system_info", "inspect remembered assistant info -> read_system_info");
|
||||||
|
pushIfAvailable("add_user_info", "remember a new user fact -> add_user_info");
|
||||||
|
pushIfAvailable("add_system_info", "remember a new assistant instruction -> add_system_info");
|
||||||
|
pushIfAvailable("remove_user_info", "forget a user fact -> remove_user_info");
|
||||||
|
pushIfAvailable("remove_system_info", "forget an assistant instruction -> remove_system_info");
|
||||||
|
pushIfAvailable("replace_user_info", "overwrite all user memory -> replace_user_info");
|
||||||
|
pushIfAvailable("replace_system_info", "overwrite all assistant memory -> replace_system_info");
|
||||||
|
pushIfAvailable("delete_user_info", "delete all user memory -> delete_user_info");
|
||||||
|
pushIfAvailable("delete_system_info", "delete all assistant memory -> delete_system_info");
|
||||||
|
pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch");
|
||||||
|
pushIfAvailable("update_file", "full existing file replacement -> update_file");
|
||||||
|
pushIfAvailable("create_file", "small new file -> create_file");
|
||||||
|
pushIfAvailable("begin_file_write", "large file writing -> begin_file_write + write_file_chunk + finish_file_write");
|
||||||
|
pushIfAvailable("delete_path", "delete/remove only when the user clearly asks -> delete_path");
|
||||||
|
pushIfAvailable("shell_execute", "terminal commands, builds, tests, git, docker -> shell_execute");
|
||||||
|
pushIfAvailable("python_interpreter", "explicit Python execution -> python_interpreter");
|
||||||
|
pushIfAvailable("code_interpreter", "sandbox computation or data analysis -> code_interpreter");
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRulesSection(availableToolNames: readonly string[]): string[] {
|
||||||
|
const names = new Set(availableToolNames);
|
||||||
|
const rules: string[] = [
|
||||||
|
"You are a tool router, not an answering model.",
|
||||||
|
"Your only job is to select the minimal set of tools needed for the user's latest request.",
|
||||||
|
"Return ONLY valid JSON: {\"toolNames\":[\"tool1\",\"tool2\"]}",
|
||||||
|
"No explanations.",
|
||||||
|
"No markdown.",
|
||||||
|
"No arguments.",
|
||||||
|
"Use only tool names from Available tools.",
|
||||||
|
"If no tool is needed, return {\"toolNames\":[\"no_tool\"]}.",
|
||||||
|
"Pick the smallest correct tool set.",
|
||||||
|
"Prefer specialized tools over generic tools.",
|
||||||
|
"Use multiple tools only when the request likely needs a combination of capabilities.",
|
||||||
|
"Be extra careful with destructive tools.",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (names.has("web_search")) {
|
||||||
|
rules.push("Do not use web_search just because you are unsure.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.has("delete_path")) {
|
||||||
|
rules.push("delete_path only when the user clearly asks to delete or remove something.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.has("update_file")) {
|
||||||
|
rules.push("update_file only for full file replacement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.has("edit_file_patch")) {
|
||||||
|
rules.push("edit_file_patch for targeted file edits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolRankerSystemPrompt(params: {
|
||||||
|
availableTools: ToolRankerToolInfo[];
|
||||||
|
includeExamples?: boolean;
|
||||||
|
maxExamplesPerTool?: number;
|
||||||
|
compact?: boolean;
|
||||||
|
}): string {
|
||||||
|
const includeExamples = params.includeExamples ?? false;
|
||||||
|
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
|
||||||
|
const compact = params.compact ?? true;
|
||||||
|
const availableTools = params.availableTools;
|
||||||
|
const availableToolNames = availableTools.map(tool => tool.name);
|
||||||
|
|
||||||
|
const sections: string[] = [
|
||||||
|
...buildRulesSection(availableToolNames),
|
||||||
|
"",
|
||||||
|
"Available tools:",
|
||||||
|
...availableTools.map(tool => renderToolLine(tool, compact)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const priorityLines = buildPriorityLines(availableTools);
|
||||||
|
if (priorityLines.length) {
|
||||||
|
sections.push("", "Priority:", ...priorityLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeExamples) {
|
||||||
|
const exampleLines = availableTools.flatMap(tool => renderExamples(tool, maxExamplesPerTool));
|
||||||
|
if (exampleLines.length) {
|
||||||
|
sections.push("", "Examples:", ...exampleLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push("", "Return ONLY JSON.");
|
||||||
|
return sections.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeToolRankerResult(params: {
|
||||||
|
raw: string;
|
||||||
|
availableToolNames: readonly string[];
|
||||||
|
}): string[] {
|
||||||
|
const raw = params.raw.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return ["no_tool"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = extractJsonCandidate(raw);
|
||||||
|
let parsed: BoundaryValue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(candidate) as BoundaryValue;
|
||||||
|
} catch {
|
||||||
|
return ["no_tool"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableToolNames = new Set(params.availableToolNames.filter(Boolean));
|
||||||
|
const selected: string[] = [];
|
||||||
|
|
||||||
|
for (const name of normalizeToolNames(parseSelectionValue(parsed))) {
|
||||||
|
if (name === "no_tool") {
|
||||||
|
selected.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableToolNames.has(name)) {
|
||||||
|
selected.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = normalizeToolNames(selected);
|
||||||
|
const withoutNoTool = deduped.filter(name => name !== "no_tool");
|
||||||
|
|
||||||
|
return withoutNoTool.length > 0 ? withoutNoTool : ["no_tool"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type {BoundaryValue} from "../common/boundary-types.js";
|
||||||
|
import type {AiRuntimeTarget} from "./ai-runtime-target.js";
|
||||||
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared.js";
|
||||||
|
import {
|
||||||
|
buildToolRankerSystemPrompt,
|
||||||
|
getToolRankerAvailableToolInfos,
|
||||||
|
type ToolRankerToolInfo,
|
||||||
|
} from "./tool-ranker-metadata.js";
|
||||||
|
|
||||||
|
export type ToolRankerMessage = {
|
||||||
|
role?: string;
|
||||||
|
content?: BoundaryValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolRankerSelection = {
|
||||||
|
toolNames: string[];
|
||||||
|
usedRanker: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolRankerContext = {
|
||||||
|
provider: AiProvider;
|
||||||
|
round: number;
|
||||||
|
userQuery: string;
|
||||||
|
availableTools: readonly BoundaryValue[];
|
||||||
|
targetModel: string;
|
||||||
|
rankerPrompt?: string | null;
|
||||||
|
promptAdditions?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolRankerPromptPlan = {
|
||||||
|
availableToolNames: string[];
|
||||||
|
availableToolInfos: ToolRankerToolInfo[];
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function latestUserTextFromMessages(messages: readonly ToolRankerMessage[]): 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 => {
|
||||||
|
if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") {
|
||||||
|
return part.text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolRankerPrompt(context: ToolRankerContext): ToolRankerPromptPlan {
|
||||||
|
const availableToolInfos = getToolRankerAvailableToolInfos(context.availableTools);
|
||||||
|
const availableToolNames = availableToolInfos.map(tool => tool.name);
|
||||||
|
const prompt = buildToolRankerSystemPrompt({
|
||||||
|
availableTools: availableToolInfos,
|
||||||
|
includeExamples: true,
|
||||||
|
maxExamplesPerTool: 1,
|
||||||
|
compact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableToolNames,
|
||||||
|
availableToolInfos,
|
||||||
|
prompt: [
|
||||||
|
context.rankerPrompt?.trim() || null,
|
||||||
|
context.promptAdditions?.trim() || null,
|
||||||
|
prompt,
|
||||||
|
].filter((line): line is string => Boolean(line?.trim?.() ?? line)).join("\n\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRankedTools<T extends BoundaryValue>(availableTools: readonly T[], toolNames: readonly string[]): T[] {
|
||||||
|
const selected = new Set(toolNames);
|
||||||
|
return availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRankerContext(config: RuntimeConfigSnapshot, provider: AiProvider, target: AiRuntimeTarget, round: number, userQuery: string, availableTools: readonly BoundaryValue[]): ToolRankerContext {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
round,
|
||||||
|
userQuery,
|
||||||
|
availableTools,
|
||||||
|
targetModel: target.model,
|
||||||
|
rankerPrompt: config.rankerToolPrompt,
|
||||||
|
promptAdditions: target.systemPromptAdditions ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRankerTarget(config: RuntimeConfigSnapshot, provider: AiProvider): AiRuntimeTarget | undefined {
|
||||||
|
const target = provider === AiProvider.OLLAMA
|
||||||
|
? config.ollamaToolRankerTarget
|
||||||
|
: provider === AiProvider.MISTRAL
|
||||||
|
? config.mistralToolRankerTarget
|
||||||
|
: provider === AiProvider.OPENAI
|
||||||
|
? config.openAiToolRankerTarget
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!target?.model) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: target.provider,
|
||||||
|
purpose: target.purpose,
|
||||||
|
model: target.model,
|
||||||
|
baseUrl: target.baseUrl,
|
||||||
|
apiKey: target.apiKey,
|
||||||
|
systemPromptAdditions: target.systemPromptAdditions ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
import type {ToolCallData} from "./unified-ai-runner.shared";
|
||||||
|
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
||||||
|
|
||||||
|
export async function persistToolResultArtifactAttachment(params: {
|
||||||
|
toolCall: ToolCallData;
|
||||||
|
resultText: string;
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
}): Promise<StoredAttachment> {
|
||||||
|
return await persistInternalJsonArtifactAttachment({
|
||||||
|
artifactKind: "tool_result",
|
||||||
|
fileNamePrefix: `tool-${params.toolCall.name}`,
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
payload: {
|
||||||
|
toolName: params.toolCall.name,
|
||||||
|
callId: params.toolCall.id,
|
||||||
|
argumentsText: params.toolCall.argumentsText,
|
||||||
|
resultText: params.resultText,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
toolName: params.toolCall.name,
|
||||||
|
callId: params.toolCall.id,
|
||||||
|
resultChars: params.resultText.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type {BoundaryValue} from "../common/boundary-types.js";
|
||||||
|
|
||||||
|
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asOptionalString(value: BoundaryValue): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolSchemaName(tool: BoundaryValue): string | undefined {
|
||||||
|
if (!isRecord(tool)) return undefined;
|
||||||
|
const fn = isRecord(tool.function) ? tool.function : undefined;
|
||||||
|
const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
|
||||||
|
return asOptionalString(directName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolSchemaNames(tool: BoundaryValue): string[] {
|
||||||
|
if (!isRecord(tool)) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(tool.functionDeclarations)) {
|
||||||
|
return tool.functionDeclarations
|
||||||
|
.map(declaration => isRecord(declaration) ? asOptionalString(declaration.name) : undefined)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = toolSchemaName(tool);
|
||||||
|
return name ? [name] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] {
|
||||||
|
return [...new Set(tools.flatMap(toolSchemaNames))];
|
||||||
|
}
|
||||||
+20
-11
@@ -1,12 +1,23 @@
|
|||||||
|
|
||||||
|
export type AiJsonPrimitive = string | number | boolean | null;
|
||||||
|
export interface AiJsonObject {
|
||||||
export type AiToolParameters = {
|
readonly [key: string]: AiJsonValue;
|
||||||
type: "object";
|
}
|
||||||
properties?: Record<string, unknown>;
|
export type AiJsonValue = AiJsonPrimitive | undefined | readonly AiJsonValue[] | AiJsonObject;
|
||||||
required?: string[];
|
export interface AiToolParameters {
|
||||||
[key: string]: unknown;
|
type: "object" | "string" | "number" | "integer" | "boolean" | "array";
|
||||||
};
|
properties?: Record<string, AiToolParameters>;
|
||||||
|
required?: readonly string[];
|
||||||
|
items?: AiToolParameters;
|
||||||
|
enum?: readonly string[];
|
||||||
|
description?: string;
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
default?: AiJsonValue;
|
||||||
|
additionalProperties?: boolean | AiToolParameters;
|
||||||
|
}
|
||||||
|
|
||||||
export type AiTool = {
|
export type AiTool = {
|
||||||
type: "function";
|
type: "function";
|
||||||
@@ -21,9 +32,7 @@ export type AiTool = {
|
|||||||
export type AiToolCall = {
|
export type AiToolCall = {
|
||||||
function: {
|
function: {
|
||||||
name: string;
|
name: string;
|
||||||
arguments: {
|
arguments: AiJsonObject;
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readFile, writeFile} from "node:fs/promises";
|
import {readFile, writeFile} from "node:fs/promises";
|
||||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
|
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("create-note");
|
const logger = toolsLogger.child("create-note");
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export const createNoteTool = {
|
|||||||
} satisfies AiTool;
|
} satisfies AiTool;
|
||||||
|
|
||||||
export async function createNote(
|
export async function createNote(
|
||||||
args?: Record<string, unknown>
|
args?: AiJsonObject
|
||||||
): Promise<CreateNoteResult> {
|
): Promise<CreateNoteResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.debug("start", {args});
|
logger.debug("start", {args});
|
||||||
@@ -83,7 +84,7 @@ export async function createNote(
|
|||||||
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
|
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
|
||||||
return {success: true, filePath: newFilePath};
|
return {success: true, filePath: newFilePath};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("failed", {duration: logger.duration(startedAt), error});
|
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return {success: false, error: `Failed to process files: ${errorMessage}`};
|
return {success: false, error: `Failed to process files: ${errorMessage}`};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
|
import {AiJsonObject} from "../tool-types";
|
||||||
|
|
||||||
export const getCurrentDateTimeTool = {
|
export const getCurrentDateTimeTool = {
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -44,7 +45,7 @@ function getSystemTimeZone(): string {
|
|||||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentDateTime(args?: Record<string, unknown>) {
|
export function getCurrentDateTime(args?: AiJsonObject) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const systemTimeZone = getSystemTimeZone();
|
const systemTimeZone = getSystemTimeZone();
|
||||||
|
|||||||
@@ -1,852 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import {Environment} from "../../common/environment";
|
|
||||||
import {AiTool} from "../tool-types";
|
|
||||||
import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits";
|
|
||||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
|
||||||
|
|
||||||
export const readFileTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "read_file",
|
|
||||||
description:
|
|
||||||
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
path: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative file path inside the root directory, for example notes/task.txt.",
|
|
||||||
},
|
|
||||||
maxBytes: {
|
|
||||||
type: "number",
|
|
||||||
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["path"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const listDirectoryTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "list_directory",
|
|
||||||
description:
|
|
||||||
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
path: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative directory path inside the root directory. Use . for root.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const createFileTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "create_file",
|
|
||||||
description:
|
|
||||||
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
path: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative file path inside the root directory.",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: "string",
|
|
||||||
description: "File content.",
|
|
||||||
},
|
|
||||||
overwrite: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to overwrite the file if it already exists. Default is false.",
|
|
||||||
},
|
|
||||||
createParents: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to create parent directories automatically. Default is true.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["path"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const createDirectoryTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "create_directory",
|
|
||||||
description:
|
|
||||||
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
path: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative directory path inside the root directory.",
|
|
||||||
},
|
|
||||||
recursive: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to create parent directories automatically. Default is true.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["path"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const copyPathTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "copy_path",
|
|
||||||
description:
|
|
||||||
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
sourcePath: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative source file or directory path inside the root directory.",
|
|
||||||
},
|
|
||||||
targetPath: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative target file or directory path inside the root directory.",
|
|
||||||
},
|
|
||||||
recursive: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Required for copying directories. Default is false.",
|
|
||||||
},
|
|
||||||
overwrite: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
|
|
||||||
},
|
|
||||||
createParents: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to create target parent directories automatically. Default is true.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["sourcePath", "targetPath"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const updateFileTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "update_file",
|
|
||||||
description:
|
|
||||||
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
path: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative file path inside the root directory.",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: "string",
|
|
||||||
description: "Content to write.",
|
|
||||||
},
|
|
||||||
mode: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["replace", "append", "prepend"],
|
|
||||||
description: "Update mode. Default is replace.",
|
|
||||||
},
|
|
||||||
createIfMissing: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to create the file if it does not exist. Default is false.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["path", "content"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const renamePathTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "rename_path",
|
|
||||||
description:
|
|
||||||
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
sourcePath: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative source path inside the root directory.",
|
|
||||||
},
|
|
||||||
targetPath: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative target path inside the root directory.",
|
|
||||||
},
|
|
||||||
overwrite: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
|
|
||||||
},
|
|
||||||
createParents: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to create target parent directories automatically. Default is false.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["sourcePath", "targetPath"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const deletePathTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "delete_path",
|
|
||||||
description:
|
|
||||||
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
path: {
|
|
||||||
type: "string",
|
|
||||||
description: "Relative file or directory path inside the root directory.",
|
|
||||||
},
|
|
||||||
recursive: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Whether to delete non-empty directories recursively. Default is false.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["path"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export const fileToolsToolPrompt = [
|
|
||||||
"Filesystem tool rules:",
|
|
||||||
"- You have access to filesystem tools working only inside the hardcoded root directory.",
|
|
||||||
"- All filesystem paths must be relative to the root directory.",
|
|
||||||
"- You may go into child directories.",
|
|
||||||
"- You must never go up to parent directories.",
|
|
||||||
"- Do not use ../ paths.",
|
|
||||||
"- Do not use absolute paths.",
|
|
||||||
"- Do not try to access symlinks.",
|
|
||||||
"- Use read_file for reading files.",
|
|
||||||
"- Use list_directory for reading directories.",
|
|
||||||
"- Use create_file for creating files.",
|
|
||||||
"- Use create_directory for creating directories.",
|
|
||||||
"- Use update_file for replacing, appending or prepending file content.",
|
|
||||||
"- Use rename_path for renaming or moving files/directories inside the root.",
|
|
||||||
"- Use delete_path for deleting files/directories inside the root.",
|
|
||||||
""
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const requireFileToolsRootDir = () => <string>Environment.FILE_TOOLS_ROOT_DIR;
|
|
||||||
|
|
||||||
async function ensureFileToolsRootExists(): Promise<void> {
|
|
||||||
await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true});
|
|
||||||
|
|
||||||
const stat = await fs.promises.stat(requireFileToolsRootDir());
|
|
||||||
|
|
||||||
if (!stat.isDirectory()) {
|
|
||||||
throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSafeToolPath(inputPath: unknown, fallback = "."): {
|
|
||||||
absolutePath: string;
|
|
||||||
relativePath: string;
|
|
||||||
} {
|
|
||||||
const rawPath = asNonEmptyString(inputPath) ?? fallback;
|
|
||||||
|
|
||||||
if (rawPath.includes("\0")) {
|
|
||||||
throw new Error("Path must not contain null bytes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
path.isAbsolute(rawPath) ||
|
|
||||||
path.win32.isAbsolute(rawPath) ||
|
|
||||||
path.posix.isAbsolute(rawPath)
|
|
||||||
) {
|
|
||||||
throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
|
|
||||||
|
|
||||||
const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath);
|
|
||||||
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
|
|
||||||
|
|
||||||
if (
|
|
||||||
relativePath.startsWith("..") ||
|
|
||||||
path.isAbsolute(relativePath)
|
|
||||||
) {
|
|
||||||
throw new Error("Path escapes the root directory. Going up is not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
absolutePath,
|
|
||||||
relativePath: relativePath || ".",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void {
|
|
||||||
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
|
|
||||||
|
|
||||||
if (
|
|
||||||
relative === "" ||
|
|
||||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
||||||
) {
|
|
||||||
throw new Error("Cannot copy a directory into itself.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertNoSymlinkInPath(
|
|
||||||
absolutePath: string,
|
|
||||||
options?: {
|
|
||||||
allowMissingTail?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
await ensureFileToolsRootExists();
|
|
||||||
|
|
||||||
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
|
|
||||||
|
|
||||||
if (!relativePath || relativePath === ".") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = relativePath.split(path.sep).filter(Boolean);
|
|
||||||
|
|
||||||
let currentPath = requireFileToolsRootDir();
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
currentPath = path.join(currentPath, part);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.lstat(currentPath);
|
|
||||||
|
|
||||||
if (stat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlinks are not allowed in file tool paths.");
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathExists(absolutePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.promises.lstat(absolutePath);
|
|
||||||
return true;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNotRoot(relativePath: string): void {
|
|
||||||
if (relativePath === ".") {
|
|
||||||
throw new Error("Operation on the root directory itself is not allowed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" {
|
|
||||||
if (stat.isSymbolicLink()) return "symlink";
|
|
||||||
if (stat.isFile()) return "file";
|
|
||||||
if (stat.isDirectory()) return "directory";
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readFile(args?: Record<string, unknown>) {
|
|
||||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(absolutePath);
|
|
||||||
|
|
||||||
const stat = await fs.promises.lstat(absolutePath);
|
|
||||||
|
|
||||||
if (!stat.isFile()) {
|
|
||||||
throw new Error(`Path is not a file: ${relativePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
|
|
||||||
|
|
||||||
if (stat.size > maxBytes) {
|
|
||||||
throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await fs.promises.readFile(absolutePath);
|
|
||||||
|
|
||||||
if (buffer.includes(0)) {
|
|
||||||
throw new Error("Binary files are not supported.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
path: relativePath,
|
|
||||||
sizeBytes: stat.size,
|
|
||||||
content: buffer.toString("utf8"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listDirectory(args?: Record<string, unknown>) {
|
|
||||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, ".");
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(absolutePath);
|
|
||||||
|
|
||||||
const stat = await fs.promises.lstat(absolutePath);
|
|
||||||
|
|
||||||
if (!stat.isDirectory()) {
|
|
||||||
throw new Error(`Path is not a directory: ${relativePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirEntries = await fs.promises.readdir(absolutePath, {
|
|
||||||
withFileTypes: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
|
|
||||||
|
|
||||||
const entries = await Promise.all(limitedEntries.map(async entry => {
|
|
||||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
|
||||||
const entryRelativePath = relativePath === "."
|
|
||||||
? entry.name
|
|
||||||
: path.join(relativePath, entry.name);
|
|
||||||
|
|
||||||
const entryStat = await fs.promises.lstat(entryAbsolutePath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: entry.name,
|
|
||||||
path: entryRelativePath,
|
|
||||||
type: getEntryType(entryStat),
|
|
||||||
sizeBytes: entryStat.isFile() ? entryStat.size : null,
|
|
||||||
modifiedAt: entryStat.mtime.toISOString(),
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
path: relativePath,
|
|
||||||
entries,
|
|
||||||
totalEntries: dirEntries.length,
|
|
||||||
returnedEntries: entries.length,
|
|
||||||
truncated: dirEntries.length > entries.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFile(args?: Record<string, unknown>) {
|
|
||||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
|
||||||
|
|
||||||
assertNotRoot(relativePath);
|
|
||||||
|
|
||||||
const content = asString(args?.content, "");
|
|
||||||
const overwrite = asBoolean(args?.overwrite, false);
|
|
||||||
const createParents = asBoolean(args?.createParents, true);
|
|
||||||
|
|
||||||
const contentSizeBytes = Buffer.byteLength(content, "utf8");
|
|
||||||
|
|
||||||
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
|
|
||||||
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = path.dirname(absolutePath);
|
|
||||||
|
|
||||||
if (createParents) {
|
|
||||||
await assertNoSymlinkInPath(parentPath, {allowMissingTail: true});
|
|
||||||
await fs.promises.mkdir(parentPath, {recursive: true});
|
|
||||||
} else {
|
|
||||||
await assertNoSymlinkInPath(parentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await pathExists(absolutePath)) {
|
|
||||||
const stat = await fs.promises.lstat(absolutePath);
|
|
||||||
|
|
||||||
if (stat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink targets are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
throw new Error(`Path is a directory, not a file: ${relativePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!overwrite) {
|
|
||||||
throw new Error(`File already exists: ${relativePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.promises.writeFile(absolutePath, content, {
|
|
||||||
encoding: "utf8",
|
|
||||||
flag: overwrite ? "w" : "wx",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
path: relativePath,
|
|
||||||
sizeBytes: contentSizeBytes,
|
|
||||||
overwritten: overwrite,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type CopyPathStats = {
|
|
||||||
entries: number;
|
|
||||||
totalBytes: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function copyPathRecursive(params: {
|
|
||||||
sourceAbsolutePath: string;
|
|
||||||
targetAbsolutePath: string;
|
|
||||||
overwrite: boolean;
|
|
||||||
stats: CopyPathStats;
|
|
||||||
}): Promise<void> {
|
|
||||||
const {
|
|
||||||
sourceAbsolutePath,
|
|
||||||
targetAbsolutePath,
|
|
||||||
overwrite,
|
|
||||||
stats,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
if (stats.entries >= MAX_COPY_ENTRIES) {
|
|
||||||
throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.entries++;
|
|
||||||
|
|
||||||
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
|
|
||||||
|
|
||||||
if (sourceStat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlinks are not allowed in copied paths.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceStat.isFile()) {
|
|
||||||
stats.totalBytes += sourceStat.size;
|
|
||||||
|
|
||||||
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
|
|
||||||
throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await pathExists(targetAbsolutePath)) {
|
|
||||||
const targetStat = await fs.promises.lstat(targetAbsolutePath);
|
|
||||||
|
|
||||||
if (targetStat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink targets are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetStat.isDirectory()) {
|
|
||||||
throw new Error("Cannot overwrite a directory with a file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!overwrite) {
|
|
||||||
throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.promises.copyFile(
|
|
||||||
sourceAbsolutePath,
|
|
||||||
targetAbsolutePath,
|
|
||||||
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceStat.isDirectory()) {
|
|
||||||
if (await pathExists(targetAbsolutePath)) {
|
|
||||||
const targetStat = await fs.promises.lstat(targetAbsolutePath);
|
|
||||||
|
|
||||||
if (targetStat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink targets are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetStat.isDirectory()) {
|
|
||||||
throw new Error("Cannot overwrite a file with a directory.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await fs.promises.mkdir(targetAbsolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await fs.promises.readdir(sourceAbsolutePath);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const childSourcePath = path.join(sourceAbsolutePath, entry);
|
|
||||||
const childTargetPath = path.join(targetAbsolutePath, entry);
|
|
||||||
|
|
||||||
await copyPathRecursive({
|
|
||||||
sourceAbsolutePath: childSourcePath,
|
|
||||||
targetAbsolutePath: childTargetPath,
|
|
||||||
overwrite,
|
|
||||||
stats,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Only files and directories can be copied.");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function copyPath(args?: Record<string, unknown>) {
|
|
||||||
const source = resolveSafeToolPath(args?.sourcePath);
|
|
||||||
const target = resolveSafeToolPath(args?.targetPath);
|
|
||||||
|
|
||||||
assertNotRoot(source.relativePath);
|
|
||||||
assertNotRoot(target.relativePath);
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(source.absolutePath);
|
|
||||||
|
|
||||||
const sourceStat = await fs.promises.lstat(source.absolutePath);
|
|
||||||
|
|
||||||
if (sourceStat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink sources are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const recursive = asBoolean(args?.recursive, false);
|
|
||||||
const overwrite = asBoolean(args?.overwrite, false);
|
|
||||||
const createParents = asBoolean(args?.createParents, true);
|
|
||||||
|
|
||||||
if (sourceStat.isDirectory() && !recursive) {
|
|
||||||
throw new Error("Source is a directory. Set recursive=true to copy directories.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceStat.isDirectory()) {
|
|
||||||
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetParentPath = path.dirname(target.absolutePath);
|
|
||||||
|
|
||||||
if (createParents) {
|
|
||||||
await assertNoSymlinkInPath(targetParentPath, {
|
|
||||||
allowMissingTail: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.promises.mkdir(targetParentPath, {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(targetParentPath);
|
|
||||||
} else {
|
|
||||||
await assertNoSymlinkInPath(targetParentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats: CopyPathStats = {
|
|
||||||
entries: 0,
|
|
||||||
totalBytes: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
await copyPathRecursive({
|
|
||||||
sourceAbsolutePath: source.absolutePath,
|
|
||||||
targetAbsolutePath: target.absolutePath,
|
|
||||||
overwrite,
|
|
||||||
stats,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
from: source.relativePath,
|
|
||||||
to: target.relativePath,
|
|
||||||
recursive,
|
|
||||||
overwrite,
|
|
||||||
entriesCopied: stats.entries,
|
|
||||||
bytesCopied: stats.totalBytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDirectory(args?: Record<string, unknown>) {
|
|
||||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
|
||||||
|
|
||||||
const recursive = asBoolean(args?.recursive, true);
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(absolutePath, {
|
|
||||||
allowMissingTail: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.promises.mkdir(absolutePath, {
|
|
||||||
recursive,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
path: relativePath,
|
|
||||||
recursive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateFile(args?: Record<string, unknown>) {
|
|
||||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
|
||||||
|
|
||||||
assertNotRoot(relativePath);
|
|
||||||
|
|
||||||
const content = asString(args?.content, "");
|
|
||||||
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
|
|
||||||
const createIfMissing = asBoolean(args?.createIfMissing, false);
|
|
||||||
|
|
||||||
if (!["replace", "append", "prepend"].includes(mode)) {
|
|
||||||
throw new Error(`Unsupported update mode: ${mode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentSizeBytes = Buffer.byteLength(content, "utf8");
|
|
||||||
|
|
||||||
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
|
|
||||||
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = path.dirname(absolutePath);
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(parentPath);
|
|
||||||
|
|
||||||
const exists = await pathExists(absolutePath);
|
|
||||||
|
|
||||||
if (!exists && !createIfMissing) {
|
|
||||||
throw new Error(`File does not exist: ${relativePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
await assertNoSymlinkInPath(absolutePath);
|
|
||||||
|
|
||||||
const stat = await fs.promises.lstat(absolutePath);
|
|
||||||
|
|
||||||
if (!stat.isFile()) {
|
|
||||||
throw new Error(`Path is not a file: ${relativePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "replace") {
|
|
||||||
await fs.promises.writeFile(absolutePath, content, {
|
|
||||||
encoding: "utf8",
|
|
||||||
flag: "w",
|
|
||||||
});
|
|
||||||
} else if (mode === "append") {
|
|
||||||
await fs.promises.appendFile(absolutePath, content, {
|
|
||||||
encoding: "utf8",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const oldContent = exists
|
|
||||||
? await fs.promises.readFile(absolutePath, "utf8")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
|
|
||||||
|
|
||||||
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
|
|
||||||
throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.promises.writeFile(absolutePath, content + oldContent, {
|
|
||||||
encoding: "utf8",
|
|
||||||
flag: "w",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStat = await fs.promises.stat(absolutePath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
path: relativePath,
|
|
||||||
mode,
|
|
||||||
sizeBytes: newStat.size,
|
|
||||||
created: !exists,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renamePath(args?: Record<string, unknown>) {
|
|
||||||
const source = resolveSafeToolPath(args?.sourcePath);
|
|
||||||
const target = resolveSafeToolPath(args?.targetPath);
|
|
||||||
|
|
||||||
assertNotRoot(source.relativePath);
|
|
||||||
assertNotRoot(target.relativePath);
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(source.absolutePath);
|
|
||||||
|
|
||||||
const sourceStat = await fs.promises.lstat(source.absolutePath);
|
|
||||||
|
|
||||||
if (sourceStat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink targets are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath);
|
|
||||||
|
|
||||||
if (
|
|
||||||
relativeTargetInsideSource === "" ||
|
|
||||||
(!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource))
|
|
||||||
) {
|
|
||||||
throw new Error("Cannot move a directory into itself.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const overwrite = asBoolean(args?.overwrite, false);
|
|
||||||
const createParents = asBoolean(args?.createParents, false);
|
|
||||||
|
|
||||||
const targetParentPath = path.dirname(target.absolutePath);
|
|
||||||
|
|
||||||
if (createParents) {
|
|
||||||
await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true});
|
|
||||||
await fs.promises.mkdir(targetParentPath, {recursive: true});
|
|
||||||
} else {
|
|
||||||
await assertNoSymlinkInPath(targetParentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await pathExists(target.absolutePath)) {
|
|
||||||
const targetStat = await fs.promises.lstat(target.absolutePath);
|
|
||||||
|
|
||||||
if (targetStat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink targets are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!overwrite) {
|
|
||||||
throw new Error(`Target already exists: ${target.relativePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
|
|
||||||
throw new Error("Overwrite for directories is not supported.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.promises.rm(target.absolutePath, {
|
|
||||||
force: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.promises.rename(source.absolutePath, target.absolutePath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
from: source.relativePath,
|
|
||||||
to: target.relativePath,
|
|
||||||
overwrite,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePath(args?: Record<string, unknown>) {
|
|
||||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
|
||||||
|
|
||||||
assertNotRoot(relativePath);
|
|
||||||
|
|
||||||
await assertNoSymlinkInPath(absolutePath);
|
|
||||||
|
|
||||||
const stat = await fs.promises.lstat(absolutePath);
|
|
||||||
|
|
||||||
if (stat.isSymbolicLink()) {
|
|
||||||
throw new Error("Symlink targets are not allowed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const recursive = asBoolean(args?.recursive, false);
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
if (recursive) {
|
|
||||||
await fs.promises.rm(absolutePath, {
|
|
||||||
recursive: true,
|
|
||||||
force: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fs.promises.rmdir(absolutePath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await fs.promises.rm(absolutePath, {
|
|
||||||
force: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
path: relativePath,
|
|
||||||
recursive,
|
|
||||||
deleted: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,15 @@ export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
|
|||||||
export const MAX_DIRECTORY_ENTRIES = 200;
|
export const MAX_DIRECTORY_ENTRIES = 200;
|
||||||
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
|
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
|
||||||
export const MAX_COPY_ENTRIES = 500;
|
export const MAX_COPY_ENTRIES = 500;
|
||||||
|
export const MAX_PATCH_OPERATIONS = 20;
|
||||||
|
export const MAX_PATCH_SEARCH_BYTES = 64 * 1024;
|
||||||
|
export const MAX_PATCH_REPLACE_BYTES = 256 * 1024;
|
||||||
|
export const MAX_PATCH_PREVIEW_CHARS = 6000;
|
||||||
|
export const MAX_FILE_SEARCH_ENTRIES = 5000;
|
||||||
|
export const MAX_FILE_SEARCH_RESULTS = 100;
|
||||||
|
export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024;
|
||||||
|
export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300;
|
||||||
|
export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024;
|
||||||
|
export const MAX_STREAM_WRITE_SESSIONS = 20;
|
||||||
|
export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000;
|
||||||
|
export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("market-rates");
|
const logger = toolsLogger.child("market-rates");
|
||||||
|
|
||||||
export const GET_FINANCIAL_MARKET_DATA = "get_financial_market_data";
|
export const GET_FINANCIAL_MARKET_DATA_TOOL_NAME = "get_financial_market_data";
|
||||||
|
|
||||||
export const getFinancialMarketData = {
|
export const getFinancialMarketData = {
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: GET_FINANCIAL_MARKET_DATA,
|
name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||||
description:
|
description:
|
||||||
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
|
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -20,11 +21,11 @@ export const getFinancialMarketData = {
|
|||||||
},
|
},
|
||||||
} satisfies AiTool;
|
} satisfies AiTool;
|
||||||
|
|
||||||
export const financialMarketDataToolPrompt = [
|
export const getFinancialMarketDataToolPrompt = [
|
||||||
"Currency rates tool rules:",
|
"Currency rates tool rules:",
|
||||||
`- Use \`${GET_FINANCIAL_MARKET_DATA}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
|
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
|
||||||
`- Use \`${GET_FINANCIAL_MARKET_DATA}\` when the user asks whether a supported asset went up or down recently.`,
|
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`,
|
||||||
`- Use \`${GET_FINANCIAL_MARKET_DATA}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`,
|
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`,
|
||||||
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
|
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
|
||||||
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
|
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
|
||||||
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
|
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
|
||||||
@@ -63,15 +64,15 @@ export const financialMarketDataToolPrompt = [
|
|||||||
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
|
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
export async function getMarketRates(): Promise<unknown | undefined> {
|
export async function getMarketRates(): Promise<AiJsonObject | undefined> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
logger.info("start");
|
logger.info("start");
|
||||||
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
|
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
|
||||||
logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
|
logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (e: unknown) {
|
} catch (error) {
|
||||||
logger.error("failed", {duration: logger.duration(startedAt), error: e});
|
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
|
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
||||||
import {notesDir, notesRootFile} from "../../index";
|
import {notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("notes");
|
const logger = toolsLogger.child("notes");
|
||||||
|
|
||||||
@@ -98,14 +100,14 @@ export async function listNotes(): Promise<ListNotesResult> {
|
|||||||
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
|
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
|
||||||
return {success: true, notes};
|
return {success: true, notes};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return {success: false, error: `Failed to list notes: ${errorMessage}`};
|
return {success: false, error: `Failed to list notes: ${errorMessage}`};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNoteContent(
|
export async function getNoteContent(
|
||||||
args?: Record<string, unknown>,
|
args?: AiJsonObject,
|
||||||
): Promise<GetNoteContentResult> {
|
): Promise<GetNoteContentResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.debug("get_content.start", {args});
|
logger.debug("get_content.start", {args});
|
||||||
@@ -144,7 +146,7 @@ export async function getNoteContent(
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return {success: false, error: `Failed to read note: ${errorMessage}`};
|
return {success: false, error: `Failed to read note: ${errorMessage}`};
|
||||||
}
|
}
|
||||||
@@ -235,7 +237,7 @@ export const deleteNoteTool = {
|
|||||||
} satisfies AiTool;
|
} satisfies AiTool;
|
||||||
|
|
||||||
export async function updateNoteContent(
|
export async function updateNoteContent(
|
||||||
args?: Record<string, unknown>,
|
args?: AiJsonObject,
|
||||||
): Promise<UpdateNoteContentResult> {
|
): Promise<UpdateNoteContentResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.debug("update_content.start", {args});
|
logger.debug("update_content.start", {args});
|
||||||
@@ -271,14 +273,14 @@ export async function updateNoteContent(
|
|||||||
|
|
||||||
return {success: true, filePath: noteFilePath};
|
return {success: true, filePath: noteFilePath};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return {success: false, error: `Failed to update note: ${errorMessage}`};
|
return {success: false, error: `Failed to update note: ${errorMessage}`};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteNote(
|
export async function deleteNote(
|
||||||
args?: Record<string, unknown>,
|
args?: AiJsonObject,
|
||||||
): Promise<DeleteNoteResult> {
|
): Promise<DeleteNoteResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.debug("delete.start", {args});
|
logger.debug("delete.start", {args});
|
||||||
@@ -304,7 +306,7 @@ export async function deleteNote(
|
|||||||
|
|
||||||
return {success: true, filePath: noteFilePath};
|
return {success: true, filePath: noteFilePath};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return {success: false, error: `Failed to delete note: ${errorMessage}`};
|
return {success: false, error: `Failed to delete note: ${errorMessage}`};
|
||||||
}
|
}
|
||||||
@@ -338,3 +340,110 @@ async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
|
|||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NoteFileAttachment = {
|
||||||
|
type: "local_file";
|
||||||
|
fileName: string;
|
||||||
|
// filePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
mimeType: "text/markdown";
|
||||||
|
sizeBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetNoteFileResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
attachment: NoteFileAttachment;
|
||||||
|
} | { success: false; error: string };
|
||||||
|
|
||||||
|
export const NoteFileAttachmentSchema = z.object({
|
||||||
|
type: z.literal("local_file"),
|
||||||
|
fileName: z.string(),
|
||||||
|
// filePath: z.string(),
|
||||||
|
relativePath: z.string(),
|
||||||
|
mimeType: z.literal("text/markdown"),
|
||||||
|
sizeBytes: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
||||||
|
z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
attachment: NoteFileAttachmentSchema,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
error: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const sendNoteAsFileTool = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "send_note_as_file",
|
||||||
|
description:
|
||||||
|
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fileName: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["fileName"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies AiTool;
|
||||||
|
|
||||||
|
export async function sendNoteAsFile(
|
||||||
|
args?: AiJsonObject,
|
||||||
|
): Promise<GetNoteFileResult> {
|
||||||
|
logger.debug("start", {args});
|
||||||
|
|
||||||
|
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||||
|
if (!fileName.trim().length) {
|
||||||
|
return {success: false, error: "No file name provided"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||||
|
if (!noteFilePath) {
|
||||||
|
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем, что файл существует и действительно читается.
|
||||||
|
await readFile(noteFilePath, "utf-8");
|
||||||
|
|
||||||
|
const fileStat = await stat(noteFilePath);
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
return {success: false, error: "Note path is not a file"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFileName = path.basename(noteFilePath);
|
||||||
|
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||||
|
|
||||||
|
const result: GetNoteFileResult = {
|
||||||
|
success: true,
|
||||||
|
attachment: {
|
||||||
|
type: "local_file",
|
||||||
|
fileName: normalizedFileName,
|
||||||
|
// filePath: noteFilePath,
|
||||||
|
relativePath,
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
sizeBytes: fileStat.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug("done", {
|
||||||
|
fileName: result.attachment.fileName,
|
||||||
|
relativePath: result.attachment.relativePath,
|
||||||
|
sizeBytes: result.attachment.sizeBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ import {spawn} from "node:child_process";
|
|||||||
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
|
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("python-interpreter");
|
const logger = toolsLogger.child("python-interpreter");
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ export const pythonInterpreterTool = {
|
|||||||
} satisfies AiTool;
|
} satisfies AiTool;
|
||||||
|
|
||||||
export async function runPythonInterpreter(
|
export async function runPythonInterpreter(
|
||||||
rawArgs: unknown,
|
rawArgs: string | AiJsonObject | undefined,
|
||||||
options: PythonInterpreterOptions = {},
|
options: PythonInterpreterOptions = {},
|
||||||
): Promise<PythonToolResult> {
|
): Promise<PythonToolResult> {
|
||||||
let args: PythonInterpreterArgs;
|
let args: PythonInterpreterArgs;
|
||||||
@@ -202,7 +203,7 @@ export async function runPythonInterpreter(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
phase: "internal",
|
phase: "internal",
|
||||||
error: errorToString(error),
|
error: errorToString(error instanceof Error ? error : String(error)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,11 +433,11 @@ async function executePythonCode(
|
|||||||
skippedArtifacts,
|
skippedArtifacts,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("execute.failed", {duration: logger.duration(startedAt), error});
|
logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
phase: "internal",
|
phase: "internal",
|
||||||
error: errorToString(error),
|
error: errorToString(error instanceof Error ? error : String(error)),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tempDir, {
|
await rm(tempDir, {
|
||||||
@@ -660,7 +661,7 @@ function mimeTypeFromPath(filePath: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePythonInterpreterArgs(
|
function parsePythonInterpreterArgs(
|
||||||
rawArgs: unknown,
|
rawArgs: string | AiJsonObject | undefined,
|
||||||
options: PythonInterpreterOptions,
|
options: PythonInterpreterOptions,
|
||||||
): PythonInterpreterArgs {
|
): PythonInterpreterArgs {
|
||||||
let args = rawArgs;
|
let args = rawArgs;
|
||||||
@@ -673,11 +674,11 @@ function parsePythonInterpreterArgs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args || typeof args !== "object") {
|
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||||
throw new Error("Tool arguments must be an object.");
|
throw new Error("Tool arguments must be an object.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = args as Record<string, unknown>;
|
const record = args as AiJsonObject;
|
||||||
const code = record.code;
|
const code = record.code;
|
||||||
|
|
||||||
if (typeof code !== "string" || !code.trim()) {
|
if (typeof code !== "string" || !code.trim()) {
|
||||||
@@ -812,7 +813,7 @@ function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorToString(error: unknown): string {
|
function errorToString(error: Error | string | object | null | undefined): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.stack || error.message;
|
return error.stack || error.message;
|
||||||
}
|
}
|
||||||
|
|||||||
+192
-111
@@ -1,18 +1,22 @@
|
|||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import {braveSearchTool, webSearch} from "./brave-search";
|
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
|
||||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
|
||||||
import {shellExecute, shellExecuteTool} from "./shell";
|
import {shellExecute, shellExecuteTool} from "./shell.js";
|
||||||
import {ToolHandler} from "./types";
|
import {ToolHandler} from "./types.js";
|
||||||
import {getWeather, getWeatherTool} from "./weather";
|
import {getWeather, getWeatherTool} from "./weather.js";
|
||||||
import {
|
import {
|
||||||
financialMarketDataToolPrompt,
|
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||||
GET_FINANCIAL_MARKET_DATA,
|
|
||||||
getFinancialMarketData,
|
getFinancialMarketData,
|
||||||
|
getFinancialMarketDataToolPrompt,
|
||||||
getMarketRates
|
getMarketRates
|
||||||
} from "./market-rates";
|
} from "./market-rates.js";
|
||||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
|
||||||
import {
|
import {
|
||||||
|
beginFileWrite,
|
||||||
|
beginFileWriteTool,
|
||||||
|
cancelFileWrite,
|
||||||
|
cancelFileWriteTool,
|
||||||
copyPath,
|
copyPath,
|
||||||
copyPathTool,
|
copyPathTool,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
@@ -21,154 +25,231 @@ import {
|
|||||||
createFileTool,
|
createFileTool,
|
||||||
deletePath,
|
deletePath,
|
||||||
deletePathTool,
|
deletePathTool,
|
||||||
|
editFilePatch,
|
||||||
|
editFilePatchTool,
|
||||||
|
fileToolsToolPrompt,
|
||||||
|
finishFileWrite,
|
||||||
|
finishFileWriteTool,
|
||||||
listDirectory,
|
listDirectory,
|
||||||
listDirectoryTool,
|
listDirectoryTool,
|
||||||
readFile,
|
readFile,
|
||||||
readFileTool,
|
readFileTool,
|
||||||
renamePath,
|
renamePath,
|
||||||
renamePathTool,
|
renamePathTool,
|
||||||
|
searchFiles,
|
||||||
|
searchFilesTool,
|
||||||
|
sendFileAsAttachment,
|
||||||
|
sendFileAsAttachmentTool,
|
||||||
updateFile,
|
updateFile,
|
||||||
updateFileTool
|
updateFileTool,
|
||||||
} from "./file-system";
|
writeFileChunk,
|
||||||
import {createNote, createNoteTool} from "./create-note";
|
writeFileChunkTool
|
||||||
import {
|
} from "./files.js";
|
||||||
deleteNote,
|
import {executeMemoryTool, memoryToolPrompt, memoryTools, type MemoryToolName} from "./user-memory.js";
|
||||||
deleteNoteTool,
|
import {getMcpToolHandlers, getMcpToolPrompts, getMcpTools} from "../mcp/mcp-registry.js";
|
||||||
getNoteContent,
|
|
||||||
getNoteContentTool,
|
|
||||||
listNotes,
|
|
||||||
listNotesTool,
|
|
||||||
updateNoteContent,
|
|
||||||
updateNoteContentTool
|
|
||||||
} from "./list-notes";
|
|
||||||
import {sendNoteAsFileTool, sendNoteAsFile} from "./send-note-as-file";
|
|
||||||
import {searchNotes, searchNotesTool} from "./search-notes";
|
|
||||||
|
|
||||||
export const getTools = () => {
|
export const defaultTools: AiTool[] = [
|
||||||
const tools: AiTool[] = [
|
|
||||||
getCurrentDateTimeTool,
|
getCurrentDateTimeTool,
|
||||||
getFinancialMarketData,
|
getFinancialMarketData,
|
||||||
createNoteTool,
|
...memoryTools,
|
||||||
listNotesTool,
|
];
|
||||||
getNoteContentTool,
|
|
||||||
updateNoteContentTool,
|
|
||||||
deleteNoteTool,
|
|
||||||
sendNoteAsFileTool,
|
|
||||||
searchNotesTool
|
|
||||||
];
|
|
||||||
|
|
||||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
export const fileTools = [
|
||||||
tools.push(pythonInterpreterTool);
|
readFileTool,
|
||||||
|
listDirectoryTool,
|
||||||
|
searchFilesTool,
|
||||||
|
|
||||||
|
createFileTool,
|
||||||
|
beginFileWriteTool,
|
||||||
|
writeFileChunkTool,
|
||||||
|
finishFileWriteTool,
|
||||||
|
cancelFileWriteTool,
|
||||||
|
|
||||||
|
sendFileAsAttachmentTool,
|
||||||
|
|
||||||
|
createDirectoryTool,
|
||||||
|
copyPathTool,
|
||||||
|
updateFileTool,
|
||||||
|
editFilePatchTool,
|
||||||
|
renamePathTool,
|
||||||
|
deletePathTool,
|
||||||
|
] satisfies AiTool[];
|
||||||
|
|
||||||
|
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
|
||||||
|
if (!raw?.trim()) return undefined;
|
||||||
|
|
||||||
|
const names = raw
|
||||||
|
.split(",")
|
||||||
|
.map(item => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return names.length ? new Set(names) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalToolEnabled(toolName: string): boolean {
|
||||||
|
if (Environment.DISABLE_LOCAL_TOOLS) return false;
|
||||||
|
|
||||||
|
const allowlist = parseToolNameSet(Environment.LOCAL_TOOL_ALLOWLIST);
|
||||||
|
if (allowlist && !allowlist.has(toolName.toLowerCase())) return false;
|
||||||
|
|
||||||
|
const denylist = parseToolNameSet(Environment.LOCAL_TOOL_DENYLIST);
|
||||||
|
if (denylist && denylist.has(toolName.toLowerCase())) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEnabledTools(tools: AiTool[]): AiTool[] {
|
||||||
|
return tools.filter(tool => isLocalToolEnabled(tool.function.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTools = (forCreator?: boolean) => {
|
||||||
|
const tools: AiTool[] = [];
|
||||||
|
|
||||||
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
|
tools.push(...getMcpTools());
|
||||||
|
return tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
tools.push(...filterEnabledTools(defaultTools));
|
||||||
tools.push(shellExecuteTool);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||||
tools.push(braveSearchTool);
|
tools.push(...filterEnabledTools([webSearchTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||||
tools.push(getWeatherTool);
|
tools.push(...filterEnabledTools([getWeatherTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||||
tools.push(
|
tools.push(...filterEnabledTools(fileTools));
|
||||||
readFileTool,
|
|
||||||
listDirectoryTool,
|
|
||||||
createFileTool,
|
|
||||||
createDirectoryTool,
|
|
||||||
updateFileTool,
|
|
||||||
renamePathTool,
|
|
||||||
copyPathTool,
|
|
||||||
deletePathTool,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (forCreator) {
|
||||||
|
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||||
|
tools.push(...filterEnabledTools([pythonInterpreterTool]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||||
|
tools.push(...filterEnabledTools([shellExecuteTool]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.push(...getMcpTools());
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
// return [
|
};
|
||||||
// createNoteTool,
|
|
||||||
// listNotesTool,
|
export const fileToolHandlers = {
|
||||||
// getNoteContentTool,
|
read_file: readFile,
|
||||||
// updateNoteContentTool,
|
list_directory: listDirectory,
|
||||||
// deleteNoteTool,
|
search_files: searchFiles,
|
||||||
// getNoteFileTool,
|
|
||||||
// searchNotesTool
|
create_file: createFile,
|
||||||
// ];
|
begin_file_write: beginFileWrite,
|
||||||
|
write_file_chunk: writeFileChunk,
|
||||||
|
finish_file_write: finishFileWrite,
|
||||||
|
cancel_file_write: cancelFileWrite,
|
||||||
|
|
||||||
|
send_file_as_attachment: sendFileAsAttachment,
|
||||||
|
|
||||||
|
create_directory: createDirectory,
|
||||||
|
copy_path: copyPath,
|
||||||
|
update_file: updateFile,
|
||||||
|
edit_file_patch: editFilePatch,
|
||||||
|
rename_path: renamePath,
|
||||||
|
delete_path: deletePath,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getToolHandlers = () => {
|
export const getToolHandlers = () => {
|
||||||
let handlers: Record<string, ToolHandler> = {
|
const handlers: Record<string, ToolHandler> = {
|
||||||
get_datetime: getCurrentDateTime,
|
...getMcpToolHandlers(),
|
||||||
get_financial_market_data: getMarketRates,
|
|
||||||
create_note: createNote,
|
|
||||||
list_notes: listNotes,
|
|
||||||
get_note_content: getNoteContent,
|
|
||||||
update_note_content: updateNoteContent,
|
|
||||||
delete_note: deleteNote,
|
|
||||||
send_note_as_file: sendNoteAsFile,
|
|
||||||
search_notes: searchNotes
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
handlers = {
|
return handlers;
|
||||||
python_interpreter: runPythonInterpreter,
|
}
|
||||||
...handlers
|
|
||||||
|
if (isLocalToolEnabled("get_datetime")) handlers.get_datetime = getCurrentDateTime;
|
||||||
|
if (isLocalToolEnabled("get_financial_market_data")) handlers.get_financial_market_data = getMarketRates;
|
||||||
|
for (const tool of memoryTools) {
|
||||||
|
if (!isLocalToolEnabled(tool.function.name)) continue;
|
||||||
|
handlers[tool.function.name] = async (args, context) => {
|
||||||
|
const userId = typeof args?.userId === "number" ? args.userId : undefined;
|
||||||
|
if (!userId) {
|
||||||
|
return {success: false, error: "Missing userId"};
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeMemoryTool(tool.function.name as MemoryToolName, {
|
||||||
|
userId,
|
||||||
|
content: typeof args?.content === "string" ? args.content : undefined,
|
||||||
|
}, context);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
if (isLocalToolEnabled("read_file")) handlers.read_file = readFile;
|
||||||
handlers = {
|
if (isLocalToolEnabled("list_directory")) handlers.list_directory = listDirectory;
|
||||||
shell_execute: shellExecute,
|
if (isLocalToolEnabled("search_files")) handlers.search_files = searchFiles;
|
||||||
...handlers,
|
if (isLocalToolEnabled("create_file")) handlers.create_file = createFile;
|
||||||
};
|
if (isLocalToolEnabled("begin_file_write")) handlers.begin_file_write = beginFileWrite;
|
||||||
}
|
if (isLocalToolEnabled("write_file_chunk")) handlers.write_file_chunk = writeFileChunk;
|
||||||
|
if (isLocalToolEnabled("finish_file_write")) handlers.finish_file_write = finishFileWrite;
|
||||||
|
if (isLocalToolEnabled("cancel_file_write")) handlers.cancel_file_write = cancelFileWrite;
|
||||||
|
if (isLocalToolEnabled("send_file_as_attachment")) handlers.send_file_as_attachment = sendFileAsAttachment;
|
||||||
|
if (isLocalToolEnabled("create_directory")) handlers.create_directory = createDirectory;
|
||||||
|
if (isLocalToolEnabled("copy_path")) handlers.copy_path = copyPath;
|
||||||
|
if (isLocalToolEnabled("update_file")) handlers.update_file = updateFile;
|
||||||
|
if (isLocalToolEnabled("edit_file_patch")) handlers.edit_file_patch = editFilePatch;
|
||||||
|
if (isLocalToolEnabled("rename_path")) handlers.rename_path = renamePath;
|
||||||
|
if (isLocalToolEnabled("delete_path")) handlers.delete_path = deletePath;
|
||||||
|
|
||||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
if (isLocalToolEnabled("python_interpreter")) handlers.python_interpreter = (args, _context) => runPythonInterpreter(args);
|
||||||
handlers = {
|
if (isLocalToolEnabled("shell_execute")) handlers.shell_execute = shellExecute;
|
||||||
web_search: webSearch,
|
if (isLocalToolEnabled("web_search")) handlers.web_search = webSearch;
|
||||||
...handlers,
|
if (isLocalToolEnabled("get_weather")) handlers.get_weather = getWeather;
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
|
||||||
handlers = {
|
|
||||||
get_weather: getWeather,
|
|
||||||
...handlers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
|
||||||
handlers = {
|
|
||||||
read_file: readFile,
|
|
||||||
list_directory: listDirectory,
|
|
||||||
create_file: createFile,
|
|
||||||
create_directory: createDirectory,
|
|
||||||
update_file: updateFile,
|
|
||||||
rename_path: renamePath,
|
|
||||||
copy_path: copyPath,
|
|
||||||
delete_path: deletePath,
|
|
||||||
...handlers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return handlers;
|
return handlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToolPrompts(toolNames: string[]): string[] {
|
export function getToolPrompts(toolNames: string[]): string[] {
|
||||||
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
|
return getMcpToolPrompts(toolNames);
|
||||||
|
}
|
||||||
|
|
||||||
const prompts: string[] = [];
|
const prompts: string[] = [];
|
||||||
|
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
|
||||||
|
let memoryPromptAdded = false;
|
||||||
|
|
||||||
for (const toolName of toolNames) {
|
for (const toolName of toolNames) {
|
||||||
|
if (!isLocalToolEnabled(toolName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompts.includes(fileToolsToolPrompt) &&
|
||||||
|
fileTools.map(t => t.function.name).includes(toolName)) {
|
||||||
|
prompts.push(fileToolsToolPrompt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memoryToolNames.has(toolName)) {
|
||||||
|
if (!memoryPromptAdded) {
|
||||||
|
prompts.push(memoryToolPrompt);
|
||||||
|
memoryPromptAdded = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case GET_FINANCIAL_MARKET_DATA:
|
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
||||||
prompts.push(financialMarketDataToolPrompt);
|
prompts.push(getFinancialMarketDataToolPrompt);
|
||||||
|
break;
|
||||||
|
case WEB_SEARCH_TOOL_NAME:
|
||||||
|
prompts.push(webSearchToolPrompt);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prompts.push(...getMcpToolPrompts(toolNames));
|
||||||
return prompts;
|
return prompts;
|
||||||
}
|
}
|
||||||
+18
-10
@@ -1,22 +1,29 @@
|
|||||||
import {getToolHandlers} from "./registry";
|
import {getToolHandlers} from "./registry.js";
|
||||||
import {normalizeToolArguments} from "./utils";
|
import {normalizeToolArguments} from "./utils.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
|
||||||
|
import type {MemoryRuntimeContext} from "./user-memory.js";
|
||||||
|
import type {AiRuntimeTarget} from "../ai-runtime-target.js";
|
||||||
|
import type {AiProvider} from "../../model/ai-provider.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("runtime");
|
const logger = toolsLogger.child("runtime");
|
||||||
|
|
||||||
export type ToolRuntimeContext = {
|
export type ToolRuntimeContext = {
|
||||||
pythonInputFiles?: PythonInterpreterInputFile[];
|
pythonInputFiles?: PythonInterpreterInputFile[];
|
||||||
};
|
provider?: AiProvider;
|
||||||
|
runtimeTarget?: AiRuntimeTarget;
|
||||||
|
} & MemoryRuntimeContext;
|
||||||
|
|
||||||
function stringifyToolResult(result: unknown): string {
|
function stringifyToolResult(result: AiJsonValue): string {
|
||||||
if (typeof result === "string") return result;
|
if (typeof result === "string") return result;
|
||||||
return JSON.stringify(result, null, 2);
|
return JSON.stringify(result, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeToolCall(
|
export async function executeToolCall(
|
||||||
|
userId: number | undefined | null,
|
||||||
name: string,
|
name: string,
|
||||||
args?: unknown,
|
args?: string | AiJsonObject,
|
||||||
context: ToolRuntimeContext = {},
|
context: ToolRuntimeContext = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -31,7 +38,7 @@ export async function executeToolCall(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
|
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
|
||||||
const result = await runPythonInterpreter(normalizeToolArguments(args), {
|
const result = await runPythonInterpreter(normalizeToolArguments(args, userId), {
|
||||||
executionTimeoutMs: 8_000,
|
executionTimeoutMs: 8_000,
|
||||||
syntaxTimeoutMs: 3_000,
|
syntaxTimeoutMs: 3_000,
|
||||||
maxCodeChars: 100_000,
|
maxCodeChars: 100_000,
|
||||||
@@ -45,12 +52,13 @@ export async function executeToolCall(
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await handler(normalizeToolArguments(args));
|
const arguments1 = normalizeToolArguments(args, userId);
|
||||||
|
const result = await handler(arguments1, context);
|
||||||
const s = stringifyToolResult(result);
|
const s = stringifyToolResult(result);
|
||||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||||
return s;
|
return s;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error});
|
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
return stringifyToolResult({
|
return stringifyToolResult({
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readdir, readFile} from "node:fs/promises";
|
import {readdir, readFile} from "node:fs/promises";
|
||||||
import {notesDir, notesRootFile} from "../../index";
|
import {notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("search-notes");
|
const logger = toolsLogger.child("search-notes");
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export const searchNotesTool = {
|
|||||||
} satisfies AiTool;
|
} satisfies AiTool;
|
||||||
|
|
||||||
export async function searchNotes(
|
export async function searchNotes(
|
||||||
args?: Record<string, unknown>,
|
args?: AiJsonObject,
|
||||||
): Promise<SearchNotesResult> {
|
): Promise<SearchNotesResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.debug("start", {args});
|
logger.debug("start", {args});
|
||||||
@@ -139,7 +140,7 @@ export async function searchNotes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSearchLimit(value: unknown): number {
|
function parseSearchLimit(value: AiJsonValue | undefined): number {
|
||||||
const parsed =
|
const parsed =
|
||||||
typeof value === "number"
|
typeof value === "number"
|
||||||
? value
|
? value
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import {AiTool} from "../tool-types";
|
|
||||||
import path from "node:path";
|
|
||||||
import {readFile, stat} from "node:fs/promises";
|
|
||||||
import {notesRootFile} from "../../index";
|
|
||||||
import {asNonEmptyString} from "./utils";
|
|
||||||
import {buildSafeNoteFilePath} from "./list-notes";
|
|
||||||
import z from "zod";
|
|
||||||
import {toolsLogger} from "./tool-logger";
|
|
||||||
|
|
||||||
const logger = toolsLogger.child("get-note-file");
|
|
||||||
|
|
||||||
export type NoteFileAttachment = {
|
|
||||||
type: "local_file";
|
|
||||||
fileName: string;
|
|
||||||
// filePath: string;
|
|
||||||
relativePath: string;
|
|
||||||
mimeType: "text/markdown";
|
|
||||||
sizeBytes: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GetNoteFileResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
attachment: NoteFileAttachment;
|
|
||||||
} | { success: false; error: string };
|
|
||||||
|
|
||||||
export const NoteFileAttachmentSchema = z.object({
|
|
||||||
type: z.literal("local_file"),
|
|
||||||
fileName: z.string(),
|
|
||||||
// filePath: z.string(),
|
|
||||||
relativePath: z.string(),
|
|
||||||
mimeType: z.literal("text/markdown"),
|
|
||||||
sizeBytes: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
|
||||||
z.object({
|
|
||||||
success: z.literal(true),
|
|
||||||
attachment: NoteFileAttachmentSchema,
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
success: z.literal(false),
|
|
||||||
error: z.string(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const sendNoteAsFileTool = {
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "send_note_as_file",
|
|
||||||
description:
|
|
||||||
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
fileName: {
|
|
||||||
type: "string",
|
|
||||||
description:
|
|
||||||
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["fileName"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AiTool;
|
|
||||||
|
|
||||||
export async function sendNoteAsFile(
|
|
||||||
args?: Record<string, unknown>,
|
|
||||||
): Promise<GetNoteFileResult> {
|
|
||||||
logger.debug("start", {args});
|
|
||||||
|
|
||||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
|
||||||
if (!fileName.trim().length) {
|
|
||||||
return {success: false, error: "No file name provided"};
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
|
||||||
if (!noteFilePath) {
|
|
||||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Проверяем, что файл существует и действительно читается.
|
|
||||||
await readFile(noteFilePath, "utf-8");
|
|
||||||
|
|
||||||
const fileStat = await stat(noteFilePath);
|
|
||||||
if (!fileStat.isFile()) {
|
|
||||||
return {success: false, error: "Note path is not a file"};
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedFileName = path.basename(noteFilePath);
|
|
||||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
|
||||||
|
|
||||||
const result: GetNoteFileResult = {
|
|
||||||
success: true,
|
|
||||||
attachment: {
|
|
||||||
type: "local_file",
|
|
||||||
fileName: normalizedFileName,
|
|
||||||
// filePath: noteFilePath,
|
|
||||||
relativePath,
|
|
||||||
mimeType: "text/markdown",
|
|
||||||
sizeBytes: fileStat.size,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug("done", {fileName: result.attachment.fileName, relativePath: result.attachment.relativePath, sizeBytes: result.attachment.sizeBytes});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types";
|
||||||
import {runCommand} from "../../util/utils";
|
import {runCommand} from "../../util/utils.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
|
import {AiJsonObject} from "../tool-types";
|
||||||
|
|
||||||
export const shellExecuteTool = {
|
export const shellExecuteTool = {
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -32,7 +33,7 @@ export const shellExecuteToolPrompt = [
|
|||||||
"- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.",
|
"- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.",
|
||||||
"- Do not assume Bash/Linux commands are available.",
|
"- Do not assume Bash/Linux commands are available.",
|
||||||
"- Do not assume Windows commands are available.",
|
"- Do not assume Windows commands are available.",
|
||||||
"- If the current OS/shell is unknown, first run a safe environment inspection command.",
|
"- If the current OS/shell is unclear, first run a safe environment inspection command.",
|
||||||
"- Safe OS inspection examples:",
|
"- Safe OS inspection examples:",
|
||||||
" - Node.js: `node -p \"process.platform\"`",
|
" - Node.js: `node -p \"process.platform\"`",
|
||||||
" - Node.js: `node -p \"process.cwd()\"`",
|
" - Node.js: `node -p \"process.cwd()\"`",
|
||||||
@@ -99,7 +100,7 @@ export const shellExecuteToolPrompt = [
|
|||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
export async function shellExecute(args?: Record<string, unknown>): Promise<string | undefined | null> {
|
export async function shellExecute(args?: AiJsonObject): Promise<string | undefined | null> {
|
||||||
const cmd = asNonEmptyString(args?.cmd);
|
const cmd = asNonEmptyString(args?.cmd);
|
||||||
if (!cmd) return undefined;
|
if (!cmd) return undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import {appLogger} from "../../logging/logger";
|
import {appLogger} from "../../logging/logger.js";
|
||||||
|
|
||||||
export const toolsLogger = appLogger.child("ai-tools");
|
export const toolsLogger = appLogger.child("ai-tools");
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export type ToolHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||||
|
import type {ToolRuntimeContext} from "./runtime.js";
|
||||||
|
|
||||||
|
export type ToolHandler = (args?: AiJsonObject, context?: ToolRuntimeContext) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import {readFile, rename, writeFile, mkdir, rm} from "node:fs/promises";
|
||||||
|
import {AiProvider} from "../../model/ai-provider.js";
|
||||||
|
import {Environment} from "../../common/environment.js";
|
||||||
|
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveOptionalAiRuntimeTarget, type AiRuntimeTarget} from "../ai-runtime-target.js";
|
||||||
|
import {AiTool} from "../tool-types.js";
|
||||||
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
|
const logger = toolsLogger.child("user-memory");
|
||||||
|
|
||||||
|
function memoryDir(): string {
|
||||||
|
return path.join(Environment.DATA_PATH, "memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_MEMORY_MAX_CHARS = 1000;
|
||||||
|
|
||||||
|
export type MemoryScope = "user" | "system";
|
||||||
|
export type MemoryAction = "add" | "replace" | "remove";
|
||||||
|
|
||||||
|
export type MemoryRuntimeContext = {
|
||||||
|
provider?: AiProvider;
|
||||||
|
runtimeTarget?: AiRuntimeTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryOperationResult =
|
||||||
|
| {success: true; scope: MemoryScope; filePath: string; content: string; chars: number; compressed: boolean}
|
||||||
|
| {success: false; scope: MemoryScope; error: string};
|
||||||
|
|
||||||
|
type CompressionRunResult = {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryCompressionRunner = (params: {
|
||||||
|
target: AiRuntimeTarget;
|
||||||
|
scope: MemoryScope;
|
||||||
|
currentText: string;
|
||||||
|
limit: number;
|
||||||
|
}) => Promise<string>;
|
||||||
|
|
||||||
|
function extractMistralText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map(part => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (part && typeof part === "object" && "text" in part && typeof (part as {text?: unknown}).text === "string") {
|
||||||
|
return (part as {text: string}).text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemoryToolName =
|
||||||
|
| "read_user_info"
|
||||||
|
| "read_system_info"
|
||||||
|
| "add_user_info"
|
||||||
|
| "add_system_info"
|
||||||
|
| "remove_user_info"
|
||||||
|
| "remove_system_info"
|
||||||
|
| "replace_user_info"
|
||||||
|
| "replace_system_info"
|
||||||
|
| "delete_user_info"
|
||||||
|
| "delete_system_info";
|
||||||
|
|
||||||
|
export const MEMORY_TOOL_NAMES: MemoryToolName[] = [
|
||||||
|
"read_user_info",
|
||||||
|
"read_system_info",
|
||||||
|
"add_user_info",
|
||||||
|
"add_system_info",
|
||||||
|
"remove_user_info",
|
||||||
|
"remove_system_info",
|
||||||
|
"replace_user_info",
|
||||||
|
"replace_system_info",
|
||||||
|
"delete_user_info",
|
||||||
|
"delete_system_info",
|
||||||
|
];
|
||||||
|
|
||||||
|
type MemoryToolSpec = {
|
||||||
|
name: MemoryToolName;
|
||||||
|
scope: MemoryScope;
|
||||||
|
kind: "read" | "write" | "delete";
|
||||||
|
action?: MemoryAction;
|
||||||
|
description: string;
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEMORY_TOOL_SPECS: MemoryToolSpec[] = [
|
||||||
|
{
|
||||||
|
name: "read_user_info",
|
||||||
|
scope: "user",
|
||||||
|
kind: "read",
|
||||||
|
description: "Read persistent user memory from user.md.",
|
||||||
|
prompt: `Use when you need to inspect remembered user facts before editing or answering.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read_system_info",
|
||||||
|
scope: "system",
|
||||||
|
kind: "read",
|
||||||
|
description: "Read persistent assistant memory from system.md.",
|
||||||
|
prompt: `Use when you need to inspect remembered assistant instructions before editing or answering.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_user_info",
|
||||||
|
scope: "user",
|
||||||
|
kind: "write",
|
||||||
|
action: "add",
|
||||||
|
description: "Append a durable fact about the user to user.md.",
|
||||||
|
prompt: `Use for new user facts, preferences, identity details, and profile information. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_system_info",
|
||||||
|
scope: "system",
|
||||||
|
kind: "write",
|
||||||
|
action: "add",
|
||||||
|
description: "Append a durable instruction about the assistant to system.md.",
|
||||||
|
prompt: `Use for new assistant identity, style, or behavior instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove_user_info",
|
||||||
|
scope: "user",
|
||||||
|
kind: "write",
|
||||||
|
action: "remove",
|
||||||
|
description: "Remove a specific user fact or fragment from user.md.",
|
||||||
|
prompt: `Use when the user asks to forget something about themselves. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove_system_info",
|
||||||
|
scope: "system",
|
||||||
|
kind: "write",
|
||||||
|
action: "remove",
|
||||||
|
description: "Remove a specific assistant instruction or fragment from system.md.",
|
||||||
|
prompt: `Use when the user asks to forget something about the assistant. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace_user_info",
|
||||||
|
scope: "user",
|
||||||
|
kind: "write",
|
||||||
|
action: "replace",
|
||||||
|
description: "Replace user.md completely with a new compact version.",
|
||||||
|
prompt: `Use when the user wants to overwrite all remembered user info, such as "forget everything about me and remember only this". Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace_system_info",
|
||||||
|
scope: "system",
|
||||||
|
kind: "write",
|
||||||
|
action: "replace",
|
||||||
|
description: "Replace system.md completely with a new compact version.",
|
||||||
|
prompt: `Use when the user wants to overwrite all remembered assistant info or instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_user_info",
|
||||||
|
scope: "user",
|
||||||
|
kind: "delete",
|
||||||
|
description: "Delete the user memory file user.md.",
|
||||||
|
prompt: `Use when the user asks to delete all remembered user info and remove the memory file entirely.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_system_info",
|
||||||
|
scope: "system",
|
||||||
|
kind: "delete",
|
||||||
|
description: "Delete the assistant memory file system.md.",
|
||||||
|
prompt: `Use when the user asks to delete all remembered assistant info and remove the memory file entirely.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const memoryToolPrompt = [
|
||||||
|
"Use the memory tools to manage persistent per-user memory.",
|
||||||
|
"- `read_*` shows the current file content before editing.",
|
||||||
|
"- `user.md` stores durable facts about the user.",
|
||||||
|
"- `system.md` stores durable facts/instructions about the assistant itself.",
|
||||||
|
"- `add_*` appends a new fact or instruction.",
|
||||||
|
"- `remove_*` removes a specific fact or fragment.",
|
||||||
|
"- `replace_*` rewrites the whole file when the user wants to overwrite memory.",
|
||||||
|
"- `delete_*` removes the file entirely.",
|
||||||
|
`- Keep each file at or below ${USER_MEMORY_MAX_CHARS} characters.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function createMemoryTool(spec: MemoryToolSpec): AiTool {
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: spec.name,
|
||||||
|
description: spec.description,
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: spec.kind === "read" || spec.kind === "delete" ? {} : {
|
||||||
|
content: {
|
||||||
|
type: "string",
|
||||||
|
description: spec.action === "remove"
|
||||||
|
? "Exact text or fragment to remove from memory."
|
||||||
|
: "Text to append or replace in memory.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: spec.kind === "read" || spec.kind === "delete" ? [] : ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies AiTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memoryTools = MEMORY_TOOL_SPECS.map(createMemoryTool);
|
||||||
|
|
||||||
|
function normalizeUserId(userId: number): number | null {
|
||||||
|
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemoryText(value: string): string {
|
||||||
|
return value.replaceAll("\r\n", "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemoryUserDir(userId: number): string {
|
||||||
|
return path.join(memoryDir(), String(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemoryFilePath(userId: number, scope: MemoryScope): string {
|
||||||
|
return path.join(getMemoryUserDir(userId), `${scope}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMemoryDir(userId: number): Promise<string> {
|
||||||
|
const dir = getMemoryUserDir(userId);
|
||||||
|
await mkdir(dir, {recursive: true});
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMemoryFile(userId: number, scope: MemoryScope): Promise<string> {
|
||||||
|
const filePath = getMemoryFilePath(userId, scope);
|
||||||
|
try {
|
||||||
|
return normalizeMemoryText(await readFile(filePath, "utf-8"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMemoryFile(userId: number, scope: MemoryScope, content: string): Promise<string> {
|
||||||
|
const normalized = normalizeMemoryText(content);
|
||||||
|
const filePath = getMemoryFilePath(userId, scope);
|
||||||
|
await ensureMemoryDir(userId);
|
||||||
|
|
||||||
|
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||||
|
await writeFile(tempPath, normalized, "utf-8");
|
||||||
|
await rename(tempPath, filePath);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimToLimit(content: string, limit = USER_MEMORY_MAX_CHARS): string {
|
||||||
|
if (content.length <= limit) return content;
|
||||||
|
return content.slice(0, limit).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCodeFences(content: string): string {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
const fenced = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i);
|
||||||
|
if (fenced?.[1]) return fenced[1].trim();
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTarget(left: AiRuntimeTarget | undefined, right: AiRuntimeTarget | undefined): boolean {
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return left.provider === right.provider
|
||||||
|
&& left.model === right.model
|
||||||
|
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
|
||||||
|
&& (left.apiKey ?? "") === (right.apiKey ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressWithTarget(params: {
|
||||||
|
target: AiRuntimeTarget;
|
||||||
|
scope: MemoryScope;
|
||||||
|
currentText: string;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<CompressionRunResult> {
|
||||||
|
const {target, scope, currentText, limit} = params;
|
||||||
|
|
||||||
|
const systemPrompt = [
|
||||||
|
"You compress persistent memory for a chat bot.",
|
||||||
|
"Return only the rewritten Markdown text.",
|
||||||
|
"Preserve important facts, preferences, identities, instructions, and durable context.",
|
||||||
|
"Remove noise, duplication, stale details, and low-value filler.",
|
||||||
|
`Keep the result at or below ${limit} characters.`,
|
||||||
|
"Do not add explanations, bullet labels, or code fences.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const userPrompt = [
|
||||||
|
`Memory scope: ${scope}`,
|
||||||
|
`Character limit: ${limit}`,
|
||||||
|
"Current memory:",
|
||||||
|
currentText.trim() || "(empty)",
|
||||||
|
"",
|
||||||
|
"Rewrite it as compact Markdown only.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
logger.info("compress.start", {provider: target.provider, model: target.model, scope, chars: currentText.length});
|
||||||
|
|
||||||
|
switch (target.provider) {
|
||||||
|
case AiProvider.OPENAI: {
|
||||||
|
const client = createOpenAiClient(target);
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: target.model,
|
||||||
|
temperature: 0,
|
||||||
|
messages: [
|
||||||
|
{role: "system", content: systemPrompt},
|
||||||
|
{role: "user", content: userPrompt},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const text = response.choices[0]?.message?.content ?? "";
|
||||||
|
return {content: stripCodeFences(text)};
|
||||||
|
}
|
||||||
|
case AiProvider.MISTRAL: {
|
||||||
|
const client = createMistralClient(target);
|
||||||
|
const response = await client.chat.complete({
|
||||||
|
model: target.model,
|
||||||
|
temperature: 0,
|
||||||
|
messages: [
|
||||||
|
{role: "system", content: systemPrompt},
|
||||||
|
{role: "user", content: userPrompt},
|
||||||
|
],
|
||||||
|
} as Parameters<typeof client.chat.complete>[0]);
|
||||||
|
const text = extractMistralText(response.choices?.[0]?.message?.content);
|
||||||
|
return {content: stripCodeFences(text)};
|
||||||
|
}
|
||||||
|
case AiProvider.OLLAMA: {
|
||||||
|
const client = createOllamaClient(target);
|
||||||
|
const response = await client.chat({
|
||||||
|
model: target.model,
|
||||||
|
stream: false,
|
||||||
|
options: {temperature: 0},
|
||||||
|
messages: [
|
||||||
|
{role: "system", content: systemPrompt},
|
||||||
|
{role: "user", content: userPrompt},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const text = typeof response.message?.content === "string" ? response.message.content : "";
|
||||||
|
return {content: stripCodeFences(text)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressMemoryWithFallback(params: {
|
||||||
|
provider?: AiProvider;
|
||||||
|
currentTarget?: AiRuntimeTarget;
|
||||||
|
scope: MemoryScope;
|
||||||
|
currentText: string;
|
||||||
|
limit?: number;
|
||||||
|
}, runner: MemoryCompressionRunner = async (input) => (await compressWithTarget(input)).content): Promise<{content: string; compressed: boolean; usedTarget?: AiRuntimeTarget}> {
|
||||||
|
const limit = params.limit ?? USER_MEMORY_MAX_CHARS;
|
||||||
|
const trimmed = normalizeMemoryText(params.currentText);
|
||||||
|
if (trimmed.length <= limit) {
|
||||||
|
return {content: trimmed, compressed: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitTarget = params.provider ? resolveOptionalAiRuntimeTarget(params.provider, "memoryCompress") : undefined;
|
||||||
|
const targets = [explicitTarget, params.currentTarget].filter((target, index, list): target is AiRuntimeTarget => !!target && list.findIndex(item => sameTarget(item, target)) === index);
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
try {
|
||||||
|
const content = trimToLimit(await runner({target, scope: params.scope, currentText: trimmed, limit}), limit);
|
||||||
|
if (content.length <= limit) {
|
||||||
|
return {content, compressed: true, usedTarget: target};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("compress.failed", {
|
||||||
|
provider: params.provider,
|
||||||
|
scope: params.scope,
|
||||||
|
target: target.model,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {content: trimToLimit(trimmed, limit), compressed: true};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressMemoryIfNeeded(params: {
|
||||||
|
userId: number;
|
||||||
|
scope: MemoryScope;
|
||||||
|
content: string;
|
||||||
|
context?: MemoryRuntimeContext;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{content: string; compressed: boolean}> {
|
||||||
|
const {scope, context, limit = USER_MEMORY_MAX_CHARS} = params;
|
||||||
|
const result = await compressMemoryWithFallback({
|
||||||
|
provider: context?.provider,
|
||||||
|
currentTarget: context?.runtimeTarget,
|
||||||
|
scope,
|
||||||
|
currentText: params.content,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.compressed) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.content.length > limit) {
|
||||||
|
return {content: trimToLimit(result.content, limit), compressed: true};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {content: result.content, compressed: true};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeMemoryWrite(params: {
|
||||||
|
userId: number;
|
||||||
|
scope: MemoryScope;
|
||||||
|
content: string;
|
||||||
|
context?: MemoryRuntimeContext;
|
||||||
|
}): Promise<{filePath: string; content: string; compressed: boolean}> {
|
||||||
|
const {userId, scope, context} = params;
|
||||||
|
const compressed = await compressMemoryIfNeeded({userId, scope, content: params.content, context});
|
||||||
|
const filePath = await writeMemoryFile(userId, scope, compressed.content);
|
||||||
|
return {filePath, content: compressed.content, compressed: compressed.compressed};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMemoryToolSpec(toolName: string): MemoryToolSpec | undefined {
|
||||||
|
return MEMORY_TOOL_SPECS.find(spec => spec.name === toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemoryWriteTool(spec: MemoryToolSpec): spec is MemoryToolSpec & {kind: "write"; action: MemoryAction} {
|
||||||
|
return spec.kind === "write";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildUserMemoryPrompt(userId: number | undefined | null): Promise<string | undefined> {
|
||||||
|
const normalizedUserId = typeof userId === "number" ? normalizeUserId(userId) : null;
|
||||||
|
if (!normalizedUserId) return undefined;
|
||||||
|
|
||||||
|
const [userMemoryResult, systemMemoryResult] = await Promise.all([
|
||||||
|
readUserMemory(normalizedUserId, "user"),
|
||||||
|
readUserMemory(normalizedUserId, "system"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userMemory = userMemoryResult.success ? userMemoryResult.content : "";
|
||||||
|
const systemMemory = systemMemoryResult.success ? systemMemoryResult.content : "";
|
||||||
|
|
||||||
|
const blocks: string[] = [];
|
||||||
|
if (systemMemory.trim()) {
|
||||||
|
blocks.push([
|
||||||
|
"## Assistant memory (system.md)",
|
||||||
|
"This is information about the assistant and its behavior.",
|
||||||
|
systemMemory.trim(),
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
if (userMemory.trim()) {
|
||||||
|
blocks.push([
|
||||||
|
"## User memory (user.md)",
|
||||||
|
"This is information about the user.",
|
||||||
|
userMemory.trim(),
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.length ? blocks.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
if (!normalizedUserId) {
|
||||||
|
return {success: false, scope, error: "Invalid userId"};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await readMemoryFile(normalizedUserId, scope);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
scope,
|
||||||
|
filePath: getMemoryFilePath(normalizedUserId, scope),
|
||||||
|
content,
|
||||||
|
chars: content.length,
|
||||||
|
compressed: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserMemory(args: {
|
||||||
|
userId: number;
|
||||||
|
scope: MemoryScope;
|
||||||
|
action: MemoryAction;
|
||||||
|
content?: string;
|
||||||
|
context?: MemoryRuntimeContext;
|
||||||
|
}): Promise<MemoryOperationResult> {
|
||||||
|
const normalizedUserId = normalizeUserId(args.userId);
|
||||||
|
if (!normalizedUserId) {
|
||||||
|
return {success: false, scope: args.scope, error: "Invalid userId"};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = await readMemoryFile(normalizedUserId, args.scope);
|
||||||
|
let next = current;
|
||||||
|
|
||||||
|
switch (args.action) {
|
||||||
|
case "add": {
|
||||||
|
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
|
||||||
|
if (!content.trim()) {
|
||||||
|
return {success: false, scope: args.scope, error: "No content provided"};
|
||||||
|
}
|
||||||
|
next = [current.trimEnd(), content.trim()].filter(Boolean).join(current.trim() ? "\n\n" : "");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "replace": {
|
||||||
|
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
|
||||||
|
next = content;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
const needle = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
|
||||||
|
if (!needle.trim()) {
|
||||||
|
return {success: false, scope: args.scope, error: "No text to remove provided"};
|
||||||
|
}
|
||||||
|
if (!current.includes(needle)) {
|
||||||
|
return {success: false, scope: args.scope, error: "Text not found in memory"};
|
||||||
|
}
|
||||||
|
next = current.split(needle).join("").trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalized = await finalizeMemoryWrite({userId: normalizedUserId, scope: args.scope, content: next, context: args.context});
|
||||||
|
logger.debug("write.done", {
|
||||||
|
userId: normalizedUserId,
|
||||||
|
scope: args.scope,
|
||||||
|
chars: finalized.content.length,
|
||||||
|
compressed: finalized.compressed,
|
||||||
|
filePath: finalized.filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
scope: args.scope,
|
||||||
|
filePath: finalized.filePath,
|
||||||
|
content: finalized.content,
|
||||||
|
chars: finalized.content.length,
|
||||||
|
compressed: finalized.compressed,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {success: false, scope: args.scope, error: error instanceof Error ? error.message : String(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeMemoryTool(toolName: MemoryToolName, args: {userId: number; content?: string}, context?: MemoryRuntimeContext): Promise<MemoryOperationResult> {
|
||||||
|
const spec = findMemoryToolSpec(toolName);
|
||||||
|
if (!spec) {
|
||||||
|
return {success: false, scope: "user", error: `Unknown memory tool: ${toolName}`};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.kind === "read") {
|
||||||
|
return readUserMemory(args.userId, spec.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.kind === "delete") {
|
||||||
|
return deleteUserMemory(args.userId, spec.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMemoryWriteTool(spec)) {
|
||||||
|
return {success: false, scope: spec.scope, error: `Unsupported memory tool: ${toolName}`};
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateUserMemory({
|
||||||
|
userId: args.userId,
|
||||||
|
scope: spec.scope,
|
||||||
|
action: spec.action,
|
||||||
|
content: args.content,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
|
||||||
|
const normalizedUserId = normalizeUserId(userId);
|
||||||
|
if (!normalizedUserId) {
|
||||||
|
return {success: false, scope, error: "Invalid userId"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = getMemoryFilePath(normalizedUserId, scope);
|
||||||
|
try {
|
||||||
|
await rm(filePath, {force: true});
|
||||||
|
return {success: true, scope, filePath, content: "", chars: 0, compressed: false};
|
||||||
|
} catch (error) {
|
||||||
|
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-12
@@ -1,23 +1,25 @@
|
|||||||
import {Ollama} from "ollama";
|
import {Ollama} from "ollama";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||||
|
import type {BoundaryValue} from "../../common/boundary-types";
|
||||||
|
|
||||||
const logger = toolsLogger.child("utils");
|
const logger = toolsLogger.child("utils");
|
||||||
|
|
||||||
export function asNonEmptyString(value: unknown): string | undefined {
|
export function asNonEmptyString(value: BoundaryValue): string | undefined {
|
||||||
return typeof value === "string" && value.trim().length > 0
|
return typeof value === "string" && value.trim().length > 0
|
||||||
? value.trim()
|
? value.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeToolArguments(args: unknown): Record<string, unknown> {
|
export function normalizeToolArguments(args: string | AiJsonObject | undefined, userId?: number | null): AiJsonObject {
|
||||||
if (!args) return {};
|
if (!args) return {};
|
||||||
|
|
||||||
if (typeof args === "string") {
|
if (typeof args === "string") {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(args);
|
const parsed = JSON.parse(args) as AiJsonValue;
|
||||||
|
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
return parsed as Record<string, unknown>;
|
return parsed as AiJsonObject;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
@@ -29,13 +31,17 @@ export function normalizeToolArguments(args: unknown): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof args === "object" && !Array.isArray(args)) {
|
if (typeof args === "object" && !Array.isArray(args)) {
|
||||||
return args as Record<string, unknown>;
|
const userIdObject = userId ? {"userId": userId} : {};
|
||||||
|
return {
|
||||||
|
...args,
|
||||||
|
...userIdObject,
|
||||||
|
} as AiJsonObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asBoolean(value: unknown, defaultValue = false): boolean {
|
export function asBoolean(value: BoundaryValue, defaultValue = false): boolean {
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -48,11 +54,11 @@ export function asBoolean(value: unknown, defaultValue = false): boolean {
|
|||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asString(value: unknown, defaultValue = ""): string {
|
export function asString(value: BoundaryValue, defaultValue = ""): string {
|
||||||
return typeof value === "string" ? value : defaultValue;
|
return typeof value === "string" ? value : defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asPositiveInt(value: unknown, defaultValue: number, maxValue: number): number {
|
export function asPositiveInt(value: BoundaryValue, defaultValue: number, maxValue: number): number {
|
||||||
const n = typeof value === "number"
|
const n = typeof value === "number"
|
||||||
? value
|
? value
|
||||||
: typeof value === "string"
|
: typeof value === "string"
|
||||||
@@ -83,7 +89,7 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
|
|||||||
await Promise.all(unloadPromises);
|
await Promise.all(unloadPromises);
|
||||||
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
|
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("ollama.unload_all.failed", {exceptFor, error});
|
logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +106,8 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
|
|||||||
});
|
});
|
||||||
logger.info("ollama.load.done", {model, contextLength});
|
logger.info("ollama.load.done", {model, contextLength});
|
||||||
return true;
|
return true;
|
||||||
} catch (e: unknown) {
|
} catch (error) {
|
||||||
logger.error("ollama.load.failed", {model, contextLength, error: e});
|
logger.error("ollama.load.failed", {model, contextLength, error: error instanceof Error ? error : String(error)});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("weather");
|
const logger = toolsLogger.child("weather");
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {logError} from "../../util/utils";
|
import {logError} from "../../util/utils.js";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiJsonObject, AiTool} from "../tool-types.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
export const getWeatherTool = {
|
export const getWeatherTool = {
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -45,7 +45,7 @@ export const weatherToolPrompt = [
|
|||||||
"If the city is missing or unclear, ask the user to specify it.",
|
"If the city is missing or unclear, ask the user to specify it.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | null> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.info("start", {args});
|
logger.info("start", {args});
|
||||||
try {
|
try {
|
||||||
@@ -141,9 +141,9 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
|
|||||||
windSpeed: wind.speed,
|
windSpeed: wind.speed,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (e: unknown) {
|
} catch (error) {
|
||||||
logger.error("failed", {duration: logger.duration(startedAt), error: e});
|
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||||
logError(e);
|
logError(error instanceof Error ? error : String(error));
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
logger.debug("done", {duration: logger.duration(startedAt)});
|
logger.debug("done", {duration: logger.duration(startedAt)});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("brave-search");
|
const logger = toolsLogger.child("brave-search");
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {logError} from "../../util/utils";
|
import {logError} from "../../util/utils.js";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||||
import {asBoolean, asNonEmptyString} from "./utils";
|
import {asBoolean, asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
type BraveSearchProfile = {
|
type BraveSearchProfile = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -83,16 +83,16 @@ type BraveSearchApiResponse = {
|
|||||||
results?: BraveSearchResult[];
|
results?: BraveSearchResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
faq?: unknown;
|
faq?: AiJsonValue;
|
||||||
infobox?: unknown;
|
infobox?: AiJsonValue;
|
||||||
locations?: unknown;
|
locations?: AiJsonValue;
|
||||||
mixed?: unknown;
|
mixed?: AiJsonValue;
|
||||||
summarizer?: unknown;
|
summarizer?: AiJsonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WEB_SEARCH_TOOL_NAME = "web_search";
|
export const WEB_SEARCH_TOOL_NAME = "web_search";
|
||||||
|
|
||||||
export const braveSearchTool = {
|
export const webSearchTool = {
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: WEB_SEARCH_TOOL_NAME,
|
name: WEB_SEARCH_TOOL_NAME,
|
||||||
@@ -163,7 +163,7 @@ export const braveSearchTool = {
|
|||||||
},
|
},
|
||||||
} satisfies AiTool;
|
} satisfies AiTool;
|
||||||
|
|
||||||
export const braveSearchToolPrompt = [
|
export const webSearchToolPrompt = [
|
||||||
"Brave Search tool rules:",
|
"Brave Search tool rules:",
|
||||||
"- You have access to `web_search`.",
|
"- You have access to `web_search`.",
|
||||||
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
|
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
|
||||||
@@ -197,7 +197,7 @@ export const braveSearchToolPrompt = [
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
function asIntegerInRange(
|
function asIntegerInRange(
|
||||||
value: unknown,
|
value: AiJsonValue | undefined,
|
||||||
fallback: number,
|
fallback: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
@@ -216,7 +216,7 @@ function asIntegerInRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asEnum<T extends string>(
|
function asEnum<T extends string>(
|
||||||
value: unknown,
|
value: AiJsonValue | undefined,
|
||||||
allowed: readonly T[],
|
allowed: readonly T[],
|
||||||
fallback: T,
|
fallback: T,
|
||||||
): T {
|
): T {
|
||||||
@@ -229,7 +229,7 @@ function asEnum<T extends string>(
|
|||||||
: fallback;
|
: fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanSearchText(value: unknown): string | null {
|
function cleanSearchText(value: AiJsonValue | undefined): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
|
|
||||||
return value
|
return value
|
||||||
@@ -243,7 +243,7 @@ function cleanSearchText(value: unknown): string | null {
|
|||||||
.trim() || null;
|
.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBraveResultFilter(value: unknown): string {
|
function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
"discussions",
|
"discussions",
|
||||||
"faq",
|
"faq",
|
||||||
@@ -268,7 +268,7 @@ function normalizeBraveResultFilter(value: unknown): string {
|
|||||||
return parts.length ? [...new Set(parts)].join(",") : "web";
|
return parts.length ? [...new Set(parts)].join(",") : "web";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function webSearch(args?: Record<string, unknown>) {
|
export async function webSearch(args?: AiJsonObject) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logger.info("start", {args});
|
logger.info("start", {args});
|
||||||
|
|
||||||
@@ -362,17 +362,16 @@ 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.",
|
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
|
||||||
};
|
};
|
||||||
} catch (e: unknown) {
|
} catch (error) {
|
||||||
logError(e);
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
|
||||||
const axiosLike = e as {response?: {status?: unknown; data?: unknown}};
|
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
||||||
const status = axiosLike.response?.status;
|
const data = axios.isAxiosError(error) ? error.response?.data : undefined;
|
||||||
const data = axiosLike.response?.data;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: typeof status === "number" ? status : null,
|
status: typeof status === "number" ? status : null,
|
||||||
error: e instanceof Error ? e.message : String(e),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
response: data ?? null,
|
response: data ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import type {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
import type {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
||||||
|
|
||||||
|
export async function persistTranscriptArtifactAttachment(params: {
|
||||||
|
provider: AiProvider;
|
||||||
|
transcript: string;
|
||||||
|
downloads: AiDownloadedFile[];
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
}): Promise<StoredAttachment | undefined> {
|
||||||
|
const text = params.transcript.trim();
|
||||||
|
if (!text) return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
const sources = params.downloads
|
||||||
|
.filter(download => download.kind === "audio" || download.kind === "video-note")
|
||||||
|
.map(download => ({
|
||||||
|
fileId: download.fileId,
|
||||||
|
fileName: download.fileName,
|
||||||
|
mimeType: download.mimeType,
|
||||||
|
sizeBytes: download.sizeBytes ?? download.buffer.length,
|
||||||
|
sha256: download.sha256,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return await persistInternalJsonArtifactAttachment({
|
||||||
|
artifactKind: "transcript",
|
||||||
|
fileNamePrefix: "transcript",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
payload: {
|
||||||
|
provider: params.provider,
|
||||||
|
transcript: text,
|
||||||
|
sources,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
provider: params.provider,
|
||||||
|
sourceFileNames: sources.map(source => source.fileName),
|
||||||
|
transcriptChars: text.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||||
|
import {Environment} from "../common/environment";
|
||||||
|
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
|
||||||
|
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
|
||||||
|
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
|
||||||
|
import {mergeReplyChainDownloads, shouldPreferCurrentDownloads} from "./reply-chain-downloads";
|
||||||
|
import {attachmentsToDownloadedFiles, type AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import type {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
|
import type {ChatMessage} from "./chat-messages-types";
|
||||||
|
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
|
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
|
import type {PreparedDocumentRag} from "./document-rag-pipeline";
|
||||||
|
import {prepareDocumentRag} from "./document-rag-pipeline";
|
||||||
|
import {persistRagArtifactAttachment} from "./rag-artifact-store";
|
||||||
|
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
|
||||||
|
import type {ToolRuntimeContext} from "./tools/runtime";
|
||||||
|
import {recordPipelineFallback, recordRagRun} from "../common/ai-observability.js";
|
||||||
|
import {
|
||||||
|
appendTranscriptToChatMessages,
|
||||||
|
collectTextMessages,
|
||||||
|
initialStatus,
|
||||||
|
providerName,
|
||||||
|
RuntimeConfigSnapshot,
|
||||||
|
stripAudioFromRunnerMessages,
|
||||||
|
toolRuntimeContextFromDownloads,
|
||||||
|
transcribeAudioIfNeeded,
|
||||||
|
collectStoredReplyChainAttachments,
|
||||||
|
UnifiedRunOptions,
|
||||||
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {aiLog} from "../logging/ai-logger";
|
||||||
|
import {isTranscribableAudioDownload} from "./speech-to-text";
|
||||||
|
|
||||||
|
export type PreparedUnifiedAiRequest = {
|
||||||
|
chatMessages: Array<OpenAIChatMessage | MistralChatMessage | ChatMessage>;
|
||||||
|
imageCount: number;
|
||||||
|
firstRoundStatus: string;
|
||||||
|
toolContext: ToolRuntimeContext;
|
||||||
|
preparedDocumentRag?: PreparedDocumentRag;
|
||||||
|
finishAfterTranscript: boolean;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MutablePreparedContext = {
|
||||||
|
chatMessages: Array<OpenAIChatMessage | MistralChatMessage | ChatMessage>;
|
||||||
|
imageCount: number;
|
||||||
|
firstRoundStatus: string;
|
||||||
|
toolContext: ToolRuntimeContext;
|
||||||
|
transcript: string;
|
||||||
|
preparedDocumentRag?: PreparedDocumentRag;
|
||||||
|
finishAfterTranscript: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnapshot) {
|
||||||
|
return options.provider === AiProvider.OLLAMA
|
||||||
|
? config.ollamaChatTarget
|
||||||
|
: options.provider === AiProvider.MISTRAL
|
||||||
|
? config.mistralChatTarget
|
||||||
|
: config.openAiChatTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
|
||||||
|
return {
|
||||||
|
requestId: options.requestId ?? `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
replyToMessageId: options.msg.reply_to_message?.message_id,
|
||||||
|
fromId: options.msg.from?.id ?? 0,
|
||||||
|
receivedAt: nowIso(),
|
||||||
|
text: options.text,
|
||||||
|
settings: {
|
||||||
|
provider: options.provider,
|
||||||
|
responseLanguage: options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
|
contextSize: options.contextSize,
|
||||||
|
voiceMode: options.voiceMode ?? "execute",
|
||||||
|
imageOutputMode: "photo",
|
||||||
|
},
|
||||||
|
inputAttachments: [],
|
||||||
|
outputAttachments: [],
|
||||||
|
artifacts: [],
|
||||||
|
toolRankDecisions: [],
|
||||||
|
audit: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareUnifiedAiRequestPipeline(params: {
|
||||||
|
options: UnifiedRunOptions;
|
||||||
|
config: RuntimeConfigSnapshot;
|
||||||
|
downloads: AiDownloadedFile[];
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
controller: AbortController;
|
||||||
|
}): Promise<PreparedUnifiedAiRequest> {
|
||||||
|
const {options, config, downloads, streamMessage, controller} = params;
|
||||||
|
const replyChainDownloads = shouldPreferCurrentDownloads(options.text, downloads)
|
||||||
|
? downloads
|
||||||
|
: mergeReplyChainDownloads(
|
||||||
|
downloads,
|
||||||
|
attachmentsToDownloadedFiles(await collectStoredReplyChainAttachments(options.msg)),
|
||||||
|
);
|
||||||
|
const prepared: MutablePreparedContext = {
|
||||||
|
chatMessages: [],
|
||||||
|
imageCount: 0,
|
||||||
|
firstRoundStatus: Environment.waitThinkText,
|
||||||
|
toolContext: {},
|
||||||
|
transcript: "",
|
||||||
|
finishAfterTranscript: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stages: UserRequestPipelineStage[] = [
|
||||||
|
{
|
||||||
|
name: "audit_start",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "audit_start",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
phase: "ai_request_prepare",
|
||||||
|
provider: options.provider,
|
||||||
|
downloads: replyChainDownloads.map(download => ({
|
||||||
|
kind: download.kind,
|
||||||
|
fileName: download.fileName,
|
||||||
|
mimeType: download.mimeType,
|
||||||
|
sizeBytes: download.sizeBytes ?? download.buffer.length,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collect_conversation_context",
|
||||||
|
async run() {
|
||||||
|
const collected = await collectTextMessages(
|
||||||
|
options.msg,
|
||||||
|
options.text,
|
||||||
|
options.provider,
|
||||||
|
replyChainDownloads,
|
||||||
|
config,
|
||||||
|
runtimeTargetFor(options, config),
|
||||||
|
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
|
);
|
||||||
|
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
|
||||||
|
prepared.imageCount = collected.imageCount;
|
||||||
|
prepared.firstRoundStatus = initialStatus(replyChainDownloads, prepared.imageCount);
|
||||||
|
prepared.toolContext = toolRuntimeContextFromDownloads(replyChainDownloads);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "collect_conversation_context",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prepare_text_context",
|
||||||
|
async run() {
|
||||||
|
streamMessage.setStatus(prepared.firstRoundStatus);
|
||||||
|
await streamMessage.flush();
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "prepare_text_context",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolve_runtime",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "resolve_runtime",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "speech_to_text",
|
||||||
|
async run() {
|
||||||
|
prepared.transcript = await transcribeAudioIfNeeded(
|
||||||
|
options.provider,
|
||||||
|
options.msg.from?.id,
|
||||||
|
replyChainDownloads,
|
||||||
|
streamMessage,
|
||||||
|
controller.signal,
|
||||||
|
).catch(error => {
|
||||||
|
if (replyChainDownloads.some(isTranscribableAudioDownload)) throw error;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcript = prepared.transcript.trim();
|
||||||
|
if (!transcript) {
|
||||||
|
return {
|
||||||
|
stage: "speech_to_text",
|
||||||
|
status: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptArtifact = await persistTranscriptArtifactAttachment({
|
||||||
|
provider: options.provider,
|
||||||
|
transcript,
|
||||||
|
downloads: replyChainDownloads,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
});
|
||||||
|
if (transcriptArtifact) {
|
||||||
|
await streamMessage.storeInternalAttachment(transcriptArtifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
|
||||||
|
prepared.finishAfterTranscript = true;
|
||||||
|
streamMessage.replaceText(`[Расшифровка]\n${transcript}`);
|
||||||
|
await streamMessage.finish();
|
||||||
|
return {
|
||||||
|
stage: "speech_to_text",
|
||||||
|
status: "succeeded",
|
||||||
|
fallbackAction: "continue_without_stage",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTranscriptToChatMessages(prepared.chatMessages, transcript);
|
||||||
|
stripAudioFromRunnerMessages(prepared.chatMessages);
|
||||||
|
aiLog("debug", "request.transcript.appended", {
|
||||||
|
provider: providerName(options.provider),
|
||||||
|
transcriptChars: transcript.length,
|
||||||
|
chatMessages: prepared.chatMessages.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "speech_to_text",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "document_rag",
|
||||||
|
async run() {
|
||||||
|
if (prepared.finishAfterTranscript) {
|
||||||
|
return {
|
||||||
|
stage: "document_rag",
|
||||||
|
status: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared.preparedDocumentRag = await prepareDocumentRag(
|
||||||
|
options.provider,
|
||||||
|
replyChainDownloads,
|
||||||
|
prepared.chatMessages,
|
||||||
|
streamMessage,
|
||||||
|
config,
|
||||||
|
controller.signal,
|
||||||
|
options.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ragArtifact = await persistRagArtifactAttachment({
|
||||||
|
provider: options.provider,
|
||||||
|
prepared: prepared.preparedDocumentRag,
|
||||||
|
downloads: replyChainDownloads,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
|
||||||
|
? {uploadedFileIds: prepared.preparedDocumentRag.uploadedFileIds}
|
||||||
|
: prepared.preparedDocumentRag?.provider === AiProvider.OLLAMA
|
||||||
|
? {
|
||||||
|
embeddingModel: config.ollamaDocumentsTarget.model,
|
||||||
|
topK: config.ollamaRagTopK,
|
||||||
|
chunkSize: config.ollamaRagChunkSize,
|
||||||
|
chunkOverlap: config.ollamaRagChunkOverlap,
|
||||||
|
maxContextChars: config.ollamaRagMaxContextChars,
|
||||||
|
artifact: prepared.preparedDocumentRag.artifact,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
if (ragArtifact) {
|
||||||
|
await streamMessage.storeInternalAttachment(ragArtifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepared.preparedDocumentRag) {
|
||||||
|
recordRagRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "document_rag",
|
||||||
|
status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audit_finish",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "audit_finish",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
phase: "ai_request_prepare",
|
||||||
|
chatMessages: prepared.chatMessages.length,
|
||||||
|
imageCount: prepared.imageCount,
|
||||||
|
hasTranscript: !!prepared.transcript.trim(),
|
||||||
|
hasDocumentRag: !!prepared.preparedDocumentRag,
|
||||||
|
finishAfterTranscript: prepared.finishAfterTranscript,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = createAiRequestPipelineState(options);
|
||||||
|
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
|
||||||
|
const pipeline = new UserRequestPipeline({
|
||||||
|
stages,
|
||||||
|
stageNames: [
|
||||||
|
"audit_start",
|
||||||
|
"collect_conversation_context",
|
||||||
|
"prepare_text_context",
|
||||||
|
"resolve_runtime",
|
||||||
|
"speech_to_text",
|
||||||
|
"document_rag",
|
||||||
|
"audit_finish",
|
||||||
|
],
|
||||||
|
onFallback: async decision => {
|
||||||
|
recordPipelineFallback(decision.action);
|
||||||
|
if (decision.action === "use_alternate_target") {
|
||||||
|
aiLog("warn", "request.fallback.use_alternate_target", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
...buildToolRankFallbackTargetDetails(options.provider, config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.action === "fail_request") {
|
||||||
|
aiLog("error", "request.fallback.fail_request", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = await fallbackNotifier.notify(state.requestId, decision);
|
||||||
|
state.audit.push({
|
||||||
|
stage: decision.stage,
|
||||||
|
status: "fallback",
|
||||||
|
startedAt: nowIso(),
|
||||||
|
finishedAt: nowIso(),
|
||||||
|
details: {
|
||||||
|
fallbackAction: decision.action,
|
||||||
|
fallbackNotification: notification.text,
|
||||||
|
fallbackNotified: notification.notified,
|
||||||
|
reason: decision.reason,
|
||||||
|
...(decision.action === "use_alternate_target"
|
||||||
|
? buildToolRankFallbackTargetDetails(options.provider, config)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline.run(state, controller.signal);
|
||||||
|
await streamMessage.storePipelineAudit(state.audit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chatMessages: prepared.chatMessages,
|
||||||
|
imageCount: prepared.imageCount,
|
||||||
|
firstRoundStatus: prepared.firstRoundStatus,
|
||||||
|
toolContext: prepared.toolContext,
|
||||||
|
preparedDocumentRag: prepared.preparedDocumentRag,
|
||||||
|
finishAfterTranscript: prepared.finishAfterTranscript,
|
||||||
|
cleanup: async () => {
|
||||||
|
await prepared.preparedDocumentRag?.cleanup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {Environment} from "../common/environment";
|
||||||
|
import {ifTrue, logError} from "../util/utils";
|
||||||
|
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import type {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import type {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
|
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
|
||||||
|
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
|
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
|
import type {ChatMessage} from "./chat-messages-types";
|
||||||
|
import {
|
||||||
|
allToolSchemaNames,
|
||||||
|
providerName,
|
||||||
|
RuntimeConfigSnapshot,
|
||||||
|
snapshotModel,
|
||||||
|
TELEGRAM_LIMIT,
|
||||||
|
UnifiedRunOptions,
|
||||||
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {runOpenAi} from "./unified-ai-runner.openai";
|
||||||
|
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
|
||||||
|
import {runOllama} from "./unified-ai-runner.ollama";
|
||||||
|
import {runMistral} from "./unified-ai-runner.mistral";
|
||||||
|
import {summarizeModelOutput} from "./response-model-output";
|
||||||
|
import {summarizeToolLoop} from "./tool-loop-summary";
|
||||||
|
import {persistToolLoopSummaryArtifactAttachment} from "./tool-loop-artifact-store";
|
||||||
|
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
|
||||||
|
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
|
||||||
|
import {
|
||||||
|
resolveTextToSpeechProviderForUser,
|
||||||
|
sendSynthesizedSpeech,
|
||||||
|
speechToOutputAttachmentRecord,
|
||||||
|
synthesizeSpeech
|
||||||
|
} from "./text-to-speech";
|
||||||
|
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
|
||||||
|
import {aiLog} from "../logging/ai-logger";
|
||||||
|
import {recordPipelineFallback, recordTtsRun} from "../common/ai-observability.js";
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
|
||||||
|
return {
|
||||||
|
requestId: options.requestId ?? `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
replyToMessageId: options.msg.reply_to_message?.message_id,
|
||||||
|
fromId: options.msg.from?.id ?? 0,
|
||||||
|
receivedAt: nowIso(),
|
||||||
|
text: options.text,
|
||||||
|
settings: {
|
||||||
|
provider: options.provider,
|
||||||
|
responseLanguage: options.responseLanguage ?? "default",
|
||||||
|
contextSize: options.contextSize,
|
||||||
|
voiceMode: options.voiceMode ?? "execute",
|
||||||
|
imageOutputMode: "photo",
|
||||||
|
},
|
||||||
|
inputAttachments: [],
|
||||||
|
outputAttachments: [],
|
||||||
|
artifacts: [],
|
||||||
|
toolRankDecisions: [],
|
||||||
|
audit: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProviderModelCall(params: {
|
||||||
|
options: UnifiedRunOptions;
|
||||||
|
config: RuntimeConfigSnapshot;
|
||||||
|
downloads: AiDownloadedFile[];
|
||||||
|
prepared: PreparedUnifiedAiRequest;
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<void> {
|
||||||
|
const {options, config, downloads, prepared, streamMessage, signal} = params;
|
||||||
|
const preparedDocumentRag = prepared.preparedDocumentRag;
|
||||||
|
const documents = preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedDocumentRag.documents : [];
|
||||||
|
|
||||||
|
aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)});
|
||||||
|
|
||||||
|
switch (options.provider) {
|
||||||
|
case AiProvider.OPENAI:
|
||||||
|
if (config.openAiBackend === "compatible") {
|
||||||
|
await runOpenAiCompatible(
|
||||||
|
options.msg,
|
||||||
|
prepared.chatMessages as OpenAIChatMessage[],
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
options.stream ?? true,
|
||||||
|
options.msg,
|
||||||
|
config,
|
||||||
|
prepared.toolContext,
|
||||||
|
downloads,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runOpenAi(
|
||||||
|
options.msg,
|
||||||
|
prepared.chatMessages as OpenAIChatMessage[],
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
options.stream ?? true,
|
||||||
|
options.msg,
|
||||||
|
config,
|
||||||
|
prepared.toolContext,
|
||||||
|
downloads,
|
||||||
|
preparedDocumentRag?.provider === AiProvider.OPENAI ? preparedDocumentRag : undefined,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case AiProvider.OLLAMA:
|
||||||
|
if (config.ollamaChatTarget.model?.includes("gpt-oss") && options.think) {
|
||||||
|
options.think = "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
await runOllama(
|
||||||
|
options.msg,
|
||||||
|
prepared.chatMessages as ChatMessage[],
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
ifTrue(options.stream),
|
||||||
|
options.think ?? false,
|
||||||
|
prepared.firstRoundStatus,
|
||||||
|
config,
|
||||||
|
prepared.toolContext,
|
||||||
|
options.contextSize,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case AiProvider.MISTRAL:
|
||||||
|
await runMistral(
|
||||||
|
options.msg,
|
||||||
|
prepared.chatMessages as MistralChatMessage[],
|
||||||
|
documents,
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
options.stream ?? true,
|
||||||
|
prepared.firstRoundStatus,
|
||||||
|
config,
|
||||||
|
prepared.toolContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthesizeResponseIfRequested(params: {
|
||||||
|
options: UnifiedRunOptions;
|
||||||
|
config: RuntimeConfigSnapshot;
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
}): Promise<"succeeded" | "skipped" | "failed"> {
|
||||||
|
const {options, config, streamMessage} = params;
|
||||||
|
|
||||||
|
if (!options.synthesizeSpeechResponse) return "skipped";
|
||||||
|
const text = streamMessage.getText().trim();
|
||||||
|
if (!text) return "skipped";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!options.msg.from?.id) {
|
||||||
|
throw new Error(Environment.couldNotIdentifyUserForSpeechToTextText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveTextToSpeechProviderForUser(options.msg.from.id, options.provider)
|
||||||
|
.catch(() => resolveTextToSpeechProviderForUser(options.msg.from!.id));
|
||||||
|
const speech = await synthesizeSpeech({provider: resolved.provider, text});
|
||||||
|
const sent = await sendSynthesizedSpeech(options.msg, speech);
|
||||||
|
streamMessage.recordOutputAttachment(speechToOutputAttachmentRecord(speech, sent.message_id));
|
||||||
|
return "succeeded";
|
||||||
|
} catch (error) {
|
||||||
|
aiLog("error", "text_to_speech.failed", {
|
||||||
|
provider: providerName(options.provider),
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runUnifiedAiResponsePipeline(params: {
|
||||||
|
options: UnifiedRunOptions;
|
||||||
|
config: RuntimeConfigSnapshot;
|
||||||
|
downloads: AiDownloadedFile[];
|
||||||
|
prepared: PreparedUnifiedAiRequest;
|
||||||
|
streamMessage: TelegramStreamMessage;
|
||||||
|
controller: AbortController;
|
||||||
|
}): Promise<void> {
|
||||||
|
const {options, config, downloads, prepared, streamMessage, controller} = params;
|
||||||
|
const state = createResponsePipelineState(options);
|
||||||
|
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
|
||||||
|
const adapter = getProviderAdapter(options.provider);
|
||||||
|
let selectedToolNames: string[] = [];
|
||||||
|
let filteredTools: unknown[] = [];
|
||||||
|
|
||||||
|
const stages: UserRequestPipelineStage[] = [
|
||||||
|
{
|
||||||
|
name: "audit_start",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "audit_start",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
phase: "ai_response",
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
chatMessages: prepared.chatMessages.length,
|
||||||
|
hasDocumentRag: !!prepared.preparedDocumentRag,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool_rank",
|
||||||
|
async run() {
|
||||||
|
const availableTools = adapter.rankTools(config, {
|
||||||
|
forCreator: options.msg.from?.id === Environment.CREATOR_ID,
|
||||||
|
vectorStoreIds: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
|
||||||
|
? prepared.preparedDocumentRag.vectorStoreIds
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankResult = await runToolRankStage({
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
round: state.toolRankDecisions.length,
|
||||||
|
config,
|
||||||
|
availableTools,
|
||||||
|
messages: prepared.chatMessages,
|
||||||
|
streamMessage,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedToolNames = rankResult.selectedToolNames;
|
||||||
|
filteredTools = rankResult.filteredTools;
|
||||||
|
state.toolRankDecisions.push({
|
||||||
|
provider: options.provider,
|
||||||
|
round: state.toolRankDecisions.length,
|
||||||
|
availableTools: allToolSchemaNames(availableTools),
|
||||||
|
selectedTools: selectedToolNames,
|
||||||
|
usedRanker: rankResult.usedRanker,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "tool_rank",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
selectedTools: selectedToolNames,
|
||||||
|
usedRanker: rankResult.usedRanker,
|
||||||
|
availableTools: allToolSchemaNames(availableTools),
|
||||||
|
toolRankDecision: state.toolRankDecisions.at(-1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filter_tools",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "filter_tools",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
selectedTools: selectedToolNames,
|
||||||
|
filteredToolCount: filteredTools.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "model_call",
|
||||||
|
async run() {
|
||||||
|
await runProviderModelCall({
|
||||||
|
options,
|
||||||
|
config,
|
||||||
|
downloads,
|
||||||
|
prepared,
|
||||||
|
streamMessage,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "model_call",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
modelOutput: summarizeModelOutput({
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
toolExecutions: streamMessage.getToolExecutions(),
|
||||||
|
outputAttachments: streamMessage.getOutputAttachments(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool_loop",
|
||||||
|
async run() {
|
||||||
|
const executions = streamMessage.getToolExecutions();
|
||||||
|
const outputAttachments = streamMessage.getOutputAttachments();
|
||||||
|
const summary = summarizeToolLoop({
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
executions,
|
||||||
|
outputAttachments,
|
||||||
|
});
|
||||||
|
const persisted = await persistToolLoopSummaryArtifactAttachment({
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
executions,
|
||||||
|
outputAttachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (persisted) {
|
||||||
|
await streamMessage.storeInternalAttachment(persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "tool_loop",
|
||||||
|
...summary,
|
||||||
|
details: {
|
||||||
|
...summary.details,
|
||||||
|
persistedSummaryArtifact: !!persisted,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output_size_gate",
|
||||||
|
async run() {
|
||||||
|
const originalChars = streamMessage.getText().length;
|
||||||
|
if (originalChars > TELEGRAM_LIMIT) {
|
||||||
|
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "output_size_gate",
|
||||||
|
status: originalChars > TELEGRAM_LIMIT ? "fallback" : "succeeded",
|
||||||
|
fallbackAction: originalChars > TELEGRAM_LIMIT ? "notify_user" : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "send_response",
|
||||||
|
async run() {
|
||||||
|
await streamMessage.finish();
|
||||||
|
return {
|
||||||
|
stage: "send_response",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "persist_output_artifacts",
|
||||||
|
async run() {
|
||||||
|
const outputAttachments = streamMessage.getOutputAttachments();
|
||||||
|
const artifact = await persistFinalTextArtifactAttachment({
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (artifact) {
|
||||||
|
await streamMessage.storeInternalAttachment(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "persist_output_artifacts",
|
||||||
|
status: artifact || outputAttachments.length ? "succeeded" : "skipped",
|
||||||
|
details: {
|
||||||
|
finalTextPersisted: !!artifact,
|
||||||
|
outputAttachments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text_to_speech",
|
||||||
|
async run() {
|
||||||
|
const status = await synthesizeResponseIfRequested({options, config, streamMessage});
|
||||||
|
recordTtsRun(status);
|
||||||
|
return {
|
||||||
|
stage: "text_to_speech",
|
||||||
|
status,
|
||||||
|
fallbackAction: status === "failed" ? "continue_without_stage" : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audit_finish",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "audit_finish",
|
||||||
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
phase: "ai_response",
|
||||||
|
textChars: streamMessage.getText().length,
|
||||||
|
toolExecutions: streamMessage.getToolExecutions().length,
|
||||||
|
outputAttachments: streamMessage.getOutputAttachments().length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responsePipeline = new UserRequestPipeline({
|
||||||
|
stages,
|
||||||
|
stageNames: [
|
||||||
|
"audit_start",
|
||||||
|
"tool_rank",
|
||||||
|
"filter_tools",
|
||||||
|
"model_call",
|
||||||
|
"tool_loop",
|
||||||
|
"output_size_gate",
|
||||||
|
"send_response",
|
||||||
|
"text_to_speech",
|
||||||
|
"persist_output_artifacts",
|
||||||
|
"audit_finish",
|
||||||
|
],
|
||||||
|
onFallback: async decision => {
|
||||||
|
recordPipelineFallback(decision.action);
|
||||||
|
if (decision.action === "use_alternate_target") {
|
||||||
|
aiLog("warn", "response.fallback.use_alternate_target", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
...buildToolRankFallbackTargetDetails(options.provider, config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.action === "fail_request") {
|
||||||
|
aiLog("error", "response.fallback.fail_request", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = await fallbackNotifier.notify(state.requestId, decision);
|
||||||
|
state.audit.push({
|
||||||
|
stage: decision.stage,
|
||||||
|
status: "fallback",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
fallbackAction: decision.action,
|
||||||
|
fallbackNotification: notification.text,
|
||||||
|
fallbackNotified: notification.notified,
|
||||||
|
reason: decision.reason,
|
||||||
|
...(decision.action === "use_alternate_target"
|
||||||
|
? buildToolRankFallbackTargetDetails(options.provider, config)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await responsePipeline.run(state, controller.signal);
|
||||||
|
await streamMessage.storePipelineAudit(state.audit);
|
||||||
|
} catch (error) {
|
||||||
|
await streamMessage.storePipelineAudit(state.audit).catch(logError);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
const cleanupState = createResponsePipelineState(options);
|
||||||
|
const cleanupPipeline = new UserRequestPipeline({
|
||||||
|
stages: [{
|
||||||
|
name: "cleanup",
|
||||||
|
async run() {
|
||||||
|
await prepared.cleanup();
|
||||||
|
return {
|
||||||
|
stage: "cleanup",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
stageNames: ["cleanup"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cleanupPipeline.run(cleanupState, controller.signal);
|
||||||
|
await streamMessage.storePipelineAudit(cleanupState.audit);
|
||||||
|
} catch (error) {
|
||||||
|
await streamMessage.storePipelineAudit(cleanupState.audit).catch(logError);
|
||||||
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
// 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 "../logging/ai-logger";
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
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: 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;
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,32 @@
|
|||||||
// Mistral provider runner extracted from unified-ai-runner.ts.
|
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {getMistralTools} 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 {MistralChatMessage} from "./mistral-chat-message";
|
import {MistralChatMessage} from "./mistral-chat-message";
|
||||||
import {createMistralClient} from "./ai-runtime-target";
|
import {createMistralClient} from "./ai-runtime-target";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared";
|
import {
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
MistralDocumentReference,
|
||||||
|
roundStatus,
|
||||||
|
RuntimeConfigSnapshot,
|
||||||
|
StreamingToolCallAccumulator,
|
||||||
|
ToolCallData,
|
||||||
|
ToolExecutionMemory
|
||||||
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
|
||||||
export async function runMistral(
|
export async function runMistral(
|
||||||
|
msg: Message,
|
||||||
messages: MistralChatMessage[],
|
messages: MistralChatMessage[],
|
||||||
documents: MistralDocumentReference[],
|
documents: MistralDocumentReference[],
|
||||||
streamMessage: TelegramStreamMessage,
|
streamMessage: TelegramStreamMessage,
|
||||||
@@ -21,6 +38,9 @@ export async function runMistral(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runnerStartedAt = Date.now();
|
const runnerStartedAt = Date.now();
|
||||||
const mistralAi = createMistralClient(config.mistralChatTarget);
|
const mistralAi = createMistralClient(config.mistralChatTarget);
|
||||||
|
const adapter = getProviderAdapter(AiProvider.MISTRAL);
|
||||||
|
const availableTools = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID});
|
||||||
|
const requestMessages = adapter.mapMessages([...messages]) as unknown as MistralChatMessage[];
|
||||||
aiLog("info", "mistral.run.start", {
|
aiLog("info", "mistral.run.start", {
|
||||||
stream,
|
stream,
|
||||||
target: aiLogProviderTarget(config.mistralChatTarget),
|
target: aiLogProviderTarget(config.mistralChatTarget),
|
||||||
@@ -30,34 +50,51 @@ export async function runMistral(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toolMemory: ToolExecutionMemory = new Map();
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
try {
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
await runToolLoopRounds({
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
onRound: async (round) => {
|
||||||
const roundStartedAt = Date.now();
|
const roundStartedAt = Date.now();
|
||||||
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
||||||
if (signal.aborted) throw new Error("Aborted");
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
|
|
||||||
|
const rankResult = await runToolRankStage({
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
|
model: config.mistralChatTarget.model,
|
||||||
|
round,
|
||||||
|
config,
|
||||||
|
availableTools,
|
||||||
|
messages,
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
|
||||||
|
const requestTools = filteredTools.length ? filteredTools : undefined;
|
||||||
|
|
||||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||||
await streamMessage.flush();
|
await streamMessage.flush();
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
const request = {
|
const request = {
|
||||||
model: config.mistralChatTarget.model,
|
model: config.mistralChatTarget.model,
|
||||||
messages,
|
messages: requestMessages,
|
||||||
tools: getMistralTools(),
|
tools: requestTools,
|
||||||
documents: documents
|
documents: documents
|
||||||
} as unknown as Parameters<typeof mistralAi.chat.complete>[0];
|
} as Parameters<typeof mistralAi.chat.complete>[0];
|
||||||
const response = await mistralAi.chat.complete(request, {signal});
|
const response = await runSingleModelRequest({
|
||||||
const msg = response.choices?.[0]?.message;
|
execute: () => adapter.callModel(request, () => mistralAi.chat.complete(request, {signal})),
|
||||||
const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? "");
|
});
|
||||||
|
const message = response.choices?.[0]?.message;
|
||||||
|
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
|
||||||
streamMessage.append(text);
|
streamMessage.append(text);
|
||||||
const calls = normalizeMistralToolCalls(mistralToolCalls(msg));
|
const calls = adapter.extractToolCalls(message);
|
||||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||||
round,
|
round,
|
||||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
textChars: text.length,
|
textChars: text.length,
|
||||||
calls: calls.map(aiLogToolCall),
|
calls: calls.map(aiLogToolCall),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: text,
|
content: text,
|
||||||
@@ -66,25 +103,50 @@ export async function runMistral(
|
|||||||
function: {name: call.name, arguments: call.argumentsText},
|
function: {name: call.name, arguments: call.argumentsText},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
requestMessages.push({
|
||||||
for (const [index, call] of calls.entries()) {
|
role: "assistant",
|
||||||
messages.push({
|
content: text,
|
||||||
role: "tool",
|
toolCalls: calls.map(call => ({
|
||||||
name: call.name,
|
id: call.id,
|
||||||
toolCallId: call.id,
|
function: {name: call.name, arguments: call.argumentsText},
|
||||||
content: toolResults[index] ?? "",
|
})),
|
||||||
|
});
|
||||||
|
await executeToolBatchWithAdapter({
|
||||||
|
userId: msg.from?.id,
|
||||||
|
toolCalls: calls,
|
||||||
|
streamMessage,
|
||||||
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
|
runtimeTarget: config.mistralChatTarget,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [messages, requestMessages],
|
||||||
|
});
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "mistral.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
return {shouldContinue: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
model: config.mistralChatTarget.model,
|
model: config.mistralChatTarget.model,
|
||||||
messages,
|
messages: requestMessages,
|
||||||
tools: getMistralTools(),
|
tools: requestTools,
|
||||||
documents: documents
|
documents: documents
|
||||||
} as unknown as Parameters<typeof mistralAi.chat.stream>[0];
|
} as Parameters<typeof mistralAi.chat.stream>[0];
|
||||||
const streamResponse = await mistralAi.chat.stream(request, {signal});
|
const streamResponse = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => mistralAi.chat.stream(request, {signal})),
|
||||||
|
});
|
||||||
aiLog("debug", "mistral.stream.open", {round});
|
aiLog("debug", "mistral.stream.open", {round});
|
||||||
let calls: ToolCallData[] = [];
|
let calls: ToolCallData[] = [];
|
||||||
const roundTextStart = streamMessage.getText().length;
|
const roundTextStart = streamMessage.getText().length;
|
||||||
@@ -95,11 +157,10 @@ export async function runMistral(
|
|||||||
|
|
||||||
const choice = event.data?.choices?.[0];
|
const choice = event.data?.choices?.[0];
|
||||||
const delta = choice?.delta;
|
const delta = choice?.delta;
|
||||||
const mistralDelta = delta as MistralDeltaLike;
|
const mistralDelta = delta;
|
||||||
|
streamMessage.append(adapter.extractTextDelta(mistralDelta));
|
||||||
|
|
||||||
streamMessage.append(contentFromMistralDelta(mistralDelta));
|
const rawDeltaCalls = adapter.extractStreamingToolCalls(mistralDelta);
|
||||||
|
|
||||||
const rawDeltaCalls = mistralToolCalls(mistralDelta);
|
|
||||||
if (rawDeltaCalls.length) {
|
if (rawDeltaCalls.length) {
|
||||||
calls = toolCallAccumulator.add(rawDeltaCalls);
|
calls = toolCallAccumulator.add(rawDeltaCalls);
|
||||||
streamMessage.setStatus(Environment.getUseToolText(calls));
|
streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||||
@@ -112,26 +173,46 @@ export async function runMistral(
|
|||||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||||
calls: calls.map(aiLogToolCall),
|
calls: calls.map(aiLogToolCall),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: roundText,
|
content: roundText,
|
||||||
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||||
});
|
});
|
||||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
requestMessages.push({
|
||||||
for (const [index, call] of calls.entries()) {
|
role: "assistant",
|
||||||
messages.push({
|
content: roundText,
|
||||||
role: "tool",
|
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||||
name: call.name,
|
});
|
||||||
toolCallId: call.id,
|
await executeToolBatchWithAdapter({
|
||||||
content: toolResults[index] ?? "",
|
userId: msg.from?.id,
|
||||||
|
toolCalls: calls,
|
||||||
|
streamMessage,
|
||||||
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
|
runtimeTarget: config.mistralChatTarget,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [messages, requestMessages],
|
||||||
|
});
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "mistral.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return {shouldContinue: true};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await adapter.finalize().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class MistralProviderRunner {
|
|
||||||
static run = runMistral;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +1,32 @@
|
|||||||
// Ollama provider runner extracted from unified-ai-runner.ts.
|
// Ollama provider runner extracted from unified-ai-runner.ts.
|
||||||
import {Message} from "typescript-telegram-bot-api";
|
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
import {bot, notesDir} from "../index";
|
import {bot, notesDir} from "../index";
|
||||||
import {clamp, logError} from "../util/utils";
|
import {clamp, logError} from "../util/utils";
|
||||||
import {getOllamaTools} from "./tool-mappers";
|
|
||||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import {ChatMessage} from "./chat-messages-types";
|
import {ChatMessage} from "./chat-messages-types";
|
||||||
import {ChatRequest, Tool} from "ollama";
|
import {ChatRequest, Tool} from "ollama";
|
||||||
import {ToolRuntimeContext} from "./tools/runtime";
|
import {ToolRuntimeContext} from "./tools/runtime";
|
||||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||||
import {getCurrentDateTimeTool} from "./tools/datetime";
|
|
||||||
import {getFinancialMarketData} from "./tools/market-rates";
|
|
||||||
import {getWeatherTool} from "./tools/weather";
|
|
||||||
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
||||||
import {createOllamaClient} from "./ai-runtime-target";
|
import {createOllamaClient} from "./ai-runtime-target";
|
||||||
import {GetNoteFileResult, GetNoteFileResultSchema, sendNoteAsFileTool} from "./tools/send-note-as-file";
|
|
||||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
allToolSchemaNames,
|
allToolSchemaNames,
|
||||||
appendOllamaToolResults,
|
|
||||||
dedupeToolCalls,
|
dedupeToolCalls,
|
||||||
DEFAULT_OLLAMA_CONTEXT_SIZE,
|
DEFAULT_OLLAMA_CONTEXT_SIZE,
|
||||||
executeToolBatch,
|
|
||||||
isOllamaModelActive,
|
isOllamaModelActive,
|
||||||
isRecord,
|
isRecord,
|
||||||
MAX_OLLAMA_CONTEXT_SIZE,
|
MAX_OLLAMA_CONTEXT_SIZE,
|
||||||
MAX_TOOL_ROUNDS,
|
MAX_TOOL_ROUNDS,
|
||||||
MIN_OLLAMA_CONTEXT_SIZE,
|
MIN_OLLAMA_CONTEXT_SIZE,
|
||||||
normalizeOllamaToolCalls,
|
|
||||||
OllamaToolCallLike,
|
|
||||||
roundStatus,
|
roundStatus,
|
||||||
RuntimeConfigSnapshot,
|
RuntimeConfigSnapshot,
|
||||||
safeJsonParseObject,
|
safeJsonParseObject,
|
||||||
@@ -39,11 +34,15 @@ import {
|
|||||||
ToolCallData,
|
ToolCallData,
|
||||||
ToolExecutionMemory
|
ToolExecutionMemory
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {latestUserTextFromOllamaMessages, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
import {getToolPrompts} from "./tools/registry";
|
import {getToolPrompts} from "./tools/registry";
|
||||||
import {createNoteTool} from "./tools/create-note";
|
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
|
||||||
import {deleteNoteTool, getNoteContentTool, listNotesTool, updateNoteContentTool} from "./tools/list-notes";
|
import {getModelCapabilities} from "./provider-model-runtime";
|
||||||
import {searchNotesTool} from "./tools/search-notes";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
|
||||||
export async function runOllama(
|
export async function runOllama(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -57,7 +56,6 @@ export async function runOllama(
|
|||||||
toolContext: ToolRuntimeContext,
|
toolContext: ToolRuntimeContext,
|
||||||
contextSize?: number,
|
contextSize?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fromId = msg.from?.id;
|
|
||||||
const runnerStartedAt = Date.now();
|
const runnerStartedAt = Date.now();
|
||||||
|
|
||||||
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
|
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
|
||||||
@@ -80,9 +78,8 @@ export async function runOllama(
|
|||||||
|
|
||||||
const ollama = createOllamaClient(target);
|
const ollama = createOllamaClient(target);
|
||||||
const modelInfo = await ollama.show({model});
|
const modelInfo = await ollama.show({model});
|
||||||
const modelInfoMap = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
|
const modelInfoMap: Record<string, BoundaryValue> = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
|
||||||
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
|
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
|
||||||
// @ts-ignore
|
|
||||||
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
|
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
|
||||||
const parsedMaxContextLength =
|
const parsedMaxContextLength =
|
||||||
typeof rawMaxContextLength === "number"
|
typeof rawMaxContextLength === "number"
|
||||||
@@ -118,7 +115,7 @@ export async function runOllama(
|
|||||||
await unloadAllOllamaModels(ollama, modelsToLoad);
|
await unloadAllOllamaModels(ollama, modelsToLoad);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isOllamaModelActive(ollama, target))) {
|
if (!(await isOllamaModelActive(ollama, target))) {
|
||||||
@@ -160,9 +157,12 @@ export async function runOllama(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolMemory: ToolExecutionMemory = new Map();
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
const adapter = getProviderAdapter(AiProvider.OLLAMA);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
await runToolLoopRounds({
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
onRound: async (round) => {
|
||||||
const roundStartedAt = Date.now();
|
const roundStartedAt = Date.now();
|
||||||
aiLog("debug", "ollama.round.start", {
|
aiLog("debug", "ollama.round.start", {
|
||||||
round,
|
round,
|
||||||
@@ -185,20 +185,8 @@ export async function runOllama(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let activeToolNames: string[] = [];
|
let activeToolNames: string[] = [];
|
||||||
// if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||||
const availableOllamaTools: Tool[] =
|
const availableOllamaTools: Tool[] = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID}) as Tool[];
|
||||||
fromId !== Environment.CREATOR_ID ? [
|
|
||||||
getCurrentDateTimeTool,
|
|
||||||
getFinancialMarketData,
|
|
||||||
getWeatherTool,
|
|
||||||
createNoteTool,
|
|
||||||
listNotesTool,
|
|
||||||
getNoteContentTool,
|
|
||||||
updateNoteContentTool,
|
|
||||||
deleteNoteTool,
|
|
||||||
sendNoteAsFileTool,
|
|
||||||
searchNotesTool
|
|
||||||
] : getOllamaTools() as Tool[];
|
|
||||||
|
|
||||||
aiLog("debug", "ollama.tools.available", {
|
aiLog("debug", "ollama.tools.available", {
|
||||||
round,
|
round,
|
||||||
@@ -206,20 +194,25 @@ export async function runOllama(
|
|||||||
rankerEnabled: !!config.ollamaToolRankerTarget,
|
rankerEnabled: !!config.ollamaToolRankerTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rankerSelection = await new OllamaToolRanker(config).selectTools({
|
const rankResult = await runToolRankStage({
|
||||||
userQuery: latestUserTextFromOllamaMessages(messages),
|
provider: AiProvider.OLLAMA,
|
||||||
availableTools: availableOllamaTools,
|
model,
|
||||||
round,
|
round,
|
||||||
|
config,
|
||||||
|
availableTools: availableOllamaTools,
|
||||||
|
messages,
|
||||||
|
streamMessage,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
activeToolNames = rankerSelection.tools.map(t => t.function.name ?? "");
|
const filteredTools = [...new Set(ensureToolsSelected(availableOllamaTools, rankResult.filteredTools as Tool[], MEMORY_TOOL_NAMES) as Tool[])];
|
||||||
if (rankerSelection.tools.length > 0) {
|
activeToolNames = filteredTools.map(t => t.function.name ?? "");
|
||||||
request.tools = [...rankerSelection.tools, ...rankerSelection.tools];
|
if (filteredTools.length > 0) {
|
||||||
|
request.tools = [...filteredTools];
|
||||||
request.options = {
|
request.options = {
|
||||||
...request.options,
|
...request.options,
|
||||||
temperature: 0
|
temperature: 0
|
||||||
}
|
};
|
||||||
|
|
||||||
const newMessage = messages[messages.length - 1];
|
const newMessage = messages[messages.length - 1];
|
||||||
if (newMessage) {
|
if (newMessage) {
|
||||||
@@ -236,31 +229,27 @@ export async function runOllama(
|
|||||||
delete request.tools;
|
delete request.tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 14.05.2026, Danil Nikolaev: check if model supports tools
|
|
||||||
|
|
||||||
|
|
||||||
aiLog("debug", "ollama.tools.selected", {
|
aiLog("debug", "ollama.tools.selected", {
|
||||||
round,
|
round,
|
||||||
tools: activeToolNames,
|
tools: activeToolNames,
|
||||||
count: activeToolNames.length,
|
count: activeToolNames.length,
|
||||||
usedRanker: rankerSelection.usedRanker,
|
usedRanker: rankResult.usedRanker,
|
||||||
});
|
});
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
const response = await ollama.chat({
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||||
...request,
|
...request,
|
||||||
stream: false
|
stream: false
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = response.message;
|
const message = response.message;
|
||||||
const rawContent = message?.content ?? "";
|
const rawContent = message?.content ?? "";
|
||||||
|
|
||||||
const nativeCalls = dedupeToolCalls(
|
const nativeCalls = dedupeToolCalls(
|
||||||
normalizeOllamaToolCalls(
|
adapter.extractToolCalls(message),
|
||||||
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
|
||||||
round,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseText = rawContent;
|
const responseText = rawContent;
|
||||||
@@ -285,10 +274,10 @@ export async function runOllama(
|
|||||||
|
|
||||||
if (!nativeCalls.length) {
|
if (!nativeCalls.length) {
|
||||||
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
||||||
break;
|
return {shouldContinue: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
const calls = nativeCalls;
|
const calls = adapter.extractToolCalls(message).length ? adapter.extractToolCalls(message) : nativeCalls;
|
||||||
|
|
||||||
aiLog("info", "ollama.tool_calls", {
|
aiLog("info", "ollama.tool_calls", {
|
||||||
round,
|
round,
|
||||||
@@ -306,19 +295,44 @@ export async function runOllama(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
appendOllamaToolResults(
|
await executeToolBatchWithAdapter({
|
||||||
messages,
|
userId: msg.from?.id,
|
||||||
calls,
|
toolCalls: calls,
|
||||||
await executeToolBatch(calls, streamMessage, toolContext, toolMemory),
|
streamMessage,
|
||||||
);
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
runtimeTarget: target,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [messages],
|
||||||
|
});
|
||||||
|
|
||||||
continue;
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("MESSAGES", JSON.stringify(request.messages));
|
return {shouldContinue: true};
|
||||||
const response = await ollama.chat({
|
}
|
||||||
|
|
||||||
|
aiLog("debug", "ollama.stream.messages", {
|
||||||
|
round,
|
||||||
|
messageCount: request.messages?.length ?? 0,
|
||||||
|
});
|
||||||
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||||
...request,
|
...request,
|
||||||
stream: true
|
stream: true
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
aiLog("debug", "ollama.stream.open", {round});
|
aiLog("debug", "ollama.stream.open", {round});
|
||||||
@@ -329,14 +343,16 @@ export async function runOllama(
|
|||||||
if (signal.aborted) abortOllamaResponse();
|
if (signal.aborted) abortOllamaResponse();
|
||||||
try {
|
try {
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
console.log("OLLAMA_CHUNK: ", chunk);
|
aiLog("trace", "ollama.stream.chunk", {
|
||||||
|
round,
|
||||||
|
contentPreview: chunk.message.content?.slice(0, 240),
|
||||||
|
hasToolCalls: !!chunk.message.tool_calls?.length,
|
||||||
|
hasThinking: !!chunk.message.thinking,
|
||||||
|
});
|
||||||
|
|
||||||
const localToolCalls: ToolCallData[] = [];
|
const localToolCalls: ToolCallData[] = [];
|
||||||
|
|
||||||
localToolCalls.push(...normalizeOllamaToolCalls(
|
localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||||
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
|
||||||
round,
|
|
||||||
));
|
|
||||||
|
|
||||||
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
||||||
const previousStatus = streamMessage.getStatus();
|
const previousStatus = streamMessage.getStatus();
|
||||||
@@ -356,13 +372,10 @@ export async function runOllama(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
||||||
streamMessage.append(chunk.message?.content ?? "");
|
streamMessage.append(adapter.extractTextDelta(chunk));
|
||||||
}
|
}
|
||||||
|
|
||||||
calls.push(...normalizeOllamaToolCalls(
|
calls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||||
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
|
||||||
round,
|
|
||||||
));
|
|
||||||
|
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
aiLog("debug", "ollama.stream.done", {
|
aiLog("debug", "ollama.stream.done", {
|
||||||
@@ -395,7 +408,7 @@ export async function runOllama(
|
|||||||
duration: aiLogDuration(runnerStartedAt),
|
duration: aiLogDuration(runnerStartedAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
return {shouldContinue: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
||||||
@@ -418,7 +431,31 @@ export async function runOllama(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
|
userId: msg.from?.id,
|
||||||
|
toolCalls: calls,
|
||||||
|
streamMessage,
|
||||||
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
runtimeTarget: target,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [messages],
|
||||||
|
});
|
||||||
|
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||||
|
|
||||||
@@ -436,18 +473,25 @@ export async function runOllama(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
|
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
|
||||||
|
const attachmentPath = path.join(notesDir, successGetNoteFileResult.attachment.relativePath);
|
||||||
|
if (!fs.existsSync(attachmentPath)) {
|
||||||
|
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
await bot.sendDocument({
|
await bot.sendDocument({
|
||||||
chat_id: msg.chat.id,
|
chat_id: msg.chat.id,
|
||||||
reply_parameters: {
|
reply_parameters: {
|
||||||
message_id: msg.message_id,
|
message_id: msg.message_id,
|
||||||
},
|
},
|
||||||
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
|
document: fs.createReadStream(attachmentPath),
|
||||||
}).catch(logError);
|
}).catch(logError);
|
||||||
}
|
}
|
||||||
|
|
||||||
appendOllamaToolResults(messages, calls, toolResults);
|
return {shouldContinue: true};
|
||||||
}
|
},
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
|
await adapter.finalize().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
import type {
|
||||||
|
ChatCompletionCreateParamsNonStreaming,
|
||||||
|
ChatCompletionCreateParamsStreaming,
|
||||||
|
ChatCompletionTool,
|
||||||
|
} from "openai/resources/chat/completions";
|
||||||
|
import {Environment} from "../common/environment.js";
|
||||||
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
|
import {ToolRuntimeContext} from "./tools/runtime";
|
||||||
|
import {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message";
|
||||||
|
import {createOpenAiClient} from "./ai-runtime-target";
|
||||||
|
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
|
import type {BoundaryValue} from "../common/boundary-types.js";
|
||||||
|
import {
|
||||||
|
AsyncIterableStream,
|
||||||
|
buildSystemInstruction,
|
||||||
|
MAX_TOOL_ROUNDS,
|
||||||
|
OpenAiChatCompletionResponseLike,
|
||||||
|
OpenAiChatCompletionStreamChunkLike,
|
||||||
|
RuntimeConfigSnapshot,
|
||||||
|
safeJsonParseObject,
|
||||||
|
ToolCallData,
|
||||||
|
ToolExecutionMemory,
|
||||||
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {mergeToolCallChunks, normalizeStreamingTextDelta} from "./provider-adapter-contract.js";
|
||||||
|
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||||
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
|
import {ensureToolsSelected, getOpenAICompatibleTools} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
|
import {logError} from "../util/utils";
|
||||||
|
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||||
|
import {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import type {AiProviderAdapter} from "./provider-adapters.js";
|
||||||
|
import {tryToUploadFiles} from "./openai-upload-files.js";
|
||||||
|
import {buildAssistantToolMessage, openAiResponseMessagesToChatCompletions} from "./openai-chat-completions.js";
|
||||||
|
|
||||||
|
function describeOpenAiCompatibleError(error: unknown): Record<string, unknown> {
|
||||||
|
const err = error as {
|
||||||
|
message?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
code?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorSummary: typeof err?.message === "string" ? err.message : String(error),
|
||||||
|
httpStatus: err?.status,
|
||||||
|
errorCode: err?.code,
|
||||||
|
errorType: err?.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeChatCompletionWithOptionalToolFallback<T>(params: {
|
||||||
|
openAi: ReturnType<typeof createOpenAiClient>;
|
||||||
|
request: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming;
|
||||||
|
signal: AbortSignal;
|
||||||
|
stream: boolean;
|
||||||
|
}): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await params.openAi.chat.completions.create(params.request as never, {signal: params.signal}) as T;
|
||||||
|
} catch (error) {
|
||||||
|
const requestWithTools = params.request as {tools?: unknown[]};
|
||||||
|
if (!requestWithTools.tools || !Array.isArray(requestWithTools.tools) || requestWithTools.tools.length === 0) {
|
||||||
|
aiLog("error", "openai_compatible.request.failed", {
|
||||||
|
stream: params.stream,
|
||||||
|
hasTools: false,
|
||||||
|
error: describeOpenAiCompatibleError(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
aiLog("warn", "openai_compatible.tools.retry_without_tools", {
|
||||||
|
stream: params.stream,
|
||||||
|
error: describeOpenAiCompatibleError(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryRequest = {...params.request} as ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming & {tools?: unknown[]};
|
||||||
|
delete retryRequest.tools;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await params.openAi.chat.completions.create(retryRequest as never, {signal: params.signal}) as T;
|
||||||
|
} catch (retryError) {
|
||||||
|
aiLog("error", "openai_compatible.request.retry_without_tools.failed", {
|
||||||
|
stream: params.stream,
|
||||||
|
hasTools: true,
|
||||||
|
error: describeOpenAiCompatibleError(retryError),
|
||||||
|
});
|
||||||
|
throw retryError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChatCompletionAdapter(): AiProviderAdapter {
|
||||||
|
const baseAdapter = getProviderAdapter(AiProvider.OPENAI);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseAdapter,
|
||||||
|
callModel: baseAdapter.callModel.bind(baseAdapter),
|
||||||
|
mapMessages(messages: readonly unknown[]): unknown[] {
|
||||||
|
return openAiResponseMessagesToChatCompletions(messages as OpenAIChatMessage[]);
|
||||||
|
},
|
||||||
|
rankTools(config: RuntimeConfigSnapshot, options?: {forCreator?: boolean; vectorStoreIds?: string[]}): readonly BoundaryValue[] {
|
||||||
|
void config;
|
||||||
|
void options?.vectorStoreIds;
|
||||||
|
return getOpenAICompatibleTools(options?.forCreator) as BoundaryValue[];
|
||||||
|
},
|
||||||
|
extractTextDelta(input: unknown): string {
|
||||||
|
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
|
||||||
|
return chunk?.choices?.[0]?.delta?.content ?? "";
|
||||||
|
},
|
||||||
|
extractToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
const response = input as OpenAiChatCompletionResponseLike | undefined;
|
||||||
|
const toolCalls = response?.choices?.[0]?.message?.tool_calls ?? [];
|
||||||
|
|
||||||
|
return toolCalls
|
||||||
|
.map((call, index) => ({
|
||||||
|
id: typeof call?.id === "string" && call.id.trim().length > 0 ? call.id : `openai_chat_${index}`,
|
||||||
|
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
|
||||||
|
argumentsText: typeof call?.function?.arguments === "string"
|
||||||
|
? call.function.arguments
|
||||||
|
: JSON.stringify(call?.function?.arguments ?? call?.arguments ?? {}),
|
||||||
|
}))
|
||||||
|
.filter(call => call.name.length > 0);
|
||||||
|
},
|
||||||
|
extractStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
|
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
|
||||||
|
const toolCalls = chunk?.choices?.[0]?.delta?.tool_calls ?? [];
|
||||||
|
|
||||||
|
return toolCalls
|
||||||
|
.map((call, index) => ({
|
||||||
|
id: typeof call?.id === "string" && call.id.trim().length > 0
|
||||||
|
? call.id
|
||||||
|
: `openai_chat_${typeof call?.index === "number" ? call.index : index}`,
|
||||||
|
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
|
||||||
|
argumentsText: typeof call?.function?.arguments === "string"
|
||||||
|
? call.function.arguments
|
||||||
|
: call?.function?.arguments
|
||||||
|
? JSON.stringify(call.function.arguments)
|
||||||
|
: typeof call?.arguments === "string"
|
||||||
|
? call.arguments
|
||||||
|
: "",
|
||||||
|
}))
|
||||||
|
.filter(call => call.id.length > 0);
|
||||||
|
},
|
||||||
|
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
|
||||||
|
for (const [index, call] of calls.entries()) {
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: results[index] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finalize: baseAdapter.finalize.bind(baseAdapter),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOpenAiCompatible(
|
||||||
|
msg: Message,
|
||||||
|
messages: OpenAIChatMessage[],
|
||||||
|
streamMessage: TelegramStreamMessage,
|
||||||
|
signal: AbortSignal,
|
||||||
|
stream: boolean,
|
||||||
|
sourceMessage: Message,
|
||||||
|
config: RuntimeConfigSnapshot,
|
||||||
|
toolContext: ToolRuntimeContext,
|
||||||
|
downloads: AiDownloadedFile[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
void downloads;
|
||||||
|
const runnerStartedAt = Date.now();
|
||||||
|
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||||
|
const adapter = makeChatCompletionAdapter();
|
||||||
|
const systemPrompt = buildSystemInstruction(
|
||||||
|
config,
|
||||||
|
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
|
false,
|
||||||
|
config.openAiChatTarget.systemPromptAdditions,
|
||||||
|
await buildUserMemoryPrompt(msg.from?.id),
|
||||||
|
);
|
||||||
|
let conversationMessages = [...openAiResponseMessagesToChatCompletions(messages)];
|
||||||
|
|
||||||
|
if (systemPrompt.trim().length) {
|
||||||
|
conversationMessages.unshift({role: "system", content: systemPrompt});
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableTools = getOpenAICompatibleTools(msg.from?.id === Environment.CREATOR_ID) as ChatCompletionTool[];
|
||||||
|
|
||||||
|
aiLog("info", "openai_compatible.run.start", {
|
||||||
|
stream,
|
||||||
|
target: aiLogProviderTarget(config.openAiChatTarget),
|
||||||
|
inputMessages: messages.length,
|
||||||
|
sourceMessage: aiLogMessageIdentity(sourceMessage),
|
||||||
|
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||||
|
backend: config.openAiBackend,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runToolLoopRounds({
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
onRound: async (round) => {
|
||||||
|
const roundStartedAt = Date.now();
|
||||||
|
aiLog("debug", "openai_compatible.round.start", {round, inputMessages: conversationMessages.length, stream});
|
||||||
|
|
||||||
|
const rankResult = await runToolRankStage({
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
model: config.openAiChatTarget.model,
|
||||||
|
round,
|
||||||
|
config,
|
||||||
|
availableTools: availableTools as readonly BoundaryValue[],
|
||||||
|
messages,
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestTools = ensureToolsSelected(
|
||||||
|
availableTools,
|
||||||
|
rankResult.filteredTools as ChatCompletionTool[],
|
||||||
|
MEMORY_TOOL_NAMES,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||||
|
model: config.openAiChatTarget.model,
|
||||||
|
messages: conversationMessages,
|
||||||
|
tools: requestTools.length ? requestTools : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<OpenAiChatCompletionResponseLike>({
|
||||||
|
openAi,
|
||||||
|
request,
|
||||||
|
signal,
|
||||||
|
stream: false,
|
||||||
|
})),
|
||||||
|
}) as OpenAiChatCompletionResponseLike;
|
||||||
|
|
||||||
|
const message = response.choices?.[0]?.message;
|
||||||
|
const responseText = typeof message?.content === "string" ? message.content : "";
|
||||||
|
streamMessage.append(responseText);
|
||||||
|
aiLog("debug", "openai_compatible.response.received", {
|
||||||
|
round,
|
||||||
|
duration: aiLogDuration(roundStartedAt),
|
||||||
|
textChars: responseText.length,
|
||||||
|
hasToolCalls: !!message?.tool_calls?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = adapter.extractToolCalls(response);
|
||||||
|
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
|
||||||
|
round,
|
||||||
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
|
calls: calls.map(call => ({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
arguments: safeJsonParseObject(call.argumentsText)
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (!calls.length) return {shouldContinue: false};
|
||||||
|
|
||||||
|
const toolCalls = calls.map(call => ({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
argumentsText: call.argumentsText,
|
||||||
|
}));
|
||||||
|
const toolMessages: OpenAICompatibleChatMessage[] = [];
|
||||||
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
|
userId: msg.from?.id,
|
||||||
|
toolCalls,
|
||||||
|
streamMessage,
|
||||||
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [toolMessages],
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||||
|
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
|
||||||
|
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
|
||||||
|
if (toolMessage && toolMessage.role === "tool") {
|
||||||
|
toolMessage.content = "Error: " + uploadFilesResult.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
|
||||||
|
return {shouldContinue: true};
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ChatCompletionCreateParamsStreaming = {
|
||||||
|
model: config.openAiChatTarget.model,
|
||||||
|
messages: conversationMessages,
|
||||||
|
stream: true,
|
||||||
|
tools: requestTools.length ? requestTools : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>>({
|
||||||
|
openAi,
|
||||||
|
request,
|
||||||
|
signal,
|
||||||
|
stream: true,
|
||||||
|
})),
|
||||||
|
}) as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
|
||||||
|
|
||||||
|
aiLog("debug", "openai_compatible.stream.open", {round});
|
||||||
|
|
||||||
|
let responseText = "";
|
||||||
|
let toolCallState: ToolCallData[] = [];
|
||||||
|
for await (const chunk of response) {
|
||||||
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
|
|
||||||
|
const deltaText = adapter.extractTextDelta(chunk);
|
||||||
|
if (deltaText) {
|
||||||
|
const appendedText = normalizeStreamingTextDelta(responseText, deltaText);
|
||||||
|
responseText += appendedText;
|
||||||
|
streamMessage.append(appendedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamedCalls = adapter.extractStreamingToolCalls(chunk);
|
||||||
|
if (streamedCalls.length) {
|
||||||
|
toolCallState = mergeToolCallChunks(toolCallState, streamedCalls);
|
||||||
|
const activeCalls = toolCallState.filter(call => call.name.length > 0);
|
||||||
|
aiLog("info", "openai_compatible.stream.tool_call.added", {
|
||||||
|
round,
|
||||||
|
toolCalls: activeCalls.map(aiLogToolCall),
|
||||||
|
});
|
||||||
|
streamMessage.setStatus(Environment.getUseToolText(activeCalls));
|
||||||
|
await streamMessage.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls = toolCallState.filter(call => call.name.length > 0);
|
||||||
|
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.stream.done", {
|
||||||
|
round,
|
||||||
|
duration: aiLogDuration(roundStartedAt),
|
||||||
|
textChars: responseText.length,
|
||||||
|
calls: calls.map(call => ({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
arguments: safeJsonParseObject(call.argumentsText)
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (!calls.length) return {shouldContinue: false};
|
||||||
|
|
||||||
|
streamMessage.clearStatus();
|
||||||
|
await streamMessage.flush();
|
||||||
|
|
||||||
|
const toolMessages: OpenAICompatibleChatMessage[] = [];
|
||||||
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
|
userId: msg.from?.id,
|
||||||
|
toolCalls: calls,
|
||||||
|
streamMessage,
|
||||||
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [toolMessages],
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||||
|
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
|
||||||
|
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
|
||||||
|
if (toolMessage && toolMessage.role === "tool") {
|
||||||
|
toolMessage.content = "Error: " + uploadFilesResult.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
|
||||||
|
return {shouldContinue: true};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
aiLog("error", "openai_compatible.run.failed", {
|
||||||
|
duration: aiLogDuration(runnerStartedAt),
|
||||||
|
error: describeOpenAiCompatibleError(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await adapter.finalize().catch(logError);
|
||||||
|
}
|
||||||
|
}
|
||||||
+301
-339
@@ -1,7 +1,6 @@
|
|||||||
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
|
|
||||||
import {Message} from "typescript-telegram-bot-api";
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
|
import {OpenAI, toFile} from "openai";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
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";
|
||||||
@@ -11,45 +10,41 @@ import type {
|
|||||||
ResponseInputItem,
|
ResponseInputItem,
|
||||||
ResponseStreamEvent
|
ResponseStreamEvent
|
||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
import type {
|
import {createOpenAiClient} from "./ai-runtime-target";
|
||||||
ChatCompletionCreateParamsNonStreaming,
|
|
||||||
ChatCompletionCreateParamsStreaming
|
|
||||||
} from "openai/resources/chat/completions";
|
|
||||||
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 {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AsyncIterableStream,
|
AsyncIterableStream,
|
||||||
collectOpenAiResponseFunctionCalls,
|
buildSystemInstruction,
|
||||||
collectOpenAiResponseCodeInterpreterCalls,
|
collectOpenAiResponseCodeInterpreterCalls,
|
||||||
collectOpenAiResponseImages,
|
collectOpenAiResponseImages,
|
||||||
collectOpenAiResponseText,
|
collectOpenAiResponseText,
|
||||||
executeToolBatch,
|
|
||||||
getOpenAIResponsesToolsWithImage,
|
|
||||||
isRecord,
|
|
||||||
MAX_TOOL_ROUNDS,
|
MAX_TOOL_ROUNDS,
|
||||||
OPENAI_IMAGE_PARTIALS,
|
OPENAI_IMAGE_PARTIALS,
|
||||||
OpenAiChatCompletionResponseLike,
|
|
||||||
OpenAiChatCompletionStreamChunkLike,
|
|
||||||
OpenAiChatToolCallLike,
|
|
||||||
OpenAiCompatibleChatMessage,
|
|
||||||
OpenAiCompatibleContentPart,
|
|
||||||
openAiResponseItemCallId,
|
openAiResponseItemCallId,
|
||||||
OpenAiResponseLike,
|
OpenAiResponseLike,
|
||||||
OpenAiResponseOutputItem,
|
OpenAiResponseOutputItem,
|
||||||
roundStatus,
|
|
||||||
RuntimeConfigSnapshot,
|
RuntimeConfigSnapshot,
|
||||||
safeJsonParseObject,
|
safeJsonParseObject,
|
||||||
showOpenAiGeneratedImage,
|
showOpenAiGeneratedImage,
|
||||||
StreamingToolCallAccumulator,
|
|
||||||
ToolCallData,
|
ToolCallData,
|
||||||
ToolExecutionMemory
|
ToolExecutionMemory,
|
||||||
|
allToolSchemaNames
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-as-file";
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
import {bot, notesDir} from "../index";
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
import fs from "node:fs";
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
import path from "node:path";
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
import {logError} from "../util/utils";
|
import {logError} from "../util/utils";
|
||||||
|
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||||
|
import {AiDownloadedFile} from "./telegram-attachments";
|
||||||
|
import {AiProvider} from "../model/ai-provider";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {tryToUploadFiles} from "./openai-upload-files.js";
|
||||||
|
|
||||||
export async function runOpenAi(
|
export async function runOpenAi(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -57,18 +52,30 @@ export async function runOpenAi(
|
|||||||
streamMessage: TelegramStreamMessage,
|
streamMessage: TelegramStreamMessage,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
stream: boolean,
|
stream: boolean,
|
||||||
firstRoundStatus: string,
|
|
||||||
sourceMessage: Message,
|
sourceMessage: Message,
|
||||||
config: RuntimeConfigSnapshot,
|
config: RuntimeConfigSnapshot,
|
||||||
toolContext: ToolRuntimeContext,
|
toolContext: ToolRuntimeContext,
|
||||||
think?: boolean
|
downloads: AiDownloadedFile[] = [],
|
||||||
|
documentRag?: OpenAiDocumentRagContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// TODO: 13.05.2026: remove
|
|
||||||
firstRoundStatus;
|
|
||||||
think;
|
|
||||||
const runnerStartedAt = Date.now();
|
const runnerStartedAt = Date.now();
|
||||||
let responseInput: unknown[] = [...messages];
|
|
||||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||||
|
const ownsDocumentRag = !documentRag;
|
||||||
|
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
|
||||||
|
const adapter = getProviderAdapter(AiProvider.OPENAI);
|
||||||
|
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = adapter.mapMessages(messages) as unknown as Array<ResponseInputItem | OpenAiResponseOutputItem>;
|
||||||
|
const availableTools = adapter.rankTools(config, {
|
||||||
|
forCreator: msg.from?.id === Environment.CREATOR_ID,
|
||||||
|
vectorStoreIds: preparedDocumentRag?.vectorStoreIds ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const systemPrompt = buildSystemInstruction(
|
||||||
|
config,
|
||||||
|
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
|
false,
|
||||||
|
config.openAiChatTarget.systemPromptAdditions,
|
||||||
|
await buildUserMemoryPrompt(msg.from?.id),
|
||||||
|
);
|
||||||
|
|
||||||
aiLog("info", "openai.run.start", {
|
aiLog("info", "openai.run.start", {
|
||||||
stream,
|
stream,
|
||||||
@@ -81,18 +88,51 @@ export async function runOpenAi(
|
|||||||
|
|
||||||
const toolMemory: ToolExecutionMemory = new Map();
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
try {
|
||||||
|
await runToolLoopRounds({
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
onRound: async (round) => {
|
||||||
const roundStartedAt = Date.now();
|
const roundStartedAt = Date.now();
|
||||||
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
|
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
|
||||||
|
const rankResult = await runToolRankStage({
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
model: config.openAiChatTarget.model,
|
||||||
|
round,
|
||||||
|
config,
|
||||||
|
availableTools,
|
||||||
|
messages,
|
||||||
|
streamMessage,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const filteredTools = rankResult.filteredTools;
|
||||||
|
const requestTools = preparedDocumentRag?.vectorStoreIds.length
|
||||||
|
? (() => {
|
||||||
|
const tools = [...filteredTools];
|
||||||
|
const hasFileSearch = allToolSchemaNames(tools).includes("file_search");
|
||||||
|
if (!hasFileSearch) {
|
||||||
|
const fileSearchTool = availableTools.find(tool => allToolSchemaNames([tool]).includes("file_search"));
|
||||||
|
if (fileSearchTool) {
|
||||||
|
tools.unshift(fileSearchTool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const withMemory = ensureToolsSelected(availableTools, tools, MEMORY_TOOL_NAMES);
|
||||||
|
return withMemory.length ? withMemory : undefined;
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
const withMemory = ensureToolsSelected(availableTools, filteredTools, MEMORY_TOOL_NAMES);
|
||||||
|
return withMemory.length ? withMemory : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
const request: ResponseCreateParamsNonStreaming = {
|
const request: ResponseCreateParamsNonStreaming = {
|
||||||
model: config.openAiChatTarget.model,
|
model: config.openAiChatTarget.model,
|
||||||
input: responseInput as ResponseInputItem[],
|
input: responseInput as ResponseInputItem[],
|
||||||
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"],
|
tools: requestTools as ResponseCreateParamsNonStreaming["tools"],
|
||||||
instructions: config.systemPrompt,
|
instructions: systemPrompt,
|
||||||
};
|
};
|
||||||
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
|
||||||
|
}) as OpenAiResponseLike;
|
||||||
|
|
||||||
const responseText = collectOpenAiResponseText(response);
|
const responseText = collectOpenAiResponseText(response);
|
||||||
streamMessage.append(responseText);
|
streamMessage.append(responseText);
|
||||||
@@ -129,57 +169,70 @@ export async function runOpenAi(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const calls = collectOpenAiResponseFunctionCalls(response);
|
const calls = adapter.extractToolCalls(response);
|
||||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||||
round,
|
round,
|
||||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
calls: calls.map(call => ({
|
calls: calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
arguments: safeJsonParseObject(call.argumentsText)
|
arguments: safeJsonParseObject(call.argumentsText)
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
|
|
||||||
const toolCalls = calls.map(call => ({
|
const toolCalls = calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
argumentsText: call.argumentsText,
|
argumentsText: call.argumentsText,
|
||||||
}));
|
}));
|
||||||
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
|
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
|
||||||
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
userId: msg.from?.id,
|
||||||
|
toolCalls,
|
||||||
for (const toolResult of toolResults) {
|
streamMessage,
|
||||||
try {
|
toolContext: {
|
||||||
const raw = JSON.parse(toolResult);
|
...toolContext,
|
||||||
const res = GetNoteFileResultSchema.safeParse(raw);
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
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)),
|
toolMemory,
|
||||||
}).catch(logError);
|
adapter,
|
||||||
|
appendTargets: [toolOutputs],
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||||
|
if (uploadFilesResult.found) {
|
||||||
|
if (!uploadFilesResult.uploaded) {
|
||||||
|
const old = toolOutputs[uploadFilesResult.toolIndex];
|
||||||
|
const callId = old?.call_id;
|
||||||
|
if (uploadFilesResult.toolIndex >= 0) {
|
||||||
|
delete toolOutputs[uploadFilesResult.toolIndex];
|
||||||
|
}
|
||||||
|
if (callId) {
|
||||||
|
toolOutputs.push({
|
||||||
|
type: "function_call_output" as const,
|
||||||
|
call_id: callId,
|
||||||
|
output: "Error: " + uploadFilesResult.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolOutputs = calls.map((call, index) => ({
|
|
||||||
type: "function_call_output" as const,
|
|
||||||
call_id: call.callId,
|
|
||||||
output: toolResults[index] ?? "",
|
|
||||||
}));
|
|
||||||
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
|
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
|
||||||
continue;
|
return {shouldContinue: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
let completedResponse: OpenAiResponseLike | null = null;
|
let completedResponse: OpenAiResponseLike | null = null;
|
||||||
@@ -187,10 +240,13 @@ export async function runOpenAi(
|
|||||||
model: config.openAiChatTarget.model,
|
model: config.openAiChatTarget.model,
|
||||||
input: responseInput as ResponseInputItem[],
|
input: responseInput as ResponseInputItem[],
|
||||||
stream: true,
|
stream: true,
|
||||||
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
|
tools: requestTools as ResponseCreateParamsStreaming["tools"],
|
||||||
parallel_tool_calls: true
|
parallel_tool_calls: true,
|
||||||
|
instructions: systemPrompt
|
||||||
};
|
};
|
||||||
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
|
||||||
|
}) as AsyncIterableStream<ResponseStreamEvent>;
|
||||||
|
|
||||||
aiLog("debug", "openai.stream.open", {round});
|
aiLog("debug", "openai.stream.open", {round});
|
||||||
|
|
||||||
@@ -200,7 +256,7 @@ export async function runOpenAi(
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
streamMessage.append(event.delta ?? "");
|
streamMessage.append(adapter.extractTextDelta(event));
|
||||||
break;
|
break;
|
||||||
case "response.image_generation_call.in_progress":
|
case "response.image_generation_call.in_progress":
|
||||||
streamMessage.setStatus(Environment.startingImageGenText);
|
streamMessage.setStatus(Environment.startingImageGenText);
|
||||||
@@ -226,6 +282,15 @@ export async function runOpenAi(
|
|||||||
streamMessage.setStatus(Environment.finalizingImageGenText);
|
streamMessage.setStatus(Environment.finalizingImageGenText);
|
||||||
await streamMessage.flush();
|
await streamMessage.flush();
|
||||||
break;
|
break;
|
||||||
|
case "response.file_search_call.in_progress":
|
||||||
|
case "response.file_search_call.searching":
|
||||||
|
streamMessage.setStatus(Environment.getUseToolText(["file_search"]));
|
||||||
|
await streamMessage.flush();
|
||||||
|
break;
|
||||||
|
case "response.file_search_call.completed":
|
||||||
|
streamMessage.clearStatus();
|
||||||
|
await streamMessage.flush();
|
||||||
|
break;
|
||||||
case "response.code_interpreter_call.in_progress":
|
case "response.code_interpreter_call.in_progress":
|
||||||
case "response.code_interpreter_call.interpreting":
|
case "response.code_interpreter_call.interpreting":
|
||||||
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
|
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
|
||||||
@@ -239,14 +304,11 @@ export async function runOpenAi(
|
|||||||
case "response.code_interpreter_call_code.done":
|
case "response.code_interpreter_call_code.done":
|
||||||
break;
|
break;
|
||||||
case "response.output_item.added":
|
case "response.output_item.added":
|
||||||
if (event.item.type === "function_call" && event.item.name) {
|
{
|
||||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
const streamedCalls = adapter.extractStreamingToolCalls(event);
|
||||||
localToolCalls.push({
|
if (streamedCalls.length) {
|
||||||
id: openAiResponseItemCallId(item),
|
localToolCalls.push(...streamedCalls);
|
||||||
name: item.name ?? "",
|
}
|
||||||
argumentsText: item.arguments ?? "{}",
|
|
||||||
});
|
|
||||||
|
|
||||||
aiLog("info", "openai.stream.tool_call.added", {
|
aiLog("info", "openai.stream.tool_call.added", {
|
||||||
round,
|
round,
|
||||||
toolCalls: localToolCalls.map(aiLogToolCall)
|
toolCalls: localToolCalls.map(aiLogToolCall)
|
||||||
@@ -277,7 +339,7 @@ export async function runOpenAi(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "response.completed":
|
case "response.completed":
|
||||||
completedResponse = event.response as unknown as OpenAiResponseLike;
|
completedResponse = event.response as OpenAiResponseLike;
|
||||||
break;
|
break;
|
||||||
case "response.failed":
|
case "response.failed":
|
||||||
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
|
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
|
||||||
@@ -321,290 +383,190 @@ export async function runOpenAi(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const calls = collectOpenAiResponseFunctionCalls(completedResponse);
|
const calls = adapter.extractToolCalls(completedResponse);
|
||||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||||
round,
|
round,
|
||||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
calls: calls.map(call => ({
|
calls: calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
arguments: safeJsonParseObject(call.argumentsText)
|
arguments: safeJsonParseObject(call.argumentsText)
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
|
|
||||||
const toolCalls = calls.map(call => ({
|
const toolCalls = calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
argumentsText: call.argumentsText,
|
argumentsText: call.argumentsText,
|
||||||
}));
|
}));
|
||||||
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
|
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
|
||||||
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
userId: msg.from?.id,
|
||||||
|
toolCalls,
|
||||||
for (const toolResult of toolResults) {
|
streamMessage,
|
||||||
try {
|
toolContext: {
|
||||||
const raw = JSON.parse(toolResult);
|
...toolContext,
|
||||||
const res = GetNoteFileResultSchema.safeParse(raw);
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
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)),
|
toolMemory,
|
||||||
}).catch(logError);
|
adapter,
|
||||||
|
appendTargets: [toolOutputs],
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||||
|
if (uploadFilesResult.found) {
|
||||||
|
if (!uploadFilesResult.uploaded) {
|
||||||
|
const old = toolOutputs[uploadFilesResult.toolIndex];
|
||||||
|
const callId = old?.call_id;
|
||||||
|
if (uploadFilesResult.toolIndex >= 0) {
|
||||||
|
delete toolOutputs[uploadFilesResult.toolIndex];
|
||||||
|
}
|
||||||
|
if (callId) {
|
||||||
|
toolOutputs.push({
|
||||||
|
type: "function_call_output" as const,
|
||||||
|
call_id: callId,
|
||||||
|
output: "Error: " + uploadFilesResult.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuation = decideToolLoopContinuation({
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
toolCalls: calls,
|
||||||
|
});
|
||||||
|
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||||
|
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
|
||||||
|
round,
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolOutputs = calls.map((call, index) => ({
|
|
||||||
type: "function_call_output",
|
|
||||||
call_id: call.callId,
|
|
||||||
output: toolResults[index] ?? "",
|
|
||||||
}));
|
|
||||||
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
|
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
|
||||||
}
|
return {shouldContinue: true};
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
function openAiResponseContentToText(content: unknown): string {
|
if (ownsDocumentRag) {
|
||||||
if (typeof content === "string") return content;
|
await preparedDocumentRag?.cleanup().catch(logError);
|
||||||
if (!Array.isArray(content)) return "";
|
}
|
||||||
return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
|
await adapter.finalize().catch(logError);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
|
|
||||||
return messages.map((message): OpenAiCompatibleChatMessage => {
|
export type OpenAiDocumentRagContext = {
|
||||||
if (message.role === "system" || message.role === "assistant") {
|
vectorStoreIds: string[];
|
||||||
return {
|
uploadedFileIds: string[];
|
||||||
role: message.role,
|
cleanup: () => Promise<void>;
|
||||||
content: openAiResponseContentToText(message.content),
|
};
|
||||||
};
|
|
||||||
}
|
export async function prepareOpenAiDocumentRag(openAi: OpenAI, downloads: AiDownloadedFile[]): Promise<OpenAiDocumentRagContext | undefined> {
|
||||||
|
if (!downloads.length) return undefined;
|
||||||
const content = Array.isArray(message.content)
|
|
||||||
? message.content.map((part): OpenAiCompatibleContentPart => {
|
const vectorStore = await openAi.vectorStores.create({
|
||||||
if (isRecord(part) && part.type === "input_image") {
|
name: `tg-chat-bot-${Date.now()}`,
|
||||||
return {
|
description: "Temporary document RAG for a single Telegram request.",
|
||||||
type: "image_url",
|
expires_after: {
|
||||||
image_url: {url: String(part.image_url ?? "")},
|
anchor: "last_active_at",
|
||||||
};
|
days: 1,
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
msg: Message,
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
const uploadedFileIds: string[] = [];
|
||||||
|
|
||||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
|
||||||
|
|
||||||
for (const toolResult of toolResults) {
|
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(toolResult);
|
for (const download of downloads) {
|
||||||
const res = GetNoteFileResultSchema.safeParse(raw);
|
const uploaded = await openAi.files.create({
|
||||||
|
file: await toFile(download.buffer, download.fileName, {
|
||||||
if (res.success && res.data.success) {
|
type: download.mimeType ?? "application/octet-stream",
|
||||||
successGetNoteFileResult = res.data;
|
}),
|
||||||
}
|
purpose: "user_data",
|
||||||
} catch {
|
});
|
||||||
// Not every tool result is JSON.
|
uploadedFileIds.push(uploaded.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
|
const batch = await openAi.vectorStores.fileBatches.createAndPoll(vectorStore.id, {
|
||||||
await bot.sendDocument({
|
file_ids: uploadedFileIds,
|
||||||
chat_id: msg.chat.id,
|
});
|
||||||
reply_parameters: {
|
|
||||||
message_id: msg.message_id,
|
if (batch.file_counts.failed > 0) {
|
||||||
|
throw new Error(`OpenAI file_search failed to index ${batch.file_counts.failed} document(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vectorStoreIds: [vectorStore.id],
|
||||||
|
uploadedFileIds,
|
||||||
|
cleanup: async () => {
|
||||||
|
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds);
|
||||||
},
|
},
|
||||||
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
|
|
||||||
}).catch(logError);
|
|
||||||
}
|
|
||||||
|
|
||||||
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request: ChatCompletionCreateParamsStreaming = {
|
|
||||||
model: config.geminiChatTarget.model,
|
|
||||||
messages: chatMessages,
|
|
||||||
tools: getOpenAITools(),
|
|
||||||
// temperature: 0.6,
|
|
||||||
stream: true,
|
|
||||||
parallel_tool_calls: true
|
|
||||||
};
|
};
|
||||||
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
|
} catch (error) {
|
||||||
|
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds).catch(() => undefined);
|
||||||
aiLog("debug", "openai_compatible.stream.open", {round});
|
throw error;
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, fileIds: string[]): Promise<void> {
|
||||||
|
await openAi.vectorStores.delete(vectorStoreId).catch(() => undefined);
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
await openAi.files.delete(fileId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): 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] ?? "",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|||||||
+189
-325
@@ -1,41 +1,40 @@
|
|||||||
import {Message} from "typescript-telegram-bot-api";
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import {Blob} from "node:buffer";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
import {Environment} from "../common/environment";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {photoGenDir} from "../index";
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
import {collectReplyChainText, delay, logError, replyToMessage} from "../util/utils";
|
import {Environment, type OpenAiBackend} from "../common/environment.js";
|
||||||
import {MessageStore} from "../common/message-store";
|
import {delay, logError, replyToMessage} from "../util/utils.js";
|
||||||
import type {OpenAiResponseTool} from "./tool-mappers";
|
import {MessageStore} from "../common/message-store.js";
|
||||||
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers";
|
import type {OpenAiResponseTool} from "./tool-mappers.js";
|
||||||
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message";
|
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers.js";
|
||||||
import {AiDownloadedFile} from "./telegram-attachments";
|
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message.js";
|
||||||
import {getRuntimeCapabilities} from "./provider-model-runtime";
|
import {AiDownloadedFile} from "./telegram-attachments.js";
|
||||||
import {StoredAttachment} from "../model/stored-attachment";
|
import {getRuntimeCapabilities} from "./provider-model-runtime.js";
|
||||||
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
|
import {StoredAttachment} from "../model/stored-attachment.js";
|
||||||
|
import {AiChatMessage, ChatMessage} from "./chat-messages-types.js";
|
||||||
import {ListResponse, Ollama} from "ollama";
|
import {ListResponse, Ollama} from "ollama";
|
||||||
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
|
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime.js";
|
||||||
import {MessageImagePart, MessagePart} from "../common/message-part";
|
import {MessageImagePart, MessagePart} from "../common/message-part.js";
|
||||||
import {KeyedAsyncLock} from "../util/async-lock";
|
import {KeyedAsyncLock} from "../util/async-lock.js";
|
||||||
import {type AiRequestQueueTarget} from "./provider-request-queue";
|
import {type AiRequestQueueTarget} from "./provider-request-queue.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator.js";
|
||||||
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings";
|
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings.js";
|
||||||
import {
|
import {
|
||||||
isTranscribableAudioDownload,
|
isTranscribableAudioDownload,
|
||||||
resolveSpeechToTextProviderForUser,
|
resolveSpeechToTextProviderForUser,
|
||||||
transcribeSpeechDownloads
|
transcribeSpeechDownloads
|
||||||
} from "./speech-to-text";
|
} from "./speech-to-text.js";
|
||||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
|
||||||
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 {MistralChatMessage} from "./mistral-chat-message.js";
|
||||||
import {MistralChatMessage} from "./mistral-chat-message";
|
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer.js";
|
||||||
import {OllamaChatMessage} from "./ollama-chat-message";
|
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
|
||||||
import {GeminiMessage} from "./gemini-chat-message";
|
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger.js";
|
||||||
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline.js";
|
||||||
import {AiRuntimeTarget, createMistralClient, getGeminiApiMode, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store.js";
|
||||||
|
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
|
||||||
|
|
||||||
export type {Message} from "typescript-telegram-bot-api";
|
export type {Message} from "typescript-telegram-bot-api";
|
||||||
export type {AiRuntimeTarget} from "./ai-runtime-target";
|
export type {AiRuntimeTarget} from "./ai-runtime-target";
|
||||||
@@ -47,7 +46,6 @@ export type {MessageImagePart, MessagePart} from "../common/message-part";
|
|||||||
export type {OpenAIChatMessage} from "./openai-chat-message";
|
export type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
export type {MistralChatMessage} from "./mistral-chat-message";
|
export type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
export type {OllamaChatMessage} from "./ollama-chat-message";
|
export type {OllamaChatMessage} from "./ollama-chat-message";
|
||||||
export type {GeminiMessage} from "./gemini-chat-message";
|
|
||||||
export type {TelegramArtifactFile} from "./telegram-stream-message";
|
export type {TelegramArtifactFile} from "./telegram-stream-message";
|
||||||
export {TelegramStreamMessage} from "./telegram-stream-message";
|
export {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
export type {ChatRequest, ListResponse, Ollama, Tool} from "ollama";
|
export type {ChatRequest, ListResponse, Ollama, Tool} from "ollama";
|
||||||
@@ -63,8 +61,6 @@ export type {
|
|||||||
ChatCompletionCreateParamsStreaming,
|
ChatCompletionCreateParamsStreaming,
|
||||||
ChatCompletionMessageParam,
|
ChatCompletionMessageParam,
|
||||||
} from "openai/resources/chat/completions";
|
} from "openai/resources/chat/completions";
|
||||||
export type {GenerateContentParameters} from "@google/genai";
|
|
||||||
|
|
||||||
export const TELEGRAM_LIMIT = 4096;
|
export const TELEGRAM_LIMIT = 4096;
|
||||||
export const MAX_TOOL_ROUNDS = 40;
|
export const MAX_TOOL_ROUNDS = 40;
|
||||||
export const MAX_IDENTICAL_TOOL_CALLS = 1;
|
export const MAX_IDENTICAL_TOOL_CALLS = 1;
|
||||||
@@ -75,13 +71,19 @@ export const MAX_OLLAMA_CONTEXT_SIZE = 262144;
|
|||||||
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
|
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
|
||||||
export const toolResourceLocks = new KeyedAsyncLock();
|
export const toolResourceLocks = new KeyedAsyncLock();
|
||||||
|
|
||||||
|
function photoGenDir(): string {
|
||||||
|
return path.join(Environment.DATA_PATH, "cache", "photo", "gen");
|
||||||
|
}
|
||||||
|
|
||||||
export type UnifiedRunOptions = {
|
export type UnifiedRunOptions = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
msg: Message;
|
msg: Message;
|
||||||
|
requestId?: string;
|
||||||
isGuestMsg?: boolean;
|
isGuestMsg?: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
think?: Think;
|
think?: Think;
|
||||||
|
synthesizeSpeechResponse?: boolean;
|
||||||
responseLanguage?: UserAiResponseLanguage;
|
responseLanguage?: UserAiResponseLanguage;
|
||||||
contextSize?: number;
|
contextSize?: number;
|
||||||
voiceMode?: UserAiVoiceMode;
|
voiceMode?: UserAiVoiceMode;
|
||||||
@@ -99,7 +101,7 @@ export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
|||||||
export type JsonObject = { [key: string]: JsonValue };
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
// SDKs sometimes expose loose object-shaped payloads. Keep the looseness at the boundary,
|
// SDKs sometimes expose loose object-shaped payloads. Keep the looseness at the boundary,
|
||||||
// but do not spread `unknown` through the rest of the code.
|
// but do not spread it through the rest of the code.
|
||||||
export type LooseRecord = Record<string, JsonValue | object | undefined>;
|
export type LooseRecord = Record<string, JsonValue | object | undefined>;
|
||||||
|
|
||||||
export type OpenAiResponsesFunctionCall = {
|
export type OpenAiResponsesFunctionCall = {
|
||||||
@@ -181,22 +183,6 @@ export type OpenAiResponseLike = {
|
|||||||
output_text?: string;
|
output_text?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeminiFunctionCallLike = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
args?: JsonObject;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GeminiResponsePartLike = {
|
|
||||||
text?: string;
|
|
||||||
functionCall?: GeminiFunctionCallLike;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GeminiResponseLike = {
|
|
||||||
functionCalls?: GeminiFunctionCallLike[];
|
|
||||||
candidates?: Array<{ content?: { parts?: GeminiResponsePartLike[] } }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OpenAiCompatibleContentPart =
|
export type OpenAiCompatibleContentPart =
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
| { type: "image_url"; image_url: { url: string } };
|
| { type: "image_url"; image_url: { url: string } };
|
||||||
@@ -213,13 +199,11 @@ export type OpenAiChatCompletionStreamChunkLike = {
|
|||||||
|
|
||||||
export type AsyncIterableStream<T> = AsyncIterable<T>;
|
export type AsyncIterableStream<T> = AsyncIterable<T>;
|
||||||
|
|
||||||
export type GeminiGenerationRequest = GenerateContentParameters;
|
export function isRecord(value: BoundaryValue): value is LooseRecord {
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is LooseRecord {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toJsonValue(value: unknown): JsonValue | undefined {
|
export function toJsonValue(value: BoundaryValue): JsonValue | undefined {
|
||||||
if (value === null) return null;
|
if (value === null) return null;
|
||||||
|
|
||||||
switch (typeof value) {
|
switch (typeof value) {
|
||||||
@@ -245,16 +229,16 @@ export function toJsonValue(value: unknown): JsonValue | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toJsonObject(value: unknown): JsonObject | undefined {
|
export function toJsonObject(value: BoundaryValue): JsonObject | undefined {
|
||||||
const json = toJsonValue(value);
|
const json = toJsonValue(value);
|
||||||
return json !== null && typeof json === "object" && !Array.isArray(json) ? json : undefined;
|
return json !== null && typeof json === "object" && !Array.isArray(json) ? json : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asOptionalString(value: unknown): string | undefined {
|
export function asOptionalString(value: BoundaryValue): string | undefined {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAbortError(error: unknown): boolean {
|
export function isAbortError(error: BoundaryValue): boolean {
|
||||||
return error instanceof Error ? error.message.includes("Aborted") : String(error).includes("Aborted");
|
return error instanceof Error ? error.message.includes("Aborted") : String(error).includes("Aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +251,7 @@ export type RuntimeConfigSnapshot = {
|
|||||||
useSystemPrompt: boolean;
|
useSystemPrompt: boolean;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
rankerToolPrompt?: string;
|
rankerToolPrompt?: string;
|
||||||
|
toolRankerFallbackPolicy: ToolRankerFallbackPolicy;
|
||||||
|
|
||||||
ollamaChatTarget: AiRuntimeTarget;
|
ollamaChatTarget: AiRuntimeTarget;
|
||||||
ollamaToolRankerTarget?: AiRuntimeTarget;
|
ollamaToolRankerTarget?: AiRuntimeTarget;
|
||||||
@@ -283,14 +268,13 @@ export type RuntimeConfigSnapshot = {
|
|||||||
ollamaRagMaxArchiveFiles: number;
|
ollamaRagMaxArchiveFiles: number;
|
||||||
ollamaRagMaxArchiveBytes: number;
|
ollamaRagMaxArchiveBytes: number;
|
||||||
ollamaRagMaxArchiveDepth: number;
|
ollamaRagMaxArchiveDepth: number;
|
||||||
|
|
||||||
geminiChatTarget: AiRuntimeTarget;
|
|
||||||
geminiImageTarget: AiRuntimeTarget;
|
|
||||||
|
|
||||||
mistralChatTarget: AiRuntimeTarget;
|
mistralChatTarget: AiRuntimeTarget;
|
||||||
|
mistralToolRankerTarget?: AiRuntimeTarget;
|
||||||
|
|
||||||
openAiChatTarget: AiRuntimeTarget;
|
openAiChatTarget: AiRuntimeTarget;
|
||||||
openAiImageTarget: AiRuntimeTarget;
|
openAiImageTarget: AiRuntimeTarget;
|
||||||
|
openAiToolRankerTarget?: AiRuntimeTarget;
|
||||||
|
openAiBackend: OpenAiBackend;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||||
@@ -300,6 +284,7 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
|||||||
|
|
||||||
systemPrompt: Environment.SYSTEM_PROMPT,
|
systemPrompt: Environment.SYSTEM_PROMPT,
|
||||||
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
|
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
|
||||||
|
toolRankerFallbackPolicy: Environment.TOOL_RANKER_FALLBACK_POLICY,
|
||||||
|
|
||||||
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
|
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
|
||||||
ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "toolRank"),
|
ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "toolRank"),
|
||||||
@@ -317,16 +302,20 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
|||||||
ollamaRagMaxArchiveBytes: Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES,
|
ollamaRagMaxArchiveBytes: Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES,
|
||||||
ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH,
|
ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH,
|
||||||
|
|
||||||
geminiChatTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "chat"),
|
|
||||||
geminiImageTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "vision"),
|
|
||||||
|
|
||||||
mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"),
|
mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"),
|
||||||
|
mistralToolRankerTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "toolRank"),
|
||||||
|
|
||||||
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
|
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
|
||||||
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
|
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
|
||||||
|
openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"),
|
||||||
|
openAiBackend: Environment.OPENAI_BACKEND,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOpenAiCompatibleBackend(config: RuntimeConfigSnapshot): boolean {
|
||||||
|
return config.openAiBackend === "compatible";
|
||||||
|
}
|
||||||
|
|
||||||
export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
|
export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
|
||||||
if (part.imageParts?.length) return part.imageParts;
|
if (part.imageParts?.length) return part.imageParts;
|
||||||
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
|
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
|
||||||
@@ -336,33 +325,10 @@ export function openAiImageDataUrl(image: MessageImagePart): string {
|
|||||||
return `data:${image.mimeType || "image/jpeg"};base64,${image.data}`;
|
return `data:${image.mimeType || "image/jpeg"};base64,${image.data}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function geminiAudioMimeType(mimeType: string | undefined): string {
|
|
||||||
const normalized = mimeType?.toLowerCase();
|
|
||||||
switch (normalized) {
|
|
||||||
case "audio/wav":
|
|
||||||
case "audio/mp3":
|
|
||||||
case "audio/aiff":
|
|
||||||
case "audio/aac":
|
|
||||||
case "audio/ogg":
|
|
||||||
case "audio/flac":
|
|
||||||
case "audio/mpeg":
|
|
||||||
case "audio/m4a":
|
|
||||||
case "audio/l16":
|
|
||||||
case "audio/opus":
|
|
||||||
case "audio/alaw":
|
|
||||||
case "audio/mulaw":
|
|
||||||
return normalized;
|
|
||||||
default:
|
|
||||||
return "audio/wav";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function snapshotModel(provider: AiProvider, config: RuntimeConfigSnapshot): string {
|
export function snapshotModel(provider: AiProvider, config: RuntimeConfigSnapshot): string {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return config.ollamaChatTarget.model;
|
return config.ollamaChatTarget.model;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return config.geminiChatTarget.model;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return config.mistralChatTarget.model;
|
return config.mistralChatTarget.model;
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
@@ -382,12 +348,27 @@ export function providerTargets(provider: AiProvider, config: RuntimeConfigSnaps
|
|||||||
config.ollamaAudioTarget,
|
config.ollamaAudioTarget,
|
||||||
config.ollamaDocumentsTarget
|
config.ollamaDocumentsTarget
|
||||||
].filter((target): target is AiRuntimeTarget => !!target);
|
].filter((target): target is AiRuntimeTarget => !!target);
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return [config.geminiChatTarget];
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return [config.mistralChatTarget];
|
return [
|
||||||
|
config.mistralChatTarget,
|
||||||
|
config.mistralToolRankerTarget,
|
||||||
|
].filter((target): target is AiRuntimeTarget => !!target);
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
return [config.openAiChatTarget];
|
return [
|
||||||
|
config.openAiChatTarget,
|
||||||
|
config.openAiToolRankerTarget,
|
||||||
|
].filter((target): target is AiRuntimeTarget => !!target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerChatTarget(provider: AiProvider, config: RuntimeConfigSnapshot): AiRuntimeTarget {
|
||||||
|
switch (provider) {
|
||||||
|
case AiProvider.OLLAMA:
|
||||||
|
return config.ollamaChatTarget;
|
||||||
|
case AiProvider.MISTRAL:
|
||||||
|
return config.mistralChatTarget;
|
||||||
|
case AiProvider.OPENAI:
|
||||||
|
return config.openAiChatTarget;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,8 +376,6 @@ export function providerName(provider: AiProvider): AiProviderName {
|
|||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return "ollama";
|
return "ollama";
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return "gemini";
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return "mistral";
|
return "mistral";
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
@@ -408,10 +387,14 @@ export function buildSystemInstruction(
|
|||||||
config: RuntimeConfigSnapshot,
|
config: RuntimeConfigSnapshot,
|
||||||
responseLanguage: UserAiResponseLanguage,
|
responseLanguage: UserAiResponseLanguage,
|
||||||
includePythonToolPrompt: boolean,
|
includePythonToolPrompt: boolean,
|
||||||
|
additions?: string | null,
|
||||||
|
memoryInstruction?: string | null,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||||
|
additions?.trim() ? additions.trim() : null,
|
||||||
|
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
|
||||||
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
||||||
].filter(Boolean).join("\n\n");
|
].filter(Boolean).join("\n\n");
|
||||||
}
|
}
|
||||||
@@ -442,8 +425,6 @@ export function resolveAiRequestQueueTarget(
|
|||||||
if (hasAudioAttachmentKind(requestedAttachmentKinds)) return config.ollamaAudioTarget;
|
if (hasAudioAttachmentKind(requestedAttachmentKinds)) return config.ollamaAudioTarget;
|
||||||
if (requestedAttachmentKinds.has("image")) return config.ollamaVisionTarget;
|
if (requestedAttachmentKinds.has("image")) return config.ollamaVisionTarget;
|
||||||
return options.think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
|
return options.think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
|
||||||
case AiProvider.GEMINI:
|
|
||||||
return config.geminiChatTarget;
|
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return config.mistralChatTarget;
|
return config.mistralChatTarget;
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
@@ -543,13 +524,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, limit: number = 1): Promise<StoredAttachment[]> {
|
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 40): 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 < limit; i++) {
|
for (let i = 0; current && i < limit; i++) {
|
||||||
for (const attachment of current?.attachments ?? []) {
|
for (const attachment of filterUserInputStoredAttachments(current?.attachments ?? [])) {
|
||||||
const key = [
|
const key = [
|
||||||
attachment.kind,
|
attachment.kind,
|
||||||
attachment.fileUniqueId || attachment.fileId,
|
attachment.fileUniqueId || attachment.fileId,
|
||||||
@@ -569,13 +550,6 @@ export async function hasStoredReplyChainImage(msg: Message): Promise<boolean> {
|
|||||||
const attachments = await collectStoredReplyChainAttachments(msg);
|
const attachments = await collectStoredReplyChainAttachments(msg);
|
||||||
if (attachments.some(attachment => attachment.kind === "image")) return true;
|
if (attachments.some(attachment => attachment.kind === "image")) return true;
|
||||||
|
|
||||||
let current = await MessageStore.get(msg.chat.id, msg.message_id);
|
|
||||||
|
|
||||||
for (let i = 0; current && i < 40; i++) {
|
|
||||||
if (current.photoMaxSizeFilePath?.length) return true;
|
|
||||||
current = await MessageStore.get(current.chatId, current.replyToMessageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,7 +622,6 @@ 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),
|
||||||
@@ -712,7 +685,7 @@ export function parseToolArgumentsObject(argumentsText?: string): ToolArgumentsP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorMessage(error: unknown): string {
|
export function errorMessage(error: BoundaryValue): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,191 +776,30 @@ export function normalizeOllamaToolCalls(calls: readonly OllamaToolCallLike[] =
|
|||||||
.filter(call => !!call.name);
|
.filter(call => !!call.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOpenAiResponseMessage(part: MessagePart, getContent: (part: MessagePart) => string): OpenAIChatMessage {
|
|
||||||
const content: Array<ResponseInputContent | any> = [{
|
|
||||||
type: part.bot ? "output_text" : "input_text",
|
|
||||||
text: getContent(part),
|
|
||||||
}];
|
|
||||||
|
|
||||||
if (!part.bot) {
|
|
||||||
for (const image of getMessageImageParts(part)) {
|
|
||||||
content.push({type: "input_image", image_url: openAiImageDataUrl(image), detail: "auto"});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {role: part.bot ? "assistant" : "user", content, type: "message"};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildGeminiMessage(part: MessagePart, getContent: (part: MessagePart) => string): GeminiMessage {
|
|
||||||
const parts: GeminiMessage["parts"] = [{text: getContent(part)}];
|
|
||||||
|
|
||||||
if (!part.bot) {
|
|
||||||
for (const image of getMessageImageParts(part)) {
|
|
||||||
parts.push({
|
|
||||||
inlineData: {
|
|
||||||
data: image.data,
|
|
||||||
mimeType: image.mimeType || "image/jpeg",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioParts = part.audioParts?.length
|
|
||||||
? part.audioParts
|
|
||||||
: (part.audios ?? []).map(data => ({data, mimeType: "audio/wav"}));
|
|
||||||
|
|
||||||
for (const audio of audioParts) {
|
|
||||||
parts.push({
|
|
||||||
inlineData: {
|
|
||||||
data: audio.data,
|
|
||||||
mimeType: geminiAudioMimeType(audio.mimeType),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const videoNote of part.videoNotes ?? []) {
|
|
||||||
parts.push({
|
|
||||||
inlineData: {
|
|
||||||
data: videoNote,
|
|
||||||
mimeType: "audio/wav",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: part.bot ? "model" : "user",
|
|
||||||
parts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectTextMessages(
|
export async function collectTextMessages(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
textOverride: string,
|
textOverride: string,
|
||||||
provider: AiProvider,
|
provider: AiProvider,
|
||||||
downloads: AiDownloadedFile[],
|
downloads: AiDownloadedFile[],
|
||||||
config: RuntimeConfigSnapshot,
|
config: RuntimeConfigSnapshot,
|
||||||
|
runtimeTarget: AiRuntimeTarget,
|
||||||
responseLanguage: UserAiResponseLanguage,
|
responseLanguage: UserAiResponseLanguage,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
chatMessages: AiChatMessage[];
|
chatMessages: AiChatMessage[];
|
||||||
imageCount: number
|
imageCount: number
|
||||||
}> {
|
}> {
|
||||||
const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id);
|
|
||||||
const messageParts = await collectReplyChainText({triggerMsg: storedMsg, downloads: downloads});
|
|
||||||
|
|
||||||
const cleanTextOverride = textOverride?.trim();
|
|
||||||
if (messageParts.length && cleanTextOverride) {
|
|
||||||
const latest = messageParts[0];
|
|
||||||
if (!latest.bot) latest.content = textOverride.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ordered = messageParts.reverse();
|
|
||||||
const imageCount = ordered.reduce((sum, p) => sum + (p.bot ? 0 : getMessageImageParts(p).length), 0);
|
|
||||||
const includePythonToolPrompt = Environment.ENABLE_PYTHON_INTERPRETER && msg.from?.id === Environment.CREATOR_ID;
|
const includePythonToolPrompt = Environment.ENABLE_PYTHON_INTERPRETER && msg.from?.id === Environment.CREATOR_ID;
|
||||||
const systemInstruction = buildSystemInstruction(config, responseLanguage, includePythonToolPrompt);
|
const snapshot = await buildConversationSnapshot(
|
||||||
|
msg,
|
||||||
|
textOverride,
|
||||||
|
downloads,
|
||||||
|
config,
|
||||||
|
runtimeTarget,
|
||||||
|
responseLanguage,
|
||||||
|
includePythonToolPrompt,
|
||||||
|
);
|
||||||
|
|
||||||
const getContent = (part: MessagePart): string => {
|
return serializeConversationSnapshot(snapshot, provider, Environment.USE_NAMES_IN_PROMPT);
|
||||||
if (part.bot) return part.content;
|
|
||||||
|
|
||||||
const userInfo = [
|
|
||||||
"[user_info]:",
|
|
||||||
`name: ${part.name}`,
|
|
||||||
`username: @${part.userName}`,
|
|
||||||
""
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const finalContent = [
|
|
||||||
part.content
|
|
||||||
];
|
|
||||||
|
|
||||||
if (Environment.USE_NAMES_IN_PROMPT) {
|
|
||||||
finalContent.unshift(userInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalContent.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (provider === AiProvider.OPENAI) {
|
|
||||||
const messages: OpenAIChatMessage[] = ordered.map(part => buildOpenAiResponseMessage(part, getContent));
|
|
||||||
|
|
||||||
if (systemInstruction) {
|
|
||||||
messages.unshift({role: "system", content: systemInstruction, type: "message"});
|
|
||||||
}
|
|
||||||
return {chatMessages: messages, imageCount};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === AiProvider.MISTRAL) {
|
|
||||||
const messages: MistralChatMessage[] = ordered.map(part => {
|
|
||||||
if (part.bot) {
|
|
||||||
return {
|
|
||||||
role: "assistant",
|
|
||||||
content: [{type: "text", text: getContent(part)}]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{type: "text", text: getContent(part)},
|
|
||||||
...getMessageImageParts(part).map(p => {
|
|
||||||
return {
|
|
||||||
type: "image_url" as const,
|
|
||||||
imageUrl: `data:${p.mimeType || "image/jpeg"};base64,${p.data}`
|
|
||||||
};
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (systemInstruction) {
|
|
||||||
messages.unshift({role: "system", content: systemInstruction});
|
|
||||||
}
|
|
||||||
return {chatMessages: messages, imageCount};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === AiProvider.OLLAMA) {
|
|
||||||
const messages: OllamaChatMessage[] = ordered.map(part => ({
|
|
||||||
role: part.bot ? "assistant" : "user",
|
|
||||||
content: getContent(part),
|
|
||||||
images: part.bot ? undefined : part.images,
|
|
||||||
imageParts: part.imageParts,
|
|
||||||
audios: part.audios,
|
|
||||||
audioParts: part.audioParts,
|
|
||||||
videos: part.videos,
|
|
||||||
videoNotes: part.videoNotes
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (systemInstruction) {
|
|
||||||
messages.unshift({
|
|
||||||
role: "system",
|
|
||||||
content: systemInstruction
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {chatMessages: messages, imageCount};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === AiProvider.GEMINI) {
|
|
||||||
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
|
|
||||||
const messages: OpenAIChatMessage[] = ordered.map(part => buildOpenAiResponseMessage(part, getContent));
|
|
||||||
if (systemInstruction) {
|
|
||||||
messages.unshift({role: "system", content: systemInstruction, type: "message"});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {chatMessages: messages, imageCount};
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages: GeminiMessage[] = ordered.map(part => buildGeminiMessage(part, getContent));
|
|
||||||
if (systemInstruction) {
|
|
||||||
messages.unshift({
|
|
||||||
role: "user",
|
|
||||||
parts: [{text: systemInstruction}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {chatMessages: messages, imageCount};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {chatMessages: [], imageCount: -1};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transcribeAudioIfNeeded(provider: AiProvider, userId: number | undefined, downloads: AiDownloadedFile[], message: TelegramStreamMessage, signal: AbortSignal): Promise<string> {
|
export async function transcribeAudioIfNeeded(provider: AiProvider, userId: number | undefined, downloads: AiDownloadedFile[], message: TelegramStreamMessage, signal: AbortSignal): Promise<string> {
|
||||||
@@ -1026,7 +838,7 @@ export async function transcribeAudioIfNeeded(provider: AiProvider, userId: numb
|
|||||||
});
|
});
|
||||||
return transcript;
|
return transcript;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e});
|
aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1043,20 +855,11 @@ export function stripAudioFromRunnerMessages(parts: AiChatMessage[]): void {
|
|||||||
if ("videoNotes" in part) {
|
if ("videoNotes" in part) {
|
||||||
delete part.videoNotes;
|
delete part.videoNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("parts" in part && Array.isArray(part.parts)) {
|
|
||||||
part.parts = part.parts.filter(geminiPart => {
|
|
||||||
if (!("inlineData" in geminiPart)) return true;
|
|
||||||
const mimeType = geminiPart.inlineData.mimeType.toLowerCase();
|
|
||||||
return !mimeType.startsWith("audio/") && !mimeType.startsWith("video/");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendTranscriptToChatMessages(
|
export function appendTranscriptToChatMessages(
|
||||||
chatMessages: AiChatMessage[],
|
chatMessages: AiChatMessage[],
|
||||||
provider: AiProvider,
|
|
||||||
transcript: string,
|
transcript: string,
|
||||||
): void {
|
): void {
|
||||||
const lastUser = [...chatMessages].reverse().find(message => "role" in message && message.role === "user");
|
const lastUser = [...chatMessages].reverse().find(message => "role" in message && message.role === "user");
|
||||||
@@ -1065,11 +868,6 @@ export function appendTranscriptToChatMessages(
|
|||||||
const text = transcript.trim();
|
const text = transcript.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
if (provider === AiProvider.GEMINI && "parts" in lastUser && Array.isArray(lastUser.parts)) {
|
|
||||||
lastUser.parts.push({text});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!("content" in lastUser)) return;
|
if (!("content" in lastUser)) return;
|
||||||
|
|
||||||
if (typeof lastUser.content === "string") {
|
if (typeof lastUser.content === "string") {
|
||||||
@@ -1085,8 +883,7 @@ export function appendTranscriptToChatMessages(
|
|||||||
// narrows it to the Chat Completions union (`text | image_url | thinking`),
|
// narrows it to the Chat Completions union (`text | image_url | thinking`),
|
||||||
// which makes comparisons with Responses parts (`input_text | input_image`)
|
// which makes comparisons with Responses parts (`input_text | input_image`)
|
||||||
// look impossible even though this is a runtime mixed-provider guard.
|
// look impossible even though this is a runtime mixed-provider guard.
|
||||||
const record: Record<string, unknown> = part;
|
const partType = (part as {type?: string}).type;
|
||||||
const partType = record["type"];
|
|
||||||
|
|
||||||
return partType === "input_text" || partType === "input_image";
|
return partType === "input_text" || partType === "input_image";
|
||||||
});
|
});
|
||||||
@@ -1109,8 +906,8 @@ export async function deleteMistralLibrary(libraryId: string | undefined, target
|
|||||||
await mistralAi.beta.libraries.delete({libraryId});
|
await mistralAi.beta.libraries.delete({libraryId});
|
||||||
aiLog("success", "mistral.library.delete.done", {libraryId, duration: aiLogDuration(startedAt)});
|
aiLog("success", "mistral.library.delete.done", {libraryId, duration: aiLogDuration(startedAt)});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e});
|
aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||||
logError(e);
|
logError(e instanceof Error ? e : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,7 +1058,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
|
|||||||
aiLog("error", "mistral.documents.prepare.failed", {
|
aiLog("error", "mistral.documents.prepare.failed", {
|
||||||
libraryId,
|
libraryId,
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: e,
|
error: e instanceof Error ? e : String(e),
|
||||||
});
|
});
|
||||||
await deleteMistralLibrary(libraryId, target);
|
await deleteMistralLibrary(libraryId, target);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -1269,6 +1066,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
|
userId: number | undefined | null,
|
||||||
toolCall: ToolCallData,
|
toolCall: ToolCallData,
|
||||||
message: TelegramStreamMessage,
|
message: TelegramStreamMessage,
|
||||||
context: ToolRuntimeContext,
|
context: ToolRuntimeContext,
|
||||||
@@ -1298,7 +1096,7 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawResult = await executeToolCall(toolCall.name, parsedArgs.args, context);
|
const rawResult = await executeToolCall(userId, toolCall.name, parsedArgs.args, context);
|
||||||
const result = stringifyToolExecutionResult(rawResult);
|
const result = stringifyToolExecutionResult(rawResult);
|
||||||
|
|
||||||
await sendToolArtifacts(toolCall, result, message);
|
await sendToolArtifacts(toolCall, result, message);
|
||||||
@@ -1311,35 +1109,47 @@ export async function executeTool(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error instanceof Error ? error : String(error))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = toolFailureResult("execution_failed", errorMessage(error));
|
const result = toolFailureResult("execution_failed", errorMessage(error instanceof Error ? error : String(error)));
|
||||||
|
|
||||||
aiLog("error", "tool.failed.returned_to_model", {
|
aiLog("error", "tool.failed.returned_to_model", {
|
||||||
name: toolCall.name,
|
name: toolCall.name,
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error,
|
error: error instanceof Error ? error : String(error),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolResourceKeys(toolCall: ToolCallData): string[] {
|
export function toolResourceKeys(toolCall: ToolCallData, userId?: number | undefined | null): string[] {
|
||||||
const args = safeJsonParseObject(toolCall.argumentsText);
|
const args = safeJsonParseObject(toolCall.argumentsText);
|
||||||
const pathValue = typeof args.path === "string" ? args.path : undefined;
|
const pathValue = typeof args.path === "string" ? args.path : undefined;
|
||||||
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
|
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
|
||||||
const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined;
|
const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined;
|
||||||
|
const memoryScope = toolCall.name.endsWith("_user_info") ? "user"
|
||||||
|
: toolCall.name.endsWith("_system_info") ? "system"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
switch (toolCall.name) {
|
switch (toolCall.name) {
|
||||||
|
case "read_user_info":
|
||||||
|
case "read_system_info":
|
||||||
case "get_datetime":
|
case "get_datetime":
|
||||||
case "web_search":
|
case "web_search":
|
||||||
case "get_weather":
|
case "get_weather":
|
||||||
case "read_file":
|
case "read_file":
|
||||||
case "list_directory":
|
case "list_directory":
|
||||||
return [];
|
return [];
|
||||||
|
case "add_user_info":
|
||||||
|
case "add_system_info":
|
||||||
|
case "remove_user_info":
|
||||||
|
case "remove_system_info":
|
||||||
|
case "replace_user_info":
|
||||||
|
case "replace_system_info":
|
||||||
|
return userId && memoryScope ? [`memory:${userId}:${memoryScope}`] : [];
|
||||||
case "create_file":
|
case "create_file":
|
||||||
case "create_directory":
|
case "create_directory":
|
||||||
case "update_file":
|
case "update_file":
|
||||||
@@ -1367,16 +1177,18 @@ export async function runWithToolLocks<T>(keys: string[], task: () => Promise<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function executeScheduledTool(
|
export async function executeScheduledTool(
|
||||||
|
userId: number | undefined | null,
|
||||||
toolCall: ToolCallData,
|
toolCall: ToolCallData,
|
||||||
message: TelegramStreamMessage,
|
message: TelegramStreamMessage,
|
||||||
context: ToolRuntimeContext,
|
context: ToolRuntimeContext,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const keys = toolResourceKeys(toolCall);
|
const keys = toolResourceKeys(toolCall, userId);
|
||||||
if (!keys.length) return executeTool(toolCall, message, context);
|
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
||||||
return runWithToolLocks(keys, () => executeTool(toolCall, message, context));
|
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeToolBatch(
|
export async function executeToolBatch(
|
||||||
|
userId: number | undefined | null,
|
||||||
toolCalls: ToolCallData[],
|
toolCalls: ToolCallData[],
|
||||||
message: TelegramStreamMessage,
|
message: TelegramStreamMessage,
|
||||||
context: ToolRuntimeContext,
|
context: ToolRuntimeContext,
|
||||||
@@ -1417,7 +1229,7 @@ export async function executeToolBatch(
|
|||||||
message.setStatus(Environment.getUseToolText(statusCalls));
|
message.setStatus(Environment.getUseToolText(statusCalls));
|
||||||
await message.flush();
|
await message.flush();
|
||||||
|
|
||||||
const resultText = await executeScheduledTool(toolCall, message, context);
|
const resultText = await executeScheduledTool(userId, toolCall, message, context);
|
||||||
|
|
||||||
memory.set(signature, {
|
memory.set(signature, {
|
||||||
count: (previous?.count ?? 0) + 1,
|
count: (previous?.count ?? 0) + 1,
|
||||||
@@ -1451,6 +1263,33 @@ export async function executeToolBatch(
|
|||||||
message.setStatus(Environment.getUseToolText(statusCalls));
|
message.setStatus(Environment.getUseToolText(statusCalls));
|
||||||
await message.flush();
|
await message.flush();
|
||||||
|
|
||||||
|
const finishedAt = new Date().toISOString();
|
||||||
|
await Promise.all(results.map(async (resultText, index) => {
|
||||||
|
const toolCall = toolCalls[index];
|
||||||
|
if (!toolCall) return;
|
||||||
|
|
||||||
|
message.recordToolExecution({
|
||||||
|
toolName: toolCall.name,
|
||||||
|
callId: toolCall.id,
|
||||||
|
argumentsText: toolCall.argumentsText,
|
||||||
|
resultChars: resultText.length,
|
||||||
|
startedAt: new Date(startedAt).toISOString(),
|
||||||
|
finishedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment = await persistToolResultArtifactAttachment({
|
||||||
|
toolCall,
|
||||||
|
resultText,
|
||||||
|
chatId: message.sourceChatId(),
|
||||||
|
messageId: message.sourceMessageId(),
|
||||||
|
});
|
||||||
|
await message.storeInternalAttachment(attachment);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
aiLog("success", "tool.batch.done", {
|
aiLog("success", "tool.batch.done", {
|
||||||
count: toolCalls.length,
|
count: toolCalls.length,
|
||||||
uniqueCount: statusCalls.length,
|
uniqueCount: statusCalls.length,
|
||||||
@@ -1462,7 +1301,7 @@ export async function executeToolBatch(
|
|||||||
aiLog("error", "tool.batch.failed", {
|
aiLog("error", "tool.batch.failed", {
|
||||||
count: toolCalls.length,
|
count: toolCalls.length,
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: e,
|
error: e instanceof Error ? e : String(e),
|
||||||
});
|
});
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
@@ -1479,7 +1318,7 @@ export function appendOllamaToolResults(messages: ChatMessage[], calls: ToolCall
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyToolExecutionResult(result: unknown): string {
|
export function stringifyToolExecutionResult(result: BoundaryValue): string {
|
||||||
if (typeof result === "string") return result;
|
if (typeof result === "string") return result;
|
||||||
const json = JSON.stringify(toJsonValue(result) ?? String(result));
|
const json = JSON.stringify(toJsonValue(result) ?? String(result));
|
||||||
return json ?? String(result);
|
return json ?? String(result);
|
||||||
@@ -1487,7 +1326,7 @@ export function stringifyToolExecutionResult(result: unknown): string {
|
|||||||
|
|
||||||
export type ToolExecutionMemory = Map<string, { count: number; result: string }>;
|
export type ToolExecutionMemory = Map<string, { count: number; result: string }>;
|
||||||
|
|
||||||
export function stableJsonStringify(value: unknown): string {
|
export function stableJsonStringify(value: BoundaryValue): string {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return `[${value.map(stableJsonStringify).join(",")}]`;
|
return `[${value.map(stableJsonStringify).join(",")}]`;
|
||||||
}
|
}
|
||||||
@@ -1602,14 +1441,14 @@ export type NormalizedRouterPlan = {
|
|||||||
m: string; // Missing
|
m: string; // Missing
|
||||||
};
|
};
|
||||||
|
|
||||||
export function toolSchemaName(tool: unknown): string | undefined {
|
export function toolSchemaName(tool: BoundaryValue): string | undefined {
|
||||||
if (!isRecord(tool)) return undefined;
|
if (!isRecord(tool)) return undefined;
|
||||||
const fn = isRecord(tool.function) ? tool.function : undefined;
|
const fn = isRecord(tool.function) ? tool.function : undefined;
|
||||||
const directName = fn?.name ?? tool.name;
|
const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
|
||||||
return asOptionalString(directName);
|
return asOptionalString(directName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolSchemaNames(tool: unknown): string[] {
|
export function toolSchemaNames(tool: BoundaryValue): string[] {
|
||||||
if (!isRecord(tool)) return [];
|
if (!isRecord(tool)) return [];
|
||||||
|
|
||||||
if (Array.isArray(tool.functionDeclarations)) {
|
if (Array.isArray(tool.functionDeclarations)) {
|
||||||
@@ -1622,13 +1461,17 @@ export function toolSchemaNames(tool: unknown): string[] {
|
|||||||
return name ? [name] : [];
|
return name ? [name] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allToolSchemaNames(tools: readonly unknown[]): string[] {
|
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] {
|
||||||
return [...new Set(tools.flatMap(toolSchemaNames))];
|
return [...new Set(tools.flatMap(toolSchemaNames))];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot): Array<OpenAiResponseTool | LooseRecord> {
|
export function getOpenAIResponsesToolsWithImage(
|
||||||
return [
|
config: RuntimeConfigSnapshot,
|
||||||
...getOpenAIResponsesTools(),
|
forCreator?: boolean,
|
||||||
|
vectorStoreIds: string[] = [],
|
||||||
|
): Array<OpenAiResponseTool | LooseRecord> {
|
||||||
|
const tools: Array<OpenAiResponseTool | LooseRecord> = [
|
||||||
|
...getOpenAIResponsesTools(forCreator),
|
||||||
getOpenAICodeInterpreterTool(),
|
getOpenAICodeInterpreterTool(),
|
||||||
{
|
{
|
||||||
type: "image_generation",
|
type: "image_generation",
|
||||||
@@ -1638,8 +1481,17 @@ export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot):
|
|||||||
output_format: "png",
|
output_format: "png",
|
||||||
partial_images: OPENAI_IMAGE_PARTIALS,
|
partial_images: OPENAI_IMAGE_PARTIALS,
|
||||||
},
|
},
|
||||||
{type: "web_search"}
|
{type: "web_search"},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (vectorStoreIds.length) {
|
||||||
|
tools.unshift({
|
||||||
|
type: "file_search",
|
||||||
|
vector_store_ids: vectorStoreIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectOpenAiResponseText(response: OpenAiResponseLike): string {
|
export function collectOpenAiResponseText(response: OpenAiResponseLike): string {
|
||||||
@@ -1667,7 +1519,7 @@ export type OpenAiCodeInterpreterCall = {
|
|||||||
code: string | null;
|
code: string | null;
|
||||||
containerId: string;
|
containerId: string;
|
||||||
status: string;
|
status: string;
|
||||||
outputs: Array<{type?: "logs" | "image"; logs?: string; url?: string}>;
|
outputs: Array<{ type?: "logs" | "image"; logs?: string; url?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] {
|
export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] {
|
||||||
@@ -1677,7 +1529,7 @@ export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiRespon
|
|||||||
id: item.id!,
|
id: item.id!,
|
||||||
code: item.code ?? null,
|
code: item.code ?? null,
|
||||||
containerId: item.container_id!,
|
containerId: item.container_id!,
|
||||||
status: item.status ?? "unknown",
|
status: item.status ?? "unrecognized",
|
||||||
outputs: Array.isArray(item.outputs) ? item.outputs : [],
|
outputs: Array.isArray(item.outputs) ? item.outputs : [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1688,11 +1540,16 @@ export function collectOpenAiResponseImages(response: OpenAiResponseLike): strin
|
|||||||
.map(item => item.result!);
|
.map(item => item.result!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): Buffer {
|
export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): {
|
||||||
const imageBuffer = Buffer.from(b64, "base64");
|
buffer: Buffer;
|
||||||
|
cachePath: string;
|
||||||
|
fileName: string;
|
||||||
|
} {
|
||||||
|
const buffer = Buffer.from(b64, "base64");
|
||||||
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
|
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
|
||||||
fs.writeFileSync(path.join(photoGenDir, fileName), imageBuffer);
|
const cachePath = path.join(photoGenDir(), fileName);
|
||||||
return imageBuffer;
|
fs.writeFileSync(cachePath, buffer);
|
||||||
|
return {buffer, cachePath, fileName};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showOpenAiGeneratedImage(
|
export async function showOpenAiGeneratedImage(
|
||||||
@@ -1703,14 +1560,21 @@ export async function showOpenAiGeneratedImage(
|
|||||||
status: string,
|
status: string,
|
||||||
final: boolean,
|
final: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const imageBuffer = writeOpenAiGeneratedImage(sourceMessage, b64, label);
|
const image = writeOpenAiGeneratedImage(sourceMessage, b64, label);
|
||||||
|
const attachment: StoredAttachment = {
|
||||||
|
kind: "image",
|
||||||
|
fileId: image.cachePath,
|
||||||
|
fileName: image.fileName,
|
||||||
|
mimeType: "image/png",
|
||||||
|
cachePath: image.cachePath,
|
||||||
|
};
|
||||||
if (final && !streamMessage.getText().trim()) {
|
if (final && !streamMessage.getText().trim()) {
|
||||||
streamMessage.replaceText(status);
|
streamMessage.replaceText(status);
|
||||||
streamMessage.clearStatus();
|
streamMessage.clearStatus();
|
||||||
} else {
|
} else {
|
||||||
streamMessage.setStatus(status);
|
streamMessage.setStatus(status);
|
||||||
}
|
}
|
||||||
await streamMessage.showImage(imageBuffer);
|
await streamMessage.showImage(image.buffer, attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openAiResponseItemCallId(item: OpenAiResponseOutputItem & { id?: string }): string {
|
export function openAiResponseItemCallId(item: OpenAiResponseOutputItem & { id?: string }): string {
|
||||||
|
|||||||
@@ -1,219 +1,248 @@
|
|||||||
import {Tool} from "ollama";
|
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
import {createOllamaClient} from "./ai-runtime-target";
|
import {ChatRequest} from "ollama";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
|
import {BoundaryValue} from "../common/boundary-types.js";
|
||||||
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target.js";
|
||||||
|
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger.js";
|
||||||
|
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
|
||||||
import {
|
import {
|
||||||
allToolSchemaNames,
|
buildRankerContext,
|
||||||
isRecord,
|
buildRankerTarget,
|
||||||
RuntimeConfigSnapshot,
|
buildToolRankerPrompt,
|
||||||
toolSchemaNames
|
filterRankedTools,
|
||||||
} from "./unified-ai-runner.shared";
|
ToolRankerSelection,
|
||||||
import {z} from "zod";
|
} from "./tool-ranker-pipeline.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
|
import {allToolSchemaNames} from "./unified-ai-runner.shared.js";
|
||||||
|
import {sanitizeToolRankerResult} from "./tool-ranker-metadata.js";
|
||||||
|
import {resolveToolRankerFallbackSelection} from "./tool-ranker-fallback.js";
|
||||||
|
|
||||||
export type ToolRankerSelection = {
|
export class ToolRanker {
|
||||||
tools: Tool[];
|
|
||||||
usedRanker: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class OllamaToolRanker {
|
|
||||||
constructor(private readonly config: RuntimeConfigSnapshot) {
|
constructor(private readonly config: RuntimeConfigSnapshot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectTools(args: {
|
async selectTools(args: {
|
||||||
|
provider: AiProvider;
|
||||||
userQuery: string;
|
userQuery: string;
|
||||||
availableTools: Tool[];
|
availableTools: readonly BoundaryValue[];
|
||||||
round: number;
|
round: number;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
|
messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[];
|
||||||
|
runRanker?: (
|
||||||
|
provider: AiProvider,
|
||||||
|
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
|
||||||
|
prompt: string,
|
||||||
|
userQuery: string,
|
||||||
|
) => Promise<string>;
|
||||||
}): Promise<ToolRankerSelection> {
|
}): Promise<ToolRankerSelection> {
|
||||||
const {availableTools, round, signal, userQuery} = args;
|
const {availableTools, provider, round, signal, userQuery} = args;
|
||||||
const target = this.config.ollamaToolRankerTarget;
|
const runRanker = args.runRanker ?? this.runRanker.bind(this);
|
||||||
|
const availableNames = allToolSchemaNames(availableTools);
|
||||||
|
const fallbackPolicy = this.config.toolRankerFallbackPolicy;
|
||||||
|
const configuredTarget = buildRankerTarget(this.config, provider);
|
||||||
|
const mainModelTarget = providerChatTarget(provider, this.config);
|
||||||
|
|
||||||
if (!availableTools.length) {
|
if (!availableTools.length) {
|
||||||
return {tools: [], usedRanker: false};
|
return {toolNames: [], usedRanker: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
|
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
|
||||||
if (!target?.model) {
|
|
||||||
return {
|
if (!target) {
|
||||||
tools: availableTools,
|
return resolveToolRankerFallbackSelection({
|
||||||
usedRanker: false,
|
fallbackPolicy,
|
||||||
};
|
availableToolNames: availableNames,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const availableNames = new Set(allToolSchemaNames(availableTools));
|
const ranker = buildToolRankerPrompt(buildRankerContext(this.config, provider, target, round, userQuery, availableTools));
|
||||||
// const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
|
|
||||||
|
|
||||||
const availableToolNames = availableTools.map(t => "- " + (t.function.name ?? ""));
|
aiLog("debug", "tool_ranker.start", {
|
||||||
|
provider,
|
||||||
const toolRouterPrompt = () => [
|
|
||||||
"You are a tool routing model.",
|
|
||||||
"Select the best tool.",
|
|
||||||
"Return ONLY valid JSON.",
|
|
||||||
"",
|
|
||||||
"Available tools:",
|
|
||||||
"- no_tool",
|
|
||||||
// "- ask_clarification",
|
|
||||||
// "- user_sad",
|
|
||||||
// "- user_angry",
|
|
||||||
availableToolNames.join("\n"),
|
|
||||||
"",
|
|
||||||
"Never explain reasoning.",
|
|
||||||
// "If user is sounds aggressive/angry, then pick `user_angry` tool.",
|
|
||||||
// "If the user's request is unclear, then pick `ask_clarification` tool.",
|
|
||||||
availableToolNames.find(t => t === "web_search") ?
|
|
||||||
"If you don't know the answer, then pick `search_web` tool." : null,
|
|
||||||
availableToolNames.find(t => t == PYTHON_INTERPRETER_TOOL_NAME) ?
|
|
||||||
"If user asks to write/execute Python code, then use `" + PYTHON_INTERPRETER_TOOL_NAME + "`" : null,
|
|
||||||
"",
|
|
||||||
|
|
||||||
"Return valid JSON ONLY in this format (NO ARGUMENTS): {\"toolNames\": [\"$toolName\", ... \"lastToolName\"]}",
|
|
||||||
].filter(Boolean).join("\n");
|
|
||||||
|
|
||||||
const routerSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: "string",
|
|
||||||
enum: [
|
|
||||||
"no_tool",
|
|
||||||
"ask_clarification",
|
|
||||||
...availableToolNames
|
|
||||||
],
|
|
||||||
},
|
|
||||||
arguments: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["name"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// const toolsForPrompt = availableTools.map(tool => ({
|
|
||||||
// names: toolSchemaNames(tool),
|
|
||||||
// schema: tool,
|
|
||||||
// }));
|
|
||||||
|
|
||||||
aiLog("debug", "ollama.tool_ranker.start", {
|
|
||||||
round,
|
round,
|
||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
queryChars: userQuery.length,
|
queryChars: userQuery.length,
|
||||||
availableTools: [...availableNames],
|
availableTools: availableNames,
|
||||||
|
fallbackPolicy,
|
||||||
|
usedMainModelFallback: !configuredTarget && fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ollama = createOllamaClient(target);
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
// const response = await ollama.chat({
|
const raw = await runRanker(provider, target, ranker.prompt, userQuery);
|
||||||
// model: target.model,
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
// messages: [
|
const selectedNames = sanitizeToolRankerResult({
|
||||||
// {role: "system", content: prompt},
|
raw,
|
||||||
// {
|
availableToolNames: availableNames,
|
||||||
// role: "user",
|
});
|
||||||
// content: JSON.stringify({
|
const filtered = filterRankedTools(availableTools, selectedNames);
|
||||||
// q: userQuery,
|
const toolNames = allToolSchemaNames(filtered);
|
||||||
// tools: toolsForPrompt,
|
|
||||||
// }),
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// stream: false,
|
|
||||||
// options: {
|
|
||||||
// temperature: 0,
|
|
||||||
// num_ctx: 8192,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
const then = performance.now();
|
aiLog("debug", "tool_ranker.done", {
|
||||||
|
provider,
|
||||||
|
round,
|
||||||
|
duration: aiLogDuration(startedAt),
|
||||||
|
selectedNames,
|
||||||
|
selectedCount: toolNames.length,
|
||||||
|
rawPreview: raw.slice(0, 800),
|
||||||
|
});
|
||||||
|
|
||||||
const response = await ollama.chat({
|
return {toolNames, usedRanker: true};
|
||||||
model: target?.model ?? "",
|
} catch (error) {
|
||||||
messages: [
|
if (error instanceof Error && error.message.includes("Aborted")) throw error;
|
||||||
{
|
let failureMessage = error instanceof Error ? error.message : String(error);
|
||||||
role: "system",
|
|
||||||
content: toolRouterPrompt()
|
const canRetryOnMainModel = fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL
|
||||||
},
|
&& (
|
||||||
{
|
target.model !== mainModelTarget.model
|
||||||
role: "user",
|
|| !sameRuntimeEndpoint(target, mainModelTarget)
|
||||||
content: userQuery,
|
);
|
||||||
|
|
||||||
|
if (canRetryOnMainModel) {
|
||||||
|
try {
|
||||||
|
aiLog("warn", "tool_ranker.failed.retry_main_model", {
|
||||||
|
provider,
|
||||||
|
round,
|
||||||
|
target: aiLogProviderTarget(target),
|
||||||
|
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||||
|
duration: aiLogDuration(startedAt),
|
||||||
|
errorSummary: failureMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackRanker = buildToolRankerPrompt(
|
||||||
|
buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools),
|
||||||
|
);
|
||||||
|
const raw = await runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery);
|
||||||
|
const selectedNames = sanitizeToolRankerResult({
|
||||||
|
raw,
|
||||||
|
availableToolNames: availableNames,
|
||||||
|
});
|
||||||
|
const filtered = filterRankedTools(availableTools, selectedNames);
|
||||||
|
const toolNames = allToolSchemaNames(filtered);
|
||||||
|
|
||||||
|
aiLog("debug", "tool_ranker.done", {
|
||||||
|
provider,
|
||||||
|
round,
|
||||||
|
duration: aiLogDuration(startedAt),
|
||||||
|
selectedNames,
|
||||||
|
selectedCount: toolNames.length,
|
||||||
|
rawPreview: raw.slice(0, 800),
|
||||||
|
fallbackUsed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {toolNames, usedRanker: true};
|
||||||
|
} catch (fallbackError) {
|
||||||
|
if (fallbackError instanceof Error && fallbackError.message.includes("Aborted")) throw fallbackError;
|
||||||
|
|
||||||
|
const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||||
|
aiLog("warn", "tool_ranker.failed.main_model_fallback_failed", {
|
||||||
|
provider,
|
||||||
|
round,
|
||||||
|
target: aiLogProviderTarget(target),
|
||||||
|
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||||
|
duration: aiLogDuration(startedAt),
|
||||||
|
errorSummary: fallbackErrorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
failureMessage = fallbackErrorMessage;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aiLog("warn", "tool_ranker.failed.fallback_all_allowed", {
|
||||||
|
provider,
|
||||||
|
round,
|
||||||
|
target: aiLogProviderTarget(target),
|
||||||
|
fallbackPolicy,
|
||||||
|
duration: aiLogDuration(startedAt),
|
||||||
|
errorSummary: failureMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolveToolRankerFallbackSelection({
|
||||||
|
fallbackPolicy,
|
||||||
|
availableToolNames: availableNames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runRanker(
|
||||||
|
provider: AiProvider,
|
||||||
|
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
|
||||||
|
prompt: string,
|
||||||
|
userQuery: string,
|
||||||
|
): Promise<string> {
|
||||||
|
switch (provider) {
|
||||||
|
case AiProvider.OLLAMA: {
|
||||||
|
const ollama = createOllamaClient(target);
|
||||||
|
const request = {
|
||||||
|
model: target.model,
|
||||||
|
messages: [
|
||||||
|
{role: "system", content: prompt},
|
||||||
|
{role: "user", content: userQuery},
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false as const,
|
||||||
think: false,
|
think: false,
|
||||||
format: routerSchema,
|
format: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
toolNames: {
|
||||||
|
type: "array",
|
||||||
|
items: {type: "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["toolNames"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
top_p: 0.8,
|
top_p: 0.8,
|
||||||
top_k: 20,
|
top_k: 20,
|
||||||
repeat_penalty: 1.05,
|
repeat_penalty: 1.05,
|
||||||
num_ctx: 8192,
|
num_ctx: 8192,
|
||||||
num_predict: 256
|
num_predict: 256,
|
||||||
},
|
},
|
||||||
});
|
} satisfies ChatRequest & { stream: false };
|
||||||
|
|
||||||
const now = performance.now();
|
const response = await ollama.chat(request);
|
||||||
const diff = now - then;
|
return response.message?.content?.trim() ?? "";
|
||||||
console.log("TOOK " + diff + "ms");
|
|
||||||
|
|
||||||
console.log("OLLAMA_RESPONSE: ", JSON.stringify(response));
|
|
||||||
|
|
||||||
if (signal.aborted) throw new Error("Aborted");
|
|
||||||
|
|
||||||
const raw = response.message?.content?.trim() ?? "";
|
|
||||||
const schema = z.object({
|
|
||||||
toolNames: z.array(z.string())
|
|
||||||
});
|
|
||||||
const res = schema.safeParse(JSON.parse(raw));
|
|
||||||
|
|
||||||
const selectedNames: string[] = [];
|
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
selectedNames.push(...res.data.toolNames);
|
|
||||||
}
|
}
|
||||||
|
case AiProvider.MISTRAL: {
|
||||||
const selectedNameSet = new Set(selectedNames);
|
const mistral = createMistralClient(target);
|
||||||
const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
|
const request: Parameters<typeof mistral.chat.complete>[0] = {
|
||||||
|
model: target.model,
|
||||||
aiLog("debug", "ollama.tool_ranker.done", {
|
messages: [
|
||||||
round,
|
{role: "system", content: prompt},
|
||||||
duration: aiLogDuration(startedAt),
|
{role: "user", content: userQuery},
|
||||||
selectedNames,
|
],
|
||||||
selectedCount: tools.length,
|
temperature: 0,
|
||||||
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, 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,
|
|
||||||
usedRanker: false,
|
|
||||||
};
|
};
|
||||||
|
const response = await mistral.chat.complete(request);
|
||||||
|
const message = response.choices?.[0]?.message;
|
||||||
|
return typeof message?.content === "string" ? message.content.trim() : "";
|
||||||
}
|
}
|
||||||
}
|
case AiProvider.OPENAI: {
|
||||||
}
|
const openAi = createOpenAiClient(target);
|
||||||
|
const messages = [
|
||||||
|
{role: "system", content: prompt},
|
||||||
|
{role: "user", content: userQuery},
|
||||||
|
] satisfies ChatCompletionMessageParam[];
|
||||||
|
|
||||||
export function latestUserTextFromOllamaMessages(messages: readonly { role?: string; content?: unknown }[]): string {
|
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
// only for official OpenAI endpoints.
|
||||||
const message = messages[i];
|
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||||
if (message?.role !== "user") continue;
|
model: target.model,
|
||||||
if (typeof message.content === "string") return message.content;
|
messages,
|
||||||
if (Array.isArray(message.content)) {
|
};
|
||||||
return message.content
|
|
||||||
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
|
if (!target.baseUrl) {
|
||||||
.filter(Boolean)
|
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
|
||||||
.join("\n");
|
request.response_format = {type: "json_object"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await openAi.chat.completions.create(request);
|
||||||
|
|
||||||
|
return response.choices[0]?.message?.content?.trim() ?? "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
+119
-159
@@ -5,52 +5,37 @@ import {ifTrue, logError, replyToMessage} from "../util/utils";
|
|||||||
import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry";
|
import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry";
|
||||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments";
|
import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments";
|
||||||
import {ChatMessage} from "./chat-messages-types";
|
|
||||||
import {aiProviderRequestQueue} from "./provider-request-queue";
|
import {aiProviderRequestQueue} from "./provider-request-queue";
|
||||||
import {prepareOllamaDocumentRag} from "./ollama-rag";
|
|
||||||
import {
|
import {
|
||||||
AI_VOICE_MODE_TRANSCRIPT,
|
AI_VOICE_MODE_TRANSCRIPT,
|
||||||
DEFAULT_AI_RESPONSE_LANGUAGE,
|
|
||||||
resolveAiContextSizeForUser,
|
resolveAiContextSizeForUser,
|
||||||
|
resolveAiImageOutputModeForUser,
|
||||||
resolveAiResponseLanguageForUser,
|
resolveAiResponseLanguageForUser,
|
||||||
resolveAiVoiceModeForUser
|
resolveAiVoiceModeForUser
|
||||||
} from "../common/user-ai-settings";
|
} from "../common/user-ai-settings";
|
||||||
import {isTranscribableAudioDownload} from "./speech-to-text";
|
|
||||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
|
||||||
import {MistralChatMessage} from "./mistral-chat-message";
|
|
||||||
import {OllamaChatMessage} from "./ollama-chat-message";
|
|
||||||
import {GeminiMessage} from "./gemini-chat-message";
|
|
||||||
import {buildAiRegenerateCallbackData} from "./regenerate-callback";
|
import {buildAiRegenerateCallbackData} from "./regenerate-callback";
|
||||||
import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target";
|
|
||||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
|
||||||
|
|
||||||
import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai";
|
|
||||||
import {runOllama} from "./unified-ai-runner.ollama";
|
|
||||||
import {runMistral} from "./unified-ai-runner.mistral";
|
|
||||||
import {runGemini} from "./unified-ai-runner.gemini";
|
|
||||||
import {
|
import {
|
||||||
AI_REQUEST_TIMEOUT_MS,
|
AI_REQUEST_TIMEOUT_MS,
|
||||||
appendTranscriptToChatMessages,
|
|
||||||
collectCachedMessageAttachments,
|
collectCachedMessageAttachments,
|
||||||
collectRequestedAttachmentKinds,
|
collectRequestedAttachmentKinds,
|
||||||
collectTextMessages,
|
|
||||||
deleteMistralLibrary,
|
|
||||||
hasAudioAttachmentKind,
|
hasAudioAttachmentKind,
|
||||||
initialStatus,
|
|
||||||
isAbortError,
|
isAbortError,
|
||||||
prepareMistralDocuments,
|
|
||||||
providerName,
|
providerName,
|
||||||
rejectUnsupportedAttachments,
|
rejectUnsupportedAttachments,
|
||||||
resolveAiRequestQueueTarget,
|
resolveAiRequestQueueTarget,
|
||||||
RuntimeConfigSnapshot,
|
RuntimeConfigSnapshot,
|
||||||
snapshotModel,
|
snapshotModel,
|
||||||
snapshotRuntimeConfig,
|
snapshotRuntimeConfig,
|
||||||
stripAudioFromRunnerMessages,
|
|
||||||
TELEGRAM_LIMIT,
|
|
||||||
toolRuntimeContextFromDownloads,
|
|
||||||
transcribeAudioIfNeeded,
|
|
||||||
UnifiedRunOptions
|
UnifiedRunOptions
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {prepareUnifiedAiRequestPipeline} from "./unified-ai-request-pipeline";
|
||||||
|
import {persistErrorArtifactAttachment} from "./final-response-artifact-store";
|
||||||
|
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
|
||||||
|
import {AiRequestStore} from "../common/ai-request-store";
|
||||||
|
import type {StoredAiRequestStatus} from "../model/stored-ai-request";
|
||||||
|
import {recordAiRequestFinish, recordAiRequestStart} from "../common/ai-observability.js";
|
||||||
|
|
||||||
export type {ToolCallData} from "./unified-ai-runner.shared";
|
export type {ToolCallData} from "./unified-ai-runner.shared";
|
||||||
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
|
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
|
||||||
@@ -61,9 +46,11 @@ async function executeUnifiedAiRequest(
|
|||||||
downloads: AiDownloadedFile[],
|
downloads: AiDownloadedFile[],
|
||||||
controller: AbortController,
|
controller: AbortController,
|
||||||
streamMessage: TelegramStreamMessage,
|
streamMessage: TelegramStreamMessage,
|
||||||
): Promise<{ mistralLibraryId?: string }> {
|
): Promise<void> {
|
||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
|
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
|
||||||
aiLog("info", "request.execute.start", {
|
aiLog("info", "request.execute.start", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
stream: options.stream ?? true,
|
stream: options.stream ?? true,
|
||||||
think: options.think,
|
think: options.think,
|
||||||
@@ -79,139 +66,48 @@ async function executeUnifiedAiRequest(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
preparedRequest = await prepareUnifiedAiRequestPipeline({
|
||||||
chatMessages,
|
options,
|
||||||
imageCount
|
|
||||||
} = await collectTextMessages(
|
|
||||||
options.msg,
|
|
||||||
options.text,
|
|
||||||
options.provider,
|
|
||||||
downloads,
|
|
||||||
config,
|
config,
|
||||||
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
|
downloads,
|
||||||
);
|
streamMessage,
|
||||||
const firstRoundStatus = initialStatus(downloads, imageCount);
|
controller,
|
||||||
const toolContext = toolRuntimeContextFromDownloads(downloads);
|
});
|
||||||
|
if (preparedRequest.finishAfterTranscript) return;
|
||||||
|
|
||||||
aiLog("debug", "request.messages.collected", {
|
aiLog("debug", "request.messages.collected", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
chatMessages: chatMessages.length,
|
chatMessages: preparedRequest.chatMessages.length,
|
||||||
imageCount,
|
imageCount: preparedRequest.imageCount,
|
||||||
firstRoundStatus,
|
firstRoundStatus: preparedRequest.firstRoundStatus,
|
||||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
hasToolInputFiles: !!preparedRequest.toolContext.pythonInputFiles?.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
streamMessage.setStatus(firstRoundStatus);
|
|
||||||
await streamMessage.flush();
|
|
||||||
|
|
||||||
const hasDocument = downloads.some(d => d.kind === "document");
|
|
||||||
if (hasDocument && options.provider !== AiProvider.MISTRAL && options.provider !== AiProvider.OLLAMA) {
|
|
||||||
aiLog("warn", "request.documents.unsupported_provider", {provider: providerName(options.provider)});
|
|
||||||
throw new Error(Environment.documentsUnifiedRunnerUnsupportedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mistralLibraryId: string | undefined;
|
|
||||||
|
|
||||||
const transcript = await transcribeAudioIfNeeded(options.provider, options.msg.from?.id, downloads, streamMessage, controller.signal).catch(e => {
|
|
||||||
if (downloads.some(isTranscribableAudioDownload)) throw e;
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
if (transcript.trim()) {
|
|
||||||
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
|
|
||||||
// TODO: 12.05.2026: extract to string
|
|
||||||
streamMessage.replaceText(`[Расшифровка]\n${transcript.trim()}`);
|
|
||||||
await streamMessage.finish();
|
|
||||||
return {mistralLibraryId};
|
|
||||||
}
|
|
||||||
|
|
||||||
appendTranscriptToChatMessages(chatMessages, options.provider, transcript);
|
|
||||||
stripAudioFromRunnerMessages(chatMessages);
|
|
||||||
aiLog("debug", "request.transcript.appended", {
|
|
||||||
provider: providerName(options.provider),
|
|
||||||
transcriptChars: transcript.length,
|
|
||||||
chatMessages: chatMessages.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preparedMistral = options.provider === AiProvider.MISTRAL
|
await runUnifiedAiResponsePipeline({
|
||||||
? await prepareMistralDocuments(downloads, chatMessages as MistralChatMessage[], streamMessage, config.mistralChatTarget, controller.signal)
|
options,
|
||||||
: {documents: []};
|
config,
|
||||||
const documents = preparedMistral.documents;
|
|
||||||
mistralLibraryId = preparedMistral.libraryId;
|
|
||||||
|
|
||||||
if (options.provider === AiProvider.OLLAMA) {
|
|
||||||
await prepareOllamaDocumentRag({
|
|
||||||
downloads,
|
downloads,
|
||||||
messages: chatMessages as OllamaChatMessage[],
|
prepared: preparedRequest,
|
||||||
userQuery: options.text,
|
streamMessage,
|
||||||
message: streamMessage,
|
controller,
|
||||||
config: {
|
|
||||||
embeddingModel: config.ollamaDocumentsTarget.model,
|
|
||||||
embeddingClient: createOllamaClient(config.ollamaDocumentsTarget),
|
|
||||||
chunkSize: config.ollamaRagChunkSize,
|
|
||||||
chunkOverlap: config.ollamaRagChunkOverlap,
|
|
||||||
topK: config.ollamaRagTopK,
|
|
||||||
maxContextChars: config.ollamaRagMaxContextChars,
|
|
||||||
minScore: config.ollamaRagMinScore,
|
|
||||||
maxArchiveFiles: config.ollamaRagMaxArchiveFiles,
|
|
||||||
maxArchiveBytes: config.ollamaRagMaxArchiveBytes,
|
|
||||||
maxArchiveDepth: config.ollamaRagMaxArchiveDepth,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)});
|
|
||||||
|
|
||||||
switch (options.provider) {
|
|
||||||
case AiProvider.OPENAI:
|
|
||||||
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext,
|
|
||||||
!!options.think);
|
|
||||||
break;
|
|
||||||
case AiProvider.OLLAMA:
|
|
||||||
const currentModel = config.ollamaChatTarget.model;
|
|
||||||
if (currentModel?.includes("gpt-oss")) {
|
|
||||||
if (options.think) {
|
|
||||||
options.think = "high";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize);
|
|
||||||
break;
|
|
||||||
case AiProvider.MISTRAL:
|
|
||||||
await runMistral(chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
|
||||||
break;
|
|
||||||
case AiProvider.GEMINI:
|
|
||||||
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
|
|
||||||
await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
|
||||||
} else {
|
|
||||||
await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamMessage.getText().length > TELEGRAM_LIMIT) {
|
|
||||||
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
|
|
||||||
}
|
|
||||||
await streamMessage.finish();
|
|
||||||
// await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
|
|
||||||
|
|
||||||
aiLog("success", "request.execute.done", {
|
aiLog("success", "request.execute.done", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
duration: aiLogDuration(requestStartedAt),
|
duration: aiLogDuration(requestStartedAt),
|
||||||
responseChars: streamMessage.getText().length,
|
responseChars: streamMessage.getText().length,
|
||||||
mistralLibraryId,
|
mistralLibraryId: preparedRequest?.preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedRequest.preparedDocumentRag.libraryId : undefined,
|
||||||
});
|
});
|
||||||
return {mistralLibraryId};
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiLog("error", "request.execute.failed", {
|
aiLog("error", "request.execute.failed", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
duration: aiLogDuration(requestStartedAt),
|
duration: aiLogDuration(requestStartedAt),
|
||||||
error: e,
|
error: e instanceof Error ? e : String(e),
|
||||||
});
|
});
|
||||||
if (mistralLibraryId) {
|
|
||||||
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
|
|
||||||
}
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,9 +118,11 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id);
|
options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id);
|
||||||
options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id);
|
options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id);
|
||||||
options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id);
|
options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id);
|
||||||
|
const imageOutputMode = await resolveAiImageOutputModeForUser(options.msg.from?.id);
|
||||||
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
|
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
|
||||||
|
|
||||||
aiLog("info", "run.start", {
|
aiLog("info", "run.start", {
|
||||||
|
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
model: snapshotModel(options.provider, config),
|
model: snapshotModel(options.provider, config),
|
||||||
message: aiLogMessageIdentity(options.msg),
|
message: aiLogMessageIdentity(options.msg),
|
||||||
@@ -241,6 +139,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
|
|
||||||
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
|
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
|
||||||
aiLog("warn", "run.rejected.unsupported_attachment", {
|
aiLog("warn", "run.rejected.unsupported_attachment", {
|
||||||
|
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
requestedAttachmentKinds: [...requestedAttachmentKinds],
|
requestedAttachmentKinds: [...requestedAttachmentKinds],
|
||||||
});
|
});
|
||||||
@@ -258,6 +157,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
|
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
|
||||||
}).catch(logError);
|
}).catch(logError);
|
||||||
aiLog("warn", "run.rejected.missing_attachment_cache", {
|
aiLog("warn", "run.rejected.missing_attachment_cache", {
|
||||||
|
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||||
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
|
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -265,12 +165,17 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
|
||||||
|
let aiRequestStatus: StoredAiRequestStatus = "running";
|
||||||
|
let aiRequestError: string | undefined;
|
||||||
|
let responseMessageId: number | undefined;
|
||||||
const cancel = createAiCancelRequest({
|
const cancel = createAiCancelRequest({
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
fromId: options.msg.from?.id ?? 0,
|
fromId: options.msg.from?.id ?? 0,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
controller
|
controller
|
||||||
});
|
});
|
||||||
|
options.requestId ??= cancel.id;
|
||||||
|
const requestId = options.requestId;
|
||||||
const streamMessage = new TelegramStreamMessage(
|
const streamMessage = new TelegramStreamMessage(
|
||||||
options.msg,
|
options.msg,
|
||||||
cancel.id,
|
cancel.id,
|
||||||
@@ -280,17 +185,42 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
: buildAiRegenerateCallbackData(options.provider, !!options.think),
|
: buildAiRegenerateCallbackData(options.provider, !!options.think),
|
||||||
options.targetMessage,
|
options.targetMessage,
|
||||||
options.provider,
|
options.provider,
|
||||||
options.isGuestMsg
|
options.isGuestMsg,
|
||||||
|
imageOutputMode
|
||||||
);
|
);
|
||||||
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
|
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
|
||||||
let mistralLibraryId: string | undefined;
|
|
||||||
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
|
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
|
||||||
aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
|
aiLog("debug", "run.queue.target", {requestId, target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
|
||||||
|
const aiRequestStartedAt = new Date().toISOString();
|
||||||
|
recordAiRequestStart();
|
||||||
|
await AiRequestStore.put({
|
||||||
|
requestId,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
fromId: options.msg.from?.id ?? 0,
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
status: "running",
|
||||||
|
startedAt: aiRequestStartedAt,
|
||||||
|
}).catch(logError);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queueMessage = await streamMessage.start(Environment.waitThinkText);
|
const queueMessage = await streamMessage.start(Environment.waitThinkText);
|
||||||
setAiCancelMessageId(cancel.id, queueMessage.message_id);
|
responseMessageId = queueMessage.message_id;
|
||||||
|
await AiRequestStore.put({
|
||||||
|
requestId,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
responseMessageId,
|
||||||
|
fromId: options.msg.from?.id ?? 0,
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
status: "running",
|
||||||
|
startedAt: aiRequestStartedAt,
|
||||||
|
}).catch(logError);
|
||||||
|
setAiCancelMessageId(requestId, queueMessage.message_id);
|
||||||
aiLog("info", "run.queue.enter", {
|
aiLog("info", "run.queue.enter", {
|
||||||
|
requestId,
|
||||||
cancelId: cancel.id,
|
cancelId: cancel.id,
|
||||||
queueMessageId: queueMessage.message_id,
|
queueMessageId: queueMessage.message_id,
|
||||||
target: aiLogProviderTarget(queueTarget),
|
target: aiLogProviderTarget(queueTarget),
|
||||||
@@ -299,15 +229,16 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
await aiProviderRequestQueue.enqueue(queueTarget, {
|
await aiProviderRequestQueue.enqueue(queueTarget, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onPositionChange: async requestsBefore => {
|
onPositionChange: async requestsBefore => {
|
||||||
aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore});
|
aiLog("debug", "run.queue.position", {requestId, cancelId: cancel.id, requestsBefore});
|
||||||
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
|
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
|
||||||
await streamMessage.flush();
|
await streamMessage.flush();
|
||||||
},
|
},
|
||||||
run: async () => {
|
run: async (): Promise<null> => {
|
||||||
const queueWaitFinishedAt = Date.now();
|
const queueWaitFinishedAt = Date.now();
|
||||||
aiLog("info", "run.queue.dequeued", {cancelId: cancel.id});
|
aiLog("info", "run.queue.dequeued", {requestId, cancelId: cancel.id});
|
||||||
const downloads = attachmentsToDownloadedFiles(cached.attachments);
|
const downloads = attachmentsToDownloadedFiles(cached.attachments);
|
||||||
aiLog("debug", "run.downloads.ready", {
|
aiLog("debug", "run.downloads.ready", {
|
||||||
|
requestId,
|
||||||
count: downloads.length,
|
count: downloads.length,
|
||||||
downloads: downloads.map(d => ({
|
downloads: downloads.map(d => ({
|
||||||
kind: d.kind,
|
kind: d.kind,
|
||||||
@@ -318,37 +249,66 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
|
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
|
||||||
mistralLibraryId = result.mistralLibraryId;
|
aiRequestStatus = "succeeded";
|
||||||
aiLog("success", "run.queue.task.done", {
|
aiLog("success", "run.queue.task.done", {
|
||||||
|
requestId,
|
||||||
cancelId: cancel.id,
|
cancelId: cancel.id,
|
||||||
duration: aiLogDuration(queueWaitFinishedAt),
|
duration: aiLogDuration(queueWaitFinishedAt),
|
||||||
mistralLibraryId,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
cleanupDownloads(downloads);
|
cleanupDownloads(downloads);
|
||||||
aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length});
|
aiLog("debug", "run.downloads.cleaned", {requestId, cancelId: cancel.id, count: downloads.length});
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (controller.signal.aborted || isAbortError(e)) {
|
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) {
|
||||||
aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
|
aiRequestStatus = "aborted";
|
||||||
|
aiRequestError = e instanceof Error ? e.message : String(e);
|
||||||
|
aiLog("warn", "run.aborted", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||||
streamMessage.replaceText(streamMessage.getText());
|
streamMessage.replaceText(streamMessage.getText());
|
||||||
await streamMessage.finish();
|
await streamMessage.finish();
|
||||||
} else {
|
} else {
|
||||||
aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
|
aiRequestStatus = "failed";
|
||||||
await streamMessage.fail(e);
|
aiRequestError = e instanceof Error ? e.message : String(e);
|
||||||
logError(e);
|
aiLog("error", "run.failed", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
await streamMessage.fail(e instanceof Error ? e : String(e));
|
||||||
|
try {
|
||||||
|
await streamMessage.storeInternalAttachment(await persistErrorArtifactAttachment({
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
message: errorMessage,
|
||||||
|
recoverable: false,
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
}));
|
||||||
|
} catch (artifactError) {
|
||||||
|
logError(artifactError instanceof Error ? artifactError : String(artifactError));
|
||||||
|
}
|
||||||
|
logError(errorMessage);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
finishAiRequest(cancel.id);
|
await AiRequestStore.put({
|
||||||
if (mistralLibraryId) {
|
requestId,
|
||||||
aiLog("debug", "run.mistral_library.cleanup", {mistralLibraryId});
|
chatId: options.msg.chat.id,
|
||||||
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
|
messageId: options.msg.message_id,
|
||||||
}
|
responseMessageId,
|
||||||
|
fromId: options.msg.from?.id ?? 0,
|
||||||
|
provider: options.provider,
|
||||||
|
model: snapshotModel(options.provider, config),
|
||||||
|
status: aiRequestStatus,
|
||||||
|
startedAt: aiRequestStartedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
error: aiRequestError,
|
||||||
|
}).catch(logError);
|
||||||
|
recordAiRequestFinish(aiRequestStatus);
|
||||||
|
finishAiRequest(requestId);
|
||||||
aiLog("success", "run.finished", {
|
aiLog("success", "run.finished", {
|
||||||
|
requestId,
|
||||||
cancelId: cancel.id,
|
cancelId: cancel.id,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type {PipelineFallbackPolicy, PipelineStageName} from "./types.js";
|
||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./types.js";
|
||||||
|
|
||||||
|
export const USER_REQUEST_PIPELINE_STAGES: readonly PipelineStageName[] = [
|
||||||
|
"receive_request",
|
||||||
|
"audit_start",
|
||||||
|
"load_user_settings",
|
||||||
|
"collect_conversation_context",
|
||||||
|
"input_size_gate",
|
||||||
|
"download_attachments",
|
||||||
|
"normalize_attachments",
|
||||||
|
"persist_input_attachments",
|
||||||
|
"prepare_text_context",
|
||||||
|
"build_system_prompt",
|
||||||
|
"resolve_runtime",
|
||||||
|
"speech_to_text",
|
||||||
|
"document_rag",
|
||||||
|
"map_provider_messages",
|
||||||
|
"tool_rank",
|
||||||
|
"filter_tools",
|
||||||
|
"model_call",
|
||||||
|
"tool_loop",
|
||||||
|
"persist_output_artifacts",
|
||||||
|
"output_size_gate",
|
||||||
|
"text_to_speech",
|
||||||
|
"send_response",
|
||||||
|
"cleanup",
|
||||||
|
"audit_finish",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const USER_REQUEST_ATTACHMENT_LIMIT_BYTES = PIPELINE_ATTACHMENT_LIMIT_BYTES;
|
||||||
|
|
||||||
|
export const DEFAULT_PIPELINE_FALLBACK_POLICIES: readonly PipelineFallbackPolicy[] = [
|
||||||
|
{
|
||||||
|
stage: "input_size_gate",
|
||||||
|
onUnavailable: "fail_request",
|
||||||
|
onFailed: "notify_user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "speech_to_text",
|
||||||
|
onUnavailable: "continue_without_stage",
|
||||||
|
onFailed: "continue_without_stage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "document_rag",
|
||||||
|
onUnavailable: "continue_without_stage",
|
||||||
|
onFailed: "notify_user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "tool_rank",
|
||||||
|
onUnavailable: "use_alternate_target",
|
||||||
|
onFailed: "use_alternate_target",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "tool_loop",
|
||||||
|
onUnavailable: "continue_without_stage",
|
||||||
|
onFailed: "notify_user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "output_size_gate",
|
||||||
|
onUnavailable: "fail_request",
|
||||||
|
onFailed: "notify_user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "text_to_speech",
|
||||||
|
onUnavailable: "continue_without_stage",
|
||||||
|
onFailed: "continue_without_stage",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isPipelineStageName(value: string): value is PipelineStageName {
|
||||||
|
return (USER_REQUEST_PIPELINE_STAGES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
PipelineFallbackAction,
|
||||||
|
PipelineFallbackPolicy,
|
||||||
|
PipelineStageName,
|
||||||
|
PipelineStageStatus,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type PipelineFallbackReason = "unavailable" | "failed";
|
||||||
|
|
||||||
|
export type PipelineFallbackDecision = {
|
||||||
|
stage: PipelineStageName;
|
||||||
|
reason: PipelineFallbackReason;
|
||||||
|
action: PipelineFallbackAction;
|
||||||
|
shouldContinue: boolean;
|
||||||
|
shouldNotifyUser: boolean;
|
||||||
|
shouldFailRequest: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ACTION_BY_REASON: Record<PipelineFallbackReason, PipelineFallbackAction> = {
|
||||||
|
unavailable: "continue_without_stage",
|
||||||
|
failed: "fail_request",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePipelineFallbackAction(params: {
|
||||||
|
stage: PipelineStageName;
|
||||||
|
reason: PipelineFallbackReason;
|
||||||
|
policies: readonly PipelineFallbackPolicy[];
|
||||||
|
}): PipelineFallbackAction {
|
||||||
|
const policy = params.policies.find(item => item.stage === params.stage);
|
||||||
|
if (!policy) return DEFAULT_ACTION_BY_REASON[params.reason];
|
||||||
|
|
||||||
|
return params.reason === "unavailable"
|
||||||
|
? policy.onUnavailable
|
||||||
|
: policy.onFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decidePipelineFallback(params: {
|
||||||
|
stage: PipelineStageName;
|
||||||
|
reason: PipelineFallbackReason;
|
||||||
|
policies: readonly PipelineFallbackPolicy[];
|
||||||
|
}): PipelineFallbackDecision {
|
||||||
|
const action = resolvePipelineFallbackAction(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: params.stage,
|
||||||
|
reason: params.reason,
|
||||||
|
action,
|
||||||
|
shouldContinue: action === "ignore"
|
||||||
|
|| action === "continue_without_stage"
|
||||||
|
|| action === "notify_user"
|
||||||
|
|| action === "use_alternate_target",
|
||||||
|
shouldNotifyUser: action === "notify_user",
|
||||||
|
shouldFailRequest: action === "fail_request",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fallbackReasonFromStageStatus(status: PipelineStageStatus): PipelineFallbackReason | undefined {
|
||||||
|
if (status === "skipped") return "unavailable";
|
||||||
|
if (status === "failed") return "failed";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type {PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
|
||||||
|
export class PipelineRequestFailure extends Error {
|
||||||
|
constructor(public readonly decision: PipelineFallbackDecision, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PipelineRequestFailure";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function raisePipelineRequestFailure(decision: PipelineFallbackDecision, stageName: string): never {
|
||||||
|
throw new PipelineRequestFailure(decision, `Pipeline send failed at stage ${stageName} with fallback action ${decision.action}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type {PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
|
||||||
|
export function fallbackNotificationKey(requestId: string, decision: PipelineFallbackDecision): string {
|
||||||
|
return `${requestId}:${decision.stage}:${decision.action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PipelineFallbackNotificationRegistry {
|
||||||
|
private readonly notifiedKeys = new Set<string>();
|
||||||
|
|
||||||
|
claim(requestId: string, decision: PipelineFallbackDecision): boolean {
|
||||||
|
const key = fallbackNotificationKey(requestId, decision);
|
||||||
|
if (this.notifiedKeys.has(key)) return false;
|
||||||
|
this.notifiedKeys.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {Localization} from "../../common/localization.js";
|
||||||
|
import type {PipelineFallbackAction, PipelineStageName} from "./types.js";
|
||||||
|
|
||||||
|
export function resolvePipelineFallbackText(
|
||||||
|
stage: PipelineStageName,
|
||||||
|
action: PipelineFallbackAction,
|
||||||
|
locale?: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (action === "continue_without_stage") return undefined;
|
||||||
|
if (action === "fail_request") return Localization.text("pipelineFallback.failRequest", {}, "⚠️ I could not finish this request.", locale);
|
||||||
|
|
||||||
|
switch (stage) {
|
||||||
|
case "speech_to_text":
|
||||||
|
return Localization.text("pipelineFallback.speechToText", {}, "⚠️ Speech transcription failed, so I will continue without the audio transcript.", locale);
|
||||||
|
case "document_rag":
|
||||||
|
return Localization.text("pipelineFallback.documentRag", {}, "⚠️ Document retrieval failed, so I will answer without RAG.", locale);
|
||||||
|
case "tool_loop":
|
||||||
|
return Localization.text("pipelineFallback.toolLoop", {}, "⚠️ Tool execution failed, so I will continue without that tool.", locale);
|
||||||
|
case "text_to_speech":
|
||||||
|
return Localization.text("pipelineFallback.textToSpeech", {}, "⚠️ Text-to-speech failed, so I will continue without audio output.", locale);
|
||||||
|
default:
|
||||||
|
return action === "notify_user"
|
||||||
|
? Localization.text("pipelineFallback.notifyUser", {}, "⚠️ I hit a problem and need to continue with a fallback.", locale)
|
||||||
|
: Localization.text("pipelineFallback.generic", {}, "⚠️ I had to skip part of the request, but I can continue.", locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type {Message} from "typescript-telegram-bot-api";
|
||||||
|
import {Localization} from "../../common/localization.js";
|
||||||
|
import {replyToMessage, logError} from "../../util/utils.js";
|
||||||
|
import type {PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
import {PipelineFallbackNotificationRegistry} from "./fallback-notifier-registry.js";
|
||||||
|
import {resolvePipelineFallbackText} from "./fallback-notifier-text.js";
|
||||||
|
|
||||||
|
export class PipelineFallbackNotifier {
|
||||||
|
private readonly registry = new PipelineFallbackNotificationRegistry();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sourceMessage: Message,
|
||||||
|
private readonly responseLanguage?: string,
|
||||||
|
private readonly sendFallbackMessage: (text: string) => Promise<void> = async text => {
|
||||||
|
await replyToMessage({
|
||||||
|
message: this.sourceMessage,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async notify(requestId: string, decision: PipelineFallbackDecision): Promise<{notified: boolean; text?: string}> {
|
||||||
|
if (!this.registry.claim(requestId, decision)) {
|
||||||
|
return {notified: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = this.responseLanguage === "default"
|
||||||
|
? Localization.currentLocale()
|
||||||
|
: Localization.normalizeLocale(this.responseLanguage) ?? Localization.currentLocale();
|
||||||
|
const text = resolvePipelineFallbackText(decision.stage, decision.action, locale);
|
||||||
|
if (!text) {
|
||||||
|
return {notified: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sendFallbackMessage(text);
|
||||||
|
return {notified: true, text};
|
||||||
|
} catch (error) {
|
||||||
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
return {notified: false, text};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {AiProvider} from "../../model/ai-provider.js";
|
||||||
|
import type {RuntimeConfigSnapshot} from "../unified-ai-runner.shared.js";
|
||||||
|
import {aiLogProviderTarget} from "../../logging/ai-logger.js";
|
||||||
|
import {buildRankerTarget} from "../tool-ranker-pipeline.js";
|
||||||
|
import {providerChatTarget} from "../unified-ai-runner.shared.js";
|
||||||
|
|
||||||
|
export function buildToolRankFallbackTargetDetails(provider: AiProvider, config: RuntimeConfigSnapshot) {
|
||||||
|
const sourceTarget = buildRankerTarget(config, provider);
|
||||||
|
const alternateTarget = providerChatTarget(provider, config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceTarget: aiLogProviderTarget(sourceTarget),
|
||||||
|
alternateTarget: aiLogProviderTarget(alternateTarget),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./blueprint.js";
|
||||||
|
export * from "./fallback-executor.js";
|
||||||
|
export * from "./pipeline.js";
|
||||||
|
export * from "./size-gate.js";
|
||||||
|
export * from "./telegram-message-stages.js";
|
||||||
|
export * from "./types.js";
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js";
|
||||||
|
import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
import {raisePipelineRequestFailure} from "./fallback-failure.js";
|
||||||
|
import type {
|
||||||
|
PipelineAuditEvent,
|
||||||
|
PipelineFallbackPolicy,
|
||||||
|
PipelineStageName,
|
||||||
|
PipelineStageResult,
|
||||||
|
UserRequestPipelineStage,
|
||||||
|
UserRequestPipelineState,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type UserRequestPipelineOptions = {
|
||||||
|
stages: UserRequestPipelineStage[];
|
||||||
|
stageNames?: readonly PipelineStageName[];
|
||||||
|
fallbackPolicies?: readonly PipelineFallbackPolicy[];
|
||||||
|
onFallback?: (decision: PipelineFallbackDecision) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationMs(startedAt: number): number {
|
||||||
|
return Date.now() - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageEvent(event: PipelineAuditEvent): PipelineAuditEvent {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserRequestPipeline {
|
||||||
|
private readonly stages = new Map<PipelineStageName, UserRequestPipelineStage>();
|
||||||
|
private readonly stageNames: readonly PipelineStageName[];
|
||||||
|
private readonly fallbackPolicies: readonly PipelineFallbackPolicy[];
|
||||||
|
private readonly onFallback?: (decision: PipelineFallbackDecision) => Promise<void> | void;
|
||||||
|
|
||||||
|
constructor(options: UserRequestPipelineOptions) {
|
||||||
|
for (const stage of options.stages) {
|
||||||
|
this.stages.set(stage.name, stage);
|
||||||
|
}
|
||||||
|
this.stageNames = options.stageNames ?? USER_REQUEST_PIPELINE_STAGES;
|
||||||
|
this.fallbackPolicies = options.fallbackPolicies ?? DEFAULT_PIPELINE_FALLBACK_POLICIES;
|
||||||
|
this.onFallback = options.onFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(state: UserRequestPipelineState, signal: AbortSignal): Promise<UserRequestPipelineState> {
|
||||||
|
for (const stageName of this.stageNames) {
|
||||||
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
|
|
||||||
|
const stage = this.stages.get(stageName);
|
||||||
|
if (!stage) {
|
||||||
|
const decision = decidePipelineFallback({
|
||||||
|
stage: stageName,
|
||||||
|
reason: "unavailable",
|
||||||
|
policies: this.fallbackPolicies,
|
||||||
|
});
|
||||||
|
await this.onFallback?.(decision);
|
||||||
|
state.audit.push(stageEvent({
|
||||||
|
stage: stageName,
|
||||||
|
status: "skipped",
|
||||||
|
startedAt: nowIso(),
|
||||||
|
finishedAt: nowIso(),
|
||||||
|
details: {
|
||||||
|
reason: "stage_not_registered",
|
||||||
|
fallbackAction: decision.action,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (decision.shouldFailRequest) {
|
||||||
|
raisePipelineRequestFailure(decision, stageName);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAtMs = Date.now();
|
||||||
|
const startedAt = nowIso();
|
||||||
|
state.audit.push(stageEvent({
|
||||||
|
stage: stageName,
|
||||||
|
status: "running",
|
||||||
|
startedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await stage.run(state, signal);
|
||||||
|
this.applyStageResult(state, result);
|
||||||
|
state.audit.push(stageEvent({
|
||||||
|
stage: stageName,
|
||||||
|
status: result.status,
|
||||||
|
startedAt,
|
||||||
|
finishedAt: nowIso(),
|
||||||
|
durationMs: durationMs(startedAtMs),
|
||||||
|
details: result.fallbackAction || result.details
|
||||||
|
? {
|
||||||
|
...(result.details ?? {}),
|
||||||
|
...(result.fallbackAction ? {fallbackAction: result.fallbackAction} : {}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const decision = decidePipelineFallback({
|
||||||
|
stage: stageName,
|
||||||
|
reason: "failed",
|
||||||
|
policies: this.fallbackPolicies,
|
||||||
|
});
|
||||||
|
await this.onFallback?.(decision);
|
||||||
|
state.audit.push(stageEvent({
|
||||||
|
stage: stageName,
|
||||||
|
status: "failed",
|
||||||
|
startedAt,
|
||||||
|
finishedAt: nowIso(),
|
||||||
|
durationMs: durationMs(startedAtMs),
|
||||||
|
details: {fallbackAction: decision.action},
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}));
|
||||||
|
if (decision.shouldFailRequest) {
|
||||||
|
raisePipelineRequestFailure(decision, stageName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyStageResult(state: UserRequestPipelineState, result: PipelineStageResult): void {
|
||||||
|
if (result.artifacts?.length) {
|
||||||
|
state.artifacts.push(...result.artifacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.attachments?.length) {
|
||||||
|
state.outputAttachments.push(...result.attachments.filter(attachment => attachment.direction === "output"));
|
||||||
|
state.inputAttachments.push(...result.attachments.filter(attachment => attachment.direction === "input"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PersistentAttachment} from "./types.js";
|
||||||
|
|
||||||
|
export type AttachmentSizeGateResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
attachment: PersistentAttachment;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
attachment: PersistentAttachment;
|
||||||
|
limitBytes: number;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateAttachmentSize(
|
||||||
|
attachment: PersistentAttachment,
|
||||||
|
limitBytes: number = PIPELINE_ATTACHMENT_LIMIT_BYTES,
|
||||||
|
): AttachmentSizeGateResult {
|
||||||
|
if (attachment.sizeBytes <= limitBytes) {
|
||||||
|
return {ok: true, attachment};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
attachment,
|
||||||
|
limitBytes,
|
||||||
|
reason: `Attachment ${attachment.fileName} is larger than ${limitBytes} bytes.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitAttachmentsBySize(
|
||||||
|
attachments: readonly PersistentAttachment[],
|
||||||
|
limitBytes: number = PIPELINE_ATTACHMENT_LIMIT_BYTES,
|
||||||
|
): {
|
||||||
|
accepted: PersistentAttachment[];
|
||||||
|
rejected: AttachmentSizeGateResult[];
|
||||||
|
} {
|
||||||
|
const accepted: PersistentAttachment[] = [];
|
||||||
|
const rejected: AttachmentSizeGateResult[] = [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const result = validateAttachmentSize(attachment, limitBytes);
|
||||||
|
if (result.ok) {
|
||||||
|
accepted.push(result.attachment);
|
||||||
|
} else {
|
||||||
|
rejected.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {accepted, rejected};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type {Message} from "typescript-telegram-bot-api";
|
||||||
|
import {AiProvider} from "../../model/ai-provider";
|
||||||
|
import type {StoredMessage} from "../../model/stored-message";
|
||||||
|
import type {StoredAttachment} from "../../model/stored-attachment";
|
||||||
|
import {MessageStore} from "../../common/message-store";
|
||||||
|
import {Environment} from "../../common/environment";
|
||||||
|
import {
|
||||||
|
DEFAULT_AI_IMAGE_OUTPUT_MODE,
|
||||||
|
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
|
DEFAULT_AI_VOICE_MODE,
|
||||||
|
} from "../../common/user-ai-settings";
|
||||||
|
import {
|
||||||
|
cacheMessageAttachmentsWithRejections,
|
||||||
|
collectTelegramAttachmentDescriptors,
|
||||||
|
type RejectedTelegramAttachment,
|
||||||
|
} from "../telegram-attachments";
|
||||||
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PersistentAttachment, type UserRequestPipelineState} from "./types";
|
||||||
|
import {UserRequestPipeline} from "./pipeline";
|
||||||
|
import type {UserRequestPipelineStage} from "./types";
|
||||||
|
|
||||||
|
type TelegramMessageAttachmentPipelineResult = {
|
||||||
|
state: UserRequestPipelineState;
|
||||||
|
storedMessage: StoredMessage;
|
||||||
|
attachments: StoredAttachment[];
|
||||||
|
rejected: RejectedTelegramAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestIdFor(msg: Message): string {
|
||||||
|
return `telegram:${msg.chat.id}:${msg.message_id}:${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectedKey(attachment: Pick<RejectedTelegramAttachment, "fileId" | "fileName">): string {
|
||||||
|
return `${attachment.fileId}:${attachment.fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeRejected(attachments: RejectedTelegramAttachment[]): RejectedTelegramAttachment[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: RejectedTelegramAttachment[] = [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const key = rejectedKey(attachment);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedToPersistentAttachment(msg: Message, attachment: StoredAttachment): PersistentAttachment {
|
||||||
|
return {
|
||||||
|
direction: "input",
|
||||||
|
kind: attachment.kind,
|
||||||
|
fileId: attachment.fileId,
|
||||||
|
fileUniqueId: attachment.fileUniqueId,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
sizeBytes: attachment.sizeBytes ?? 0,
|
||||||
|
cachePath: attachment.cachePath,
|
||||||
|
sha256: attachment.sha256,
|
||||||
|
sourceChatId: msg.chat.id,
|
||||||
|
sourceMessageId: msg.message_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTelegramMessageAttachmentPipeline(
|
||||||
|
msg: Message,
|
||||||
|
storedMessage: StoredMessage,
|
||||||
|
): Promise<TelegramMessageAttachmentPipelineResult> {
|
||||||
|
let downloadedAttachments: StoredAttachment[] = [];
|
||||||
|
let rejectedAttachments: RejectedTelegramAttachment[] = [];
|
||||||
|
let persistedMessage = storedMessage;
|
||||||
|
|
||||||
|
const state: UserRequestPipelineState = {
|
||||||
|
requestId: requestIdFor(msg),
|
||||||
|
chatId: msg.chat.id,
|
||||||
|
messageId: msg.message_id,
|
||||||
|
replyToMessageId: msg.reply_to_message?.message_id,
|
||||||
|
fromId: msg.from?.id ?? storedMessage.fromId,
|
||||||
|
receivedAt: nowIso(),
|
||||||
|
text: storedMessage.text ?? msg.text ?? msg.caption ?? "",
|
||||||
|
settings: {
|
||||||
|
provider: Environment.DEFAULT_AI_PROVIDER ?? AiProvider.OLLAMA,
|
||||||
|
responseLanguage: DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
|
voiceMode: DEFAULT_AI_VOICE_MODE,
|
||||||
|
imageOutputMode: DEFAULT_AI_IMAGE_OUTPUT_MODE,
|
||||||
|
},
|
||||||
|
inputAttachments: [],
|
||||||
|
outputAttachments: [],
|
||||||
|
artifacts: [],
|
||||||
|
toolRankDecisions: [],
|
||||||
|
audit: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stages: UserRequestPipelineStage[] = [
|
||||||
|
{
|
||||||
|
name: "input_size_gate",
|
||||||
|
async run() {
|
||||||
|
rejectedAttachments = dedupeRejected([
|
||||||
|
...rejectedAttachments,
|
||||||
|
...collectTelegramAttachmentDescriptors(msg)
|
||||||
|
.filter(attachment => (attachment.sizeBytes ?? 0) > PIPELINE_ATTACHMENT_LIMIT_BYTES)
|
||||||
|
.map(attachment => ({
|
||||||
|
kind: attachment.kind,
|
||||||
|
fileId: attachment.fileId,
|
||||||
|
fileUniqueId: attachment.fileUniqueId,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
sizeBytes: attachment.sizeBytes ?? 0,
|
||||||
|
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
|
||||||
|
reason: "too_large" as const,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "input_size_gate",
|
||||||
|
status: rejectedAttachments.length ? "fallback" : "succeeded",
|
||||||
|
fallbackAction: rejectedAttachments.length ? "notify_user" : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "download_attachments",
|
||||||
|
async run() {
|
||||||
|
const result = await cacheMessageAttachmentsWithRejections(msg);
|
||||||
|
downloadedAttachments = result.attachments;
|
||||||
|
rejectedAttachments = dedupeRejected([...rejectedAttachments, ...result.rejected]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "download_attachments",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalize_attachments",
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
stage: "normalize_attachments",
|
||||||
|
status: "succeeded",
|
||||||
|
attachments: downloadedAttachments.map(attachment => storedToPersistentAttachment(msg, attachment)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "persist_input_attachments",
|
||||||
|
async run() {
|
||||||
|
if (downloadedAttachments.length) {
|
||||||
|
persistedMessage = {
|
||||||
|
...persistedMessage,
|
||||||
|
attachments: downloadedAttachments,
|
||||||
|
};
|
||||||
|
persistedMessage = await MessageStore.put(persistedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage: "persist_input_attachments",
|
||||||
|
status: "succeeded",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pipeline = new UserRequestPipeline({
|
||||||
|
stages,
|
||||||
|
stageNames: [
|
||||||
|
"input_size_gate",
|
||||||
|
"download_attachments",
|
||||||
|
"normalize_attachments",
|
||||||
|
"persist_input_attachments",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await pipeline.run(state, new AbortController().signal);
|
||||||
|
persistedMessage = await MessageStore.put({
|
||||||
|
...persistedMessage,
|
||||||
|
pipelineAudit: state.audit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
storedMessage: persistedMessage,
|
||||||
|
attachments: downloadedAttachments,
|
||||||
|
rejected: rejectedAttachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import type {AiProvider} from "../../model/ai-provider";
|
||||||
|
import type {StoredAttachmentKind} from "../../model/stored-attachment";
|
||||||
|
import type {
|
||||||
|
UserAiImageOutputMode,
|
||||||
|
UserAiResponseLanguage,
|
||||||
|
UserAiVoiceMode,
|
||||||
|
} from "../../common/user-ai-settings";
|
||||||
|
|
||||||
|
export const PIPELINE_ATTACHMENT_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type PipelineStageName =
|
||||||
|
| "receive_request"
|
||||||
|
| "audit_start"
|
||||||
|
| "load_user_settings"
|
||||||
|
| "collect_conversation_context"
|
||||||
|
| "input_size_gate"
|
||||||
|
| "download_attachments"
|
||||||
|
| "normalize_attachments"
|
||||||
|
| "persist_input_attachments"
|
||||||
|
| "prepare_text_context"
|
||||||
|
| "build_system_prompt"
|
||||||
|
| "resolve_runtime"
|
||||||
|
| "speech_to_text"
|
||||||
|
| "document_rag"
|
||||||
|
| "map_provider_messages"
|
||||||
|
| "tool_rank"
|
||||||
|
| "filter_tools"
|
||||||
|
| "model_call"
|
||||||
|
| "tool_loop"
|
||||||
|
| "persist_output_artifacts"
|
||||||
|
| "output_size_gate"
|
||||||
|
| "text_to_speech"
|
||||||
|
| "send_response"
|
||||||
|
| "cleanup"
|
||||||
|
| "audit_finish";
|
||||||
|
|
||||||
|
export type PipelineStageStatus =
|
||||||
|
| "pending"
|
||||||
|
| "running"
|
||||||
|
| "succeeded"
|
||||||
|
| "skipped"
|
||||||
|
| "failed"
|
||||||
|
| "fallback";
|
||||||
|
|
||||||
|
export type PipelineFallbackAction =
|
||||||
|
| "ignore"
|
||||||
|
| "notify_user"
|
||||||
|
| "continue_without_stage"
|
||||||
|
| "use_alternate_target"
|
||||||
|
| "fail_request";
|
||||||
|
|
||||||
|
export type PipelineFallbackPolicy = {
|
||||||
|
stage: PipelineStageName;
|
||||||
|
onUnavailable: PipelineFallbackAction;
|
||||||
|
onFailed: PipelineFallbackAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineUserSettings = {
|
||||||
|
provider: AiProvider;
|
||||||
|
responseLanguage: UserAiResponseLanguage;
|
||||||
|
contextSize?: number;
|
||||||
|
voiceMode: UserAiVoiceMode;
|
||||||
|
imageOutputMode: UserAiImageOutputMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineRuntimeTarget = {
|
||||||
|
provider: AiProvider;
|
||||||
|
purpose:
|
||||||
|
| "chat"
|
||||||
|
| "toolRank"
|
||||||
|
| "documents"
|
||||||
|
| "speechToText"
|
||||||
|
| "textToSpeech"
|
||||||
|
| "outputImages"
|
||||||
|
| "tools";
|
||||||
|
model: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineRuntimePlan = {
|
||||||
|
chat: PipelineRuntimeTarget;
|
||||||
|
toolRank?: PipelineRuntimeTarget;
|
||||||
|
documents?: PipelineRuntimeTarget;
|
||||||
|
speechToText?: PipelineRuntimeTarget;
|
||||||
|
textToSpeech?: PipelineRuntimeTarget;
|
||||||
|
outputImages?: PipelineRuntimeTarget;
|
||||||
|
tools?: PipelineRuntimeTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineAttachmentDirection = "input" | "output";
|
||||||
|
|
||||||
|
export type PersistentAttachment = {
|
||||||
|
id?: string;
|
||||||
|
direction: PipelineAttachmentDirection;
|
||||||
|
kind: StoredAttachmentKind | "file";
|
||||||
|
fileId?: string;
|
||||||
|
fileUniqueId?: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
cachePath?: string;
|
||||||
|
sha256?: string;
|
||||||
|
sourceChatId?: number;
|
||||||
|
sourceMessageId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineArtifactKind =
|
||||||
|
| "transcript"
|
||||||
|
| "rag"
|
||||||
|
| "tool_result"
|
||||||
|
| "generated_file"
|
||||||
|
| "tts_audio"
|
||||||
|
| "final_text"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type PipelineArtifactBase = {
|
||||||
|
id?: string;
|
||||||
|
kind: PipelineArtifactKind;
|
||||||
|
stage: PipelineStageName;
|
||||||
|
requestId?: string;
|
||||||
|
messageChatId?: number;
|
||||||
|
messageId?: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscriptArtifact = PipelineArtifactBase & {
|
||||||
|
kind: "transcript";
|
||||||
|
text: string;
|
||||||
|
sourceAttachmentIds: string[];
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RagArtifact = PipelineArtifactBase & {
|
||||||
|
kind: "rag";
|
||||||
|
sourceAttachmentIds: string[];
|
||||||
|
provider: AiProvider;
|
||||||
|
extractedText?: string;
|
||||||
|
chunks?: Array<{
|
||||||
|
id: string;
|
||||||
|
sourceName: string;
|
||||||
|
text: string;
|
||||||
|
score?: number;
|
||||||
|
}>;
|
||||||
|
providerState?: {
|
||||||
|
vectorStoreIds?: string[];
|
||||||
|
libraryId?: string;
|
||||||
|
documentIds?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolResultArtifact = PipelineArtifactBase & {
|
||||||
|
kind: "tool_result";
|
||||||
|
toolName: string;
|
||||||
|
callId: string;
|
||||||
|
resultText: string;
|
||||||
|
outputAttachmentIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeneratedFileArtifact = PipelineArtifactBase & {
|
||||||
|
kind: "generated_file" | "tts_audio";
|
||||||
|
attachmentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalTextArtifact = PipelineArtifactBase & {
|
||||||
|
kind: "final_text";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorArtifact = PipelineArtifactBase & {
|
||||||
|
kind: "error";
|
||||||
|
errorCode?: string;
|
||||||
|
message: string;
|
||||||
|
recoverable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineArtifact =
|
||||||
|
| TranscriptArtifact
|
||||||
|
| RagArtifact
|
||||||
|
| ToolResultArtifact
|
||||||
|
| GeneratedFileArtifact
|
||||||
|
| FinalTextArtifact
|
||||||
|
| ErrorArtifact;
|
||||||
|
|
||||||
|
export type PipelineAuditEvent = {
|
||||||
|
stage: PipelineStageName;
|
||||||
|
status: PipelineStageStatus;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
provider?: AiProvider;
|
||||||
|
model?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolRankDecision = {
|
||||||
|
provider: AiProvider;
|
||||||
|
round: number;
|
||||||
|
availableTools: string[];
|
||||||
|
selectedTools: string[];
|
||||||
|
usedRanker: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserRequestPipelineState = {
|
||||||
|
requestId: string;
|
||||||
|
chatId: number;
|
||||||
|
messageId: number;
|
||||||
|
replyToMessageId?: number;
|
||||||
|
fromId: number;
|
||||||
|
receivedAt: string;
|
||||||
|
text: string;
|
||||||
|
settings: PipelineUserSettings;
|
||||||
|
runtime?: PipelineRuntimePlan;
|
||||||
|
inputAttachments: PersistentAttachment[];
|
||||||
|
outputAttachments: PersistentAttachment[];
|
||||||
|
artifacts: PipelineArtifact[];
|
||||||
|
toolRankDecisions: ToolRankDecision[];
|
||||||
|
audit: PipelineAuditEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineStageResult = {
|
||||||
|
stage: PipelineStageName;
|
||||||
|
status: PipelineStageStatus;
|
||||||
|
artifacts?: PipelineArtifact[];
|
||||||
|
attachments?: PersistentAttachment[];
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
fallbackAction?: PipelineFallbackAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserRequestPipelineStage {
|
||||||
|
readonly name: PipelineStageName;
|
||||||
|
run(state: UserRequestPipelineState, signal: AbortSignal): Promise<PipelineStageResult>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user