Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b367f29c5 | |||
| 2b1940bf4d | |||
| c1a913d5e4 | |||
| 363f0cd607 | |||
| 858e3d7990 | |||
| 067bbd0708 | |||
| 78932e82af | |||
| a411c6874a | |||
| bd548a9f43 | |||
| 674c3cbd44 | |||
| c5b61ee3d8 | |||
| cd8d2683c0 | |||
| 3848dd82d9 | |||
| d2464b9b21 | |||
| 94d695e008 | |||
| 3d14e3c0d5 | |||
| 1b94760b21 | |||
| 355ae8e5da | |||
| 32c35f54aa | |||
| 4c2a5471df | |||
| d666244863 | |||
| 28f67aefc2 | |||
| 986d4aca46 | |||
| 35354a86de | |||
| 2fc60806ff | |||
| 86b26813e2 | |||
| ca7caf7a51 | |||
| 13b41c3026 | |||
| ac51702f00 | |||
| 0a34e15a22 | |||
| c24bc8394b | |||
| 0f91e43ea0 | |||
| 382e00ce31 |
+13
-45
@@ -11,18 +11,6 @@ BOT_TOKEN=your_bot_token_here
|
||||
# To get your ID: send /id command to the bot and use the "from id" value
|
||||
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)
|
||||
# ============================================
|
||||
@@ -43,27 +31,9 @@ ONLY_FOR_CREATOR_MODE=false
|
||||
# Use user names in AI prompts
|
||||
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)
|
||||
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
|
||||
MAX_PHOTO_SIZE=1280
|
||||
|
||||
@@ -74,6 +44,17 @@ LOCALES_DIR=locales
|
||||
# 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_API_KEY=
|
||||
MISTRAL_MODEL=mistral-small-latest
|
||||
@@ -103,10 +84,6 @@ OLLAMA_MAX_CONCURRENT_REQUESTS=1
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=
|
||||
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_IMAGE_MODEL=gpt-image-1-mini
|
||||
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
|
||||
@@ -115,29 +92,20 @@ OPENAI_TTS_VOICE=alloy
|
||||
OPENAI_TTS_INSTRUCTIONS=
|
||||
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
|
||||
# Pattern:
|
||||
# <PROVIDER>_<CAPABILITY>_MODEL=
|
||||
# <PROVIDER>_<CAPABILITY>_BASE_URL=
|
||||
# <PROVIDER>_<CAPABILITY>_API_KEY=
|
||||
#
|
||||
# Providers: OLLAMA, MISTRAL, OPENAI
|
||||
# Providers: OLLAMA, GEMINI, MISTRAL, OPENAI
|
||||
# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO,
|
||||
# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH
|
||||
# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS.
|
||||
# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL,
|
||||
# OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
||||
# GEMINI_BASE_URL, OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
||||
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
|
||||
# TRANSCRIPTION, STT, TTS.
|
||||
# Backend override: OPENAI_BACKEND=official|compatible.
|
||||
#
|
||||
# Examples:
|
||||
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
@@ -1,314 +0,0 @@
|
||||
# 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,50 +1,12 @@
|
||||
# Telegram Chat Bot
|
||||
|
||||
Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written in TypeScript + NodeJS/Bun runtime + SQLite/PostgreSQL/in-memory storage
|
||||
Bot for Telegram with a lot of commands and AI (Ollama/Gemini/Mistral) written in TypeScript + NodeJS/Bun runtime + Drizzle ORM (SQLite DB)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 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
|
||||
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (GEMINI_API_KEY, MISTRAL_API_KEY, OLLAMA_ADDRESS)
|
||||
```
|
||||
|
||||
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
|
||||
@@ -54,19 +16,24 @@ ollama pull nomic-embed-text
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
```
|
||||
|
||||
Tool ranker fallback is configurable via `TOOL_RANKER_FALLBACK_POLICY`:
|
||||
**With Bun (Recommended):**
|
||||
```bash
|
||||
bun install
|
||||
bunx drizzle-kit generate && bunx drizzle-kit migrate
|
||||
bun run build && bun start
|
||||
```
|
||||
|
||||
- `MAIN_MODEL` - use the provider's main chat model to rank tools if a dedicated ranker target is missing or fails
|
||||
- `ALL_TOOLS` - skip tool ranking fallback and allow all tools
|
||||
- `NO_TOOLS` - skip tool ranking fallback and allow no tools
|
||||
|
||||
The default is `ALL_TOOLS`.
|
||||
**With Node.js:**
|
||||
```bash
|
||||
npm install
|
||||
npx drizzle-kit generate && npx drizzle-kit migrate
|
||||
npm run build && npm start
|
||||
```
|
||||
|
||||
**With Docker Compose:**
|
||||
```bash
|
||||
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:**
|
||||
```bash
|
||||
@@ -82,13 +49,13 @@ docker run -d --env-file .env -v $(pwd)/data:/config/data tg-bot-bun
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 20.19 OR Bun >= 1.0
|
||||
- Node.js >= 20 OR Bun >= 1.0
|
||||
- Docker (optional)
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- AI chat (Mistral, Ollama, OpenAI)
|
||||
- AI chat (Gemini, Mistral, Ollama)
|
||||
- Local document RAG for Ollama without third-party providers
|
||||
- Custom answers and commands
|
||||
- Admin management
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
"": {
|
||||
"name": "tg-chat-bot",
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.0.0",
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@mistralai/mistralai": "^2.2.1",
|
||||
"@napi-rs/canvas": "^1.0.0",
|
||||
"axios": "^1.16.1",
|
||||
"axios": "^1.16.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"emoji-regex": "^10.6.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.38.0",
|
||||
"pg": "^8.21.0",
|
||||
"openai": "^6.37.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5",
|
||||
"systeminformation": "^5.31.6",
|
||||
@@ -24,48 +24,77 @@
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/node": "^25.6.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"eslint": "^9.39.4",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@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/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/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
"@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/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/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@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/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@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-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@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-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -117,6 +146,8 @@
|
||||
|
||||
"@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/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="],
|
||||
@@ -171,76 +202,70 @@
|
||||
|
||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@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/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/node": ["@types/node@25.6.1", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha1-x57Zf380y48robyXkLzDZkdLS3k="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
@@ -249,16 +274,14 @@
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"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="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -267,10 +290,16 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
@@ -281,65 +310,53 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"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=="],
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
@@ -347,43 +364,31 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"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=="],
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
||||
|
||||
"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=="],
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
"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=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
"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=="],
|
||||
|
||||
"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@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
@@ -391,65 +396,43 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.29", "", { "dependencies": { "mime-db": "1.46.0" } }, "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
|
||||
"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=="],
|
||||
|
||||
"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-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
"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=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
@@ -457,7 +440,13 @@
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -469,41 +458,39 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"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-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-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=="],
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
|
||||
|
||||
@@ -511,13 +498,11 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
"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=="],
|
||||
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
@@ -525,13 +510,17 @@
|
||||
|
||||
"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-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -539,19 +528,11 @@
|
||||
|
||||
"@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/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@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=="],
|
||||
"bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
@@ -559,30 +540,124 @@
|
||||
|
||||
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||
|
||||
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
|
||||
"protobufjs/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
|
||||
|
||||
"string-width/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=="],
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
"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=="],
|
||||
|
||||
"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/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/ws/@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=="],
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
|
||||
"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-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
|
||||
"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/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||
|
||||
"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:
|
||||
tgchatbot:
|
||||
container_name: tgchatbot
|
||||
image: ghcr.io/melod1n/tg-chat-bot:${IMAGE_TAG:-1.0.0}
|
||||
image: ghcr.io/melod1n/tg-chat-bot:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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),
|
||||
},
|
||||
});
|
||||
+15
-26
@@ -1,42 +1,31 @@
|
||||
import js from "@eslint/js";
|
||||
import {defineConfig} from "eslint/config";
|
||||
import tseslint from "typescript-eslint";
|
||||
const tsParser = require("@typescript-eslint/parser");
|
||||
const tsPlugin = require("@typescript-eslint/eslint-plugin");
|
||||
|
||||
export default defineConfig(
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"data/**",
|
||||
"node_modules/**",
|
||||
"**/*.tsbuildinfo",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin,
|
||||
},
|
||||
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",
|
||||
"no-unused-vars": "off",
|
||||
"quotes": "warn",
|
||||
"semi": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/logging/logger.ts"],
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
+4
-16
@@ -8,13 +8,6 @@
|
||||
},
|
||||
"providerChoice.default": "Default",
|
||||
"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...",
|
||||
"analyzingPictureText": "🔍 Analyzing the image...",
|
||||
"analyzingPicturesText": "🔍 Analyzing the images...",
|
||||
@@ -69,21 +62,18 @@
|
||||
"userSettingsResponseLanguageSelectionTitle": "Response Language Selection",
|
||||
"userSettingsContextSizeSelectionTitle": "Context Size Selection",
|
||||
"userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection",
|
||||
"userSettingsImageOutputSelectionTitle": "Image Output Mode Selection",
|
||||
"userSettingsTierLabel": "Tier",
|
||||
"userSettingsAiProviderLabel": "AI provider",
|
||||
"userSettingsInterfaceLanguageLabel": "Interface language",
|
||||
"userSettingsResponseLanguageLabel": "LLM response language",
|
||||
"userSettingsContextSizeLabel": "Context size",
|
||||
"userSettingsVoiceModeLabel": "Voice messages",
|
||||
"userSettingsImageOutputLabel": "Image output",
|
||||
"userSettingsBackButtonText": "Back",
|
||||
"userSettingsAiProviderButtonPrefix": "AI provider",
|
||||
"userSettingsInterfaceLanguageButtonPrefix": "Interface language",
|
||||
"userSettingsResponseLanguageButtonPrefix": "Response language",
|
||||
"userSettingsContextSizeButtonPrefix": "Context",
|
||||
"userSettingsVoiceModeButtonPrefix": "Voice",
|
||||
"userSettingsImageOutputButtonPrefix": "Image output",
|
||||
"userSettingsCreatorTierText": "Creator",
|
||||
"userSettingsAdminTierText": "Admin",
|
||||
"userSettingsUserTierText": "User",
|
||||
@@ -91,8 +81,6 @@
|
||||
"userSettingsContextSizeDefaultText": "Default",
|
||||
"userSettingsVoiceModeExecuteText": "Run through AI",
|
||||
"userSettingsVoiceModeTranscriptText": "Show transcript only",
|
||||
"userSettingsImageOutputPhotoText": "As photo",
|
||||
"userSettingsImageOutputDocumentText": "As document",
|
||||
"startingImageGenText": "🌈 Starting image generation...",
|
||||
"imageGenText": "🌈 Generating image...",
|
||||
"finalizingImageGenText": "🌈 Finalizing image generation...",
|
||||
@@ -148,7 +136,6 @@
|
||||
"getPreparingRAGText.default": "🔍 Preparing RAG for the document...",
|
||||
"getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`",
|
||||
"getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}",
|
||||
"getSelectingToolsText": "🧩 Choosing the right tools...",
|
||||
"getBuildingRAGIndexText.default": "🧠 Building RAG index...",
|
||||
"getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.",
|
||||
"queueNoneText": "none",
|
||||
@@ -183,9 +170,6 @@
|
||||
"getWhenPluralUnitText": "{unit}s",
|
||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||
"commandDescriptions": {
|
||||
"aiAudit": "Inspect AI request audit and artifacts",
|
||||
"aiMetrics": "Show AI observability counters",
|
||||
"aiRequests": "Show recent AI requests",
|
||||
"ae": "evaluation",
|
||||
"adminsAdd": "Add user to admins",
|
||||
"adminsRemove": "Remove user from admins",
|
||||
@@ -195,6 +179,10 @@
|
||||
"debug": "Returns msg (or reply) as json",
|
||||
"dice": "Sends random or specific dice",
|
||||
"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",
|
||||
"id": "ID of chat, user and reply (if replied to any message)",
|
||||
"ignore": "Bot will ignore user",
|
||||
|
||||
+4
-16
@@ -8,13 +8,6 @@
|
||||
},
|
||||
"providerChoice.default": "По умолчанию",
|
||||
"errorText": "⚠️ Произошла ошибка.",
|
||||
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
|
||||
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
|
||||
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
|
||||
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
|
||||
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
|
||||
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
|
||||
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
|
||||
"waitThinkText": "⏳ Дайте-ка подумать...",
|
||||
"analyzingPictureText": "🔍 Анализирую изображение...",
|
||||
"analyzingPicturesText": "🔍 Анализирую изображения...",
|
||||
@@ -95,21 +88,18 @@
|
||||
"userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов",
|
||||
"userSettingsContextSizeSelectionTitle": "Выбор размера контекста",
|
||||
"userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений",
|
||||
"userSettingsImageOutputSelectionTitle": "Режим отправки изображений",
|
||||
"userSettingsTierLabel": "Уровень",
|
||||
"userSettingsAiProviderLabel": "AI-провайдер",
|
||||
"userSettingsInterfaceLanguageLabel": "Язык интерфейса",
|
||||
"userSettingsResponseLanguageLabel": "Язык ответов LLM",
|
||||
"userSettingsContextSizeLabel": "Размер контекста",
|
||||
"userSettingsVoiceModeLabel": "Голосовые сообщения",
|
||||
"userSettingsImageOutputLabel": "Изображения",
|
||||
"userSettingsBackButtonText": "Назад",
|
||||
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
|
||||
"userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса",
|
||||
"userSettingsResponseLanguageButtonPrefix": "Язык ответов",
|
||||
"userSettingsContextSizeButtonPrefix": "Контекст",
|
||||
"userSettingsVoiceModeButtonPrefix": "Голосовые",
|
||||
"userSettingsImageOutputButtonPrefix": "Изображения",
|
||||
"userSettingsCreatorTierText": "Создатель",
|
||||
"userSettingsAdminTierText": "Админ",
|
||||
"userSettingsUserTierText": "Пользователь",
|
||||
@@ -117,8 +107,6 @@
|
||||
"userSettingsContextSizeDefaultText": "По умолчанию",
|
||||
"userSettingsVoiceModeExecuteText": "Выполнять через ИИ",
|
||||
"userSettingsVoiceModeTranscriptText": "Только расшифровка",
|
||||
"userSettingsImageOutputPhotoText": "Как фото",
|
||||
"userSettingsImageOutputDocumentText": "Как документ",
|
||||
"startingImageGenText": "🌈 Запускаю генерацию изображения...",
|
||||
"imageGenText": "🌈 Генерирую изображение...",
|
||||
"finalizingImageGenText": "🌈 Завершаю генерацию изображения...",
|
||||
@@ -174,7 +162,6 @@
|
||||
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
|
||||
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
|
||||
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
|
||||
"getSelectingToolsText": "🧩 Выбираю подходящие инструменты...",
|
||||
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
|
||||
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
|
||||
"queueNoneText": "нет",
|
||||
@@ -209,9 +196,6 @@
|
||||
"getWhenPluralUnitText": "{unit}",
|
||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||
"commandDescriptions": {
|
||||
"aiRequests": "Показать последние AI-запросы",
|
||||
"aiAudit": "Показать аудит AI-запроса и артефакты",
|
||||
"aiMetrics": "Показать счётчики AI-обсервабилити",
|
||||
"ae": "вычисление",
|
||||
"adminsAdd": "Добавить пользователя в администраторы",
|
||||
"adminsRemove": "Удалить пользователя из администраторов",
|
||||
@@ -221,6 +205,10 @@
|
||||
"debug": "Вернуть msg или reply в JSON",
|
||||
"dice": "Отправить случайный или конкретный дайс",
|
||||
"distort": "Искажение изображения",
|
||||
"geminiChat": "Чат с AI (Gemini)",
|
||||
"geminiGetModel": "Показать текущую модель Gemini",
|
||||
"geminiListModels": "Показать все модели Gemini",
|
||||
"geminiSetModel": "Установить модель Gemini",
|
||||
"help": "Показать список команд",
|
||||
"id": "ID чата, пользователя и ответа",
|
||||
"ignore": "Бот будет игнорировать пользователя",
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
},
|
||||
"providerChoice.default": "За замовчуванням",
|
||||
"errorText": "⚠️ Сталася помилка.",
|
||||
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
|
||||
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
|
||||
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
|
||||
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
|
||||
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
|
||||
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
|
||||
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
|
||||
"waitThinkText": "⏳ Дайте-но подумати...",
|
||||
"analyzingPictureText": "🔍 Аналізую зображення...",
|
||||
"analyzingPicturesText": "🔍 Аналізую зображення...",
|
||||
@@ -94,21 +87,18 @@
|
||||
"userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей",
|
||||
"userSettingsContextSizeSelectionTitle": "Вибір розміру контексту",
|
||||
"userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень",
|
||||
"userSettingsImageOutputSelectionTitle": "Режим надсилання зображень",
|
||||
"userSettingsTierLabel": "Рівень",
|
||||
"userSettingsAiProviderLabel": "AI-провайдер",
|
||||
"userSettingsInterfaceLanguageLabel": "Мова інтерфейсу",
|
||||
"userSettingsResponseLanguageLabel": "Мова відповідей LLM",
|
||||
"userSettingsContextSizeLabel": "Розмір контексту",
|
||||
"userSettingsVoiceModeLabel": "Голосові повідомлення",
|
||||
"userSettingsImageOutputLabel": "Зображення",
|
||||
"userSettingsBackButtonText": "Назад",
|
||||
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
|
||||
"userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу",
|
||||
"userSettingsResponseLanguageButtonPrefix": "Мова відповідей",
|
||||
"userSettingsContextSizeButtonPrefix": "Контекст",
|
||||
"userSettingsVoiceModeButtonPrefix": "Голосові",
|
||||
"userSettingsImageOutputButtonPrefix": "Зображення",
|
||||
"userSettingsCreatorTierText": "Творець",
|
||||
"userSettingsAdminTierText": "Адмін",
|
||||
"userSettingsUserTierText": "Користувач",
|
||||
@@ -116,8 +106,6 @@
|
||||
"userSettingsContextSizeDefaultText": "За замовчуванням",
|
||||
"userSettingsVoiceModeExecuteText": "Виконувати через AI",
|
||||
"userSettingsVoiceModeTranscriptText": "Лише розшифровка",
|
||||
"userSettingsImageOutputPhotoText": "Як фото",
|
||||
"userSettingsImageOutputDocumentText": "Як документ",
|
||||
"startingImageGenText": "🌈 Запускаю генерацію зображення...",
|
||||
"imageGenText": "🌈 Генерую зображення...",
|
||||
"finalizingImageGenText": "🌈 Завершую генерацію зображення...",
|
||||
@@ -173,7 +161,6 @@
|
||||
"getPreparingRAGText.default": "🔍 Готую RAG для документа...",
|
||||
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
|
||||
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
|
||||
"getSelectingToolsText": "🧩 Вибираю підхожі інструменти...",
|
||||
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
|
||||
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
|
||||
"queueNoneText": "немає",
|
||||
@@ -208,9 +195,6 @@
|
||||
"getWhenPluralUnitText": "{unit}",
|
||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||
"commandDescriptions": {
|
||||
"aiRequests": "Показати останні AI-запити",
|
||||
"aiAudit": "Показати аудит AI-запиту та артефакти",
|
||||
"aiMetrics": "Показати лічильники AI-спостережуваності",
|
||||
"help": "Показати список команд",
|
||||
"settings": "Налаштування користувача",
|
||||
"start": "Запустити бота",
|
||||
|
||||
Generated
+1872
-1495
File diff suppressed because it is too large
Load Diff
+11
-18
@@ -4,42 +4,35 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"build": "tsgo -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"bun:start": "bun run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@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",
|
||||
"@napi-rs/canvas": "^1.0.0",
|
||||
"axios": "^1.16.1",
|
||||
"axios": "^1.16.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"emoji-regex": "^10.6.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.38.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5",
|
||||
"systeminformation": "^5.31.6",
|
||||
"twemoji": "^14.0.2",
|
||||
"typescript-telegram-bot-api": "^0.16.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/node": "^25.6.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"eslint": "^9.39.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4"
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
+48
-35
@@ -1,12 +1,13 @@
|
||||
import {Mistral} from "@mistralai/mistralai";
|
||||
import {GoogleGenAI} from "@google/genai";
|
||||
import {Ollama} from "ollama";
|
||||
import {OpenAI} from "openai";
|
||||
import {Environment} from "../common/environment.js";
|
||||
import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
|
||||
import {AiProvider} from "../model/ai-provider.js";
|
||||
import {Environment} from "../common/environment";
|
||||
import {AiModelCapabilities} from "../model/ai-model-capabilities";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
|
||||
export type AiCapabilityName = keyof AiModelCapabilities;
|
||||
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
|
||||
export type AiRuntimePurpose = AiCapabilityName | "chat";
|
||||
|
||||
export type AiRuntimeTarget = {
|
||||
provider: AiProvider;
|
||||
@@ -14,9 +15,12 @@ export type AiRuntimeTarget = {
|
||||
model: string;
|
||||
baseUrl?: 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[]> = {
|
||||
chat: ["CHAT"],
|
||||
vision: ["VISION", "IMAGE"],
|
||||
@@ -24,7 +28,6 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
|
||||
thinking: ["THINKING", "THINK"],
|
||||
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
|
||||
tools: ["TOOLS", "CHAT"],
|
||||
memoryCompress: ["MEMORY_COMPRESS"],
|
||||
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
||||
audio: ["AUDIO"],
|
||||
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
||||
@@ -69,18 +72,13 @@ function modelEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[
|
||||
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 {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
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:
|
||||
return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT");
|
||||
case AiProvider.OPENAI:
|
||||
@@ -92,6 +90,8 @@ export function getProviderApiKey(provider: AiProvider): string | undefined {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return Environment.OLLAMA_API_KEY;
|
||||
case AiProvider.GEMINI:
|
||||
return Environment.GEMINI_API_KEY;
|
||||
case AiProvider.MISTRAL:
|
||||
return Environment.MISTRAL_API_KEY;
|
||||
case AiProvider.OPENAI:
|
||||
@@ -118,6 +118,19 @@ export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRunti
|
||||
default:
|
||||
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:
|
||||
switch (purpose) {
|
||||
case "speechToText":
|
||||
@@ -151,28 +164,8 @@ export function resolveAiRuntimeTarget(
|
||||
?? getDefaultModelForPurpose(provider, purpose);
|
||||
const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider);
|
||||
const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider);
|
||||
const systemPromptAdditions = firstEnv(systemPromptEnvNames(provider, purpose));
|
||||
|
||||
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);
|
||||
return {provider, purpose, model, baseUrl, apiKey};
|
||||
}
|
||||
|
||||
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
|
||||
@@ -188,6 +181,26 @@ 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 {
|
||||
return new Mistral({
|
||||
apiKey: target.apiKey,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {AiToolCall} from "./tool-types";
|
||||
import {OllamaChatMessage} from "./ollama-chat-message";
|
||||
import {GeminiMessage} from "./gemini-chat-message";
|
||||
import {MistralChatMessage} from "./mistral-chat-message";
|
||||
import {MessageAudioPart, MessageImagePart} from "../common/message-part";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
@@ -30,6 +31,27 @@ 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 {
|
||||
return {
|
||||
role: message.role,
|
||||
@@ -42,4 +64,6 @@ export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
export type AiChatMessage = OpenAIChatMessage | OllamaChatMessage | MistralChatMessage;
|
||||
|
||||
|
||||
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
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};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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[];
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
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});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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] : [];
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
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 = {
|
||||
name: string;
|
||||
arguments: AiJsonObject | string;
|
||||
arguments: Record<string, unknown> | string;
|
||||
};
|
||||
|
||||
export type MistralToolCall = {
|
||||
@@ -109,5 +109,4 @@ export type MistralChatMessage =
|
||||
| MistralAssistantMessage
|
||||
| MistralSystemMessage
|
||||
| MistralToolMessage
|
||||
| MistralUserMessage
|
||||
import {AiJsonObject} from "./tool-types";
|
||||
| MistralUserMessage
|
||||
@@ -1,5 +0,0 @@
|
||||
export async function runSingleModelRequest<T>(params: {
|
||||
execute: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
return await params.execute();
|
||||
}
|
||||
+9
-88
@@ -476,40 +476,6 @@ type ExtractedRagDocument = {
|
||||
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 = {
|
||||
fileName: string;
|
||||
reason: string;
|
||||
@@ -621,7 +587,7 @@ function reserveArchiveFile(
|
||||
return true;
|
||||
}
|
||||
|
||||
function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: Error | string | object | null | undefined): void {
|
||||
function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: unknown): void {
|
||||
state.skipped.push({
|
||||
fileName,
|
||||
reason: reason instanceof Error ? reason.message : String(reason),
|
||||
@@ -641,7 +607,7 @@ function extractArchiveChildDocuments(
|
||||
try {
|
||||
return extractRagDocumentsFromFile(child, config, state, depth + 1);
|
||||
} catch (e) {
|
||||
pushArchiveSkip(state, child.fileName, e instanceof Error ? e : String(e));
|
||||
pushArchiveSkip(state, child.fileName, e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -675,7 +641,7 @@ function extractZipArchiveDocuments(
|
||||
const buffer = readZipEntry(doc.buffer, entry);
|
||||
documents.push(...extractArchiveChildDocuments(doc, normalizedName, buffer, config, state, depth));
|
||||
} catch (e) {
|
||||
pushArchiveSkip(state, displayName, e instanceof Error ? e : String(e));
|
||||
pushArchiveSkip(state, displayName, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1269,48 +1235,6 @@ function formatRagContext(chunks: DocumentChunk[], totalChunks: number, document
|
||||
].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 {
|
||||
const systemIndex = messages.findIndex(message => message.role === "system");
|
||||
|
||||
@@ -1334,7 +1258,7 @@ export async function buildOllamaDocumentRagContext(params: {
|
||||
userQuery: string;
|
||||
config: OllamaDocumentRagConfig;
|
||||
onStatus?: (status: string) => Promise<void> | void;
|
||||
}): Promise<{context: string; artifact: OllamaRagArtifactDetails} | null> {
|
||||
}): Promise<string | null> {
|
||||
const docs = params.downloads.filter(download => download.kind === "document");
|
||||
if (!docs.length) return null;
|
||||
|
||||
@@ -1409,10 +1333,7 @@ export async function buildOllamaDocumentRagContext(params: {
|
||||
throw new Error(Environment.localRagNoSuitableFragmentsText);
|
||||
}
|
||||
|
||||
return {
|
||||
context: formatRagContext(selected, chunks.length, documents, skippedDocuments),
|
||||
artifact: buildOllamaRagArtifactDetails(buildRetrievalQuery(params.userQuery, params.messages), documents, selected, skippedDocuments, params.config),
|
||||
};
|
||||
return formatRagContext(selected, chunks.length, documents, skippedDocuments);
|
||||
}
|
||||
|
||||
export async function prepareOllamaDocumentRag(params: {
|
||||
@@ -1421,7 +1342,7 @@ export async function prepareOllamaDocumentRag(params: {
|
||||
userQuery: string;
|
||||
message: TelegramStreamMessage;
|
||||
config: OllamaDocumentRagConfig;
|
||||
}): Promise<{prepared: boolean; artifact?: OllamaRagArtifactDetails}> {
|
||||
}): Promise<boolean> {
|
||||
const context = await buildOllamaDocumentRagContext({
|
||||
downloads: params.downloads,
|
||||
messages: params.messages,
|
||||
@@ -1433,7 +1354,7 @@ export async function prepareOllamaDocumentRag(params: {
|
||||
},
|
||||
});
|
||||
|
||||
if (!context) return {prepared: false};
|
||||
injectOllamaRagContext(params.messages, context.context);
|
||||
return {prepared: true, artifact: context.artifact};
|
||||
if (!context) return false;
|
||||
injectOllamaRagContext(params.messages, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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,22 +1,7 @@
|
||||
import type {
|
||||
ResponseInputMessageContentList,
|
||||
ResponseOutputMessage,
|
||||
} from "openai/resources/responses/responses";
|
||||
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||
|
||||
type OpenAIInputChatMessage = {
|
||||
export type OpenAIChatMessage = {
|
||||
type: "message";
|
||||
role: "system" | "user";
|
||||
role: "system" | "user" | "assistant";
|
||||
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;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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 : "";
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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,9 +7,12 @@ import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
|
||||
import {
|
||||
AiCapabilityName,
|
||||
AiRuntimeTarget,
|
||||
createGeminiOpenAiClient,
|
||||
createGoogleGenAiClient,
|
||||
createMistralClient,
|
||||
createOllamaClient,
|
||||
createOpenAiClient,
|
||||
getGeminiApiMode,
|
||||
resolveAiRuntimeTarget,
|
||||
sameRuntimeEndpoint,
|
||||
} from "./ai-runtime-target";
|
||||
@@ -32,6 +35,8 @@ export function getRuntimeModel(provider: AiProvider): string {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return Environment.OLLAMA_CHAT_MODEL;
|
||||
case AiProvider.GEMINI:
|
||||
return Environment.GEMINI_MODEL;
|
||||
case AiProvider.MISTRAL:
|
||||
return Environment.MISTRAL_MODEL;
|
||||
case AiProvider.OPENAI:
|
||||
@@ -44,6 +49,9 @@ export function setRuntimeModel(provider: AiProvider, model: string): void {
|
||||
case AiProvider.OLLAMA:
|
||||
Environment.OLLAMA_CHAT_MODEL = model;
|
||||
break;
|
||||
case AiProvider.GEMINI:
|
||||
Environment.GEMINI_MODEL = model;
|
||||
break;
|
||||
case AiProvider.MISTRAL:
|
||||
Environment.MISTRAL_MODEL = model;
|
||||
break;
|
||||
@@ -103,6 +111,12 @@ function isOpenAiReasoningModel(model: string): boolean {
|
||||
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 {
|
||||
const name = lowerModelName(model);
|
||||
if (!isOpenAiTextModel(model)) return false;
|
||||
@@ -111,6 +125,16 @@ function isOpenAiVisionModel(model: string): boolean {
|
||||
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(
|
||||
provider: AiProvider,
|
||||
model: string,
|
||||
@@ -143,6 +167,26 @@ export async function getModelCapabilities(
|
||||
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: {
|
||||
const mistral = createMistralClient(target);
|
||||
const info = await mistral.models.retrieve({modelId: model});
|
||||
@@ -176,7 +220,6 @@ export async function getModelCapabilities(
|
||||
thinking: capability(reasoningModel, target, runtimeTarget),
|
||||
extendedThinking: capability(reasoningModel, target, runtimeTarget),
|
||||
tools: capability(textModel, target, runtimeTarget),
|
||||
documents: capability(textModel, target, runtimeTarget),
|
||||
outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget),
|
||||
speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget),
|
||||
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
|
||||
@@ -185,7 +228,7 @@ export async function getModelCapabilities(
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -196,14 +239,9 @@ export async function getRuntimeCapabilities(
|
||||
target?: AiRuntimeTarget
|
||||
): Promise<AiModelCapabilities> {
|
||||
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
|
||||
const targetPurpose = target?.purpose && target.purpose !== "memoryCompress" ? target.purpose : "chat";
|
||||
const result = await getModelCapabilities(provider, runtimeTarget.model, targetPurpose) ?? buildCapabilities({});
|
||||
const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
|
||||
|
||||
for (const capabilityName of CAPABILITY_NAMES) {
|
||||
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = resolveAiRuntimeTarget(provider, capabilityName);
|
||||
if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue;
|
||||
|
||||
@@ -278,12 +316,29 @@ type ModelListResponse = {
|
||||
export async function listProviderModels(provider: AiProvider): Promise<string[]> {
|
||||
const target = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
|
||||
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA: {
|
||||
const ollama = createOllamaClient(target);
|
||||
const result = await ollama.list() as ModelListResponse;
|
||||
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA: {
|
||||
const ollama = createOllamaClient(target);
|
||||
const result = await ollama.list() as ModelListResponse;
|
||||
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
|
||||
}
|
||||
case AiProvider.GEMINI: {
|
||||
const models: string[] = [];
|
||||
if (getGeminiApiMode(target) === "openai") {
|
||||
const geminiAi = createGeminiOpenAiClient(target);
|
||||
const iterable = 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: {
|
||||
const mistralAi = createMistralClient(target);
|
||||
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {Environment} from "../common/environment";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {appLogger} from "../logging/logger";
|
||||
import type {BoundaryValue} from "../common/boundary-types";
|
||||
|
||||
const logger = appLogger.child("ai-provider-queue");
|
||||
|
||||
@@ -14,16 +13,16 @@ export type AiRequestQueueTarget = {
|
||||
type QueueEntry = {
|
||||
target: AiRequestQueueTarget;
|
||||
queueKey: string;
|
||||
run: () => Promise<BoundaryValue>;
|
||||
resolve: (value: BoundaryValue) => void;
|
||||
reject: (reason?: Error | string | BoundaryValue | null | undefined) => void;
|
||||
run: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
||||
signal?: AbortSignal;
|
||||
abortHandler?: () => void;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
type EnqueueOptions<T extends BoundaryValue> = {
|
||||
type EnqueueOptions<T> = {
|
||||
signal?: AbortSignal;
|
||||
onPositionChange: (requestsBefore: number) => Promise<void> | void;
|
||||
run: () => Promise<T>;
|
||||
@@ -33,7 +32,7 @@ class AiProviderRequestQueue {
|
||||
private readonly waiting = new Map<string, QueueEntry[]>();
|
||||
private readonly active = new Map<string, number>();
|
||||
|
||||
enqueue<T extends BoundaryValue>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
|
||||
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
|
||||
if (options.signal?.aborted) {
|
||||
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
|
||||
return Promise.reject(new Error("Aborted"));
|
||||
@@ -161,9 +160,8 @@ class AiProviderRequestQueue {
|
||||
entry.resolve(await entry.run());
|
||||
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : String(e);
|
||||
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error});
|
||||
entry.reject(error);
|
||||
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error: e});
|
||||
entry.reject(e);
|
||||
} finally {
|
||||
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
|
||||
this.schedule(entry.target);
|
||||
@@ -180,10 +178,10 @@ class AiProviderRequestQueue {
|
||||
})).then(results => {
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason instanceof Error ? result.reason : String(result.reason)});
|
||||
logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason});
|
||||
}
|
||||
}
|
||||
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error: error instanceof Error ? error : String(error)}));
|
||||
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error}));
|
||||
}
|
||||
|
||||
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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};
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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("этот документ");
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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,13 +2,19 @@ import fs, {openAsBlob} from "node:fs";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {
|
||||
getAvailableAiProviderChoices,
|
||||
getProviderChoiceLabel,
|
||||
normalizeAiProviderChoice,
|
||||
resolveEffectiveAiProviderForUser,
|
||||
} from "../common/user-ai-settings";
|
||||
import {providerDisplayName} from "./provider-aliases";
|
||||
import {AiDownloadedFile} from "./telegram-attachments";
|
||||
import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
|
||||
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
||||
import {
|
||||
createGoogleGenAiClient,
|
||||
createMistralClient,
|
||||
createOllamaClient,
|
||||
createOpenAiClient,
|
||||
resolveAiRuntimeTarget
|
||||
} from "./ai-runtime-target";
|
||||
import {Environment} from "../common/environment";
|
||||
|
||||
export type TranscribedSpeech = {
|
||||
@@ -33,6 +39,10 @@ export type SpeechToTextResolveOptions = {
|
||||
allowFallback?: boolean;
|
||||
};
|
||||
|
||||
function providerName(provider: AiProvider): string {
|
||||
return getProviderChoiceLabel(provider);
|
||||
}
|
||||
|
||||
export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean {
|
||||
if (download.kind === "audio") return true;
|
||||
return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav"));
|
||||
@@ -43,6 +53,9 @@ export function isSpeechToTextConfigured(provider: AiProvider): boolean {
|
||||
case AiProvider.OPENAI:
|
||||
const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||
return !!openAiTarget.apiKey && !!openAiTarget.model;
|
||||
case AiProvider.GEMINI:
|
||||
const geminiTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||
return !!geminiTarget.apiKey && !!geminiTarget.model;
|
||||
case AiProvider.MISTRAL:
|
||||
const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||
return !!mistralTarget.apiKey && !!mistralTarget.model;
|
||||
@@ -65,7 +78,7 @@ export async function resolveSpeechToTextProviderForUser(
|
||||
|
||||
if (preferredProvider) {
|
||||
if (!allowedProviders.includes(preferredProvider)) {
|
||||
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(preferredProvider)));
|
||||
throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(preferredProvider)));
|
||||
}
|
||||
|
||||
if (isSpeechToTextConfigured(preferredProvider)) {
|
||||
@@ -73,7 +86,7 @@ export async function resolveSpeechToTextProviderForUser(
|
||||
}
|
||||
|
||||
if (!allowFallback) {
|
||||
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerDisplayName(preferredProvider)));
|
||||
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerName(preferredProvider)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +112,8 @@ export async function transcribeSpeech(request: SpeechToTextRequest): Promise<Tr
|
||||
switch (request.provider) {
|
||||
case AiProvider.OPENAI:
|
||||
return transcribeOpenAiSpeech(request.audio, request.signal);
|
||||
case AiProvider.GEMINI:
|
||||
return transcribeGeminiSpeech(request.audio, request.signal);
|
||||
case AiProvider.MISTRAL:
|
||||
return transcribeMistralSpeech(request.audio, request.signal);
|
||||
case AiProvider.OLLAMA:
|
||||
@@ -162,6 +177,37 @@ 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> {
|
||||
if (signal?.aborted) throw new Error("Aborted");
|
||||
|
||||
@@ -193,3 +239,20 @@ async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSig
|
||||
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 "";
|
||||
}
|
||||
|
||||
+25
-219
@@ -9,8 +9,6 @@ import {performFFmpeg} from "../util/ffmpeg";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
|
||||
import {appLogger} from "../logging/logger";
|
||||
import {createHash} from "node:crypto";
|
||||
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline/types";
|
||||
|
||||
export type AiDownloadedFile = {
|
||||
kind: StoredAttachmentKind;
|
||||
@@ -19,33 +17,6 @@ export type AiDownloadedFile = {
|
||||
mimeType?: string;
|
||||
buffer: Buffer;
|
||||
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();
|
||||
@@ -120,113 +91,7 @@ function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefin
|
||||
return path.join(cacheDirFor(kind), `${base}${ext || ""}`);
|
||||
}
|
||||
|
||||
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> {
|
||||
async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("download.start", {kind, fileId, fileName, mimeType});
|
||||
const file = await bot.getFile({file_id: fileId});
|
||||
@@ -252,17 +117,7 @@ async function downloadToCache(
|
||||
logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)});
|
||||
});
|
||||
|
||||
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),
|
||||
};
|
||||
return {kind, fileId, fileUniqueId, fileName: finalFileName, mimeType, cachePath: location};
|
||||
}
|
||||
|
||||
async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> {
|
||||
@@ -298,28 +153,23 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
|
||||
if (fs.existsSync(tempOutput)) {
|
||||
fs.rmSync(tempOutput, {force: true});
|
||||
}
|
||||
logger.error("audio.convert.failed", {input, output, error: e instanceof Error ? e : String(e)});
|
||||
logger.error("audio.convert.failed", {input, output, error: e});
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cacheMessageAttachmentsWithRejections(msg: Message): Promise<MessageAttachmentCacheResult> {
|
||||
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
|
||||
const startedAt = Date.now();
|
||||
const result: StoredAttachment[] = [];
|
||||
const rejected: RejectedTelegramAttachment[] = [];
|
||||
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
|
||||
|
||||
try {
|
||||
if (msg.photo?.length) {
|
||||
const size = msg.photo[msg.photo.length - 1]!;
|
||||
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);
|
||||
}
|
||||
const file = await downloadToCache("image", size.file_id, `${size.file_unique_id || size.file_id}.jpg`, "image/jpeg", size.file_unique_id);
|
||||
if (file) result.push(file);
|
||||
}
|
||||
|
||||
if (msg.document) {
|
||||
@@ -329,19 +179,12 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
|
||||
: doc.mime_type?.startsWith("audio/")
|
||||
? "audio"
|
||||
: "document";
|
||||
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);
|
||||
}
|
||||
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);
|
||||
if (file) result.push(file);
|
||||
}
|
||||
|
||||
if (msg.voice) {
|
||||
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);
|
||||
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);
|
||||
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`);
|
||||
try {
|
||||
@@ -349,10 +192,8 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
|
||||
file.cachePath = output;
|
||||
file.fileName = file?.fileName?.replace(".ogg", ".wav");
|
||||
file.mimeType = "audio/wav";
|
||||
file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes;
|
||||
file.sha256 = fileSha256(output);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,19 +201,12 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
|
||||
}
|
||||
|
||||
if (msg.audio) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
if (file) result.push(file);
|
||||
}
|
||||
|
||||
if (msg.video_note) {
|
||||
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);
|
||||
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);
|
||||
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`);
|
||||
try {
|
||||
@@ -380,61 +214,33 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
|
||||
file.cachePath = output;
|
||||
file.fileName = file?.fileName?.replace(".mp4", ".wav");
|
||||
file.mimeType = "audio/wav";
|
||||
file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes;
|
||||
file.sha256 = fileSha256(output);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (file) result.push(file);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
}
|
||||
|
||||
logger.debug("message.cache.done", {
|
||||
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;
|
||||
logger.debug("message.cache.done", {chatId: msg.chat?.id, messageId: msg.message_id, attachments: result.length, duration: logger.duration(startedAt)});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
|
||||
logger.trace("downloaded_files.build", {attachments: attachments.length});
|
||||
return attachments
|
||||
.filter(attachment => fs.existsSync(attachment.cachePath))
|
||||
.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,
|
||||
fileId: attachment.fileId,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
buffer: fs.readFileSync(attachment.cachePath),
|
||||
path: attachment.cachePath,
|
||||
sizeBytes,
|
||||
sha256: attachment.sha256,
|
||||
}];
|
||||
});
|
||||
.map(attachment => ({
|
||||
kind: attachment.kind,
|
||||
fileId: attachment.fileId,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
buffer: fs.readFileSync(attachment.cachePath),
|
||||
path: attachment.cachePath,
|
||||
}));
|
||||
}
|
||||
|
||||
export function cleanupDownloads(files: AiDownloadedFile[]): void {
|
||||
|
||||
@@ -5,22 +5,17 @@ import {Environment} from "../common/environment";
|
||||
import {MessageStore} from "../common/message-store";
|
||||
import {createQueuedFunction} from "../util/async-lock";
|
||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||
import {appLogger} from "../logging/logger";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
|
||||
import {StoredMessage} from "../model/stored-message";
|
||||
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
||||
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_CAPTION_LIMIT = 1024;
|
||||
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||
const EDIT_INTERVAL_MS = 4500;
|
||||
const logger = appLogger.child("telegram-stream-message");
|
||||
|
||||
export type TelegramArtifactFile = {
|
||||
kind: "image" | "file";
|
||||
@@ -30,23 +25,6 @@ export type TelegramArtifactFile = {
|
||||
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 {
|
||||
private waitMessage: Message | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
@@ -56,11 +34,8 @@ export class TelegramStreamMessage {
|
||||
private mediaMode = false;
|
||||
private cancelled = false;
|
||||
private cancelledProvider = "";
|
||||
private readonly sendImagesAsDocuments: boolean;
|
||||
private readonly startedAt = Date.now();
|
||||
private readonly enqueueEdit = createQueuedFunction();
|
||||
private readonly toolExecutions: TelegramToolExecutionRecord[] = [];
|
||||
private readonly outputAttachments: TelegramOutputAttachmentRecord[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sourceMessage: Message,
|
||||
@@ -70,9 +45,7 @@ export class TelegramStreamMessage {
|
||||
private readonly targetMessage?: Message,
|
||||
private readonly cancelProvider?: AiProvider,
|
||||
private readonly isGuest?: boolean,
|
||||
imageOutputMode: UserAiImageOutputMode = "photo",
|
||||
) {
|
||||
this.sendImagesAsDocuments = imageOutputMode === AI_IMAGE_OUTPUT_MODE_DOCUMENT;
|
||||
}
|
||||
|
||||
keyboard(): InlineKeyboardMarkup {
|
||||
@@ -101,8 +74,18 @@ export class TelegramStreamMessage {
|
||||
};
|
||||
}
|
||||
|
||||
private isMessageNotModified(message: string): boolean {
|
||||
return message.includes("message is not modified");
|
||||
private isMessageNotModified(error: unknown): boolean {
|
||||
const textToLookUp = "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> {
|
||||
@@ -122,8 +105,7 @@ export class TelegramStreamMessage {
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
|
||||
if (!this.isMessageNotModified(e)) logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +166,7 @@ export class TelegramStreamMessage {
|
||||
this.startFlushTimer();
|
||||
return this.waitMessage;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (this.isMessageNotModified(message)) {
|
||||
if (this.isMessageNotModified(e)) {
|
||||
this.lastSent = rawText;
|
||||
await this.updateKeyboard(this.keyboard());
|
||||
await this.store();
|
||||
@@ -193,7 +174,7 @@ export class TelegramStreamMessage {
|
||||
return this.waitMessage;
|
||||
}
|
||||
|
||||
logError(e instanceof Error ? e : message);
|
||||
logError(e);
|
||||
this.waitMessage = null;
|
||||
this.mediaMode = false;
|
||||
}
|
||||
@@ -239,44 +220,6 @@ export class TelegramStreamMessage {
|
||||
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> {
|
||||
return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end));
|
||||
}
|
||||
@@ -349,14 +292,13 @@ export class TelegramStreamMessage {
|
||||
}
|
||||
if (shouldRemoveKeyboard) await this.removeKeyboard();
|
||||
this.lastSent = next;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (shouldRemoveKeyboard && this.isMessageNotModified(message)) {
|
||||
} catch (e: unknown) {
|
||||
if (shouldRemoveKeyboard && this.isMessageNotModified(e)) {
|
||||
await this.removeKeyboard();
|
||||
this.lastSent = next;
|
||||
return;
|
||||
}
|
||||
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
|
||||
if (!this.isMessageNotModified(e)) logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,68 +313,51 @@ export class TelegramStreamMessage {
|
||||
await this.store();
|
||||
}
|
||||
|
||||
async showImage(image: Buffer, attachment?: StoredAttachment): Promise<void> {
|
||||
return this.enqueueEdit(() => this.showImageUnsafe(image, attachment));
|
||||
async showImage(image: Buffer): Promise<void> {
|
||||
return this.enqueueEdit(() => this.showImageUnsafe(image));
|
||||
}
|
||||
|
||||
async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> {
|
||||
return this.enqueueEdit(() => this.sendArtifactUnsafe(file));
|
||||
}
|
||||
|
||||
private async showImageUnsafe(image: Buffer, attachment?: StoredAttachment): Promise<void> {
|
||||
private async showImageUnsafe(image: Buffer): Promise<void> {
|
||||
if (this.cancelled) return;
|
||||
const next = this.visibleCaption();
|
||||
const useDocument = this.sendImagesAsDocuments;
|
||||
|
||||
if (!this.waitMessage) {
|
||||
if (this.stream) return;
|
||||
|
||||
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
|
||||
try {
|
||||
this.waitMessage = useDocument
|
||||
? await this.sendImageAsDocument(upload!, next)
|
||||
: await enqueueTelegramApiCall(
|
||||
() => bot.sendPhoto({
|
||||
chat_id: this.sourceMessage.chat.id,
|
||||
photo: image,
|
||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||
parse_mode: "MarkdownV2",
|
||||
reply_parameters: {message_id: this.sourceMessage.message_id},
|
||||
}),
|
||||
{
|
||||
method: "sendPhoto",
|
||||
chatId: this.sourceMessage.chat.id,
|
||||
chatType: this.sourceMessage.chat.type,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (upload) this.destroyUpload(upload);
|
||||
}
|
||||
this.waitMessage = await enqueueTelegramApiCall(
|
||||
() => bot.sendPhoto({
|
||||
chat_id: this.sourceMessage.chat.id,
|
||||
photo: image,
|
||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||
parse_mode: "MarkdownV2",
|
||||
reply_parameters: {message_id: this.sourceMessage.message_id},
|
||||
}),
|
||||
{
|
||||
method: "sendPhoto",
|
||||
chatId: this.sourceMessage.chat.id,
|
||||
chatType: this.sourceMessage.chat.type,
|
||||
}
|
||||
);
|
||||
this.mediaMode = true;
|
||||
this.lastSent = next;
|
||||
await this.storeMediaMessage(this.waitMessage, attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
|
||||
try {
|
||||
const result = await enqueueTelegramApiCall(
|
||||
() => bot.editMessageMedia({
|
||||
chat_id: this.waitMessage!.chat.id,
|
||||
message_id: this.waitMessage!.message_id,
|
||||
media: useDocument
|
||||
? {
|
||||
type: "document",
|
||||
media: upload!,
|
||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||
parse_mode: "MarkdownV2",
|
||||
}
|
||||
: {
|
||||
type: "photo",
|
||||
media: image,
|
||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||
parse_mode: "MarkdownV2",
|
||||
},
|
||||
media: {
|
||||
type: "photo",
|
||||
media: image,
|
||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||
parse_mode: "MarkdownV2",
|
||||
},
|
||||
reply_markup: this.keyboard(),
|
||||
}),
|
||||
{
|
||||
@@ -444,50 +369,19 @@ export class TelegramStreamMessage {
|
||||
if (result && result !== true) this.waitMessage = result;
|
||||
this.mediaMode = true;
|
||||
this.lastSent = next;
|
||||
await this.storeMediaMessage(this.waitMessage, attachment);
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(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);
|
||||
if (!message.includes("message is not modified")) logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
if (this.cancelled) return null;
|
||||
|
||||
if (file.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||
if (file.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
|
||||
throw new Error(Environment.getTelegramFileTooLargeText(
|
||||
file.fileName,
|
||||
PIPELINE_ATTACHMENT_LIMIT_BYTES / 1024 / 1024,
|
||||
TELEGRAM_FILE_LIMIT_BYTES / 1024 / 1024,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -530,7 +424,7 @@ export class TelegramStreamMessage {
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
sent = await this.sendArtifactAsDocument(file, caption);
|
||||
}
|
||||
} else {
|
||||
@@ -538,37 +432,15 @@ export class TelegramStreamMessage {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private isPhotoArtifact(file: TelegramArtifactFile): boolean {
|
||||
if (this.sendImagesAsDocuments) return false;
|
||||
return file.kind === "image"
|
||||
&& file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES
|
||||
&& ["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 {
|
||||
return new FileOptions(fs.createReadStream(file.path), {
|
||||
filename: file.fileName,
|
||||
@@ -582,23 +454,6 @@ 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> {
|
||||
return enqueueTelegramApiCall(
|
||||
async () => {
|
||||
@@ -632,9 +487,6 @@ export class TelegramStreamMessage {
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
cachePath: file.path,
|
||||
sizeBytes: file.sizeBytes,
|
||||
scope: "bot_output",
|
||||
artifactKind: "generated_file",
|
||||
};
|
||||
|
||||
const stored: StoredMessage = {
|
||||
@@ -650,44 +502,6 @@ export class TelegramStreamMessage {
|
||||
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> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
@@ -709,7 +523,7 @@ export class TelegramStreamMessage {
|
||||
await this.store();
|
||||
}
|
||||
|
||||
async fail(error: Error | string | object | null | undefined): Promise<void> {
|
||||
async fail(error: unknown): Promise<void> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
this.status = "";
|
||||
@@ -722,7 +536,7 @@ export class TelegramStreamMessage {
|
||||
try {
|
||||
await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+145
-31
@@ -7,20 +7,20 @@ import {Environment} from "../common/environment";
|
||||
import {bot} from "../index";
|
||||
import {
|
||||
getAvailableAiProviderChoices,
|
||||
getProviderChoiceLabel,
|
||||
normalizeAiProviderChoice,
|
||||
resolveEffectiveAiProviderForUser,
|
||||
} from "../common/user-ai-settings";
|
||||
import {providerDisplayName} from "./provider-aliases";
|
||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||
import {MessageStore} from "../common/message-store";
|
||||
import {StoredAttachment} from "../model/stored-attachment";
|
||||
import {StoredMessage} from "../model/stored-message";
|
||||
import {logError} from "../util/utils";
|
||||
import {SpeechRequest} from "@mistralai/mistralai/models/components";
|
||||
import {createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
||||
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
|
||||
import {createGoogleGenAiClient, createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
||||
|
||||
const MAX_TTS_TEXT_CHARS = 4096;
|
||||
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm";
|
||||
|
||||
@@ -54,6 +54,10 @@ function ttsCacheDir(): string {
|
||||
return path.join(Environment.DATA_PATH, "cache", "audio");
|
||||
}
|
||||
|
||||
function providerName(provider: AiProvider): string {
|
||||
return getProviderChoiceLabel(provider);
|
||||
}
|
||||
|
||||
function assertText(text: string): string {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
@@ -72,6 +76,9 @@ export function isTextToSpeechConfigured(provider: AiProvider): boolean {
|
||||
case AiProvider.OPENAI:
|
||||
const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||
return !!openAiTarget.apiKey && !!openAiTarget.model;
|
||||
case AiProvider.GEMINI:
|
||||
const geminiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||
return !!geminiTarget.apiKey && !!geminiTarget.model;
|
||||
case AiProvider.MISTRAL:
|
||||
const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||
return !!mistralTarget.apiKey && !!mistralTarget.model;
|
||||
@@ -91,11 +98,11 @@ export async function resolveTextToSpeechProviderForUser(
|
||||
|
||||
if (explicitProvider) {
|
||||
if (!allowedProviders.includes(explicitProvider)) {
|
||||
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(explicitProvider)));
|
||||
throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(explicitProvider)));
|
||||
}
|
||||
|
||||
if (!isTextToSpeechConfigured(explicitProvider)) {
|
||||
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerDisplayName(explicitProvider)));
|
||||
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerName(explicitProvider)));
|
||||
}
|
||||
|
||||
return {provider: explicitProvider, fallback: false};
|
||||
@@ -120,6 +127,8 @@ export async function synthesizeSpeech(request: TextToSpeechRequest): Promise<Sy
|
||||
switch (request.provider) {
|
||||
case AiProvider.OPENAI:
|
||||
return synthesizeOpenAiSpeech(text, request.voice);
|
||||
case AiProvider.GEMINI:
|
||||
return synthesizeGeminiSpeech(text, request.voice);
|
||||
case AiProvider.MISTRAL:
|
||||
return synthesizeMistralSpeech(text, request.voice);
|
||||
case AiProvider.OLLAMA:
|
||||
@@ -162,7 +171,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
|
||||
if (target.model) request.model = target.model;
|
||||
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
|
||||
|
||||
const response = await mistralAi.audio.speech.complete(request) as {audioData?: string; audio_data?: string};
|
||||
const response = await mistralAi.audio.speech.complete(request) as unknown as {audioData?: string; audio_data?: string};
|
||||
const audioData = response?.audioData ?? response?.audio_data;
|
||||
if (typeof audioData !== "string" || !audioData.trim()) {
|
||||
throw new Error(Environment.mistralTtsNoAudioDataText);
|
||||
@@ -180,6 +189,130 @@ 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 {
|
||||
fs.mkdirSync(ttsCacheDir(), {recursive: true});
|
||||
|
||||
@@ -213,11 +346,11 @@ function destroyUpload(upload: FileOptions): void {
|
||||
}
|
||||
|
||||
export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> {
|
||||
if (speech.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
|
||||
if (speech.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
|
||||
throw new Error(Environment.speechFileTooLargeText);
|
||||
}
|
||||
|
||||
const caption = Environment.getTextToSpeechCaption(providerDisplayName(speech.provider), speech.model, speech.voice);
|
||||
const caption = Environment.getTextToSpeechCaption(providerName(speech.provider), speech.model, speech.voice);
|
||||
|
||||
await enqueueTelegramApiCall(
|
||||
() => bot.sendChatAction({
|
||||
@@ -246,10 +379,10 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
|
||||
},
|
||||
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
sent = await sendSpeechDocument(sourceMessage, speech, caption);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
sent = await sendSpeechDocument(sourceMessage, speech, caption);
|
||||
}
|
||||
} else {
|
||||
sent = await sendSpeechDocument(sourceMessage, speech, caption);
|
||||
}
|
||||
@@ -258,16 +391,6 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
|
||||
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> {
|
||||
return enqueueTelegramApiCall(
|
||||
async () => {
|
||||
@@ -296,15 +419,6 @@ async function storeSpeechMessage(sent: Message, sourceMessage: Message, speech:
|
||||
fileName: speech.fileName,
|
||||
mimeType: speech.mimeType,
|
||||
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 = {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
+29
-32
@@ -1,11 +1,10 @@
|
||||
import {AiTool} from "./tool-types";
|
||||
import {AiProvider} from "../model/ai-provider.js";
|
||||
import {getTools} from "./tools/registry.js";
|
||||
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
|
||||
import {toolSchemaNames} from "./tool-schema-utils.js";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {getTools} from "./tools/registry";
|
||||
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
|
||||
|
||||
export type AiProviderName = "ollama" | "openai" | "mistral";
|
||||
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
|
||||
|
||||
export function getOllamaTools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator);
|
||||
@@ -14,10 +13,10 @@ export function getOllamaTools(forCreator?: boolean): AiTool[] {
|
||||
const openAiForbiddenTools = [
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
PYTHON_INTERPRETER_TOOL_NAME
|
||||
];
|
||||
]
|
||||
|
||||
function allowedOpenAiTool(tool: AiTool): boolean {
|
||||
return !openAiForbiddenTools.includes(tool.function.name);
|
||||
return !openAiForbiddenTools.includes(tool.function.name)
|
||||
}
|
||||
|
||||
export function getOpenAITools(forCreator?: boolean): AiTool[] {
|
||||
@@ -27,16 +26,11 @@ export function getOpenAITools(forCreator?: boolean): AiTool[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function getOpenAICompatibleTools(forCreator?: boolean): AiTool[] {
|
||||
// The compatible chat.completions backend only accepts plain function tools.
|
||||
return getOpenAITools(forCreator);
|
||||
}
|
||||
|
||||
export type OpenAiResponseTool = {
|
||||
type: "function";
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: object;
|
||||
parameters?: unknown;
|
||||
strict: false;
|
||||
};
|
||||
|
||||
@@ -50,7 +44,7 @@ export type OpenAiCodeInterpreterTool = {
|
||||
};
|
||||
|
||||
export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] {
|
||||
return getOpenAITools(forCreator).map(tool => ({
|
||||
return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
@@ -75,30 +69,33 @@ export function getMistralTools(forCreator?: boolean): AiTool[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export type GeminiTool = {
|
||||
functionDeclarations: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
parametersJsonSchema?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function getGeminiTools(forCreator?: boolean): GeminiTool[] {
|
||||
const functionDeclarations = getTools(forCreator).map(tool => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parametersJsonSchema: tool.function.parameters,
|
||||
}));
|
||||
|
||||
return functionDeclarations.length ? [{functionDeclarations}] : [];
|
||||
}
|
||||
|
||||
export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return getOllamaTools(forCreator);
|
||||
case AiProvider.GEMINI:
|
||||
return getTools(forCreator);
|
||||
case AiProvider.MISTRAL:
|
||||
return getMistralTools(forCreator);
|
||||
case AiProvider.OPENAI:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
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"];
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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))];
|
||||
}
|
||||
+11
-20
@@ -1,23 +1,12 @@
|
||||
|
||||
export type AiJsonPrimitive = string | number | boolean | null;
|
||||
export interface AiJsonObject {
|
||||
readonly [key: string]: AiJsonValue;
|
||||
}
|
||||
export type AiJsonValue = AiJsonPrimitive | undefined | readonly AiJsonValue[] | AiJsonObject;
|
||||
export interface AiToolParameters {
|
||||
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 AiToolParameters = {
|
||||
type: "object";
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AiTool = {
|
||||
type: "function";
|
||||
@@ -32,7 +21,9 @@ export type AiTool = {
|
||||
export type AiToolCall = {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: AiJsonObject;
|
||||
arguments: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readFile, writeFile} from "node:fs/promises";
|
||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import fs from "node:fs";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("create-note");
|
||||
|
||||
@@ -40,7 +39,7 @@ export const createNoteTool = {
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function createNote(
|
||||
args?: AiJsonObject
|
||||
args?: Record<string, unknown>
|
||||
): Promise<CreateNoteResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("start", {args});
|
||||
@@ -84,8 +83,8 @@ export async function createNote(
|
||||
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
|
||||
return {success: true, filePath: newFilePath};
|
||||
} catch (error) {
|
||||
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("failed", {duration: logger.duration(startedAt), error});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to process files: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export const getCurrentDateTimeTool = {
|
||||
type: "function",
|
||||
@@ -45,7 +44,7 @@ function getSystemTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
export function getCurrentDateTime(args?: AiJsonObject) {
|
||||
export function getCurrentDateTime(args?: Record<string, unknown>) {
|
||||
const now = new Date();
|
||||
|
||||
const systemTimeZone = getSystemTimeZone();
|
||||
|
||||
+36
-33
@@ -3,8 +3,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {z} from "zod";
|
||||
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {
|
||||
MAX_COPY_ENTRIES,
|
||||
MAX_COPY_TOTAL_BYTES,
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
MAX_PATCH_SEARCH_BYTES,
|
||||
MAX_STREAM_WRITE_IDLE_MS,
|
||||
MAX_STREAM_WRITE_SESSIONS,
|
||||
} from "./limits.js";
|
||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils.js";
|
||||
} from "./limits";
|
||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
||||
|
||||
// =============================================================================
|
||||
// Public types and schemas
|
||||
@@ -613,7 +613,7 @@ export const fileToolsToolPrompt = [
|
||||
"- Do not use ../ paths.",
|
||||
"- Do not use absolute paths.",
|
||||
"- Do not try to access symlinks.",
|
||||
"- Use search_files to find files by name, path or text content before reading or editing unfamiliar files.",
|
||||
"- Use search_files to find files by name, path or text content before reading or editing unknown files.",
|
||||
"- Use read_file for reading files.",
|
||||
"- Use list_directory for reading directories.",
|
||||
"- Use create_file for creating small or medium files in one call.",
|
||||
@@ -637,7 +637,7 @@ export const fileToolsToolPrompt = [
|
||||
// Exported tool implementations
|
||||
// =============================================================================
|
||||
|
||||
export async function readFile(args?: AiJsonObject) {
|
||||
export async function readFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -678,7 +678,7 @@ export async function readFile(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDirectory(args?: AiJsonObject) {
|
||||
export async function listDirectory(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -727,7 +727,7 @@ export async function listDirectory(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchFiles(args?: AiJsonObject) {
|
||||
export async function searchFiles(args?: Record<string, unknown>) {
|
||||
const start = resolveSafeToolPath(args?.path, ".", args?.userId);
|
||||
|
||||
await assertNoSymlinkInPath(start.absolutePath, start.rootDir);
|
||||
@@ -894,7 +894,7 @@ export async function searchFiles(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createFile(args?: AiJsonObject) {
|
||||
export async function createFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -953,7 +953,7 @@ export async function createFile(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateFile(args?: AiJsonObject) {
|
||||
export async function updateFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -1036,7 +1036,7 @@ export async function updateFile(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function editFilePatch(args?: AiJsonObject) {
|
||||
export async function editFilePatch(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -1146,7 +1146,7 @@ export async function editFilePatch(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDirectory(args?: AiJsonObject) {
|
||||
export async function createDirectory(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -1168,7 +1168,7 @@ export async function createDirectory(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyPath(args?: AiJsonObject) {
|
||||
export async function copyPath(args?: Record<string, unknown>) {
|
||||
const source = resolveSafeToolPath(args?.sourcePath, undefined, args?.userId);
|
||||
const target = resolveSafeToolPath(args?.targetPath, undefined, args?.userId);
|
||||
|
||||
@@ -1237,7 +1237,7 @@ export async function copyPath(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function renamePath(args?: AiJsonObject) {
|
||||
export async function renamePath(args?: Record<string, unknown>) {
|
||||
const source = resolveSafeToolPath(args?.sourcePath, undefined, args?.userId);
|
||||
const target = resolveSafeToolPath(args?.targetPath, undefined, args?.userId);
|
||||
|
||||
@@ -1307,7 +1307,7 @@ export async function renamePath(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePath(args?: AiJsonObject) {
|
||||
export async function deletePath(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath, rootDir} = resolveSafeToolPath(
|
||||
args?.path,
|
||||
".",
|
||||
@@ -1350,7 +1350,7 @@ export async function deletePath(args?: AiJsonObject) {
|
||||
}
|
||||
|
||||
export async function sendFileAsAttachment(
|
||||
args?: AiJsonObject,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<SendFileAttachmentResult> {
|
||||
try {
|
||||
const target = resolveSafeToolPath(args?.path, undefined, args?.userId);
|
||||
@@ -1419,7 +1419,7 @@ export async function sendFileAsAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
export async function beginFileWrite(args?: AiJsonObject) {
|
||||
export async function beginFileWrite(args?: Record<string, unknown>) {
|
||||
await cleanupExpiredFileWriteSessions();
|
||||
|
||||
if (fileWriteSessions.size >= MAX_STREAM_WRITE_SESSIONS) {
|
||||
@@ -1510,7 +1510,7 @@ export async function beginFileWrite(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeFileChunk(args?: AiJsonObject) {
|
||||
export async function writeFileChunk(args?: Record<string, unknown>) {
|
||||
await cleanupExpiredFileWriteSessions();
|
||||
|
||||
const session = getFileWriteSession(args?.sessionId);
|
||||
@@ -1575,7 +1575,7 @@ export async function writeFileChunk(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function finishFileWrite(args?: AiJsonObject) {
|
||||
export async function finishFileWrite(args?: Record<string, unknown>) {
|
||||
await cleanupExpiredFileWriteSessions();
|
||||
|
||||
const session = getFileWriteSession(args?.sessionId);
|
||||
@@ -1634,7 +1634,7 @@ export async function finishFileWrite(args?: AiJsonObject) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelFileWrite(args?: AiJsonObject) {
|
||||
export async function cancelFileWrite(args?: Record<string, unknown>) {
|
||||
const session = getFileWriteSession(args?.sessionId);
|
||||
|
||||
fileWriteSessions.delete(session.sessionId);
|
||||
@@ -1657,7 +1657,7 @@ export async function cancelFileWrite(args?: AiJsonObject) {
|
||||
// Path and filesystem helpers
|
||||
// =============================================================================
|
||||
|
||||
function parseTelegramUserId(input: AiJsonValue | null | undefined): number | null {
|
||||
function parseTelegramUserId(input: unknown): number | null {
|
||||
if (input === null || input === undefined) {
|
||||
return null;
|
||||
}
|
||||
@@ -1673,7 +1673,7 @@ function parseTelegramUserId(input: AiJsonValue | null | undefined): number | nu
|
||||
return input;
|
||||
}
|
||||
|
||||
function requireFileToolsRootDir(userIdInput?: AiJsonValue | null | undefined): string {
|
||||
function requireFileToolsRootDir(userIdInput?: unknown): string {
|
||||
const baseRootDir = Environment.FILE_TOOLS_ROOT_DIR as string;
|
||||
const userId = parseTelegramUserId(userIdInput);
|
||||
|
||||
@@ -1695,9 +1695,9 @@ async function ensureFileToolsRootExists(rootDir: string): Promise<void> {
|
||||
}
|
||||
|
||||
function resolveSafeToolPath(
|
||||
inputPath: AiJsonValue | null | undefined,
|
||||
inputPath: unknown,
|
||||
fallback = ".",
|
||||
userIdInput?: AiJsonValue | null | undefined,
|
||||
userIdInput?: unknown,
|
||||
): {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
@@ -1788,8 +1788,11 @@ async function assertNoSymlinkInPath(
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlinks are not allowed in file tool paths.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && (error as {code?: string}).code === "ENOENT" && options?.allowMissingTail) {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT" &&
|
||||
options?.allowMissingTail
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1802,8 +1805,8 @@ async function pathExists(absolutePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.lstat(absolutePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && (error as {code?: string}).code === "ENOENT") {
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1985,7 +1988,7 @@ function isPatchOperationType(value: string): value is PatchOperationType {
|
||||
return (PATCH_OPERATION_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function parsePatchOperations(input: AiJsonValue | null | undefined): ParsedPatchOperation[] {
|
||||
function parsePatchOperations(input: unknown): ParsedPatchOperation[] {
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error("operations must be an array.");
|
||||
}
|
||||
@@ -2009,7 +2012,7 @@ function parsePatchOperations(input: AiJsonValue | null | undefined): ParsedPatc
|
||||
throw new Error(`Operation #${index} must be an object.`);
|
||||
}
|
||||
|
||||
const operation = rawOperation as AiJsonObject;
|
||||
const operation = rawOperation as Record<string, unknown>;
|
||||
const rawType = asNonEmptyString(operation.type)?.toLowerCase();
|
||||
|
||||
if (!rawType || !isPatchOperationType(rawType)) {
|
||||
@@ -2192,7 +2195,7 @@ function normalizeForSearch(value: string, caseSensitive: boolean): string {
|
||||
return caseSensitive ? value : value.toLowerCase();
|
||||
}
|
||||
|
||||
function parseSearchExtensions(input: AiJsonValue | null | undefined): string[] | null {
|
||||
function parseSearchExtensions(input: unknown): string[] | null {
|
||||
if (input === undefined || input === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -2382,7 +2385,7 @@ function guessMimeType(fileName: string): string {
|
||||
// Chunked write helpers
|
||||
// =============================================================================
|
||||
|
||||
function parsePositiveInteger(value: AiJsonValue | null | undefined, fieldName: string): number {
|
||||
function parsePositiveInteger(value: unknown, fieldName: string): number {
|
||||
const numberValue =
|
||||
typeof value === "number"
|
||||
? value
|
||||
@@ -2397,7 +2400,7 @@ function parsePositiveInteger(value: AiJsonValue | null | undefined, fieldName:
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
function getFileWriteSession(sessionIdInput: AiJsonValue | null | undefined): FileWriteSession {
|
||||
function getFileWriteSession(sessionIdInput: unknown): FileWriteSession {
|
||||
const sessionId = asNonEmptyString(sessionIdInput);
|
||||
|
||||
if (!sessionId) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {AiTool} from "../tool-types";
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("market-rates");
|
||||
|
||||
@@ -64,15 +63,15 @@ export const getFinancialMarketDataToolPrompt = [
|
||||
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
|
||||
].join("\n");
|
||||
|
||||
export async function getMarketRates(): Promise<AiJsonObject | undefined> {
|
||||
export async function getMarketRates(): Promise<unknown | undefined> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
logger.info("start");
|
||||
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
|
||||
logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
} catch (e: unknown) {
|
||||
logger.error("failed", {duration: logger.duration(startedAt), error: e});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-14
@@ -1,11 +1,10 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {z} from "zod";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("notes");
|
||||
|
||||
@@ -100,14 +99,14 @@ export async function listNotes(): Promise<ListNotesResult> {
|
||||
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
|
||||
return {success: true, notes};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to list notes: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNoteContent(
|
||||
args?: AiJsonObject,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteContentResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("get_content.start", {args});
|
||||
@@ -146,7 +145,7 @@ export async function getNoteContent(
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to read note: ${errorMessage}`};
|
||||
}
|
||||
@@ -237,7 +236,7 @@ export const deleteNoteTool = {
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function updateNoteContent(
|
||||
args?: AiJsonObject,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<UpdateNoteContentResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("update_content.start", {args});
|
||||
@@ -273,14 +272,14 @@ export async function updateNoteContent(
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to update note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNote(
|
||||
args?: AiJsonObject,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<DeleteNoteResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("delete.start", {args});
|
||||
@@ -306,7 +305,7 @@ export async function deleteNote(
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to delete note: ${errorMessage}`};
|
||||
}
|
||||
@@ -397,7 +396,7 @@ export const sendNoteAsFileTool = {
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function sendNoteAsFile(
|
||||
args?: AiJsonObject,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteFileResult> {
|
||||
logger.debug("start", {args});
|
||||
|
||||
@@ -446,4 +445,4 @@ export async function sendNoteAsFile(
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import {spawn} from "node:child_process";
|
||||
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("python-interpreter");
|
||||
|
||||
@@ -192,7 +191,7 @@ export const pythonInterpreterTool = {
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function runPythonInterpreter(
|
||||
rawArgs: string | AiJsonObject | undefined,
|
||||
rawArgs: unknown,
|
||||
options: PythonInterpreterOptions = {},
|
||||
): Promise<PythonToolResult> {
|
||||
let args: PythonInterpreterArgs;
|
||||
@@ -203,7 +202,7 @@ export async function runPythonInterpreter(
|
||||
return {
|
||||
ok: false,
|
||||
phase: "internal",
|
||||
error: errorToString(error instanceof Error ? error : String(error)),
|
||||
error: errorToString(error),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,11 +432,11 @@ async function executePythonCode(
|
||||
skippedArtifacts,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("execute.failed", {duration: logger.duration(startedAt), error});
|
||||
return {
|
||||
ok: false,
|
||||
phase: "internal",
|
||||
error: errorToString(error instanceof Error ? error : String(error)),
|
||||
error: errorToString(error),
|
||||
};
|
||||
} finally {
|
||||
await rm(tempDir, {
|
||||
@@ -661,7 +660,7 @@ function mimeTypeFromPath(filePath: string): string | undefined {
|
||||
}
|
||||
|
||||
function parsePythonInterpreterArgs(
|
||||
rawArgs: string | AiJsonObject | undefined,
|
||||
rawArgs: unknown,
|
||||
options: PythonInterpreterOptions,
|
||||
): PythonInterpreterArgs {
|
||||
let args = rawArgs;
|
||||
@@ -674,11 +673,11 @@ function parsePythonInterpreterArgs(
|
||||
}
|
||||
}
|
||||
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||
if (!args || typeof args !== "object") {
|
||||
throw new Error("Tool arguments must be an object.");
|
||||
}
|
||||
|
||||
const record = args as AiJsonObject;
|
||||
const record = args as Record<string, unknown>;
|
||||
const code = record.code;
|
||||
|
||||
if (typeof code !== "string" || !code.trim()) {
|
||||
@@ -813,7 +812,7 @@ function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv {
|
||||
};
|
||||
}
|
||||
|
||||
function errorToString(error: Error | string | object | null | undefined): string {
|
||||
function errorToString(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.stack || error.message;
|
||||
}
|
||||
|
||||
+53
-119
@@ -1,17 +1,17 @@
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
|
||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
|
||||
import {shellExecute, shellExecuteTool} from "./shell.js";
|
||||
import {ToolHandler} from "./types.js";
|
||||
import {getWeather, getWeatherTool} from "./weather.js";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
|
||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
||||
import {shellExecute, shellExecuteTool} from "./shell";
|
||||
import {ToolHandler} from "./types";
|
||||
import {getWeather, getWeatherTool} from "./weather";
|
||||
import {
|
||||
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||
getFinancialMarketData,
|
||||
getFinancialMarketDataToolPrompt,
|
||||
getMarketRates
|
||||
} from "./market-rates.js";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
|
||||
} from "./market-rates";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
||||
import {
|
||||
beginFileWrite,
|
||||
beginFileWriteTool,
|
||||
@@ -44,15 +44,12 @@ import {
|
||||
updateFileTool,
|
||||
writeFileChunk,
|
||||
writeFileChunkTool
|
||||
} from "./files.js";
|
||||
import {executeMemoryTool, memoryToolPrompt, memoryTools, type MemoryToolName} from "./user-memory.js";
|
||||
import {getMcpToolHandlers, getMcpToolPrompts, getMcpTools} from "../mcp/mcp-registry.js";
|
||||
} from "./files";
|
||||
|
||||
export const defaultTools: AiTool[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getFinancialMarketData,
|
||||
...memoryTools,
|
||||
];
|
||||
]
|
||||
|
||||
export const fileTools = [
|
||||
readFileTool,
|
||||
@@ -75,67 +72,44 @@ export const fileTools = [
|
||||
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 notesFileTools: AiTool[] = [
|
||||
// createNoteTool,
|
||||
// listNotesTool,
|
||||
// getNoteContentTool,
|
||||
// updateNoteContentTool,
|
||||
// deleteNoteTool,
|
||||
// sendNoteAsFileTool,
|
||||
// searchNotesTool
|
||||
// ]
|
||||
|
||||
export const getTools = (forCreator?: boolean) => {
|
||||
const tools: AiTool[] = [];
|
||||
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||
tools.push(...getMcpTools());
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools.push(...filterEnabledTools(defaultTools));
|
||||
const tools: AiTool[] = [
|
||||
...defaultTools,
|
||||
// ...notesFileTools
|
||||
];
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
tools.push(...filterEnabledTools([webSearchTool]));
|
||||
tools.push(webSearchTool);
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
tools.push(...filterEnabledTools([getWeatherTool]));
|
||||
tools.push(getWeatherTool);
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
tools.push(...filterEnabledTools(fileTools));
|
||||
tools.push(...fileTools);
|
||||
}
|
||||
|
||||
if (forCreator) {
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
tools.push(...filterEnabledTools([pythonInterpreterTool]));
|
||||
tools.push(pythonInterpreterTool);
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
tools.push(...filterEnabledTools([shellExecuteTool]));
|
||||
tools.push(shellExecuteTool);
|
||||
}
|
||||
}
|
||||
|
||||
tools.push(...getMcpTools());
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
@@ -161,83 +135,44 @@ export const fileToolHandlers = {
|
||||
};
|
||||
|
||||
export const getToolHandlers = () => {
|
||||
const handlers: Record<string, ToolHandler> = {
|
||||
...getMcpToolHandlers(),
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
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,
|
||||
|
||||
...fileToolHandlers,
|
||||
|
||||
|
||||
python_interpreter: runPythonInterpreter,
|
||||
|
||||
shell_execute: shellExecute,
|
||||
|
||||
web_search: webSearch,
|
||||
|
||||
get_weather: getWeather,
|
||||
|
||||
};
|
||||
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||
return 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 (isLocalToolEnabled("read_file")) handlers.read_file = readFile;
|
||||
if (isLocalToolEnabled("list_directory")) handlers.list_directory = listDirectory;
|
||||
if (isLocalToolEnabled("search_files")) handlers.search_files = searchFiles;
|
||||
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 (isLocalToolEnabled("python_interpreter")) handlers.python_interpreter = (args, _context) => runPythonInterpreter(args);
|
||||
if (isLocalToolEnabled("shell_execute")) handlers.shell_execute = shellExecute;
|
||||
if (isLocalToolEnabled("web_search")) handlers.web_search = webSearch;
|
||||
if (isLocalToolEnabled("get_weather")) handlers.get_weather = getWeather;
|
||||
|
||||
return handlers;
|
||||
};
|
||||
|
||||
export function getToolPrompts(toolNames: string[]): string[] {
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||
return getMcpToolPrompts(toolNames);
|
||||
}
|
||||
|
||||
const prompts: string[] = [];
|
||||
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
|
||||
let memoryPromptAdded = false;
|
||||
|
||||
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) {
|
||||
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
||||
prompts.push(getFinancialMarketDataToolPrompt);
|
||||
@@ -250,6 +185,5 @@ export function getToolPrompts(toolNames: string[]): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
prompts.push(...getMcpToolPrompts(toolNames));
|
||||
return prompts;
|
||||
}
|
||||
}
|
||||
+9
-15
@@ -1,21 +1,15 @@
|
||||
import {getToolHandlers} from "./registry.js";
|
||||
import {normalizeToolArguments} from "./utils.js";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
|
||||
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";
|
||||
import {getToolHandlers} from "./registry";
|
||||
import {normalizeToolArguments} from "./utils";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("runtime");
|
||||
|
||||
export type ToolRuntimeContext = {
|
||||
pythonInputFiles?: PythonInterpreterInputFile[];
|
||||
provider?: AiProvider;
|
||||
runtimeTarget?: AiRuntimeTarget;
|
||||
} & MemoryRuntimeContext;
|
||||
};
|
||||
|
||||
function stringifyToolResult(result: AiJsonValue): string {
|
||||
function stringifyToolResult(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
@@ -23,7 +17,7 @@ function stringifyToolResult(result: AiJsonValue): string {
|
||||
export async function executeToolCall(
|
||||
userId: number | undefined | null,
|
||||
name: string,
|
||||
args?: string | AiJsonObject,
|
||||
args?: unknown,
|
||||
context: ToolRuntimeContext = {},
|
||||
): Promise<string> {
|
||||
const startedAt = Date.now();
|
||||
@@ -53,12 +47,12 @@ export async function executeToolCall(
|
||||
}
|
||||
|
||||
const arguments1 = normalizeToolArguments(args, userId);
|
||||
const result = await handler(arguments1, context);
|
||||
const result = await handler(arguments1);
|
||||
const s = stringifyToolResult(result);
|
||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||
return s;
|
||||
} catch (error) {
|
||||
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error});
|
||||
return stringifyToolResult({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("search-notes");
|
||||
|
||||
@@ -53,7 +52,7 @@ export const searchNotesTool = {
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function searchNotes(
|
||||
args?: AiJsonObject,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<SearchNotesResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("start", {args});
|
||||
@@ -140,7 +139,7 @@ export async function searchNotes(
|
||||
}
|
||||
}
|
||||
|
||||
function parseSearchLimit(value: AiJsonValue | undefined): number {
|
||||
function parseSearchLimit(value: unknown): number {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
@@ -392,4 +391,4 @@ function buildContentSnippet(query: string, content: string): string | undefined
|
||||
const suffix = end < content.length ? "..." : "";
|
||||
|
||||
return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {runCommand} from "../../util/utils.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
import {runCommand} from "../../util/utils";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export const shellExecuteTool = {
|
||||
type: "function",
|
||||
@@ -33,7 +32,7 @@ export const shellExecuteToolPrompt = [
|
||||
"- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.",
|
||||
"- Do not assume Bash/Linux commands are available.",
|
||||
"- Do not assume Windows commands are available.",
|
||||
"- If the current OS/shell is unclear, first run a safe environment inspection command.",
|
||||
"- If the current OS/shell is unknown, first run a safe environment inspection command.",
|
||||
"- Safe OS inspection examples:",
|
||||
" - Node.js: `node -p \"process.platform\"`",
|
||||
" - Node.js: `node -p \"process.cwd()\"`",
|
||||
@@ -100,7 +99,7 @@ export const shellExecuteToolPrompt = [
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
export async function shellExecute(args?: AiJsonObject): Promise<string | undefined | null> {
|
||||
export async function shellExecute(args?: Record<string, unknown>): Promise<string | undefined | null> {
|
||||
const cmd = asNonEmptyString(args?.cmd);
|
||||
if (!cmd) return undefined;
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import {appLogger} from "../../logging/logger.js";
|
||||
import {appLogger} from "../../logging/logger";
|
||||
|
||||
export const toolsLogger = appLogger.child("ai-tools");
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
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;
|
||||
export type ToolHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
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)};
|
||||
}
|
||||
}
|
||||
+15
-17
@@ -1,25 +1,23 @@
|
||||
import {Ollama} from "ollama";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||
import type {BoundaryValue} from "../../common/boundary-types";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("utils");
|
||||
|
||||
export function asNonEmptyString(value: BoundaryValue): string | undefined {
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeToolArguments(args: string | AiJsonObject | undefined, userId?: number | null): AiJsonObject {
|
||||
export function normalizeToolArguments(args: unknown, userId?: number | null): Record<string, unknown> {
|
||||
if (!args) return {};
|
||||
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as AiJsonValue;
|
||||
const parsed = JSON.parse(args);
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as AiJsonObject;
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
@@ -33,15 +31,15 @@ export function normalizeToolArguments(args: string | AiJsonObject | undefined,
|
||||
if (typeof args === "object" && !Array.isArray(args)) {
|
||||
const userIdObject = userId ? {"userId": userId} : {};
|
||||
return {
|
||||
...args,
|
||||
...userIdObject,
|
||||
} as AiJsonObject;
|
||||
...args as Record<string, unknown>,
|
||||
...userIdObject as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function asBoolean(value: BoundaryValue, defaultValue = false): boolean {
|
||||
export function asBoolean(value: unknown, defaultValue = false): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
@@ -54,11 +52,11 @@ export function asBoolean(value: BoundaryValue, defaultValue = false): boolean {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function asString(value: BoundaryValue, defaultValue = ""): string {
|
||||
export function asString(value: unknown, defaultValue = ""): string {
|
||||
return typeof value === "string" ? value : defaultValue;
|
||||
}
|
||||
|
||||
export function asPositiveInt(value: BoundaryValue, defaultValue: number, maxValue: number): number {
|
||||
export function asPositiveInt(value: unknown, defaultValue: number, maxValue: number): number {
|
||||
const n = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
@@ -89,7 +87,7 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
|
||||
await Promise.all(unloadPromises);
|
||||
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
|
||||
} catch (error) {
|
||||
logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)});
|
||||
logger.error("ollama.unload_all.failed", {exceptFor, error});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +104,8 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
|
||||
});
|
||||
logger.info("ollama.load.done", {model, contextLength});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("ollama.load.failed", {model, contextLength, error: error instanceof Error ? error : String(error)});
|
||||
} catch (e: unknown) {
|
||||
logger.error("ollama.load.failed", {model, contextLength, error: e});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("weather");
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {logError} from "../../util/utils.js";
|
||||
import {AiJsonObject, AiTool} from "../tool-types.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {logError} from "../../util/utils";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export const getWeatherTool = {
|
||||
type: "function",
|
||||
@@ -45,7 +45,7 @@ export const weatherToolPrompt = [
|
||||
"If the city is missing or unclear, ask the user to specify it.",
|
||||
].join("\n");
|
||||
|
||||
export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | null> {
|
||||
export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
const startedAt = Date.now();
|
||||
logger.info("start", {args});
|
||||
try {
|
||||
@@ -141,9 +141,9 @@ export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | nu
|
||||
windSpeed: wind.speed,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
logError(error instanceof Error ? error : String(error));
|
||||
} catch (e: unknown) {
|
||||
logger.error("failed", {duration: logger.duration(startedAt), error: e});
|
||||
logError(e);
|
||||
return null;
|
||||
} finally {
|
||||
logger.debug("done", {duration: logger.duration(startedAt)});
|
||||
|
||||
+21
-20
@@ -1,11 +1,11 @@
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("brave-search");
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {logError} from "../../util/utils.js";
|
||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||
import {asBoolean, asNonEmptyString} from "./utils.js";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {logError} from "../../util/utils";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {asBoolean, asNonEmptyString} from "./utils";
|
||||
|
||||
type BraveSearchProfile = {
|
||||
name?: string;
|
||||
@@ -83,11 +83,11 @@ type BraveSearchApiResponse = {
|
||||
results?: BraveSearchResult[];
|
||||
};
|
||||
|
||||
faq?: AiJsonValue;
|
||||
infobox?: AiJsonValue;
|
||||
locations?: AiJsonValue;
|
||||
mixed?: AiJsonValue;
|
||||
summarizer?: AiJsonValue;
|
||||
faq?: unknown;
|
||||
infobox?: unknown;
|
||||
locations?: unknown;
|
||||
mixed?: unknown;
|
||||
summarizer?: unknown;
|
||||
};
|
||||
|
||||
export const WEB_SEARCH_TOOL_NAME = "web_search";
|
||||
@@ -197,7 +197,7 @@ export const webSearchToolPrompt = [
|
||||
].join("\n");
|
||||
|
||||
function asIntegerInRange(
|
||||
value: AiJsonValue | undefined,
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -216,7 +216,7 @@ function asIntegerInRange(
|
||||
}
|
||||
|
||||
function asEnum<T extends string>(
|
||||
value: AiJsonValue | undefined,
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
@@ -229,7 +229,7 @@ function asEnum<T extends string>(
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function cleanSearchText(value: AiJsonValue | undefined): string | null {
|
||||
function cleanSearchText(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
return value
|
||||
@@ -243,7 +243,7 @@ function cleanSearchText(value: AiJsonValue | undefined): string | null {
|
||||
.trim() || null;
|
||||
}
|
||||
|
||||
function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
|
||||
function normalizeBraveResultFilter(value: unknown): string {
|
||||
const allowed = new Set([
|
||||
"discussions",
|
||||
"faq",
|
||||
@@ -268,7 +268,7 @@ function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
|
||||
return parts.length ? [...new Set(parts)].join(",") : "web";
|
||||
}
|
||||
|
||||
export async function webSearch(args?: AiJsonObject) {
|
||||
export async function webSearch(args?: Record<string, unknown>) {
|
||||
const startedAt = Date.now();
|
||||
logger.info("start", {args});
|
||||
|
||||
@@ -362,16 +362,17 @@ export async function webSearch(args?: AiJsonObject) {
|
||||
|
||||
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : String(error));
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
|
||||
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
||||
const data = axios.isAxiosError(error) ? error.response?.data : undefined;
|
||||
const axiosLike = e as {response?: {status?: unknown; data?: unknown}};
|
||||
const status = axiosLike.response?.status;
|
||||
const data = axiosLike.response?.data;
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: typeof status === "number" ? status : null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
response: data ?? null,
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// 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";
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
|
||||
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(
|
||||
msg: Message,
|
||||
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(msg.from?.id, 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(msg.from?.id, calls, streamMessage, toolContext, toolMemory));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class GeminiProviderRunner {
|
||||
static run = runGemini;
|
||||
}
|
||||
@@ -1,28 +1,25 @@
|
||||
import {Environment} from "../common/environment";
|
||||
import {getMistralTools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
import {MistralChatMessage} from "./mistral-chat-message";
|
||||
import {createMistralClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../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 {
|
||||
contentFromMistralDelta,
|
||||
executeToolBatch,
|
||||
MAX_TOOL_ROUNDS,
|
||||
MistralDeltaLike,
|
||||
MistralDocumentReference,
|
||||
mistralToolCalls,
|
||||
normalizeMistralToolCalls,
|
||||
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(
|
||||
@@ -38,9 +35,6 @@ export async function runMistral(
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
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", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.mistralChatTarget),
|
||||
@@ -50,169 +44,103 @@ export async function runMistral(
|
||||
});
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
try {
|
||||
await runToolLoopRounds({
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
onRound: async (round) => {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
||||
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;
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||
await streamMessage.flush();
|
||||
|
||||
if (!stream) {
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages: requestMessages,
|
||||
tools: requestTools,
|
||||
documents: documents
|
||||
} as Parameters<typeof mistralAi.chat.complete>[0];
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => mistralAi.chat.complete(request, {signal})),
|
||||
});
|
||||
const message = response.choices?.[0]?.message;
|
||||
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
|
||||
streamMessage.append(text);
|
||||
const calls = adapter.extractToolCalls(message);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: text.length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return {shouldContinue: false};
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: text,
|
||||
toolCalls: calls.map(call => ({
|
||||
id: call.id,
|
||||
function: {name: call.name, arguments: call.argumentsText},
|
||||
})),
|
||||
});
|
||||
requestMessages.push({
|
||||
role: "assistant",
|
||||
content: text,
|
||||
toolCalls: calls.map(call => ({
|
||||
id: call.id,
|
||||
function: {name: call.name, arguments: call.argumentsText},
|
||||
})),
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
return {shouldContinue: true};
|
||||
}
|
||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||
await streamMessage.flush();
|
||||
|
||||
if (!stream) {
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages: requestMessages,
|
||||
tools: requestTools,
|
||||
messages,
|
||||
tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID),
|
||||
documents: documents
|
||||
} as Parameters<typeof mistralAi.chat.stream>[0];
|
||||
const streamResponse = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => mistralAi.chat.stream(request, {signal})),
|
||||
});
|
||||
aiLog("debug", "mistral.stream.open", {round});
|
||||
let calls: ToolCallData[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
|
||||
|
||||
for await (const event of streamResponse) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
const choice = event.data?.choices?.[0];
|
||||
const delta = choice?.delta;
|
||||
const mistralDelta = delta;
|
||||
streamMessage.append(adapter.extractTextDelta(mistralDelta));
|
||||
|
||||
const rawDeltaCalls = adapter.extractStreamingToolCalls(mistralDelta);
|
||||
if (rawDeltaCalls.length) {
|
||||
calls = toolCallAccumulator.add(rawDeltaCalls);
|
||||
streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof mistralAi.chat.complete>[0];
|
||||
const response = await mistralAi.chat.complete(request, {signal});
|
||||
const message = response.choices?.[0]?.message;
|
||||
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
|
||||
streamMessage.append(text);
|
||||
const calls = normalizeMistralToolCalls(mistralToolCalls(message));
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
textChars: text.length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return {shouldContinue: false};
|
||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
if (!calls.length) return;
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||
content: text,
|
||||
toolCalls: calls.map(call => ({
|
||||
id: call.id,
|
||||
function: {name: call.name, arguments: call.argumentsText},
|
||||
})),
|
||||
});
|
||||
requestMessages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
return {shouldContinue: true};
|
||||
},
|
||||
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
|
||||
for (const [index, call] of calls.entries()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
name: call.name,
|
||||
toolCallId: call.id,
|
||||
content: toolResults[index] ?? "",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages,
|
||||
tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID),
|
||||
documents: documents
|
||||
} as unknown as Parameters<typeof mistralAi.chat.stream>[0];
|
||||
const streamResponse = await mistralAi.chat.stream(request, {signal});
|
||||
aiLog("debug", "mistral.stream.open", {round});
|
||||
let calls: ToolCallData[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
|
||||
|
||||
for await (const event of streamResponse) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
const choice = event.data?.choices?.[0];
|
||||
const delta = choice?.delta;
|
||||
const mistralDelta = delta as MistralDeltaLike;
|
||||
|
||||
streamMessage.append(contentFromMistralDelta(mistralDelta));
|
||||
|
||||
const rawDeltaCalls = mistralToolCalls(mistralDelta);
|
||||
if (rawDeltaCalls.length) {
|
||||
calls = toolCallAccumulator.add(rawDeltaCalls);
|
||||
streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
} finally {
|
||||
await adapter.finalize().catch(() => undefined);
|
||||
if (!calls.length) return;
|
||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||
});
|
||||
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
|
||||
for (const [index, call] of calls.entries()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
name: call.name,
|
||||
toolCallId: call.id,
|
||||
content: toolResults[index] ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Environment} from "../common/environment";
|
||||
import type {BoundaryValue} from "../common/boundary-types";
|
||||
import {bot, notesDir} from "../index";
|
||||
import {clamp, logError} from "../util/utils";
|
||||
import {getOllamaTools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ChatMessage} from "./chat-messages-types";
|
||||
import {ChatRequest, Tool} from "ollama";
|
||||
@@ -13,20 +13,20 @@ import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
||||
import {createOllamaClient} from "./ai-runtime-target";
|
||||
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 {
|
||||
allToolSchemaNames,
|
||||
appendOllamaToolResults,
|
||||
dedupeToolCalls,
|
||||
DEFAULT_OLLAMA_CONTEXT_SIZE,
|
||||
executeToolBatch,
|
||||
isOllamaModelActive,
|
||||
isRecord,
|
||||
MAX_OLLAMA_CONTEXT_SIZE,
|
||||
MAX_TOOL_ROUNDS,
|
||||
MIN_OLLAMA_CONTEXT_SIZE,
|
||||
normalizeOllamaToolCalls,
|
||||
OllamaToolCallLike,
|
||||
roundStatus,
|
||||
RuntimeConfigSnapshot,
|
||||
safeJsonParseObject,
|
||||
@@ -34,10 +34,7 @@ import {
|
||||
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 {latestUserTextFromOllamaMessages, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
|
||||
import {getToolPrompts} from "./tools/registry";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
|
||||
import {getModelCapabilities} from "./provider-model-runtime";
|
||||
@@ -78,8 +75,9 @@ export async function runOllama(
|
||||
|
||||
const ollama = createOllamaClient(target);
|
||||
const modelInfo = await ollama.show({model});
|
||||
const modelInfoMap: Record<string, BoundaryValue> = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
|
||||
const modelInfoMap = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
|
||||
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
|
||||
// @ts-ignore
|
||||
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
|
||||
const parsedMaxContextLength =
|
||||
typeof rawMaxContextLength === "number"
|
||||
@@ -115,7 +113,7 @@ export async function runOllama(
|
||||
await unloadAllOllamaModels(ollama, modelsToLoad);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
}
|
||||
|
||||
if (!(await isOllamaModelActive(ollama, target))) {
|
||||
@@ -157,12 +155,9 @@ export async function runOllama(
|
||||
}
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
const adapter = getProviderAdapter(AiProvider.OLLAMA);
|
||||
|
||||
try {
|
||||
await runToolLoopRounds({
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
onRound: async (round) => {
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "ollama.round.start", {
|
||||
round,
|
||||
@@ -186,7 +181,7 @@ export async function runOllama(
|
||||
|
||||
let activeToolNames: string[] = [];
|
||||
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||
const availableOllamaTools: Tool[] = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID}) as Tool[];
|
||||
const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[];
|
||||
|
||||
aiLog("debug", "ollama.tools.available", {
|
||||
round,
|
||||
@@ -194,25 +189,21 @@ export async function runOllama(
|
||||
rankerEnabled: !!config.ollamaToolRankerTarget,
|
||||
});
|
||||
|
||||
const rankResult = await runToolRankStage({
|
||||
provider: AiProvider.OLLAMA,
|
||||
model,
|
||||
round,
|
||||
config,
|
||||
const rankerSelection = await new OllamaToolRanker(config).selectTools({
|
||||
userQuery: latestUserTextFromOllamaMessages(messages),
|
||||
availableTools: availableOllamaTools,
|
||||
messages,
|
||||
streamMessage,
|
||||
round,
|
||||
signal,
|
||||
});
|
||||
|
||||
const filteredTools = [...new Set(ensureToolsSelected(availableOllamaTools, rankResult.filteredTools as Tool[], MEMORY_TOOL_NAMES) as Tool[])];
|
||||
const filteredTools = [...new Set(rankerSelection.tools)];
|
||||
activeToolNames = filteredTools.map(t => t.function.name ?? "");
|
||||
if (filteredTools.length > 0) {
|
||||
request.tools = [...filteredTools];
|
||||
request.options = {
|
||||
...request.options,
|
||||
temperature: 0
|
||||
};
|
||||
}
|
||||
|
||||
const newMessage = messages[messages.length - 1];
|
||||
if (newMessage) {
|
||||
@@ -229,27 +220,31 @@ export async function runOllama(
|
||||
delete request.tools;
|
||||
}
|
||||
|
||||
// TODO: 14.05.2026, Danil Nikolaev: check if model supports tools
|
||||
|
||||
|
||||
aiLog("debug", "ollama.tools.selected", {
|
||||
round,
|
||||
tools: activeToolNames,
|
||||
count: activeToolNames.length,
|
||||
usedRanker: rankResult.usedRanker,
|
||||
usedRanker: rankerSelection.usedRanker,
|
||||
});
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||
...request,
|
||||
stream: false
|
||||
})),
|
||||
const response = await ollama.chat({
|
||||
...request,
|
||||
stream: false
|
||||
});
|
||||
|
||||
const message = response.message;
|
||||
const rawContent = message?.content ?? "";
|
||||
|
||||
const nativeCalls = dedupeToolCalls(
|
||||
adapter.extractToolCalls(message),
|
||||
normalizeOllamaToolCalls(
|
||||
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
||||
round,
|
||||
),
|
||||
);
|
||||
|
||||
const responseText = rawContent;
|
||||
@@ -274,10 +269,10 @@ export async function runOllama(
|
||||
|
||||
if (!nativeCalls.length) {
|
||||
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
||||
return {shouldContinue: false};
|
||||
break;
|
||||
}
|
||||
|
||||
const calls = adapter.extractToolCalls(message).length ? adapter.extractToolCalls(message) : nativeCalls;
|
||||
const calls = nativeCalls;
|
||||
|
||||
aiLog("info", "ollama.tool_calls", {
|
||||
round,
|
||||
@@ -295,44 +290,19 @@ export async function runOllama(
|
||||
})),
|
||||
});
|
||||
|
||||
await executeToolBatchWithAdapter({
|
||||
userId: msg.from?.id,
|
||||
toolCalls: calls,
|
||||
streamMessage,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OLLAMA,
|
||||
runtimeTarget: target,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [messages],
|
||||
});
|
||||
appendOllamaToolResults(
|
||||
messages,
|
||||
calls,
|
||||
await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory),
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return {shouldContinue: true};
|
||||
continue;
|
||||
}
|
||||
|
||||
aiLog("debug", "ollama.stream.messages", {
|
||||
round,
|
||||
messageCount: request.messages?.length ?? 0,
|
||||
});
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||
...request,
|
||||
stream: true
|
||||
})),
|
||||
console.log("MESSAGES", JSON.stringify(request.messages));
|
||||
const response = await ollama.chat({
|
||||
...request,
|
||||
stream: true
|
||||
});
|
||||
|
||||
aiLog("debug", "ollama.stream.open", {round});
|
||||
@@ -343,16 +313,14 @@ export async function runOllama(
|
||||
if (signal.aborted) abortOllamaResponse();
|
||||
try {
|
||||
for await (const chunk of response) {
|
||||
aiLog("trace", "ollama.stream.chunk", {
|
||||
round,
|
||||
contentPreview: chunk.message.content?.slice(0, 240),
|
||||
hasToolCalls: !!chunk.message.tool_calls?.length,
|
||||
hasThinking: !!chunk.message.thinking,
|
||||
});
|
||||
console.log("OLLAMA_CHUNK: ", chunk);
|
||||
|
||||
const localToolCalls: ToolCallData[] = [];
|
||||
|
||||
localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||
localToolCalls.push(...normalizeOllamaToolCalls(
|
||||
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
||||
round,
|
||||
));
|
||||
|
||||
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
||||
const previousStatus = streamMessage.getStatus();
|
||||
@@ -372,10 +340,13 @@ export async function runOllama(
|
||||
}
|
||||
|
||||
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
||||
streamMessage.append(adapter.extractTextDelta(chunk));
|
||||
streamMessage.append(chunk.message?.content ?? "");
|
||||
}
|
||||
|
||||
calls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||
calls.push(...normalizeOllamaToolCalls(
|
||||
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
||||
round,
|
||||
));
|
||||
|
||||
if (chunk.done) {
|
||||
aiLog("debug", "ollama.stream.done", {
|
||||
@@ -408,7 +379,7 @@ export async function runOllama(
|
||||
duration: aiLogDuration(runnerStartedAt),
|
||||
});
|
||||
|
||||
return {shouldContinue: false};
|
||||
break;
|
||||
}
|
||||
|
||||
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
||||
@@ -431,31 +402,7 @@ export async function runOllama(
|
||||
})),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
|
||||
|
||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||
|
||||
@@ -473,25 +420,18 @@ export async function runOllama(
|
||||
}
|
||||
|
||||
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({
|
||||
chat_id: msg.chat.id,
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
},
|
||||
document: fs.createReadStream(attachmentPath),
|
||||
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
|
||||
}).catch(logError);
|
||||
}
|
||||
|
||||
return {shouldContinue: true};
|
||||
},
|
||||
});
|
||||
appendOllamaToolResults(messages, calls, toolResults);
|
||||
}
|
||||
} finally {
|
||||
if (interval) clearInterval(interval);
|
||||
await adapter.finalize().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+440
-387
@@ -1,5 +1,5 @@
|
||||
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import {OpenAI, toFile} from "openai";
|
||||
import {Environment} from "../common/environment";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
@@ -12,14 +12,16 @@ import type {
|
||||
} from "openai/resources/responses/responses";
|
||||
import {createOpenAiClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||
|
||||
import {
|
||||
AsyncIterableStream,
|
||||
buildSystemInstruction,
|
||||
collectOpenAiResponseCodeInterpreterCalls,
|
||||
collectOpenAiResponseFunctionCalls,
|
||||
collectOpenAiResponseImages,
|
||||
collectOpenAiResponseText,
|
||||
executeToolBatch,
|
||||
getOpenAIResponsesToolsWithImage,
|
||||
MAX_TOOL_ROUNDS,
|
||||
OPENAI_IMAGE_PARTIALS,
|
||||
openAiResponseItemCallId,
|
||||
@@ -29,22 +31,14 @@ import {
|
||||
safeJsonParseObject,
|
||||
showOpenAiGeneratedImage,
|
||||
ToolCallData,
|
||||
ToolExecutionMemory,
|
||||
allToolSchemaNames
|
||||
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 {ensureToolsSelected} from "./tool-mappers.js";
|
||||
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||
import {bot, filesDir} from "../index";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {logError} from "../util/utils";
|
||||
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
|
||||
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(
|
||||
msg: Message,
|
||||
@@ -55,27 +49,12 @@ export async function runOpenAi(
|
||||
sourceMessage: Message,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
downloads: AiDownloadedFile[] = [],
|
||||
documentRag?: OpenAiDocumentRagContext,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
let responseInput: unknown[] = [...messages];
|
||||
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),
|
||||
);
|
||||
const systemPrompt = buildSystemInstruction(config, DEFAULT_AI_RESPONSE_LANGUAGE, false);
|
||||
|
||||
aiLog("info", "openai.run.start", {
|
||||
stream,
|
||||
@@ -88,275 +67,28 @@ export async function runOpenAi(
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
|
||||
try {
|
||||
await runToolLoopRounds({
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
onRound: async (round) => {
|
||||
const roundStartedAt = Date.now();
|
||||
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;
|
||||
})();
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
|
||||
|
||||
if (!stream) {
|
||||
const request: ResponseCreateParamsNonStreaming = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
tools: requestTools as ResponseCreateParamsNonStreaming["tools"],
|
||||
instructions: systemPrompt,
|
||||
};
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
|
||||
}) as OpenAiResponseLike;
|
||||
|
||||
const responseText = collectOpenAiResponseText(response);
|
||||
streamMessage.append(responseText);
|
||||
aiLog("debug", "openai.response.received", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
textChars: responseText.length,
|
||||
outputItems: response?.output?.length ?? 0,
|
||||
});
|
||||
const images = collectOpenAiResponseImages(response);
|
||||
if (images.length) {
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
images[images.length - 1],
|
||||
`final_${round}`,
|
||||
Environment.getImageGenDoneText(config.openAiImageTarget.model),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response);
|
||||
if (codeInterpreterCalls.length) {
|
||||
aiLog("info", "openai.code_interpreter_calls", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
calls: codeInterpreterCalls.map(call => ({
|
||||
id: call.id,
|
||||
status: call.status,
|
||||
containerId: call.containerId,
|
||||
codeChars: call.code?.length ?? 0,
|
||||
outputItems: call.outputs.length,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const calls = adapter.extractToolCalls(response);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
calls: calls.map(call => ({
|
||||
id: call.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 toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
|
||||
const toolResults = await executeToolBatchWithAdapter({
|
||||
userId: msg.from?.id,
|
||||
toolCalls,
|
||||
streamMessage,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OPENAI,
|
||||
runtimeTarget: config.openAiChatTarget,
|
||||
},
|
||||
toolMemory,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
|
||||
return {shouldContinue: true};
|
||||
}
|
||||
|
||||
let completedResponse: OpenAiResponseLike | null = null;
|
||||
const request: ResponseCreateParamsStreaming = {
|
||||
if (!stream) {
|
||||
const request: ResponseCreateParamsNonStreaming = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
stream: true,
|
||||
tools: requestTools as ResponseCreateParamsStreaming["tools"],
|
||||
parallel_tool_calls: true,
|
||||
instructions: systemPrompt
|
||||
tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsNonStreaming["tools"],
|
||||
instructions: systemPrompt,
|
||||
};
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
|
||||
}) as AsyncIterableStream<ResponseStreamEvent>;
|
||||
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
|
||||
|
||||
aiLog("debug", "openai.stream.open", {round});
|
||||
|
||||
let localToolCalls: ToolCallData[] = [];
|
||||
for await (const event of response) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
switch (event.type) {
|
||||
case "response.output_text.delta":
|
||||
streamMessage.append(adapter.extractTextDelta(event));
|
||||
break;
|
||||
case "response.image_generation_call.in_progress":
|
||||
streamMessage.setStatus(Environment.startingImageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.image_generation_call.generating":
|
||||
streamMessage.setStatus(Environment.imageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.image_generation_call.partial_image": {
|
||||
const iteration = (event.partial_image_index ?? 0) + 1;
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
event.partial_image_b64,
|
||||
`partial_${round}_${iteration}`,
|
||||
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "response.image_generation_call.completed":
|
||||
streamMessage.setStatus(Environment.finalizingImageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.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.interpreting":
|
||||
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.code_interpreter_call.completed":
|
||||
streamMessage.clearStatus();
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.code_interpreter_call_code.delta":
|
||||
case "response.code_interpreter_call_code.done":
|
||||
break;
|
||||
case "response.output_item.added":
|
||||
{
|
||||
const streamedCalls = adapter.extractStreamingToolCalls(event);
|
||||
if (streamedCalls.length) {
|
||||
localToolCalls.push(...streamedCalls);
|
||||
}
|
||||
aiLog("info", "openai.stream.tool_call.added", {
|
||||
round,
|
||||
toolCalls: localToolCalls.map(aiLogToolCall)
|
||||
});
|
||||
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
break;
|
||||
case "response.output_item.done":
|
||||
if (event.item.type === "function_call" && event.item.name) {
|
||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
||||
const itemId = openAiResponseItemCallId(item);
|
||||
const index = localToolCalls.findIndex(c => c.id === itemId);
|
||||
if (index !== -1) {
|
||||
localToolCalls.splice(index, 1);
|
||||
if (localToolCalls.length === 0) {
|
||||
streamMessage.clearStatus();
|
||||
} else {
|
||||
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
|
||||
}
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "response.function_call_arguments.delta":
|
||||
break;
|
||||
case "response.function_call_arguments.done":
|
||||
break;
|
||||
|
||||
case "response.completed":
|
||||
completedResponse = event.response as OpenAiResponseLike;
|
||||
break;
|
||||
case "response.failed":
|
||||
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
|
||||
case "error":
|
||||
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
|
||||
|
||||
aiLog("debug", "openai.stream.completed", {
|
||||
const responseText = collectOpenAiResponseText(response);
|
||||
streamMessage.append(responseText);
|
||||
aiLog("debug", "openai.response.received", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
outputItems: completedResponse?.output?.length ?? 0,
|
||||
textChars: responseText.length,
|
||||
outputItems: response?.output?.length ?? 0,
|
||||
});
|
||||
|
||||
const images = collectOpenAiResponseImages(completedResponse);
|
||||
const images = collectOpenAiResponseImages(response);
|
||||
if (images.length) {
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
@@ -368,7 +100,7 @@ export async function runOpenAi(
|
||||
);
|
||||
}
|
||||
|
||||
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse);
|
||||
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response);
|
||||
if (codeInterpreterCalls.length) {
|
||||
aiLog("info", "openai.code_interpreter_calls", {
|
||||
round,
|
||||
@@ -383,143 +115,287 @@ export async function runOpenAi(
|
||||
});
|
||||
}
|
||||
|
||||
const calls = adapter.extractToolCalls(completedResponse);
|
||||
const calls = collectOpenAiResponseFunctionCalls(response);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
calls: calls.map(call => ({
|
||||
id: call.id,
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
arguments: safeJsonParseObject(call.argumentsText)
|
||||
})),
|
||||
});
|
||||
if (!calls.length) return {shouldContinue: false};
|
||||
if (!calls.length) return;
|
||||
|
||||
const toolCalls = calls.map(call => ({
|
||||
id: call.id,
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
argumentsText: call.argumentsText,
|
||||
}));
|
||||
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
|
||||
const toolResults = await executeToolBatchWithAdapter({
|
||||
userId: msg.from?.id,
|
||||
toolCalls,
|
||||
streamMessage,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OPENAI,
|
||||
runtimeTarget: config.openAiChatTarget,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [toolOutputs],
|
||||
});
|
||||
const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
|
||||
const toolOutputs = calls.map((call, index) => ({
|
||||
type: "function_call_output" as const,
|
||||
call_id: call.callId,
|
||||
output: toolResults[index] ?? "",
|
||||
}));
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
delete toolOutputs[uploadFilesResult.toolIndex];
|
||||
toolOutputs.push({
|
||||
type: "function_call_output" as const,
|
||||
call_id: old.call_id,
|
||||
output: "Error: " + uploadFilesResult.error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const continuation = decideToolLoopContinuation({
|
||||
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
|
||||
continue;
|
||||
}
|
||||
|
||||
let completedResponse: OpenAiResponseLike | null = null;
|
||||
const request: ResponseCreateParamsStreaming = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
stream: true,
|
||||
tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsStreaming["tools"],
|
||||
parallel_tool_calls: true,
|
||||
instructions: systemPrompt
|
||||
};
|
||||
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
|
||||
|
||||
aiLog("debug", "openai.stream.open", {round});
|
||||
|
||||
let localToolCalls: ToolCallData[] = [];
|
||||
for await (const event of response) {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
|
||||
switch (event.type) {
|
||||
case "response.output_text.delta":
|
||||
streamMessage.append(event.delta ?? "");
|
||||
break;
|
||||
case "response.image_generation_call.in_progress":
|
||||
streamMessage.setStatus(Environment.startingImageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.image_generation_call.generating":
|
||||
streamMessage.setStatus(Environment.imageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.image_generation_call.partial_image": {
|
||||
const iteration = (event.partial_image_index ?? 0) + 1;
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
event.partial_image_b64,
|
||||
`partial_${round}_${iteration}`,
|
||||
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "response.image_generation_call.completed":
|
||||
streamMessage.setStatus(Environment.finalizingImageGenText);
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.code_interpreter_call.in_progress":
|
||||
case "response.code_interpreter_call.interpreting":
|
||||
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.code_interpreter_call.completed":
|
||||
streamMessage.clearStatus();
|
||||
await streamMessage.flush();
|
||||
break;
|
||||
case "response.code_interpreter_call_code.delta":
|
||||
case "response.code_interpreter_call_code.done":
|
||||
break;
|
||||
case "response.output_item.added":
|
||||
if (event.item.type === "function_call" && event.item.name) {
|
||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
||||
localToolCalls.push({
|
||||
id: openAiResponseItemCallId(item),
|
||||
name: item.name ?? "",
|
||||
argumentsText: item.arguments ?? "{}",
|
||||
});
|
||||
|
||||
aiLog("info", "openai.stream.tool_call.added", {
|
||||
round,
|
||||
toolCalls: localToolCalls.map(aiLogToolCall)
|
||||
});
|
||||
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
|
||||
await streamMessage.flush();
|
||||
}
|
||||
break;
|
||||
case "response.output_item.done":
|
||||
if (event.item.type === "function_call" && event.item.name) {
|
||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
||||
const itemId = openAiResponseItemCallId(item);
|
||||
const index = localToolCalls.findIndex(c => c.id === itemId);
|
||||
if (index !== -1) {
|
||||
localToolCalls.splice(index, 1);
|
||||
if (localToolCalls.length === 0) {
|
||||
streamMessage.clearStatus();
|
||||
} else {
|
||||
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
|
||||
}
|
||||
await streamMessage.flush();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "response.function_call_arguments.delta":
|
||||
break;
|
||||
case "response.function_call_arguments.done":
|
||||
break;
|
||||
|
||||
case "response.completed":
|
||||
completedResponse = event.response as unknown as OpenAiResponseLike;
|
||||
break;
|
||||
case "response.failed":
|
||||
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
|
||||
case "error":
|
||||
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
|
||||
|
||||
aiLog("debug", "openai.stream.completed", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
outputItems: completedResponse?.output?.length ?? 0,
|
||||
});
|
||||
|
||||
const images = collectOpenAiResponseImages(completedResponse);
|
||||
if (images.length) {
|
||||
await showOpenAiGeneratedImage(
|
||||
streamMessage,
|
||||
sourceMessage,
|
||||
images[images.length - 1],
|
||||
`final_${round}`,
|
||||
Environment.getImageGenDoneText(config.openAiImageTarget.model),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse);
|
||||
if (codeInterpreterCalls.length) {
|
||||
aiLog("info", "openai.code_interpreter_calls", {
|
||||
round,
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
toolCalls: calls,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
calls: codeInterpreterCalls.map(call => ({
|
||||
id: call.id,
|
||||
status: call.status,
|
||||
containerId: call.containerId,
|
||||
codeChars: call.code?.length ?? 0,
|
||||
outputItems: call.outputs.length,
|
||||
})),
|
||||
});
|
||||
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
|
||||
round,
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
}
|
||||
|
||||
const calls = collectOpenAiResponseFunctionCalls(completedResponse);
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
calls: calls.map(call => ({
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
arguments: safeJsonParseObject(call.argumentsText)
|
||||
})),
|
||||
});
|
||||
if (!calls.length) return;
|
||||
|
||||
const toolCalls = calls.map(call => ({
|
||||
id: call.callId,
|
||||
name: call.name,
|
||||
argumentsText: call.argumentsText,
|
||||
}));
|
||||
const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
|
||||
const toolOutputs = calls.map((call, index) => ({
|
||||
type: "function_call_output",
|
||||
call_id: call.callId,
|
||||
output: toolResults[index] ?? "",
|
||||
}));
|
||||
|
||||
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||
if (uploadFilesResult.found) {
|
||||
if (!uploadFilesResult.uploaded) {
|
||||
const old = toolOutputs[uploadFilesResult.toolIndex];
|
||||
delete toolOutputs[uploadFilesResult.toolIndex];
|
||||
toolOutputs.push({
|
||||
type: "function_call_output" as const,
|
||||
call_id: old.call_id,
|
||||
output: "Error: " + uploadFilesResult.error
|
||||
});
|
||||
}
|
||||
|
||||
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
|
||||
return {shouldContinue: true};
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (ownsDocumentRag) {
|
||||
await preparedDocumentRag?.cleanup().catch(logError);
|
||||
}
|
||||
await adapter.finalize().catch(logError);
|
||||
|
||||
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAiDocumentRagContext = {
|
||||
vectorStoreIds: string[];
|
||||
uploadedFileIds: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
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;
|
||||
|
||||
export async function prepareOpenAiDocumentRag(openAi: OpenAI, downloads: AiDownloadedFile[]): Promise<OpenAiDocumentRagContext | undefined> {
|
||||
if (!downloads.length) return undefined;
|
||||
|
||||
const vectorStore = await openAi.vectorStores.create({
|
||||
name: `tg-chat-bot-${Date.now()}`,
|
||||
description: "Temporary document RAG for a single Telegram request.",
|
||||
expires_after: {
|
||||
anchor: "last_active_at",
|
||||
days: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const uploadedFileIds: string[] = [];
|
||||
let found = false;
|
||||
|
||||
try {
|
||||
for (const download of downloads) {
|
||||
const uploaded = await openAi.files.create({
|
||||
file: await toFile(download.buffer, download.fileName, {
|
||||
type: download.mimeType ?? "application/octet-stream",
|
||||
}),
|
||||
purpose: "user_data",
|
||||
});
|
||||
uploadedFileIds.push(uploaded.id);
|
||||
for (const toolResult of toolResults) {
|
||||
const raw = JSON.parse(toolResult);
|
||||
const res = SendFileAttachmentResultSchema.safeParse(raw);
|
||||
|
||||
if (res.success) {
|
||||
found = true;
|
||||
|
||||
if (res.data.success) {
|
||||
sendFileAttachment = {result: res.data, toolIndex: toolResults.indexOf(toolResult)};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batch = await openAi.vectorStores.fileBatches.createAndPoll(vectorStore.id, {
|
||||
file_ids: uploadedFileIds,
|
||||
});
|
||||
|
||||
if (batch.file_counts.failed > 0) {
|
||||
throw new Error(`OpenAI file_search failed to index ${batch.file_counts.failed} document(s).`);
|
||||
if (!found) {
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
return {
|
||||
vectorStoreIds: [vectorStore.id],
|
||||
uploadedFileIds,
|
||||
cleanup: async () => {
|
||||
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds);
|
||||
await bot.sendDocument({
|
||||
chat_id: msg.chat.id,
|
||||
reply_parameters: {
|
||||
message_id: msg.message_id,
|
||||
},
|
||||
document: fs.createReadStream(path.join(filesDir, String(msg.from?.id), sendFileAttachment?.result?.attachment?.relativePath ?? "")),
|
||||
})
|
||||
|
||||
return {found: true, uploaded: true};
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
return {
|
||||
found: found,
|
||||
uploaded: false,
|
||||
error: (e as any)?.message ?? "",
|
||||
toolIndex: sendFileAttachment?.toolIndex ?? -1
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// function openAiResponseContentToText(content: unknown): string {
|
||||
// if (typeof content === "string") return content;
|
||||
// if (!Array.isArray(content)) return "";
|
||||
// return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
|
||||
// }
|
||||
|
||||
// function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
|
||||
// return messages.map((message): OpenAiCompatibleChatMessage => {
|
||||
// if (message.role === "system" || message.role === "assistant") {
|
||||
@@ -548,6 +424,7 @@ async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, f
|
||||
// return {role: "user", content};
|
||||
// });
|
||||
// }
|
||||
|
||||
// function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
|
||||
// return toolCalls.map((call, i) => ({
|
||||
// id: call.id || `openai_chat_${Date.now()}_${i}`,
|
||||
@@ -557,6 +434,7 @@ async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, f
|
||||
// : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
|
||||
// })).filter(call => call.name);
|
||||
// }
|
||||
|
||||
// async function appendOpenAiChatToolResults(
|
||||
// messages: OpenAiCompatibleChatMessage[],
|
||||
// calls: ToolCallData[],
|
||||
@@ -570,3 +448,178 @@ async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, f
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// 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(msg.from?.id === Environment.CREATOR_ID),
|
||||
// // 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);
|
||||
//
|
||||
// let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||
//
|
||||
// for (const toolResult of toolResults) {
|
||||
// try {
|
||||
// const raw = JSON.parse(toolResult);
|
||||
// const res = GetNoteFileResultSchema.safeParse(raw);
|
||||
//
|
||||
// if (res.success && res.data.success) {
|
||||
// successGetNoteFileResult = res.data;
|
||||
// }
|
||||
// } catch {
|
||||
// // Not every tool result is JSON.
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
|
||||
// await bot.sendDocument({
|
||||
// chat_id: msg.chat.id,
|
||||
// reply_parameters: {
|
||||
// message_id: msg.message_id,
|
||||
// },
|
||||
// document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
|
||||
// }).catch(logError);
|
||||
// }
|
||||
//
|
||||
// await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// const request: ChatCompletionCreateParamsStreaming = {
|
||||
// model: config.geminiChatTarget.model,
|
||||
// messages: chatMessages,
|
||||
// tools: getOpenAITools(msg.from?.id === Environment.CREATOR_ID),
|
||||
// // temperature: 0.6,
|
||||
// stream: true,
|
||||
// parallel_tool_calls: true
|
||||
// };
|
||||
// const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
|
||||
//
|
||||
// aiLog("debug", "openai_compatible.stream.open", {round});
|
||||
// // const streamToolCalls: OpenAiChatToolCallLike[] = [];
|
||||
// const roundTextStart = streamMessage.getText().length;
|
||||
// const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
|
||||
// let calls: ToolCallData[] = [];
|
||||
//
|
||||
// for await (const chunk of response) {
|
||||
// if (signal.aborted) throw new Error("Aborted");
|
||||
//
|
||||
// const delta = chunk.choices?.[0]?.delta;
|
||||
// streamMessage.append(delta?.content ?? "");
|
||||
//
|
||||
// if (delta?.tool_calls?.length) {
|
||||
// calls = toolCallAccumulator.add(delta.tool_calls);
|
||||
// streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||
// await streamMessage.flush();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
|
||||
// aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
|
||||
// round,
|
||||
// duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
// textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
// calls: calls.map(aiLogToolCall),
|
||||
// });
|
||||
// if (!calls.length) return;
|
||||
//
|
||||
// const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
// chatMessages.push({
|
||||
// role: "assistant",
|
||||
// content: roundText,
|
||||
// tool_calls: calls.map(call => ({
|
||||
// id: call.id,
|
||||
// type: "function",
|
||||
// function: {
|
||||
// name: call.name,
|
||||
// arguments: call.argumentsText,
|
||||
// },
|
||||
// })),
|
||||
// });
|
||||
//
|
||||
// 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);
|
||||
// }
|
||||
// }
|
||||
|
||||
+320
-181
@@ -1,40 +1,41 @@
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import * as fs from "node:fs";
|
||||
import {Blob} from "node:buffer";
|
||||
import path from "node:path";
|
||||
import type {BoundaryValue} from "../common/boundary-types";
|
||||
import {AiProvider} from "../model/ai-provider.js";
|
||||
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||
import {Environment, type OpenAiBackend} from "../common/environment.js";
|
||||
import {delay, logError, replyToMessage} from "../util/utils.js";
|
||||
import {MessageStore} from "../common/message-store.js";
|
||||
import type {OpenAiResponseTool} from "./tool-mappers.js";
|
||||
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers.js";
|
||||
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message.js";
|
||||
import {AiDownloadedFile} from "./telegram-attachments.js";
|
||||
import {getRuntimeCapabilities} from "./provider-model-runtime.js";
|
||||
import {StoredAttachment} from "../model/stored-attachment.js";
|
||||
import {AiChatMessage, ChatMessage} from "./chat-messages-types.js";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {Environment} from "../common/environment";
|
||||
import {photoGenDir} from "../index";
|
||||
import {collectReplyChainText, delay, logError, replyToMessage} from "../util/utils";
|
||||
import {MessageStore} from "../common/message-store";
|
||||
import type {OpenAiResponseTool} from "./tool-mappers";
|
||||
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers";
|
||||
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {AiDownloadedFile} from "./telegram-attachments";
|
||||
import {getRuntimeCapabilities} from "./provider-model-runtime";
|
||||
import {StoredAttachment} from "../model/stored-attachment";
|
||||
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
|
||||
import {ListResponse, Ollama} from "ollama";
|
||||
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime.js";
|
||||
import {MessageImagePart, MessagePart} from "../common/message-part.js";
|
||||
import {KeyedAsyncLock} from "../util/async-lock.js";
|
||||
import {type AiRequestQueueTarget} from "./provider-request-queue.js";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator.js";
|
||||
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings.js";
|
||||
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
|
||||
import {MessageImagePart, MessagePart} from "../common/message-part";
|
||||
import {KeyedAsyncLock} from "../util/async-lock";
|
||||
import {type AiRequestQueueTarget} from "./provider-request-queue";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
|
||||
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings";
|
||||
import {
|
||||
isTranscribableAudioDownload,
|
||||
resolveSpeechToTextProviderForUser,
|
||||
transcribeSpeechDownloads
|
||||
} from "./speech-to-text.js";
|
||||
} from "./speech-to-text";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
import type {ResponseInputContent, ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||
import {MistralChatMessage} from "./mistral-chat-message.js";
|
||||
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer.js";
|
||||
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger.js";
|
||||
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline.js";
|
||||
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store.js";
|
||||
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
|
||||
import type {GenerateContentParameters} from "@google/genai";
|
||||
import {MistralChatMessage} from "./mistral-chat-message";
|
||||
import {OllamaChatMessage} from "./ollama-chat-message";
|
||||
import {GeminiMessage} from "./gemini-chat-message";
|
||||
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
||||
import {AiRuntimeTarget, createMistralClient, getGeminiApiMode, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||
|
||||
export type {Message} from "typescript-telegram-bot-api";
|
||||
export type {AiRuntimeTarget} from "./ai-runtime-target";
|
||||
@@ -46,6 +47,7 @@ export type {MessageImagePart, MessagePart} from "../common/message-part";
|
||||
export type {OpenAIChatMessage} from "./openai-chat-message";
|
||||
export type {MistralChatMessage} from "./mistral-chat-message";
|
||||
export type {OllamaChatMessage} from "./ollama-chat-message";
|
||||
export type {GeminiMessage} from "./gemini-chat-message";
|
||||
export type {TelegramArtifactFile} from "./telegram-stream-message";
|
||||
export {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
export type {ChatRequest, ListResponse, Ollama, Tool} from "ollama";
|
||||
@@ -61,6 +63,8 @@ export type {
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
ChatCompletionMessageParam,
|
||||
} from "openai/resources/chat/completions";
|
||||
export type {GenerateContentParameters} from "@google/genai";
|
||||
|
||||
export const TELEGRAM_LIMIT = 4096;
|
||||
export const MAX_TOOL_ROUNDS = 40;
|
||||
export const MAX_IDENTICAL_TOOL_CALLS = 1;
|
||||
@@ -71,19 +75,13 @@ export const MAX_OLLAMA_CONTEXT_SIZE = 262144;
|
||||
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
|
||||
export const toolResourceLocks = new KeyedAsyncLock();
|
||||
|
||||
function photoGenDir(): string {
|
||||
return path.join(Environment.DATA_PATH, "cache", "photo", "gen");
|
||||
}
|
||||
|
||||
export type UnifiedRunOptions = {
|
||||
provider: AiProvider;
|
||||
msg: Message;
|
||||
requestId?: string;
|
||||
isGuestMsg?: boolean;
|
||||
text: string;
|
||||
stream?: boolean;
|
||||
think?: Think;
|
||||
synthesizeSpeechResponse?: boolean;
|
||||
responseLanguage?: UserAiResponseLanguage;
|
||||
contextSize?: number;
|
||||
voiceMode?: UserAiVoiceMode;
|
||||
@@ -101,7 +99,7 @@ export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
// SDKs sometimes expose loose object-shaped payloads. Keep the looseness at the boundary,
|
||||
// but do not spread it through the rest of the code.
|
||||
// but do not spread `unknown` through the rest of the code.
|
||||
export type LooseRecord = Record<string, JsonValue | object | undefined>;
|
||||
|
||||
export type OpenAiResponsesFunctionCall = {
|
||||
@@ -183,6 +181,22 @@ export type OpenAiResponseLike = {
|
||||
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 =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } };
|
||||
@@ -199,11 +213,13 @@ export type OpenAiChatCompletionStreamChunkLike = {
|
||||
|
||||
export type AsyncIterableStream<T> = AsyncIterable<T>;
|
||||
|
||||
export function isRecord(value: BoundaryValue): value is LooseRecord {
|
||||
export type GeminiGenerationRequest = GenerateContentParameters;
|
||||
|
||||
export function isRecord(value: unknown): value is LooseRecord {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function toJsonValue(value: BoundaryValue): JsonValue | undefined {
|
||||
export function toJsonValue(value: unknown): JsonValue | undefined {
|
||||
if (value === null) return null;
|
||||
|
||||
switch (typeof value) {
|
||||
@@ -229,16 +245,16 @@ export function toJsonValue(value: BoundaryValue): JsonValue | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export function toJsonObject(value: BoundaryValue): JsonObject | undefined {
|
||||
export function toJsonObject(value: unknown): JsonObject | undefined {
|
||||
const json = toJsonValue(value);
|
||||
return json !== null && typeof json === "object" && !Array.isArray(json) ? json : undefined;
|
||||
}
|
||||
|
||||
export function asOptionalString(value: BoundaryValue): string | undefined {
|
||||
export function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function isAbortError(error: BoundaryValue): boolean {
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error ? error.message.includes("Aborted") : String(error).includes("Aborted");
|
||||
}
|
||||
|
||||
@@ -251,7 +267,6 @@ export type RuntimeConfigSnapshot = {
|
||||
useSystemPrompt: boolean;
|
||||
systemPrompt?: string;
|
||||
rankerToolPrompt?: string;
|
||||
toolRankerFallbackPolicy: ToolRankerFallbackPolicy;
|
||||
|
||||
ollamaChatTarget: AiRuntimeTarget;
|
||||
ollamaToolRankerTarget?: AiRuntimeTarget;
|
||||
@@ -268,13 +283,14 @@ export type RuntimeConfigSnapshot = {
|
||||
ollamaRagMaxArchiveFiles: number;
|
||||
ollamaRagMaxArchiveBytes: number;
|
||||
ollamaRagMaxArchiveDepth: number;
|
||||
|
||||
geminiChatTarget: AiRuntimeTarget;
|
||||
geminiImageTarget: AiRuntimeTarget;
|
||||
|
||||
mistralChatTarget: AiRuntimeTarget;
|
||||
mistralToolRankerTarget?: AiRuntimeTarget;
|
||||
|
||||
openAiChatTarget: AiRuntimeTarget;
|
||||
openAiImageTarget: AiRuntimeTarget;
|
||||
openAiToolRankerTarget?: AiRuntimeTarget;
|
||||
openAiBackend: OpenAiBackend;
|
||||
};
|
||||
|
||||
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||
@@ -284,7 +300,6 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||
|
||||
systemPrompt: Environment.SYSTEM_PROMPT,
|
||||
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
|
||||
toolRankerFallbackPolicy: Environment.TOOL_RANKER_FALLBACK_POLICY,
|
||||
|
||||
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
|
||||
ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "toolRank"),
|
||||
@@ -302,20 +317,16 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||
ollamaRagMaxArchiveBytes: Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES,
|
||||
ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH,
|
||||
|
||||
geminiChatTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "chat"),
|
||||
geminiImageTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "vision"),
|
||||
|
||||
mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"),
|
||||
mistralToolRankerTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "toolRank"),
|
||||
|
||||
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
|
||||
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[] {
|
||||
if (part.imageParts?.length) return part.imageParts;
|
||||
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
|
||||
@@ -325,10 +336,33 @@ export function openAiImageDataUrl(image: MessageImagePart): string {
|
||||
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 {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return config.ollamaChatTarget.model;
|
||||
case AiProvider.GEMINI:
|
||||
return config.geminiChatTarget.model;
|
||||
case AiProvider.MISTRAL:
|
||||
return config.mistralChatTarget.model;
|
||||
case AiProvider.OPENAI:
|
||||
@@ -348,27 +382,12 @@ export function providerTargets(provider: AiProvider, config: RuntimeConfigSnaps
|
||||
config.ollamaAudioTarget,
|
||||
config.ollamaDocumentsTarget
|
||||
].filter((target): target is AiRuntimeTarget => !!target);
|
||||
case AiProvider.GEMINI:
|
||||
return [config.geminiChatTarget];
|
||||
case AiProvider.MISTRAL:
|
||||
return [
|
||||
config.mistralChatTarget,
|
||||
config.mistralToolRankerTarget,
|
||||
].filter((target): target is AiRuntimeTarget => !!target);
|
||||
return [config.mistralChatTarget];
|
||||
case AiProvider.OPENAI:
|
||||
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;
|
||||
return [config.openAiChatTarget];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +395,8 @@ export function providerName(provider: AiProvider): AiProviderName {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return "ollama";
|
||||
case AiProvider.GEMINI:
|
||||
return "gemini";
|
||||
case AiProvider.MISTRAL:
|
||||
return "mistral";
|
||||
case AiProvider.OPENAI:
|
||||
@@ -387,14 +408,10 @@ export 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");
|
||||
}
|
||||
@@ -425,6 +442,8 @@ export function resolveAiRequestQueueTarget(
|
||||
if (hasAudioAttachmentKind(requestedAttachmentKinds)) return config.ollamaAudioTarget;
|
||||
if (requestedAttachmentKinds.has("image")) return config.ollamaVisionTarget;
|
||||
return options.think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
|
||||
case AiProvider.GEMINI:
|
||||
return config.geminiChatTarget;
|
||||
case AiProvider.MISTRAL:
|
||||
return config.mistralChatTarget;
|
||||
case AiProvider.OPENAI:
|
||||
@@ -524,13 +543,13 @@ export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set<A
|
||||
if (msg.video) kinds.add("video");
|
||||
}
|
||||
|
||||
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 40): Promise<StoredAttachment[]> {
|
||||
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 1): Promise<StoredAttachment[]> {
|
||||
const attachments: StoredAttachment[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = await MessageStore.get(msg.chat.id, msg.message_id);
|
||||
|
||||
for (let i = 0; current && i < limit; i++) {
|
||||
for (const attachment of filterUserInputStoredAttachments(current?.attachments ?? [])) {
|
||||
for (const attachment of current?.attachments ?? []) {
|
||||
const key = [
|
||||
attachment.kind,
|
||||
attachment.fileUniqueId || attachment.fileId,
|
||||
@@ -550,6 +569,13 @@ export async function hasStoredReplyChainImage(msg: Message): Promise<boolean> {
|
||||
const attachments = await collectStoredReplyChainAttachments(msg);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -622,6 +648,7 @@ export async function rejectUnsupportedAttachments(
|
||||
if (!unsupported) return false;
|
||||
|
||||
if (!kinds.has("audio")) {
|
||||
// TODO: 13.05.2026, Danil Nikolaev: add "Regenerate" button
|
||||
await replyToMessage({
|
||||
message: msg,
|
||||
text: unsupportedAttachmentText(provider, effectiveModel, unsupported),
|
||||
@@ -685,7 +712,7 @@ export function parseToolArgumentsObject(argumentsText?: string): ToolArgumentsP
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(error: BoundaryValue): string {
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -776,30 +803,191 @@ export function normalizeOllamaToolCalls(calls: readonly OllamaToolCallLike[] =
|
||||
.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(
|
||||
msg: Message,
|
||||
textOverride: string,
|
||||
provider: AiProvider,
|
||||
downloads: AiDownloadedFile[],
|
||||
config: RuntimeConfigSnapshot,
|
||||
runtimeTarget: AiRuntimeTarget,
|
||||
responseLanguage: UserAiResponseLanguage,
|
||||
): Promise<{
|
||||
chatMessages: AiChatMessage[];
|
||||
imageCount: number
|
||||
}> {
|
||||
const includePythonToolPrompt = Environment.ENABLE_PYTHON_INTERPRETER && msg.from?.id === Environment.CREATOR_ID;
|
||||
const snapshot = await buildConversationSnapshot(
|
||||
msg,
|
||||
textOverride,
|
||||
downloads,
|
||||
config,
|
||||
runtimeTarget,
|
||||
responseLanguage,
|
||||
includePythonToolPrompt,
|
||||
);
|
||||
const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id);
|
||||
const messageParts = await collectReplyChainText({triggerMsg: storedMsg, downloads: downloads});
|
||||
|
||||
return serializeConversationSnapshot(snapshot, provider, Environment.USE_NAMES_IN_PROMPT);
|
||||
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 systemInstruction = buildSystemInstruction(config, responseLanguage, includePythonToolPrompt);
|
||||
|
||||
const getContent = (part: MessagePart): string => {
|
||||
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> {
|
||||
@@ -838,7 +1026,7 @@ export async function transcribeAudioIfNeeded(provider: AiProvider, userId: numb
|
||||
});
|
||||
return transcript;
|
||||
} catch (e) {
|
||||
aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||
aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -855,11 +1043,20 @@ export function stripAudioFromRunnerMessages(parts: AiChatMessage[]): void {
|
||||
if ("videoNotes" in part) {
|
||||
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(
|
||||
chatMessages: AiChatMessage[],
|
||||
provider: AiProvider,
|
||||
transcript: string,
|
||||
): void {
|
||||
const lastUser = [...chatMessages].reverse().find(message => "role" in message && message.role === "user");
|
||||
@@ -868,6 +1065,11 @@ export function appendTranscriptToChatMessages(
|
||||
const text = transcript.trim();
|
||||
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 (typeof lastUser.content === "string") {
|
||||
@@ -883,7 +1085,8 @@ export function appendTranscriptToChatMessages(
|
||||
// narrows it to the Chat Completions union (`text | image_url | thinking`),
|
||||
// which makes comparisons with Responses parts (`input_text | input_image`)
|
||||
// look impossible even though this is a runtime mixed-provider guard.
|
||||
const partType = (part as {type?: string}).type;
|
||||
const record: Record<string, unknown> = part;
|
||||
const partType = record["type"];
|
||||
|
||||
return partType === "input_text" || partType === "input_image";
|
||||
});
|
||||
@@ -906,8 +1109,8 @@ export async function deleteMistralLibrary(libraryId: string | undefined, target
|
||||
await mistralAi.beta.libraries.delete({libraryId});
|
||||
aiLog("success", "mistral.library.delete.done", {libraryId, duration: aiLogDuration(startedAt)});
|
||||
} catch (e) {
|
||||
aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e});
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,7 +1261,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
|
||||
aiLog("error", "mistral.documents.prepare.failed", {
|
||||
libraryId,
|
||||
duration: aiLogDuration(startedAt),
|
||||
error: e instanceof Error ? e : String(e),
|
||||
error: e,
|
||||
});
|
||||
await deleteMistralLibrary(libraryId, target);
|
||||
throw e;
|
||||
@@ -1109,47 +1312,35 @@ export async function executeTool(
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isAbortError(error instanceof Error ? error : String(error))) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = toolFailureResult("execution_failed", errorMessage(error instanceof Error ? error : String(error)));
|
||||
const result = toolFailureResult("execution_failed", errorMessage(error));
|
||||
|
||||
aiLog("error", "tool.failed.returned_to_model", {
|
||||
name: toolCall.name,
|
||||
duration: aiLogDuration(startedAt),
|
||||
error: error instanceof Error ? error : String(error),
|
||||
error,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function toolResourceKeys(toolCall: ToolCallData, userId?: number | undefined | null): string[] {
|
||||
export function toolResourceKeys(toolCall: ToolCallData): string[] {
|
||||
const args = safeJsonParseObject(toolCall.argumentsText);
|
||||
const pathValue = typeof args.path === "string" ? args.path : undefined;
|
||||
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : 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) {
|
||||
case "read_user_info":
|
||||
case "read_system_info":
|
||||
case "get_datetime":
|
||||
case "web_search":
|
||||
case "get_weather":
|
||||
case "read_file":
|
||||
case "list_directory":
|
||||
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_directory":
|
||||
case "update_file":
|
||||
@@ -1182,7 +1373,7 @@ export async function executeScheduledTool(
|
||||
message: TelegramStreamMessage,
|
||||
context: ToolRuntimeContext,
|
||||
): Promise<string> {
|
||||
const keys = toolResourceKeys(toolCall, userId);
|
||||
const keys = toolResourceKeys(toolCall);
|
||||
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
||||
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
|
||||
}
|
||||
@@ -1263,33 +1454,6 @@ export async function executeToolBatch(
|
||||
message.setStatus(Environment.getUseToolText(statusCalls));
|
||||
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", {
|
||||
count: toolCalls.length,
|
||||
uniqueCount: statusCalls.length,
|
||||
@@ -1301,7 +1465,7 @@ export async function executeToolBatch(
|
||||
aiLog("error", "tool.batch.failed", {
|
||||
count: toolCalls.length,
|
||||
duration: aiLogDuration(startedAt),
|
||||
error: e instanceof Error ? e : String(e),
|
||||
error: e,
|
||||
});
|
||||
|
||||
throw e;
|
||||
@@ -1318,7 +1482,7 @@ export function appendOllamaToolResults(messages: ChatMessage[], calls: ToolCall
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyToolExecutionResult(result: BoundaryValue): string {
|
||||
export function stringifyToolExecutionResult(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
const json = JSON.stringify(toJsonValue(result) ?? String(result));
|
||||
return json ?? String(result);
|
||||
@@ -1326,7 +1490,7 @@ export function stringifyToolExecutionResult(result: BoundaryValue): string {
|
||||
|
||||
export type ToolExecutionMemory = Map<string, { count: number; result: string }>;
|
||||
|
||||
export function stableJsonStringify(value: BoundaryValue): string {
|
||||
export function stableJsonStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableJsonStringify).join(",")}]`;
|
||||
}
|
||||
@@ -1441,14 +1605,14 @@ export type NormalizedRouterPlan = {
|
||||
m: string; // Missing
|
||||
};
|
||||
|
||||
export function toolSchemaName(tool: BoundaryValue): string | undefined {
|
||||
export function toolSchemaName(tool: unknown): 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);
|
||||
const directName = fn?.name ?? tool.name;
|
||||
return asOptionalString(directName);
|
||||
}
|
||||
|
||||
export function toolSchemaNames(tool: BoundaryValue): string[] {
|
||||
export function toolSchemaNames(tool: unknown): string[] {
|
||||
if (!isRecord(tool)) return [];
|
||||
|
||||
if (Array.isArray(tool.functionDeclarations)) {
|
||||
@@ -1461,16 +1625,12 @@ export function toolSchemaNames(tool: BoundaryValue): string[] {
|
||||
return name ? [name] : [];
|
||||
}
|
||||
|
||||
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] {
|
||||
export function allToolSchemaNames(tools: readonly unknown[]): string[] {
|
||||
return [...new Set(tools.flatMap(toolSchemaNames))];
|
||||
}
|
||||
|
||||
export function getOpenAIResponsesToolsWithImage(
|
||||
config: RuntimeConfigSnapshot,
|
||||
forCreator?: boolean,
|
||||
vectorStoreIds: string[] = [],
|
||||
): Array<OpenAiResponseTool | LooseRecord> {
|
||||
const tools: Array<OpenAiResponseTool | LooseRecord> = [
|
||||
export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot, forCreator?: boolean): Array<OpenAiResponseTool | LooseRecord> {
|
||||
return [
|
||||
...getOpenAIResponsesTools(forCreator),
|
||||
getOpenAICodeInterpreterTool(),
|
||||
{
|
||||
@@ -1481,17 +1641,8 @@ export function getOpenAIResponsesToolsWithImage(
|
||||
output_format: "png",
|
||||
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 {
|
||||
@@ -1529,7 +1680,7 @@ export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiRespon
|
||||
id: item.id!,
|
||||
code: item.code ?? null,
|
||||
containerId: item.container_id!,
|
||||
status: item.status ?? "unrecognized",
|
||||
status: item.status ?? "unknown",
|
||||
outputs: Array.isArray(item.outputs) ? item.outputs : [],
|
||||
}));
|
||||
}
|
||||
@@ -1540,16 +1691,11 @@ export function collectOpenAiResponseImages(response: OpenAiResponseLike): strin
|
||||
.map(item => item.result!);
|
||||
}
|
||||
|
||||
export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): {
|
||||
buffer: Buffer;
|
||||
cachePath: string;
|
||||
fileName: string;
|
||||
} {
|
||||
const buffer = Buffer.from(b64, "base64");
|
||||
export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): Buffer {
|
||||
const imageBuffer = Buffer.from(b64, "base64");
|
||||
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
|
||||
const cachePath = path.join(photoGenDir(), fileName);
|
||||
fs.writeFileSync(cachePath, buffer);
|
||||
return {buffer, cachePath, fileName};
|
||||
fs.writeFileSync(path.join(photoGenDir, fileName), imageBuffer);
|
||||
return imageBuffer;
|
||||
}
|
||||
|
||||
export async function showOpenAiGeneratedImage(
|
||||
@@ -1560,21 +1706,14 @@ export async function showOpenAiGeneratedImage(
|
||||
status: string,
|
||||
final: boolean,
|
||||
): Promise<void> {
|
||||
const image = writeOpenAiGeneratedImage(sourceMessage, b64, label);
|
||||
const attachment: StoredAttachment = {
|
||||
kind: "image",
|
||||
fileId: image.cachePath,
|
||||
fileName: image.fileName,
|
||||
mimeType: "image/png",
|
||||
cachePath: image.cachePath,
|
||||
};
|
||||
const imageBuffer = writeOpenAiGeneratedImage(sourceMessage, b64, label);
|
||||
if (final && !streamMessage.getText().trim()) {
|
||||
streamMessage.replaceText(status);
|
||||
streamMessage.clearStatus();
|
||||
} else {
|
||||
streamMessage.setStatus(status);
|
||||
}
|
||||
await streamMessage.showImage(image.buffer, attachment);
|
||||
await streamMessage.showImage(imageBuffer);
|
||||
}
|
||||
|
||||
export function openAiResponseItemCallId(item: OpenAiResponseOutputItem & { id?: string }): string {
|
||||
|
||||
@@ -1,248 +1,219 @@
|
||||
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||
import {ChatRequest} from "ollama";
|
||||
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 {Tool} from "ollama";
|
||||
import {createOllamaClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
|
||||
import {
|
||||
buildRankerContext,
|
||||
buildRankerTarget,
|
||||
buildToolRankerPrompt,
|
||||
filterRankedTools,
|
||||
ToolRankerSelection,
|
||||
} from "./tool-ranker-pipeline.js";
|
||||
import {allToolSchemaNames} from "./unified-ai-runner.shared.js";
|
||||
import {sanitizeToolRankerResult} from "./tool-ranker-metadata.js";
|
||||
import {resolveToolRankerFallbackSelection} from "./tool-ranker-fallback.js";
|
||||
allToolSchemaNames,
|
||||
isRecord,
|
||||
RuntimeConfigSnapshot,
|
||||
toolSchemaNames
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {z} from "zod";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
|
||||
|
||||
export class ToolRanker {
|
||||
export type ToolRankerSelection = {
|
||||
tools: Tool[];
|
||||
usedRanker: boolean;
|
||||
};
|
||||
|
||||
export class OllamaToolRanker {
|
||||
constructor(private readonly config: RuntimeConfigSnapshot) {
|
||||
}
|
||||
|
||||
async selectTools(args: {
|
||||
provider: AiProvider;
|
||||
userQuery: string;
|
||||
availableTools: readonly BoundaryValue[];
|
||||
availableTools: Tool[];
|
||||
round: number;
|
||||
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> {
|
||||
const {availableTools, provider, round, signal, userQuery} = args;
|
||||
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);
|
||||
const {availableTools, round, signal, userQuery} = args;
|
||||
const target = this.config.ollamaToolRankerTarget;
|
||||
|
||||
if (!availableTools.length) {
|
||||
return {toolNames: [], usedRanker: false};
|
||||
return {tools: [], usedRanker: false};
|
||||
}
|
||||
|
||||
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
|
||||
|
||||
if (!target) {
|
||||
return resolveToolRankerFallbackSelection({
|
||||
fallbackPolicy,
|
||||
availableToolNames: availableNames,
|
||||
});
|
||||
// Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
|
||||
if (!target?.model) {
|
||||
return {
|
||||
tools: availableTools,
|
||||
usedRanker: false,
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const ranker = buildToolRankerPrompt(buildRankerContext(this.config, provider, target, round, userQuery, availableTools));
|
||||
const availableNames = new Set(allToolSchemaNames(availableTools));
|
||||
// const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
|
||||
|
||||
aiLog("debug", "tool_ranker.start", {
|
||||
provider,
|
||||
const availableToolNames = availableTools.map(t => "- " + (t.function.name ?? ""));
|
||||
|
||||
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,
|
||||
target: aiLogProviderTarget(target),
|
||||
queryChars: userQuery.length,
|
||||
availableTools: availableNames,
|
||||
fallbackPolicy,
|
||||
usedMainModelFallback: !configuredTarget && fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL,
|
||||
availableTools: [...availableNames],
|
||||
});
|
||||
|
||||
try {
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
const raw = await runRanker(provider, target, ranker.prompt, userQuery);
|
||||
if (signal.aborted) throw new Error("Aborted");
|
||||
const selectedNames = sanitizeToolRankerResult({
|
||||
raw,
|
||||
availableToolNames: availableNames,
|
||||
});
|
||||
const filtered = filterRankedTools(availableTools, selectedNames);
|
||||
const toolNames = allToolSchemaNames(filtered);
|
||||
const ollama = createOllamaClient(target);
|
||||
// const response = await ollama.chat({
|
||||
// model: target.model,
|
||||
// messages: [
|
||||
// {role: "system", content: prompt},
|
||||
// {
|
||||
// role: "user",
|
||||
// content: JSON.stringify({
|
||||
// q: userQuery,
|
||||
// tools: toolsForPrompt,
|
||||
// }),
|
||||
// },
|
||||
// ],
|
||||
// stream: false,
|
||||
// options: {
|
||||
// temperature: 0,
|
||||
// num_ctx: 8192,
|
||||
// },
|
||||
// });
|
||||
|
||||
aiLog("debug", "tool_ranker.done", {
|
||||
provider,
|
||||
const then = performance.now();
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: target?.model ?? "",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: toolRouterPrompt()
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: userQuery,
|
||||
}
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
format: routerSchema,
|
||||
options: {
|
||||
temperature: 0,
|
||||
top_p: 0.8,
|
||||
top_k: 20,
|
||||
repeat_penalty: 1.05,
|
||||
num_ctx: 8192,
|
||||
num_predict: 256
|
||||
},
|
||||
});
|
||||
|
||||
const now = performance.now();
|
||||
const diff = now - then;
|
||||
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);
|
||||
}
|
||||
|
||||
const selectedNameSet = new Set(selectedNames);
|
||||
const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
|
||||
|
||||
aiLog("debug", "ollama.tool_ranker.done", {
|
||||
round,
|
||||
duration: aiLogDuration(startedAt),
|
||||
selectedNames,
|
||||
selectedCount: toolNames.length,
|
||||
selectedCount: tools.length,
|
||||
rawPreview: raw.slice(0, 800),
|
||||
});
|
||||
|
||||
return {toolNames, usedRanker: true};
|
||||
// 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 (error instanceof Error && error.message.includes("Aborted")) throw error;
|
||||
let failureMessage = error instanceof Error ? error.message : String(error);
|
||||
if (String(error).includes("Aborted")) throw error;
|
||||
|
||||
const canRetryOnMainModel = fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL
|
||||
&& (
|
||||
target.model !== mainModelTarget.model
|
||||
|| !sameRuntimeEndpoint(target, mainModelTarget)
|
||||
);
|
||||
|
||||
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,
|
||||
aiLog("warn", "ollama.tool_ranker.failed.fallback_all_allowed", {
|
||||
round,
|
||||
target: aiLogProviderTarget(target),
|
||||
fallbackPolicy,
|
||||
duration: aiLogDuration(startedAt),
|
||||
errorSummary: failureMessage,
|
||||
error,
|
||||
});
|
||||
|
||||
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 as const,
|
||||
think: false,
|
||||
format: {
|
||||
type: "object",
|
||||
properties: {
|
||||
toolNames: {
|
||||
type: "array",
|
||||
items: {type: "string"},
|
||||
},
|
||||
},
|
||||
required: ["toolNames"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
options: {
|
||||
temperature: 0,
|
||||
top_p: 0.8,
|
||||
top_k: 20,
|
||||
repeat_penalty: 1.05,
|
||||
num_ctx: 8192,
|
||||
num_predict: 256,
|
||||
},
|
||||
} satisfies ChatRequest & { stream: false };
|
||||
|
||||
const response = await ollama.chat(request);
|
||||
return response.message?.content?.trim() ?? "";
|
||||
}
|
||||
case AiProvider.MISTRAL: {
|
||||
const mistral = createMistralClient(target);
|
||||
const request: Parameters<typeof mistral.chat.complete>[0] = {
|
||||
model: target.model,
|
||||
messages: [
|
||||
{role: "system", content: prompt},
|
||||
{role: "user", content: userQuery},
|
||||
],
|
||||
temperature: 0,
|
||||
};
|
||||
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[];
|
||||
|
||||
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
|
||||
// only for official OpenAI endpoints.
|
||||
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||
model: target.model,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (!target.baseUrl) {
|
||||
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
|
||||
request.response_format = {type: "json_object"};
|
||||
}
|
||||
|
||||
const response = await openAi.chat.completions.create(request);
|
||||
|
||||
return response.choices[0]?.message?.content?.trim() ?? "";
|
||||
}
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function latestUserTextFromOllamaMessages(messages: readonly { role?: string; content?: unknown }[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message?.role !== "user") continue;
|
||||
if (typeof message.content === "string") return message.content;
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
+176
-122
@@ -5,37 +5,53 @@ import {ifTrue, logError, replyToMessage} from "../util/utils";
|
||||
import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments";
|
||||
import {ChatMessage} from "./chat-messages-types";
|
||||
import {aiProviderRequestQueue} from "./provider-request-queue";
|
||||
import {prepareOllamaDocumentRag} from "./ollama-rag";
|
||||
import {
|
||||
AI_VOICE_MODE_TRANSCRIPT,
|
||||
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||
resolveAiContextSizeForUser,
|
||||
resolveAiImageOutputModeForUser,
|
||||
resolveAiResponseLanguageForUser,
|
||||
resolveAiVoiceModeForUser
|
||||
} 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 {buildAiRegenerateCallbackData} from "./regenerate-callback";
|
||||
import {createOllamaClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
|
||||
|
||||
import {runOpenAi} from "./unified-ai-runner.openai";
|
||||
import {runOllama} from "./unified-ai-runner.ollama";
|
||||
import {runMistral} from "./unified-ai-runner.mistral";
|
||||
import {
|
||||
AI_REQUEST_TIMEOUT_MS,
|
||||
appendTranscriptToChatMessages,
|
||||
collectCachedMessageAttachments,
|
||||
collectRequestedAttachmentKinds,
|
||||
collectTextMessages,
|
||||
deleteMistralLibrary,
|
||||
GeminiMessage,
|
||||
hasAudioAttachmentKind,
|
||||
initialStatus,
|
||||
isAbortError,
|
||||
prepareMistralDocuments,
|
||||
providerName,
|
||||
rejectUnsupportedAttachments,
|
||||
resolveAiRequestQueueTarget,
|
||||
RuntimeConfigSnapshot,
|
||||
snapshotModel,
|
||||
snapshotRuntimeConfig,
|
||||
stripAudioFromRunnerMessages,
|
||||
TELEGRAM_LIMIT,
|
||||
toolRuntimeContextFromDownloads,
|
||||
transcribeAudioIfNeeded,
|
||||
UnifiedRunOptions
|
||||
} 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";
|
||||
import {runGemini} from "./unified-ai-runner.gemini";
|
||||
import {resolveTextToSpeechProviderForUser, sendSynthesizedSpeech, synthesizeSpeech} from "./text-to-speech";
|
||||
|
||||
export type {ToolCallData} from "./unified-ai-runner.shared";
|
||||
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
|
||||
@@ -46,11 +62,9 @@ async function executeUnifiedAiRequest(
|
||||
downloads: AiDownloadedFile[],
|
||||
controller: AbortController,
|
||||
streamMessage: TelegramStreamMessage,
|
||||
): Promise<void> {
|
||||
): Promise<{ mistralLibraryId?: string }> {
|
||||
const requestStartedAt = Date.now();
|
||||
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
|
||||
aiLog("info", "request.execute.start", {
|
||||
requestId: options.requestId,
|
||||
provider: providerName(options.provider),
|
||||
stream: options.stream ?? true,
|
||||
think: options.think,
|
||||
@@ -66,63 +80,165 @@ async function executeUnifiedAiRequest(
|
||||
})),
|
||||
});
|
||||
|
||||
preparedRequest = await prepareUnifiedAiRequestPipeline({
|
||||
options,
|
||||
config,
|
||||
const {
|
||||
chatMessages,
|
||||
imageCount
|
||||
} = await collectTextMessages(
|
||||
options.msg,
|
||||
options.text,
|
||||
options.provider,
|
||||
downloads,
|
||||
streamMessage,
|
||||
controller,
|
||||
});
|
||||
if (preparedRequest.finishAfterTranscript) return;
|
||||
|
||||
config,
|
||||
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||
);
|
||||
const firstRoundStatus = initialStatus(downloads, imageCount);
|
||||
const toolContext = toolRuntimeContextFromDownloads(downloads);
|
||||
aiLog("debug", "request.messages.collected", {
|
||||
requestId: options.requestId,
|
||||
provider: providerName(options.provider),
|
||||
chatMessages: preparedRequest.chatMessages.length,
|
||||
imageCount: preparedRequest.imageCount,
|
||||
firstRoundStatus: preparedRequest.firstRoundStatus,
|
||||
hasToolInputFiles: !!preparedRequest.toolContext.pythonInputFiles?.length,
|
||||
chatMessages: chatMessages.length,
|
||||
imageCount,
|
||||
firstRoundStatus,
|
||||
hasToolInputFiles: !!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 {
|
||||
await runUnifiedAiResponsePipeline({
|
||||
options,
|
||||
config,
|
||||
downloads,
|
||||
prepared: preparedRequest,
|
||||
streamMessage,
|
||||
controller,
|
||||
});
|
||||
const preparedMistral = options.provider === AiProvider.MISTRAL
|
||||
? await prepareMistralDocuments(downloads, chatMessages as MistralChatMessage[], streamMessage, config.mistralChatTarget, controller.signal)
|
||||
: {documents: []};
|
||||
const documents = preparedMistral.documents;
|
||||
mistralLibraryId = preparedMistral.libraryId;
|
||||
|
||||
if (options.provider === AiProvider.OLLAMA) {
|
||||
await prepareOllamaDocumentRag({
|
||||
downloads,
|
||||
messages: chatMessages as OllamaChatMessage[],
|
||||
userQuery: options.text,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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, options.msg, config, toolContext);
|
||||
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(options.msg, chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
||||
break;
|
||||
case AiProvider.GEMINI:
|
||||
await runGemini(options.msg, 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", {
|
||||
requestId: options.requestId,
|
||||
provider: providerName(options.provider),
|
||||
duration: aiLogDuration(requestStartedAt),
|
||||
responseChars: streamMessage.getText().length,
|
||||
mistralLibraryId: preparedRequest?.preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedRequest.preparedDocumentRag.libraryId : undefined,
|
||||
mistralLibraryId,
|
||||
});
|
||||
return;
|
||||
return {mistralLibraryId};
|
||||
} catch (e) {
|
||||
aiLog("error", "request.execute.failed", {
|
||||
requestId: options.requestId,
|
||||
provider: providerName(options.provider),
|
||||
duration: aiLogDuration(requestStartedAt),
|
||||
error: e instanceof Error ? e : String(e),
|
||||
error: e,
|
||||
});
|
||||
if (mistralLibraryId) {
|
||||
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// noinspection JSUnusedLocalSymbols
|
||||
// @ts-ignore
|
||||
async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise<void> {
|
||||
if (!downloads.some(isTranscribableAudioDownload)) return;
|
||||
if (!options.msg.from?.id) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
try {
|
||||
const provider = (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider;
|
||||
const speech = await synthesizeSpeech({provider, text: trimmed});
|
||||
await sendSynthesizedSpeech(options.msg, speech);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const config = snapshotRuntimeConfig();
|
||||
options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id);
|
||||
options.contextSize ??= await resolveAiContextSizeForUser(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);
|
||||
|
||||
aiLog("info", "run.start", {
|
||||
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||
provider: providerName(options.provider),
|
||||
model: snapshotModel(options.provider, config),
|
||||
message: aiLogMessageIdentity(options.msg),
|
||||
@@ -139,7 +255,6 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
|
||||
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
|
||||
aiLog("warn", "run.rejected.unsupported_attachment", {
|
||||
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||
provider: providerName(options.provider),
|
||||
requestedAttachmentKinds: [...requestedAttachmentKinds],
|
||||
});
|
||||
@@ -157,7 +272,6 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
|
||||
}).catch(logError);
|
||||
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})),
|
||||
});
|
||||
return;
|
||||
@@ -165,17 +279,12 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
|
||||
let aiRequestStatus: StoredAiRequestStatus = "running";
|
||||
let aiRequestError: string | undefined;
|
||||
let responseMessageId: number | undefined;
|
||||
const cancel = createAiCancelRequest({
|
||||
chatId: options.msg.chat.id,
|
||||
fromId: options.msg.from?.id ?? 0,
|
||||
provider: providerName(options.provider),
|
||||
controller
|
||||
});
|
||||
options.requestId ??= cancel.id;
|
||||
const requestId = options.requestId;
|
||||
const streamMessage = new TelegramStreamMessage(
|
||||
options.msg,
|
||||
cancel.id,
|
||||
@@ -185,42 +294,17 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
: buildAiRegenerateCallbackData(options.provider, !!options.think),
|
||||
options.targetMessage,
|
||||
options.provider,
|
||||
options.isGuestMsg,
|
||||
imageOutputMode
|
||||
options.isGuestMsg
|
||||
);
|
||||
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
|
||||
let mistralLibraryId: string | undefined;
|
||||
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
|
||||
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);
|
||||
aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
|
||||
|
||||
try {
|
||||
const queueMessage = await streamMessage.start(Environment.waitThinkText);
|
||||
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);
|
||||
setAiCancelMessageId(cancel.id, queueMessage.message_id);
|
||||
aiLog("info", "run.queue.enter", {
|
||||
requestId,
|
||||
cancelId: cancel.id,
|
||||
queueMessageId: queueMessage.message_id,
|
||||
target: aiLogProviderTarget(queueTarget),
|
||||
@@ -229,16 +313,15 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
await aiProviderRequestQueue.enqueue(queueTarget, {
|
||||
signal: controller.signal,
|
||||
onPositionChange: async requestsBefore => {
|
||||
aiLog("debug", "run.queue.position", {requestId, cancelId: cancel.id, requestsBefore});
|
||||
aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore});
|
||||
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
|
||||
await streamMessage.flush();
|
||||
},
|
||||
run: async (): Promise<null> => {
|
||||
run: async () => {
|
||||
const queueWaitFinishedAt = Date.now();
|
||||
aiLog("info", "run.queue.dequeued", {requestId, cancelId: cancel.id});
|
||||
aiLog("info", "run.queue.dequeued", {cancelId: cancel.id});
|
||||
const downloads = attachmentsToDownloadedFiles(cached.attachments);
|
||||
aiLog("debug", "run.downloads.ready", {
|
||||
requestId,
|
||||
count: downloads.length,
|
||||
downloads: downloads.map(d => ({
|
||||
kind: d.kind,
|
||||
@@ -249,70 +332,41 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
|
||||
aiRequestStatus = "succeeded";
|
||||
const result = await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
|
||||
mistralLibraryId = result.mistralLibraryId;
|
||||
aiLog("success", "run.queue.task.done", {
|
||||
requestId,
|
||||
cancelId: cancel.id,
|
||||
duration: aiLogDuration(queueWaitFinishedAt),
|
||||
mistralLibraryId,
|
||||
});
|
||||
} finally {
|
||||
cleanupDownloads(downloads);
|
||||
aiLog("debug", "run.downloads.cleaned", {requestId, cancelId: cancel.id, count: downloads.length});
|
||||
aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(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)});
|
||||
if (controller.signal.aborted || isAbortError(e)) {
|
||||
aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
|
||||
streamMessage.replaceText(streamMessage.getText());
|
||||
await streamMessage.finish();
|
||||
} else {
|
||||
aiRequestStatus = "failed";
|
||||
aiRequestError = e instanceof Error ? e.message : String(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);
|
||||
aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
|
||||
await streamMessage.fail(e);
|
||||
logError(e);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
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: aiRequestStatus,
|
||||
startedAt: aiRequestStartedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: aiRequestError,
|
||||
}).catch(logError);
|
||||
recordAiRequestFinish(aiRequestStatus);
|
||||
finishAiRequest(requestId);
|
||||
finishAiRequest(cancel.id);
|
||||
if (mistralLibraryId) {
|
||||
aiLog("debug", "run.mistral_library.cleanup", {mistralLibraryId});
|
||||
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
|
||||
}
|
||||
aiLog("success", "run.finished", {
|
||||
requestId,
|
||||
cancelId: cancel.id,
|
||||
provider: providerName(options.provider),
|
||||
duration: aiLogDuration(startedAt),
|
||||
aborted: controller.signal.aborted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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";
|
||||
@@ -1,134 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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};
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
export abstract class Dao<I, GetByIdParams, GetByIdsParams, InsertParams> {
|
||||
export abstract class Dao<I> {
|
||||
abstract getAll(): Promise<I[]>;
|
||||
|
||||
abstract getById(params: GetByIdParams): Promise<I | null>;
|
||||
abstract getById(params: never): Promise<I | null>
|
||||
|
||||
abstract getByIds(params: GetByIdsParams): Promise<I[]>;
|
||||
abstract getByIds(params: never): Promise<I[]>
|
||||
|
||||
abstract insert(items: InsertParams): Promise<true>;
|
||||
}
|
||||
abstract insert(items: never[]): Promise<true>
|
||||
}
|
||||
@@ -72,41 +72,46 @@ export class AiCancel extends CallbackCommand {
|
||||
const cancelledText = buildCancelledGenerationText(baseText, providerName, limit);
|
||||
const replyMarkup = this.regenerateKeyboard(regenerateProvider);
|
||||
const formatted = prepareTelegramMarkdownV2(cancelledText, {mode: "final"});
|
||||
const deletedByBotAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
await enqueueTelegramApiCall(
|
||||
() => isCaption
|
||||
? bot.editMessageCaption({
|
||||
const result = isCaption
|
||||
? await enqueueTelegramApiCall(
|
||||
() => bot.editMessageCaption({
|
||||
chat_id: message.chat.id,
|
||||
message_id: message.message_id,
|
||||
caption: formatted,
|
||||
parse_mode: "MarkdownV2",
|
||||
reply_markup: replyMarkup,
|
||||
})
|
||||
: bot.editMessageText({
|
||||
}),
|
||||
{method: "editMessageCaption", chatId: message.chat.id, chatType: message.chat.type}
|
||||
)
|
||||
: await enqueueTelegramApiCall(
|
||||
() => bot.editMessageText({
|
||||
chat_id: message.chat.id,
|
||||
message_id: message.message_id,
|
||||
text: formatted,
|
||||
parse_mode: "MarkdownV2",
|
||||
reply_markup: replyMarkup,
|
||||
}),
|
||||
{method: isCaption ? "editMessageCaption" : "editMessageText", chatId: message.chat.id, chatType: message.chat.type}
|
||||
);
|
||||
{method: "editMessageText", chatId: message.chat.id, chatType: message.chat.type}
|
||||
);
|
||||
|
||||
await MessageStore.put({
|
||||
chatId: message.chat.id,
|
||||
id: message.message_id,
|
||||
replyToMessageId: stored?.replyToMessageId ?? this.replyToMessageId(message),
|
||||
fromId: message.from?.id ?? stored?.fromId ?? 0,
|
||||
text: cancelledText,
|
||||
quoteText: stored?.quoteText,
|
||||
date: message.date ?? stored?.date ?? deletedByBotAt,
|
||||
deletedByBotAt,
|
||||
attachments: stored?.attachments,
|
||||
});
|
||||
if (result) {
|
||||
await MessageStore.put({...(result as object), text: cancelledText} as Message);
|
||||
} else {
|
||||
await MessageStore.put({
|
||||
chatId: message.chat.id,
|
||||
id: message.message_id,
|
||||
replyToMessageId: stored?.replyToMessageId ?? this.replyToMessageId(message),
|
||||
fromId: message.from?.id ?? stored?.fromId ?? 0,
|
||||
text: cancelledText,
|
||||
date: message.date ?? stored?.date ?? Math.floor(Date.now() / 1000),
|
||||
photoMaxSizeFilePath: stored?.photoMaxSizeFilePath,
|
||||
attachments: stored?.attachments,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ import {UserStore} from "../common/user-store";
|
||||
import {
|
||||
ensureValidUserAiSettings,
|
||||
normalizeAiContextSizeChoice,
|
||||
normalizeAiImageOutputMode,
|
||||
normalizeAiProviderChoice,
|
||||
normalizeAiResponseLanguage,
|
||||
normalizeAiVoiceMode,
|
||||
normalizeInterfaceLanguage,
|
||||
resolveInterfaceLocaleForUser,
|
||||
setUserAiContextSizeChoice,
|
||||
setUserAiImageOutputMode,
|
||||
setUserAiProviderChoice,
|
||||
setUserAiResponseLanguage,
|
||||
setUserAiVoiceMode,
|
||||
@@ -89,15 +87,6 @@ export class UserSettingsCallback extends CallbackCommand {
|
||||
screen = "voiceMode";
|
||||
}
|
||||
|
||||
if (parsed.screen === "imageOutput" && parsed.imageOutputMode) {
|
||||
const mode = normalizeAiImageOutputMode(parsed.imageOutputMode);
|
||||
if (mode) {
|
||||
const result = await setUserAiImageOutputMode(query.from.id, mode);
|
||||
settings = result.settings;
|
||||
}
|
||||
screen = "imageOutput";
|
||||
}
|
||||
|
||||
const locale = await resolveInterfaceLocaleForUser(query.from.id, query.from.language_code);
|
||||
|
||||
await Localization.runWithLocale(locale, () => editMessageText({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user