Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,44 +24,100 @@
|
||||
"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-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^10.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@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=="],
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@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=="],
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
"@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=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
@@ -117,6 +173,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 +229,102 @@
|
||||
|
||||
"@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/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@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/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@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/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
|
||||
"@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/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
|
||||
|
||||
"@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/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"@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/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
|
||||
|
||||
"@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/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "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-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@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/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "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-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
|
||||
|
||||
"@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/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"@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=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||
|
||||
"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=="],
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"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=="],
|
||||
"ajv": ["ajv@6.14.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-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"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=="],
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"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@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||
|
||||
"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,10 +333,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -267,10 +351,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,15 +371,17 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"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=="],
|
||||
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.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", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.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", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
@@ -299,6 +391,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"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=="],
|
||||
@@ -307,39 +401,57 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"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=="],
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"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,11 +459,9 @@
|
||||
|
||||
"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=="],
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"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=="],
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
@@ -363,9 +473,11 @@
|
||||
|
||||
"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-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
@@ -375,6 +487,10 @@
|
||||
|
||||
"jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
@@ -383,7 +499,9 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"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,15 +509,21 @@
|
||||
|
||||
"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@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -407,46 +531,28 @@
|
||||
|
||||
"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=="],
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"pg-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=="],
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
@@ -457,7 +563,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,24 +581,30 @@
|
||||
|
||||
"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=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -495,16 +613,18 @@
|
||||
|
||||
"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-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
|
||||
@@ -515,9 +635,9 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -531,55 +651,167 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@esbuild-kit/esm-loader/get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@libsql/isomorphic-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"@mistralai/mistralai/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@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/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
|
||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@types/pg/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
"@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/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=="],
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
|
||||
+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
-17
@@ -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...",
|
||||
@@ -140,7 +128,6 @@
|
||||
"getImageGenDoneText.default": "👨🎨 Image generated.",
|
||||
"getErrorText.withReason": "{errorText} Reason:\n{reason}",
|
||||
"getUseToolText.python": "👨💻 Running `Python`",
|
||||
"getUseToolText.codeInterpreter": "👨💻 Running `Code Interpreter`",
|
||||
"getUseToolText.default": "🔧 Using tool `{name}`",
|
||||
"getAnalyzingDocumentText.default": "🔍 Analyzing the document...",
|
||||
"getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`",
|
||||
@@ -148,7 +135,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 +169,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 +178,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",
|
||||
|
||||
+5
-18
@@ -8,14 +8,7 @@
|
||||
},
|
||||
"providerChoice.default": "По умолчанию",
|
||||
"errorText": "⚠️ Произошла ошибка.",
|
||||
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
|
||||
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
|
||||
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
|
||||
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
|
||||
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
|
||||
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
|
||||
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
|
||||
"waitThinkText": "⏳ Дайте-ка подумать...",
|
||||
"waitThinkText": "⏳ Думаю...",
|
||||
"analyzingPictureText": "🔍 Анализирую изображение...",
|
||||
"analyzingPicturesText": "🔍 Анализирую изображения...",
|
||||
"reasoningText": "🤔 Рассуждаю...",
|
||||
@@ -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": "🌈 Завершаю генерацию изображения...",
|
||||
@@ -166,7 +154,6 @@
|
||||
"getImageGenDoneText.default": "👨🎨 Изображение сгенерировано.",
|
||||
"getErrorText.withReason": "{errorText} Причина:\n{reason}",
|
||||
"getUseToolText.python": "👨💻 Запускаю `Python`",
|
||||
"getUseToolText.codeInterpreter": "👨💻 Запускаю `Code Interpreter`",
|
||||
"getUseToolText.default": "🔧 Использую инструмент `{name}`",
|
||||
"getAnalyzingDocumentText.default": "🔍 Анализирую документ...",
|
||||
"getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`",
|
||||
@@ -174,7 +161,6 @@
|
||||
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
|
||||
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
|
||||
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
|
||||
"getSelectingToolsText": "🧩 Выбираю подходящие инструменты...",
|
||||
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
|
||||
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
|
||||
"queueNoneText": "нет",
|
||||
@@ -209,9 +195,6 @@
|
||||
"getWhenPluralUnitText": "{unit}",
|
||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||
"commandDescriptions": {
|
||||
"aiRequests": "Показать последние AI-запросы",
|
||||
"aiAudit": "Показать аудит AI-запроса и артефакты",
|
||||
"aiMetrics": "Показать счётчики AI-обсервабилити",
|
||||
"ae": "вычисление",
|
||||
"adminsAdd": "Добавить пользователя в администраторы",
|
||||
"adminsRemove": "Удалить пользователя из администраторов",
|
||||
@@ -221,6 +204,10 @@
|
||||
"debug": "Вернуть msg или reply в JSON",
|
||||
"dice": "Отправить случайный или конкретный дайс",
|
||||
"distort": "Искажение изображения",
|
||||
"geminiChat": "Чат с AI (Gemini)",
|
||||
"geminiGetModel": "Показать текущую модель Gemini",
|
||||
"geminiListModels": "Показать все модели Gemini",
|
||||
"geminiSetModel": "Установить модель Gemini",
|
||||
"help": "Показать список команд",
|
||||
"id": "ID чата, пользователя и ответа",
|
||||
"ignore": "Бот будет игнорировать пользователя",
|
||||
|
||||
+1
-18
@@ -8,14 +8,7 @@
|
||||
},
|
||||
"providerChoice.default": "За замовчуванням",
|
||||
"errorText": "⚠️ Сталася помилка.",
|
||||
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
|
||||
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
|
||||
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
|
||||
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
|
||||
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
|
||||
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
|
||||
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
|
||||
"waitThinkText": "⏳ Дайте-но подумати...",
|
||||
"waitThinkText": "⏳ Думаю...",
|
||||
"analyzingPictureText": "🔍 Аналізую зображення...",
|
||||
"analyzingPicturesText": "🔍 Аналізую зображення...",
|
||||
"reasoningText": "🤔 Міркую...",
|
||||
@@ -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": "🌈 Завершую генерацію зображення...",
|
||||
@@ -165,7 +153,6 @@
|
||||
"getImageGenDoneText.default": "👨🎨 Зображення згенеровано.",
|
||||
"getErrorText.withReason": "{errorText} Причина:\n{reason}",
|
||||
"getUseToolText.python": "👨💻 Запускаю `Python`",
|
||||
"getUseToolText.codeInterpreter": "👨💻 Запускаю `Code Interpreter`",
|
||||
"getUseToolText.default": "🔧 Використовую інструмент `{name}`",
|
||||
"getAnalyzingDocumentText.default": "🔍 Аналізую документ...",
|
||||
"getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`",
|
||||
@@ -173,7 +160,6 @@
|
||||
"getPreparingRAGText.default": "🔍 Готую RAG для документа...",
|
||||
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
|
||||
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
|
||||
"getSelectingToolsText": "🧩 Вибираю підхожі інструменти...",
|
||||
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
|
||||
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
|
||||
"queueNoneText": "немає",
|
||||
@@ -208,9 +194,6 @@
|
||||
"getWhenPluralUnitText": "{unit}",
|
||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||
"commandDescriptions": {
|
||||
"aiRequests": "Показати останні AI-запити",
|
||||
"aiAudit": "Показати аудит AI-запиту та артефакти",
|
||||
"aiMetrics": "Показати лічильники AI-спостережуваності",
|
||||
"help": "Показати список команд",
|
||||
"settings": "Налаштування користувача",
|
||||
"start": "Запустити бота",
|
||||
|
||||
Generated
+2265
-769
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -2,44 +2,44 @@
|
||||
"name": "tg-chat-bot",
|
||||
"main": "src/index.ts",
|
||||
"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",
|
||||
"start": "node dist/index.js",
|
||||
"bun:start": "bun run src/index.ts"
|
||||
"bun:start": "bun run dist/index.js"
|
||||
},
|
||||
"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-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^10.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4"
|
||||
"typescript-eslint": "^8.59.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../logging/ai-logger";
|
||||
+47
-37
@@ -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,8 +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"],
|
||||
outputImages: ["OUTPUT_IMAGES", "IMAGE"],
|
||||
@@ -69,18 +71,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 +89,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 +117,17 @@ export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRunti
|
||||
default:
|
||||
return Environment.OLLAMA_CHAT_MODEL;
|
||||
}
|
||||
case AiProvider.GEMINI:
|
||||
switch (purpose) {
|
||||
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 +161,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 +178,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,
|
||||
@@ -197,7 +207,7 @@ export function createMistralClient(target: AiRuntimeTarget): Mistral {
|
||||
|
||||
export function createOllamaClient(target: AiRuntimeTarget): Ollama {
|
||||
return new Ollama({
|
||||
host: target.baseUrl,
|
||||
host: target.baseUrl?.endsWith(":11434") ? target.baseUrl : target.baseUrl + ":11434",
|
||||
headers: target.apiKey ? {"Authorization": `Bearer ${target.apiKey}`} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,27 @@ export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
export type AiChatMessage = OpenAIChatMessage | OllamaChatMessage | MistralChatMessage;
|
||||
|
||||
/*
|
||||
const messages: any[] = ordered.map(part => {
|
||||
const content: any[] = [{
|
||||
type: "input_text",
|
||||
text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER \"${part.name}\":\n` : "") + part.content,
|
||||
}];
|
||||
|
||||
if (!part.bot) {
|
||||
for (const image of part.images ?? []) {
|
||||
content.push({type: "input_image", image_url: `data:image/jpeg;base64,${image}`, detail: "auto"});
|
||||
}
|
||||
}
|
||||
|
||||
return {role: part.bot ? "assistant" : "user", content};
|
||||
});
|
||||
|
||||
if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) {
|
||||
messages.unshift({role: "system", content: Environment.SYSTEM_PROMPT});
|
||||
}
|
||||
return {parts: messages, imageCount};
|
||||
*/
|
||||
|
||||
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
|
||||
|
||||
@@ -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: { [k: string]: any } | string;
|
||||
};
|
||||
|
||||
export type MistralToolCall = {
|
||||
@@ -110,4 +110,3 @@ export type MistralChatMessage =
|
||||
| MistralSystemMessage
|
||||
| MistralToolMessage
|
||||
| MistralUserMessage
|
||||
import {AiJsonObject} from "./tool-types";
|
||||
|
||||
@@ -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,3 @@
|
||||
import type {
|
||||
ResponseInputMessageContentList,
|
||||
ResponseOutputMessage,
|
||||
} from "openai/resources/responses/responses";
|
||||
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||
import {ResponseInputItem} from "openai/resources/responses/responses";
|
||||
|
||||
type OpenAIInputChatMessage = {
|
||||
type: "message";
|
||||
role: "system" | "user";
|
||||
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;
|
||||
export type OpenAIChatMessage = ResponseInputItem
|
||||
@@ -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;
|
||||
@@ -111,6 +119,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 +161,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, "outputImages");
|
||||
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
|
||||
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
|
||||
|
||||
return buildCapabilities({
|
||||
chat: capability(true, target, runtimeTarget),
|
||||
vision: capability(chatLike, target, runtimeTarget),
|
||||
ocr: capability(chatLike, target, 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 +214,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 +222,7 @@ export async function getModelCapabilities(
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -196,14 +233,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;
|
||||
|
||||
@@ -263,37 +295,41 @@ export async function formatRuntimeModelInfo(
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
type NamedModel = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type ModelListResponse = {
|
||||
models?: NamedModel[];
|
||||
data?: NamedModel[];
|
||||
};
|
||||
|
||||
export async function listProviderModels(provider: AiProvider): Promise<string[]> {
|
||||
const target = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
|
||||
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA: {
|
||||
const ollama = createOllamaClient(target);
|
||||
const result = await ollama.list() as ModelListResponse;
|
||||
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
|
||||
const result: any = await ollama.list();
|
||||
return (result.models ?? []).map((m: any) => m.model || m.name).filter(Boolean);
|
||||
}
|
||||
case AiProvider.GEMINI: {
|
||||
const models: string[] = [];
|
||||
if (getGeminiApiMode(target) === "openai") {
|
||||
const geminiAi = createGeminiOpenAiClient(target);
|
||||
const iterable: any = await geminiAi.models.list();
|
||||
for await (const model of iterable) models.push(model.name || model.id || String(model));
|
||||
return models;
|
||||
}
|
||||
|
||||
const geminiAi = createGoogleGenAiClient(target);
|
||||
const iterable: any = await geminiAi.models.list();
|
||||
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[];
|
||||
const items = Array.isArray(result) ? result : result.data ?? result.models ?? [];
|
||||
return items.map(m => m.id || m.name || String(m)).filter((name): name is string => !!name);
|
||||
const result: any = await mistralAi.models.list();
|
||||
return (result.data ?? result.models ?? result ?? []).map((m: any) => m.id || m.name || String(m)).filter(Boolean);
|
||||
}
|
||||
case AiProvider.OPENAI: {
|
||||
const openAi = createOpenAiClient(target);
|
||||
const result = await openAi.models.list() as ModelListResponse;
|
||||
return (result.data ?? []).map(m => m.id).filter((id): id is string => !!id);
|
||||
const result: any = await openAi.models.list();
|
||||
return (result.data ?? []).map((m: any) => m.id).filter(Boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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");
|
||||
|
||||
export type AiRequestQueueTarget = {
|
||||
provider: AiProvider;
|
||||
@@ -11,41 +7,40 @@ export type AiRequestQueueTarget = {
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
type QueueEntry = {
|
||||
type QueueEntry<T> = {
|
||||
target: AiRequestQueueTarget;
|
||||
queueKey: string;
|
||||
run: () => Promise<BoundaryValue>;
|
||||
resolve: (value: BoundaryValue) => void;
|
||||
reject: (reason?: Error | string | BoundaryValue | null | undefined) => void;
|
||||
run: () => Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => 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>;
|
||||
};
|
||||
|
||||
class AiProviderRequestQueue {
|
||||
private readonly waiting = new Map<string, QueueEntry[]>();
|
||||
private readonly waiting = new Map<string, Array<QueueEntry<any>>>();
|
||||
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"));
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const queueKey = this.queueKey(target);
|
||||
const entry: QueueEntry = {
|
||||
const entry: QueueEntry<T> = {
|
||||
target,
|
||||
queueKey,
|
||||
run: options.run,
|
||||
resolve: value => resolve(value as T),
|
||||
resolve,
|
||||
reject,
|
||||
onPositionChange: options.onPositionChange,
|
||||
signal: options.signal,
|
||||
@@ -58,23 +53,21 @@ class AiProviderRequestQueue {
|
||||
const removed = this.removeWaitingEntry(entry);
|
||||
if (!removed) return;
|
||||
|
||||
logger.debug("entry.cancelled", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
|
||||
reject(new Error("Aborted"));
|
||||
this.schedule(target);
|
||||
};
|
||||
|
||||
options.signal?.addEventListener("abort", entry.abortHandler, {once: true});
|
||||
this.getOrCreateQueue(queueKey).push(entry);
|
||||
logger.debug("enqueue.accepted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: this.getOrCreateQueue(queueKey).length, active: this.activeCount(queueKey)});
|
||||
this.schedule(target);
|
||||
});
|
||||
}
|
||||
|
||||
private getQueue(queueKey: string): QueueEntry[] | undefined {
|
||||
private getQueue(queueKey: string): Array<QueueEntry<any>> | undefined {
|
||||
return this.waiting.get(queueKey);
|
||||
}
|
||||
|
||||
private getOrCreateQueue(queueKey: string): QueueEntry[] {
|
||||
private getOrCreateQueue(queueKey: string): Array<QueueEntry<any>> {
|
||||
let queue = this.waiting.get(queueKey);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
@@ -111,7 +104,7 @@ class AiProviderRequestQueue {
|
||||
]);
|
||||
}
|
||||
|
||||
private removeWaitingEntry(entry: QueueEntry): boolean {
|
||||
private removeWaitingEntry(entry: QueueEntry<any>): boolean {
|
||||
const queue = this.getQueue(entry.queueKey);
|
||||
if (!queue) return false;
|
||||
|
||||
@@ -139,14 +132,12 @@ class AiProviderRequestQueue {
|
||||
}
|
||||
|
||||
if (entry.signal?.aborted) {
|
||||
logger.debug("entry.skipped.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
|
||||
entry.reject(new Error("Aborted"));
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.started = true;
|
||||
this.setActiveCount(queueKey, this.activeCount(queueKey) + 1);
|
||||
logger.debug("entry.started", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: queue.length, active: this.activeCount(queueKey)});
|
||||
void this.runEntry(entry);
|
||||
}
|
||||
|
||||
@@ -156,14 +147,11 @@ class AiProviderRequestQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private async runEntry(entry: QueueEntry): Promise<void> {
|
||||
private async runEntry(entry: QueueEntry<any>): Promise<void> {
|
||||
try {
|
||||
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);
|
||||
entry.reject(e);
|
||||
} finally {
|
||||
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
|
||||
this.schedule(entry.target);
|
||||
@@ -180,13 +168,13 @@ 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)});
|
||||
console.error(result.reason);
|
||||
}
|
||||
}
|
||||
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error: error instanceof Error ? error : String(error)}));
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
|
||||
private deleteQueueIfIdle(queueKey: string, queue: Array<QueueEntry<any>>): void {
|
||||
if (!queue.length && this.activeCount(queueKey) <= 0) {
|
||||
this.waiting.delete(queueKey);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
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,18 @@ async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSig
|
||||
fileName: audio.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
function collectGeminiText(response: any): string {
|
||||
if (typeof response?.text === "string") return response.text;
|
||||
|
||||
const candidates = response?.candidates ?? [];
|
||||
const candidateText = candidates
|
||||
.flatMap((candidate: any) => candidate?.content?.parts ?? [])
|
||||
.map((part: any) => part?.text ?? "")
|
||||
.join("");
|
||||
if (candidateText.trim()) return candidateText;
|
||||
|
||||
return (response?.candidates ?? [])
|
||||
.map((output: any) => typeof output === "string" ? output : output?.content?.parts?.[0]?.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
+19
-239
@@ -8,9 +8,6 @@ import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment
|
||||
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,38 +16,10 @@ 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();
|
||||
const ffmpegSemaphore = new AsyncSemaphore(2);
|
||||
const logger = appLogger.child("attachments");
|
||||
|
||||
function safeFileName(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 180);
|
||||
@@ -120,165 +89,32 @@ 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> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("download.start", {kind, fileId, fileName, mimeType});
|
||||
async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> {
|
||||
const file = await bot.getFile({file_id: fileId});
|
||||
const finalFileName = fileNameWithExtension(fileName, mimeType, file.file_path);
|
||||
const location = cachePathFor(kind, fileUniqueId, fileId, finalFileName);
|
||||
|
||||
await cachePathLocks.runExclusive(location, async () => {
|
||||
if (fs.existsSync(location)) {
|
||||
logger.trace("download.cache_hit", {kind, location});
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(location)) return;
|
||||
|
||||
const buffer = await downloadTelegramFile(file.file_path);
|
||||
if (!buffer) {
|
||||
logger.warn("download.empty", {kind, fileId, telegramFilePath: file.file_path});
|
||||
return;
|
||||
}
|
||||
if (!buffer) return;
|
||||
|
||||
const tempLocation = `${location}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.mkdirSync(path.dirname(location), {recursive: true});
|
||||
fs.writeFileSync(tempLocation, buffer);
|
||||
fs.renameSync(tempLocation, location);
|
||||
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> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("audio.convert.start", {input, output, noVideo});
|
||||
await cachePathLocks.runExclusive(output, async () => {
|
||||
if (fs.existsSync(output)) {
|
||||
logger.trace("audio.convert.cache_hit", {output});
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(output)) return;
|
||||
|
||||
await ffmpegSemaphore.runExclusive(async () => {
|
||||
if (fs.existsSync(output)) {
|
||||
logger.trace("audio.convert.cache_hit", {output});
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(output)) return;
|
||||
|
||||
const tempOutput = `${output}.${process.pid}.${Date.now()}.tmp.wav`;
|
||||
try {
|
||||
@@ -289,38 +125,29 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
|
||||
.toFormat("wav")
|
||||
.save(tempOutput)
|
||||
.on("progress", (progress) => {
|
||||
logger.trace("audio.convert.progress", {input, output, progress});
|
||||
console.log("progress", progress);
|
||||
});
|
||||
});
|
||||
fs.renameSync(tempOutput, output);
|
||||
logger.debug("audio.convert.done", {input, output, duration: logger.duration(startedAt)});
|
||||
} catch (e) {
|
||||
if (fs.existsSync(tempOutput)) {
|
||||
fs.rmSync(tempOutput, {force: true});
|
||||
}
|
||||
logger.error("audio.convert.failed", {input, output, error: e instanceof Error ? e : String(e)});
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cacheMessageAttachmentsWithRejections(msg: Message): Promise<MessageAttachmentCacheResult> {
|
||||
const startedAt = Date.now();
|
||||
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
|
||||
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);
|
||||
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) {
|
||||
const doc = msg.document;
|
||||
@@ -329,19 +156,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);
|
||||
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 +169,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 +178,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);
|
||||
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,65 +191,34 @@ 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;
|
||||
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 [{
|
||||
.map(attachment => ({
|
||||
kind: attachment.kind,
|
||||
fileId: attachment.fileId,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
buffer: fs.readFileSync(attachment.cachePath),
|
||||
path: attachment.cachePath,
|
||||
sizeBytes,
|
||||
sha256: attachment.sha256,
|
||||
}];
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function cleanupDownloads(files: AiDownloadedFile[]): void {
|
||||
logger.trace("downloaded_files.cleanup", {files: files.length});
|
||||
// Files stay on disk in the message cache; drop in-memory buffers eagerly.
|
||||
for (const file of files) {
|
||||
file.buffer = Buffer.alloc(0);
|
||||
|
||||
@@ -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: any) {
|
||||
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,27 +313,22 @@ 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(
|
||||
this.waitMessage = await enqueueTelegramApiCall(
|
||||
() => bot.sendPhoto({
|
||||
chat_id: this.sourceMessage.chat.id,
|
||||
photo: image,
|
||||
@@ -405,29 +342,17 @@ export class TelegramStreamMessage {
|
||||
chatType: this.sourceMessage.chat.type,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (upload) this.destroyUpload(upload);
|
||||
}
|
||||
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",
|
||||
}
|
||||
: {
|
||||
media: {
|
||||
type: "photo",
|
||||
media: image,
|
||||
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
|
||||
@@ -444,50 +369,18 @@ export class TelegramStreamMessage {
|
||||
if (result && result !== true) this.waitMessage = result;
|
||||
this.mediaMode = true;
|
||||
this.lastSent = next;
|
||||
await this.storeMediaMessage(this.waitMessage, attachment);
|
||||
} catch (e) {
|
||||
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));
|
||||
} catch (e: any) {
|
||||
if (!String(e?.message ?? e).includes("message is not modified")) logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.includes("message is not modified")) logError(e instanceof Error ? e : message);
|
||||
} finally {
|
||||
if (upload) this.destroyUpload(upload);
|
||||
}
|
||||
}
|
||||
|
||||
private async storeMediaMessage(sent: Message | null, attachment?: StoredAttachment): Promise<void> {
|
||||
if (!sent || !attachment) return;
|
||||
|
||||
const stored: StoredMessage = {
|
||||
chatId: sent.chat.id,
|
||||
id: sent.message_id,
|
||||
replyToMessageId: sent.reply_to_message?.message_id ?? this.sourceMessage.message_id,
|
||||
fromId: sent.from?.id ?? 0,
|
||||
text: sent.caption ?? this.visibleText(),
|
||||
date: sent.date ?? Math.floor(Date.now() / 1000),
|
||||
attachments: [attachment],
|
||||
};
|
||||
|
||||
await MessageStore.put(stored);
|
||||
}
|
||||
|
||||
private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise<Message | null> {
|
||||
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 +423,7 @@ export class TelegramStreamMessage {
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
sent = await this.sendArtifactAsDocument(file, caption);
|
||||
}
|
||||
} else {
|
||||
@@ -538,37 +431,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 +453,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 +486,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 +501,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 +522,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 +535,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+143
-29
@@ -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: any = await mistralAi.audio.speech.complete(request);
|
||||
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: any = 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({
|
||||
@@ -241,13 +374,13 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
|
||||
reply_parameters: {message_id: sourceMessage.message_id},
|
||||
});
|
||||
} finally {
|
||||
// destroyUpload(upload);
|
||||
destroyUpload(upload);
|
||||
}
|
||||
},
|
||||
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
logError(e);
|
||||
sent = await sendSpeechDocument(sourceMessage, speech, caption);
|
||||
}
|
||||
} else {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+35
-75
@@ -1,56 +1,22 @@
|
||||
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";
|
||||
|
||||
export type AiProviderName = "ollama" | "openai" | "mistral";
|
||||
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
|
||||
|
||||
export function getOllamaTools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator);
|
||||
export function getOllamaTools(): AiTool[] {
|
||||
return getTools();
|
||||
}
|
||||
|
||||
const openAiForbiddenTools = [
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
PYTHON_INTERPRETER_TOOL_NAME
|
||||
];
|
||||
|
||||
function allowedOpenAiTool(tool: AiTool): boolean {
|
||||
return !openAiForbiddenTools.includes(tool.function.name);
|
||||
}
|
||||
|
||||
export function getOpenAITools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
|
||||
export function getOpenAITools(): AiTool[] {
|
||||
return getTools().map(tool => ({
|
||||
type: "function",
|
||||
function: tool.function,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getOpenAICompatibleTools(forCreator?: boolean): AiTool[] {
|
||||
// The compatible chat.completions backend only accepts plain function tools.
|
||||
return getOpenAITools(forCreator);
|
||||
}
|
||||
|
||||
export type OpenAiResponseTool = {
|
||||
type: "function";
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: object;
|
||||
strict: false;
|
||||
};
|
||||
|
||||
export type OpenAiCodeInterpreterTool = {
|
||||
type: "code_interpreter";
|
||||
container: {
|
||||
type: "auto";
|
||||
file_ids?: string[];
|
||||
memory_limit?: "1g" | "4g" | "16g" | "64g" | null;
|
||||
} | string;
|
||||
};
|
||||
|
||||
export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] {
|
||||
return getOpenAITools(forCreator).map(tool => ({
|
||||
export function getOpenAIResponsesTools(): any[] {
|
||||
return getTools().map(tool => ({
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
@@ -59,46 +25,40 @@ export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseToo
|
||||
}));
|
||||
}
|
||||
|
||||
export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool {
|
||||
return {
|
||||
type: "code_interpreter",
|
||||
container: {
|
||||
type: "auto",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMistralTools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator).map(tool => ({
|
||||
export function getMistralTools(): AiTool[] {
|
||||
return getTools().map(tool => ({
|
||||
type: "function",
|
||||
function: tool.function,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] {
|
||||
export type GeminiTool = {
|
||||
functionDeclarations: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
parametersJsonSchema?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function getGeminiTools(): GeminiTool[] {
|
||||
const functionDeclarations = getTools().map(tool => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parametersJsonSchema: tool.function.parameters,
|
||||
}));
|
||||
|
||||
return functionDeclarations.length ? [{functionDeclarations}] : [];
|
||||
}
|
||||
|
||||
export function getProviderTools(provider: AiProvider): AiTool[] {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return getOllamaTools(forCreator);
|
||||
return getOllamaTools();
|
||||
case AiProvider.GEMINI:
|
||||
return getTools();
|
||||
case AiProvider.MISTRAL:
|
||||
return getMistralTools(forCreator);
|
||||
return getMistralTools();
|
||||
case AiProvider.OPENAI:
|
||||
return getOpenAITools(forCreator);
|
||||
return getOpenAITools();
|
||||
}
|
||||
}
|
||||
|
||||
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))];
|
||||
}
|
||||
+32
-18
@@ -1,23 +1,35 @@
|
||||
|
||||
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[];
|
||||
/*
|
||||
interface Tool {
|
||||
type: string;
|
||||
function: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: AiJsonValue;
|
||||
additionalProperties?: boolean | AiToolParameters;
|
||||
type?: string;
|
||||
parameters?: {
|
||||
type?: string;
|
||||
$defs?: any;
|
||||
items?: any;
|
||||
required?: string[];
|
||||
properties?: {
|
||||
[key: string]: {
|
||||
type?: string | string[];
|
||||
items?: any;
|
||||
description?: string;
|
||||
enum?: any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
*/
|
||||
|
||||
export type AiToolParameters = {
|
||||
type: "object";
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AiTool = {
|
||||
type: "function";
|
||||
@@ -32,7 +44,9 @@ export type AiTool = {
|
||||
export type AiToolCall = {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: AiJsonObject;
|
||||
arguments: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
|
||||
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,19 +80,17 @@ 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";
|
||||
|
||||
export const webSearchTool = {
|
||||
export const braveSearchTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: WEB_SEARCH_TOOL_NAME,
|
||||
name: "web_search",
|
||||
description:
|
||||
"Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.",
|
||||
parameters: {
|
||||
@@ -163,7 +158,7 @@ export const webSearchTool = {
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const webSearchToolPrompt = [
|
||||
export const braveSearchToolPrompt = [
|
||||
"Brave Search tool rules:",
|
||||
"- You have access to `web_search`.",
|
||||
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
|
||||
@@ -197,7 +192,7 @@ export const webSearchToolPrompt = [
|
||||
].join("\n");
|
||||
|
||||
function asIntegerInRange(
|
||||
value: AiJsonValue | undefined,
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -216,7 +211,7 @@ function asIntegerInRange(
|
||||
}
|
||||
|
||||
function asEnum<T extends string>(
|
||||
value: AiJsonValue | undefined,
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
@@ -229,7 +224,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 +238,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,9 +263,8 @@ function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
|
||||
return parts.length ? [...new Set(parts)].join(",") : "web";
|
||||
}
|
||||
|
||||
export async function webSearch(args?: AiJsonObject) {
|
||||
const startedAt = Date.now();
|
||||
logger.info("start", {args});
|
||||
export async function webSearch(args?: Record<string, unknown>) {
|
||||
console.log("braveSearch()");
|
||||
|
||||
try {
|
||||
const query = asNonEmptyString(args?.query);
|
||||
@@ -362,20 +356,20 @@ 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: any) {
|
||||
logError(e);
|
||||
|
||||
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
||||
const data = axios.isAxiosError(error) ? error.response?.data : undefined;
|
||||
const status = e?.response?.status;
|
||||
const data = e?.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 {
|
||||
logger.debug("done", {duration: logger.duration(startedAt)});
|
||||
console.log("END: braveSearch()");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
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 fs from "node:fs";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("create-note");
|
||||
|
||||
export type CreateNoteResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const createNoteTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_note",
|
||||
description: "Create a new Markdown note with a valid file name, optional title, and Markdown-formatted content.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description: "The valid file name for the note. It must be suitable for use as a file name and must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. Use a clear, concise name based on the note topic. Include the .md extension if the user provides it or if Markdown files are expected."
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "The title of the note. Use a concise, human-readable title based on the user's request or the note content."
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The full content of the note formatted as valid Markdown. Preserve existing Markdown formatting when provided. If the source content has little or no formatting, add appropriate Markdown structure such as headings, paragraphs, lists, links, code blocks, tables, or emphasis where useful, without changing the meaning."
|
||||
}
|
||||
},
|
||||
required: ["fileName", "content"],
|
||||
}
|
||||
}
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function createNote(
|
||||
args?: AiJsonObject
|
||||
): Promise<CreateNoteResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
const title = asNonEmptyString(args?.title) ?? fileName;
|
||||
|
||||
const content = asNonEmptyString(args?.content) ?? "";
|
||||
if (!content.trim().length) {
|
||||
return {success: false, error: "No content provided"};
|
||||
}
|
||||
|
||||
const newFilePath = path.join(notesDir, fileName.endsWith(".md") ? fileName : fileName + ".md");
|
||||
const linkMarkdown = `* [${title}](${path.relative(path.dirname(notesRootFile), newFilePath)})`;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(newFilePath)) {
|
||||
return {success: false, error: "File already exists"};
|
||||
}
|
||||
|
||||
await writeFile(newFilePath, content, "utf-8");
|
||||
|
||||
let rootContent: string;
|
||||
try {
|
||||
rootContent = await readFile(notesRootFile, "utf-8");
|
||||
} catch (e) {
|
||||
rootContent = "";
|
||||
}
|
||||
|
||||
const notesHeaderIndex = rootContent.indexOf(NOTES_HEADER);
|
||||
if (notesHeaderIndex >= 0) {
|
||||
rootContent += "\n" + linkMarkdown;
|
||||
} else {
|
||||
rootContent = NOTES_HEADER + "\n" + linkMarkdown;
|
||||
}
|
||||
|
||||
await writeFile(notesRootFile, rootContent, "utf-8");
|
||||
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)});
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits";
|
||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
||||
|
||||
export const readFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file path inside the root directory, for example notes/task.txt.",
|
||||
},
|
||||
maxBytes: {
|
||||
type: "number",
|
||||
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const listDirectoryTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_directory",
|
||||
description:
|
||||
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative directory path inside the root directory. Use . for root.",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const createFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_file",
|
||||
description:
|
||||
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file path inside the root directory.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "File content.",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: "Whether to overwrite the file if it already exists. Default is false.",
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "Whether to create parent directories automatically. Default is true.",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const createDirectoryTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_directory",
|
||||
description:
|
||||
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative directory path inside the root directory.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
description: "Whether to create parent directories automatically. Default is true.",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const copyPathTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "copy_path",
|
||||
description:
|
||||
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourcePath: {
|
||||
type: "string",
|
||||
description: "Relative source file or directory path inside the root directory.",
|
||||
},
|
||||
targetPath: {
|
||||
type: "string",
|
||||
description: "Relative target file or directory path inside the root directory.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
description: "Required for copying directories. Default is false.",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "Whether to create target parent directories automatically. Default is true.",
|
||||
},
|
||||
},
|
||||
required: ["sourcePath", "targetPath"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const updateFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_file",
|
||||
description:
|
||||
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file path inside the root directory.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Content to write.",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["replace", "append", "prepend"],
|
||||
description: "Update mode. Default is replace.",
|
||||
},
|
||||
createIfMissing: {
|
||||
type: "boolean",
|
||||
description: "Whether to create the file if it does not exist. Default is false.",
|
||||
},
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const renamePathTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "rename_path",
|
||||
description:
|
||||
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourcePath: {
|
||||
type: "string",
|
||||
description: "Relative source path inside the root directory.",
|
||||
},
|
||||
targetPath: {
|
||||
type: "string",
|
||||
description: "Relative target path inside the root directory.",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "Whether to create target parent directories automatically. Default is false.",
|
||||
},
|
||||
},
|
||||
required: ["sourcePath", "targetPath"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const deletePathTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_path",
|
||||
description:
|
||||
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file or directory path inside the root directory.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
description: "Whether to delete non-empty directories recursively. Default is false.",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const fileToolsToolPrompt = [
|
||||
"Filesystem tool rules:",
|
||||
"- You have access to filesystem tools working only inside the hardcoded root directory.",
|
||||
"- All filesystem paths must be relative to the root directory.",
|
||||
"- You may go into child directories.",
|
||||
"- You must never go up to parent directories.",
|
||||
"- Do not use ../ paths.",
|
||||
"- Do not use absolute paths.",
|
||||
"- Do not try to access symlinks.",
|
||||
"- Use read_file for reading files.",
|
||||
"- Use list_directory for reading directories.",
|
||||
"- Use create_file for creating files.",
|
||||
"- Use create_directory for creating directories.",
|
||||
"- Use update_file for replacing, appending or prepending file content.",
|
||||
"- Use rename_path for renaming or moving files/directories inside the root.",
|
||||
"- Use delete_path for deleting files/directories inside the root.",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
const requireFileToolsRootDir = () => <string>Environment.FILE_TOOLS_ROOT_DIR;
|
||||
|
||||
async function ensureFileToolsRootExists(): Promise<void> {
|
||||
await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true});
|
||||
|
||||
const stat = await fs.promises.stat(requireFileToolsRootDir());
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSafeToolPath(inputPath: unknown, fallback = "."): {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
} {
|
||||
const rawPath = asNonEmptyString(inputPath) ?? fallback;
|
||||
|
||||
if (rawPath.includes("\0")) {
|
||||
throw new Error("Path must not contain null bytes.");
|
||||
}
|
||||
|
||||
if (
|
||||
path.isAbsolute(rawPath) ||
|
||||
path.win32.isAbsolute(rawPath) ||
|
||||
path.posix.isAbsolute(rawPath)
|
||||
) {
|
||||
throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory.");
|
||||
}
|
||||
|
||||
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
|
||||
|
||||
const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath);
|
||||
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
|
||||
|
||||
if (
|
||||
relativePath.startsWith("..") ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error("Path escapes the root directory. Going up is not allowed.");
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: relativePath || ".",
|
||||
};
|
||||
}
|
||||
|
||||
function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void {
|
||||
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
|
||||
|
||||
if (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
) {
|
||||
throw new Error("Cannot copy a directory into itself.");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoSymlinkInPath(
|
||||
absolutePath: string,
|
||||
options?: {
|
||||
allowMissingTail?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
await ensureFileToolsRootExists();
|
||||
|
||||
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
|
||||
|
||||
if (!relativePath || relativePath === ".") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = relativePath.split(path.sep).filter(Boolean);
|
||||
|
||||
let currentPath = requireFileToolsRootDir();
|
||||
|
||||
for (const part of parts) {
|
||||
currentPath = path.join(currentPath, part);
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(currentPath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlinks are not allowed in file tool paths.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT" && options?.allowMissingTail) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(absolutePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.lstat(absolutePath);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotRoot(relativePath: string): void {
|
||||
if (relativePath === ".") {
|
||||
throw new Error("Operation on the root directory itself is not allowed.");
|
||||
}
|
||||
}
|
||||
|
||||
function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" {
|
||||
if (stat.isSymbolicLink()) return "symlink";
|
||||
if (stat.isFile()) return "file";
|
||||
if (stat.isDirectory()) return "directory";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export async function readFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Path is not a file: ${relativePath}`);
|
||||
}
|
||||
|
||||
const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
|
||||
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`);
|
||||
}
|
||||
|
||||
const buffer = await fs.promises.readFile(absolutePath);
|
||||
|
||||
if (buffer.includes(0)) {
|
||||
throw new Error("Binary files are not supported.");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
sizeBytes: stat.size,
|
||||
content: buffer.toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDirectory(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, ".");
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${relativePath}`);
|
||||
}
|
||||
|
||||
const dirEntries = await fs.promises.readdir(absolutePath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
|
||||
const entries = await Promise.all(limitedEntries.map(async entry => {
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryRelativePath = relativePath === "."
|
||||
? entry.name
|
||||
: path.join(relativePath, entry.name);
|
||||
|
||||
const entryStat = await fs.promises.lstat(entryAbsolutePath);
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entryRelativePath,
|
||||
type: getEntryType(entryStat),
|
||||
sizeBytes: entryStat.isFile() ? entryStat.size : null,
|
||||
modifiedAt: entryStat.mtime.toISOString(),
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
entries,
|
||||
totalEntries: dirEntries.length,
|
||||
returnedEntries: entries.length,
|
||||
truncated: dirEntries.length > entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
assertNotRoot(relativePath);
|
||||
|
||||
const content = asString(args?.content, "");
|
||||
const overwrite = asBoolean(args?.overwrite, false);
|
||||
const createParents = asBoolean(args?.createParents, true);
|
||||
|
||||
const contentSizeBytes = Buffer.byteLength(content, "utf8");
|
||||
|
||||
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
|
||||
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(absolutePath);
|
||||
|
||||
if (createParents) {
|
||||
await assertNoSymlinkInPath(parentPath, {allowMissingTail: true});
|
||||
await fs.promises.mkdir(parentPath, {recursive: true});
|
||||
} else {
|
||||
await assertNoSymlinkInPath(parentPath);
|
||||
}
|
||||
|
||||
if (await pathExists(absolutePath)) {
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`Path is a directory, not a file: ${relativePath}`);
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
throw new Error(`File already exists: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(absolutePath, content, {
|
||||
encoding: "utf8",
|
||||
flag: overwrite ? "w" : "wx",
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
sizeBytes: contentSizeBytes,
|
||||
overwritten: overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
type CopyPathStats = {
|
||||
entries: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
async function copyPathRecursive(params: {
|
||||
sourceAbsolutePath: string;
|
||||
targetAbsolutePath: string;
|
||||
overwrite: boolean;
|
||||
stats: CopyPathStats;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
sourceAbsolutePath,
|
||||
targetAbsolutePath,
|
||||
overwrite,
|
||||
stats,
|
||||
} = params;
|
||||
|
||||
if (stats.entries >= MAX_COPY_ENTRIES) {
|
||||
throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`);
|
||||
}
|
||||
|
||||
stats.entries++;
|
||||
|
||||
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Symlinks are not allowed in copied paths.");
|
||||
}
|
||||
|
||||
if (sourceStat.isFile()) {
|
||||
stats.totalBytes += sourceStat.size;
|
||||
|
||||
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
|
||||
throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
if (await pathExists(targetAbsolutePath)) {
|
||||
const targetStat = await fs.promises.lstat(targetAbsolutePath);
|
||||
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (targetStat.isDirectory()) {
|
||||
throw new Error("Cannot overwrite a directory with a file.");
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.copyFile(
|
||||
sourceAbsolutePath,
|
||||
targetAbsolutePath,
|
||||
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory()) {
|
||||
if (await pathExists(targetAbsolutePath)) {
|
||||
const targetStat = await fs.promises.lstat(targetAbsolutePath);
|
||||
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (!targetStat.isDirectory()) {
|
||||
throw new Error("Cannot overwrite a file with a directory.");
|
||||
}
|
||||
} else {
|
||||
await fs.promises.mkdir(targetAbsolutePath);
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(sourceAbsolutePath);
|
||||
|
||||
for (const entry of entries) {
|
||||
const childSourcePath = path.join(sourceAbsolutePath, entry);
|
||||
const childTargetPath = path.join(targetAbsolutePath, entry);
|
||||
|
||||
await copyPathRecursive({
|
||||
sourceAbsolutePath: childSourcePath,
|
||||
targetAbsolutePath: childTargetPath,
|
||||
overwrite,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Only files and directories can be copied.");
|
||||
}
|
||||
|
||||
export async function copyPath(args?: Record<string, unknown>) {
|
||||
const source = resolveSafeToolPath(args?.sourcePath);
|
||||
const target = resolveSafeToolPath(args?.targetPath);
|
||||
|
||||
assertNotRoot(source.relativePath);
|
||||
assertNotRoot(target.relativePath);
|
||||
|
||||
await assertNoSymlinkInPath(source.absolutePath);
|
||||
|
||||
const sourceStat = await fs.promises.lstat(source.absolutePath);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink sources are not allowed.");
|
||||
}
|
||||
|
||||
const recursive = asBoolean(args?.recursive, false);
|
||||
const overwrite = asBoolean(args?.overwrite, false);
|
||||
const createParents = asBoolean(args?.createParents, true);
|
||||
|
||||
if (sourceStat.isDirectory() && !recursive) {
|
||||
throw new Error("Source is a directory. Set recursive=true to copy directories.");
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory()) {
|
||||
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
|
||||
}
|
||||
|
||||
const targetParentPath = path.dirname(target.absolutePath);
|
||||
|
||||
if (createParents) {
|
||||
await assertNoSymlinkInPath(targetParentPath, {
|
||||
allowMissingTail: true,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(targetParentPath, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await assertNoSymlinkInPath(targetParentPath);
|
||||
} else {
|
||||
await assertNoSymlinkInPath(targetParentPath);
|
||||
}
|
||||
|
||||
const stats: CopyPathStats = {
|
||||
entries: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
await copyPathRecursive({
|
||||
sourceAbsolutePath: source.absolutePath,
|
||||
targetAbsolutePath: target.absolutePath,
|
||||
overwrite,
|
||||
stats,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
from: source.relativePath,
|
||||
to: target.relativePath,
|
||||
recursive,
|
||||
overwrite,
|
||||
entriesCopied: stats.entries,
|
||||
bytesCopied: stats.totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDirectory(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
const recursive = asBoolean(args?.recursive, true);
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath, {
|
||||
allowMissingTail: true,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(absolutePath, {
|
||||
recursive,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
recursive,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
assertNotRoot(relativePath);
|
||||
|
||||
const content = asString(args?.content, "");
|
||||
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
|
||||
const createIfMissing = asBoolean(args?.createIfMissing, false);
|
||||
|
||||
if (!["replace", "append", "prepend"].includes(mode)) {
|
||||
throw new Error(`Unsupported update mode: ${mode}`);
|
||||
}
|
||||
|
||||
const contentSizeBytes = Buffer.byteLength(content, "utf8");
|
||||
|
||||
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
|
||||
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(absolutePath);
|
||||
|
||||
await assertNoSymlinkInPath(parentPath);
|
||||
|
||||
const exists = await pathExists(absolutePath);
|
||||
|
||||
if (!exists && !createIfMissing) {
|
||||
throw new Error(`File does not exist: ${relativePath}`);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Path is not a file: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "replace") {
|
||||
await fs.promises.writeFile(absolutePath, content, {
|
||||
encoding: "utf8",
|
||||
flag: "w",
|
||||
});
|
||||
} else if (mode === "append") {
|
||||
await fs.promises.appendFile(absolutePath, content, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} else {
|
||||
const oldContent = exists
|
||||
? await fs.promises.readFile(absolutePath, "utf8")
|
||||
: "";
|
||||
|
||||
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
|
||||
|
||||
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
|
||||
throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(absolutePath, content + oldContent, {
|
||||
encoding: "utf8",
|
||||
flag: "w",
|
||||
});
|
||||
}
|
||||
|
||||
const newStat = await fs.promises.stat(absolutePath);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
mode,
|
||||
sizeBytes: newStat.size,
|
||||
created: !exists,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renamePath(args?: Record<string, unknown>) {
|
||||
const source = resolveSafeToolPath(args?.sourcePath);
|
||||
const target = resolveSafeToolPath(args?.targetPath);
|
||||
|
||||
assertNotRoot(source.relativePath);
|
||||
assertNotRoot(target.relativePath);
|
||||
|
||||
await assertNoSymlinkInPath(source.absolutePath);
|
||||
|
||||
const sourceStat = await fs.promises.lstat(source.absolutePath);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath);
|
||||
|
||||
if (
|
||||
relativeTargetInsideSource === "" ||
|
||||
(!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource))
|
||||
) {
|
||||
throw new Error("Cannot move a directory into itself.");
|
||||
}
|
||||
|
||||
const overwrite = asBoolean(args?.overwrite, false);
|
||||
const createParents = asBoolean(args?.createParents, false);
|
||||
|
||||
const targetParentPath = path.dirname(target.absolutePath);
|
||||
|
||||
if (createParents) {
|
||||
await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true});
|
||||
await fs.promises.mkdir(targetParentPath, {recursive: true});
|
||||
} else {
|
||||
await assertNoSymlinkInPath(targetParentPath);
|
||||
}
|
||||
|
||||
if (await pathExists(target.absolutePath)) {
|
||||
const targetStat = await fs.promises.lstat(target.absolutePath);
|
||||
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
throw new Error(`Target already exists: ${target.relativePath}`);
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
|
||||
throw new Error("Overwrite for directories is not supported.");
|
||||
}
|
||||
|
||||
await fs.promises.rm(target.absolutePath, {
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
|
||||
await fs.promises.rename(source.absolutePath, target.absolutePath);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
from: source.relativePath,
|
||||
to: target.relativePath,
|
||||
overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePath(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
assertNotRoot(relativePath);
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
const recursive = asBoolean(args?.recursive, false);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (recursive) {
|
||||
await fs.promises.rm(absolutePath, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
});
|
||||
} else {
|
||||
await fs.promises.rmdir(absolutePath);
|
||||
}
|
||||
} else {
|
||||
await fs.promises.rm(absolutePath, {
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
recursive,
|
||||
deleted: true,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,3 @@ export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_DIRECTORY_ENTRIES = 200;
|
||||
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_COPY_ENTRIES = 500;
|
||||
export const MAX_PATCH_OPERATIONS = 20;
|
||||
export const MAX_PATCH_SEARCH_BYTES = 64 * 1024;
|
||||
export const MAX_PATCH_REPLACE_BYTES = 256 * 1024;
|
||||
export const MAX_PATCH_PREVIEW_CHARS = 6000;
|
||||
export const MAX_FILE_SEARCH_ENTRIES = 5000;
|
||||
export const MAX_FILE_SEARCH_RESULTS = 100;
|
||||
export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024;
|
||||
export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300;
|
||||
export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024;
|
||||
export const MAX_STREAM_WRITE_SESSIONS = 20;
|
||||
export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000;
|
||||
export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
@@ -1,16 +1,10 @@
|
||||
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";
|
||||
|
||||
const logger = toolsLogger.child("market-rates");
|
||||
|
||||
export const GET_FINANCIAL_MARKET_DATA_TOOL_NAME = "get_financial_market_data";
|
||||
|
||||
export const getFinancialMarketData = {
|
||||
export const getMarketRatesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||
name: "get_market_rates",
|
||||
description:
|
||||
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
|
||||
parameters: {
|
||||
@@ -21,11 +15,11 @@ export const getFinancialMarketData = {
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const getFinancialMarketDataToolPrompt = [
|
||||
export const marketRatesToolPrompt = [
|
||||
"Currency rates tool rules:",
|
||||
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
|
||||
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`,
|
||||
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`,
|
||||
"- Use `get_market_rates` whenever the answer depends on current exchange rates, crypto prices, or gold price.",
|
||||
"- Use `get_market_rates` when the user asks whether a supported asset went up or down recently.",
|
||||
"- Use `get_market_rates` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.",
|
||||
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
|
||||
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
|
||||
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
|
||||
@@ -64,15 +58,12 @@ 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> {
|
||||
const startedAt = Date.now();
|
||||
export async function getMarketRates(): Promise<any | undefined> {
|
||||
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: any) {
|
||||
console.error("GET_MARKET_RATES", e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
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 {z} from "zod";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("notes");
|
||||
|
||||
export type NoteListItem = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type ListNotesResult =
|
||||
| { success: true; notes: NoteListItem[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type GetNoteContentResult =
|
||||
| {
|
||||
success: true;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
content: string;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const listNotesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_notes",
|
||||
description: "Display all available Markdown notes from the notes directory.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const getNoteContentTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_note_content",
|
||||
description: "Get the full Markdown content of a specific note by its file name.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to read. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function listNotes(): Promise<ListNotesResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("list.start");
|
||||
|
||||
try {
|
||||
const entries = await readdir(notesDir, {withFileTypes: true});
|
||||
|
||||
const markdownFiles = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((fileName) => fileName.endsWith(".md") && !fileName.startsWith("index"));
|
||||
|
||||
const notes: NoteListItem[] = await Promise.all(
|
||||
markdownFiles.map(async (fileName) => {
|
||||
const filePath = path.join(notesDir, fileName);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
|
||||
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
// Ignore content read errors for individual files.
|
||||
}
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
relativePath,
|
||||
title: extractNoteTitle(fileName, content),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
notes.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
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)});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to list notes: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNoteContent(
|
||||
args?: AiJsonObject,
|
||||
): Promise<GetNoteContentResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("get_content.start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
if (fileName.trim().includes("index")) {
|
||||
return {success: false, error: "It is forbidden to access `index.md`"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(noteFilePath, "utf-8");
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
logger.debug("get_content.done", {
|
||||
fileName: normalizedFileName,
|
||||
relativePath,
|
||||
chars: content.length,
|
||||
duration: logger.duration(startedAt)
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
fileName: normalizedFileName,
|
||||
filePath: noteFilePath,
|
||||
relativePath,
|
||||
title: extractNoteTitle(normalizedFileName, content),
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to read note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
function extractNoteTitle(fileName: string, content: string): string {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
const heading = headingMatch?.[1]?.trim();
|
||||
|
||||
if (heading) {
|
||||
return heading;
|
||||
}
|
||||
|
||||
return path.basename(fileName, ".md");
|
||||
}
|
||||
|
||||
export function buildSafeNoteFilePath(fileName: string): string | null {
|
||||
const normalizedFileName = fileName.endsWith(".md") ? fileName : `${fileName}.md`;
|
||||
|
||||
if (!normalizedFileName.trim().length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unsafeFileNamePattern = /[/\\:*?"<>|\x00-\x1F]/;
|
||||
if (unsafeFileNamePattern.test(normalizedFileName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedNotesDir = path.resolve(notesDir);
|
||||
const resolvedFilePath = path.resolve(notesDir, normalizedFileName);
|
||||
|
||||
if (!resolvedFilePath.startsWith(resolvedNotesDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedFilePath;
|
||||
}
|
||||
|
||||
export type UpdateNoteContentResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type DeleteNoteResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const updateNoteContentTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_note_content",
|
||||
description: "Update the full Markdown content of an existing note by its file name.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to update. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description:
|
||||
"The new full content of the note formatted as valid Markdown. This replaces the previous content completely.",
|
||||
},
|
||||
},
|
||||
required: ["fileName", "content"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const deleteNoteTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_note",
|
||||
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present. It is forbidden to delete/edit/rename `index.md` note.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. It is forbidden to delete/edit/rename `index.md` note.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function updateNoteContent(
|
||||
args?: AiJsonObject,
|
||||
): Promise<UpdateNoteContentResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("update_content.start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
if (fileName.trim().includes("index")) {
|
||||
return {success: false, error: "It is forbidden to edit `index.md`"};
|
||||
}
|
||||
|
||||
const content = asNonEmptyString(args?.content) ?? "";
|
||||
if (!content.trim().length) {
|
||||
return {success: false, error: "No content provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
await readFile(noteFilePath, "utf-8");
|
||||
await writeFile(noteFilePath, content, "utf-8");
|
||||
logger.debug("update_content.done", {
|
||||
fileName,
|
||||
filePath: noteFilePath,
|
||||
chars: content.length,
|
||||
duration: logger.duration(startedAt)
|
||||
});
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to update note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNote(
|
||||
args?: AiJsonObject,
|
||||
): Promise<DeleteNoteResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("delete.start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
if (fileName.trim().includes("index")) {
|
||||
return {success: false, error: "It is forbidden to delete `index.md`"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(noteFilePath);
|
||||
await removeNoteLinkFromRoot(noteFilePath);
|
||||
logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)});
|
||||
|
||||
return {success: true, filePath: noteFilePath};
|
||||
} catch (error) {
|
||||
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to delete note: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
|
||||
let rootContent: string;
|
||||
|
||||
try {
|
||||
rootContent = await readFile(notesRootFile, "utf-8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
const normalizedRelativePath = relativePath.replaceAll("\\", "\\\\");
|
||||
|
||||
const escapedRelativePath = escapeRegExp(normalizedRelativePath);
|
||||
const linkLinePattern = new RegExp(
|
||||
`^\\s*[-*]\\s+\\[[^\\]]+]\\(${escapedRelativePath}\\)\\s*$\\n?`,
|
||||
"gm",
|
||||
);
|
||||
|
||||
const updatedRootContent = rootContent.replace(linkLinePattern, "");
|
||||
|
||||
if (updatedRootContent !== rootContent) {
|
||||
await writeFile(notesRootFile, updatedRootContent.trimEnd() + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export type NoteFileAttachment = {
|
||||
type: "local_file";
|
||||
fileName: string;
|
||||
// filePath: string;
|
||||
relativePath: string;
|
||||
mimeType: "text/markdown";
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type GetNoteFileResult =
|
||||
| {
|
||||
success: true;
|
||||
attachment: NoteFileAttachment;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const NoteFileAttachmentSchema = z.object({
|
||||
type: z.literal("local_file"),
|
||||
fileName: z.string(),
|
||||
// filePath: z.string(),
|
||||
relativePath: z.string(),
|
||||
mimeType: z.literal("text/markdown"),
|
||||
sizeBytes: z.number(),
|
||||
});
|
||||
|
||||
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
attachment: NoteFileAttachmentSchema,
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const sendNoteAsFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_note_as_file",
|
||||
description:
|
||||
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function sendNoteAsFile(
|
||||
args?: AiJsonObject,
|
||||
): Promise<GetNoteFileResult> {
|
||||
logger.debug("start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, что файл существует и действительно читается.
|
||||
await readFile(noteFilePath, "utf-8");
|
||||
|
||||
const fileStat = await stat(noteFilePath);
|
||||
if (!fileStat.isFile()) {
|
||||
return {success: false, error: "Note path is not a file"};
|
||||
}
|
||||
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
const result: GetNoteFileResult = {
|
||||
success: true,
|
||||
attachment: {
|
||||
type: "local_file",
|
||||
fileName: normalizedFileName,
|
||||
// filePath: noteFilePath,
|
||||
relativePath,
|
||||
mimeType: "text/markdown",
|
||||
sizeBytes: fileStat.size,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug("done", {
|
||||
fileName: result.attachment.fileName,
|
||||
relativePath: result.attachment.relativePath,
|
||||
sizeBytes: result.attachment.sizeBytes
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import {spawn} from "node:child_process";
|
||||
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
|
||||
import {copyFile, lstat, mkdir, readdir, 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 {randomUUID} from "node:crypto";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("python-interpreter");
|
||||
|
||||
export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter";
|
||||
|
||||
@@ -192,7 +188,7 @@ export const pythonInterpreterTool = {
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function runPythonInterpreter(
|
||||
rawArgs: string | AiJsonObject | undefined,
|
||||
rawArgs: unknown,
|
||||
options: PythonInterpreterOptions = {},
|
||||
): Promise<PythonToolResult> {
|
||||
let args: PythonInterpreterArgs;
|
||||
@@ -203,21 +199,21 @@ export async function runPythonInterpreter(
|
||||
return {
|
||||
ok: false,
|
||||
phase: "internal",
|
||||
error: errorToString(error instanceof Error ? error : String(error)),
|
||||
error: errorToString(error),
|
||||
};
|
||||
}
|
||||
|
||||
const syntaxStartedAt = Date.now();
|
||||
console.time("python.syntax");
|
||||
const syntax = await validatePythonSyntax(args.code, options);
|
||||
logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok});
|
||||
console.timeEnd("python.syntax");
|
||||
|
||||
if (!syntax.ok) {
|
||||
return syntax;
|
||||
}
|
||||
|
||||
const executionStartedAt = Date.now();
|
||||
console.time("python.execution");
|
||||
const result = await executePythonCode(args, options);
|
||||
logger.debug("execution.done", {duration: logger.duration(executionStartedAt), ok: result.ok, phase: result.phase});
|
||||
console.timeEnd("python.execution");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -297,16 +293,16 @@ async function executePythonCode(
|
||||
args: PythonInterpreterArgs,
|
||||
options: PythonInterpreterOptions = {},
|
||||
): Promise<PythonToolResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.info("execute.start", {args, options});
|
||||
console.log("EXECUTE_PYTHON_CODE", "ARGS: ", JSON.stringify(args), "; OPTIONS: ", JSON.stringify(options));
|
||||
|
||||
const pythonBinary =
|
||||
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "python";
|
||||
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "C:\\Users\\meloda\\Desktop\\AI_BOT\\.venv\\Scripts\\python.exe";
|
||||
|
||||
const timeoutMs = args.timeoutMs ?? options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS;
|
||||
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
||||
|
||||
const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID());
|
||||
// const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID());
|
||||
const tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "ollama-python-temp-" + randomUUID());
|
||||
const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME);
|
||||
const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME);
|
||||
const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME);
|
||||
@@ -333,7 +329,7 @@ async function executePythonCode(
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
logger.debug("script.written", {tempDir, userScriptPath, runnerPath, duration: logger.duration(startedAt)});
|
||||
console.log("EXECUTE_PYTHON_CODE", "SCRIPT FILE WRITTEN", new Date());
|
||||
|
||||
const result = await runProcess({
|
||||
command: pythonBinary,
|
||||
@@ -350,15 +346,10 @@ async function executePythonCode(
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug("process.done", {
|
||||
duration: logger.duration(startedAt),
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
outputTruncated: result.outputTruncated
|
||||
});
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ACHIEVED", new Date());
|
||||
|
||||
if (result.timedOut) {
|
||||
logger.warn("process.timeout", {duration: logger.duration(startedAt)});
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TIMED OUT", new Date());
|
||||
return {
|
||||
ok: false,
|
||||
phase: "execution",
|
||||
@@ -374,11 +365,7 @@ async function executePythonCode(
|
||||
}
|
||||
|
||||
if (result.outputTruncated) {
|
||||
logger.warn("process.output_truncated", {
|
||||
duration: logger.duration(startedAt),
|
||||
stdoutChars: result.stdout.length,
|
||||
stderrChars: result.stderr.length
|
||||
});
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TRUNCATED", new Date());
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
@@ -395,7 +382,7 @@ async function executePythonCode(
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.warn("process.non_zero_exit", {duration: logger.duration(startedAt), result});
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR EXIT CODE", new Date(), "\n", JSON.stringify(result, null, 2));
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
@@ -411,7 +398,7 @@ async function executePythonCode(
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("process.ok", {duration: logger.duration(startedAt)});
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT NORMAL", new Date());
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
@@ -433,17 +420,17 @@ async function executePythonCode(
|
||||
skippedArtifacts,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR", new Date());
|
||||
return {
|
||||
ok: false,
|
||||
phase: "internal",
|
||||
error: errorToString(error instanceof Error ? error : String(error)),
|
||||
error: errorToString(error),
|
||||
};
|
||||
} finally {
|
||||
await rm(tempDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
// await rm(tempDir, {
|
||||
// recursive: true,
|
||||
// force: true,
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,7 +648,7 @@ function mimeTypeFromPath(filePath: string): string | undefined {
|
||||
}
|
||||
|
||||
function parsePythonInterpreterArgs(
|
||||
rawArgs: string | AiJsonObject | undefined,
|
||||
rawArgs: unknown,
|
||||
options: PythonInterpreterOptions,
|
||||
): PythonInterpreterArgs {
|
||||
let args = rawArgs;
|
||||
@@ -674,11 +661,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()) {
|
||||
@@ -705,7 +692,7 @@ function parsePythonInterpreterArgs(
|
||||
|
||||
return {
|
||||
code,
|
||||
stdin: typeof stdin === "string" ? stdin : undefined,
|
||||
stdin,
|
||||
timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
|
||||
};
|
||||
}
|
||||
@@ -813,7 +800,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;
|
||||
}
|
||||
|
||||
+74
-213
@@ -1,22 +1,13 @@
|
||||
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 {braveSearchTool, webSearch} from "./brave-search";
|
||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
||||
import {shellExecute, shellExecuteTool} from "./shell";
|
||||
import {ToolHandler} from "./types";
|
||||
import {getWeather, getWeatherTool} from "./weather";
|
||||
import {getMarketRates, getMarketRatesTool} from "./market-rates";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
||||
import {
|
||||
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||
getFinancialMarketData,
|
||||
getFinancialMarketDataToolPrompt,
|
||||
getMarketRates
|
||||
} from "./market-rates.js";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
|
||||
import {
|
||||
beginFileWrite,
|
||||
beginFileWriteTool,
|
||||
cancelFileWrite,
|
||||
cancelFileWriteTool,
|
||||
copyPath,
|
||||
copyPathTool,
|
||||
createDirectory,
|
||||
@@ -25,231 +16,101 @@ import {
|
||||
createFileTool,
|
||||
deletePath,
|
||||
deletePathTool,
|
||||
editFilePatch,
|
||||
editFilePatchTool,
|
||||
fileToolsToolPrompt,
|
||||
finishFileWrite,
|
||||
finishFileWriteTool,
|
||||
listDirectory,
|
||||
listDirectoryTool,
|
||||
readFile,
|
||||
readFileTool,
|
||||
renamePath,
|
||||
renamePathTool,
|
||||
searchFiles,
|
||||
searchFilesTool,
|
||||
sendFileAsAttachment,
|
||||
sendFileAsAttachmentTool,
|
||||
updateFile,
|
||||
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";
|
||||
updateFileTool
|
||||
} from "./file-system";
|
||||
|
||||
export const defaultTools: AiTool[] = [
|
||||
export const getTools = () => {
|
||||
const tools: AiTool[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getFinancialMarketData,
|
||||
...memoryTools,
|
||||
getMarketRatesTool,
|
||||
];
|
||||
|
||||
export const fileTools = [
|
||||
readFileTool,
|
||||
listDirectoryTool,
|
||||
searchFilesTool,
|
||||
|
||||
createFileTool,
|
||||
beginFileWriteTool,
|
||||
writeFileChunkTool,
|
||||
finishFileWriteTool,
|
||||
cancelFileWriteTool,
|
||||
|
||||
sendFileAsAttachmentTool,
|
||||
|
||||
createDirectoryTool,
|
||||
copyPathTool,
|
||||
updateFileTool,
|
||||
editFilePatchTool,
|
||||
renamePathTool,
|
||||
deletePathTool,
|
||||
] satisfies AiTool[];
|
||||
|
||||
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
|
||||
if (!raw?.trim()) return undefined;
|
||||
|
||||
const names = raw
|
||||
.split(",")
|
||||
.map(item => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return names.length ? new Set(names) : undefined;
|
||||
}
|
||||
|
||||
function isLocalToolEnabled(toolName: string): boolean {
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) return false;
|
||||
|
||||
const allowlist = parseToolNameSet(Environment.LOCAL_TOOL_ALLOWLIST);
|
||||
if (allowlist && !allowlist.has(toolName.toLowerCase())) return false;
|
||||
|
||||
const denylist = parseToolNameSet(Environment.LOCAL_TOOL_DENYLIST);
|
||||
if (denylist && denylist.has(toolName.toLowerCase())) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterEnabledTools(tools: AiTool[]): AiTool[] {
|
||||
return tools.filter(tool => isLocalToolEnabled(tool.function.name));
|
||||
}
|
||||
|
||||
export const getTools = (forCreator?: boolean) => {
|
||||
const tools: AiTool[] = [];
|
||||
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||
tools.push(...getMcpTools());
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools.push(...filterEnabledTools(defaultTools));
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
tools.push(...filterEnabledTools([webSearchTool]));
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
tools.push(...filterEnabledTools([getWeatherTool]));
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
tools.push(...filterEnabledTools(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());
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
tools.push(braveSearchTool);
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
tools.push(getWeatherTool);
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
tools.push(
|
||||
readFileTool,
|
||||
listDirectoryTool,
|
||||
createFileTool,
|
||||
createDirectoryTool,
|
||||
updateFileTool,
|
||||
renamePathTool,
|
||||
copyPathTool,
|
||||
deletePathTool,
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
export const fileToolHandlers = {
|
||||
export const getToolHandlers = () => {
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
get_market_rates: getMarketRates,
|
||||
};
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
handlers = {
|
||||
python_interpreter: runPythonInterpreter,
|
||||
...handlers
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
handlers = {
|
||||
shell_execute: shellExecute,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
handlers = {
|
||||
web_search: webSearch,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
handlers = {
|
||||
get_weather: getWeather,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
handlers = {
|
||||
read_file: readFile,
|
||||
list_directory: listDirectory,
|
||||
search_files: searchFiles,
|
||||
|
||||
create_file: createFile,
|
||||
begin_file_write: beginFileWrite,
|
||||
write_file_chunk: writeFileChunk,
|
||||
finish_file_write: finishFileWrite,
|
||||
cancel_file_write: cancelFileWrite,
|
||||
|
||||
send_file_as_attachment: sendFileAsAttachment,
|
||||
|
||||
create_directory: createDirectory,
|
||||
copy_path: copyPath,
|
||||
update_file: updateFile,
|
||||
edit_file_patch: editFilePatch,
|
||||
rename_path: renamePath,
|
||||
copy_path: copyPath,
|
||||
delete_path: deletePath,
|
||||
};
|
||||
|
||||
export const getToolHandlers = () => {
|
||||
const handlers: Record<string, ToolHandler> = {
|
||||
...getMcpToolHandlers(),
|
||||
};
|
||||
|
||||
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);
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
break;
|
||||
case WEB_SEARCH_TOOL_NAME:
|
||||
prompts.push(webSearchToolPrompt);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
prompts.push(...getMcpToolPrompts(toolNames));
|
||||
return prompts;
|
||||
}
|
||||
|
||||
+10
-26
@@ -1,34 +1,22 @@
|
||||
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";
|
||||
|
||||
const logger = toolsLogger.child("runtime");
|
||||
import {getToolHandlers} from "./registry";
|
||||
import {normalizeToolArguments} from "./utils";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function executeToolCall(
|
||||
userId: number | undefined | null,
|
||||
name: string,
|
||||
args?: string | AiJsonObject,
|
||||
args?: unknown,
|
||||
context: ToolRuntimeContext = {},
|
||||
): Promise<string> {
|
||||
const startedAt = Date.now();
|
||||
const handler = getToolHandlers()[name];
|
||||
logger.info("execute.start", {name, args});
|
||||
|
||||
if (!handler) {
|
||||
return stringifyToolResult({
|
||||
@@ -38,7 +26,7 @@ export async function executeToolCall(
|
||||
|
||||
try {
|
||||
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
|
||||
const result = await runPythonInterpreter(normalizeToolArguments(args, userId), {
|
||||
const result = await runPythonInterpreter(normalizeToolArguments(args), {
|
||||
executionTimeoutMs: 8_000,
|
||||
syntaxTimeoutMs: 3_000,
|
||||
maxCodeChars: 100_000,
|
||||
@@ -47,18 +35,14 @@ export async function executeToolCall(
|
||||
});
|
||||
|
||||
const s = stringifyToolResult(result);
|
||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||
console.log("PYTHON_INTERPRETER_STRING_RESULT", s);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
const arguments1 = normalizeToolArguments(args, userId);
|
||||
const result = await handler(arguments1, context);
|
||||
const s = stringifyToolResult(result);
|
||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||
return s;
|
||||
const result = await handler(normalizeToolArguments(args));
|
||||
return stringifyToolResult(result);
|
||||
} catch (error) {
|
||||
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
|
||||
return stringifyToolResult({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import {AiTool} from "../tool-types.js";
|
||||
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";
|
||||
|
||||
const logger = toolsLogger.child("search-notes");
|
||||
|
||||
export type SearchNoteMatchedField = "file_name" | "title" | "content";
|
||||
|
||||
export type SearchNoteItem = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
score: number;
|
||||
matchedFields: SearchNoteMatchedField[];
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
export type SearchNotesResult =
|
||||
| { success: true; results: SearchNoteItem[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const searchNotesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_notes",
|
||||
description:
|
||||
"Search Markdown notes by file name, note title, and full note content. Supports fuzzy matching. Use this when the user refers to a note by title, topic, partial title, approximate name, keyword, or something written inside the note. Returns success=true and results[], where each result contains fileName, title, score, matchedFields, relativePath, and optional snippet. Later note tools should use results[0].fileName unless multiple results are ambiguous.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description:
|
||||
"Search query for finding notes by file name, title, topic, keywords, or content. Can be partial, approximate, or contain typos. Use a short clean phrase, not the full user sentence.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Maximum number of search results to return. Defaults to 3. Maximum is 10.",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function searchNotes(
|
||||
args?: AiJsonObject,
|
||||
): Promise<SearchNotesResult> {
|
||||
const startedAt = Date.now();
|
||||
logger.debug("start", {args});
|
||||
|
||||
const query = asNonEmptyString(args?.query) ?? "";
|
||||
if (!query.trim().length) {
|
||||
return {success: false, error: "No query provided"};
|
||||
}
|
||||
|
||||
const limit = parseSearchLimit(args?.limit);
|
||||
|
||||
try {
|
||||
const entries = await readdir(notesDir, {withFileTypes: true});
|
||||
|
||||
const markdownFiles = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((fileName) => fileName.endsWith(".md"));
|
||||
|
||||
const notes = await Promise.all(
|
||||
markdownFiles.map(async (fileName) => {
|
||||
const filePath = path.join(notesDir, fileName);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
|
||||
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
// Ignore content read errors for individual files.
|
||||
}
|
||||
|
||||
const title = extractNoteTitle(fileName, content);
|
||||
const fileNameWithoutExtension = path.basename(fileName, ".md");
|
||||
|
||||
const fileNameScore = calculateFuzzyScore(query, fileNameWithoutExtension);
|
||||
const titleScore = calculateFuzzyScore(query, title);
|
||||
const contentScore = calculateContentScore(query, content);
|
||||
|
||||
const matchedFields: SearchNoteMatchedField[] = [];
|
||||
|
||||
if (fileNameScore > 0) {
|
||||
matchedFields.push("file_name");
|
||||
}
|
||||
|
||||
if (titleScore > 0) {
|
||||
matchedFields.push("title");
|
||||
}
|
||||
|
||||
if (contentScore > 0) {
|
||||
matchedFields.push("content");
|
||||
}
|
||||
|
||||
const score = Math.max(
|
||||
fileNameScore,
|
||||
titleScore,
|
||||
contentScore,
|
||||
);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
relativePath,
|
||||
title,
|
||||
score,
|
||||
matchedFields,
|
||||
snippet:
|
||||
contentScore > 0
|
||||
? buildContentSnippet(query, content)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const results = notes
|
||||
.filter((note) => note.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
logger.debug("done", {query, limit, results: results.length, duration: logger.duration(startedAt)});
|
||||
return {success: true, results};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to search notes: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSearchLimit(value: AiJsonValue | undefined): number {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: 3;
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(10, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
function extractNoteTitle(fileName: string, content: string): string {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
const heading = headingMatch?.[1]?.trim();
|
||||
|
||||
if (heading) {
|
||||
return heading;
|
||||
}
|
||||
|
||||
return path.basename(fileName, ".md");
|
||||
}
|
||||
|
||||
function calculateFuzzyScore(query: string, value: string): number {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedValue = normalizeSearchText(value);
|
||||
|
||||
if (!normalizedQuery.length || !normalizedValue.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedValue === normalizedQuery) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (normalizedValue.startsWith(normalizedQuery)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
if (normalizedValue.includes(normalizedQuery)) {
|
||||
return 85;
|
||||
}
|
||||
|
||||
const queryWords = normalizedQuery.split(" ").filter(Boolean);
|
||||
const valueWords = normalizedValue.split(" ").filter(Boolean);
|
||||
|
||||
const wordMatchScore = calculateWordMatchScore(queryWords, valueWords);
|
||||
const subsequenceScore = isSubsequence(normalizedQuery, normalizedValue) ? 55 : 0;
|
||||
const distanceScore = calculateLevenshteinScore(normalizedQuery, normalizedValue);
|
||||
|
||||
return Math.max(wordMatchScore, subsequenceScore, distanceScore);
|
||||
}
|
||||
|
||||
function calculateContentScore(query: string, content: string): number {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
|
||||
if (!normalizedQuery.length || !normalizedContent.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedContent.includes(normalizedQuery)) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
const queryWords = normalizedQuery.split(" ").filter(Boolean);
|
||||
const contentWords = new Set(normalizedContent.split(" ").filter(Boolean));
|
||||
|
||||
if (!queryWords.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let matchedWords = 0;
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
if (contentWords.has(queryWord)) {
|
||||
matchedWords++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasPartialMatch = [...contentWords].some((contentWord) => {
|
||||
if (contentWord.includes(queryWord) || queryWord.includes(contentWord)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (queryWord.length < 4 || contentWord.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(queryWord, contentWord);
|
||||
const maxLength = Math.max(queryWord.length, contentWord.length);
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
return similarity >= 0.75;
|
||||
});
|
||||
|
||||
if (hasPartialMatch) {
|
||||
matchedWords += 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
const matchRatio = matchedWords / queryWords.length;
|
||||
|
||||
if (matchRatio <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(matchRatio * 60);
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[^a-zа-я0-9\s-]/gi, " ")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function calculateWordMatchScore(queryWords: string[], valueWords: string[]): number {
|
||||
if (!queryWords.length || !valueWords.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let matchedWords = 0;
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
const bestWordScore = Math.max(
|
||||
...valueWords.map((valueWord) => {
|
||||
if (valueWord === queryWord) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (valueWord.startsWith(queryWord) || valueWord.includes(queryWord)) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(queryWord, valueWord);
|
||||
const maxLength = Math.max(queryWord.length, valueWord.length);
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
return similarity >= 0.7 ? similarity : 0;
|
||||
}),
|
||||
);
|
||||
|
||||
if (bestWordScore > 0) {
|
||||
matchedWords += bestWordScore;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = matchedWords / queryWords.length;
|
||||
return Math.round(ratio * 75);
|
||||
}
|
||||
|
||||
function calculateLevenshteinScore(query: string, value: string): number {
|
||||
const distance = levenshteinDistance(query, value);
|
||||
const maxLength = Math.max(query.length, value.length);
|
||||
|
||||
if (maxLength === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const similarity = 1 - distance / maxLength;
|
||||
|
||||
if (similarity < 0.45) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(similarity * 65);
|
||||
}
|
||||
|
||||
function isSubsequence(query: string, value: string): boolean {
|
||||
let queryIndex = 0;
|
||||
|
||||
for (const valueChar of value) {
|
||||
if (valueChar === query[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
if (queryIndex === query.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = Array.from({length: a.length + 1}, () =>
|
||||
Array.from({length: b.length + 1}, () => 0),
|
||||
);
|
||||
|
||||
for (let i = 0; i <= a.length; i++) {
|
||||
matrix[i][0] = i;
|
||||
}
|
||||
|
||||
for (let j = 0; j <= b.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[a.length][b.length];
|
||||
}
|
||||
|
||||
function buildContentSnippet(query: string, content: string): string | undefined {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const normalizedContent = content.toLowerCase();
|
||||
|
||||
let matchIndex = normalizedContent.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex < 0) {
|
||||
const queryWords = normalizeSearchText(query)
|
||||
.split(" ")
|
||||
.filter((word) => word.length >= 3);
|
||||
|
||||
for (const word of queryWords) {
|
||||
matchIndex = normalizedContent.indexOf(word);
|
||||
if (matchIndex >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const snippetRadius = 120;
|
||||
const start = Math.max(0, matchIndex - snippetRadius);
|
||||
const end = Math.min(content.length, matchIndex + normalizedQuery.length + snippetRadius);
|
||||
|
||||
const prefix = start > 0 ? "..." : "";
|
||||
const suffix = end < content.length ? "..." : "";
|
||||
|
||||
return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
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",
|
||||
function: {
|
||||
name: "shell_execute",
|
||||
description: "Execute NON-Python command in a shell. Do not use if you intend to execute some python.",
|
||||
description: "Execute command in a shell",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -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 +0,0 @@
|
||||
import {appLogger} from "../../logging/logger.js";
|
||||
|
||||
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)};
|
||||
}
|
||||
}
|
||||
+12
-23
@@ -1,25 +1,20 @@
|
||||
import {Ollama} from "ollama";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||
import type {BoundaryValue} from "../../common/boundary-types";
|
||||
|
||||
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): 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 {
|
||||
@@ -31,17 +26,13 @@ export function normalizeToolArguments(args: string | AiJsonObject | undefined,
|
||||
}
|
||||
|
||||
if (typeof args === "object" && !Array.isArray(args)) {
|
||||
const userIdObject = userId ? {"userId": userId} : {};
|
||||
return {
|
||||
...args,
|
||||
...userIdObject,
|
||||
} as AiJsonObject;
|
||||
return args 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 +45,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"
|
||||
@@ -87,15 +78,14 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
|
||||
);
|
||||
|
||||
await Promise.all(unloadPromises);
|
||||
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
|
||||
console.log("All models have been requested to unload" + exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : ".");
|
||||
} catch (error) {
|
||||
logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)});
|
||||
console.error("Error unloading models:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise<boolean> {
|
||||
try {
|
||||
logger.info("ollama.load.start", {model, contextLength});
|
||||
await ollama.generate({
|
||||
model: model,
|
||||
stream: false,
|
||||
@@ -104,10 +94,9 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
|
||||
num_ctx: contextLength
|
||||
}
|
||||
});
|
||||
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: any) {
|
||||
console.error("Error loading Ollama model:", model);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-16
@@ -1,11 +1,8 @@
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
|
||||
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,9 +42,8 @@ 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> {
|
||||
const startedAt = Date.now();
|
||||
logger.info("start", {args});
|
||||
export async function getWeather(args?: Record<string, unknown>): Promise<any | null> {
|
||||
console.log("getWeather()");
|
||||
try {
|
||||
const city = asNonEmptyString(args?.city);
|
||||
const lang = asNonEmptyString(args?.lang);
|
||||
@@ -65,7 +61,7 @@ export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | nu
|
||||
appid: apiKey,
|
||||
},
|
||||
})).data[0];
|
||||
logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse});
|
||||
console.log("GEOCODE_RESPONSE", geocodeResponse);
|
||||
if (!geocodeResponse) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -87,7 +83,7 @@ export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | nu
|
||||
...(lang ? {lang} : {}),
|
||||
},
|
||||
})).data;
|
||||
logger.debug("weather_api.done", {city, country: geocodeResponse.country, lang, units: "metric", hasResponse: !!response});
|
||||
console.log("RESPONSE: getWeather(lang=" + lang + "): ", response);
|
||||
|
||||
const main = response.main;
|
||||
const sys = response.sys;
|
||||
@@ -141,11 +137,10 @@ 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: any) {
|
||||
logError(e);
|
||||
return null;
|
||||
} finally {
|
||||
logger.debug("done", {duration: logger.duration(startedAt)});
|
||||
console.log("END: getWeather()");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import {Environment} from "../common/environment";
|
||||
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 {
|
||||
MAX_TOOL_ROUNDS,
|
||||
MistralDocumentReference,
|
||||
roundStatus,
|
||||
RuntimeConfigSnapshot,
|
||||
StreamingToolCallAccumulator,
|
||||
ToolCallData,
|
||||
ToolExecutionMemory
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||
import {runSingleModelRequest} from "./model-call-stage";
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
|
||||
export async function runMistral(
|
||||
msg: Message,
|
||||
messages: MistralChatMessage[],
|
||||
documents: MistralDocumentReference[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
firstRoundStatus: string,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
const mistralAi = createMistralClient(config.mistralChatTarget);
|
||||
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),
|
||||
inputMessages: messages.length,
|
||||
documents: documents.length,
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages: requestMessages,
|
||||
tools: requestTools,
|
||||
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();
|
||||
}
|
||||
}
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
if (!calls.length) return {shouldContinue: false};
|
||||
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}}))
|
||||
});
|
||||
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};
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await adapter.finalize().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
// Ollama provider runner extracted from unified-ai-runner.ts.
|
||||
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 {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ChatMessage} from "./chat-messages-types";
|
||||
import {ChatRequest, Tool} from "ollama";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
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,
|
||||
dedupeToolCalls,
|
||||
DEFAULT_OLLAMA_CONTEXT_SIZE,
|
||||
isOllamaModelActive,
|
||||
isRecord,
|
||||
MAX_OLLAMA_CONTEXT_SIZE,
|
||||
MAX_TOOL_ROUNDS,
|
||||
MIN_OLLAMA_CONTEXT_SIZE,
|
||||
roundStatus,
|
||||
RuntimeConfigSnapshot,
|
||||
safeJsonParseObject,
|
||||
Think,
|
||||
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 {getToolPrompts} from "./tools/registry";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
|
||||
import {getModelCapabilities} from "./provider-model-runtime";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
|
||||
export async function runOllama(
|
||||
msg: Message,
|
||||
messages: ChatMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
think: Think,
|
||||
firstRoundStatus: string,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
contextSize?: number,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
|
||||
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
|
||||
const videoNoteCount = messages.reduce((sum, m) => sum + (m.videoNotes?.length ?? 0), 0);
|
||||
const imageCount = messages.reduce((sum, m) => sum + (m.imageParts?.length || m.images?.length || 0), 0);
|
||||
|
||||
const target = (audioCount || videoNoteCount) ? config.ollamaAudioTarget :
|
||||
imageCount ? config.ollamaVisionTarget :
|
||||
think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
|
||||
const model = target.model;
|
||||
aiLog("info", "ollama.run.start", {
|
||||
stream,
|
||||
think,
|
||||
target: aiLogProviderTarget(target),
|
||||
requestedContextSize: contextSize,
|
||||
message: aiLogMessageIdentity(msg),
|
||||
counts: {messages: messages.length, images: imageCount, audio: audioCount, videoNotes: videoNoteCount},
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
const ollama = createOllamaClient(target);
|
||||
const modelInfo = await ollama.show({model});
|
||||
const modelInfoMap: Record<string, BoundaryValue> = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
|
||||
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
|
||||
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
|
||||
const parsedMaxContextLength =
|
||||
typeof rawMaxContextLength === "number"
|
||||
? rawMaxContextLength
|
||||
: typeof rawMaxContextLength === "string"
|
||||
? Number(rawMaxContextLength)
|
||||
: DEFAULT_OLLAMA_CONTEXT_SIZE;
|
||||
|
||||
const maxContextLength = Number.isFinite(parsedMaxContextLength)
|
||||
? parsedMaxContextLength
|
||||
: DEFAULT_OLLAMA_CONTEXT_SIZE;
|
||||
|
||||
const context = clamp(
|
||||
contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : contextSize ?? DEFAULT_OLLAMA_CONTEXT_SIZE,
|
||||
MIN_OLLAMA_CONTEXT_SIZE,
|
||||
maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE
|
||||
);
|
||||
aiLog("debug", "ollama.context.resolved", {model, contextKey, maxContextLength, context});
|
||||
|
||||
const modelsToLoad = [model];
|
||||
|
||||
try {
|
||||
const activeModels = (await ollama.ps()).models.map(m => m.model);
|
||||
const oldSet = new Set(activeModels);
|
||||
const newSet = new Set(modelsToLoad);
|
||||
|
||||
const added = modelsToLoad.filter(m => !oldSet.has(m));
|
||||
const removed = activeModels.filter(m => !newSet.has(m));
|
||||
const diff = [...added, ...removed];
|
||||
aiLog("debug", "ollama.models.active", {activeModels, requiredModels: modelsToLoad, added, removed});
|
||||
if (diff.length) {
|
||||
aiLog("info", "ollama.models.unload_extra", {keep: modelsToLoad, diff});
|
||||
await unloadAllOllamaModels(ollama, modelsToLoad);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e instanceof Error ? e : String(e));
|
||||
}
|
||||
|
||||
if (!(await isOllamaModelActive(ollama, target))) {
|
||||
const loadStartedAt = Date.now();
|
||||
aiLog("info", "ollama.model.load.start", {model, context});
|
||||
const currentStatus = streamMessage.getStatus();
|
||||
streamMessage.setStatus(Environment.getLoadingModelText(model));
|
||||
await streamMessage.flush();
|
||||
if (await loadOllamaModel(model, ollama, context)) {
|
||||
aiLog("success", "ollama.model.load.done", {model, duration: aiLogDuration(loadStartedAt)});
|
||||
streamMessage.setStatus(currentStatus ?? Environment.waitThinkText);
|
||||
await streamMessage.flush();
|
||||
}
|
||||
} else {
|
||||
aiLog("debug", "ollama.model.already_loaded", {model});
|
||||
}
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (!stream) {
|
||||
let typingInFlight = false;
|
||||
const applyTyping = async () => {
|
||||
if (typingInFlight) return;
|
||||
typingInFlight = true;
|
||||
try {
|
||||
await enqueueTelegramApiCall(
|
||||
() => bot.sendChatAction({chat_id: msg.chat.id, action: "typing"}),
|
||||
{method: "sendChatAction", chatId: msg.chat.id, chatType: msg.chat.type}
|
||||
).catch(logError);
|
||||
} finally {
|
||||
typingInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
await applyTyping();
|
||||
interval = setInterval(() => {
|
||||
applyTyping().catch(logError);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
const toolMemory: ToolExecutionMemory = new Map();
|
||||
const adapter = getProviderAdapter(AiProvider.OLLAMA);
|
||||
|
||||
try {
|
||||
await runToolLoopRounds({
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
onRound: async (round) => {
|
||||
const roundStartedAt = Date.now();
|
||||
aiLog("debug", "ollama.round.start", {
|
||||
round,
|
||||
context,
|
||||
messages: messages.length,
|
||||
stream,
|
||||
think: audioCount ? false : think,
|
||||
});
|
||||
|
||||
const request: ChatRequest = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
think: audioCount ? false : think,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
top_k: 40,
|
||||
num_ctx: 16384
|
||||
}
|
||||
};
|
||||
|
||||
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[];
|
||||
|
||||
aiLog("debug", "ollama.tools.available", {
|
||||
round,
|
||||
tools: allToolSchemaNames(availableOllamaTools),
|
||||
rankerEnabled: !!config.ollamaToolRankerTarget,
|
||||
});
|
||||
|
||||
const rankResult = await runToolRankStage({
|
||||
provider: AiProvider.OLLAMA,
|
||||
model,
|
||||
round,
|
||||
config,
|
||||
availableTools: availableOllamaTools,
|
||||
messages,
|
||||
streamMessage,
|
||||
signal,
|
||||
});
|
||||
|
||||
const filteredTools = [...new Set(ensureToolsSelected(availableOllamaTools, rankResult.filteredTools as Tool[], MEMORY_TOOL_NAMES) as Tool[])];
|
||||
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) {
|
||||
newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", ");
|
||||
}
|
||||
|
||||
const systemMessage = messages.find(m => m.role === "system");
|
||||
if (systemMessage) {
|
||||
systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n");
|
||||
}
|
||||
|
||||
request.model = config.ollamaToolTarget.model;
|
||||
} else {
|
||||
delete request.tools;
|
||||
}
|
||||
|
||||
aiLog("debug", "ollama.tools.selected", {
|
||||
round,
|
||||
tools: activeToolNames,
|
||||
count: activeToolNames.length,
|
||||
usedRanker: rankResult.usedRanker,
|
||||
});
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||
...request,
|
||||
stream: false
|
||||
})),
|
||||
});
|
||||
|
||||
const message = response.message;
|
||||
const rawContent = message?.content ?? "";
|
||||
|
||||
const nativeCalls = dedupeToolCalls(
|
||||
adapter.extractToolCalls(message),
|
||||
);
|
||||
|
||||
const responseText = rawContent;
|
||||
|
||||
// if (looksLikeToolRankerJson(responseText)) {
|
||||
// aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
|
||||
// round,
|
||||
// preview: responseText.slice(0, 800),
|
||||
// target: aiLogProviderTarget(target),
|
||||
// });
|
||||
// throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
|
||||
// }
|
||||
|
||||
streamMessage.append(responseText);
|
||||
|
||||
aiLog("debug", "ollama.response.received", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
textChars: responseText.length,
|
||||
nativeToolCallCount: nativeCalls.length,
|
||||
});
|
||||
|
||||
if (!nativeCalls.length) {
|
||||
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
||||
return {shouldContinue: false};
|
||||
}
|
||||
|
||||
const calls = adapter.extractToolCalls(message).length ? adapter.extractToolCalls(message) : nativeCalls;
|
||||
|
||||
aiLog("info", "ollama.tool_calls", {
|
||||
round,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
tool_calls: calls.map(c => ({
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: safeJsonParseObject(c.argumentsText),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return {shouldContinue: true};
|
||||
}
|
||||
|
||||
aiLog("debug", "ollama.stream.messages", {
|
||||
round,
|
||||
messageCount: request.messages?.length ?? 0,
|
||||
});
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||
...request,
|
||||
stream: true
|
||||
})),
|
||||
});
|
||||
|
||||
aiLog("debug", "ollama.stream.open", {round});
|
||||
const calls: ToolCallData[] = [];
|
||||
const roundTextStart = streamMessage.getText().length;
|
||||
const abortOllamaResponse = () => response.abort?.();
|
||||
signal.addEventListener("abort", abortOllamaResponse, {once: true});
|
||||
if (signal.aborted) abortOllamaResponse();
|
||||
try {
|
||||
for await (const chunk of response) {
|
||||
aiLog("trace", "ollama.stream.chunk", {
|
||||
round,
|
||||
contentPreview: chunk.message.content?.slice(0, 240),
|
||||
hasToolCalls: !!chunk.message.tool_calls?.length,
|
||||
hasThinking: !!chunk.message.thinking,
|
||||
});
|
||||
|
||||
const localToolCalls: ToolCallData[] = [];
|
||||
|
||||
localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||
|
||||
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
||||
const previousStatus = streamMessage.getStatus();
|
||||
if (newStatus && newStatus !== Environment.waitThinkText) {
|
||||
streamMessage.setStatus(newStatus);
|
||||
} else {
|
||||
streamMessage.clearStatus();
|
||||
}
|
||||
|
||||
if (streamMessage.getStatus() !== previousStatus && previousStatus && newStatus !== Environment.waitThinkText) {
|
||||
await streamMessage.flush();
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
response.abort?.();
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
|
||||
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
||||
streamMessage.append(adapter.extractTextDelta(chunk));
|
||||
}
|
||||
|
||||
calls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||
|
||||
if (chunk.done) {
|
||||
aiLog("debug", "ollama.stream.done", {
|
||||
round,
|
||||
duration: aiLogDuration(roundStartedAt),
|
||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||
toolCallCount: calls.length,
|
||||
});
|
||||
await streamMessage.flush(streamMessage.regenerateKeyboard(), true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortOllamaResponse);
|
||||
}
|
||||
|
||||
// const streamedRoundText = streamMessage.getText().slice(roundTextStart);
|
||||
// if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) {
|
||||
// streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart));
|
||||
// aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
|
||||
// round,
|
||||
// preview: streamedRoundText.slice(0, 800),
|
||||
// target: aiLogProviderTarget(target),
|
||||
// });
|
||||
// throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
|
||||
// }
|
||||
|
||||
if (!calls.length) {
|
||||
aiLog("success", "ollama.run.done", {
|
||||
round,
|
||||
duration: aiLogDuration(runnerStartedAt),
|
||||
});
|
||||
|
||||
return {shouldContinue: false};
|
||||
}
|
||||
|
||||
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
||||
|
||||
aiLog("info", "ollama.tool_calls", {
|
||||
round,
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
|
||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: roundText,
|
||||
tool_calls: calls.map(c => ({
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: safeJsonParseObject(c.argumentsText),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const toolResults = await executeToolBatchWithAdapter({
|
||||
userId: msg.from?.id,
|
||||
toolCalls: calls,
|
||||
streamMessage,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OLLAMA,
|
||||
runtimeTarget: target,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [messages],
|
||||
});
|
||||
|
||||
const continuation = decideToolLoopContinuation({
|
||||
round,
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
toolCalls: calls,
|
||||
});
|
||||
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
|
||||
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
|
||||
round,
|
||||
maxRounds: MAX_TOOL_ROUNDS,
|
||||
});
|
||||
}
|
||||
|
||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||
|
||||
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) {
|
||||
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),
|
||||
}).catch(logError);
|
||||
}
|
||||
|
||||
return {shouldContinue: true};
|
||||
},
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
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";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
import type {
|
||||
ResponseCreateParamsNonStreaming,
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseInputItem,
|
||||
ResponseStreamEvent
|
||||
} 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,
|
||||
collectOpenAiResponseImages,
|
||||
collectOpenAiResponseText,
|
||||
MAX_TOOL_ROUNDS,
|
||||
OPENAI_IMAGE_PARTIALS,
|
||||
openAiResponseItemCallId,
|
||||
OpenAiResponseLike,
|
||||
OpenAiResponseOutputItem,
|
||||
RuntimeConfigSnapshot,
|
||||
safeJsonParseObject,
|
||||
showOpenAiGeneratedImage,
|
||||
ToolCallData,
|
||||
ToolExecutionMemory,
|
||||
allToolSchemaNames
|
||||
} 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 {logError} from "../util/utils";
|
||||
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||
import {AiDownloadedFile} from "./telegram-attachments";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {getProviderAdapter} from "./provider-adapters";
|
||||
import {runToolRankStage} from "./tool-rank-stage";
|
||||
import {tryToUploadFiles} from "./openai-upload-files.js";
|
||||
|
||||
export async function runOpenAi(
|
||||
msg: Message,
|
||||
messages: OpenAIChatMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
sourceMessage: Message,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
downloads: AiDownloadedFile[] = [],
|
||||
documentRag?: OpenAiDocumentRagContext,
|
||||
): Promise<void> {
|
||||
const runnerStartedAt = Date.now();
|
||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||
const ownsDocumentRag = !documentRag;
|
||||
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
|
||||
const adapter = getProviderAdapter(AiProvider.OPENAI);
|
||||
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = adapter.mapMessages(messages) as unknown as Array<ResponseInputItem | OpenAiResponseOutputItem>;
|
||||
const availableTools = adapter.rankTools(config, {
|
||||
forCreator: msg.from?.id === Environment.CREATOR_ID,
|
||||
vectorStoreIds: preparedDocumentRag?.vectorStoreIds ?? [],
|
||||
});
|
||||
|
||||
const systemPrompt = buildSystemInstruction(
|
||||
config,
|
||||
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||
false,
|
||||
config.openAiChatTarget.systemPromptAdditions,
|
||||
await buildUserMemoryPrompt(msg.from?.id),
|
||||
);
|
||||
|
||||
aiLog("info", "openai.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.openAiChatTarget),
|
||||
imageTarget: aiLogProviderTarget(config.openAiImageTarget),
|
||||
inputMessages: messages.length,
|
||||
sourceMessage: aiLogMessageIdentity(sourceMessage),
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
|
||||
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;
|
||||
})();
|
||||
|
||||
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 = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
stream: true,
|
||||
tools: requestTools as ResponseCreateParamsStreaming["tools"],
|
||||
parallel_tool_calls: true,
|
||||
instructions: systemPrompt
|
||||
};
|
||||
const response = await runSingleModelRequest({
|
||||
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
|
||||
}) 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(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", {
|
||||
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,
|
||||
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(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.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, ...(completedResponse.output ?? []), ...toolOutputs];
|
||||
return {shouldContinue: true};
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (ownsDocumentRag) {
|
||||
await preparedDocumentRag?.cleanup().catch(logError);
|
||||
}
|
||||
await adapter.finalize().catch(logError);
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAiDocumentRagContext = {
|
||||
vectorStoreIds: string[];
|
||||
uploadedFileIds: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
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[] = [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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).`);
|
||||
}
|
||||
|
||||
return {
|
||||
vectorStoreIds: [vectorStore.id],
|
||||
uploadedFileIds,
|
||||
cleanup: async () => {
|
||||
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds);
|
||||
},
|
||||
};
|
||||
} 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 {
|
||||
// if (typeof content === "string") return content;
|
||||
// if (!Array.isArray(content)) return "";
|
||||
// return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
|
||||
// function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
|
||||
// return messages.map((message): OpenAiCompatibleChatMessage => {
|
||||
// if (message.role === "system" || message.role === "assistant") {
|
||||
// return {
|
||||
// role: message.role,
|
||||
// content: openAiResponseContentToText(message.content),
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// const content = Array.isArray(message.content)
|
||||
// ? message.content.map((part): OpenAiCompatibleContentPart => {
|
||||
// if (isRecord(part) && part.type === "input_image") {
|
||||
// return {
|
||||
// type: "image_url",
|
||||
// image_url: {url: String(part.image_url ?? "")},
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// return {
|
||||
// type: "text",
|
||||
// text: isRecord(part) && typeof part.text === "string" ? part.text : "",
|
||||
// };
|
||||
// })
|
||||
// : message.content;
|
||||
//
|
||||
// return {role: "user", content};
|
||||
// });
|
||||
// }
|
||||
// function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
|
||||
// return toolCalls.map((call, i) => ({
|
||||
// id: call.id || `openai_chat_${Date.now()}_${i}`,
|
||||
// name: call.function?.name || call.name || "",
|
||||
// argumentsText: typeof call.function?.arguments === "string"
|
||||
// ? call.function.arguments
|
||||
// : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
|
||||
// })).filter(call => call.name);
|
||||
// }
|
||||
// async function appendOpenAiChatToolResults(
|
||||
// messages: OpenAiCompatibleChatMessage[],
|
||||
// calls: ToolCallData[],
|
||||
// results: string[],
|
||||
// ): Promise<void> {
|
||||
// for (const [index, call] of calls.entries()) {
|
||||
// messages.push({
|
||||
// role: "tool",
|
||||
// tool_call_id: call.id,
|
||||
// content: results[index] ?? "",
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,248 +0,0 @@
|
||||
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 {
|
||||
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";
|
||||
|
||||
export class ToolRanker {
|
||||
constructor(private readonly config: RuntimeConfigSnapshot) {
|
||||
}
|
||||
|
||||
async selectTools(args: {
|
||||
provider: AiProvider;
|
||||
userQuery: string;
|
||||
availableTools: readonly BoundaryValue[];
|
||||
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);
|
||||
|
||||
if (!availableTools.length) {
|
||||
return {toolNames: [], usedRanker: false};
|
||||
}
|
||||
|
||||
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
|
||||
|
||||
if (!target) {
|
||||
return resolveToolRankerFallbackSelection({
|
||||
fallbackPolicy,
|
||||
availableToolNames: availableNames,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const ranker = buildToolRankerPrompt(buildRankerContext(this.config, provider, target, round, userQuery, availableTools));
|
||||
|
||||
aiLog("debug", "tool_ranker.start", {
|
||||
provider,
|
||||
round,
|
||||
target: aiLogProviderTarget(target),
|
||||
queryChars: userQuery.length,
|
||||
availableTools: availableNames,
|
||||
fallbackPolicy,
|
||||
usedMainModelFallback: !configuredTarget && fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
aiLog("debug", "tool_ranker.done", {
|
||||
provider,
|
||||
round,
|
||||
duration: aiLogDuration(startedAt),
|
||||
selectedNames,
|
||||
selectedCount: toolNames.length,
|
||||
rawPreview: raw.slice(0, 800),
|
||||
});
|
||||
|
||||
return {toolNames, usedRanker: true};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Aborted")) throw error;
|
||||
let failureMessage = error instanceof Error ? error.message : String(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,
|
||||
round,
|
||||
target: aiLogProviderTarget(target),
|
||||
fallbackPolicy,
|
||||
duration: aiLogDuration(startedAt),
|
||||
errorSummary: failureMessage,
|
||||
});
|
||||
|
||||
return resolveToolRankerFallbackSelection({
|
||||
fallbackPolicy,
|
||||
availableToolNames: availableNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async runRanker(
|
||||
provider: AiProvider,
|
||||
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
|
||||
prompt: string,
|
||||
userQuery: string,
|
||||
): Promise<string> {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA: {
|
||||
const ollama = createOllamaClient(target);
|
||||
const request = {
|
||||
model: target.model,
|
||||
messages: [
|
||||
{role: "system", content: prompt},
|
||||
{role: "user", content: userQuery},
|
||||
],
|
||||
stream: false 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() ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2055
-224
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
}
|
||||
+4
-4
@@ -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>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user