26 Commits

Author SHA1 Message Date
melod1n 46a99605e6 Add OpenAI compatible chat backend 2026-05-22 20:52:35 +03:00
melod1n 321d185592 bump versions 2026-05-21 17:05:33 +03:00
melod1n a3f19f0413 Fix startup schema migration deadlock 2026-05-19 17:58:09 +03:00
melod1n c613c636e1 Add local tool filtering 2026-05-19 08:33:18 +03:00
melod1n 7f5011b871 bump libs 2026-05-19 01:53:57 +03:00
melod1n 5b67e23060 shitton 2026-05-19 01:46:12 +03:00
melod1n a143d512ab Remove pipeline todo checklist 2026-05-18 22:43:51 +03:00
melod1n d47e2288d6 Add pipeline integration tests 2026-05-18 22:09:44 +03:00
melod1n 7b2bc93bc1 Add stale RAG provider cleanup 2026-05-18 21:27:41 +03:00
melod1n 75253534d8 Add AI observability commands and metrics 2026-05-18 20:58:19 +03:00
melod1n 53e9798193 Merge reply-chain documents into AI requests 2026-05-18 20:43:35 +03:00
melod1n df39d89ea8 Localize pipeline fallback notifications 2026-05-18 20:31:04 +03:00
melod1n 1773b44edd Add fallback target logging and unified failures 2026-05-18 20:22:47 +03:00
melod1n 507b15aa5f Add centralized pipeline fallback notifier 2026-05-18 20:13:19 +03:00
melod1n d163d72a0b Split model call and tool loop helpers 2026-05-18 19:55:00 +03:00
melod1n 57985ce87b Persist tool loop summary artifact 2026-05-18 19:31:48 +03:00
melod1n 9a105caf0b Add shared tool loop stop policy 2026-05-18 19:24:39 +03:00
melod1n 13df2a1c23 Extract shared tool batch adapter helper 2026-05-18 19:18:22 +03:00
melod1n 9352ade19f Summarize tool loop output 2026-05-18 19:05:13 +03:00
melod1n 9d6cdb008b Normalize model call output 2026-05-18 18:59:09 +03:00
melod1n e520c412af Route tool ranker fallback through executor 2026-05-18 17:16:28 +03:00
melod1n 58f5a645fd Add tool ranker fallback policy tests 2026-05-18 16:23:32 +03:00
melod1n c3481dfcfe Inline tool rank audit into stage 2026-05-18 16:10:03 +03:00
melod1n b16c213afb Isolate tool rank stage pipeline 2026-05-18 16:03:47 +03:00
melod1n 8aede4b053 Add unified request pipeline stages 2026-05-18 15:45:39 +03:00
melod1n 8cff086a8e shitton of the ai changes 2026-05-18 13:31:37 +03:00
244 changed files with 35279 additions and 8075 deletions
+99 -7
View File
@@ -11,6 +11,18 @@ BOT_TOKEN=your_bot_token_here
# To get your ID: send /id command to the bot and use the "from id" value
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)
# ============================================
@@ -31,25 +43,105 @@ 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
# Directory with localization JSON files
LOCALES_DIR=locales
# ============================================
# AI MODELS CONFIGURATION (Optional)
# ============================================
# Google Gemini
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
# Mistral AI
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
MISTRAL_TRANSCRIPTION_MODEL=voxtral-mini-latest
MISTRAL_TTS_MODEL=
MISTRAL_TTS_VOICE_ID=
MISTRAL_MAX_CONCURRENT_REQUESTS=3
# Ollama (Local AI Model)
OLLAMA_ADDRESS=
OLLAMA_MODEL=
OLLAMA_CHAT_MODEL=
OLLAMA_IMAGE_MODEL=
OLLAMA_THINK_MODEL=
OLLAMA_AUDIO_MODEL=gemma4:e2b
OLLAMA_API_KEY=
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
OLLAMA_RAG_CHUNK_SIZE=1400
OLLAMA_RAG_CHUNK_OVERLAP=220
OLLAMA_RAG_TOP_K=8
OLLAMA_RAG_MAX_CONTEXT_CHARS=14000
OLLAMA_RAG_MIN_SCORE=0.12
OLLAMA_RAG_MAX_ARCHIVE_FILES=200
OLLAMA_RAG_MAX_ARCHIVE_BYTES=52428800
OLLAMA_RAG_MAX_ARCHIVE_DEPTH=2
OLLAMA_MAX_CONCURRENT_REQUESTS=1
# Custom system prompt for AI
SYSTEM_PROMPT=
# 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
OPENAI_TTS_MODEL=gpt-4o-mini-tts
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
# 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.
# 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
# OPENAI_SPEECH_TO_TEXT_BASE_URL=https://api.openai.com/v1
# OPENAI_SPEECH_TO_TEXT_API_KEY=
# MISTRAL_TTS_BASE_URL=
# OLLAMA_DOCUMENTS_ADDRESS=http://localhost:11434
-30
View File
@@ -1,30 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
]
}
}
+55
View File
@@ -0,0 +1,55 @@
name: CI
on:
push:
pull_request:
jobs:
node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20.19.0
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
bun:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Build
run: bun run build
+1
View File
@@ -25,5 +25,6 @@ USER node
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/assets ./assets
COPY --chown=node:node locales ./locales
CMD ["node", "dist/index.js"]
+1
View File
@@ -23,5 +23,6 @@ RUN bun install --frozen-lockfile --production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/assets ./assets
COPY locales ./locales
CMD [ "bun", "dist/index.js" ]
+314
View File
@@ -0,0 +1,314 @@
# OPENAI Compatible Target Implementation
## Purpose
Add a separate execution path for OpenAI-compatible backends such as `llama.cpp`, while keeping the current official OpenAI path unchanged.
## Checklist
- [x] Add explicit OpenAI backend mode in config
- [x] Route OpenAI requests to separate official and compatible runners
- [x] Keep official OpenAI on `responses.create(...)`
- [x] Add compatible `chat.completions.create(...)` runner
- [x] Add compatible tool-call extractors
- [x] Add backend selection tests
- [x] Add basic memory/config regression coverage
- [x] Normalize compatible streaming tool-call assembly
- [x] Preserve file upload behavior in compatible backend
- [x] Guard unsupported OpenAI-only tools for compatible backend
- [x] Add environment docs and example config entries
- [x] Add real-server integration coverage for compatible backend
- [x] Revisit shared orchestration extraction for further deduplication
## Non-Goals
1. Do not change the current official OpenAI `responses.create(...)` behavior.
2. Do not auto-switch behavior only because `OPENAI_BASE_URL` is set.
3. Do not merge compatible backend quirks into the official OpenAI runner.
4. Do not remove or weaken existing tool ranking, memory, RAG, logging, or upload behavior.
## Current State
1. `src/ai/unified-ai-runner.openai.ts` currently uses the official `responses` API.
2. `src/ai/provider-adapters.ts` already has provider-specific adapters and tool/result mapping.
3. `src/ai/provider-adapter-contract.ts` already contains `responses`-style extractors.
4. `src/ai/openai-chat-message.ts` currently models `responses`-style messages, not `chat.completions` tool messages.
5. `src/ai/unified-ai-request-pipeline.ts` prepares chat context and runtime state before the model call.
6. `src/ai/ai-runtime-target.ts` resolves provider targets, base URLs, models, and keys.
7. `src/ai/unified-ai-runner.tool-ranker.ts` already uses a `chat.completions`-style call path, which is closer to compatible backends.
## Target Architecture
1. Official OpenAI backend stays on `responses.create(...)`.
2. Compatible OpenAI backend uses `chat.completions.create(...)`.
3. Backend selection is explicit through config, for example `OPENAI_BACKEND=official|compatible`.
4. Shared preparation logic remains common.
5. Transport-specific request formatting and response parsing are split.
## Configuration Design
1. Add a new config value `OPENAI_BACKEND`.
2. Allowed values should be `official` and `compatible`.
3. Default must be `official`.
4. Keep `OPENAI_BASE_URL` as a transport setting only.
5. `OPENAI_BASE_URL` must not imply compatible mode by itself.
6. Extend environment schema and runtime config to expose this value.
7. Update env docs and example env files.
## Step 1: Config and Target Selection
1. Update `src/common/environment.ts`.
2. Add a new environment field for backend mode.
3. Add setters if the codebase uses runtime env mutation in tests.
4. Update the startup schema and runtime snapshot.
5. Add tests for default value and explicit `compatible` selection.
Expected result:
- Official OpenAI stays unchanged by default.
- Explicit `OPENAI_BACKEND=compatible` selects the new execution path.
## Step 2: Split Runner Selection
1. Update the unified AI execution entry point.
2. Add a small backend selector for OpenAI targets.
3. Route official mode to the current runner.
4. Route compatible mode to a new compatible runner.
5. Keep other providers untouched.
Expected result:
- One codepath for official OpenAI.
- One codepath for OpenAI-compatible servers.
## Step 3: Shared Orchestration Extraction
1. Identify logic that is identical for both OpenAI branches.
2. Extract common orchestration into a shared helper where possible.
3. Keep these pieces shared:
- memory prompt injection
- tool ranking
- tool loop control
- logging and timing
- cancellation handling
- file upload post-processing
- document RAG preparation and cleanup
4. Keep transport-specific pieces separate:
- request shape
- response parsing
- tool result message shape
- streaming event parsing
Expected result:
- Less duplicate logic.
- Cleaner separation between official and compatible behavior.
## Step 4: Compatible Message Model
1. Update `src/ai/openai-chat-message.ts` or create a sibling type file for compatible chat messages.
2. Model `system`, `user`, `assistant`, and `tool` roles explicitly.
3. Support `tool_calls` on assistant messages.
4. Support `tool_call_id` on tool result messages.
5. Preserve support for text and multimodal user content where the backend supports it.
6. Avoid forcing `responses` output types into `chat.completions`.
Expected result:
- Compatible runner can build valid `chat.completions` message arrays.
## Step 5: Compatible Contract Extractors
1. Extend `src/ai/provider-adapter-contract.ts`.
2. Add extractors for `chat.completions` tool calls.
3. Add extractors for `chat.completions` streaming tool call deltas.
4. Keep existing `responses` extractors intact.
5. Normalize tool call IDs, names, and argument text the same way as existing extractors.
6. Ensure arguments are always represented as JSON text for the tool loop.
Expected result:
- Compatible runner can parse tool calls from both normal and streaming responses.
## Step 6: Compatible Provider Adapter
1. Update `src/ai/provider-adapters.ts`.
2. Add a separate adapter or branch for OpenAI-compatible chat.completions behavior.
3. Reuse existing tool ranking where safe.
4. Make `appendToolResults(...)` emit `role: "tool"` messages with `tool_call_id`.
5. Keep official OpenAI adapter outputting `function_call_output`.
6. Keep Mistral and Ollama unchanged.
Expected result:
- Each backend uses the tool result shape it expects.
## Step 7: Compatible Runner Implementation
1. Create a new file such as `src/ai/unified-ai-runner.openai-compatible.ts`.
2. Use `openai.chat.completions.create(...)`.
3. Pass `messages`, `tools`, `model`, `stream`, and `signal`.
4. Map system prompt and memory prompt into the `messages` array correctly.
5. Keep the tool loop structure from the current runner.
6. Append assistant tool-call messages and tool result messages between rounds.
7. Continue until no tool calls remain or max rounds is reached.
Expected result:
- Compatible backends can complete multi-round tool flows.
## Step 8: Tool Call Loop Semantics
1. Preserve `MAX_TOOL_ROUNDS`.
2. Preserve tool ranking before each round.
3. Preserve memory tool selection.
4. Preserve file search injection when document RAG is active.
5. Preserve file upload post-processing.
6. Preserve max-rounds warnings and continuation decisions.
7. Keep the final text visible in the stream message exactly as today.
Expected result:
- Compatible backend behaves like the current runner from the users 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.
+47 -6
View File
@@ -1,32 +1,72 @@
# Telegram Chat Bot
Bot for Telegram with a lot of commands and AI (Ollama/Gemini/Mistral) written in TypeScript + NodeJS/Bun runtime + Drizzle ORM (SQLite DB)
Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written in TypeScript + NodeJS/Bun runtime + SQLite/PostgreSQL/in-memory storage
## Quick Start
```bash
cp .env.example .env
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (GEMINI_API_KEY, MISTRAL_API_KEY, OLLAMA_ADDRESS)
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (MISTRAL_API_KEY, OPENAI_API_KEY, OLLAMA_ADDRESS)
# For OpenAI-compatible servers (llama.cpp, etc.), set OPENAI_BACKEND=compatible and OPENAI_BASE_URL.
# Optional: set DATABASE_URL to postgres://... for PostgreSQL or :memory: for ephemeral SQLite.
# Optional: set DATA_PATH if you want to override the default local storage directory.
```
**With Bun (Recommended):**
```bash
bun install
bunx drizzle-kit generate && bunx drizzle-kit migrate
bun run build && bun start
```
**With Node.js:**
```bash
npm install
npx drizzle-kit generate && npx drizzle-kit migrate
npm run build && npm start
```
The bot initializes and migrates its database schema automatically on startup.
`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup.
`/importdb` restores the database from the JSON backup format.
MCP tool servers can be configured through `MCP_SERVERS` in `.env`. Use a JSON array with `stdio` or `http` transports. Example:
```bash
MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
```
If you want to disable all built-in local tools and use only MCP tools, set:
```bash
DISABLE_LOCAL_TOOLS=true
```
If you want a partial filter instead, use tool names:
```bash
LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
```
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
```bash
ollama pull nomic-embed-text
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
```
Tool ranker fallback is configurable via `TOOL_RANKER_FALLBACK_POLICY`:
- `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 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
@@ -42,13 +82,14 @@ docker run -d --env-file .env -v $(pwd)/data:/config/data tg-bot-bun
## Requirements
- Node.js >= 18 OR Bun >= 1.0
- Node.js >= 20.19 OR Bun >= 1.0
- Docker (optional)
## Features
- AI chat (Gemini, Mistral, Ollama)
- AI chat (Mistral, Ollama, OpenAI)
- Local document RAG for Ollama without third-party providers
- Custom answers and commands
- Admin management
- User blocking (mute/unmute)
+98 -667
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,8 +1,10 @@
services:
tgchatbot:
container_name: tgchatbot
image: ghcr.io/melod1n/tg-chat-bot:latest
image: ghcr.io/melod1n/tg-chat-bot:${IMAGE_TAG:-1.0.0}
restart: unless-stopped
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
-11
View File
@@ -1,11 +0,0 @@
import "dotenv/config";
import {defineConfig} from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_FILE_NAME,
},
});
+42
View File
@@ -0,0 +1,42 @@
import js from "@eslint/js";
import {defineConfig} from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
{
ignores: [
"dist/**",
"data/**",
"node_modules/**",
"**/*.tsbuildinfo",
],
},
js.configs.recommended,
tseslint.configs.recommended,
{
files: ["src/**/*.ts"],
linterOptions: {
reportUnusedDisableDirectives: "off",
},
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",
},
},
{
files: ["src/logging/logger.ts"],
rules: {
"no-console": "off",
},
},
);
+236
View File
@@ -0,0 +1,236 @@
{
"language": {
"default": "Default",
"en": "English",
"ru": "Russian",
"ua": "Ukrainian",
"instructionName": "English"
},
"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...",
"reasoningText": "🤔 Reasoning...",
"transcribingAudioText": "🦻 Transcribing audio...",
"genImageText": "👨‍🎨 Generating an image...",
"cancelText": "❌ Cancel",
"regenerateText": "🔄 Regenerate",
"aiCancelCallbackText": "Cancel AI generation",
"aiRegenerateCallbackText": "Regenerate AI response",
"userSettingsCallbackText": "User settings",
"noAccessText": "No access",
"notBotCreatorText": "You are not the bot creator.",
"notBotAdministratorText": "You are not a bot administrator.",
"notAChatText": "This is not a chat.",
"notChatAdministratorText": "You are not a chat administrator.",
"botNotChatAdministratorText": "The bot is not a chat administrator.",
"replyRequiredText": "A reply to a message is required.",
"onlyOriginalAuthorText": "Only the author of the original message can perform this action.",
"commandsHeaderText": "Commands:\n\n",
"sentCommandsInDmText": "Sent commands in DM 😎",
"couldNotSendCommandsInDmText": "Could not send commands in DM ☹️\nSending them here instead",
"administratorsHeaderText": "*Administrators*:\n\n",
"noUserInfoText": "No user information",
"useLeaveCommandText": "Use /leave",
"databaseBackupCaption": "Database backup",
"databaseBackupSentText": "Successfully sent to the creator in DM!",
"noChoicesText": "Nothing to choose from",
"qrCodeMissingTextText": "No text found for QR code generation.",
"quoteMissingTextText": "Could not find text in the message 😢",
"quoteBuildFailedText": "Could not build the quote 😢",
"speechToTextInstructionText": "Send audio/voice/video-note or reply with /stt to a message containing audio.",
"speechToTextEmptyResultText": "Speech-to-text did not return transcription text.",
"textToSpeechInstructionText": "Send text after the command or reply with /tts to a message containing text.",
"titleMissingText": "Could not find a title...",
"betterFallbackText": "Better",
"pongText": "pong",
"modelListHeaderText": "Available models:\n\n",
"modelListLoadFailedText": "Could not load the model list",
"noCurrentModelText": "Model is not set. Use one of the listed values.",
"unsupportedAttachmentText": "This attachment type is not supported.",
"attachmentMissingFromCacheText": "Attachment file is missing from cache.",
"noSupportedTranscriptionProviderText": "No supported speech-to-text provider is configured.",
"noSupportedTextToSpeechProviderText": "No supported text-to-speech provider is configured.",
"noSpeechToTextProviderForAccessText": "No speech-to-text providers are configured for your access level.",
"noTextToSpeechProviderForAccessText": "No text-to-speech providers are configured for your access level.",
"noTextToSynthesizeText": "No text to synthesize.",
"speechFileTooLargeText": "The speech file is larger than 50 MB and cannot be sent.",
"userSettingsTitle": "User Settings",
"userSettingsAiProviderSelectionTitle": "AI Provider Selection",
"userSettingsInterfaceLanguageSelectionTitle": "Interface Language Selection",
"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",
"userSettingsSelectedPrefix": "✓ ",
"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...",
"botCannotMakeItselfAdminText": "The bot cannot make itself an admin",
"botCreatorAlreadyAdminText": "The bot creator is already an admin",
"botCannotRemoveItselfFromAdminsText": "The bot cannot remove itself from admins",
"botCreatorCannotStopBeingAdminText": "The bot creator cannot stop being an admin",
"botWillNotBanCreatorText": "The bot will not ban its creator.",
"botWillNotBanAdminsText": "The bot will not ban its administrators.",
"botIsNotBannedByItselfText": "The bot is not banned by itself anyway.",
"botCreatorNeverBannedText": "The bot creator is not banned and never will be.",
"botAdminsNotBannedText": "Bot administrators are not banned anyway.",
"botWillNotIgnoreItselfText": "The bot will not ignore itself.",
"botWillNotIgnoreCreatorText": "The bot will not ignore its creator.",
"botWillNotIgnoreAdminsText": "The bot will not ignore its administrators.",
"botIsNotIgnoredByItselfText": "The bot is not ignored by itself anyway.",
"botCreatorNotIgnoredText": "The bot creator is not ignored and never will be.",
"botAdminsNotIgnoredText": "Bot administrators are not ignored anyway.",
"botAlreadyAlwaysListensToItselfText": "The bot already always listens to itself",
"botAlwaysListensToCreatorText": "The bot always listens to its creator",
"coinHeadsText": "Heads",
"coinTailsText": "Tails",
"distortReplyInstructionText": "Reply with /distort to a message containing an image (photo, document, or sticker).\nExample: /distort 16 80",
"distortMissingImageText": "I do not see an image in the reply. Send a photo or image file.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"well then, everyone",
"it was nice talking to you",
"but it is time for me to rest",
"all the best"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "right now",
"whenNeverText": "never",
"whenYearUnitText": "year",
"whenDayUnitText": "day",
"whenWeekUnitText": "week",
"whenMonthUnitText": "month",
"whenHourUnitText": "hour",
"whenMinuteUnitText": "minute",
"whenSecondUnitText": "second",
"getCancelledText": "{provider}\n❌ Generation cancelled.",
"getPartialImageGenText": "🌈 Generating image ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Image generated. Model: `{model}`.",
"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}`",
"getAnalyzingDocumentText.many": "🔍 Analyzing documents: {names}",
"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",
"getAiQueueText.queued": "⏳ Request to {provider} is queued.",
"getAiQueueText.ahead": "Requests ahead: {count}.",
"getTelegramFileTooLargeText": "File {fileName} is larger than {maxSizeMb} MB and cannot be sent.",
"getUserIsNowAdminText": "{name} is now an admin!",
"getUserAlreadyAdminText": "{name} is already an admin 🤔",
"getUserNoLongerAdminText": "{name} is no longer an admin!",
"getUserWasNotAdminText": "{name} was not an admin 🤔",
"getUserBannedText": "{name} banned 🚫",
"getUserBanFailedText": "Could not ban {name} ☹️",
"getUserUnbannedText": "{name} unbanned ⛓️‍💥",
"getUserUnbanFailedText": "Could not unban {name} ☹️",
"getUserIgnoredText": "{name} is muted! 🔇",
"getUserAlreadyIgnoredText": "{name} is already muted 🤔",
"getUserIgnoreFailedText": "Could not mute {name} ☹️",
"getUserUnignoredText": "{name} is no longer muted! 🔈",
"getUserWasNotIgnoredText": "{name} was not muted 🤔",
"getUserUnignoreFailedText": "Could not unmute {name} ☹️",
"getChoiceText": "Chose *{choice}*",
"getCoinResultText": "It landed on *{result}*",
"getLoadedModelsText": "Loaded models: {models}",
"getSelectedModelText": "Selected model: `{model}`",
"getCurrentModelText": "Current model: `{model}`",
"getLoadingModelText": "Loading model `{model}`...",
"getUserSettingsContextSizeText": "{size} tokens",
"getQrCodeTextTooLongText": "Text is too long for QR ({actualLength} characters). It will be trimmed to {maxLength} characters.",
"getQrCodeReadyText": "QR code ready ✅\nContent:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Could not generate QR: {reason}",
"getWhenPrefixText": "in ",
"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",
"ban": "ban user from chat",
"choice": "Choose a random value",
"coin": "Heads or tails",
"debug": "Returns msg (or reply) as json",
"dice": "Sends random or specific dice",
"distort": "Distortion of picture",
"help": "Show list of commands",
"id": "ID of chat, user and reply (if replied to any message)",
"ignore": "Bot will ignore user",
"info": "Info about bot",
"leave": "Bot will leave current chat",
"mistralChat": "Chat with AI (Mistral)",
"mistralGetModel": "Get current Mistral model",
"mistralListModels": "List all Mistral models",
"mistralSetModel": "Set Mistral model",
"ollamaChat": "Chat with AI (Ollama)",
"ollamaGetModel": "Get current Ollama model",
"ollamaListModels": "List all Ollama models",
"ollamaSearch": "Web search via Ollama",
"ollamaSetModel": "Set Ollama model",
"openAiChat": "Chat with AI (OpenAI)",
"openAiGetModel": "Get current OpenAI model",
"openAiListModels": "List all OpenAI models",
"openAiSetModel": "Set OpenAI model",
"ping": "Ping between received and sent message",
"qr": "Generates QR-code from text you sent or replied to.",
"quote": "Make quote from text (or quote)",
"randomInt": "Ranged random integer from parameters",
"randomString": "literally random string (up to 4096 symbols)",
"settings": "User settings",
"shutdown": "Self-destruction sequence for bot (shutdown)",
"speechToText": "Transcribe speech to text",
"start": "Start the bot",
"systemInfo": "System information",
"textToSpeech": "Generate speech from text",
"title": "Change group title",
"test": "System functionality check",
"transliteration": "Transliteration EN <--> RU",
"unban": "unban user from chat",
"unignore": "Bot will start responding to the user",
"uptime": "Bot's uptime",
"whatBetter": "either a or b randomly (50% chance)",
"when": "random date"
}
}
+262
View File
@@ -0,0 +1,262 @@
{
"language": {
"default": "По умолчанию",
"en": "Английский",
"ru": "Русский",
"ua": "Украинский",
"instructionName": "Russian"
},
"providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.",
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
"waitThinkText": "⏳ Дайте-ка подумать...",
"analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...",
"reasoningText": "🤔 Рассуждаю...",
"transcribingAudioText": "🦻 Распознаю аудио...",
"genImageText": "👨‍🎨 Генерирую изображение...",
"cancelText": "❌ Отмена",
"regenerateText": "🔄 Сгенерировать заново",
"aiCancelCallbackText": "Отменить генерацию ИИ",
"aiRegenerateCallbackText": "Сгенерировать ответ ИИ заново",
"userSettingsCallbackText": "Настройки пользователя",
"noAccessText": "Нет доступа",
"notBotCreatorText": "Вы не создатель бота.",
"notBotAdministratorText": "Вы не администратор бота.",
"notAChatText": "Это не чат.",
"notChatAdministratorText": "Вы не администратор чата.",
"botNotChatAdministratorText": "Бот не является администратором чата.",
"replyRequiredText": "Нужно ответить на сообщение.",
"onlyOriginalAuthorText": "Это действие доступно только автору исходного сообщения.",
"dockerContainerLabelText": "Docker-контейнер",
"processLabelText": "Процесс",
"systemLabelText": "Система",
"systemInfoOsLabelText": "ОС",
"systemInfoRuntimeLabelText": "RUNTIME",
"systemInfoDockerLabelText": "DOCKER",
"systemInfoCpuLabelText": "CPU",
"systemInfoRamLabelText": "RAM",
"systemInfoCpuCoresText": "ядер",
"systemInfoCpuThreadsText": "потоков",
"idChatLabelText": "id чата",
"idFromLabelText": "id пользователя",
"idReplyLabelText": "id ответа",
"runtimeProviderLabelText": "провайдер",
"runtimeProviderCurrentLabelText": "текущий",
"runtimeModelLabelText": "модель",
"runtimeCapabilitiesLabelText": "возможности",
"runtimeExternalLabelText": "внешний",
"infoAiBlockLabelText": "AI",
"infoSupportedProvidersLabelText": "провайдеры",
"infoToolsBlockLabelText": "инструменты",
"infoCommandsBlockLabelText": "команды",
"infoPublicLabelText": "публичные",
"infoPrivateLabelText": "приватные",
"infoChatLabelText": "чат",
"infoCallbackLabelText": "колбэки",
"commandsHeaderText": "Команды:\n\n",
"sentCommandsInDmText": "Отправил команды в личные сообщения 😎",
"couldNotSendCommandsInDmText": "Не получилось отправить команды в личные сообщения ☹️\nОтправляю их сюда",
"administratorsHeaderText": "*Администраторы*:\n\n",
"noUserInfoText": "Нет информации о пользователе",
"useLeaveCommandText": "Используйте /leave",
"databaseBackupCaption": "Резервная копия базы данных",
"databaseBackupSentText": "Успешно отправил создателю в личные сообщения!",
"noChoicesText": "Не из чего выбирать",
"qrCodeMissingTextText": "Не найден текст для генерации QR-кода.",
"quoteMissingTextText": "Не удалось найти текст в сообщении 😢",
"quoteBuildFailedText": "Не удалось собрать цитату 😢",
"speechToTextInstructionText": "Отправьте аудио/voice/video-note или ответьте /stt на сообщение с аудио.",
"speechToTextEmptyResultText": "Распознавание речи не вернуло текст.",
"textToSpeechInstructionText": "Отправьте текст после команды или ответьте /tts на сообщение с текстом.",
"titleMissingText": "Не удалось найти заголовок...",
"betterFallbackText": "Лучше",
"pongText": "понг",
"modelListHeaderText": "Доступные модели:\n\n",
"modelListLoadFailedText": "Не удалось загрузить список моделей",
"noCurrentModelText": "Модель не задана. Используйте одно из значений из списка.",
"unsupportedAttachmentText": "Этот тип вложения не поддерживается.",
"attachmentMissingFromCacheText": "Файл вложения отсутствует в кэше.",
"noSupportedTranscriptionProviderText": "Не настроен ни один провайдер распознавания речи.",
"noSupportedTextToSpeechProviderText": "Не настроен ни один провайдер синтеза речи.",
"noSpeechToTextProviderForAccessText": "Для вашего уровня доступа не настроены провайдеры распознавания речи.",
"noTextToSpeechProviderForAccessText": "Для вашего уровня доступа не настроены провайдеры синтеза речи.",
"noTextToSynthesizeText": "Нет текста для синтеза речи.",
"speechFileTooLargeText": "Файл речи больше 50 МБ и не может быть отправлен.",
"userSettingsTitle": "Настройки пользователя",
"userSettingsAiProviderSelectionTitle": "Выбор AI-провайдера",
"userSettingsInterfaceLanguageSelectionTitle": "Выбор языка интерфейса",
"userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов",
"userSettingsContextSizeSelectionTitle": "Выбор размера контекста",
"userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений",
"userSettingsImageOutputSelectionTitle": "Режим отправки изображений",
"userSettingsTierLabel": "Уровень",
"userSettingsAiProviderLabel": "AI-провайдер",
"userSettingsInterfaceLanguageLabel": "Язык интерфейса",
"userSettingsResponseLanguageLabel": "Язык ответов LLM",
"userSettingsContextSizeLabel": "Размер контекста",
"userSettingsVoiceModeLabel": "Голосовые сообщения",
"userSettingsImageOutputLabel": "Изображения",
"userSettingsBackButtonText": "Назад",
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
"userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса",
"userSettingsResponseLanguageButtonPrefix": "Язык ответов",
"userSettingsContextSizeButtonPrefix": "Контекст",
"userSettingsVoiceModeButtonPrefix": "Голосовые",
"userSettingsImageOutputButtonPrefix": "Изображения",
"userSettingsCreatorTierText": "Создатель",
"userSettingsAdminTierText": "Админ",
"userSettingsUserTierText": "Пользователь",
"userSettingsSelectedPrefix": "✓ ",
"userSettingsContextSizeDefaultText": "По умолчанию",
"userSettingsVoiceModeExecuteText": "Выполнять через ИИ",
"userSettingsVoiceModeTranscriptText": "Только расшифровка",
"userSettingsImageOutputPhotoText": "Как фото",
"userSettingsImageOutputDocumentText": "Как документ",
"startingImageGenText": "🌈 Запускаю генерацию изображения...",
"imageGenText": "🌈 Генерирую изображение...",
"finalizingImageGenText": "🌈 Завершаю генерацию изображения...",
"botCannotMakeItselfAdminText": "Бот не может назначить себя администратором",
"botCreatorAlreadyAdminText": "Создатель бота уже администратор",
"botCannotRemoveItselfFromAdminsText": "Бот не может удалить себя из администраторов",
"botCreatorCannotStopBeingAdminText": "Создатель бота не может перестать быть администратором",
"botWillNotBanCreatorText": "Бот не будет банить своего создателя.",
"botWillNotBanAdminsText": "Бот не будет банить своих администраторов.",
"botIsNotBannedByItselfText": "Бот и так не забанен сам собой.",
"botCreatorNeverBannedText": "Создатель бота не забанен и никогда не будет.",
"botAdminsNotBannedText": "Администраторы бота и так не забанены.",
"botWillNotIgnoreItselfText": "Бот не будет игнорировать себя.",
"botWillNotIgnoreCreatorText": "Бот не будет игнорировать своего создателя.",
"botWillNotIgnoreAdminsText": "Бот не будет игнорировать своих администраторов.",
"botIsNotIgnoredByItselfText": "Бот и так не игнорирует сам себя.",
"botCreatorNotIgnoredText": "Создатель бота не игнорируется и никогда не будет.",
"botAdminsNotIgnoredText": "Администраторы бота и так не игнорируются.",
"botAlreadyAlwaysListensToItselfText": "Бот и так всегда слушает сам себя",
"botAlwaysListensToCreatorText": "Бот всегда слушает своего создателя",
"coinHeadsText": "Орёл",
"coinTailsText": "Решка",
"distortReplyInstructionText": "Ответьте /distort на сообщение с изображением (фото, документ или стикер).\nПример: /distort 16 80",
"distortMissingImageText": "Не вижу изображения в ответе. Отправьте фото или файл изображения.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"ну что ж, народ",
"было приятно пообщаться",
"но мне пора отдохнуть",
"всем добра"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "прямо сейчас",
"whenNeverText": "никогда",
"whenYearUnitText": "год",
"whenDayUnitText": "день",
"whenWeekUnitText": "неделя",
"whenMonthUnitText": "месяц",
"whenHourUnitText": "час",
"whenMinuteUnitText": "минута",
"whenSecondUnitText": "секунда",
"getCancelledText": "{provider}\n❌ Генерация отменена.",
"getPartialImageGenText": "🌈 Генерирую изображение ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Изображение сгенерировано. Модель: `{model}`.",
"getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`",
"getUseToolText.default": "🔧 Использую инструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Анализирую документ...",
"getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`",
"getAnalyzingDocumentText.many": "🔍 Анализирую документы: {names}",
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
"getSelectingToolsText": "🧩 Выбираю подходящие инструменты...",
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
"queueNoneText": "нет",
"getAiQueueText.queued": "⏳ Запрос к {provider} поставлен в очередь.",
"getAiQueueText.ahead": "Запросов впереди: {count}.",
"getTelegramFileTooLargeText": "Файл {fileName} больше {maxSizeMb} МБ и не может быть отправлен.",
"getUserIsNowAdminText": "{name} теперь администратор!",
"getUserAlreadyAdminText": "{name} уже администратор 🤔",
"getUserNoLongerAdminText": "{name} больше не администратор!",
"getUserWasNotAdminText": "{name} не был администратором 🤔",
"getUserBannedText": "{name} забанен 🚫",
"getUserBanFailedText": "Не удалось забанить {name} ☹️",
"getUserUnbannedText": "{name} разбанен ⛓️‍💥",
"getUserUnbanFailedText": "Не удалось разбанить {name} ☹️",
"getUserIgnoredText": "{name} заглушён! 🔇",
"getUserAlreadyIgnoredText": "{name} уже заглушён 🤔",
"getUserIgnoreFailedText": "Не удалось заглушить {name} ☹️",
"getUserUnignoredText": "{name} больше не заглушён! 🔈",
"getUserWasNotIgnoredText": "{name} не был заглушён 🤔",
"getUserUnignoreFailedText": "Не удалось включить {name} обратно ☹️",
"getChoiceText": "Выбрал *{choice}*",
"getCoinResultText": "Выпало: *{result}*",
"getLoadedModelsText": "Загруженные модели: {models}",
"getSelectedModelText": "Выбрана модель: `{model}`",
"getCurrentModelText": "Текущая модель: `{model}`",
"getLoadingModelText": "Загружаю модель `{model}`...",
"getUserSettingsContextSizeText": "{size} токенов",
"getQrCodeTextTooLongText": "Текст слишком длинный для QR ({actualLength} символов). Обрежу до {maxLength} символов.",
"getQrCodeReadyText": "QR-код готов ✅\nСодержимое:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Не удалось сгенерировать QR: {reason}",
"getWhenPrefixText": "через ",
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"aiRequests": "Показать последние AI-запросы",
"aiAudit": "Показать аудит AI-запроса и артефакты",
"aiMetrics": "Показать счётчики AI-обсервабилити",
"ae": "вычисление",
"adminsAdd": "Добавить пользователя в администраторы",
"adminsRemove": "Удалить пользователя из администраторов",
"ban": "забанить пользователя в чате",
"choice": "Выбрать случайное значение",
"coin": "Орёл или решка",
"debug": "Вернуть msg или reply в JSON",
"dice": "Отправить случайный или конкретный дайс",
"distort": "Искажение изображения",
"help": "Показать список команд",
"id": "ID чата, пользователя и ответа",
"ignore": "Бот будет игнорировать пользователя",
"info": "Информация о боте",
"leave": "Бот покинет текущий чат",
"mistralChat": "Чат с AI (Mistral)",
"mistralGetModel": "Показать текущую модель Mistral",
"mistralListModels": "Показать все модели Mistral",
"mistralSetModel": "Установить модель Mistral",
"ollamaChat": "Чат с AI (Ollama)",
"ollamaGetModel": "Показать текущую модель Ollama",
"ollamaListModels": "Показать все модели Ollama",
"ollamaSearch": "Веб-поиск через Ollama",
"ollamaSetModel": "Установить модель Ollama",
"openAiChat": "Чат с AI (OpenAI)",
"openAiGetModel": "Показать текущую модель OpenAI",
"openAiListModels": "Показать все модели OpenAI",
"openAiSetModel": "Установить модель OpenAI",
"ping": "Задержка между получением и отправкой сообщения",
"qr": "Сгенерировать QR-код из текста",
"quote": "Сделать цитату из текста",
"randomInt": "Случайное число из диапазона",
"randomString": "Случайная строка до 4096 символов",
"settings": "Настройки пользователя",
"shutdown": "Выключить бота",
"speechToText": "Распознать речь в текст",
"start": "Запустить бота",
"systemInfo": "Информация о системе",
"textToSpeech": "Сгенерировать речь из текста",
"title": "Изменить название группы",
"test": "Проверка системной функциональности",
"transliteration": "Транслитерация EN <--> RU",
"unban": "разбанить пользователя в чате",
"unignore": "Бот снова будет отвечать пользователю",
"uptime": "Время работы бота",
"whatBetter": "случайно выбрать a или b",
"when": "случайная дата"
}
}
+228
View File
@@ -0,0 +1,228 @@
{
"language": {
"default": "За замовчуванням",
"en": "Англійська",
"ru": "Російська",
"ua": "Українська",
"instructionName": "Ukrainian"
},
"providerChoice.default": "За замовчуванням",
"errorText": "⚠️ Сталася помилка.",
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
"waitThinkText": "⏳ Дайте-но подумати...",
"analyzingPictureText": "🔍 Аналізую зображення...",
"analyzingPicturesText": "🔍 Аналізую зображення...",
"reasoningText": "🤔 Міркую...",
"transcribingAudioText": "🦻 Розпізнаю аудіо...",
"genImageText": "👨‍🎨 Генерую зображення...",
"cancelText": "❌ Скасувати",
"regenerateText": "🔄 Згенерувати заново",
"aiCancelCallbackText": "Скасувати генерацію AI",
"aiRegenerateCallbackText": "Згенерувати відповідь AI заново",
"userSettingsCallbackText": "Налаштування користувача",
"noAccessText": "Немає доступу",
"notBotCreatorText": "Ви не творець бота.",
"notBotAdministratorText": "Ви не адміністратор бота.",
"notAChatText": "Це не чат.",
"notChatAdministratorText": "Ви не адміністратор чату.",
"botNotChatAdministratorText": "Бот не є адміністратором чату.",
"replyRequiredText": "Потрібно відповісти на повідомлення.",
"onlyOriginalAuthorText": "Ця дія доступна лише автору початкового повідомлення.",
"dockerContainerLabelText": "Docker-контейнер",
"processLabelText": "Процес",
"systemLabelText": "Система",
"systemInfoOsLabelText": "ОС",
"systemInfoRuntimeLabelText": "RUNTIME",
"systemInfoDockerLabelText": "DOCKER",
"systemInfoCpuLabelText": "CPU",
"systemInfoRamLabelText": "RAM",
"systemInfoCpuCoresText": "ядер",
"systemInfoCpuThreadsText": "потоків",
"idChatLabelText": "id чату",
"idFromLabelText": "id користувача",
"idReplyLabelText": "id відповіді",
"runtimeProviderLabelText": "провайдер",
"runtimeModelLabelText": "модель",
"runtimeCapabilitiesLabelText": "можливості",
"runtimeExternalLabelText": "зовнішній",
"infoAiBlockLabelText": "AI",
"infoSupportedProvidersLabelText": "провайдери",
"infoToolsBlockLabelText": "інструменти",
"infoCommandsBlockLabelText": "команди",
"infoPublicLabelText": "публічні",
"infoPrivateLabelText": "приватні",
"infoChatLabelText": "чат",
"infoCallbackLabelText": "колбеки",
"commandsHeaderText": "Команди:\n\n",
"sentCommandsInDmText": "Надіслав команди в особисті повідомлення 😎",
"couldNotSendCommandsInDmText": "Не вдалося надіслати команди в особисті повідомлення ☹️\nНадсилаю їх сюди",
"administratorsHeaderText": "*Адміністратори*:\n\n",
"noUserInfoText": "Немає інформації про користувача",
"useLeaveCommandText": "Використайте /leave",
"databaseBackupCaption": "Резервна копія бази даних",
"databaseBackupSentText": "Успішно надіслав творцю в особисті повідомлення!",
"noChoicesText": "Немає з чого вибирати",
"qrCodeMissingTextText": "Не знайдено текст для генерації QR-коду.",
"quoteMissingTextText": "Не вдалося знайти текст у повідомленні 😢",
"quoteBuildFailedText": "Не вдалося створити цитату 😢",
"speechToTextInstructionText": "Надішліть аудіо/voice/video-note або відповідайте /stt на повідомлення з аудіо.",
"speechToTextEmptyResultText": "Розпізнавання мовлення не повернуло текст.",
"textToSpeechInstructionText": "Надішліть текст після команди або відповідайте /tts на повідомлення з текстом.",
"titleMissingText": "Не вдалося знайти заголовок...",
"betterFallbackText": "Краще",
"pongText": "понг",
"modelListHeaderText": "Доступні моделі:\n\n",
"modelListLoadFailedText": "Не вдалося завантажити список моделей",
"noCurrentModelText": "Модель не задана. Використайте одне зі значень зі списку.",
"unsupportedAttachmentText": "Цей тип вкладення не підтримується.",
"attachmentMissingFromCacheText": "Файл вкладення відсутній у кеші.",
"noSupportedTranscriptionProviderText": "Не налаштовано жодного провайдера розпізнавання мовлення.",
"noSupportedTextToSpeechProviderText": "Не налаштовано жодного провайдера синтезу мовлення.",
"noSpeechToTextProviderForAccessText": "Для вашого рівня доступу не налаштовано провайдери розпізнавання мовлення.",
"noTextToSpeechProviderForAccessText": "Для вашого рівня доступу не налаштовано провайдери синтезу мовлення.",
"noTextToSynthesizeText": "Немає тексту для синтезу мовлення.",
"speechFileTooLargeText": "Файл мовлення більший за 50 МБ і не може бути надісланий.",
"userSettingsTitle": "Налаштування користувача",
"userSettingsAiProviderSelectionTitle": "Вибір AI-провайдера",
"userSettingsInterfaceLanguageSelectionTitle": "Вибір мови інтерфейсу",
"userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей",
"userSettingsContextSizeSelectionTitle": "Вибір розміру контексту",
"userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень",
"userSettingsImageOutputSelectionTitle": "Режим надсилання зображень",
"userSettingsTierLabel": "Рівень",
"userSettingsAiProviderLabel": "AI-провайдер",
"userSettingsInterfaceLanguageLabel": "Мова інтерфейсу",
"userSettingsResponseLanguageLabel": "Мова відповідей LLM",
"userSettingsContextSizeLabel": "Розмір контексту",
"userSettingsVoiceModeLabel": "Голосові повідомлення",
"userSettingsImageOutputLabel": "Зображення",
"userSettingsBackButtonText": "Назад",
"userSettingsAiProviderButtonPrefix": "AI-провайдер",
"userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу",
"userSettingsResponseLanguageButtonPrefix": "Мова відповідей",
"userSettingsContextSizeButtonPrefix": "Контекст",
"userSettingsVoiceModeButtonPrefix": "Голосові",
"userSettingsImageOutputButtonPrefix": "Зображення",
"userSettingsCreatorTierText": "Творець",
"userSettingsAdminTierText": "Адмін",
"userSettingsUserTierText": "Користувач",
"userSettingsSelectedPrefix": "✓ ",
"userSettingsContextSizeDefaultText": "За замовчуванням",
"userSettingsVoiceModeExecuteText": "Виконувати через AI",
"userSettingsVoiceModeTranscriptText": "Лише розшифровка",
"userSettingsImageOutputPhotoText": "Як фото",
"userSettingsImageOutputDocumentText": "Як документ",
"startingImageGenText": "🌈 Запускаю генерацію зображення...",
"imageGenText": "🌈 Генерую зображення...",
"finalizingImageGenText": "🌈 Завершую генерацію зображення...",
"botCannotMakeItselfAdminText": "Бот не може призначити себе адміністратором",
"botCreatorAlreadyAdminText": "Творець бота вже адміністратор",
"botCannotRemoveItselfFromAdminsText": "Бот не може видалити себе з адміністраторів",
"botCreatorCannotStopBeingAdminText": "Творець бота не може перестати бути адміністратором",
"botWillNotBanCreatorText": "Бот не банитиме свого творця.",
"botWillNotBanAdminsText": "Бот не банитиме своїх адміністраторів.",
"botIsNotBannedByItselfText": "Бот і так не забанений сам собою.",
"botCreatorNeverBannedText": "Творець бота не забанений і ніколи не буде.",
"botAdminsNotBannedText": "Адміністратори бота і так не забанені.",
"botWillNotIgnoreItselfText": "Бот не ігноруватиме себе.",
"botWillNotIgnoreCreatorText": "Бот не ігноруватиме свого творця.",
"botWillNotIgnoreAdminsText": "Бот не ігноруватиме своїх адміністраторів.",
"botIsNotIgnoredByItselfText": "Бот і так не ігнорує сам себе.",
"botCreatorNotIgnoredText": "Творець бота не ігнорується і ніколи не буде.",
"botAdminsNotIgnoredText": "Адміністратори бота і так не ігноруються.",
"botAlreadyAlwaysListensToItselfText": "Бот і так завжди слухає сам себе",
"botAlwaysListensToCreatorText": "Бот завжди слухає свого творця",
"coinHeadsText": "Орел",
"coinTailsText": "Решка",
"distortReplyInstructionText": "Відповідайте /distort на повідомлення із зображенням (фото, документ або стікер).\nПриклад: /distort 16 80",
"distortMissingImageText": "Не бачу зображення у відповіді. Надішліть фото або файл зображення.",
"shutdownFallbackText": "...",
"shutdownSequenceTexts": [
"ну що ж, усі",
"було приємно поспілкуватися",
"але мені час відпочити",
"усього доброго"
],
"shutdownDoneText": "*R.I.P*",
"whenNowText": "прямо зараз",
"whenNeverText": "ніколи",
"whenYearUnitText": "рік",
"whenDayUnitText": "день",
"whenWeekUnitText": "тиждень",
"whenMonthUnitText": "місяць",
"whenHourUnitText": "година",
"whenMinuteUnitText": "хвилина",
"whenSecondUnitText": "секунда",
"getCancelledText": "{provider}\n❌ Генерацію скасовано.",
"getPartialImageGenText": "🌈 Генерую зображення ({iteration}/{total})...",
"getImageGenDoneText.withModel": "👨‍🎨 Зображення згенеровано. Модель: `{model}`.",
"getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`",
"getUseToolText.default": "🔧 Використовую інструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Аналізую документ...",
"getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`",
"getAnalyzingDocumentText.many": "🔍 Аналізую документи: {names}",
"getPreparingRAGText.default": "🔍 Готую RAG для документа...",
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
"getSelectingToolsText": "🧩 Вибираю підхожі інструменти...",
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
"queueNoneText": "немає",
"getAiQueueText.queued": "⏳ Запит до {provider} поставлено в чергу.",
"getAiQueueText.ahead": "Запитів попереду: {count}.",
"getTelegramFileTooLargeText": "Файл {fileName} більший за {maxSizeMb} МБ і не може бути надісланий.",
"getUserIsNowAdminText": "{name} тепер адміністратор!",
"getUserAlreadyAdminText": "{name} вже адміністратор 🤔",
"getUserNoLongerAdminText": "{name} більше не адміністратор!",
"getUserWasNotAdminText": "{name} не був адміністратором 🤔",
"getUserBannedText": "{name} забанений 🚫",
"getUserBanFailedText": "Не вдалося забанити {name} ☹️",
"getUserUnbannedText": "{name} розбанений ⛓️‍💥",
"getUserUnbanFailedText": "Не вдалося розбанити {name} ☹️",
"getUserIgnoredText": "{name} заглушений! 🔇",
"getUserAlreadyIgnoredText": "{name} вже заглушений 🤔",
"getUserIgnoreFailedText": "Не вдалося заглушити {name} ☹️",
"getUserUnignoredText": "{name} більше не заглушений! 🔈",
"getUserWasNotIgnoredText": "{name} не був заглушений 🤔",
"getUserUnignoreFailedText": "Не вдалося увімкнути {name} назад ☹️",
"getChoiceText": "Вибрав *{choice}*",
"getCoinResultText": "Випало: *{result}*",
"getLoadedModelsText": "Завантажені моделі: {models}",
"getSelectedModelText": "Обрано модель: `{model}`",
"getCurrentModelText": "Поточна модель: `{model}`",
"getLoadingModelText": "Завантажую модель `{model}`...",
"getUserSettingsContextSizeText": "{size} токенів",
"getQrCodeTextTooLongText": "Текст занадто довгий для QR ({actualLength} символів). Обріжу до {maxLength} символів.",
"getQrCodeReadyText": "QR-код готовий ✅\nВміст:\n<blockquote expandable>{content}</blockquote>",
"getQrCodeFailedText": "Не вдалося згенерувати QR: {reason}",
"getWhenPrefixText": "через ",
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"aiRequests": "Показати останні AI-запити",
"aiAudit": "Показати аудит AI-запиту та артефакти",
"aiMetrics": "Показати лічильники AI-спостережуваності",
"help": "Показати список команд",
"settings": "Налаштування користувача",
"start": "Запустити бота",
"ping": "Затримка між отриманням і надсиланням повідомлення",
"info": "Інформація про бота",
"systemInfo": "Інформація про систему",
"speechToText": "Розпізнати мовлення в текст",
"textToSpeech": "Згенерувати мовлення з тексту",
"qr": "Згенерувати QR-код з тексту",
"quote": "Створити цитату з тексту",
"choice": "Вибрати випадкове значення",
"coin": "Орел або решка",
"when": "випадкова дата"
}
}
+585 -3671
View File
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -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 dist/index.js"
"bun:start": "bun run src/index.ts"
},
"dependencies": {
"@google/genai": "^1.50.1",
"@libsql/client": "^0.17.3",
"@mistralai/mistralai": "^1.15.1",
"@napi-rs/canvas": "^0.1.100",
"axios": "^1.15.2",
"@mistralai/mistralai": "^2.2.1",
"@napi-rs/canvas": "^1.0.0",
"axios": "^1.16.1",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-orm": "0.45.2",
"emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3",
"openai": "^6.35.0",
"puppeteer": "^24.42.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"openai": "^6.38.0",
"pg": "^8.21.0",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"systeminformation": "^5.31.5",
"systeminformation": "^5.31.6",
"twemoji": "^14.0.2",
"typescript-telegram-bot-api": "^0.11.0",
"youtubei.js": "^16.0.1",
"zod": "^4.3.6"
"typescript-telegram-bot-api": "^0.16.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@eslint/js": "^9.39.4",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"drizzle-kit": "^1.0.0-beta.9-e89174b",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"eslint": "^9.39.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "../logging/ai-logger";
+203
View File
@@ -0,0 +1,203 @@
import {Mistral} from "@mistralai/mistralai";
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";
export type AiCapabilityName = keyof AiModelCapabilities;
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
export type AiRuntimeTarget = {
provider: AiProvider;
purpose: AiRuntimePurpose;
model: string;
baseUrl?: string;
apiKey?: string;
systemPromptAdditions?: string | null;
};
const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
chat: ["CHAT"],
vision: ["VISION", "IMAGE"],
ocr: ["OCR", "VISION", "IMAGE"],
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"],
speechToText: ["SPEECH_TO_TEXT", "TRANSCRIPTION", "STT", "AUDIO"],
textToSpeech: ["TEXT_TO_SPEECH", "TTS"],
};
function providerPrefix(provider: AiProvider): string {
return provider.toString();
}
function env(name: string): string | undefined {
return Environment.getOptionalConfigValue(name);
}
function firstEnv(names: string[]): string | undefined {
for (const name of names) {
const value = env(name);
if (value) return value;
}
return undefined;
}
function endpointEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
return PURPOSE_SUFFIXES[purpose].flatMap(suffix => [
`${prefix}_${suffix}_BASE_URL`,
`${prefix}_${suffix}_ENDPOINT`,
`${prefix}_${suffix}_ADDRESS`,
]);
}
function apiKeyEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_API_KEY`);
}
function modelEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
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.MISTRAL:
return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT");
case AiProvider.OPENAI:
return env("OPENAI_BASE_URL") ?? env("OPENAI_ENDPOINT");
}
}
export function getProviderApiKey(provider: AiProvider): string | undefined {
switch (provider) {
case AiProvider.OLLAMA:
return Environment.OLLAMA_API_KEY;
case AiProvider.MISTRAL:
return Environment.MISTRAL_API_KEY;
case AiProvider.OPENAI:
return Environment.OPENAI_API_KEY;
}
}
export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRuntimePurpose): string {
switch (provider) {
case AiProvider.OLLAMA:
switch (purpose) {
case "vision":
case "ocr":
case "outputImages":
return Environment.OLLAMA_IMAGE_MODEL;
case "thinking":
case "extendedThinking":
return Environment.OLLAMA_THINK_MODEL;
case "audio":
case "speechToText":
return Environment.OLLAMA_AUDIO_MODEL;
case "documents":
return Environment.OLLAMA_EMBEDDING_MODEL;
default:
return Environment.OLLAMA_CHAT_MODEL;
}
case AiProvider.MISTRAL:
switch (purpose) {
case "speechToText":
return Environment.MISTRAL_TRANSCRIPTION_MODEL;
case "textToSpeech":
return Environment.MISTRAL_TTS_MODEL || Environment.MISTRAL_MODEL;
default:
return Environment.MISTRAL_MODEL;
}
case AiProvider.OPENAI:
switch (purpose) {
case "outputImages":
return Environment.OPENAI_IMAGE_MODEL;
case "speechToText":
return Environment.OPENAI_TRANSCRIPTION_MODEL;
case "textToSpeech":
return Environment.OPENAI_TTS_MODEL;
default:
return Environment.OPENAI_MODEL;
}
}
}
export function resolveAiRuntimeTarget(
provider: AiProvider,
purpose: AiRuntimePurpose,
modelOverride?: string,
): AiRuntimeTarget {
const model = modelOverride
?? firstEnv(modelEnvNames(provider, purpose))
?? 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);
}
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
return left.provider === right.provider
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
export function createOpenAiClient(target: AiRuntimeTarget): OpenAI {
return new OpenAI({
apiKey: target.apiKey,
baseURL: target.baseUrl,
});
}
export function createMistralClient(target: AiRuntimeTarget): Mistral {
return new Mistral({
apiKey: target.apiKey,
serverURL: target.baseUrl,
});
}
export function createOllamaClient(target: AiRuntimeTarget): Ollama {
return new Ollama({
host: target.baseUrl,
headers: target.apiKey ? {"Authorization": `Bearer ${target.apiKey}`} : undefined,
});
}
+55
View File
@@ -0,0 +1,55 @@
import {randomUUID} from "node:crypto";
export type AiCancelRequest = {
id: string;
chatId: number;
messageId?: number;
fromId: number;
provider: string;
controller: AbortController;
onCancel?: () => Promise<void> | void;
};
const requests = new Map<string, AiCancelRequest>();
export function createAiCancelRequest(params: Omit<AiCancelRequest, "id" | "controller"> & { controller?: AbortController }): AiCancelRequest {
const request: AiCancelRequest = {
id: randomUUID(),
controller: params.controller ?? new AbortController(),
chatId: params.chatId,
messageId: params.messageId,
fromId: params.fromId,
provider: params.provider,
onCancel: params.onCancel,
};
requests.set(request.id, request);
return request;
}
export function setAiCancelMessageId(id: string, messageId: number): void {
const request = requests.get(id);
if (request) request.messageId = messageId;
}
export function getAiCancelRequest(id: string): AiCancelRequest | undefined {
return requests.get(id);
}
export async function abortAiRequest(id: string): Promise<boolean> {
const request = requests.get(id);
if (!request) return false;
request.controller.abort();
try {
await request.onCancel?.();
} finally {
requests.delete(id);
}
return true;
}
export function finishAiRequest(id: string): void {
requests.delete(id);
}
+45
View File
@@ -0,0 +1,45 @@
import {AiToolCall} from "./tool-types";
import {OllamaChatMessage} from "./ollama-chat-message";
import {MistralChatMessage} from "./mistral-chat-message";
import {MessageAudioPart, MessageImagePart} from "../common/message-part";
import {OpenAIChatMessage} from "./openai-chat-message";
export type ChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
images?: string[];
imageParts?: MessageImagePart[];
documents?: string[];
audios?: string[];
audioParts?: MessageAudioPart[];
videos?: string[];
videoNotes?: string[];
thinking?: string;
tool_calls?: AiToolCall[];
tool_name?: string;
}
export function asOllamaChatMessage(message: ChatMessage): OllamaChatMessage {
return {
role: message.role,
content: message.content,
thinking: message.thinking,
images: message.images,
tool_calls: message.tool_calls,
tool_name: message.tool_name
};
}
export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
return {
role: message.role,
content: message.content,
};
}
// export function asOpenAIChatMessage(message: ChatMessage): OpenAIChatMessage {
// return {
//
// }
// }
export type AiChatMessage = OpenAIChatMessage | OllamaChatMessage | MistralChatMessage;
+358
View File
@@ -0,0 +1,358 @@
import {Message} from "typescript-telegram-bot-api";
import type {
ResponseInputMessageContentList,
ResponseOutputMessage,
ResponseOutputText,
} from "openai/resources/responses/responses";
import {AiProvider} from "../model/ai-provider";
import {MessageStore} from "../common/message-store";
import {collectReplyChainText} from "../util/utils";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part";
import type {UserAiResponseLanguage} from "../common/user-ai-settings";
import {getResponseLanguageInstruction} from "../common/user-ai-settings";
import {pythonInterpreterToolPrompt} from "./tools/python-interpretator";
import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared";
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {OllamaChatMessage} from "./ollama-chat-message";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
export type ConversationAttachment = {
kind: AttachmentKind;
data: string;
mimeType: string;
fileName?: string;
};
export type ConversationTurn = {
bot: boolean;
name?: string;
langCode?: string;
userName?: string;
content: string;
deletedByBotAt?: number | null;
attachments: ConversationAttachment[];
documentNames?: string[];
};
export type ConversationSnapshot = {
turns: ConversationTurn[];
imageCount: number;
systemInstruction: string;
};
function buildAttachmentFromImage(image: MessageImagePart): ConversationAttachment {
return {
kind: "image",
data: image.data,
mimeType: image.mimeType || "image/jpeg",
fileName: "image.jpg",
};
}
function buildAttachmentFromAudio(audio: MessageAudioPart): ConversationAttachment {
return {
kind: "audio",
data: audio.data,
mimeType: audio.mimeType || "audio/mpeg",
fileName: "audio.bin",
};
}
function buildConversationAttachments(part: MessagePart): ConversationAttachment[] {
const attachments: ConversationAttachment[] = [];
for (const image of part.imageParts ?? []) {
attachments.push(buildAttachmentFromImage(image));
}
for (const audio of part.audioParts ?? []) {
attachments.push(buildAttachmentFromAudio(audio));
}
for (const document of part.documents ?? []) {
attachments.push({
kind: "document",
data: document,
mimeType: "application/octet-stream",
fileName: "document.bin",
});
}
for (const video of part.videos ?? []) {
attachments.push({
kind: "video",
data: video,
mimeType: "video/mp4",
fileName: "video.mp4",
});
}
for (const videoNote of part.videoNotes ?? []) {
attachments.push({
kind: "video-note",
data: videoNote,
mimeType: "video/mp4",
fileName: "video-note.mp4",
});
}
return attachments;
}
function attachmentCounts(attachments: ConversationAttachment[]): Record<AttachmentKind, number> {
return attachments.reduce<Record<AttachmentKind, number>>((counts, attachment) => {
counts[attachment.kind] += 1;
return counts;
}, {
image: 0,
document: 0,
audio: 0,
video: 0,
"video-note": 0,
});
}
function attachmentSummary(attachments: ConversationAttachment[]): string {
const counts = attachmentCounts(attachments);
const lines = Object.entries(counts)
.filter(([, count]) => count > 0)
.map(([kind, count]) => `- ${kind}: ${count}`);
if (!lines.length) return "";
return ["[attachments]:", ...lines].join("\n");
}
function namesSummary(kind: string, names: string[]): string {
const filtered = names.map(name => name.trim()).filter(Boolean);
if (!filtered.length) return "";
return [`[${kind}]:`, ...filtered.map(name => `- ${name}`)].join("\n");
}
function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> {
if (bot) return new Set<AttachmentKind>();
switch (provider) {
case AiProvider.OPENAI:
return new Set<AttachmentKind>(["image", "audio", "document", "video", "video-note"]);
case AiProvider.MISTRAL:
return new Set<AttachmentKind>(["image"]);
case AiProvider.OLLAMA:
return new Set<AttachmentKind>();
}
return new Set<AttachmentKind>();
}
function renderContentText(
turn: ConversationTurn,
provider: AiProvider,
includeNames: boolean,
): string {
const parts = [turn.content.trim()];
const supported = supportedAttachmentKinds(provider, turn.bot);
const unsupported = turn.attachments.filter(attachment => !supported.has(attachment.kind));
if (includeNames && !turn.bot) {
parts.unshift([
"[user_info]:",
`name: ${turn.name ?? ""}`.trimEnd(),
`username: @${turn.userName ?? ""}`.trimEnd(),
"",
].join("\n"));
}
if (turn.bot && turn.deletedByBotAt) {
parts.push("[message_state]: deleted_by_bot");
}
if (turn.documentNames?.length) {
parts.push(namesSummary("documents", turn.documentNames));
}
if (unsupported.length) {
parts.push(attachmentSummary(unsupported));
}
return parts.filter(part => part.trim().length > 0).join("\n\n").trim();
}
function buildOpenAiOutputText(text: string): ResponseOutputText {
return {
type: "output_text",
text,
annotations: [],
};
}
function buildOpenAiInputMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage {
const text = renderContentText(turn, provider, includeNames);
const content: ResponseInputMessageContentList = [
{
type: "input_text",
text,
},
];
for (const attachment of turn.attachments.filter(item => item.kind === "image")) {
content.push({
type: "input_image",
image_url: `data:${attachment.mimeType};base64,${attachment.data}`,
detail: "auto",
});
}
return {
type: "message",
role: "user",
content,
};
}
function buildOpenAiAssistantMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): ResponseOutputMessage {
const text = renderContentText(turn, provider, includeNames);
return {
id: `msg_${Date.now()}`,
type: "message",
role: "assistant",
status: "completed",
phase: "final_answer",
content: [buildOpenAiOutputText(text)],
};
}
function buildOpenAiMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage {
return turn.bot
? buildOpenAiAssistantMessage(turn, provider, includeNames)
: buildOpenAiInputMessage(turn, provider, includeNames);
}
function buildMistralMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): MistralChatMessage {
const text = renderContentText(turn, provider, includeNames);
if (turn.bot) {
return {
role: "assistant",
content: [{type: "text", text}],
};
}
return {
role: "user",
content: [
{type: "text", text},
...turn.attachments
.filter(attachment => attachment.kind === "image")
.map(attachment => ({
type: "image_url" as const,
imageUrl: `data:${attachment.mimeType};base64,${attachment.data}`,
})),
],
};
}
function buildOllamaMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OllamaChatMessage {
const text = renderContentText(turn, provider, includeNames);
return {
role: turn.bot ? "assistant" : "user",
content: text,
images: turn.bot ? undefined : turn.attachments.filter(attachment => attachment.kind === "image").map(attachment => attachment.data),
};
}
function buildSystemInstruction(
config: RuntimeConfigSnapshot,
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string {
return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n");
}
export async function buildConversationSnapshot(
msg: Message,
textOverride: string,
downloads: AiDownloadedFile[],
config: RuntimeConfigSnapshot,
runtimeTarget: AiRuntimeTarget,
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
): Promise<ConversationSnapshot> {
const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id);
const messageParts = await collectReplyChainText({triggerMsg: storedMsg ?? msg, downloads});
if (messageParts.length && textOverride.trim()) {
const latest = messageParts[0];
if (!latest.bot) latest.content = textOverride.trim();
}
const turns = messageParts
.reverse()
.map(part => ({
bot: part.bot,
name: part.name,
langCode: part.langCode,
userName: part.userName,
content: part.content,
deletedByBotAt: part.deletedByBotAt,
attachments: buildConversationAttachments(part),
documentNames: part.documentNames,
}));
const imageCount = turns.reduce((sum, turn) => {
if (turn.bot) return sum;
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
}, 0);
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
return {
turns,
imageCount,
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
};
}
export function serializeConversationSnapshot(
snapshot: ConversationSnapshot,
provider: AiProvider,
includeNames: boolean,
): { chatMessages: Array<OpenAIChatMessage | MistralChatMessage | OllamaChatMessage>; imageCount: number } {
switch (provider) {
case AiProvider.OPENAI: {
const messages = snapshot.turns.map(turn => buildOpenAiMessage(turn, provider, includeNames));
if (snapshot.systemInstruction) {
messages.unshift({role: "system", content: snapshot.systemInstruction, type: "message"});
}
return {chatMessages: messages, imageCount: snapshot.imageCount};
}
case AiProvider.MISTRAL: {
const messages = snapshot.turns.map(turn => buildMistralMessage(turn, provider, includeNames));
if (snapshot.systemInstruction) {
messages.unshift({role: "system", content: snapshot.systemInstruction});
}
return {chatMessages: messages, imageCount: snapshot.imageCount};
}
case AiProvider.OLLAMA: {
const messages = snapshot.turns.map(turn => buildOllamaMessage(turn, provider, includeNames));
if (snapshot.systemInstruction) {
messages.unshift({role: "system", content: snapshot.systemInstruction});
}
return {chatMessages: messages, imageCount: snapshot.imageCount};
}
}
return {chatMessages: [], imageCount: snapshot.imageCount};
}
+102
View File
@@ -0,0 +1,102 @@
import {AiProvider} from "../model/ai-provider";
import {AiDownloadedFile} from "./telegram-attachments";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {deleteMistralLibrary, RuntimeConfigSnapshot, MistralDocumentReference, prepareMistralDocuments} from "./unified-ai-runner.shared";
import {MistralChatMessage} from "./mistral-chat-message";
import {OllamaChatMessage} from "./ollama-chat-message";
import {prepareOllamaDocumentRag} from "./ollama-rag";
import type {OllamaRagArtifactDetails} from "./ollama-rag";
import {OpenAIChatMessage} from "./openai-chat-message";
import {createOpenAiClient, createOllamaClient} from "./ai-runtime-target";
import {prepareOpenAiDocumentRag} from "./unified-ai-runner.openai";
export type PreparedDocumentRag =
| {
provider: AiProvider.OPENAI;
vectorStoreIds: string[];
uploadedFileIds: string[];
cleanup: () => Promise<void>;
}
| {
provider: AiProvider.MISTRAL;
documents: MistralDocumentReference[];
libraryId?: string;
cleanup: () => Promise<void>;
}
| {
provider: AiProvider.OLLAMA;
prepared: boolean;
artifact?: OllamaRagArtifactDetails;
cleanup: () => Promise<void>;
};
export async function prepareDocumentRag(
provider: AiProvider,
downloads: AiDownloadedFile[],
messages: Array<OpenAIChatMessage | MistralChatMessage | OllamaChatMessage>,
streamMessage: TelegramStreamMessage,
config: RuntimeConfigSnapshot,
signal: AbortSignal,
userQuery: string,
): Promise<PreparedDocumentRag | undefined> {
const documents = downloads.filter(download => download.kind === "document");
if (!documents.length) return undefined;
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
return undefined;
}
switch (provider) {
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(config.openAiChatTarget);
const prepared = await prepareOpenAiDocumentRag(openAi, documents);
if (!prepared) {
throw new Error("OpenAI document RAG preparation returned no context.");
}
return {
provider,
vectorStoreIds: prepared.vectorStoreIds,
uploadedFileIds: prepared.uploadedFileIds,
cleanup: prepared.cleanup,
};
}
case AiProvider.MISTRAL: {
const prepared = await prepareMistralDocuments(documents, messages as MistralChatMessage[], streamMessage, config.mistralChatTarget, signal);
return {
provider,
documents: prepared.documents,
libraryId: prepared.libraryId,
cleanup: async () => {
await deleteMistralLibrary(prepared.libraryId, config.mistralChatTarget);
},
};
}
case AiProvider.OLLAMA: {
const prepared = await prepareOllamaDocumentRag({
downloads,
messages: messages as OllamaChatMessage[],
userQuery,
message: streamMessage,
config: {
embeddingModel: config.ollamaDocumentsTarget.model,
embeddingClient: createOllamaClient(config.ollamaDocumentsTarget),
chunkSize: config.ollamaRagChunkSize,
chunkOverlap: config.ollamaRagChunkOverlap,
topK: config.ollamaRagTopK,
maxContextChars: config.ollamaRagMaxContextChars,
minScore: config.ollamaRagMinScore,
maxArchiveFiles: config.ollamaRagMaxArchiveFiles,
maxArchiveBytes: config.ollamaRagMaxArchiveBytes,
maxArchiveDepth: config.ollamaRagMaxArchiveDepth,
},
});
return {
provider,
prepared: prepared.prepared,
artifact: prepared.artifact,
cleanup: async () => undefined,
};
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import {AiProvider} from "../model/ai-provider";
import type {StoredAttachment} from "../model/stored-attachment";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistFinalTextArtifactAttachment(params: {
provider: AiProvider;
model: string;
text: string;
chatId: number;
messageId: number;
}): Promise<StoredAttachment | undefined> {
const text = params.text.trim();
if (!text) return Promise.resolve(undefined);
return await persistInternalJsonArtifactAttachment({
artifactKind: "final_text",
fileNamePrefix: "final-text",
chatId: params.chatId,
messageId: params.messageId,
payload: {
provider: params.provider,
model: params.model,
text,
},
metadata: {
provider: params.provider,
model: params.model,
textChars: text.length,
},
});
}
export async function persistErrorArtifactAttachment(params: {
provider: AiProvider;
model: string;
message: string;
recoverable: boolean;
chatId: number;
messageId: number;
}): Promise<StoredAttachment> {
return await persistInternalJsonArtifactAttachment({
artifactKind: "error",
fileNamePrefix: "error",
chatId: params.chatId,
messageId: params.messageId,
payload: {
provider: params.provider,
model: params.model,
message: params.message,
recoverable: params.recoverable,
},
metadata: {
provider: params.provider,
model: params.model,
recoverable: params.recoverable,
},
});
}
+102
View File
@@ -0,0 +1,102 @@
import fs from "node:fs";
import path from "node:path";
import {createHash} from "node:crypto";
import {Environment} from "../common/environment";
import {ArtifactStore} from "../common/artifact-store";
import type {StoredAttachment} from "../model/stored-attachment";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PipelineArtifactKind} from "./user-request-pipeline";
export type InternalArtifactAttachmentInput = {
artifactKind: PipelineArtifactKind;
fileNamePrefix: string;
chatId: number;
messageId: number;
requestId?: string;
payload: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
const INTERNAL_ARTIFACT_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
function sha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}
function safeFileNamePart(value: string): string {
return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 80) || "artifact";
}
export async function persistInternalJsonArtifactAttachment(input: InternalArtifactAttachmentInput): Promise<StoredAttachment> {
const createdAt = new Date().toISOString();
const buffer = Buffer.from(JSON.stringify({
artifactKind: input.artifactKind,
createdAt,
...input.payload,
}, null, 2), "utf8");
if (buffer.length > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
throw new Error(`Internal ${input.artifactKind} artifact is larger than ${PIPELINE_ATTACHMENT_LIMIT_BYTES} bytes.`);
}
const dir = path.join(Environment.DATA_PATH, "cache", "internal-artifacts", input.artifactKind);
fs.mkdirSync(dir, {recursive: true});
const digest = sha256(buffer);
const fileName = `${safeFileNamePart(input.fileNamePrefix)}-${input.chatId}-${input.messageId}-${Date.now()}.json`;
const cachePath = path.join(dir, fileName);
fs.writeFileSync(cachePath, buffer);
const attachment: StoredAttachment = {
kind: "document",
fileId: cachePath,
fileUniqueId: digest,
fileName,
mimeType: "application/json",
cachePath,
sizeBytes: buffer.length,
sha256: digest,
scope: "internal_artifact",
artifactKind: input.artifactKind,
metadata: input.metadata,
};
await ArtifactStore.put({
id: "",
requestId: input.requestId ?? `message:${input.chatId}:${input.messageId}:${input.artifactKind}`,
messageChatId: input.chatId,
messageId: input.messageId,
kind: input.artifactKind,
stage: input.artifactKind,
attachmentId: cachePath,
payload: {
artifactKind: input.artifactKind,
createdAt,
...input.payload,
},
createdAt,
attachment,
});
return attachment;
}
export function cleanupInternalArtifactCache(now = Date.now()): void {
const root = path.join(Environment.DATA_PATH, "cache", "internal-artifacts");
if (!fs.existsSync(root)) return;
const cutoff = now - INTERNAL_ARTIFACT_RETENTION_MS;
for (const artifactKind of fs.readdirSync(root, {withFileTypes: true})) {
if (!artifactKind.isDirectory()) continue;
const dir = path.join(root, artifactKind.name);
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
if (!entry.isFile()) continue;
const filePath = path.join(dir, entry.name);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.rmSync(filePath, {force: true});
}
}
}
}
+421
View File
@@ -0,0 +1,421 @@
import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process";
import type {BoundaryValue} from "../../common/boundary-types.js";
import {toolsLogger} from "../tools/tool-logger.js";
import type {McpServerConfig} from "./mcp-config.js";
const logger = toolsLogger.child("mcp");
const MCP_PROTOCOL_VERSION = "2025-06-18";
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
export type McpToolDefinition = {
name: string;
description?: string;
inputSchema?: BoundaryValue;
};
type JsonRpcRequest = {
jsonrpc: "2.0";
id: number;
method: string;
params?: BoundaryValue;
};
type JsonRpcNotification = {
jsonrpc: "2.0";
method: string;
params?: BoundaryValue;
};
type JsonRpcResponse = {
jsonrpc?: "2.0";
id?: BoundaryValue;
result?: BoundaryValue;
error?: {
code?: number;
message?: string;
data?: BoundaryValue;
};
};
interface JsonRpcTransport {
request(method: string, params?: BoundaryValue): Promise<BoundaryValue>;
notify(method: string, params?: BoundaryValue): Promise<void>;
close(): Promise<void>;
}
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toJsonRpcResponse(value: BoundaryValue): JsonRpcResponse | undefined {
if (!isRecord(value)) return undefined;
if (value.jsonrpc !== undefined && value.jsonrpc !== "2.0") return undefined;
return value as JsonRpcResponse;
}
function extractJsonRpcResult(response: BoundaryValue, expectedId?: number): BoundaryValue {
const parsed = toJsonRpcResponse(response);
if (!parsed) {
throw new Error("Invalid JSON-RPC response from MCP server.");
}
if (parsed.error) {
throw new Error(parsed.error.message || "MCP server returned an error.");
}
if (expectedId !== undefined && parsed.id !== undefined && parsed.id !== expectedId) {
throw new Error(`Unexpected JSON-RPC response id from MCP server. Expected ${expectedId}, got ${String(parsed.id)}.`);
}
return parsed.result ?? {};
}
function parseSsePayload(text: string): BoundaryValue[] {
const events: string[] = [];
let current: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line) {
if (current.length) {
events.push(current.join("\n"));
current = [];
}
continue;
}
if (line.startsWith("data:")) {
current.push(line.slice(5).replace(/^ /, ""));
}
}
if (current.length) {
events.push(current.join("\n"));
}
return events.map(event => {
try {
return JSON.parse(event) as BoundaryValue;
} catch {
return undefined;
}
}).filter((event): event is BoundaryValue => event !== undefined);
}
function timeoutPromise<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});
}
class StdioJsonRpcTransport implements JsonRpcTransport {
private readonly process: ChildProcessWithoutNullStreams;
private readonly pending = new Map<number, {resolve: (value: BoundaryValue) => void; reject: (error: Error) => void;}>();
private buffer = "";
private nextId = 1;
constructor(private readonly config: McpServerConfig) {
if (!config.command) {
throw new Error(`MCP stdio server '${config.name}' is missing command.`);
}
this.process = spawn(config.command, config.args ?? [], {
cwd: config.cwd,
env: {
...process.env,
...config.env,
},
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.process.stdout.on("data", chunk => this.handleStdout(chunk));
this.process.stderr.on("data", chunk => {
const text = chunk.toString("utf8").trim();
if (text) logger.debug("stdio.stderr", {server: config.name, text});
});
this.process.on("error", error => this.failAll(error));
this.process.on("exit", code => this.failAll(new Error(`MCP stdio server '${config.name}' exited with code ${code ?? "unknown"}.`)));
}
private handleStdout(chunk: Buffer): void {
this.buffer += chunk.toString("utf8");
let newlineIndex = this.buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
newlineIndex = this.buffer.indexOf("\n");
if (!line) continue;
try {
const message = JSON.parse(line) as JsonRpcResponse | JsonRpcNotification;
if ("id" in message && message.id !== undefined) {
const pending = this.pending.get(Number(message.id));
if (pending) {
this.pending.delete(Number(message.id));
if ("error" in message && message.error) {
pending.reject(new Error(message.error.message || "MCP stdio request failed."));
} else {
pending.resolve((message as JsonRpcResponse).result ?? {});
}
}
continue;
}
if ("method" in message) {
logger.debug("stdio.notification", {server: this.config.name, method: message.method});
}
} catch (error) {
logger.warn("stdio.parse_failed", {
server: this.config.name,
line: line.slice(0, 500),
error: error instanceof Error ? error.message : String(error),
});
}
}
}
private failAll(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
if (this.process.exitCode !== null) {
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
}
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
const result = new Promise<BoundaryValue>((resolve, reject) => {
this.pending.set(id, {resolve, reject});
});
this.process.stdin.write(`${JSON.stringify(request)}\n`);
return timeoutPromise(result, this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, `${this.config.name}.${method}`);
}
async notify(method: string, params?: BoundaryValue): Promise<void> {
if (this.process.exitCode !== null) {
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
}
const notification: JsonRpcNotification = {
jsonrpc: "2.0",
method,
params,
};
this.process.stdin.write(`${JSON.stringify(notification)}\n`);
}
async close(): Promise<void> {
this.failAll(new Error(`MCP stdio server '${this.config.name}' closed.`));
if (!this.process.killed) {
this.process.kill();
}
}
}
class HttpJsonRpcTransport implements JsonRpcTransport {
private nextId = 1;
private sessionId?: string;
constructor(private readonly config: McpServerConfig) {
if (!config.url) {
throw new Error(`MCP HTTP server '${config.name}' is missing url.`);
}
}
private async post(body: BoundaryValue): Promise<Response> {
const controller = new AbortController();
const timeoutMs = this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(this.config.url!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
...(this.sessionId ? {"Mcp-Session-Id": this.sessionId} : {}),
...(this.config.headers ?? {}),
},
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(timeoutId));
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
const response = await this.post(request);
const sessionId = response.headers.get("Mcp-Session-Id");
if (sessionId) {
this.sessionId = sessionId;
}
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`MCP HTTP server '${this.config.name}' returned ${response.status}: ${errorText || response.statusText}`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
let payload: BoundaryValue;
if (contentType.includes("text/event-stream")) {
const text = await response.text();
const messages = parseSsePayload(text);
const responseMessage = messages.map(toJsonRpcResponse).find(message => message?.id === id && (message.result !== undefined || message.error));
payload = extractJsonRpcResult(responseMessage ?? messages[0] ?? {}, id);
} else {
payload = extractJsonRpcResult(await response.json() as BoundaryValue, id);
}
return payload;
}
async notify(method: string, params?: BoundaryValue): Promise<void> {
const response = await this.post({
jsonrpc: "2.0",
method,
params,
});
const sessionId = response.headers.get("Mcp-Session-Id");
if (sessionId) {
this.sessionId = sessionId;
}
if (!response.ok && response.status !== 202) {
const errorText = await response.text().catch(() => "");
throw new Error(`MCP HTTP notification failed for '${this.config.name}' with ${response.status}: ${errorText || response.statusText}`);
}
}
async close(): Promise<void> {
return;
}
}
function createTransport(config: McpServerConfig): JsonRpcTransport {
return config.transport === "stdio"
? new StdioJsonRpcTransport(config)
: new HttpJsonRpcTransport(config);
}
function normalizeToolResultContent(content: BoundaryValue): string {
if (content === undefined || content === null) return "";
if (typeof content === "string") return content;
if (typeof content === "number" || typeof content === "boolean") return String(content);
if (Array.isArray(content)) return content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
if (!isRecord(content)) return JSON.stringify(content);
if (content.type === "text" && typeof content.text === "string") return content.text;
if (content.type === "image") {
return `[image ${typeof content.mimeType === "string" ? content.mimeType : "unknown"}]`;
}
if (content.type === "resource" && isRecord(content.resource)) {
if (typeof content.resource.text === "string") return content.resource.text;
return JSON.stringify(content.resource);
}
return JSON.stringify(content);
}
export class McpClient {
private readonly transport: JsonRpcTransport;
private initialized = false;
constructor(readonly config: McpServerConfig) {
this.transport = createTransport(config);
}
async initialize(): Promise<void> {
if (this.initialized) return;
await this.transport.request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: "tg-chat-bot",
version: "1.0.0",
},
capabilities: {},
});
await this.transport.notify("notifications/initialized");
this.initialized = true;
}
async listTools(): Promise<McpToolDefinition[]> {
await this.initialize();
const result = await this.transport.request("tools/list");
if (!isRecord(result)) return [];
const tools = Array.isArray(result.tools) ? result.tools : [];
return tools.flatMap(tool => {
if (!isRecord(tool) || typeof tool.name !== "string") return [];
return [{
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
inputSchema: tool.inputSchema,
}];
});
}
async callTool(name: string, args?: BoundaryValue): Promise<string> {
await this.initialize();
const result = await this.transport.request("tools/call", {
name,
arguments: args ?? {},
});
if (!isRecord(result)) {
return normalizeToolResultContent(result);
}
const content = Array.isArray(result.content) ? result.content : [];
const text = content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
if (result.isError) {
return text ? `[MCP error] ${text}` : "[MCP error]";
}
return text || JSON.stringify(result);
}
async close(): Promise<void> {
await this.transport.close();
}
}
+106
View File
@@ -0,0 +1,106 @@
import type {BoundaryValue} from "../../common/boundary-types.js";
export type McpTransport = "stdio" | "http";
export type McpServerConfig = {
name: string;
transport: McpTransport;
command?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
timeoutMs?: number;
};
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function toStringRecord(value: BoundaryValue): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const result: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") {
result[key] = String(entry);
}
}
return Object.keys(result).length ? result : undefined;
}
function toStringArray(value: BoundaryValue): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const items = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.map(item => item.trim());
return items.length ? items : undefined;
}
function toPositiveInt(value: BoundaryValue): number | undefined {
const n = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(n) || n <= 0) return undefined;
return Math.floor(n);
}
function normalizeServerConfig(value: BoundaryValue, fallbackName?: string): McpServerConfig | undefined {
if (!isRecord(value)) return undefined;
const name = asString(value.name) ?? fallbackName;
const transportRaw = asString(value.transport);
const transport = transportRaw === "http" || transportRaw === "stdio" ? transportRaw : undefined;
if (!name || !transport) return undefined;
return {
name,
transport,
command: asString(value.command),
args: toStringArray(value.args),
cwd: asString(value.cwd),
env: toStringRecord(value.env),
url: asString(value.url),
headers: toStringRecord(value.headers),
timeoutMs: toPositiveInt(value.timeoutMs),
};
}
export function parseMcpServerConfigs(raw: string | undefined): McpServerConfig[] {
if (!raw?.trim()) return [];
let parsed: BoundaryValue;
try {
parsed = JSON.parse(raw) as BoundaryValue;
} catch (error) {
throw new Error(`Invalid MCP_SERVERS JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (Array.isArray(parsed)) {
return parsed.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
}
if (!isRecord(parsed)) {
return [];
}
if (Array.isArray(parsed.servers)) {
return parsed.servers.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
}
if (isRecord(parsed.mcpServers)) {
return Object.entries(parsed.mcpServers).flatMap(([name, item]) => normalizeServerConfig(item, name) ? [normalizeServerConfig(item, name)!] : []);
}
const single = normalizeServerConfig(parsed);
return single ? [single] : [];
}
+123
View File
@@ -0,0 +1,123 @@
import type {AiJsonValue, AiToolParameters} from "../tool-types.js";
import type {BoundaryValue} from "../../common/boundary-types.js";
type JsonSchemaRecord = Record<string, BoundaryValue>;
function isRecord(value: BoundaryValue): value is JsonSchemaRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toAiJsonValue(value: BoundaryValue): AiJsonValue | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.map(item => toAiJsonValue(item) ?? null);
}
if (!isRecord(value)) return undefined;
const result: Record<string, AiJsonValue> = {};
for (const [key, entry] of Object.entries(value)) {
const normalized = toAiJsonValue(entry);
if (normalized !== undefined) {
result[key] = normalized;
}
}
return result;
}
function normalizeType(value: BoundaryValue): AiToolParameters["type"] | undefined {
const candidates = Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: typeof value === "string"
? [value]
: [];
const prioritized = candidates.find(item => item !== "null") ?? candidates[0];
if (!prioritized) return undefined;
switch (prioritized) {
case "object":
case "string":
case "number":
case "integer":
case "boolean":
case "array":
return prioritized;
default:
return undefined;
}
}
export function convertJsonSchemaToToolParameters(schema: BoundaryValue): AiToolParameters | undefined {
if (!isRecord(schema)) return undefined;
const declaredType = normalizeType(schema.type);
const inferredType = declaredType
?? (schema.properties !== undefined || schema.additionalProperties !== undefined ? "object" : undefined)
?? (schema.items !== undefined ? "array" : undefined)
?? "object";
const result: AiToolParameters = {
type: inferredType,
};
const description = typeof schema.description === "string" && schema.description.trim().length > 0
? schema.description.trim()
: undefined;
if (description) result.description = description;
const defaultValue = toAiJsonValue(schema.default);
if (defaultValue !== undefined) result.default = defaultValue;
if (Array.isArray(schema.enum)) {
const enumValues = schema.enum
.filter((item): item is string => typeof item === "string" && item.length > 0);
if (enumValues.length) result.enum = enumValues;
}
if (typeof schema.minItems === "number") result.minItems = schema.minItems;
if (typeof schema.maxItems === "number") result.maxItems = schema.maxItems;
if (typeof schema.minimum === "number") result.minimum = schema.minimum;
if (typeof schema.maximum === "number") result.maximum = schema.maximum;
if (Array.isArray(schema.required)) {
const required = schema.required.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
if (required.length) result.required = required;
}
if (inferredType === "object" || schema.properties !== undefined || schema.additionalProperties !== undefined) {
if (isRecord(schema.properties)) {
const properties: Record<string, AiToolParameters> = {};
for (const [key, value] of Object.entries(schema.properties)) {
const converted = convertJsonSchemaToToolParameters(value);
if (converted) properties[key] = converted;
}
if (Object.keys(properties).length) result.properties = properties;
}
if (schema.additionalProperties !== undefined) {
result.additionalProperties = typeof schema.additionalProperties === "boolean"
? schema.additionalProperties
: convertJsonSchemaToToolParameters(schema.additionalProperties);
}
}
if (inferredType === "array" || schema.items !== undefined) {
if (Array.isArray(schema.items)) {
const firstItem = schema.items[0];
if (firstItem !== undefined) {
const converted = convertJsonSchemaToToolParameters(firstItem);
if (converted) result.items = converted;
}
} else {
const converted = convertJsonSchemaToToolParameters(schema.items);
if (converted) result.items = converted;
}
}
return result;
}
+165
View File
@@ -0,0 +1,165 @@
import {Environment} from "../../common/environment.js";
import type {AiTool} from "../tool-types.js";
import type {ToolHandler} from "../tools/types.js";
import {normalizeToolArguments} from "../tools/utils.js";
import {toolsLogger} from "../tools/tool-logger.js";
import {convertJsonSchemaToToolParameters} from "./mcp-json-schema.js";
import {McpClient, type McpToolDefinition} from "./mcp-client.js";
import {parseMcpServerConfigs, type McpServerConfig} from "./mcp-config.js";
const logger = toolsLogger.child("mcp-registry");
type McpToolBinding = {
server: McpServerConfig;
client: McpClient;
remoteToolName: string;
localToolName: string;
tool: AiTool;
};
type McpInitSummary = {
servers: number;
loadedServers: number;
tools: number;
failedServers: string[];
};
const toolBindings = new Map<string, McpToolBinding>();
const clients = new Map<string, McpClient>();
let initPromise: Promise<McpInitSummary> | undefined;
function sanitizeSegment(value: string): string {
return value
.trim()
.replace(/[^a-zA-Z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "") || "tool";
}
function buildLocalToolName(serverName: string, toolName: string): string {
return `mcp__${sanitizeSegment(serverName)}__${sanitizeSegment(toolName)}`;
}
function buildTool(serverName: string, tool: McpToolDefinition): AiTool {
const localName = buildLocalToolName(serverName, tool.name);
const description = tool.description?.trim()
? `[MCP ${serverName}] ${tool.description.trim()}`
: `[MCP ${serverName}] ${tool.name}`;
return {
type: "function",
function: {
name: localName,
description,
parameters: convertJsonSchemaToToolParameters(tool.inputSchema),
},
};
}
async function loadServer(config: McpServerConfig): Promise<{loaded: boolean; tools: number}> {
const client = new McpClient(config);
clients.set(config.name, client);
try {
const remoteTools = await client.listTools();
let loaded = 0;
for (const remoteTool of remoteTools) {
const localName = buildLocalToolName(config.name, remoteTool.name);
if (toolBindings.has(localName)) {
logger.warn("tool.duplicate", {
server: config.name,
tool: remoteTool.name,
localName,
});
continue;
}
const binding: McpToolBinding = {
server: config,
client,
remoteToolName: remoteTool.name,
localToolName: localName,
tool: buildTool(config.name, remoteTool),
};
toolBindings.set(localName, binding);
loaded += 1;
}
logger.info("server.loaded", {
server: config.name,
transport: config.transport,
tools: loaded,
});
return {loaded: true, tools: loaded};
} catch (error) {
logger.error("server.failed", {
server: config.name,
transport: config.transport,
error: error instanceof Error ? error.message : String(error),
});
await client.close().catch(() => undefined);
clients.delete(config.name);
return {loaded: false, tools: 0};
}
}
export async function initializeMcpTools(): Promise<McpInitSummary> {
if (initPromise) return initPromise;
initPromise = (async () => {
toolBindings.clear();
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
clients.clear();
const configs = parseMcpServerConfigs(Environment.MCP_SERVERS);
const results = await Promise.all(configs.map(config => loadServer(config)));
return {
servers: configs.length,
loadedServers: results.filter(result => result.loaded).length,
tools: [...results].reduce((sum, result) => sum + result.tools, 0),
failedServers: configs.filter((_, index) => !results[index]?.loaded).map(config => config.name),
};
})();
try {
const summary = await initPromise;
logger.info("init.done", summary);
return summary;
} catch (error) {
initPromise = undefined;
logger.error("init.failed", {error: error instanceof Error ? error.message : String(error)});
throw error;
}
}
export function getMcpTools(): AiTool[] {
return [...toolBindings.values()].map(binding => binding.tool);
}
export function getMcpToolHandlers(): Record<string, ToolHandler> {
const handlers: Record<string, ToolHandler> = {};
for (const binding of toolBindings.values()) {
handlers[binding.localToolName] = async args => {
const normalized = normalizeToolArguments(args, undefined);
return binding.client.callTool(binding.remoteToolName, normalized);
};
}
return handlers;
}
export function getMcpToolPrompts(_toolNames: string[]): string[] {
return [];
}
export async function shutdownMcpTools(): Promise<void> {
initPromise = undefined;
toolBindings.clear();
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
clients.clear();
}
+113
View File
@@ -0,0 +1,113 @@
export const MistralImageDetail = {
Low: "low",
Auto: "auto",
High: "high",
} as const;
export type MistralImageDetail = OpenEnum<typeof MistralImageDetail>;
declare const __brand: unique symbol;
export type Unrecognized<T> = T & { [__brand]: "unrecognized" };
export type OpenEnum<T extends Readonly<Record<string, string | number>>> =
| T[keyof T]
| Unrecognized<T[keyof T] extends number ? number : string>;
export const BuiltInConnectors = {
WebSearch: "web_search",
WebSearchPremium: "web_search_premium",
CodeInterpreter: "code_interpreter",
ImageGeneration: "image_generation",
DocumentLibrary: "document_library",
} as const;
export type BuiltInConnectors = OpenEnum<typeof BuiltInConnectors>;
export type MistralTextChunk = {
type: "text";
text: string;
};
export type MistralToolReferenceChunk = {
type: "tool_reference" | undefined;
tool: BuiltInConnectors | string;
title: string;
url?: string | null | undefined;
favicon?: string | null | undefined;
description?: string | null | undefined;
};
export type MistralThinkChunk = {
type: "thinking";
thinking: Array<MistralToolReferenceChunk | MistralTextChunk>;
signature?: string | null | undefined;
closed?: boolean | undefined;
};
export type MistralImageURLChunk = {
type: "image_url";
imageUrl: string | {
url: string;
detail?: MistralImageDetail | null | undefined;
};
}
export type MistralContentChunk =
| MistralTextChunk
| MistralThinkChunk
| MistralImageURLChunk
/*
| (ImageURLChunk & { type: "image_url" })
| (DocumentURLChunk & { type: "document_url" })
| (TextChunk & { type: "text" })
| (ReferenceChunk & { type: "reference" })
| (FileChunk & { type: "file" })
| (ThinkChunk & { type: "thinking" })
| AudioChunk
*/
export type MistralFunctionCall = {
name: string;
arguments: AiJsonObject | string;
};
export type MistralToolCall = {
id?: string | undefined;
type?: string | undefined;
function: MistralFunctionCall;
index?: number | undefined;
};
export type MistralAssistantMessage = {
role: "assistant";
content?: string | Array<MistralContentChunk> | null | undefined;
toolCalls?: Array<MistralToolCall> | null | undefined;
prefix?: boolean | undefined;
}
export type MistralSystemMessageContentChunks =
| MistralTextChunk
| MistralThinkChunk;
export type MistralSystemMessage = {
role: "system";
content: string;
}
export type MistralToolMessage = {
role: "tool";
content: string | Array<MistralContentChunk> | null;
toolCallId?: string | null | undefined;
name?: string | null | undefined;
};
export type MistralUserMessage = {
role: "user";
content: string | Array<MistralContentChunk> | null;
};
export type MistralChatMessage =
| MistralAssistantMessage
| MistralSystemMessage
| MistralToolMessage
| MistralUserMessage
import {AiJsonObject} from "./tool-types";
+5
View File
@@ -0,0 +1,5 @@
export async function runSingleModelRequest<T>(params: {
execute: () => Promise<T>;
}): Promise<T> {
return await params.execute();
}
+3
View File
@@ -0,0 +1,3 @@
import {Message} from "ollama";
export type OllamaChatMessage = Message;
+1439
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
import {isRecord} from "./unified-ai-runner.shared.js";
import type {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message.js";
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export function responseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
export function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAICompatibleChatMessage[] {
return messages.map((message): OpenAICompatibleChatMessage => {
if (message.role === "system") {
return {role: "system", content: responseContentToText(message.content)};
}
if (message.role === "assistant") {
const text = responseContentToText(message.content);
return text.length
? {role: "assistant", content: text}
: {role: "assistant", content: null};
}
const content = Array.isArray(message.content)
? (() => {
const parts = message.content.map((part): {type: "text"; text: string} | {type: "image_url"; image_url: {url: string}} => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
});
return parts.every(part => part.type === "text")
? parts.map(part => part.text).join("")
: parts;
})()
: message.content;
return {role: "user", content};
});
}
export function buildAssistantToolMessage(calls: ToolCallData[], text: string): OpenAICompatibleChatMessage {
return {
role: "assistant",
content: text,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
};
}
+22
View File
@@ -0,0 +1,22 @@
import type {
ResponseInputMessageContentList,
ResponseOutputMessage,
} from "openai/resources/responses/responses";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
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;
+74
View File
@@ -0,0 +1,74 @@
import {Message} from "typescript-telegram-bot-api";
import fs from "node:fs";
import path from "node:path";
import {bot} from "../index.js";
import {Environment} from "../common/environment.js";
import {logError} from "../util/utils.js";
import {errorMessage} from "./unified-ai-runner.shared.js";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files.js";
export async function tryToUploadFiles(
msg: Message,
toolResults: string[]
): Promise<
| { found: false }
| { found: true, uploaded: true }
| { found: boolean, uploaded: false, error: string, toolIndex: number }
> {
let sendFileAttachment: {
result: SendFileAttachmentResult & { success: true },
toolIndex: number
} | null = null;
let found = false;
try {
for (const [index, toolResult] of toolResults.entries()) {
const raw = JSON.parse(toolResult);
const res = SendFileAttachmentResultSchema.safeParse(raw);
if (res.success) {
found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: index};
}
}
}
if (!found) {
return {found: false};
}
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
const attachmentPath = attachmentRoot
? path.join(
attachmentRoot,
String(msg.from?.id),
sendFileAttachment?.result?.attachment?.relativePath ?? "",
)
: "";
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(attachmentPath),
});
return {found: true, uploaded: true};
} catch (e) {
logError(e instanceof Error ? e : String(e));
return {
found: found,
uploaded: false,
error: errorMessage(e instanceof Error ? e : String(e)),
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
}
}
+198
View File
@@ -0,0 +1,198 @@
import type {ToolCallData} from "./unified-ai-runner.shared.js";
import type {ResponseStreamEvent} from "openai/resources/responses/responses";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeToolCallId(value: unknown, fallback: string): string {
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
}
function normalizeToolArguments(value: unknown): string {
if (typeof value === "string") return value;
return JSON.stringify(value ?? {});
}
function normalizeToolArgumentsChunk(value: unknown): string {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
return JSON.stringify(value);
}
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
return output
.filter(item => isRecord(item) && item.type === "function_call" && (typeof item.call_id === "string" || typeof item.name === "string"))
.map((item, index) => ({
id: normalizeToolCallId(item.call_id, `openai_${index}`),
name: typeof item.name === "string" ? item.name : "",
argumentsText: normalizeToolArguments(item.arguments),
}))
.filter(call => call.name.length > 0);
}
export function extractOpenAiTextDelta(input: unknown): string {
const event = input as ResponseStreamEvent | undefined;
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
}
export function extractOpenAiChatTextDelta(input: unknown): string {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const content = delta && typeof delta.content === "string" ? delta.content : "";
return content;
}
export function normalizeStreamingTextDelta(existingText: string, deltaText: string): string {
if (!deltaText) return "";
if (!existingText) return deltaText;
if (deltaText.startsWith(existingText)) {
return deltaText.slice(existingText.length);
}
return deltaText;
}
export function extractOpenAiChatToolCalls(response: unknown): ToolCallData[] {
const record = isRecord(response) ? response : undefined;
const choice = record && Array.isArray(record.choices) && isRecord(record.choices[0]) ? record.choices[0] : undefined;
const message = isRecord(choice?.message) ? choice.message : undefined;
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
return toolCalls
.filter((item, index) => isRecord(item) && ((typeof item.id === "string") || typeof item.index === "number" || index >= 0))
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOpenAiChatStreamingToolCalls(input: unknown): ToolCallData[] {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
return toolCalls
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArgumentsChunk(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.id.length > 0);
}
export function mergeToolCallChunks(existing: ToolCallData[], chunks: ToolCallData[]): ToolCallData[] {
const merged = new Map<string, ToolCallData>(existing.map(call => [call.id, {...call}]));
for (const chunk of chunks) {
const current = merged.get(chunk.id);
if (!current) {
merged.set(chunk.id, {...chunk});
continue;
}
merged.set(chunk.id, {
id: current.id,
name: current.name || chunk.name,
argumentsText: current.argumentsText + (chunk.argumentsText ?? ""),
});
}
return [...merged.values()];
}
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
const event = input as ResponseStreamEvent | undefined;
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
return extractOpenAiToolCalls({
output: [{
type: "function_call",
call_id: event.item.call_id ?? event.item.id,
name: event.item.name,
arguments: event.item.arguments,
}],
});
}
return [];
}
export function extractMistralToolCalls(calls: unknown): ToolCallData[] {
const normalized = Array.isArray(calls)
? calls
: isRecord(calls) && (Array.isArray(calls.toolCalls) || Array.isArray(calls.tool_calls))
? (calls.toolCalls ?? calls.tool_calls)
: [];
if (!Array.isArray(normalized)) return [];
return normalized
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `mistral_${index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractMistralTextDelta(input: unknown): string {
const delta = isRecord(input) ? input : {};
const content = delta.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
return "";
}
export function extractOllamaToolCalls(calls: unknown): ToolCallData[] {
const normalized = Array.isArray(calls)
? calls
: isRecord(calls) && Array.isArray(calls.tool_calls)
? calls.tool_calls
: [];
if (!Array.isArray(normalized)) return [];
return normalized
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `ollama_${index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOllamaTextDelta(input: unknown): string {
const chunk = isRecord(input) ? input.message : undefined;
return isRecord(chunk) && typeof chunk.content === "string" ? chunk.content : "";
}
+196
View File
@@ -0,0 +1,196 @@
import {AiProvider} from "../model/ai-provider.js";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {RuntimeConfigSnapshot, ToolCallData} from "./unified-ai-runner.shared.js";
import {getMistralTools, getOllamaTools, getOpenAIResponsesTools, getOpenAICodeInterpreterTool} from "./tool-mappers.js";
import type {MistralChatMessage as MistralMessageType} from "./mistral-chat-message.js";
import type {OpenAIChatMessage as OpenAiMessageType} from "./openai-chat-message.js";
import type {Message as OllamaMessage} from "ollama";
import {
extractMistralTextDelta,
extractMistralToolCalls,
extractOllamaTextDelta,
extractOllamaToolCalls,
extractOpenAiTextDelta,
extractOpenAiStreamingToolCalls,
extractOpenAiToolCalls,
} from "./provider-adapter-contract.js";
export type ProviderRankToolOptions = {
forCreator?: boolean;
vectorStoreIds?: string[];
};
export interface AiProviderAdapter {
readonly provider: AiProvider;
mapMessages(messages: readonly unknown[]): unknown[];
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[];
callModel<T>(request: unknown, execute: () => Promise<T>): Promise<T>;
extractTextDelta(input: unknown): string;
extractToolCalls(input: unknown): ToolCallData[];
extractStreamingToolCalls(input: unknown): ToolCallData[];
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void;
finalize(): Promise<void>;
}
function appendOllamaToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
content: results[index] ?? "",
tool_name: call.name,
});
}
}
class OpenAiProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.OPENAI;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as OpenAiMessageType[];
}
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
const tools: BoundaryValue[] = [
...getOpenAIResponsesTools(options?.forCreator) as BoundaryValue[],
getOpenAICodeInterpreterTool() as BoundaryValue,
{
type: "image_generation",
model: config.openAiImageTarget.model,
size: "auto",
moderation: "low",
output_format: "png",
partial_images: 3,
},
{type: "web_search"},
];
if (options?.vectorStoreIds?.length) {
tools.unshift({
type: "file_search",
vector_store_ids: options.vectorStoreIds,
});
}
return tools;
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractOpenAiTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractOpenAiToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return extractOpenAiStreamingToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
type: "function_call_output",
call_id: call.id,
output: results[index] ?? "",
});
}
}
async finalize(): Promise<void> {
return;
}
}
class MistralProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.MISTRAL;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as MistralMessageType[];
}
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
return getMistralTools(options?.forCreator) as BoundaryValue[];
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractMistralTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractMistralToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return this.extractToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: results[index] ?? "",
});
}
}
async finalize(): Promise<void> {
return;
}
}
class OllamaProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.OLLAMA;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as OllamaMessage[];
}
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
return getOllamaTools(options?.forCreator) as BoundaryValue[];
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractOllamaTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractOllamaToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return this.extractToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
appendOllamaToolResults(messages, calls, results);
}
async finalize(): Promise<void> {
return;
}
}
export function getProviderAdapter(provider: AiProvider): AiProviderAdapter {
switch (provider) {
case AiProvider.OPENAI:
return new OpenAiProviderAdapter();
case AiProvider.MISTRAL:
return new MistralProviderAdapter();
case AiProvider.OLLAMA:
return new OllamaProviderAdapter();
}
}
+18
View File
@@ -0,0 +1,18 @@
import {AiProvider} from "../model/ai-provider";
const PROVIDER_ALIASES = new Map<string, AiProvider>([
["openai", AiProvider.OPENAI],
["chatgpt", AiProvider.OPENAI],
["gpt", AiProvider.OPENAI],
["mistral", AiProvider.MISTRAL],
["ollama", AiProvider.OLLAMA],
]);
export function parseProviderToken(token: string | undefined): AiProvider | undefined {
if (!token) return undefined;
return PROVIDER_ALIASES.get(token.toLowerCase().replace(/:$/, ""));
}
export function providerDisplayName(provider: AiProvider): string {
return provider.charAt(0) + provider.slice(1).toLowerCase();
}
+299
View File
@@ -0,0 +1,299 @@
import {AiProvider} from "../model/ai-provider";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {Environment} from "../common/environment";
import {logError} from "../util/utils";
import {AiCapabilityInfo} from "../model/ai-capability-info";
import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
import {
AiCapabilityName,
AiRuntimeTarget,
createMistralClient,
createOllamaClient,
createOpenAiClient,
resolveAiRuntimeTarget,
sameRuntimeEndpoint,
} from "./ai-runtime-target";
const CAPABILITY_NAMES: AiCapabilityName[] = [
"chat",
"vision",
"ocr",
"thinking",
"extendedThinking",
"tools",
"audio",
"documents",
"outputImages",
"speechToText",
"textToSpeech",
];
export function getRuntimeModel(provider: AiProvider): string {
switch (provider) {
case AiProvider.OLLAMA:
return Environment.OLLAMA_CHAT_MODEL;
case AiProvider.MISTRAL:
return Environment.MISTRAL_MODEL;
case AiProvider.OPENAI:
return Environment.OPENAI_MODEL;
}
}
export function setRuntimeModel(provider: AiProvider, model: string): void {
switch (provider) {
case AiProvider.OLLAMA:
Environment.OLLAMA_CHAT_MODEL = model;
break;
case AiProvider.MISTRAL:
Environment.MISTRAL_MODEL = model;
break;
case AiProvider.OPENAI:
Environment.OPENAI_MODEL = model;
break;
}
}
function capability(supported: boolean, target?: AiRuntimeTarget, runtimeTarget?: AiRuntimeTarget): AiCapabilityInfo {
const result: AiCapabilityInfo = {supported};
if (target?.model) result.model = target.model;
if (target) {
result.endpoint = {
provider: target.provider,
baseUrl: target.baseUrl,
external: runtimeTarget ? !sameRuntimeEndpoint(target, runtimeTarget) : false,
};
}
if (target && runtimeTarget && (target.model !== runtimeTarget.model || !sameRuntimeEndpoint(target, runtimeTarget))) {
result.external = true;
}
return result;
}
function buildCapabilities(overrides: Partial<Record<AiCapabilityName, AiCapabilityInfo>>): AiModelCapabilities {
return Object.assign(new AiModelCapabilities(), {
chat: {supported: false},
vision: {supported: false},
ocr: {supported: false},
thinking: {supported: false},
extendedThinking: {supported: false},
tools: {supported: false},
audio: {supported: false},
documents: {supported: false},
outputImages: {supported: false},
speechToText: {supported: false},
textToSpeech: {supported: false},
...overrides,
});
}
function lowerModelName(model: string): string {
return model.toLowerCase();
}
function isOpenAiTextModel(model: string): boolean {
const name = lowerModelName(model);
if (!name) return false;
if (/^(gpt-image|dall-e|tts-|whisper|text-embedding|text-moderation|omni-moderation)/.test(name)) return false;
if (name.includes("transcribe")) return false;
return /^(gpt-|o\d|chatgpt-|codex-|computer-use)/.test(name);
}
function isOpenAiReasoningModel(model: string): boolean {
const name = lowerModelName(model);
return /^o\d/.test(name) || name.startsWith("gpt-5");
}
function isOpenAiVisionModel(model: string): boolean {
const name = lowerModelName(model);
if (!isOpenAiTextModel(model)) return false;
if (name.startsWith("gpt-3.5")) return false;
if (name.includes("audio-preview") || name.includes("search-preview")) return false;
return true;
}
export async function getModelCapabilities(
provider: AiProvider,
model: string,
purpose: AiCapabilityName | "chat" = "chat",
): Promise<AiModelCapabilities | undefined> {
if (!model) return undefined;
try {
const runtimeTarget = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
const target = resolveAiRuntimeTarget(provider, purpose, model);
switch (provider) {
case AiProvider.OLLAMA: {
const ollama = createOllamaClient(target);
const info = await ollama.show({model});
const modelCapabilities = Array.isArray(info.capabilities) ? info.capabilities : [];
const has = (cap: string): boolean => modelCapabilities.includes(cap);
const audioSupported = isOllamaSpeechToTextModel(model);
const documentsTarget = resolveAiRuntimeTarget(provider, "documents");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(has("vision"), target, runtimeTarget),
ocr: capability(has("ocr"), target, runtimeTarget),
thinking: capability(has("thinking"), target, runtimeTarget),
extendedThinking: capability(has("thinking") && model.includes("gpt-oss"), target, runtimeTarget),
tools: capability(has("tools"), target, runtimeTarget),
audio: capability(audioSupported, target, runtimeTarget),
documents: capability(!!documentsTarget.model, documentsTarget, runtimeTarget),
speechToText: capability(audioSupported, target, runtimeTarget),
});
}
case AiProvider.MISTRAL: {
const mistral = createMistralClient(target);
const info = await mistral.models.retrieve({modelId: model});
const caps = info.type !== "UNKNOWN" ? info.capabilities : undefined;
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(!!caps?.vision, target, runtimeTarget),
ocr: capability(!!caps?.ocr, target, runtimeTarget),
thinking: capability(!!caps?.reasoning, target, runtimeTarget),
tools: capability(!!caps?.functionCalling, target, runtimeTarget),
audio: capability(!!caps?.audio, target, runtimeTarget),
documents: capability(true, target, runtimeTarget),
speechToText: capability(!!speechTarget.model || !!caps?.audioTranscription, speechTarget, runtimeTarget),
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
});
}
case AiProvider.OPENAI: {
const textModel = isOpenAiTextModel(model);
const reasoningModel = isOpenAiReasoningModel(model);
const imageTarget = resolveAiRuntimeTarget(provider, "outputImages");
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(isOpenAiVisionModel(model), target, runtimeTarget),
ocr: capability(isOpenAiVisionModel(model), target, runtimeTarget),
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),
});
}
}
} catch (e) {
logError(e instanceof Error ? e : String(e));
return undefined;
}
}
export async function getRuntimeCapabilities(
provider: AiProvider = Environment.DEFAULT_AI_PROVIDER,
model: string | undefined = getRuntimeModel(provider),
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({});
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;
const targetCapabilities = await getModelCapabilities(provider, target.model, capabilityName);
const capabilityInfo = targetCapabilities?.[capabilityName];
if (capabilityInfo) {
result[capabilityName] = capabilityInfo;
}
}
return result;
}
export async function getFormattedCapabilities(
provider: AiProvider = Environment.DEFAULT_AI_PROVIDER,
model: string | undefined = getRuntimeModel(provider),
caps?: AiModelCapabilities,
): Promise<string[]> {
if (!caps) caps = await getRuntimeCapabilities(provider, model);
const line = (title: string, value?: AiCapabilityInfo) => {
const state = value?.supported ? "✅" : "❌";
const external = value?.external ?? (!!value?.model && value.model !== model);
return Environment.getRuntimeCapabilityLineText({
state,
title,
model: value?.model,
endpointBaseUrl: value?.endpoint?.baseUrl,
external,
});
};
return [
line(Environment.runtimeCapabilityChatText, caps.chat),
line(Environment.runtimeCapabilityVisionText, caps.vision),
line(Environment.runtimeCapabilityOcrText, caps.ocr),
line(Environment.runtimeCapabilityThinkingText, caps.thinking),
line(Environment.runtimeCapabilityExtendedThinkingText, caps.extendedThinking),
line(Environment.runtimeCapabilityToolsText, caps.tools),
line(Environment.runtimeCapabilityAudioText, caps.audio),
line(Environment.runtimeCapabilitySpeechToTextText, caps.speechToText),
line(Environment.runtimeCapabilityTextToSpeechText, caps.textToSpeech),
line(Environment.runtimeCapabilityDocumentsText, caps.documents),
line(Environment.runtimeCapabilityOutputImagesText, caps.outputImages),
];
}
export async function formatRuntimeModelInfo(
provider: AiProvider = Environment.DEFAULT_AI_PROVIDER,
model: string | undefined = getRuntimeModel(provider),
caps?: AiModelCapabilities,
): Promise<string> {
return Environment.getRuntimeModelInfoText(
provider.toString().toLowerCase(),
model,
await getFormattedCapabilities(provider, model, caps)
);
}
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);
}
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);
}
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);
}
}
}
+196
View File
@@ -0,0 +1,196 @@
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;
model: string;
baseUrl?: string;
};
type QueueEntry = {
target: AiRequestQueueTarget;
queueKey: string;
run: () => Promise<BoundaryValue>;
resolve: (value: BoundaryValue) => void;
reject: (reason?: Error | string | BoundaryValue | null | undefined) => void;
onPositionChange: (requestsBefore: number) => Promise<void> | void;
signal?: AbortSignal;
abortHandler?: () => void;
started: boolean;
};
type EnqueueOptions<T extends BoundaryValue> = {
signal?: AbortSignal;
onPositionChange: (requestsBefore: number) => Promise<void> | void;
run: () => Promise<T>;
};
class AiProviderRequestQueue {
private readonly waiting = new Map<string, QueueEntry[]>();
private readonly active = new Map<string, number>();
enqueue<T extends BoundaryValue>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
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 = {
target,
queueKey,
run: options.run,
resolve: value => resolve(value as T),
reject,
onPositionChange: options.onPositionChange,
signal: options.signal,
started: false,
};
entry.abortHandler = () => {
if (entry.started) return;
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 {
return this.waiting.get(queueKey);
}
private getOrCreateQueue(queueKey: string): QueueEntry[] {
let queue = this.waiting.get(queueKey);
if (!queue) {
queue = [];
this.waiting.set(queueKey, queue);
}
return queue;
}
private activeCount(queueKey: string): number {
return this.active.get(queueKey) ?? 0;
}
private setActiveCount(queueKey: string, count: number): void {
if (count <= 0) {
this.active.delete(queueKey);
return;
}
this.active.set(queueKey, count);
}
private maxActive(target: AiRequestQueueTarget): number {
return Math.max(1, Environment.getAiProviderMaxConcurrentRequests(target.provider));
}
private normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
private queueKey(target: AiRequestQueueTarget): string {
return JSON.stringify([
target.provider,
this.normalizeBaseUrl(target.baseUrl),
target.model.trim(),
]);
}
private removeWaitingEntry(entry: QueueEntry): boolean {
const queue = this.getQueue(entry.queueKey);
if (!queue) return false;
const index = queue.indexOf(entry);
if (index < 0) return false;
queue.splice(index, 1);
if (entry.abortHandler) {
entry.signal?.removeEventListener("abort", entry.abortHandler);
}
this.deleteQueueIfIdle(entry.queueKey, queue);
return true;
}
private schedule(target: AiRequestQueueTarget): void {
const queueKey = this.queueKey(target);
const queue = this.getOrCreateQueue(queueKey);
while (queue.length && this.activeCount(queueKey) < this.maxActive(target)) {
const entry = queue.shift();
if (!entry) continue;
if (entry.abortHandler) {
entry.signal?.removeEventListener("abort", entry.abortHandler);
}
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);
}
this.updateWaitingMessages(target);
if (!queue.length && this.activeCount(queueKey) <= 0) {
this.waiting.delete(queueKey);
}
}
private async runEntry(entry: QueueEntry): 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);
} finally {
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
this.schedule(entry.target);
}
}
private updateWaitingMessages(target: AiRequestQueueTarget): void {
const queueKey = this.queueKey(target);
const active = this.activeCount(queueKey);
const queue = [...(this.getQueue(queueKey) ?? [])];
Promise.allSettled(queue.map((entry, index) => {
return entry.onPositionChange(active + index);
})).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)});
}
}
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error: error instanceof Error ? error : String(error)}));
}
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
if (!queue.length && this.activeCount(queueKey) <= 0) {
this.waiting.delete(queueKey);
}
}
}
export const aiProviderRequestQueue = new AiProviderRequestQueue();
+77
View File
@@ -0,0 +1,77 @@
import type {AiProvider} from "../model/ai-provider";
export type RagArtifactSource = {
fileId: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
sha256?: string;
uploadedFileId?: string;
documentId?: string;
};
export type RagArtifactPayload = {
artifactKind: "rag";
provider: AiProvider;
createdAt: string;
sources: RagArtifactSource[];
providerState:
| {
provider: AiProvider.OPENAI;
vectorStoreIds: string[];
uploadedFileIds: string[];
}
| {
provider: AiProvider.MISTRAL;
libraryId?: string;
documentCount: number;
}
| {
provider: AiProvider.OLLAMA;
prepared: boolean;
embeddingModel?: string;
topK?: number;
chunkSize?: number;
chunkOverlap?: number;
maxContextChars?: number;
extractedDocuments: Array<{
documentIndex: number;
fileName: string;
textChars: number;
}>;
selectedChunks: Array<{
sourceId: string;
documentIndex: number;
documentName: string;
chunkIndex: number;
chunkCount: number;
textChars: number;
score?: number;
}>;
skippedDocuments: Array<{
documentIndex: number;
fileName: string;
reason: string;
}>;
query: string;
minScore: number;
maxArchiveFiles: number;
maxArchiveBytes: number;
maxArchiveDepth: number;
};
};
export function buildRagArtifactPayload(params: {
provider: AiProvider;
createdAt?: string;
sources: RagArtifactSource[];
providerState: RagArtifactPayload["providerState"];
}): RagArtifactPayload {
return {
artifactKind: "rag",
provider: params.provider,
createdAt: params.createdAt ?? new Date().toISOString(),
sources: params.sources,
providerState: params.providerState,
};
}
+100
View File
@@ -0,0 +1,100 @@
import type {StoredAttachment} from "../model/stored-attachment";
import {AiProvider} from "../model/ai-provider";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {PreparedDocumentRag} from "./document-rag-pipeline";
import type {OllamaRagArtifactDetails} from "./ollama-rag";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
import {buildRagArtifactPayload, type RagArtifactPayload} from "./rag-artifact-payload";
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
switch (prepared.provider) {
case AiProvider.OPENAI:
return {
provider: AiProvider.OPENAI,
vectorStoreIds: prepared.vectorStoreIds,
uploadedFileIds: prepared.uploadedFileIds,
};
case AiProvider.MISTRAL:
return {
provider: AiProvider.MISTRAL,
libraryId: prepared.libraryId,
documentCount: prepared.documents.length,
};
case AiProvider.OLLAMA:
return {
provider: AiProvider.OLLAMA,
prepared: prepared.prepared,
embeddingModel: details?.embeddingModel,
topK: details?.topK,
chunkSize: details?.chunkSize,
chunkOverlap: details?.chunkOverlap,
maxContextChars: details?.maxContextChars,
extractedDocuments: details?.artifact?.extractedDocuments ?? [],
selectedChunks: details?.artifact?.selectedChunks ?? [],
skippedDocuments: details?.artifact?.skippedDocuments ?? [],
query: details?.artifact?.query ?? "",
minScore: details?.artifact?.providerState?.minScore ?? 0,
maxArchiveFiles: details?.artifact?.providerState?.maxArchiveFiles ?? 0,
maxArchiveBytes: details?.artifact?.providerState?.maxArchiveBytes ?? 0,
maxArchiveDepth: details?.artifact?.providerState?.maxArchiveDepth ?? 0,
};
}
}
export async function persistRagArtifactAttachment(params: {
provider: AiProvider;
prepared: PreparedDocumentRag | undefined;
downloads: AiDownloadedFile[];
chatId: number;
messageId: number;
details?: {
uploadedFileIds?: string[];
sourceDocuments?: Array<{
fileId: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
sha256?: string;
uploadedFileId?: string;
documentId?: string;
}>;
embeddingModel?: string;
topK?: number;
chunkSize?: number;
chunkOverlap?: number;
maxContextChars?: number;
artifact?: OllamaRagArtifactDetails;
};
}): Promise<StoredAttachment | undefined> {
if (!params.prepared) return Promise.resolve(undefined);
const sources = params.downloads
.filter(download => download.kind === "document")
.map((download, index) => ({
fileId: download.fileId,
fileName: download.fileName,
mimeType: download.mimeType,
sizeBytes: download.sizeBytes ?? download.buffer.length,
sha256: download.sha256,
uploadedFileId: params.details?.uploadedFileIds?.[index],
}));
if (!sources.length) return Promise.resolve(undefined);
const payload = buildRagArtifactPayload({
provider: params.provider,
sources,
providerState: providerState(params.prepared, params.details),
});
return await persistInternalJsonArtifactAttachment({
artifactKind: "rag",
fileNamePrefix: "rag",
chatId: params.chatId,
messageId: params.messageId,
payload,
metadata: {
sourceFileNames: sources.map(source => source.fileName),
...payload.providerState,
},
});
}
+75
View File
@@ -0,0 +1,75 @@
import type {RagArtifactPayload} from "./rag-artifact-payload";
export type ArtifactLike = {
id: string;
createdAt: string;
payload: string;
};
export type RagCleanupTarget = {
artifactId: string;
createdAt: string;
provider: RagArtifactPayload["providerState"]["provider"];
vectorStoreIds?: string[];
uploadedFileIds?: string[];
libraryId?: string;
};
export type RagCleanupPlan = {
cutoffAt: string;
targets: RagCleanupTarget[];
};
function parseRagArtifactPayload(payload: string): RagArtifactPayload | null {
try {
const parsed = JSON.parse(payload) as Partial<RagArtifactPayload>;
if (!parsed || parsed.artifactKind !== "rag" || !parsed.providerState) return null;
return parsed as RagArtifactPayload;
} catch {
return null;
}
}
export function buildStaleRagCleanupPlan(
artifacts: ArtifactLike[],
retentionDays = 14,
now = new Date(),
): RagCleanupPlan {
const cutoffAt = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const targets: RagCleanupTarget[] = [];
for (const artifact of artifacts) {
if (artifact.createdAt > cutoffAt) continue;
const payload = parseRagArtifactPayload(artifact.payload);
if (!payload || payload.artifactKind !== "rag") continue;
switch (payload.providerState.provider) {
case "OPENAI":
if (payload.providerState.vectorStoreIds.length || payload.providerState.uploadedFileIds.length) {
targets.push({
artifactId: artifact.id,
createdAt: artifact.createdAt,
provider: payload.providerState.provider,
vectorStoreIds: [...payload.providerState.vectorStoreIds],
uploadedFileIds: [...payload.providerState.uploadedFileIds],
});
}
break;
case "MISTRAL":
if (payload.providerState.libraryId) {
targets.push({
artifactId: artifact.id,
createdAt: artifact.createdAt,
provider: payload.providerState.provider,
libraryId: payload.providerState.libraryId,
});
}
break;
case "OLLAMA":
break;
}
}
return {cutoffAt, targets};
}
+117
View File
@@ -0,0 +1,117 @@
import {appLogger} from "../logging/logger.js";
import {DatabaseManager} from "../db/database-manager.js";
import {AiProvider} from "../model/ai-provider.js";
import {createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
import {deleteMistralLibrary} from "./unified-ai-runner.shared.js";
import {buildStaleRagCleanupPlan} from "./rag-retention-planner.js";
const logger = appLogger.child("rag-retention");
function unique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
async function cleanupOpenAiRag(vectorStoreIds: string[], uploadedFileIds: string[]): Promise<void> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "documents");
const client = createOpenAiClient(target);
for (const vectorStoreId of unique(vectorStoreIds)) {
const startedAt = Date.now();
logger.info("openai.vector_store.cleanup.start", {vectorStoreId});
try {
await client.vectorStores.delete(vectorStoreId);
logger.success("openai.vector_store.cleanup.done", {vectorStoreId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("openai.vector_store.cleanup.failed", {
vectorStoreId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
for (const fileId of unique(uploadedFileIds)) {
const startedAt = Date.now();
logger.info("openai.file.cleanup.start", {fileId});
try {
await client.files.delete(fileId);
logger.success("openai.file.cleanup.done", {fileId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("openai.file.cleanup.failed", {
fileId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
}
async function cleanupMistralRag(libraryId: string): Promise<void> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "documents");
const startedAt = Date.now();
logger.info("mistral.library.cleanup.start", {libraryId});
try {
await deleteMistralLibrary(libraryId, target);
logger.success("mistral.library.cleanup.done", {libraryId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("mistral.library.cleanup.failed", {
libraryId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
export async function cleanupStaleRagProviderState(retentionDays = 14): Promise<{
scannedArtifacts: number;
cleanupTargets: number;
openaiTargets: number;
mistralTargets: number;
}> {
const startedAt = Date.now();
const artifacts = await DatabaseManager.getAllArtifacts().catch(() => []);
const plan = buildStaleRagCleanupPlan(artifacts, retentionDays);
logger.info("cleanup.start", {
retentionDays,
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
cutoffAt: plan.cutoffAt,
});
let openaiTargets = 0;
let mistralTargets = 0;
for (const target of plan.targets) {
switch (target.provider) {
case "OPENAI":
openaiTargets += 1;
await cleanupOpenAiRag(target.vectorStoreIds ?? [], target.uploadedFileIds ?? []);
break;
case "MISTRAL":
mistralTargets += 1;
if (target.libraryId) {
await cleanupMistralRag(target.libraryId);
}
break;
case "OLLAMA":
break;
}
}
logger.success("cleanup.done", {
retentionDays,
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
openaiTargets,
mistralTargets,
duration: `${Date.now() - startedAt}ms`,
});
return {
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
openaiTargets,
mistralTargets,
};
}
+24
View File
@@ -0,0 +1,24 @@
import {AiProvider} from "../model/ai-provider";
export const AI_REGENERATE_CALLBACK = "/regenerate_ai";
export type AiRegenerateCallbackData = {
provider: AiProvider;
think: boolean;
};
export function buildAiRegenerateCallbackData(provider: AiProvider, think = false): string {
return `${AI_REGENERATE_CALLBACK} ${provider} ${think ? "1" : "0"}`;
}
export function parseAiRegenerateCallbackData(data: string): AiRegenerateCallbackData | null {
if (!data.startsWith(AI_REGENERATE_CALLBACK)) return null;
const [, provider, think] = data.split(/\s+/);
if (!Object.values(AiProvider).includes(provider as AiProvider)) return null;
return {
provider: provider as AiProvider,
think: think === "1" || think === "true",
};
}
+39
View File
@@ -0,0 +1,39 @@
import type {AiDownloadedFile} from "./telegram-attachments.js";
function downloadKey(download: AiDownloadedFile): string {
return [
download.kind,
download.fileId,
download.sha256 ?? "",
download.fileName,
].join(":");
}
export function mergeReplyChainDownloads(
currentDownloads: readonly AiDownloadedFile[],
replyChainDownloads: readonly AiDownloadedFile[],
): AiDownloadedFile[] {
const result: AiDownloadedFile[] = [];
const seen = new Set<string>();
for (const download of [...currentDownloads, ...replyChainDownloads]) {
const key = downloadKey(download);
if (seen.has(key)) continue;
seen.add(key);
result.push(download);
}
return result;
}
export function shouldPreferCurrentDownloads(text: string, currentDownloads: readonly AiDownloadedFile[]): boolean {
if (!currentDownloads.length) return false;
const normalized = text.trim().toLowerCase();
if (!normalized) return false;
return normalized.includes("this file")
|| normalized.includes("this document")
|| normalized.includes("этот файл")
|| normalized.includes("этот документ");
}
+19
View File
@@ -0,0 +1,19 @@
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
export type NormalizedModelOutput = {
text: string;
toolExecutions: TelegramToolExecutionRecord[];
outputAttachments: TelegramOutputAttachmentRecord[];
};
export function summarizeModelOutput(params: {
text: string;
toolExecutions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): NormalizedModelOutput {
return {
text: params.text.trim(),
toolExecutions: [...params.toolExecutions],
outputAttachments: [...params.outputAttachments],
};
}
+8
View File
@@ -0,0 +1,8 @@
const OLLAMA_SPEECH_TO_TEXT_MODELS = new Set([
"gemma4:e2b",
"gemma4:e4b",
]);
export function isOllamaSpeechToTextModel(model: string | undefined | null): boolean {
return !!model && OLLAMA_SPEECH_TO_TEXT_MODELS.has(model.trim().toLowerCase());
}
+195
View File
@@ -0,0 +1,195 @@
import fs, {openAsBlob} from "node:fs";
import {AiProvider} from "../model/ai-provider";
import {
getAvailableAiProviderChoices,
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 {Environment} from "../common/environment";
export type TranscribedSpeech = {
provider: AiProvider;
model: string;
text: string;
fileName: string;
};
export type SpeechToTextRequest = {
provider: AiProvider;
audio: AiDownloadedFile;
signal?: AbortSignal;
};
export type SpeechToTextProviderResolution = {
provider: AiProvider;
fallback: boolean;
};
export type SpeechToTextResolveOptions = {
allowFallback?: boolean;
};
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"));
}
export function isSpeechToTextConfigured(provider: AiProvider): boolean {
switch (provider) {
case AiProvider.OPENAI:
const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!openAiTarget.apiKey && !!openAiTarget.model;
case AiProvider.MISTRAL:
const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!mistralTarget.apiKey && !!mistralTarget.model;
case AiProvider.OLLAMA:
const ollamaTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!ollamaTarget.baseUrl && isOllamaSpeechToTextModel(ollamaTarget.model);
}
}
export async function resolveSpeechToTextProviderForUser(
userId: number,
preferredProvider?: AiProvider,
options: SpeechToTextResolveOptions = {},
): Promise<SpeechToTextProviderResolution> {
const allowFallback = options.allowFallback ?? true;
const availableChoices = getAvailableAiProviderChoices(userId);
const allowedProviders = availableChoices
.map(choice => normalizeAiProviderChoice(choice))
.filter((choice): choice is AiProvider => !!choice && choice !== "DEFAULT");
if (preferredProvider) {
if (!allowedProviders.includes(preferredProvider)) {
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(preferredProvider)));
}
if (isSpeechToTextConfigured(preferredProvider)) {
return {provider: preferredProvider, fallback: false};
}
if (!allowFallback) {
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerDisplayName(preferredProvider)));
}
}
const effectiveProvider = await resolveEffectiveAiProviderForUser(userId);
if (isSpeechToTextConfigured(effectiveProvider)) {
return {
provider: effectiveProvider,
fallback: preferredProvider !== undefined && preferredProvider !== effectiveProvider
};
}
const fallbackProvider = allowedProviders.find(isSpeechToTextConfigured);
if (!fallbackProvider) {
throw new Error(Environment.noSpeechToTextProviderForAccessText);
}
return {provider: fallbackProvider, fallback: true};
}
export async function transcribeSpeech(request: SpeechToTextRequest): Promise<TranscribedSpeech> {
if (request.signal?.aborted) throw new Error("Aborted");
switch (request.provider) {
case AiProvider.OPENAI:
return transcribeOpenAiSpeech(request.audio, request.signal);
case AiProvider.MISTRAL:
return transcribeMistralSpeech(request.audio, request.signal);
case AiProvider.OLLAMA:
return transcribeOllamaSpeech(request.audio, request.signal);
}
}
export async function transcribeSpeechDownloads(provider: AiProvider, downloads: AiDownloadedFile[], signal?: AbortSignal): Promise<string> {
const audios = downloads.filter(isTranscribableAudioDownload);
const transcriptions: string[] = [];
for (const [index, audio] of audios.entries()) {
if (signal?.aborted) throw new Error("Aborted");
const result = await transcribeSpeech({provider, audio, signal});
const text = result.text.trim();
if (!text) continue;
transcriptions.push(audios.length > 1
? `[${index + 1}. ${audio.fileName}]\n${text}`
: text);
}
return transcriptions.join("\n\n").trim();
}
async function transcribeOpenAiSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "speechToText");
const openAi = createOpenAiClient(target);
const file = fs.createReadStream(audio.path);
try {
const result = await openAi.audio.transcriptions.create({
file,
model: target.model,
}, {signal});
return {
provider: AiProvider.OPENAI,
model: target.model,
text: result.text || "",
fileName: audio.fileName,
};
} finally {
file.destroy();
}
}
async function transcribeMistralSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "speechToText");
const mistralAi = createMistralClient(target);
const result = await mistralAi.audio.transcriptions.complete({
model: target.model,
file: await openAsBlob(audio.path),
}, {signal});
return {
provider: AiProvider.MISTRAL,
model: target.model,
text: result.text || "",
fileName: audio.fileName,
};
}
async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
if (signal?.aborted) throw new Error("Aborted");
const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "speechToText");
const model = target.model;
if (!isOllamaSpeechToTextModel(model)) {
throw new Error(Environment.ollamaSpeechToTextModelRequiredText);
}
const ollama = createOllamaClient(target);
const response = await ollama.chat({
model,
stream: false,
think: false,
messages: [{
role: "user",
content: "Transcribe the attached audio verbatim. Reply only with the transcription text. Do not answer the speaker.",
images: [audio.buffer.toString("base64")],
}],
options: {
temperature: 0,
},
});
return {
provider: AiProvider.OLLAMA,
model,
text: response?.message?.content || "",
fileName: audio.fileName,
};
}
+447
View File
@@ -0,0 +1,447 @@
import {Message} from "typescript-telegram-bot-api";
import {bot} from "../index";
import {downloadTelegramFile, logError} from "../util/utils";
import fs from "node:fs";
import path from "node:path";
import {Environment} from "../common/environment";
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;
fileId: string;
fileName: string;
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);
}
function extensionFromMimeType(mimeType?: string): string {
switch ((mimeType || "").toLowerCase()) {
case "audio/ogg":
case "audio/opus":
return ".ogg";
case "audio/mpeg":
case "audio/mp3":
return ".mp3";
case "audio/mp4":
case "audio/x-m4a":
return ".m4a";
case "audio/wav":
case "audio/wave":
case "audio/x-wav":
return ".wav";
case "audio/webm":
return ".webm";
case "image/jpeg":
return ".jpg";
case "image/png":
return ".png";
case "image/webp":
return ".webp";
case "application/pdf":
return ".pdf";
case "text/plain":
return ".txt";
case "application/zip":
case "application/x-zip":
case "application/x-zip-compressed":
return ".zip";
case "application/x-tar":
case "application/tar":
return ".tar";
case "application/gzip":
case "application/x-gzip":
case "application/gzip-compressed":
return ".gz";
case "video/mp4":
return ".mp4";
default:
return "";
}
}
function fileNameWithExtension(fileName: string, mimeType?: string, telegramFilePath?: string): string {
if (path.extname(fileName)) return fileName;
const telegramExt = telegramFilePath ? path.extname(telegramFilePath) : "";
const ext = telegramExt || extensionFromMimeType(mimeType);
return ext ? `${fileName}${ext}` : fileName;
}
function cacheDirFor(kind: StoredAttachmentKind): string {
const dirName = kind === "image" ? "photo" : kind;
return path.join(Environment.DATA_PATH, "cache", dirName);
}
function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefined, fileId: string, fileName: string): string {
const base = safeFileName(fileUniqueId || fileId);
const ext = path.extname(fileName);
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});
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;
}
const buffer = await downloadTelegramFile(file.file_path);
if (!buffer) {
logger.warn("download.empty", {kind, fileId, telegramFilePath: file.file_path});
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),
};
}
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;
}
await ffmpegSemaphore.runExclusive(async () => {
if (fs.existsSync(output)) {
logger.trace("audio.convert.cache_hit", {output});
return;
}
const tempOutput = `${output}.${process.pid}.${Date.now()}.tmp.wav`;
try {
await performFFmpeg(() => {
const command = ffmpeg(input);
if (noVideo) command.noVideo();
return command
.toFormat("wav")
.save(tempOutput)
.on("progress", (progress) => {
logger.trace("audio.convert.progress", {input, output, 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();
const result: StoredAttachment[] = [];
const rejected: RejectedTelegramAttachment[] = [];
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
try {
if (msg.photo?.length) {
const size = msg.photo[msg.photo.length - 1]!;
const fileName = `${size.file_unique_id || size.file_id}.jpg`;
const mimeType = "image/jpeg";
if (!rejectIfTooLarge(rejected, "image", size.file_id, fileName, mimeType, size.file_size, size.file_unique_id)) {
const file = await downloadToCache("image", size.file_id, fileName, mimeType, size.file_unique_id, size.file_size);
if (file) result.push(file);
}
}
if (msg.document) {
const doc = msg.document;
const kind: StoredAttachmentKind = doc.mime_type?.startsWith("image/")
? "image"
: doc.mime_type?.startsWith("audio/")
? "audio"
: "document";
const fileName = doc.file_name || `${doc.file_unique_id || doc.file_id}`;
if (!rejectIfTooLarge(rejected, kind, doc.file_id, fileName, doc.mime_type, doc.file_size, doc.file_unique_id)) {
const file = await downloadToCache(kind, doc.file_id, fileName, doc.mime_type, doc.file_unique_id, doc.file_size);
if (file) result.push(file);
}
}
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);
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 {
await convertAudioToWav(file.cachePath, output);
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));
}
}
if (file) result.push(file);
}
if (msg.audio) {
const fileName = msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`;
if (!rejectIfTooLarge(rejected, "audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_size, msg.audio.file_unique_id)) {
const file = await downloadToCache("audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_unique_id, msg.audio.file_size);
if (file) result.push(file);
}
}
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);
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 {
await convertAudioToWav(file.cachePath, output, true);
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));
}
}
if (file) result.push(file);
}
} catch (e) {
logError(e instanceof Error ? e : String(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;
}
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
logger.trace("downloaded_files.build", {attachments: attachments.length});
return attachments
.filter(attachment => fs.existsSync(attachment.cachePath))
.flatMap(attachment => {
const sizeBytes = attachment.sizeBytes ?? fs.statSync(attachment.cachePath).size;
if (sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
logger.warn("downloaded_files.skipped.too_large", {
kind: attachment.kind,
fileName: attachment.fileName,
sizeBytes,
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
});
return [];
}
return [{
kind: attachment.kind,
fileId: attachment.fileId,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
buffer: fs.readFileSync(attachment.cachePath),
path: attachment.cachePath,
sizeBytes,
sha256: attachment.sha256,
}];
});
}
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);
}
files.length = 0;
}
+728
View File
@@ -0,0 +1,728 @@
import {FileOptions, InlineKeyboardMarkup, Message} from "typescript-telegram-bot-api";
import {bot} from "../index";
import {buildCancelledGenerationText, logError, replyToMessage} from "../util/utils";
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_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
const EDIT_INTERVAL_MS = 4500;
const logger = appLogger.child("telegram-stream-message");
export type TelegramArtifactFile = {
kind: "image" | "file";
path: string;
fileName: string;
mimeType?: string;
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;
private lastSent = "";
private text = "";
private status = "";
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,
private readonly cancelRequestId: string,
private readonly stream: boolean,
private readonly regenerateCallbackData?: string,
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 {
return {
inline_keyboard: [[{
text: Environment.cancelText,
callback_data: this.cancelProvider
? `/cancel_ai ${this.cancelRequestId} ${this.cancelProvider}`
: `/cancel_ai ${this.cancelRequestId}`,
}]],
};
}
emptyKeyboard(): InlineKeyboardMarkup {
return {inline_keyboard: []};
}
regenerateKeyboard(): InlineKeyboardMarkup | null {
if (!this.regenerateCallbackData) return null;
return {
inline_keyboard: [[{
text: Environment.regenerateText,
callback_data: this.regenerateCallbackData,
}]],
};
}
private isMessageNotModified(message: string): boolean {
return message.includes("message is not modified");
}
private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise<void> {
if (!this.waitMessage) return;
try {
await enqueueTelegramApiCall(
() => bot.editMessageReplyMarkup({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
reply_markup: replyMarkup,
}),
{
method: "editMessageReplyMarkup",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
}
}
private async removeKeyboard(): Promise<void> {
await this.updateKeyboard(this.emptyKeyboard());
}
private startFlushTimer(): void {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => this.flush().catch(logError), EDIT_INTERVAL_MS);
}
private visibleText(): string {
const parts = [this.text, this.status].filter(v => v && v.trim().length);
let value = parts.join("\n\n").trim() || Environment.waitThinkText;
if (value.length > TELEGRAM_LIMIT) {
value = value.substring(0, TELEGRAM_LIMIT - 1);
}
return value;
}
private visibleCaption(): string {
let value = this.visibleText();
if (value.length > TELEGRAM_CAPTION_LIMIT) {
value = value.substring(0, TELEGRAM_CAPTION_LIMIT - 1);
}
return value;
}
async start(initialStatus: string): Promise<Message> {
this.status = initialStatus;
const rawText = this.visibleText();
const formatted = prepareTelegramMarkdownV2(rawText, {mode: "draft"});
if (this.targetMessage) {
this.waitMessage = this.targetMessage;
try {
await MessageStore.put(this.targetMessage).catch(logError);
const result = await enqueueTelegramApiCall(
() => bot.editMessageText({
chat_id: this.targetMessage!.chat.id,
message_id: this.targetMessage!.message_id,
text: formatted,
parse_mode: "MarkdownV2",
reply_markup: this.keyboard(),
}),
{
method: "editMessageText",
chatId: this.targetMessage.chat.id,
chatType: this.targetMessage.chat.type,
}
);
if (result && result !== true) this.waitMessage = result;
this.mediaMode = false;
this.lastSent = rawText;
await this.store();
this.startFlushTimer();
return this.waitMessage;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (this.isMessageNotModified(message)) {
this.lastSent = rawText;
await this.updateKeyboard(this.keyboard());
await this.store();
this.startFlushTimer();
return this.waitMessage;
}
logError(e instanceof Error ? e : message);
this.waitMessage = null;
this.mediaMode = false;
}
}
this.waitMessage = await replyToMessage({
message: this.sourceMessage,
text: formatted,
reply_markup: this.keyboard(),
parse_mode: "MarkdownV2"
});
this.lastSent = rawText;
this.startFlushTimer();
return this.waitMessage;
}
setStatus(status: string): void {
if (this.cancelled) return;
this.status = status;
}
getStatus(): string {
return this.status;
}
clearStatus(): void {
if (this.cancelled) return;
this.status = "";
}
append(delta: string): void {
if (this.cancelled) return;
if (!delta) return;
this.text += delta;
}
replaceText(text: string): void {
if (this.cancelled) return;
this.text = text;
}
getText(): string {
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));
}
private async flushUnsafe(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> {
if (!this.waitMessage && this.stream) return;
const next = this.mediaMode ? this.visibleCaption() : this.visibleText();
const shouldRemoveKeyboard = replyMarkup === null;
if (next === this.lastSent && shouldRemoveKeyboard) {
await this.removeKeyboard();
return;
}
const formatted = prepareTelegramMarkdownV2(next, {mode: end ? "final" : "draft"});
if (next === this.lastSent && replyMarkup !== null) {
if (end) await this.updateKeyboard(replyMarkup);
return;
}
try {
if (!this.stream && end && !this.waitMessage) {
if (this.isGuest) {
// await enqueueTelegramApiCall(() => bot.answerGuestQuery({
// guest_query_id: this.sourceMessage.guest_query_id ?? "",
// result: {}
// }),
// {});
} else {
await replyToMessage({
message: this.sourceMessage,
text: formatted,
parse_mode: "MarkdownV2",
});
}
} else {
if (this.waitMessage) {
const result = this.mediaMode
? await enqueueTelegramApiCall(
() => bot.editMessageCaption({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
caption: formatted,
parse_mode: "MarkdownV2",
reply_markup: replyMarkup ?? this.emptyKeyboard(),
}),
{
method: "editMessageCaption",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
)
: await enqueueTelegramApiCall(
() => bot.editMessageText({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
text: formatted,
parse_mode: "MarkdownV2",
reply_markup: replyMarkup ?? this.emptyKeyboard(),
}),
{
method: "editMessageText",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
);
if (result && result !== true) this.waitMessage = result;
}
}
if (shouldRemoveKeyboard) await this.removeKeyboard();
this.lastSent = next;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (shouldRemoveKeyboard && this.isMessageNotModified(message)) {
await this.removeKeyboard();
this.lastSent = next;
return;
}
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
}
}
async cancel(provider: string): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.cancelled = true;
this.cancelledProvider = provider;
this.status = "";
this.text = buildCancelledGenerationText(this.text, this.cancelledProvider, this.mediaMode ? TELEGRAM_CAPTION_LIMIT : TELEGRAM_LIMIT);
await this.flush(this.regenerateKeyboard(), true);
await this.store();
}
async showImage(image: Buffer, attachment?: StoredAttachment): Promise<void> {
return this.enqueueEdit(() => this.showImageUnsafe(image, attachment));
}
async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> {
return this.enqueueEdit(() => this.sendArtifactUnsafe(file));
}
private async showImageUnsafe(image: Buffer, attachment?: StoredAttachment): Promise<void> {
if (this.cancelled) return;
const next = this.visibleCaption();
const useDocument = this.sendImagesAsDocuments;
if (!this.waitMessage) {
if (this.stream) return;
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
try {
this.waitMessage = useDocument
? await this.sendImageAsDocument(upload!, next)
: await enqueueTelegramApiCall(
() => bot.sendPhoto({
chat_id: this.sourceMessage.chat.id,
photo: image,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
parse_mode: "MarkdownV2",
reply_parameters: {message_id: this.sourceMessage.message_id},
}),
{
method: "sendPhoto",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
} finally {
if (upload) this.destroyUpload(upload);
}
this.mediaMode = true;
this.lastSent = next;
await this.storeMediaMessage(this.waitMessage, attachment);
return;
}
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
try {
const result = await enqueueTelegramApiCall(
() => bot.editMessageMedia({
chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id,
media: useDocument
? {
type: "document",
media: upload!,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
parse_mode: "MarkdownV2",
}
: {
type: "photo",
media: image,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
parse_mode: "MarkdownV2",
},
reply_markup: this.keyboard(),
}),
{
method: "editMessageMedia",
chatId: this.waitMessage.chat.id,
chatType: this.waitMessage.chat.type,
}
);
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));
}
}
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) {
throw new Error(Environment.getTelegramFileTooLargeText(
file.fileName,
PIPELINE_ATTACHMENT_LIMIT_BYTES / 1024 / 1024,
));
}
const caption = file.fileName.slice(0, TELEGRAM_CAPTION_LIMIT);
const isPhoto = this.isPhotoArtifact(file);
await enqueueTelegramApiCall(
() => bot.sendChatAction({
chat_id: this.sourceMessage.chat.id,
action: isPhoto ? "upload_photo" : "upload_document",
}),
{
method: "sendChatAction",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
).catch(logError);
let sent: Message;
if (isPhoto) {
try {
sent = await enqueueTelegramApiCall(
async () => {
const upload = this.createArtifactUpload(file);
try {
return await bot.sendPhoto({
chat_id: this.sourceMessage.chat.id,
photo: upload,
caption,
reply_parameters: {message_id: this.sourceMessage.message_id},
});
} finally {
this.destroyUpload(upload);
}
},
{
method: "sendPhoto",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
} catch (e) {
logError(e instanceof Error ? e : String(e));
sent = await this.sendArtifactAsDocument(file, caption);
}
} else {
sent = await this.sendArtifactAsDocument(file, caption);
}
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,
contentType: file.mimeType || "application/octet-stream",
});
}
private destroyUpload(upload: FileOptions): void {
if ("destroy" in upload.file && typeof upload.file.destroy === "function") {
upload.file.destroy();
}
}
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 () => {
const upload = this.createArtifactUpload(file);
try {
return await bot.sendDocument({
chat_id: this.sourceMessage.chat.id,
document: upload,
caption,
reply_parameters: {message_id: this.sourceMessage.message_id},
});
} finally {
this.destroyUpload(upload);
}
},
{
method: "sendDocument",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
}
private async storeArtifactMessage(sent: Message, file: TelegramArtifactFile): Promise<void> {
const photo = sent.photo?.[sent.photo.length - 1];
const attachmentKind: StoredAttachmentKind = file.kind === "image" ? "image" : "document";
const attachment: StoredAttachment = {
kind: attachmentKind,
fileId: sent.document?.file_id ?? photo?.file_id ?? file.path,
fileUniqueId: sent.document?.file_unique_id ?? photo?.file_unique_id,
fileName: file.fileName,
mimeType: file.mimeType,
cachePath: file.path,
sizeBytes: file.sizeBytes,
scope: "bot_output",
artifactKind: "generated_file",
};
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 ?? file.fileName,
date: sent.date ?? Math.floor(Date.now() / 1000),
attachments: [attachment],
};
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;
if (this.cancelled) {
await this.flush(removeKeyboard ? this.regenerateKeyboard() : this.keyboard(), true);
await this.store();
return;
}
if (Environment.SEND_TIME_TOOK) {
const diff = Date.now() - this.startedAt;
if (this.text.length + 32 < TELEGRAM_LIMIT) this.text += `\n\n⏱️ ${diff}ms`;
}
this.clearStatus();
await this.flush(removeKeyboard ? this.regenerateKeyboard() : this.keyboard(), true);
await this.store();
}
async fail(error: Error | string | object | null | undefined): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.status = "";
this.text = `${Environment.errorText}\n${error instanceof Error ? error.message : String(error)}`;
await this.flush(this.regenerateKeyboard(), true);
}
private async store(): Promise<void> {
if (!this.waitMessage) return;
try {
await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message);
} catch (e) {
logError(e instanceof Error ? e : String(e));
}
}
}
+321
View File
@@ -0,0 +1,321 @@
import fs from "node:fs";
import path from "node:path";
import {randomUUID} from "node:crypto";
import {FileOptions, Message} from "typescript-telegram-bot-api";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {bot} from "../index";
import {
getAvailableAiProviderChoices,
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";
const MAX_TTS_TEXT_CHARS = 4096;
export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm";
export type SynthesizedSpeech = {
provider: AiProvider;
model: string;
voice?: string;
format: TextToSpeechFormat;
mimeType: string;
fileName: string;
path: string;
sizeBytes: number;
};
export type TextToSpeechRequest = {
provider: AiProvider;
text: string;
voice?: string;
};
export type TextToSpeechProviderResolution = {
provider: AiProvider;
fallback: boolean;
};
type SpeechFileParams = Omit<SynthesizedSpeech, "fileName" | "path" | "sizeBytes"> & {
buffer: Buffer;
};
function ttsCacheDir(): string {
return path.join(Environment.DATA_PATH, "cache", "audio");
}
function assertText(text: string): string {
const normalized = text.trim();
if (!normalized) {
throw new Error(Environment.noTextToSynthesizeText);
}
if (normalized.length > MAX_TTS_TEXT_CHARS) {
throw new Error(Environment.getTextToSpeechTooLongText(normalized.length, MAX_TTS_TEXT_CHARS));
}
return normalized;
}
export function isTextToSpeechConfigured(provider: AiProvider): boolean {
switch (provider) {
case AiProvider.OPENAI:
const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!openAiTarget.apiKey && !!openAiTarget.model;
case AiProvider.MISTRAL:
const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!mistralTarget.apiKey && !!mistralTarget.model;
case AiProvider.OLLAMA:
return false;
}
}
export async function resolveTextToSpeechProviderForUser(
userId: number,
explicitProvider?: AiProvider,
): Promise<TextToSpeechProviderResolution> {
const availableChoices = getAvailableAiProviderChoices(userId);
const allowedProviders = availableChoices
.map(choice => normalizeAiProviderChoice(choice))
.filter((choice): choice is AiProvider => !!choice && choice !== "DEFAULT");
if (explicitProvider) {
if (!allowedProviders.includes(explicitProvider)) {
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(explicitProvider)));
}
if (!isTextToSpeechConfigured(explicitProvider)) {
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerDisplayName(explicitProvider)));
}
return {provider: explicitProvider, fallback: false};
}
const effectiveProvider = await resolveEffectiveAiProviderForUser(userId);
if (isTextToSpeechConfigured(effectiveProvider)) {
return {provider: effectiveProvider, fallback: false};
}
const fallbackProvider = allowedProviders.find(isTextToSpeechConfigured);
if (!fallbackProvider) {
throw new Error(Environment.noTextToSpeechProviderForAccessText);
}
return {provider: fallbackProvider, fallback: true};
}
export async function synthesizeSpeech(request: TextToSpeechRequest): Promise<SynthesizedSpeech> {
const text = assertText(request.text);
switch (request.provider) {
case AiProvider.OPENAI:
return synthesizeOpenAiSpeech(text, request.voice);
case AiProvider.MISTRAL:
return synthesizeMistralSpeech(text, request.voice);
case AiProvider.OLLAMA:
throw new Error(Environment.ollamaTextToSpeechUnsupportedText);
}
}
async function synthesizeOpenAiSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "textToSpeech");
const openAi = createOpenAiClient(target);
const response = await openAi.audio.speech.create({
model: target.model,
voice: voice || Environment.OPENAI_TTS_VOICE,
input: text,
response_format: "mp3",
instructions: Environment.OPENAI_TTS_INSTRUCTIONS,
});
const buffer = Buffer.from(await response.arrayBuffer());
return writeSpeechFile({
provider: AiProvider.OPENAI,
model: target.model,
voice: voice || Environment.OPENAI_TTS_VOICE,
buffer,
format: "mp3",
mimeType: "audio/mpeg",
});
}
async function synthesizeMistralSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "textToSpeech");
const mistralAi = createMistralClient(target);
const request: SpeechRequest = {
input: text,
responseFormat: "mp3"
// stream: false,
};
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 audioData = response?.audioData ?? response?.audio_data;
if (typeof audioData !== "string" || !audioData.trim()) {
throw new Error(Environment.mistralTtsNoAudioDataText);
}
const buffer = Buffer.from(audioData, "base64");
return writeSpeechFile({
provider: AiProvider.MISTRAL,
model: target.model || "mistral speech",
voice: voice || Environment.MISTRAL_TTS_VOICE_ID,
buffer,
format: "mp3",
mimeType: "audio/mpeg",
});
}
function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech {
fs.mkdirSync(ttsCacheDir(), {recursive: true});
const fileName = `${params.provider.toLowerCase()}-tts-${Date.now()}-${randomUUID()}.${params.format}`;
const filePath = path.join(ttsCacheDir(), fileName);
fs.writeFileSync(filePath, params.buffer);
return {
provider: params.provider,
model: params.model,
voice: params.voice,
format: params.format,
mimeType: params.mimeType,
fileName,
path: filePath,
sizeBytes: params.buffer.length,
};
}
function createSpeechUpload(speech: SynthesizedSpeech): FileOptions {
return new FileOptions(fs.createReadStream(speech.path), {
filename: speech.fileName,
contentType: speech.mimeType,
});
}
function destroyUpload(upload: FileOptions): void {
if ("destroy" in upload.file && typeof upload.file.destroy === "function") {
upload.file.destroy();
}
}
export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> {
if (speech.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
throw new Error(Environment.speechFileTooLargeText);
}
const caption = Environment.getTextToSpeechCaption(providerDisplayName(speech.provider), speech.model, speech.voice);
await enqueueTelegramApiCall(
() => bot.sendChatAction({
chat_id: sourceMessage.chat.id,
action: speech.format === "mp3" || speech.format === "opus" ? "upload_voice" : "upload_document",
}),
{method: "sendChatAction", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
).catch(logError);
let sent: Message;
if (speech.format === "mp3" || speech.format === "opus") {
try {
sent = await enqueueTelegramApiCall(
async () => {
const upload = createSpeechUpload(speech);
try {
return await bot.sendVoice({
chat_id: sourceMessage.chat.id,
voice: upload,
caption,
reply_parameters: {message_id: sourceMessage.message_id},
});
} finally {
// destroyUpload(upload);
}
},
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
);
} catch (e) {
logError(e instanceof Error ? e : String(e));
sent = await sendSpeechDocument(sourceMessage, speech, caption);
}
} else {
sent = await sendSpeechDocument(sourceMessage, speech, caption);
}
await storeSpeechMessage(sent, sourceMessage, speech);
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 () => {
const upload = createSpeechUpload(speech);
try {
return await bot.sendDocument({
chat_id: sourceMessage.chat.id,
document: upload,
caption,
reply_parameters: {message_id: sourceMessage.message_id},
});
} finally {
destroyUpload(upload);
}
},
{method: "sendDocument", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
);
}
async function storeSpeechMessage(sent: Message, sourceMessage: Message, speech: SynthesizedSpeech): Promise<void> {
const file = sent.voice ?? sent.audio ?? sent.document;
const attachment: StoredAttachment = {
kind: "audio",
fileId: file?.file_id ?? speech.path,
fileUniqueId: file?.file_unique_id,
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 = {
chatId: sent.chat.id,
id: sent.message_id,
replyToMessageId: sent.reply_to_message?.message_id ?? sourceMessage.message_id,
fromId: sent.from?.id ?? 0,
text: sent.caption ?? speech.fileName,
date: sent.date ?? Math.floor(Date.now() / 1000),
attachments: [attachment],
};
await MessageStore.put(stored);
}
+28
View File
@@ -0,0 +1,28 @@
import type {AiProviderAdapter} from "./provider-adapters.js";
import {executeToolBatch, type ToolCallData, type ToolExecutionMemory} from "./unified-ai-runner.shared.js";
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
import type {ToolRuntimeContext} from "./tools/runtime.js";
export async function executeToolBatchWithAdapter(params: {
userId: number | undefined | null;
toolCalls: ToolCallData[];
streamMessage: TelegramStreamMessage;
toolContext: ToolRuntimeContext;
toolMemory: ToolExecutionMemory;
adapter: AiProviderAdapter;
appendTargets?: unknown[][];
}): Promise<string[]> {
const results = await executeToolBatch(
params.userId,
params.toolCalls,
params.streamMessage,
params.toolContext,
params.toolMemory,
);
for (const target of params.appendTargets ?? []) {
params.adapter.appendToolResults(target, params.toolCalls, results);
}
return results;
}
+39
View File
@@ -0,0 +1,39 @@
import type {StoredAttachment} from "../model/stored-attachment";
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistToolLoopSummaryArtifactAttachment(params: {
chatId: number;
messageId: number;
text: string;
executions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): Promise<StoredAttachment | undefined> {
if (!params.executions.length) return undefined;
return await persistInternalJsonArtifactAttachment({
artifactKind: "tool_result",
fileNamePrefix: "tool-loop-summary",
chatId: params.chatId,
messageId: params.messageId,
payload: {
stage: "tool_loop",
text: params.text.trim(),
executions: params.executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
argumentsText: execution.argumentsText,
resultChars: execution.resultChars,
startedAt: execution.startedAt,
finishedAt: execution.finishedAt,
})),
outputAttachments: params.outputAttachments,
},
metadata: {
stage: "tool_loop",
toolExecutions: params.executions.length,
outputAttachments: params.outputAttachments.length,
textChars: params.text.trim().length,
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export type ToolLoopStopReason = "no_tool_calls" | "max_rounds_reached";
export type ToolLoopContinuation = {
continue: boolean;
reason?: ToolLoopStopReason;
remainingRounds: number;
};
export function decideToolLoopContinuation(params: {
round: number;
maxRounds: number;
toolCalls: readonly ToolCallData[];
}): ToolLoopContinuation {
const remainingRounds = Math.max(params.maxRounds - params.round - 1, 0);
if (!params.toolCalls.length) {
return {
continue: false,
reason: "no_tool_calls",
remainingRounds,
};
}
if (remainingRounds === 0) {
return {
continue: false,
reason: "max_rounds_reached",
remainingRounds,
};
}
return {
continue: true,
remainingRounds,
};
}
+22
View File
@@ -0,0 +1,22 @@
export type ToolLoopRoundOutcome = {
shouldContinue: boolean;
maxRoundsReached?: boolean;
};
export async function runToolLoopRounds(params: {
maxRounds: number;
onRound: (round: number) => Promise<ToolLoopRoundOutcome>;
onMaxRoundsReached?: (round: number) => Promise<void> | void;
}): Promise<void> {
for (let round = 0; round < params.maxRounds; round++) {
const outcome = await params.onRound(round);
if (!outcome.shouldContinue) {
if (outcome.maxRoundsReached) {
await params.onMaxRoundsReached?.(round);
}
return;
}
}
await params.onMaxRoundsReached?.(params.maxRounds - 1);
}
+56
View File
@@ -0,0 +1,56 @@
import type {PipelineArtifact} from "./user-request-pipeline/types.js";
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
import {summarizeModelOutput} from "./response-model-output.js";
export type ToolLoopSummary = {
status: "succeeded" | "skipped";
fallbackAction?: "continue_without_stage";
details: {
modelOutput: ReturnType<typeof summarizeModelOutput>;
count: number;
tools: Array<{
toolName: string;
callId: string;
resultChars: number;
}>;
};
artifacts?: PipelineArtifact[];
};
export function summarizeToolLoop(params: {
text: string;
executions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): ToolLoopSummary {
const count = params.executions.length;
const tools = params.executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
resultChars: execution.resultChars,
}));
return {
status: count ? "succeeded" : "skipped",
fallbackAction: count ? undefined : "continue_without_stage",
details: {
modelOutput: summarizeModelOutput({
text: params.text,
toolExecutions: params.executions,
outputAttachments: params.outputAttachments,
}),
count,
tools,
},
artifacts: count ? [{
kind: "tool_result",
stage: "tool_loop",
createdAt: new Date().toISOString(),
toolName: "summary",
callId: "tool_loop_summary",
resultText: JSON.stringify({
count,
tools,
}),
}] : undefined,
};
}
+104
View File
@@ -0,0 +1,104 @@
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";
export type AiProviderName = "ollama" | "openai" | "mistral";
export function getOllamaTools(forCreator?: boolean): AiTool[] {
return getTools(forCreator);
}
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 => ({
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 => ({
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: false,
}));
}
export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool {
return {
type: "code_interpreter",
container: {
type: "auto",
},
};
}
export function getMistralTools(forCreator?: boolean): AiTool[] {
return getTools(forCreator).map(tool => ({
type: "function",
function: tool.function,
}));
}
export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] {
switch (provider) {
case AiProvider.OLLAMA:
return getOllamaTools(forCreator);
case AiProvider.MISTRAL:
return getMistralTools(forCreator);
case AiProvider.OPENAI:
return getOpenAITools(forCreator);
}
}
export function ensureToolsSelected<T>(availableTools: readonly T[], selectedTools: readonly T[], toolNames: readonly string[]): T[] {
const selected = [...selectedTools];
const selectedNames = new Set(selected.flatMap(tool => toolSchemaNames(tool as never)));
for (const toolName of toolNames) {
if (selectedNames.has(toolName)) continue;
const extraTool = availableTools.find(tool => toolSchemaNames(tool as never).includes(toolName));
if (extraTool) {
selected.unshift(extraTool);
selectedNames.add(toolName);
}
}
return selected;
}
+146
View File
@@ -0,0 +1,146 @@
import {AiProvider} from "../model/ai-provider.js";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
import type {RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
import {allToolSchemaNames, toolSchemaNames} from "./tool-schema-utils.js";
import type {ToolRanker} from "./unified-ai-runner.tool-ranker.js";
import type {PipelineAuditEvent} from "./user-request-pipeline/types.js";
function latestUserText(messages: readonly { role?: string; content?: unknown }[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => typeof part === "object" && part !== null && "text" in part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: "")
.filter(Boolean)
.join("\n");
}
}
return "";
}
export async function runToolRankStage(params: {
provider: AiProvider;
model: string;
round: number;
config: RuntimeConfigSnapshot;
availableTools: readonly BoundaryValue[];
messages: readonly { role?: string; content?: unknown }[];
streamMessage: TelegramStreamMessage;
signal: AbortSignal;
toolRanker?: ToolRanker;
storeAudit?: (params: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
availableTools: string[];
selectedTools?: string[];
usedRanker?: boolean;
error?: unknown;
}) => Promise<void>;
}): Promise<{
filteredTools: BoundaryValue[];
selectedToolNames: string[];
usedRanker: boolean;
}> {
const toolRanker = params.toolRanker ?? new (await import("./unified-ai-runner.tool-ranker.js")).ToolRanker(params.config);
const startedAt = Date.now();
const startedAtIso = new Date().toISOString();
const filterSelectedTools = (selectedToolNames: readonly string[]): BoundaryValue[] => {
const selected = new Set(selectedToolNames);
return params.availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
};
const storeAudit = params.storeAudit ?? (async (auditParams: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
availableTools: string[];
selectedTools?: string[];
usedRanker?: boolean;
error?: unknown;
}) => {
const event: PipelineAuditEvent = {
stage: "tool_rank",
status: auditParams.error ? "failed" : "succeeded",
startedAt: auditParams.startedAtIso,
finishedAt: new Date().toISOString(),
durationMs: Date.now() - auditParams.startedAt,
provider: auditParams.provider,
model: auditParams.model,
details: {
round: auditParams.round,
availableTools: auditParams.availableTools,
selectedTools: auditParams.selectedTools ?? [],
usedRanker: auditParams.usedRanker ?? false,
toolRankDecision: {
provider: auditParams.provider,
round: auditParams.round,
availableTools: auditParams.availableTools,
selectedTools: auditParams.selectedTools ?? [],
usedRanker: auditParams.usedRanker ?? false,
},
},
error: auditParams.error instanceof Error ? auditParams.error.message : auditParams.error ? String(auditParams.error) : undefined,
};
await auditParams.streamMessage.storePipelineAudit([event]);
});
params.streamMessage.setStatus("🧩 Выбираю подходящие инструменты...");
await params.streamMessage.flush();
try {
const selection = await toolRanker.selectTools({
provider: params.provider,
userQuery: latestUserText(params.messages),
availableTools: params.availableTools,
round: params.round,
signal: params.signal,
});
params.streamMessage.clearStatus();
await params.streamMessage.flush();
await storeAudit({
streamMessage: params.streamMessage,
provider: params.provider,
model: params.model,
round: params.round,
startedAt,
startedAtIso,
availableTools: allToolSchemaNames(params.availableTools),
selectedTools: selection.toolNames,
usedRanker: selection.usedRanker,
});
return {
filteredTools: filterSelectedTools(selection.toolNames),
selectedToolNames: selection.toolNames,
usedRanker: selection.usedRanker,
};
} catch (error) {
params.streamMessage.clearStatus();
await params.streamMessage.flush();
await storeAudit({
streamMessage: params.streamMessage,
provider: params.provider,
model: params.model,
round: params.round,
startedAt,
startedAtIso,
availableTools: allToolSchemaNames(params.availableTools),
error,
});
throw error;
}
}
+56
View File
@@ -0,0 +1,56 @@
import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {decidePipelineFallback, type PipelineFallbackDecision} from "./user-request-pipeline/fallback-executor.js";
export type ToolRankerFallbackSelection = {
toolNames: string[];
usedRanker: boolean;
};
export type ToolRankerFallbackDecision = PipelineFallbackDecision & ToolRankerFallbackSelection;
function fallbackActionForPolicy(policy: ToolRankerFallbackPolicy) {
return policy === ToolRankerFallbackPolicy.MAIN_MODEL
? "use_alternate_target"
: "continue_without_stage";
}
export function decideToolRankerFallback(params: {
fallbackPolicy: ToolRankerFallbackPolicy;
availableToolNames: readonly string[];
reason: "unavailable" | "failed";
}): ToolRankerFallbackDecision {
const action = fallbackActionForPolicy(params.fallbackPolicy);
const decision = decidePipelineFallback({
stage: "tool_rank",
reason: params.reason,
policies: [{
stage: "tool_rank",
onUnavailable: action,
onFailed: action,
}],
});
return {
...decision,
toolNames: params.fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS
? []
: [...params.availableToolNames],
usedRanker: false,
};
}
export function resolveToolRankerFallbackSelection(params: {
fallbackPolicy: ToolRankerFallbackPolicy;
availableToolNames: readonly string[];
}): ToolRankerFallbackSelection {
const decision = decideToolRankerFallback({
fallbackPolicy: params.fallbackPolicy,
availableToolNames: params.availableToolNames,
reason: "failed",
});
return {
toolNames: decision.toolNames,
usedRanker: decision.usedRanker,
};
}
+669
View File
@@ -0,0 +1,669 @@
import type {BoundaryValue} from "../common/boundary-types";
export type ToolRankerExample = {
user: string;
toolNames: string[];
note?: string;
};
export type ToolRankerToolInfo = {
name: string;
description: string;
rankerHint: string;
examples?: ToolRankerExample[];
};
const tool = (
name: string,
description: string,
rankerHint: string,
examples: ToolRankerExample[] = [],
): ToolRankerToolInfo => ({
name,
description,
rankerHint,
examples: examples.length ? examples : undefined,
});
const example = (user: string, toolNames: string[], note?: string): ToolRankerExample => ({
user,
toolNames,
note,
});
export const TOOL_RANKER_TOOL_INFOS = {
no_tool: tool(
"no_tool",
"No tool action is needed.",
"Use for normal answers, explanations, advice, planning, code writing without execution, rewriting, translation, and general conversation.",
[
example("объясни docker volumes", ["no_tool"]),
example("напиши промпт для Claude", ["no_tool"]),
example("как лучше спроектировать эту архитектуру?", ["no_tool"]),
],
),
get_datetime: tool(
"get_datetime",
"Get the current date, time, or timezone-aware moment.",
"Use for current date/time, today/tomorrow/yesterday, timezone-aware time, and calculations based on the current moment.",
[
example("какое сегодня число?", ["get_datetime"]),
example("который час?", ["get_datetime"]),
example("что будет через 10 дней?", ["get_datetime"]),
],
),
get_financial_market_data: tool(
"get_financial_market_data",
"Get current market, price, currency, or ticker data.",
"Use for current/recent stocks, crypto, fiat exchange rates, commodities, indices, futures, and market prices.",
[
example("сколько сейчас BTC?", ["get_financial_market_data"]),
example("курс USD/RUB", ["get_financial_market_data"]),
example("цена золота сейчас", ["get_financial_market_data"]),
],
),
get_weather: tool(
"get_weather",
"Get current weather or forecast data.",
"Use for weather, rain, snow, wind, temperature, forecast, and weather-dependent planning.",
[
example("погода завтра", ["get_weather"]),
example("будет дождь сегодня?", ["get_weather"]),
example("можно сегодня на велике?", ["get_weather"]),
],
),
read_file: tool(
"read_file",
"Read a known local file path.",
"Use when the user asks to read, open, inspect, or summarize a known local file path.",
[
example("прочитай src/index.ts", ["read_file"]),
example("посмотри package.json", ["read_file"]),
example("открой этот файл", ["read_file"]),
],
),
list_directory: tool(
"list_directory",
"List files or folders in a local path.",
"Use when the user asks to list files/folders, inspect a directory, show project structure, or see what exists in a path.",
[
example("покажи структуру проекта", ["list_directory"]),
example("что лежит в src?", ["list_directory"]),
example("выведи список файлов", ["list_directory"]),
],
),
search_files: tool(
"search_files",
"Search local files by name, content, symbol, or keyword.",
"Use when the exact file path is unknown and the user wants to find files, usages, TODOs, symbols, classes, functions, or error messages.",
[
example("найди где используется sendMessage", ["search_files"]),
example("найди все TODO", ["search_files"]),
example("где определён BotService?", ["search_files"]),
],
),
read_user_info: tool(
"read_user_info",
"Read persistent user memory from user.md.",
"Use before editing or when the user asks what you remember about them.",
[
example("что ты помнишь обо мне?", ["read_user_info"]),
example("покажи мою память", ["read_user_info"]),
],
),
read_system_info: tool(
"read_system_info",
"Read persistent assistant memory from system.md.",
"Use before editing or when the user asks what instructions you remember about yourself.",
[
example("что ты помнишь о себе?", ["read_system_info"]),
example("покажи память о тебе", ["read_system_info"]),
],
),
add_user_info: tool(
"add_user_info",
"Append a durable fact about the user to persistent memory.",
"Use when the user asks to remember a new fact, preference, identity detail, or profile information about themselves.",
[
example("запомни, что меня зовут Иван", ["add_user_info"]),
example("запомни, что я люблю чай", ["add_user_info"]),
example("remember that I like short answers", ["add_user_info"]),
],
),
add_system_info: tool(
"add_system_info",
"Append a durable instruction about the assistant to persistent memory.",
"Use when the user asks to remember a new assistant identity, style, or behavior instruction.",
[
example("тебя зовут Евлампий", ["add_system_info"]),
example("ты ИИ помощник", ["add_system_info"]),
example("remember you are a concise assistant", ["add_system_info"]),
],
),
remove_user_info: tool(
"remove_user_info",
"Remove a specific user fact from persistent memory.",
"Use when the user asks to forget, delete, or remove a specific fact about themselves.",
[
example("забудь, что я люблю кофе", ["remove_user_info"]),
example("удали из памяти, что я живу в Москве", ["remove_user_info"]),
example("forget that I work at ACME", ["remove_user_info"]),
],
),
remove_system_info: tool(
"remove_system_info",
"Remove a specific assistant instruction from persistent memory.",
"Use when the user asks to forget or remove a specific instruction about the assistant.",
[
example("забудь, что тебя зовут Евлампий", ["remove_system_info"]),
example("убери правило отвечать коротко", ["remove_system_info"]),
example("forget that you are a concise assistant", ["remove_system_info"]),
],
),
replace_user_info: tool(
"replace_user_info",
"Replace the full user memory with a new compact version.",
"Use when the user wants to overwrite all remembered user info, for example when they say to forget everything and keep only the new fact.",
[
example("забудь всё обо мне и запиши только это: меня зовут Иван", ["replace_user_info"]),
example("замени всю память обо мне на: люблю чай и короткие ответы", ["replace_user_info"]),
],
),
replace_system_info: tool(
"replace_system_info",
"Replace the full assistant memory with a new compact version.",
"Use when the user wants to overwrite all remembered assistant info or instructions.",
[
example("забудь всё о себе и запиши только это: тебя зовут Евлампий", ["replace_system_info"]),
example("замени инструкцию о себе на: ты краткий ИИ помощник", ["replace_system_info"]),
],
),
delete_user_info: tool(
"delete_user_info",
"Delete user.md entirely.",
"Use when the user explicitly asks to delete all remembered user info, not just a fragment.",
[
example("удали всю память обо мне", ["delete_user_info"]),
example("forget all user memory", ["delete_user_info"]),
],
),
delete_system_info: tool(
"delete_system_info",
"Delete system.md entirely.",
"Use when the user explicitly asks to delete all remembered assistant info, not just a fragment.",
[
example("удали всю память о себе", ["delete_system_info"]),
example("forget all assistant memory", ["delete_system_info"]),
],
),
create_file: tool(
"create_file",
"Create a new small file.",
"Use when the user asks to create a new file with specific content.",
[
example("создай README.md", ["create_file"]),
example("создай .env.example", ["create_file"]),
example("сделай docker-compose.yml", ["create_file"]),
],
),
update_file: tool(
"update_file",
"Replace an existing file completely.",
"Use only for full file replacement or overwrite.",
[
example("полностью перезапиши config.json", ["update_file"]),
example("замени файл этой версией", ["update_file"]),
example("overwrite this file", ["update_file"]),
],
),
edit_file_patch: tool(
"edit_file_patch",
"Apply a targeted patch to an existing file.",
"Use for targeted edits, patches, diffs, refactors, and changes that should preserve most of the file.",
[
example("исправь этот баг патчем", ["edit_file_patch"]),
example("добавь эту опцию в существующий конфиг", ["edit_file_patch"]),
example("измени только эту функцию", ["edit_file_patch"]),
],
),
create_directory: tool(
"create_directory",
"Create directories or folder trees.",
"Use when the user asks to create folders or directory structures.",
[
example("создай папку src/services", ["create_directory"]),
example("создай структуру директорий", ["create_directory"]),
],
),
copy_path: tool(
"copy_path",
"Copy a file or folder path.",
"Use when the user asks to copy or duplicate a file or folder.",
[
example("скопируй config.example.json в config.json", ["copy_path"]),
example("дублируй эту папку", ["copy_path"]),
],
),
rename_path: tool(
"rename_path",
"Rename or move a file or folder.",
"Use when the user asks to rename or move a file or folder.",
[
example("переименуй файл", ["rename_path"]),
example("перемести notes.md в archive", ["rename_path"]),
],
),
delete_path: tool(
"delete_path",
"Delete a file or folder.",
"Use only when the user clearly asks to delete or remove something.",
[
example("удали папку dist", ["delete_path"]),
example("remove node_modules", ["delete_path"]),
example("delete this file", ["delete_path"]),
],
),
send_file_as_attachment: tool(
"send_file_as_attachment",
"Send a local file as an attachment.",
"Use when the user wants to receive, export, send, attach, or download a local file as an attachment.",
[
example("пришли мне этот файл", ["send_file_as_attachment"]),
example("отправь заметку файлом", ["send_file_as_attachment"]),
example("export this as attachment", ["send_file_as_attachment"]),
],
),
begin_file_write: tool(
"begin_file_write",
"Start a large chunked file write.",
"Use with write_file_chunk and finish_file_write for large file creation or writing.",
[
example("создай большой markdown отчёт", ["begin_file_write", "write_file_chunk", "finish_file_write"]),
example("запиши большой файл чанками", ["begin_file_write", "write_file_chunk", "finish_file_write"]),
],
),
write_file_chunk: tool(
"write_file_chunk",
"Append a chunk to an active large file write.",
"Use together with begin_file_write and finish_file_write for chunked file writing.",
),
finish_file_write: tool(
"finish_file_write",
"Complete an active large file write.",
"Use together with begin_file_write and write_file_chunk to finish chunked file writing.",
),
cancel_file_write: tool(
"cancel_file_write",
"Cancel an active large file write.",
"Use when the user asks to cancel an active file write operation.",
[
example("отмени запись файла", ["cancel_file_write"]),
example("cancel file write", ["cancel_file_write"]),
],
),
shell_execute: tool(
"shell_execute",
"Run shell commands in the workspace environment.",
"Use for terminal commands, tests, builds, docker, git, npm, pnpm, bun, gradle, diagnostics, logs, install commands, or system inspection.",
[
example("запусти npm test", ["shell_execute"]),
example("собери проект", ["shell_execute"]),
example("проверь docker logs", ["shell_execute"]),
],
),
python_interpreter: tool(
"python_interpreter",
"Execute Python code.",
"Use when the user explicitly asks to run Python code, execute Python, calculate with Python, or test a Python script.",
[
example("выполни этот python код", ["python_interpreter"]),
example("посчитай это питоном", ["python_interpreter"]),
example("напиши и запусти python скрипт", ["python_interpreter"]),
],
),
code_interpreter: tool(
"code_interpreter",
"Run sandboxed code and data analysis.",
"Use for sandbox computation, data/file analysis, CSV processing, archive processing, charts, tables, and generated reports.",
[
example("проанализируй CSV", ["code_interpreter"]),
example("построй график", ["code_interpreter"]),
example("обработай архив", ["code_interpreter"]),
],
),
image_generation: tool(
"image_generation",
"Generate or edit an image.",
"Use when the user asks to generate, create, edit, transform, restyle, enhance, remove, add, replace, recolor, upscale, or alter an image.",
[
example("сделай его лысым", ["image_generation"]),
example("убери фон", ["image_generation"]),
example("сделай в стиле аниме", ["image_generation"]),
],
),
web_search: tool(
"web_search",
"Search the public web for current, recent, or external information.",
"Use only for current/recent/public online information, search, verification, links, documentation, comparisons, or external data.",
[
example("найди актуальную документацию OpenAI API", ["web_search"]),
example("проверь, вышел ли Kotlin 2.3", ["web_search"]),
example("какие сейчас цены на VPS?", ["web_search"]),
],
),
file_search: tool(
"file_search",
"Search uploaded documents or indexed vector-store files.",
"Use for uploaded documents, vector stores, PDFs/docs already indexed or attached to the assistant context.",
[
example("найди в моих документах про MCP", ["file_search"]),
example("что в загруженном PDF написано про оплату?", ["file_search"]),
example("поищи в базе знаний", ["file_search"]),
],
),
} as const satisfies Record<string, ToolRankerToolInfo>;
export type ToolRankerToolName = keyof typeof TOOL_RANKER_TOOL_INFOS;
function isString(value: BoundaryValue): value is string {
return typeof value === "string";
}
function normalizeToolNames(names: readonly string[]): string[] {
const unique: string[] = [];
for (const name of names) {
if (!name || unique.includes(name)) {
continue;
}
unique.push(name);
}
return unique;
}
function extractJsonCandidate(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) {
return fenced[1].trim();
}
const firstObjectStart = trimmed.indexOf("{");
const lastObjectEnd = trimmed.lastIndexOf("}");
if (firstObjectStart !== -1 && lastObjectEnd !== -1 && lastObjectEnd > firstObjectStart) {
return trimmed.slice(firstObjectStart, lastObjectEnd + 1).trim();
}
const firstArrayStart = trimmed.indexOf("[");
const lastArrayEnd = trimmed.lastIndexOf("]");
if (firstArrayStart !== -1 && lastArrayEnd !== -1 && lastArrayEnd > firstArrayStart) {
return trimmed.slice(firstArrayStart, lastArrayEnd + 1).trim();
}
return trimmed;
}
function parseSelectionValue(value: BoundaryValue): string[] {
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.filter(isString);
}
if (value !== null && typeof value === "object") {
const rawToolNames = (value as Record<string, BoundaryValue>).toolNames;
return parseSelectionValue(rawToolNames as BoundaryValue);
}
return [];
}
function asOptionalString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toolNamesFromTool(tool: BoundaryValue): string[] {
if (!isRecord(tool)) {
return [];
}
const functionValue = isRecord(tool.function) ? tool.function : undefined;
const directName = functionValue?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
const name = asOptionalString(directName);
return name ? [name] : [];
}
function fallbackToolInfoFromTool(toolValue: BoundaryValue, name: string): ToolRankerToolInfo | undefined {
if (!isRecord(toolValue)) return undefined;
const fn = isRecord(toolValue.function) ? toolValue.function : undefined;
const description = asOptionalString(fn?.description ?? toolValue.description)
?? `Tool ${name}.`;
return tool(
name,
description,
"Use when the tool description matches the user's request.",
);
}
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
}
export function getToolRankerToolInfos(names: readonly string[]): ToolRankerToolInfo[] {
return normalizeToolNames(names)
.map(name => getToolRankerToolInfo(name))
.filter((tool): tool is ToolRankerToolInfo => !!tool);
}
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
const infos = new Map<string, ToolRankerToolInfo>();
infos.set("no_tool", TOOL_RANKER_TOOL_INFOS.no_tool);
for (const tool of availableTools) {
for (const name of toolNamesFromTool(tool)) {
if (infos.has(name)) continue;
const known = getToolRankerToolInfo(name);
const fallback = fallbackToolInfoFromTool(tool, name);
if (known) {
infos.set(name, known);
} else if (fallback) {
infos.set(name, fallback);
}
}
}
return [...infos.values()];
}
function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string {
if (compact) {
return `- ${tool.name}: ${tool.rankerHint}`;
}
return `- ${tool.name}: ${tool.description}\n ${tool.rankerHint}`;
}
function renderExamples(tool: ToolRankerToolInfo, maxExamplesPerTool: number): string[] {
if (!tool.examples?.length || maxExamplesPerTool <= 0) {
return [];
}
return tool.examples.slice(0, maxExamplesPerTool).flatMap(example => {
const lines = [
`User: ${JSON.stringify(example.user)}`,
];
if (example.note?.trim()) {
lines.push(`Note: ${example.note.trim()}`);
}
lines.push(JSON.stringify({toolNames: example.toolNames}));
return lines;
});
}
function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
const names = new Set(tools.map(tool => tool.name));
const lines: string[] = [];
const pushIfAvailable = (name: string, line: string): void => {
if (names.has(name)) {
lines.push(`- ${line}`);
}
};
pushIfAvailable("get_datetime", "current date/time -> get_datetime");
pushIfAvailable("get_financial_market_data", "market prices, currency, crypto, stocks -> get_financial_market_data");
pushIfAvailable("get_weather", "weather or forecast -> get_weather");
pushIfAvailable("image_generation", "image creation or editing -> image_generation");
pushIfAvailable("file_search", "uploaded/vector documents -> file_search");
pushIfAvailable("read_file", "known local file path -> read_file");
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
pushIfAvailable("read_user_info", "inspect remembered user info -> read_user_info");
pushIfAvailable("read_system_info", "inspect remembered assistant info -> read_system_info");
pushIfAvailable("add_user_info", "remember a new user fact -> add_user_info");
pushIfAvailable("add_system_info", "remember a new assistant instruction -> add_system_info");
pushIfAvailable("remove_user_info", "forget a user fact -> remove_user_info");
pushIfAvailable("remove_system_info", "forget an assistant instruction -> remove_system_info");
pushIfAvailable("replace_user_info", "overwrite all user memory -> replace_user_info");
pushIfAvailable("replace_system_info", "overwrite all assistant memory -> replace_system_info");
pushIfAvailable("delete_user_info", "delete all user memory -> delete_user_info");
pushIfAvailable("delete_system_info", "delete all assistant memory -> delete_system_info");
pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch");
pushIfAvailable("update_file", "full existing file replacement -> update_file");
pushIfAvailable("create_file", "small new file -> create_file");
pushIfAvailable("begin_file_write", "large file writing -> begin_file_write + write_file_chunk + finish_file_write");
pushIfAvailable("delete_path", "delete/remove only when the user clearly asks -> delete_path");
pushIfAvailable("shell_execute", "terminal commands, builds, tests, git, docker -> shell_execute");
pushIfAvailable("python_interpreter", "explicit Python execution -> python_interpreter");
pushIfAvailable("code_interpreter", "sandbox computation or data analysis -> code_interpreter");
return lines;
}
function buildRulesSection(availableToolNames: readonly string[]): string[] {
const names = new Set(availableToolNames);
const rules: string[] = [
"You are a tool router, not an answering model.",
"Your only job is to select the minimal set of tools needed for the user's latest request.",
"Return ONLY valid JSON: {\"toolNames\":[\"tool1\",\"tool2\"]}",
"No explanations.",
"No markdown.",
"No arguments.",
"Use only tool names from Available tools.",
"If no tool is needed, return {\"toolNames\":[\"no_tool\"]}.",
"Pick the smallest correct tool set.",
"Prefer specialized tools over generic tools.",
"Use multiple tools only when the request likely needs a combination of capabilities.",
"Be extra careful with destructive tools.",
];
if (names.has("web_search")) {
rules.push("Do not use web_search just because you are unsure.");
}
if (names.has("delete_path")) {
rules.push("delete_path only when the user clearly asks to delete or remove something.");
}
if (names.has("update_file")) {
rules.push("update_file only for full file replacement.");
}
if (names.has("edit_file_patch")) {
rules.push("edit_file_patch for targeted file edits.");
}
return rules;
}
export function buildToolRankerSystemPrompt(params: {
availableTools: ToolRankerToolInfo[];
includeExamples?: boolean;
maxExamplesPerTool?: number;
compact?: boolean;
}): string {
const includeExamples = params.includeExamples ?? false;
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
const compact = params.compact ?? true;
const availableTools = params.availableTools;
const availableToolNames = availableTools.map(tool => tool.name);
const sections: string[] = [
...buildRulesSection(availableToolNames),
"",
"Available tools:",
...availableTools.map(tool => renderToolLine(tool, compact)),
];
const priorityLines = buildPriorityLines(availableTools);
if (priorityLines.length) {
sections.push("", "Priority:", ...priorityLines);
}
if (includeExamples) {
const exampleLines = availableTools.flatMap(tool => renderExamples(tool, maxExamplesPerTool));
if (exampleLines.length) {
sections.push("", "Examples:", ...exampleLines);
}
}
sections.push("", "Return ONLY JSON.");
return sections.join("\n");
}
export function sanitizeToolRankerResult(params: {
raw: string;
availableToolNames: readonly string[];
}): string[] {
const raw = params.raw.trim();
if (!raw) {
return ["no_tool"];
}
const candidate = extractJsonCandidate(raw);
let parsed: BoundaryValue;
try {
parsed = JSON.parse(candidate) as BoundaryValue;
} catch {
return ["no_tool"];
}
const availableToolNames = new Set(params.availableToolNames.filter(Boolean));
const selected: string[] = [];
for (const name of normalizeToolNames(parseSelectionValue(parsed))) {
if (name === "no_tool") {
selected.push(name);
continue;
}
if (availableToolNames.has(name)) {
selected.push(name);
}
}
const deduped = normalizeToolNames(selected);
const withoutNoTool = deduped.filter(name => name !== "no_tool");
return withoutNoTool.length > 0 ? withoutNoTool : ["no_tool"];
}
+116
View File
@@ -0,0 +1,116 @@
import type {BoundaryValue} from "../common/boundary-types.js";
import type {AiRuntimeTarget} from "./ai-runtime-target.js";
import {AiProvider} from "../model/ai-provider.js";
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared.js";
import {
buildToolRankerSystemPrompt,
getToolRankerAvailableToolInfos,
type ToolRankerToolInfo,
} from "./tool-ranker-metadata.js";
export type ToolRankerMessage = {
role?: string;
content?: BoundaryValue;
};
export type ToolRankerSelection = {
toolNames: string[];
usedRanker: boolean;
};
export type ToolRankerContext = {
provider: AiProvider;
round: number;
userQuery: string;
availableTools: readonly BoundaryValue[];
targetModel: string;
rankerPrompt?: string | null;
promptAdditions?: string | null;
};
export type ToolRankerPromptPlan = {
availableToolNames: string[];
availableToolInfos: ToolRankerToolInfo[];
prompt: string;
};
export function latestUserTextFromMessages(messages: readonly ToolRankerMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => {
if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") {
return part.text;
}
return "";
})
.filter(Boolean)
.join("\n");
}
}
return "";
}
export function buildToolRankerPrompt(context: ToolRankerContext): ToolRankerPromptPlan {
const availableToolInfos = getToolRankerAvailableToolInfos(context.availableTools);
const availableToolNames = availableToolInfos.map(tool => tool.name);
const prompt = buildToolRankerSystemPrompt({
availableTools: availableToolInfos,
includeExamples: true,
maxExamplesPerTool: 1,
compact: true,
});
return {
availableToolNames,
availableToolInfos,
prompt: [
context.rankerPrompt?.trim() || null,
context.promptAdditions?.trim() || null,
prompt,
].filter((line): line is string => Boolean(line?.trim?.() ?? line)).join("\n\n"),
};
}
export function filterRankedTools<T extends BoundaryValue>(availableTools: readonly T[], toolNames: readonly string[]): T[] {
const selected = new Set(toolNames);
return availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
}
export function buildRankerContext(config: RuntimeConfigSnapshot, provider: AiProvider, target: AiRuntimeTarget, round: number, userQuery: string, availableTools: readonly BoundaryValue[]): ToolRankerContext {
return {
provider,
round,
userQuery,
availableTools,
targetModel: target.model,
rankerPrompt: config.rankerToolPrompt,
promptAdditions: target.systemPromptAdditions ?? null,
};
}
export function buildRankerTarget(config: RuntimeConfigSnapshot, provider: AiProvider): AiRuntimeTarget | undefined {
const target = provider === AiProvider.OLLAMA
? config.ollamaToolRankerTarget
: provider === AiProvider.MISTRAL
? config.mistralToolRankerTarget
: provider === AiProvider.OPENAI
? config.openAiToolRankerTarget
: undefined;
if (!target?.model) return undefined;
return {
provider: target.provider,
purpose: target.purpose,
model: target.model,
baseUrl: target.baseUrl,
apiKey: target.apiKey,
systemPromptAdditions: target.systemPromptAdditions ?? null,
};
}
+28
View File
@@ -0,0 +1,28 @@
import type {StoredAttachment} from "../model/stored-attachment";
import type {ToolCallData} from "./unified-ai-runner.shared";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistToolResultArtifactAttachment(params: {
toolCall: ToolCallData;
resultText: string;
chatId: number;
messageId: number;
}): Promise<StoredAttachment> {
return await persistInternalJsonArtifactAttachment({
artifactKind: "tool_result",
fileNamePrefix: `tool-${params.toolCall.name}`,
chatId: params.chatId,
messageId: params.messageId,
payload: {
toolName: params.toolCall.name,
callId: params.toolCall.id,
argumentsText: params.toolCall.argumentsText,
resultText: params.resultText,
},
metadata: {
toolName: params.toolCall.name,
callId: params.toolCall.id,
resultChars: params.resultText.length,
},
});
}
+33
View File
@@ -0,0 +1,33 @@
import type {BoundaryValue} from "../common/boundary-types.js";
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asOptionalString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function toolSchemaName(tool: BoundaryValue): string | undefined {
if (!isRecord(tool)) return undefined;
const fn = isRecord(tool.function) ? tool.function : undefined;
const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
return asOptionalString(directName);
}
export function toolSchemaNames(tool: BoundaryValue): string[] {
if (!isRecord(tool)) return [];
if (Array.isArray(tool.functionDeclarations)) {
return tool.functionDeclarations
.map(declaration => isRecord(declaration) ? asOptionalString(declaration.name) : undefined)
.filter((name): name is string => !!name);
}
const name = toolSchemaName(tool);
return name ? [name] : [];
}
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] {
return [...new Set(tools.flatMap(toolSchemaNames))];
}
+38
View File
@@ -0,0 +1,38 @@
export type AiJsonPrimitive = string | number | boolean | null;
export interface AiJsonObject {
readonly [key: string]: AiJsonValue;
}
export type AiJsonValue = AiJsonPrimitive | undefined | readonly AiJsonValue[] | AiJsonObject;
export interface AiToolParameters {
type: "object" | "string" | "number" | "integer" | "boolean" | "array";
properties?: Record<string, AiToolParameters>;
required?: readonly string[];
items?: AiToolParameters;
enum?: readonly string[];
description?: string;
minItems?: number;
maxItems?: number;
minimum?: number;
maximum?: number;
default?: AiJsonValue;
additionalProperties?: boolean | AiToolParameters;
}
export type AiTool = {
type: "function";
function: {
name: string;
description?: string;
type?: string;
parameters?: AiToolParameters;
};
};
export type AiToolCall = {
function: {
name: string;
arguments: AiJsonObject;
};
};
+91
View File
@@ -0,0 +1,91 @@
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}`};
}
}
+93
View File
@@ -0,0 +1,93 @@
import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils.js";
import {AiJsonObject} from "../tool-types";
export const getCurrentDateTimeTool = {
type: "function",
function: {
name: "get_datetime",
description:
"Get the real current date and time. Use this tool before answering any request that depends on today, now, current time, current date, weekday, timestamp, timezone conversion, or relative dates like yesterday, tomorrow, next week, or 3 days ago.",
parameters: {
type: "object",
properties: {
timeZone: {
type: "string",
description:
"Optional IANA timezone, for example Europe/Moscow, Europe/Berlin, UTC. If omitted, system timezone is used.",
},
locale: {
type: "string",
description:
"Optional locale, for example ru-RU or en-US. If omitted, system locale/default locale is used.",
},
},
required: [],
},
},
} satisfies AiTool;
export const dateTimeToolPrompt = [
"Datetime tool rules:",
"- Use `get_datetime` whenever the answer depends on the real current date/time.",
"- Never guess the current date/time. Call the tool first.",
"",
"Arguments:",
"- `timeZone`: optional IANA timezone, e.g. `Europe/Moscow`, `Europe/Berlin`, `UTC`.",
"- `locale`: optional locale, e.g. `ru-RU`, `en-US`.",
"",
"After the tool returns:",
"- Base the answer on the returned value.",
"- Do not expose raw tool JSON unless asked.",
].join("\n");
function getSystemTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function getCurrentDateTime(args?: AiJsonObject) {
const now = new Date();
const systemTimeZone = getSystemTimeZone();
const requestedTimeZone = asNonEmptyString(args?.timeZone);
const requestedLocale = asNonEmptyString(args?.locale);
const timeZone = requestedTimeZone ?? systemTimeZone;
const locale = requestedLocale ?? undefined;
try {
const formatted = new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "full",
timeStyle: "long",
}).format(now);
return {
iso: now.toISOString(),
unixMs: now.getTime(),
timeZone,
systemTimeZone,
locale: locale ?? "system-default",
formatted,
};
} catch (error) {
const formatted = new Intl.DateTimeFormat(undefined, {
timeZone: systemTimeZone,
dateStyle: "full",
timeStyle: "long",
}).format(now);
return {
iso: now.toISOString(),
unixMs: now.getTime(),
timeZone: systemTimeZone,
systemTimeZone,
locale: "system-default",
formatted,
warning: "Invalid locale or timezone was provided. Fallback to system locale and system timezone was used.",
requestedTimeZone: requestedTimeZone ?? null,
requestedLocale: requestedLocale ?? null,
error: error instanceof Error ? error.message : String(error),
};
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
export const MAX_FILE_READ_BYTES = 128 * 1024 * 1024;
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;
+78
View File
@@ -0,0 +1,78 @@
import {AiTool} from "../tool-types.js";
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 = {
type: "function",
function: {
name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
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: {
type: "object",
properties: {},
required: [],
},
},
} satisfies AiTool;
export const getFinancialMarketDataToolPrompt = [
"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.`,
"- 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.",
"",
"Supported pairs:",
"- `usd_to_rub`: USD to RUB.",
"- `usd_to_eur`: USD to EUR.",
"- `usd_to_kzt`: USD to KZT.",
"- `usd_to_uah`: USD to UAH.",
"- `usd_to_byn`: USD to BYN.",
"- `usd_to_gbp`: USD to GBP.",
"- `usd_to_cny`: USD to CNY.",
"- `ton_to_usd`: TON to USD.",
"- `btc_to_usd`: BTC to USD.",
"- `eth_to_usd`: ETH to USD.",
"- `sol_to_usd`: SOL to USD.",
"- `xau_to_usd`: gold/XAU to USD.",
"",
"Arguments:",
"- This tool takes no arguments.",
"",
"Returned data:",
"- Each supported pair contains `rate` with the latest available rate.",
"- Each supported pair may contain `change.absolute` with the absolute 24-hour change.",
"- Each supported pair may contain `change.percent` with the percentage 24-hour change.",
"- Each supported pair may contain `change.direction` with the movement direction, e.g. `up`, `down`, or `flat`.",
"- `has_24h_comparison`: whether 24-hour comparison data is available.",
"",
"After the tool returns:",
"- Base the answer only on the returned values.",
"- If `has_24h_comparison` is false, provide only the current rates and say that 24-hour comparison is unavailable.",
"- Do not expose raw tool JSON unless asked.",
"- Format the answer in a user-friendly way.",
"- For fiat pairs, show the rate with the target currency, for example: `USD/RUB is 75.22 RUB, down 0.16% over 24 hours.`",
"- For crypto and gold pairs, show the USD price, for example: `BTC/USD is $81,451.66, up 0.22% over 24 hours.`",
"- 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();
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)});
return undefined;
}
}
+449
View File
@@ -0,0 +1,449 @@
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}`};
}
}
+822
View File
@@ -0,0 +1,822 @@
import {spawn} from "node:child_process";
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {AiTool} from "../tool-types.js";
import {Environment} from "../../common/environment.js";
import {toolsLogger} from "./tool-logger.js";
import {randomUUID} from "node:crypto";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("python-interpreter");
export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter";
export type PythonInterpreterArgs = {
/**
* Full Python 3 script.
* The model should use print(...) to expose useful output.
*/
code: string;
/**
* Optional stdin passed to the Python process.
*/
stdin?: string;
/**
* Optional timeout override.
*/
timeoutMs?: number;
};
export type PythonInterpreterOptions = {
pythonBinary?: string;
syntaxTimeoutMs?: number;
executionTimeoutMs?: number;
maxCodeChars?: number;
maxOutputChars?: number;
maxArtifactBytes?: number;
maxArtifactCount?: number;
inputFiles?: PythonInterpreterInputFile[];
};
type ProcessRunResult = {
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
outputTruncated: boolean;
durationMs: number;
};
export type PythonInterpreterInputFile = {
kind?: string;
path: string;
fileName: string;
mimeType?: string;
};
export type PythonInterpreterRuntimeInputFile = PythonInterpreterInputFile & {
index: number;
path: string;
sourcePath: string;
relativePath: string;
sizeBytes: number;
};
export type PythonInterpreterArtifact = {
kind: "image" | "file";
path: string;
relativePath: string;
fileName: string;
mimeType?: string;
sizeBytes: number;
};
export type PythonInterpreterSkippedArtifact = {
path: string;
relativePath: string;
fileName: string;
sizeBytes?: number;
reason: string;
maxSizeBytes?: number;
};
export type PythonToolResult =
| {
ok: true;
phase: "execution";
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number;
outputTruncated: boolean;
inputDir?: string;
outputDir?: string;
inputFiles?: PythonInterpreterRuntimeInputFile[];
artifacts?: PythonInterpreterArtifact[];
skippedArtifacts?: PythonInterpreterSkippedArtifact[];
}
| {
ok: false;
phase: "syntax" | "execution" | "internal";
error: string;
stdout?: string;
stderr?: string;
exitCode?: number | null;
signal?: NodeJS.Signals | null;
timedOut?: boolean;
durationMs?: number;
outputTruncated?: boolean;
};
const DEFAULT_PYTHON_BINARY = process.platform === "win32" ? "python" : "python3";
const DEFAULT_SYNTAX_TIMEOUT_MS = 3_000;
const DEFAULT_EXECUTION_TIMEOUT_MS = 8_000;
const DEFAULT_MAX_CODE_CHARS = 100_000;
const DEFAULT_MAX_OUTPUT_CHARS = 20_000;
export const PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
const DEFAULT_MAX_ARTIFACT_COUNT = 20;
const PYTHON_INPUTS_DIR_NAME = "inputs";
const PYTHON_OUTPUTS_DIR_NAME = "outputs";
const PYTHON_ATTACHMENTS_FILE_NAME = "attachments.json";
const PYTHON_USER_CODE_FILE_NAME = "user_code.py";
const PYTHON_RUNNER_FILE_NAME = "main.py";
const PYTHON_CODE_TEMPLATE = [
"from pathlib import Path",
"import json",
"",
"# These globals are predefined by the python_interpreter runtime:",
"# INPUT_DIR = Path('inputs')",
"# OUTPUT_DIR = Path('outputs')",
"# ATTACHMENTS_FILE = Path('attachments.json')",
"",
"attachments = load_attachments()",
"# Read attached files from INPUT_DIR, for example:",
"# text = (INPUT_DIR/attachments[0]['fileName']).read_text(encoding='utf-8')",
"",
"# Save every user-visible generated file into outputs.",
"# Example:",
"# (OUTPUT_DIR/'result.txt').write_text('done', encoding='utf-8')",
"",
"print('done')",
].join("\n");
export const pythonInterpreterToolPrompt = [
"Python interpreter rules:",
"- You have access to the `python_interpreter` tool for Python 3 code.",
"- Each Python run starts in a temporary workspace.",
"- Incoming user files are always in `inputs/`.",
"- Outgoing user-visible files must always be saved into `outputs/`.",
"- Attachment metadata is always in `attachments.json`.",
"- The runtime predefines these globals in executed code: `INPUT_DIR`, `OUTPUT_DIR`, `ATTACHMENTS_FILE`, `WORK_DIR`, `input_path(name)`, `output_path(name)`, and `load_attachments()`.",
"- Use `input_path(filename)` for reading incoming files.",
"- Use `output_path(filename)` for files that should be returned to the user.",
"- Do not invent other directories for user attachments or generated artifacts.",
"- Prefer this template:",
"```python",
PYTHON_CODE_TEMPLATE,
"```",
"",
].join("\n");
export const pythonInterpreterTool = {
type: "function",
function: {
name: PYTHON_INTERPRETER_TOOL_NAME,
description:
"Validate and execute short Python 3 code. Use for calculations, data transformations, parsing, chart rendering, and image/file processing. The code must print useful text results. The runtime always creates hardcoded directories `inputs/` and `outputs/` in the current working directory. User attachments are copied into `inputs/` and described in `attachments.json`. The executed code has predefined globals: INPUT_DIR, OUTPUT_DIR, ATTACHMENTS_FILE, WORK_DIR, input_path(name), output_path(name), and load_attachments(). Put every user-visible output image or file into `outputs/`; every regular file there up to 50 MB will be returned by the tool and sent to the user.",
parameters: {
type: "object",
required: ["code"],
properties: {
code: {
type: "string",
description:
`Complete Python 3 script to execute. Use print(...) for the final answer. Do not use markdown fences. Read incoming files only from INPUT_DIR / "file" or input_path("file"). Save charts/images/files intended for the user only into OUTPUT_DIR / "file" or output_path("file"). You can inspect attachments via load_attachments(). Template:\n${PYTHON_CODE_TEMPLATE}`,
},
stdin: {
type: "string",
description: "Optional stdin passed to the Python script.",
},
timeoutMs: {
type: "integer",
description: "Optional execution timeout in milliseconds. Default is 8000.",
},
},
},
},
} satisfies AiTool;
export async function runPythonInterpreter(
rawArgs: string | AiJsonObject | undefined,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
let args: PythonInterpreterArgs;
try {
args = parsePythonInterpreterArgs(rawArgs, options);
} catch (error) {
return {
ok: false,
phase: "internal",
error: errorToString(error instanceof Error ? error : String(error)),
};
}
const syntaxStartedAt = Date.now();
const syntax = await validatePythonSyntax(args.code, options);
logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok});
if (!syntax.ok) {
return syntax;
}
const executionStartedAt = Date.now();
const result = await executePythonCode(args, options);
logger.debug("execution.done", {duration: logger.duration(executionStartedAt), ok: result.ok, phase: result.phase});
return result;
}
export async function validatePythonSyntax(
code: string,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
const pythonBinary = options.pythonBinary ?? DEFAULT_PYTHON_BINARY;
const timeoutMs = options.syntaxTimeoutMs ?? DEFAULT_SYNTAX_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
const syntaxCheckScript = `
import ast
import sys
source = sys.stdin.read()
try:
ast.parse(source, filename="<llm_python>")
except SyntaxError as e:
print(f"SyntaxError: {e.msg} at line {e.lineno}, column {e.offset}", file=sys.stderr)
if e.text:
print(e.text.rstrip(), file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"{type(e).__name__}: {e}", file=sys.stderr)
sys.exit(1)
`.trim();
const result = await runProcess({
command: pythonBinary,
args: ["-I", "-B", "-S", "-c", syntaxCheckScript],
input: code,
timeoutMs,
maxOutputChars,
env: buildSafeEnv(),
});
if (result.timedOut) {
return {
ok: false,
phase: "syntax",
error: `Python syntax check timed out after ${timeoutMs} ms.`,
stderr: result.stderr,
durationMs: result.durationMs,
timedOut: true,
outputTruncated: result.outputTruncated,
};
}
if (result.exitCode !== 0) {
return {
ok: false,
phase: "syntax",
error: result.stderr.trim() || "Python syntax check failed.",
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
return {
ok: true,
phase: "execution",
stdout: "",
stderr: "",
exitCode: 0,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
async function executePythonCode(
args: PythonInterpreterArgs,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
const startedAt = Date.now();
logger.info("execute.start", {args, options});
const pythonBinary =
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "python";
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 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);
await mkdir(tempDir, {recursive: true});
await mkdir(inputDir, {recursive: true});
await mkdir(outputDir, {recursive: true});
const userScriptPath = path.join(tempDir, PYTHON_USER_CODE_FILE_NAME);
const runnerPath = path.join(tempDir, PYTHON_RUNNER_FILE_NAME);
try {
const inputFiles = await prepareInputFiles(options.inputFiles ?? [], inputDir);
await writeFile(attachmentsPath, JSON.stringify(inputFiles, null, 2), {
encoding: "utf8",
mode: 0o600,
});
await writeFile(userScriptPath, args.code, {
encoding: "utf8",
mode: 0o600,
});
await writeFile(runnerPath, buildPythonRunnerScript(), {
encoding: "utf8",
mode: 0o600,
});
logger.debug("script.written", {tempDir, userScriptPath, runnerPath, duration: logger.duration(startedAt)});
const result = await runProcess({
command: pythonBinary,
args: ["-I", "-B", runnerPath],
input: args.stdin ?? "",
cwd: tempDir,
timeoutMs,
maxOutputChars,
env: {
...buildSafeEnv(tempDir),
PYTHON_INPUT_DIR: inputDir,
PYTHON_OUTPUT_DIR: outputDir,
PYTHON_ATTACHMENTS_FILE: attachmentsPath,
},
});
logger.debug("process.done", {
duration: logger.duration(startedAt),
exitCode: result.exitCode,
timedOut: result.timedOut,
outputTruncated: result.outputTruncated
});
if (result.timedOut) {
logger.warn("process.timeout", {duration: logger.duration(startedAt)});
return {
ok: false,
phase: "execution",
error: `Python execution timed out after ${timeoutMs} ms.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: true,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
if (result.outputTruncated) {
logger.warn("process.output_truncated", {
duration: logger.duration(startedAt),
stdoutChars: result.stdout.length,
stderrChars: result.stderr.length
});
return {
ok: false,
phase: "execution",
error: `Python output exceeded limit of ${maxOutputChars} characters.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: false,
durationMs: result.durationMs,
outputTruncated: true,
};
}
if (result.exitCode !== 0) {
logger.warn("process.non_zero_exit", {duration: logger.duration(startedAt), result});
return {
ok: false,
phase: "execution",
error: result.stderr.trim() || `Python exited with code ${result.exitCode}.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: false,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
logger.debug("process.ok", {duration: logger.duration(startedAt)});
const {
artifacts,
skippedArtifacts
} = await collectOutputArtifacts(outputDir, options);
return {
ok: true,
phase: "execution",
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
inputDir,
outputDir,
inputFiles,
artifacts,
skippedArtifacts,
};
} catch (error) {
logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
return {
ok: false,
phase: "internal",
error: errorToString(error instanceof Error ? error : String(error)),
};
} finally {
await rm(tempDir, {
recursive: true,
force: true,
});
}
}
function buildPythonRunnerScript(): string {
return `
import json
import runpy
from pathlib import Path
WORK_DIR = Path(__file__).resolve().parent
INPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_INPUTS_DIR_NAME)}
OUTPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_OUTPUTS_DIR_NAME)}
ATTACHMENTS_FILE = WORK_DIR / ${JSON.stringify(PYTHON_ATTACHMENTS_FILE_NAME)}
USER_CODE_FILE = WORK_DIR / ${JSON.stringify(PYTHON_USER_CODE_FILE_NAME)}
INPUT_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def input_path(name=""):
return INPUT_DIR / name
def output_path(name=""):
return OUTPUT_DIR / name
def load_attachments():
if not ATTACHMENTS_FILE.exists():
return []
return json.loads(ATTACHMENTS_FILE.read_text(encoding="utf-8"))
runpy.run_path(
str(USER_CODE_FILE),
run_name="__main__",
init_globals={
"Path": Path,
"WORK_DIR": WORK_DIR,
"INPUT_DIR": INPUT_DIR,
"OUTPUT_DIR": OUTPUT_DIR,
"ATTACHMENTS_FILE": ATTACHMENTS_FILE,
"input_path": input_path,
"output_path": output_path,
"load_attachments": load_attachments,
},
)
`.trimStart();
}
async function prepareInputFiles(
inputFiles: PythonInterpreterInputFile[],
inputDir: string,
): Promise<PythonInterpreterRuntimeInputFile[]> {
const prepared: PythonInterpreterRuntimeInputFile[] = [];
for (const [index, file] of inputFiles.entries()) {
const sourcePath = path.resolve(file.path);
const info = await lstat(sourcePath).catch(() => null);
if (!info?.isFile()) continue;
const fileName = uniqueInputFileName(index, file.fileName || path.basename(sourcePath));
const runtimePath = path.join(inputDir, fileName);
await copyFile(sourcePath, runtimePath);
prepared.push({
...file,
index,
path: runtimePath,
sourcePath,
relativePath: path.join(PYTHON_INPUTS_DIR_NAME, fileName).replace(/\\/g, "/"),
sizeBytes: info.size,
fileName,
});
}
return prepared;
}
async function collectOutputArtifacts(
outputDir: string,
options: PythonInterpreterOptions,
): Promise<{
artifacts: PythonInterpreterArtifact[];
skippedArtifacts: PythonInterpreterSkippedArtifact[];
}> {
const maxBytes = options.maxArtifactBytes ?? PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES;
const maxCount = options.maxArtifactCount ?? DEFAULT_MAX_ARTIFACT_COUNT;
const artifacts: PythonInterpreterArtifact[] = [];
const skippedArtifacts: PythonInterpreterSkippedArtifact[] = [];
const walk = async (dir: string): Promise<void> => {
const entries = await readdir(dir, {withFileTypes: true}).catch(() => []);
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const info = await lstat(fullPath).catch(() => null);
if (!info) continue;
const relativePath = path.relative(outputDir, fullPath).replace(/\\/g, "/");
if (info.isSymbolicLink()) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName: safeFileName(entry.name),
reason: "Symbolic links are not returned.",
});
continue;
}
if (info.isDirectory()) {
await walk(fullPath);
continue;
}
if (!info.isFile()) continue;
const fileName = safeFileName(entry.name);
if (info.size > maxBytes) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: `File exceeds the ${maxBytes} byte limit.`,
maxSizeBytes: maxBytes,
});
continue;
}
if (artifacts.length >= maxCount) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: `Artifact count exceeds the ${maxCount} file limit.`,
});
continue;
}
const mimeType = mimeTypeFromPath(fullPath);
if (mimeType) {
artifacts.push({
kind: mimeType?.startsWith("image/") ? "image" : "file",
path: fullPath,
relativePath,
fileName,
mimeType,
sizeBytes: info.size,
});
} else {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: "Unsupported mimeType for extension " + path.extname(fullPath)
});
}
}
};
await walk(outputDir);
return {artifacts, skippedArtifacts};
}
function safeFileName(value: string): string {
const sanitized = path.basename(value)
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_")
.trim()
.slice(0, 180);
return sanitized || "file";
}
function uniqueInputFileName(index: number, value: string): string {
const safe = safeFileName(value);
const ext = path.extname(safe);
const base = path.basename(safe, ext).slice(0, 140) || "input";
return `${index + 1}_${base}${ext}`;
}
function mimeTypeFromPath(filePath: string): string | undefined {
switch (path.extname(filePath).toLowerCase()) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
case ".bmp":
return "image/bmp";
case ".svg":
return "image/svg+xml";
case ".pdf":
return "application/pdf";
case ".txt":
return "text/plain";
case ".csv":
return "text/csv";
case ".json":
return "application/json";
case ".zip":
return "application/zip";
case ".mp3":
return "audio/mpeg";
case ".wav":
return "audio/wav";
case ".mp4":
return "video/mp4";
default:
return undefined;
}
}
function parsePythonInterpreterArgs(
rawArgs: string | AiJsonObject | undefined,
options: PythonInterpreterOptions,
): PythonInterpreterArgs {
let args = rawArgs;
if (typeof rawArgs === "string") {
try {
args = JSON.parse(rawArgs);
} catch {
args = {code: rawArgs};
}
}
if (!args || typeof args !== "object" || Array.isArray(args)) {
throw new Error("Tool arguments must be an object.");
}
const record = args as AiJsonObject;
const code = record.code;
if (typeof code !== "string" || !code.trim()) {
throw new Error("Tool argument `code` must be a non-empty string.");
}
const maxCodeChars = options.maxCodeChars ?? DEFAULT_MAX_CODE_CHARS;
if (code.length > maxCodeChars) {
throw new Error(`Python code is too large: ${code.length} chars, max ${maxCodeChars}.`);
}
const stdin = record.stdin;
if (stdin !== undefined && typeof stdin !== "string") {
throw new Error("Tool argument `stdin` must be a string when provided.");
}
const timeoutMs = record.timeoutMs;
if (
timeoutMs !== undefined &&
(!Number.isInteger(timeoutMs) || Number(timeoutMs) < 100 || Number(timeoutMs) > 60_000)
) {
throw new Error("Tool argument `timeoutMs` must be an integer from 100 to 60000.");
}
return {
code,
stdin: typeof stdin === "string" ? stdin : undefined,
timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
};
}
async function runProcess(params: {
command: string;
args: string[];
input?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs: number;
maxOutputChars: number;
}): Promise<ProcessRunResult> {
const startedAt = Date.now();
return new Promise<ProcessRunResult>((resolve) => {
let stdout = "";
let stderr = "";
let timedOut = false;
let outputTruncated = false;
let settled = false;
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
const finish = (result: Omit<ProcessRunResult, "durationMs">) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
...result,
durationMs: Date.now() - startedAt,
});
};
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs);
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer) => {
const text = chunk.toString("utf8");
if (target === "stdout") {
stdout += text;
} else {
stderr += text;
}
const total = stdout.length + stderr.length;
if (total > params.maxOutputChars) {
outputTruncated = true;
stdout = stdout.slice(0, params.maxOutputChars);
stderr = stderr.slice(0, params.maxOutputChars);
child.kill("SIGKILL");
}
};
child.stdout.on("data", (chunk: Buffer) => appendOutput("stdout", chunk));
child.stderr.on("data", (chunk: Buffer) => appendOutput("stderr", chunk));
child.on("error", (error) => {
finish({
exitCode: null,
signal: null,
stdout,
stderr: stderr + `\n${errorToString(error)}`,
timedOut,
outputTruncated,
});
});
child.on("close", (exitCode, signal) => {
finish({
exitCode,
signal,
stdout,
stderr,
timedOut,
outputTruncated,
});
});
child.stdin.end(params.input ?? "");
});
}
function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH ?? "",
PATHEXT: process.env.PATHEXT ?? "",
SystemRoot: process.env.SystemRoot ?? "",
HOME: tempDir ?? os.tmpdir(),
USERPROFILE: tempDir ?? os.tmpdir(),
TEMP: tempDir ?? os.tmpdir(),
TMP: tempDir ?? os.tmpdir(),
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8",
};
}
function errorToString(error: Error | string | object | null | undefined): string {
if (error instanceof Error) {
return error.stack || error.message;
}
return String(error);
}
+255
View File
@@ -0,0 +1,255 @@
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 {
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,
createDirectoryTool,
createFile,
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";
export const defaultTools: AiTool[] = [
getCurrentDateTimeTool,
getFinancialMarketData,
...memoryTools,
];
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]));
}
if (Environment.ENABLE_UNSAFE_EVAL) {
tools.push(...filterEnabledTools([shellExecuteTool]));
}
}
tools.push(...getMcpTools());
return tools;
};
export const fileToolHandlers = {
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,
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);
};
}
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;
}
+66
View File
@@ -0,0 +1,66 @@
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");
export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[];
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
} & MemoryRuntimeContext;
function stringifyToolResult(result: AiJsonValue): 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,
context: ToolRuntimeContext = {},
): Promise<string> {
const startedAt = Date.now();
const handler = getToolHandlers()[name];
logger.info("execute.start", {name, args});
if (!handler) {
return stringifyToolResult({
error: `Unknown tool: ${name}`,
});
}
try {
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
const result = await runPythonInterpreter(normalizeToolArguments(args, userId), {
executionTimeoutMs: 8_000,
syntaxTimeoutMs: 3_000,
maxCodeChars: 100_000,
maxOutputChars: 20_000,
inputFiles: context.pythonInputFiles,
});
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
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;
} 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),
});
}
}
+395
View File
@@ -0,0 +1,395 @@
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}`;
}
+110
View File
@@ -0,0 +1,110 @@
import {AiTool} from "../tool-types";
import {runCommand} from "../../util/utils.js";
import {asNonEmptyString} from "./utils.js";
import {AiJsonObject} from "../tool-types";
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.",
parameters: {
type: "object",
properties: {
cmd: {
type: "string",
description: "Actual command to execute in a shell"
}
},
required: ["cmd"]
}
}
} satisfies AiTool;
export const shellExecuteToolPrompt = [
"Shell tool rules:",
"- You have access to the `shell_execute` tool.",
"- `shell_execute` executes a shell command on the server.",
"- This tool is powerful and potentially dangerous.",
"- Use this tool only when command execution is actually necessary.",
"- Prefer specialized tools when available, for example filesystem tools for reading, creating, updating, copying, moving or deleting files.",
"",
"Platform awareness:",
"- 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.",
"- Safe OS inspection examples:",
" - Node.js: `node -p \"process.platform\"`",
" - Node.js: `node -p \"process.cwd()\"`",
" - Windows CMD: `ver`",
" - PowerShell: `$PSVersionTable.PSVersion`",
" - POSIX shell: `uname -a`",
"",
"Preferred safe commands:",
"- Prefer read-only commands.",
"- Prefer short, explicit and predictable commands.",
"- Cross-platform when Node.js is available:",
" - `node -p \"process.cwd()\"`",
" - `node -p \"process.platform\"`",
" - `node -e \"console.log(require('fs').readdirSync('.'))\"`",
"- POSIX examples:",
" - `pwd`, `ls`, `find`, `cat`, `head`, `tail`, `grep`, `sed -n`, `wc`, `stat`, `file`, `du`, `df`, `ps`.",
"- Windows CMD examples:",
" - `cd`, `dir`, `type`, `where`, `findstr`.",
"- PowerShell examples:",
" - `Get-Location`, `Get-ChildItem`, `Get-Content`, `Select-String`, `Measure-Object`, `Get-Item`, `Get-Process`.",
"",
"Filesystem restrictions:",
"- Work only inside the allowed project/root directory.",
"- Use relative paths when possible.",
"- Do not use absolute paths unless the user explicitly asks and it is safe.",
"- Do not use `..` to go to parent directories.",
"- Do not access files outside the allowed root directory.",
"- Do not follow or use symlinks to escape the allowed root directory.",
"",
"Forbidden actions unless the user explicitly asks and the action is clearly safe:",
"- Do not delete files or directories.",
"- Do not overwrite files.",
"- Do not move files.",
"- Do not change permissions.",
"- Do not change ownership.",
"- Do not install packages.",
"- Do not update the system.",
"- Do not start, stop or restart services.",
"- Do not run background processes.",
"- Do not run long-running commands.",
"- Do not run infinite loops.",
"- Do not use fork bombs.",
"- Do not use privilege escalation.",
"",
"Forbidden command examples:",
"- POSIX: `sudo`, `su`, `rm`, `rmdir`, `chmod`, `chown`, `dd`, `mkfs`, `mount`, `umount`, `kill`, `reboot`, `shutdown`.",
"- Windows CMD: `del`, `erase`, `rmdir`, `rd`, `format`, `shutdown`, `taskkill`.",
"- PowerShell: `Remove-Item`, `Move-Item`, `Set-ItemProperty`, `Stop-Process`, `Restart-Computer`, `Stop-Computer`.",
"",
"Network restrictions:",
"- Do not make network requests unless the user explicitly asks.",
"- Do not use `curl`, `wget`, `Invoke-WebRequest`, `Invoke-RestMethod`, `ssh`, `scp`, `rsync`, `nc`, `nmap` unless explicitly requested and safe.",
"",
"Secrets and privacy:",
"- Never read secrets, tokens, API keys, passwords, private keys, certificates, `.env` files, SSH keys, browser data or credential stores unless the user explicitly asks and it is necessary.",
"- If command output contains secrets, do not repeat them back to the user.",
"",
"Command construction:",
"- Do not execute untrusted user text directly as shell code.",
"- Quote paths and arguments safely.",
"- Avoid command chaining with `;`, `&&`, `||`, pipes, backticks or command substitution unless necessary.",
"- Avoid glob patterns that may affect too many files.",
"- If unsure whether a command is safe, do not run it.",
"",
].join("\n");
export async function shellExecute(args?: AiJsonObject): Promise<string | undefined | null> {
const cmd = asNonEmptyString(args?.cmd);
if (!cmd) return undefined;
const {stdout, stderr} = await runCommand(cmd);
return stdout ?? stderr;
}
+3
View File
@@ -0,0 +1,3 @@
import {appLogger} from "../../logging/logger.js";
export const toolsLogger = appLogger.child("ai-tools");
+4
View File
@@ -0,0 +1,4 @@
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;
+582
View File
@@ -0,0 +1,582 @@
import path from "node:path";
import {readFile, rename, writeFile, mkdir, rm} from "node:fs/promises";
import {AiProvider} from "../../model/ai-provider.js";
import {Environment} from "../../common/environment.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveOptionalAiRuntimeTarget, type AiRuntimeTarget} from "../ai-runtime-target.js";
import {AiTool} from "../tool-types.js";
import {toolsLogger} from "./tool-logger.js";
import {asNonEmptyString} from "./utils.js";
const logger = toolsLogger.child("user-memory");
function memoryDir(): string {
return path.join(Environment.DATA_PATH, "memory");
}
export const USER_MEMORY_MAX_CHARS = 1000;
export type MemoryScope = "user" | "system";
export type MemoryAction = "add" | "replace" | "remove";
export type MemoryRuntimeContext = {
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
};
export type MemoryOperationResult =
| {success: true; scope: MemoryScope; filePath: string; content: string; chars: number; compressed: boolean}
| {success: false; scope: MemoryScope; error: string};
type CompressionRunResult = {
content: string;
};
export type MemoryCompressionRunner = (params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}) => Promise<string>;
function extractMistralText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part && typeof (part as {text?: unknown}).text === "string") {
return (part as {text: string}).text;
}
return "";
})
.join("");
}
export type MemoryToolName =
| "read_user_info"
| "read_system_info"
| "add_user_info"
| "add_system_info"
| "remove_user_info"
| "remove_system_info"
| "replace_user_info"
| "replace_system_info"
| "delete_user_info"
| "delete_system_info";
export const MEMORY_TOOL_NAMES: MemoryToolName[] = [
"read_user_info",
"read_system_info",
"add_user_info",
"add_system_info",
"remove_user_info",
"remove_system_info",
"replace_user_info",
"replace_system_info",
"delete_user_info",
"delete_system_info",
];
type MemoryToolSpec = {
name: MemoryToolName;
scope: MemoryScope;
kind: "read" | "write" | "delete";
action?: MemoryAction;
description: string;
prompt: string;
};
const MEMORY_TOOL_SPECS: MemoryToolSpec[] = [
{
name: "read_user_info",
scope: "user",
kind: "read",
description: "Read persistent user memory from user.md.",
prompt: `Use when you need to inspect remembered user facts before editing or answering.`,
},
{
name: "read_system_info",
scope: "system",
kind: "read",
description: "Read persistent assistant memory from system.md.",
prompt: `Use when you need to inspect remembered assistant instructions before editing or answering.`,
},
{
name: "add_user_info",
scope: "user",
kind: "write",
action: "add",
description: "Append a durable fact about the user to user.md.",
prompt: `Use for new user facts, preferences, identity details, and profile information. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "add_system_info",
scope: "system",
kind: "write",
action: "add",
description: "Append a durable instruction about the assistant to system.md.",
prompt: `Use for new assistant identity, style, or behavior instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_user_info",
scope: "user",
kind: "write",
action: "remove",
description: "Remove a specific user fact or fragment from user.md.",
prompt: `Use when the user asks to forget something about themselves. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_system_info",
scope: "system",
kind: "write",
action: "remove",
description: "Remove a specific assistant instruction or fragment from system.md.",
prompt: `Use when the user asks to forget something about the assistant. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_user_info",
scope: "user",
kind: "write",
action: "replace",
description: "Replace user.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered user info, such as "forget everything about me and remember only this". Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_system_info",
scope: "system",
kind: "write",
action: "replace",
description: "Replace system.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered assistant info or instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "delete_user_info",
scope: "user",
kind: "delete",
description: "Delete the user memory file user.md.",
prompt: `Use when the user asks to delete all remembered user info and remove the memory file entirely.`,
},
{
name: "delete_system_info",
scope: "system",
kind: "delete",
description: "Delete the assistant memory file system.md.",
prompt: `Use when the user asks to delete all remembered assistant info and remove the memory file entirely.`,
},
];
export const memoryToolPrompt = [
"Use the memory tools to manage persistent per-user memory.",
"- `read_*` shows the current file content before editing.",
"- `user.md` stores durable facts about the user.",
"- `system.md` stores durable facts/instructions about the assistant itself.",
"- `add_*` appends a new fact or instruction.",
"- `remove_*` removes a specific fact or fragment.",
"- `replace_*` rewrites the whole file when the user wants to overwrite memory.",
"- `delete_*` removes the file entirely.",
`- Keep each file at or below ${USER_MEMORY_MAX_CHARS} characters.`,
].join("\n");
function createMemoryTool(spec: MemoryToolSpec): AiTool {
return {
type: "function",
function: {
name: spec.name,
description: spec.description,
parameters: {
type: "object",
properties: spec.kind === "read" || spec.kind === "delete" ? {} : {
content: {
type: "string",
description: spec.action === "remove"
? "Exact text or fragment to remove from memory."
: "Text to append or replace in memory.",
},
},
required: spec.kind === "read" || spec.kind === "delete" ? [] : ["content"],
},
},
} satisfies AiTool;
}
export const memoryTools = MEMORY_TOOL_SPECS.map(createMemoryTool);
function normalizeUserId(userId: number): number | null {
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
}
function normalizeMemoryText(value: string): string {
return value.replaceAll("\r\n", "\n");
}
function getMemoryUserDir(userId: number): string {
return path.join(memoryDir(), String(userId));
}
export function getMemoryFilePath(userId: number, scope: MemoryScope): string {
return path.join(getMemoryUserDir(userId), `${scope}.md`);
}
async function ensureMemoryDir(userId: number): Promise<string> {
const dir = getMemoryUserDir(userId);
await mkdir(dir, {recursive: true});
return dir;
}
async function readMemoryFile(userId: number, scope: MemoryScope): Promise<string> {
const filePath = getMemoryFilePath(userId, scope);
try {
return normalizeMemoryText(await readFile(filePath, "utf-8"));
} catch (error) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
}
}
async function writeMemoryFile(userId: number, scope: MemoryScope, content: string): Promise<string> {
const normalized = normalizeMemoryText(content);
const filePath = getMemoryFilePath(userId, scope);
await ensureMemoryDir(userId);
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, normalized, "utf-8");
await rename(tempPath, filePath);
return filePath;
}
function trimToLimit(content: string, limit = USER_MEMORY_MAX_CHARS): string {
if (content.length <= limit) return content;
return content.slice(0, limit).trimEnd();
}
function stripCodeFences(content: string): string {
const trimmed = content.trim();
const fenced = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) return fenced[1].trim();
return trimmed;
}
function sameTarget(left: AiRuntimeTarget | undefined, right: AiRuntimeTarget | undefined): boolean {
if (!left || !right) return false;
return left.provider === right.provider
&& left.model === right.model
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
async function compressWithTarget(params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}): Promise<CompressionRunResult> {
const {target, scope, currentText, limit} = params;
const systemPrompt = [
"You compress persistent memory for a chat bot.",
"Return only the rewritten Markdown text.",
"Preserve important facts, preferences, identities, instructions, and durable context.",
"Remove noise, duplication, stale details, and low-value filler.",
`Keep the result at or below ${limit} characters.`,
"Do not add explanations, bullet labels, or code fences.",
].join("\n");
const userPrompt = [
`Memory scope: ${scope}`,
`Character limit: ${limit}`,
"Current memory:",
currentText.trim() || "(empty)",
"",
"Rewrite it as compact Markdown only.",
].join("\n");
logger.info("compress.start", {provider: target.provider, model: target.model, scope, chars: currentText.length});
switch (target.provider) {
case AiProvider.OPENAI: {
const client = createOpenAiClient(target);
const response = await client.chat.completions.create({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = response.choices[0]?.message?.content ?? "";
return {content: stripCodeFences(text)};
}
case AiProvider.MISTRAL: {
const client = createMistralClient(target);
const response = await client.chat.complete({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
} as Parameters<typeof client.chat.complete>[0]);
const text = extractMistralText(response.choices?.[0]?.message?.content);
return {content: stripCodeFences(text)};
}
case AiProvider.OLLAMA: {
const client = createOllamaClient(target);
const response = await client.chat({
model: target.model,
stream: false,
options: {temperature: 0},
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = typeof response.message?.content === "string" ? response.message.content : "";
return {content: stripCodeFences(text)};
}
}
}
export async function compressMemoryWithFallback(params: {
provider?: AiProvider;
currentTarget?: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit?: number;
}, runner: MemoryCompressionRunner = async (input) => (await compressWithTarget(input)).content): Promise<{content: string; compressed: boolean; usedTarget?: AiRuntimeTarget}> {
const limit = params.limit ?? USER_MEMORY_MAX_CHARS;
const trimmed = normalizeMemoryText(params.currentText);
if (trimmed.length <= limit) {
return {content: trimmed, compressed: false};
}
const explicitTarget = params.provider ? resolveOptionalAiRuntimeTarget(params.provider, "memoryCompress") : undefined;
const targets = [explicitTarget, params.currentTarget].filter((target, index, list): target is AiRuntimeTarget => !!target && list.findIndex(item => sameTarget(item, target)) === index);
for (const target of targets) {
try {
const content = trimToLimit(await runner({target, scope: params.scope, currentText: trimmed, limit}), limit);
if (content.length <= limit) {
return {content, compressed: true, usedTarget: target};
}
} catch (error) {
logger.warn("compress.failed", {
provider: params.provider,
scope: params.scope,
target: target.model,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {content: trimToLimit(trimmed, limit), compressed: true};
}
async function compressMemoryIfNeeded(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
limit?: number;
}): Promise<{content: string; compressed: boolean}> {
const {scope, context, limit = USER_MEMORY_MAX_CHARS} = params;
const result = await compressMemoryWithFallback({
provider: context?.provider,
currentTarget: context?.runtimeTarget,
scope,
currentText: params.content,
limit,
});
if (!result.compressed) {
return result;
}
if (result.content.length > limit) {
return {content: trimToLimit(result.content, limit), compressed: true};
}
return {content: result.content, compressed: true};
}
async function finalizeMemoryWrite(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
}): Promise<{filePath: string; content: string; compressed: boolean}> {
const {userId, scope, context} = params;
const compressed = await compressMemoryIfNeeded({userId, scope, content: params.content, context});
const filePath = await writeMemoryFile(userId, scope, compressed.content);
return {filePath, content: compressed.content, compressed: compressed.compressed};
}
function findMemoryToolSpec(toolName: string): MemoryToolSpec | undefined {
return MEMORY_TOOL_SPECS.find(spec => spec.name === toolName);
}
function isMemoryWriteTool(spec: MemoryToolSpec): spec is MemoryToolSpec & {kind: "write"; action: MemoryAction} {
return spec.kind === "write";
}
export async function buildUserMemoryPrompt(userId: number | undefined | null): Promise<string | undefined> {
const normalizedUserId = typeof userId === "number" ? normalizeUserId(userId) : null;
if (!normalizedUserId) return undefined;
const [userMemoryResult, systemMemoryResult] = await Promise.all([
readUserMemory(normalizedUserId, "user"),
readUserMemory(normalizedUserId, "system"),
]);
const userMemory = userMemoryResult.success ? userMemoryResult.content : "";
const systemMemory = systemMemoryResult.success ? systemMemoryResult.content : "";
const blocks: string[] = [];
if (systemMemory.trim()) {
blocks.push([
"## Assistant memory (system.md)",
"This is information about the assistant and its behavior.",
systemMemory.trim(),
].join("\n"));
}
if (userMemory.trim()) {
blocks.push([
"## User memory (user.md)",
"This is information about the user.",
userMemory.trim(),
].join("\n"));
}
return blocks.length ? blocks.join("\n\n") : undefined;
}
export async function readUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
try {
const content = await readMemoryFile(normalizedUserId, scope);
return {
success: true,
scope,
filePath: getMemoryFilePath(normalizedUserId, scope),
content,
chars: content.length,
compressed: false,
};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function updateUserMemory(args: {
userId: number;
scope: MemoryScope;
action: MemoryAction;
content?: string;
context?: MemoryRuntimeContext;
}): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(args.userId);
if (!normalizedUserId) {
return {success: false, scope: args.scope, error: "Invalid userId"};
}
try {
const current = await readMemoryFile(normalizedUserId, args.scope);
let next = current;
switch (args.action) {
case "add": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!content.trim()) {
return {success: false, scope: args.scope, error: "No content provided"};
}
next = [current.trimEnd(), content.trim()].filter(Boolean).join(current.trim() ? "\n\n" : "");
break;
}
case "replace": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
next = content;
break;
}
case "remove": {
const needle = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!needle.trim()) {
return {success: false, scope: args.scope, error: "No text to remove provided"};
}
if (!current.includes(needle)) {
return {success: false, scope: args.scope, error: "Text not found in memory"};
}
next = current.split(needle).join("").trim();
break;
}
}
const finalized = await finalizeMemoryWrite({userId: normalizedUserId, scope: args.scope, content: next, context: args.context});
logger.debug("write.done", {
userId: normalizedUserId,
scope: args.scope,
chars: finalized.content.length,
compressed: finalized.compressed,
filePath: finalized.filePath,
});
return {
success: true,
scope: args.scope,
filePath: finalized.filePath,
content: finalized.content,
chars: finalized.content.length,
compressed: finalized.compressed,
};
} catch (error) {
return {success: false, scope: args.scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function executeMemoryTool(toolName: MemoryToolName, args: {userId: number; content?: string}, context?: MemoryRuntimeContext): Promise<MemoryOperationResult> {
const spec = findMemoryToolSpec(toolName);
if (!spec) {
return {success: false, scope: "user", error: `Unknown memory tool: ${toolName}`};
}
if (spec.kind === "read") {
return readUserMemory(args.userId, spec.scope);
}
if (spec.kind === "delete") {
return deleteUserMemory(args.userId, spec.scope);
}
if (!isMemoryWriteTool(spec)) {
return {success: false, scope: spec.scope, error: `Unsupported memory tool: ${toolName}`};
}
return updateUserMemory({
userId: args.userId,
scope: spec.scope,
action: spec.action,
content: args.content,
context,
});
}
export async function deleteUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
const filePath = getMemoryFilePath(normalizedUserId, scope);
try {
await rm(filePath, {force: true});
return {success: true, scope, filePath, content: "", chars: 0, compressed: false};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
+113
View File
@@ -0,0 +1,113 @@
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 {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
export function normalizeToolArguments(args: string | AiJsonObject | undefined, userId?: number | null): AiJsonObject {
if (!args) return {};
if (typeof args === "string") {
try {
const parsed = JSON.parse(args) as AiJsonValue;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as AiJsonObject;
}
} catch {
return {
raw: args,
};
}
return {};
}
if (typeof args === "object" && !Array.isArray(args)) {
const userIdObject = userId ? {"userId": userId} : {};
return {
...args,
...userIdObject,
} as AiJsonObject;
}
return {};
}
export function asBoolean(value: BoundaryValue, defaultValue = false): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") return true;
if (normalized === "false") return false;
}
return defaultValue;
}
export function asString(value: BoundaryValue, defaultValue = ""): string {
return typeof value === "string" ? value : defaultValue;
}
export function asPositiveInt(value: BoundaryValue, defaultValue: number, maxValue: number): number {
const n = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(n) || n <= 0) return defaultValue;
return Math.min(Math.floor(n), maxValue);
}
export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]) {
try {
const runningModels = await ollama.ps();
const modelsToUnload = runningModels.models
.filter(m => !exceptFor?.includes(m.model));
const unloadPromises = modelsToUnload
.map(model =>
ollama.generate({
model: model.name,
keep_alive: 0,
stream: false,
prompt: ""
})
);
await Promise.all(unloadPromises);
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
} catch (error) {
logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)});
}
}
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,
prompt: "",
options: {
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)});
return false;
}
}
+151
View File
@@ -0,0 +1,151 @@
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";
export const getWeatherTool = {
type: "function",
function: {
name: "get_weather",
description: "Get the current temperature for a city.",
parameters: {
type: "object",
properties: {
city: {
type: "string",
description: "The name of the city."
},
lang: {
type: "string",
description: "language code for the response/content. Must be a valid ISO 639-1 two-letter language code, for example: \"en\", \"ru\", \"de\", \"fr\".Determine the value automatically from the language the user is using to communicate with the LLM. If the user explicitly requests a specific language, use that requested language instead. Do not use language names, locales, or regional variants such as \"English\", \"ru-RU\", or \"en_US\"; return only the ISO 639-1 code."
}
},
required: ["city", "lang"],
}
}
} satisfies AiTool;
export const weatherToolPrompt = [
"Weather tool rules:",
"- Use `get_weather` for current weather, current temperature, conditions, hot/cold/rainy/snowy questions, and weather follow-ups.",
"- Weather is live/current data. Never answer it from memory.",
"- A weather tool result is valid only for the exact city used in that tool call.",
"- If the user changes the city, call `get_weather` again.",
"- Follow-up questions like `what about Moscow?`, `and for Krasnodar?`, `what about there?`, `what about Berlin?` inherit the previous weather intent and require a new tool call for the new city.",
"",
"Arguments:",
"- `city`: the city from the latest user request or resolved from the follow-up context.",
"- `lang`: ISO 639-1 two-letter language code only: `ru`, `en`, `de`, etc.",
"",
"Do not guess, compare, or reuse weather from another city.",
"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});
try {
const city = asNonEmptyString(args?.city);
const lang = asNonEmptyString(args?.lang);
if (!city) {
return null;
}
const apiKey = Environment.OPEN_WEATHER_MAP_API_KEY;
const geocodeResponse = (await axios.get("https://api.openweathermap.org/geo/1.0/direct", {
params: {
q: city,
limit: 1,
appid: apiKey,
},
})).data[0];
logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse});
if (!geocodeResponse) {
return {
ok: false,
tool: "get_weather",
error: `City not found: ${city}`,
city,
lang,
};
}
const lat = geocodeResponse.lat;
const lon = geocodeResponse.lon;
const response = (await axios.get("https://api.openweathermap.org/data/2.5/weather", {
params: {
lat,
lon,
units: "metric",
appid: apiKey,
...(lang ? {lang} : {}),
},
})).data;
logger.debug("weather_api.done", {city, country: geocodeResponse.country, lang, units: "metric", hasResponse: !!response});
const main = response.main;
const sys = response.sys;
const wind = response.wind;
const weather = response.weather[0];
let date = new Date(sys.sunrise * 1000);
const sunrise = [
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
]
.map((v) => String(v).padStart(2, "0"))
.join(":");
date = new Date(sys.sunset * 1000);
const sunset = [
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
]
.map((v) => String(v).padStart(2, "0"))
.join(":");
return {
ok: true,
tool: "get_weather",
scope: {
city,
lang,
validOnlyForExactCity: true,
liveData: true,
note: "If the user asks about another city, call get_weather again.",
},
weather: {
main: weather.main,
description: weather.description,
temperature: main.temp,
temperatureMax: main.temp_max,
temperatureMin: main.temp_min,
feelsLike: main.feels_like,
humidity: main.humidity,
pressure: main.pressure,
seaLevel: main.sea_level ?? null,
groundLevel: main.grnd_level ?? null,
sunriseUtc: sunrise,
sunsetUtc: sunset,
windDegree: wind.deg,
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));
return null;
} finally {
logger.debug("done", {duration: logger.duration(startedAt)});
}
}
+403
View File
@@ -0,0 +1,403 @@
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";
type BraveSearchProfile = {
name?: string;
long_name?: string;
url?: string;
img?: string;
};
type BraveSearchMetaUrl = {
scheme?: string;
netloc?: string;
hostname?: string;
favicon?: string;
path?: string;
};
type BraveSearchThumbnail = {
src?: string;
original?: string;
};
type BraveSearchResult = {
type?: string;
title?: string;
url?: string;
description?: string;
age?: string;
page_age?: string;
language?: string;
family_friendly?: boolean;
is_source_local?: boolean;
is_source_both?: boolean;
profile?: BraveSearchProfile;
meta_url?: BraveSearchMetaUrl;
thumbnail?: BraveSearchThumbnail;
extra_snippets?: string[];
};
type BraveSearchApiResponse = {
type?: string;
query?: {
original?: string;
show_strict_warning?: boolean;
is_navigational?: boolean;
is_news_breaking?: boolean;
spellcheck_off?: boolean;
country?: string;
bad_results?: boolean;
should_fallback?: boolean;
postal_code?: string;
city?: string;
header_country?: string;
more_results_available?: boolean;
state?: string;
altered?: string;
};
web?: {
type?: string;
results?: BraveSearchResult[];
};
news?: {
type?: string;
results?: BraveSearchResult[];
};
videos?: {
type?: string;
results?: BraveSearchResult[];
};
discussions?: {
type?: string;
results?: BraveSearchResult[];
};
faq?: AiJsonValue;
infobox?: AiJsonValue;
locations?: AiJsonValue;
mixed?: AiJsonValue;
summarizer?: AiJsonValue;
};
export const WEB_SEARCH_TOOL_NAME = "web_search";
export const webSearchTool = {
type: "function",
function: {
name: WEB_SEARCH_TOOL_NAME,
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: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query. Must be non-empty. Maximum 400 characters and 50 words.",
},
count: {
type: "number",
description:
"Number of web results to return. Min 1, max 20. Default is 5.",
},
offset: {
type: "number",
description:
"Zero-based page offset. Min 0, max 9. Default is 0.",
},
country: {
type: "string",
description:
"Optional 2-letter country code for result localization, for example US, RU, DE. Default is US.",
},
searchLang: {
type: "string",
description:
"Optional search language code, for example en, ru, de. Default is en.",
},
uiLang: {
type: "string",
description:
"Optional UI language, usually language-country format, for example en-US, ru-RU, de-DE.",
},
safesearch: {
type: "string",
enum: ["off", "moderate", "strict"],
description:
"Adult content filter. Default is moderate.",
},
freshness: {
type: "string",
description:
"Optional freshness filter: pd for last 24h, pw for last 7 days, pm for last 31 days, py for last 365 days, or YYYY-MM-DDtoYYYY-MM-DD.",
},
resultFilter: {
type: "string",
description:
"Comma-separated result types. Examples: web, news, videos, discussions, faq, infobox, locations, query, summarizer. Default is web.",
},
extraSnippets: {
type: "boolean",
description:
"Whether to request extra snippets. Default is false.",
},
spellcheck: {
type: "boolean",
description:
"Whether Brave may spellcheck and alter the query. Default is true.",
},
},
required: ["query"],
},
},
} satisfies AiTool;
export const webSearchToolPrompt = [
"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.",
"- Use `web_search` for normal web search results.",
"- Do not use `shell_execute` for web search.",
"",
"How to query:",
"- Keep search queries short and focused.",
"- Prefer the user's original language unless another language is clearly better for the topic.",
"- Use `searchLang` based on the expected language of results: `ru` for Russian, `en` for English, `de` for German.",
"- Use `country` for localization when relevant, for example `RU`, `US`, `DE`.",
"- Use `count` between 3 and 10 by default.",
"- Use `resultFilter: \"web\"` for normal search.",
"- Use `resultFilter: \"news,web\"` for recent news/events.",
"- Use `resultFilter: \"videos\"` only when the user asks for videos.",
"- Use `resultFilter: \"discussions,web\"` when forum/community opinions are useful.",
"",
"Freshness:",
"- Use `freshness: \"pd\"` for last 24 hours.",
"- Use `freshness: \"pw\"` for last 7 days.",
"- Use `freshness: \"pm\"` for last 31 days.",
"- Use `freshness: \"py\"` for last 365 days.",
"- Use a custom range like `2025-01-01to2025-12-31` only when the user asks for a specific date range.",
"",
"Answering:",
"- Treat snippets as hints, not as full source documents.",
"- Do not invent details that are not present in the search results.",
"- When giving factual claims based on search results, mention the source title or URL.",
"- If results are weak, ambiguous or empty, say that the search result was insufficient.",
"",
].join("\n");
function asIntegerInRange(
value: AiJsonValue | undefined,
fallback: number,
min: number,
max: number,
): number {
const parsed = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(parsed)) return fallback;
const int = Math.trunc(parsed);
return Math.min(max, Math.max(min, int));
}
function asEnum<T extends string>(
value: AiJsonValue | undefined,
allowed: readonly T[],
fallback: T,
): T {
if (typeof value !== "string") return fallback;
const normalized = value.trim();
return allowed.includes(normalized as T)
? normalized as T
: fallback;
}
function cleanSearchText(value: AiJsonValue | undefined): string | null {
if (typeof value !== "string") return null;
return value
.replace(/<[^>]*>/g, "")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim() || null;
}
function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
const allowed = new Set([
"discussions",
"faq",
"infobox",
"news",
"query",
"summarizer",
"videos",
"web",
"locations",
]);
const raw = asNonEmptyString(value);
if (!raw) return "web";
const parts = raw
.split(",")
.map(part => part.trim().toLowerCase())
.filter(part => allowed.has(part));
return parts.length ? [...new Set(parts)].join(",") : "web";
}
export async function webSearch(args?: AiJsonObject) {
const startedAt = Date.now();
logger.info("start", {args});
try {
const query = asNonEmptyString(args?.query);
if (!query) {
throw new Error("query is required");
}
if (query.length > 400) {
throw new Error("query is too long. Max allowed length is 400 characters.");
}
const wordCount = query.split(/\s+/).filter(Boolean).length;
if (wordCount > 50) {
throw new Error("query has too many words. Max allowed word count is 50.");
}
const count = asIntegerInRange(args?.count, 5, 1, 20);
const offset = asIntegerInRange(args?.offset, 0, 0, 9);
const country = asNonEmptyString(args?.country)?.toUpperCase() ?? "US";
const searchLang = asNonEmptyString(args?.searchLang)?.toLowerCase() ?? "en";
const uiLang = asNonEmptyString(args?.uiLang) ?? undefined;
const safesearch = asEnum(
args?.safesearch,
["off", "moderate", "strict"] as const,
"moderate",
);
const freshness = asNonEmptyString(args?.freshness);
const resultFilter = normalizeBraveResultFilter(args?.resultFilter);
const extraSnippets = asBoolean(args?.extraSnippets, false);
const spellcheck = asBoolean(args?.spellcheck, true);
const response = await axios.get<BraveSearchApiResponse>(
"https://api.search.brave.com/res/v1/web/search",
{
timeout: 10_000,
params: {
q: query,
count,
offset,
country,
search_lang: searchLang,
safesearch,
result_filter: resultFilter,
text_decorations: false,
spellcheck,
extra_snippets: extraSnippets,
...(uiLang ? {ui_lang: uiLang} : {}),
...(freshness ? {freshness} : {}),
},
headers: {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": Environment.BRAVE_SEARCH_API_KEY,
"User-Agent": "TelegramBot/1.0",
},
},
);
const data = response.data;
return {
ok: true,
query,
alteredQuery: data.query?.altered ?? null,
moreResultsAvailable: data.query?.more_results_available ?? null,
resultFilter,
count,
offset,
country,
searchLang,
safesearch,
freshness: freshness ?? null,
web: data.web?.results?.map(mapBraveResult) ?? [],
news: data.news?.results?.map(mapBraveResult) ?? [],
videos: data.videos?.results?.map(mapBraveResult) ?? [],
discussions: data.discussions?.results?.map(mapBraveResult) ?? [],
hasInfobox: Boolean(data.infobox),
hasFaq: Boolean(data.faq),
hasLocations: Boolean(data.locations),
hasSummarizer: Boolean(data.summarizer),
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));
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
const data = axios.isAxiosError(error) ? error.response?.data : undefined;
return {
ok: false,
status: typeof status === "number" ? status : null,
error: error instanceof Error ? error.message : String(error),
response: data ?? null,
};
} finally {
logger.debug("done", {duration: logger.duration(startedAt)});
}
}
function mapBraveResult(result: BraveSearchResult) {
return {
title: cleanSearchText(result.title),
url: asNonEmptyString(result.url) ?? null,
description: cleanSearchText(result.description),
age: asNonEmptyString(result.age) ?? asNonEmptyString(result.page_age) ?? null,
language: asNonEmptyString(result.language) ?? null,
source: asNonEmptyString(result.profile?.name)
?? asNonEmptyString(result.profile?.long_name)
?? asNonEmptyString(result.meta_url?.hostname)
?? null,
hostname: asNonEmptyString(result.meta_url?.hostname) ?? null,
thumbnail: asNonEmptyString(result.thumbnail?.src)
?? asNonEmptyString(result.thumbnail?.original)
?? null,
extraSnippets: Array.isArray(result.extra_snippets)
? result.extra_snippets
.map(cleanSearchText)
.filter((value): value is string => Boolean(value))
: [],
};
}
+42
View File
@@ -0,0 +1,42 @@
import {AiProvider} from "../model/ai-provider";
import type {StoredAttachment} from "../model/stored-attachment";
import type {AiDownloadedFile} from "./telegram-attachments";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistTranscriptArtifactAttachment(params: {
provider: AiProvider;
transcript: string;
downloads: AiDownloadedFile[];
chatId: number;
messageId: number;
}): Promise<StoredAttachment | undefined> {
const text = params.transcript.trim();
if (!text) return Promise.resolve(undefined);
const sources = params.downloads
.filter(download => download.kind === "audio" || download.kind === "video-note")
.map(download => ({
fileId: download.fileId,
fileName: download.fileName,
mimeType: download.mimeType,
sizeBytes: download.sizeBytes ?? download.buffer.length,
sha256: download.sha256,
}));
return await persistInternalJsonArtifactAttachment({
artifactKind: "transcript",
fileNamePrefix: "transcript",
chatId: params.chatId,
messageId: params.messageId,
payload: {
provider: params.provider,
transcript: text,
sources,
},
metadata: {
provider: params.provider,
sourceFileNames: sources.map(source => source.fileName),
transcriptChars: text.length,
},
});
}
+373
View File
@@ -0,0 +1,373 @@
import {AiProvider} from "../model/ai-provider";
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {Environment} from "../common/environment";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
import {mergeReplyChainDownloads, shouldPreferCurrentDownloads} from "./reply-chain-downloads";
import {attachmentsToDownloadedFiles, type AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {ChatMessage} from "./chat-messages-types";
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {PreparedDocumentRag} from "./document-rag-pipeline";
import {prepareDocumentRag} from "./document-rag-pipeline";
import {persistRagArtifactAttachment} from "./rag-artifact-store";
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
import type {ToolRuntimeContext} from "./tools/runtime";
import {recordPipelineFallback, recordRagRun} from "../common/ai-observability.js";
import {
appendTranscriptToChatMessages,
collectTextMessages,
initialStatus,
providerName,
RuntimeConfigSnapshot,
stripAudioFromRunnerMessages,
toolRuntimeContextFromDownloads,
transcribeAudioIfNeeded,
collectStoredReplyChainAttachments,
UnifiedRunOptions,
} from "./unified-ai-runner.shared";
import {aiLog} from "../logging/ai-logger";
import {isTranscribableAudioDownload} from "./speech-to-text";
export type PreparedUnifiedAiRequest = {
chatMessages: Array<OpenAIChatMessage | MistralChatMessage | ChatMessage>;
imageCount: number;
firstRoundStatus: string;
toolContext: ToolRuntimeContext;
preparedDocumentRag?: PreparedDocumentRag;
finishAfterTranscript: boolean;
cleanup: () => Promise<void>;
};
type MutablePreparedContext = {
chatMessages: Array<OpenAIChatMessage | MistralChatMessage | ChatMessage>;
imageCount: number;
firstRoundStatus: string;
toolContext: ToolRuntimeContext;
transcript: string;
preparedDocumentRag?: PreparedDocumentRag;
finishAfterTranscript: boolean;
};
function nowIso(): string {
return new Date().toISOString();
}
function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnapshot) {
return options.provider === AiProvider.OLLAMA
? config.ollamaChatTarget
: options.provider === AiProvider.MISTRAL
? config.mistralChatTarget
: config.openAiChatTarget;
}
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
return {
requestId: options.requestId ?? `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id,
fromId: options.msg.from?.id ?? 0,
receivedAt: nowIso(),
text: options.text,
settings: {
provider: options.provider,
responseLanguage: options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
contextSize: options.contextSize,
voiceMode: options.voiceMode ?? "execute",
imageOutputMode: "photo",
},
inputAttachments: [],
outputAttachments: [],
artifacts: [],
toolRankDecisions: [],
audit: [],
};
}
export async function prepareUnifiedAiRequestPipeline(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
downloads: AiDownloadedFile[];
streamMessage: TelegramStreamMessage;
controller: AbortController;
}): Promise<PreparedUnifiedAiRequest> {
const {options, config, downloads, streamMessage, controller} = params;
const replyChainDownloads = shouldPreferCurrentDownloads(options.text, downloads)
? downloads
: mergeReplyChainDownloads(
downloads,
attachmentsToDownloadedFiles(await collectStoredReplyChainAttachments(options.msg)),
);
const prepared: MutablePreparedContext = {
chatMessages: [],
imageCount: 0,
firstRoundStatus: Environment.waitThinkText,
toolContext: {},
transcript: "",
finishAfterTranscript: false,
};
const stages: UserRequestPipelineStage[] = [
{
name: "audit_start",
async run() {
return {
stage: "audit_start",
status: "succeeded",
details: {
phase: "ai_request_prepare",
provider: options.provider,
downloads: replyChainDownloads.map(download => ({
kind: download.kind,
fileName: download.fileName,
mimeType: download.mimeType,
sizeBytes: download.sizeBytes ?? download.buffer.length,
})),
},
};
},
},
{
name: "collect_conversation_context",
async run() {
const collected = await collectTextMessages(
options.msg,
options.text,
options.provider,
replyChainDownloads,
config,
runtimeTargetFor(options, config),
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
);
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
prepared.imageCount = collected.imageCount;
prepared.firstRoundStatus = initialStatus(replyChainDownloads, prepared.imageCount);
prepared.toolContext = toolRuntimeContextFromDownloads(replyChainDownloads);
return {
stage: "collect_conversation_context",
status: "succeeded",
};
},
},
{
name: "prepare_text_context",
async run() {
streamMessage.setStatus(prepared.firstRoundStatus);
await streamMessage.flush();
return {
stage: "prepare_text_context",
status: "succeeded",
};
},
},
{
name: "resolve_runtime",
async run() {
return {
stage: "resolve_runtime",
status: "succeeded",
};
},
},
{
name: "speech_to_text",
async run() {
prepared.transcript = await transcribeAudioIfNeeded(
options.provider,
options.msg.from?.id,
replyChainDownloads,
streamMessage,
controller.signal,
).catch(error => {
if (replyChainDownloads.some(isTranscribableAudioDownload)) throw error;
return "";
});
const transcript = prepared.transcript.trim();
if (!transcript) {
return {
stage: "speech_to_text",
status: "skipped",
};
}
const transcriptArtifact = await persistTranscriptArtifactAttachment({
provider: options.provider,
transcript,
downloads: replyChainDownloads,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
});
if (transcriptArtifact) {
await streamMessage.storeInternalAttachment(transcriptArtifact);
}
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
prepared.finishAfterTranscript = true;
streamMessage.replaceText(`[Расшифровка]\n${transcript}`);
await streamMessage.finish();
return {
stage: "speech_to_text",
status: "succeeded",
fallbackAction: "continue_without_stage",
};
}
appendTranscriptToChatMessages(prepared.chatMessages, transcript);
stripAudioFromRunnerMessages(prepared.chatMessages);
aiLog("debug", "request.transcript.appended", {
provider: providerName(options.provider),
transcriptChars: transcript.length,
chatMessages: prepared.chatMessages.length,
});
return {
stage: "speech_to_text",
status: "succeeded",
};
},
},
{
name: "document_rag",
async run() {
if (prepared.finishAfterTranscript) {
return {
stage: "document_rag",
status: "skipped",
};
}
prepared.preparedDocumentRag = await prepareDocumentRag(
options.provider,
replyChainDownloads,
prepared.chatMessages,
streamMessage,
config,
controller.signal,
options.text,
);
const ragArtifact = await persistRagArtifactAttachment({
provider: options.provider,
prepared: prepared.preparedDocumentRag,
downloads: replyChainDownloads,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
? {uploadedFileIds: prepared.preparedDocumentRag.uploadedFileIds}
: prepared.preparedDocumentRag?.provider === AiProvider.OLLAMA
? {
embeddingModel: config.ollamaDocumentsTarget.model,
topK: config.ollamaRagTopK,
chunkSize: config.ollamaRagChunkSize,
chunkOverlap: config.ollamaRagChunkOverlap,
maxContextChars: config.ollamaRagMaxContextChars,
artifact: prepared.preparedDocumentRag.artifact,
}
: undefined,
});
if (ragArtifact) {
await streamMessage.storeInternalAttachment(ragArtifact);
}
if (prepared.preparedDocumentRag) {
recordRagRun();
}
return {
stage: "document_rag",
status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
};
},
},
{
name: "audit_finish",
async run() {
return {
stage: "audit_finish",
status: "succeeded",
details: {
phase: "ai_request_prepare",
chatMessages: prepared.chatMessages.length,
imageCount: prepared.imageCount,
hasTranscript: !!prepared.transcript.trim(),
hasDocumentRag: !!prepared.preparedDocumentRag,
finishAfterTranscript: prepared.finishAfterTranscript,
},
};
},
},
];
const state = createAiRequestPipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const pipeline = new UserRequestPipeline({
stages,
stageNames: [
"audit_start",
"collect_conversation_context",
"prepare_text_context",
"resolve_runtime",
"speech_to_text",
"document_rag",
"audit_finish",
],
onFallback: async decision => {
recordPipelineFallback(decision.action);
if (decision.action === "use_alternate_target") {
aiLog("warn", "request.fallback.use_alternate_target", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
...buildToolRankFallbackTargetDetails(options.provider, config),
});
}
if (decision.action === "fail_request") {
aiLog("error", "request.fallback.fail_request", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
});
}
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: nowIso(),
finishedAt: nowIso(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
...(decision.action === "use_alternate_target"
? buildToolRankFallbackTargetDetails(options.provider, config)
: {}),
},
});
},
});
await pipeline.run(state, controller.signal);
await streamMessage.storePipelineAudit(state.audit);
return {
chatMessages: prepared.chatMessages,
imageCount: prepared.imageCount,
firstRoundStatus: prepared.firstRoundStatus,
toolContext: prepared.toolContext,
preparedDocumentRag: prepared.preparedDocumentRag,
finishAfterTranscript: prepared.finishAfterTranscript,
cleanup: async () => {
await prepared.preparedDocumentRag?.cleanup();
},
};
}
+486
View File
@@ -0,0 +1,486 @@
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {ifTrue, logError} from "../util/utils";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {getProviderAdapter} from "./provider-adapters";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {ChatMessage} from "./chat-messages-types";
import {
allToolSchemaNames,
providerName,
RuntimeConfigSnapshot,
snapshotModel,
TELEGRAM_LIMIT,
UnifiedRunOptions,
} from "./unified-ai-runner.shared";
import {runToolRankStage} from "./tool-rank-stage";
import {runOpenAi} from "./unified-ai-runner.openai";
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral";
import {summarizeModelOutput} from "./response-model-output";
import {summarizeToolLoop} from "./tool-loop-summary";
import {persistToolLoopSummaryArtifactAttachment} from "./tool-loop-artifact-store";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
import {
resolveTextToSpeechProviderForUser,
sendSynthesizedSpeech,
speechToOutputAttachmentRecord,
synthesizeSpeech
} from "./text-to-speech";
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
import {aiLog} from "../logging/ai-logger";
import {recordPipelineFallback, recordTtsRun} from "../common/ai-observability.js";
function nowIso(): string {
return new Date().toISOString();
}
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
return {
requestId: options.requestId ?? `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id,
fromId: options.msg.from?.id ?? 0,
receivedAt: nowIso(),
text: options.text,
settings: {
provider: options.provider,
responseLanguage: options.responseLanguage ?? "default",
contextSize: options.contextSize,
voiceMode: options.voiceMode ?? "execute",
imageOutputMode: "photo",
},
inputAttachments: [],
outputAttachments: [],
artifacts: [],
toolRankDecisions: [],
audit: [],
};
}
async function runProviderModelCall(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
downloads: AiDownloadedFile[];
prepared: PreparedUnifiedAiRequest;
streamMessage: TelegramStreamMessage;
signal: AbortSignal;
}): Promise<void> {
const {options, config, downloads, prepared, streamMessage, signal} = params;
const preparedDocumentRag = prepared.preparedDocumentRag;
const documents = preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedDocumentRag.documents : [];
aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)});
switch (options.provider) {
case AiProvider.OPENAI:
if (config.openAiBackend === "compatible") {
await runOpenAiCompatible(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
streamMessage,
signal,
options.stream ?? true,
options.msg,
config,
prepared.toolContext,
downloads,
);
return;
}
await runOpenAi(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
streamMessage,
signal,
options.stream ?? true,
options.msg,
config,
prepared.toolContext,
downloads,
preparedDocumentRag?.provider === AiProvider.OPENAI ? preparedDocumentRag : undefined,
);
return;
case AiProvider.OLLAMA:
if (config.ollamaChatTarget.model?.includes("gpt-oss") && options.think) {
options.think = "high";
}
await runOllama(
options.msg,
prepared.chatMessages as ChatMessage[],
streamMessage,
signal,
ifTrue(options.stream),
options.think ?? false,
prepared.firstRoundStatus,
config,
prepared.toolContext,
options.contextSize,
);
return;
case AiProvider.MISTRAL:
await runMistral(
options.msg,
prepared.chatMessages as MistralChatMessage[],
documents,
streamMessage,
signal,
options.stream ?? true,
prepared.firstRoundStatus,
config,
prepared.toolContext,
);
}
}
async function synthesizeResponseIfRequested(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
streamMessage: TelegramStreamMessage;
}): Promise<"succeeded" | "skipped" | "failed"> {
const {options, config, streamMessage} = params;
if (!options.synthesizeSpeechResponse) return "skipped";
const text = streamMessage.getText().trim();
if (!text) return "skipped";
try {
if (!options.msg.from?.id) {
throw new Error(Environment.couldNotIdentifyUserForSpeechToTextText);
}
const resolved = await resolveTextToSpeechProviderForUser(options.msg.from.id, options.provider)
.catch(() => resolveTextToSpeechProviderForUser(options.msg.from!.id));
const speech = await synthesizeSpeech({provider: resolved.provider, text});
const sent = await sendSynthesizedSpeech(options.msg, speech);
streamMessage.recordOutputAttachment(speechToOutputAttachmentRecord(speech, sent.message_id));
return "succeeded";
} catch (error) {
aiLog("error", "text_to_speech.failed", {
provider: providerName(options.provider),
model: snapshotModel(options.provider, config),
error: error instanceof Error ? error.message : String(error),
});
return "failed";
}
}
export async function runUnifiedAiResponsePipeline(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
downloads: AiDownloadedFile[];
prepared: PreparedUnifiedAiRequest;
streamMessage: TelegramStreamMessage;
controller: AbortController;
}): Promise<void> {
const {options, config, downloads, prepared, streamMessage, controller} = params;
const state = createResponsePipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const adapter = getProviderAdapter(options.provider);
let selectedToolNames: string[] = [];
let filteredTools: unknown[] = [];
const stages: UserRequestPipelineStage[] = [
{
name: "audit_start",
async run() {
return {
stage: "audit_start",
status: "succeeded",
details: {
phase: "ai_response",
provider: options.provider,
model: snapshotModel(options.provider, config),
chatMessages: prepared.chatMessages.length,
hasDocumentRag: !!prepared.preparedDocumentRag,
},
};
},
},
{
name: "tool_rank",
async run() {
const availableTools = adapter.rankTools(config, {
forCreator: options.msg.from?.id === Environment.CREATOR_ID,
vectorStoreIds: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
? prepared.preparedDocumentRag.vectorStoreIds
: [],
});
const rankResult = await runToolRankStage({
provider: options.provider,
model: snapshotModel(options.provider, config),
round: state.toolRankDecisions.length,
config,
availableTools,
messages: prepared.chatMessages,
streamMessage,
signal: controller.signal,
});
selectedToolNames = rankResult.selectedToolNames;
filteredTools = rankResult.filteredTools;
state.toolRankDecisions.push({
provider: options.provider,
round: state.toolRankDecisions.length,
availableTools: allToolSchemaNames(availableTools),
selectedTools: selectedToolNames,
usedRanker: rankResult.usedRanker,
});
return {
stage: "tool_rank",
status: "succeeded",
details: {
selectedTools: selectedToolNames,
usedRanker: rankResult.usedRanker,
availableTools: allToolSchemaNames(availableTools),
toolRankDecision: state.toolRankDecisions.at(-1),
},
};
},
},
{
name: "filter_tools",
async run() {
return {
stage: "filter_tools",
status: "succeeded",
details: {
selectedTools: selectedToolNames,
filteredToolCount: filteredTools.length,
},
};
},
},
{
name: "model_call",
async run() {
await runProviderModelCall({
options,
config,
downloads,
prepared,
streamMessage,
signal: controller.signal,
});
return {
stage: "model_call",
status: "succeeded",
details: {
modelOutput: summarizeModelOutput({
text: streamMessage.getText(),
toolExecutions: streamMessage.getToolExecutions(),
outputAttachments: streamMessage.getOutputAttachments(),
}),
},
};
},
},
{
name: "tool_loop",
async run() {
const executions = streamMessage.getToolExecutions();
const outputAttachments = streamMessage.getOutputAttachments();
const summary = summarizeToolLoop({
text: streamMessage.getText(),
executions,
outputAttachments,
});
const persisted = await persistToolLoopSummaryArtifactAttachment({
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
text: streamMessage.getText(),
executions,
outputAttachments,
});
if (persisted) {
await streamMessage.storeInternalAttachment(persisted);
}
return {
stage: "tool_loop",
...summary,
details: {
...summary.details,
persistedSummaryArtifact: !!persisted,
},
};
},
},
{
name: "output_size_gate",
async run() {
const originalChars = streamMessage.getText().length;
if (originalChars > TELEGRAM_LIMIT) {
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
}
return {
stage: "output_size_gate",
status: originalChars > TELEGRAM_LIMIT ? "fallback" : "succeeded",
fallbackAction: originalChars > TELEGRAM_LIMIT ? "notify_user" : undefined,
};
},
},
{
name: "send_response",
async run() {
await streamMessage.finish();
return {
stage: "send_response",
status: "succeeded",
};
},
},
{
name: "persist_output_artifacts",
async run() {
const outputAttachments = streamMessage.getOutputAttachments();
const artifact = await persistFinalTextArtifactAttachment({
provider: options.provider,
model: snapshotModel(options.provider, config),
text: streamMessage.getText(),
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
});
if (artifact) {
await streamMessage.storeInternalAttachment(artifact);
}
return {
stage: "persist_output_artifacts",
status: artifact || outputAttachments.length ? "succeeded" : "skipped",
details: {
finalTextPersisted: !!artifact,
outputAttachments,
},
};
},
},
{
name: "text_to_speech",
async run() {
const status = await synthesizeResponseIfRequested({options, config, streamMessage});
recordTtsRun(status);
return {
stage: "text_to_speech",
status,
fallbackAction: status === "failed" ? "continue_without_stage" : undefined,
};
},
},
{
name: "audit_finish",
async run() {
return {
stage: "audit_finish",
status: "succeeded",
details: {
phase: "ai_response",
textChars: streamMessage.getText().length,
toolExecutions: streamMessage.getToolExecutions().length,
outputAttachments: streamMessage.getOutputAttachments().length,
},
};
},
},
];
const responsePipeline = new UserRequestPipeline({
stages,
stageNames: [
"audit_start",
"tool_rank",
"filter_tools",
"model_call",
"tool_loop",
"output_size_gate",
"send_response",
"text_to_speech",
"persist_output_artifacts",
"audit_finish",
],
onFallback: async decision => {
recordPipelineFallback(decision.action);
if (decision.action === "use_alternate_target") {
aiLog("warn", "response.fallback.use_alternate_target", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
...buildToolRankFallbackTargetDetails(options.provider, config),
});
}
if (decision.action === "fail_request") {
aiLog("error", "response.fallback.fail_request", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
});
}
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
...(decision.action === "use_alternate_target"
? buildToolRankFallbackTargetDetails(options.provider, config)
: {}),
},
});
},
});
try {
await responsePipeline.run(state, controller.signal);
await streamMessage.storePipelineAudit(state.audit);
} catch (error) {
await streamMessage.storePipelineAudit(state.audit).catch(logError);
throw error;
} finally {
const cleanupState = createResponsePipelineState(options);
const cleanupPipeline = new UserRequestPipeline({
stages: [{
name: "cleanup",
async run() {
await prepared.cleanup();
return {
stage: "cleanup",
status: "succeeded",
};
},
}],
stageNames: ["cleanup"],
});
try {
await cleanupPipeline.run(cleanupState, controller.signal);
await streamMessage.storePipelineAudit(cleanupState.audit);
} catch (error) {
await streamMessage.storePipelineAudit(cleanupState.audit).catch(logError);
logError(error instanceof Error ? error : String(error));
}
}
}
+218
View File
@@ -0,0 +1,218 @@
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);
}
}
+497
View File
@@ -0,0 +1,497 @@
// 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);
}
}
@@ -0,0 +1,419 @@
import {Message} from "typescript-telegram-bot-api";
import type {
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming,
ChatCompletionTool,
} from "openai/resources/chat/completions";
import {Environment} from "../common/environment.js";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message";
import {createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import type {BoundaryValue} from "../common/boundary-types.js";
import {
AsyncIterableStream,
buildSystemInstruction,
MAX_TOOL_ROUNDS,
OpenAiChatCompletionResponseLike,
OpenAiChatCompletionStreamChunkLike,
RuntimeConfigSnapshot,
safeJsonParseObject,
ToolCallData,
ToolExecutionMemory,
} from "./unified-ai-runner.shared";
import {mergeToolCallChunks, normalizeStreamingTextDelta} from "./provider-adapter-contract.js";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {ensureToolsSelected, getOpenAICompatibleTools} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {logError} from "../util/utils";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import type {AiProviderAdapter} from "./provider-adapters.js";
import {tryToUploadFiles} from "./openai-upload-files.js";
import {buildAssistantToolMessage, openAiResponseMessagesToChatCompletions} from "./openai-chat-completions.js";
function describeOpenAiCompatibleError(error: unknown): Record<string, unknown> {
const err = error as {
message?: unknown;
status?: unknown;
code?: unknown;
type?: unknown;
error?: unknown;
} | undefined;
return {
errorSummary: typeof err?.message === "string" ? err.message : String(error),
httpStatus: err?.status,
errorCode: err?.code,
errorType: err?.type,
};
}
async function executeChatCompletionWithOptionalToolFallback<T>(params: {
openAi: ReturnType<typeof createOpenAiClient>;
request: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming;
signal: AbortSignal;
stream: boolean;
}): Promise<T> {
try {
return await params.openAi.chat.completions.create(params.request as never, {signal: params.signal}) as T;
} catch (error) {
const requestWithTools = params.request as {tools?: unknown[]};
if (!requestWithTools.tools || !Array.isArray(requestWithTools.tools) || requestWithTools.tools.length === 0) {
aiLog("error", "openai_compatible.request.failed", {
stream: params.stream,
hasTools: false,
error: describeOpenAiCompatibleError(error),
});
throw error;
}
aiLog("warn", "openai_compatible.tools.retry_without_tools", {
stream: params.stream,
error: describeOpenAiCompatibleError(error),
});
const retryRequest = {...params.request} as ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming & {tools?: unknown[]};
delete retryRequest.tools;
try {
return await params.openAi.chat.completions.create(retryRequest as never, {signal: params.signal}) as T;
} catch (retryError) {
aiLog("error", "openai_compatible.request.retry_without_tools.failed", {
stream: params.stream,
hasTools: true,
error: describeOpenAiCompatibleError(retryError),
});
throw retryError;
}
}
}
function makeChatCompletionAdapter(): AiProviderAdapter {
const baseAdapter = getProviderAdapter(AiProvider.OPENAI);
return {
...baseAdapter,
callModel: baseAdapter.callModel.bind(baseAdapter),
mapMessages(messages: readonly unknown[]): unknown[] {
return openAiResponseMessagesToChatCompletions(messages as OpenAIChatMessage[]);
},
rankTools(config: RuntimeConfigSnapshot, options?: {forCreator?: boolean; vectorStoreIds?: string[]}): readonly BoundaryValue[] {
void config;
void options?.vectorStoreIds;
return getOpenAICompatibleTools(options?.forCreator) as BoundaryValue[];
},
extractTextDelta(input: unknown): string {
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
return chunk?.choices?.[0]?.delta?.content ?? "";
},
extractToolCalls(input: unknown): ToolCallData[] {
const response = input as OpenAiChatCompletionResponseLike | undefined;
const toolCalls = response?.choices?.[0]?.message?.tool_calls ?? [];
return toolCalls
.map((call, index) => ({
id: typeof call?.id === "string" && call.id.trim().length > 0 ? call.id : `openai_chat_${index}`,
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
argumentsText: typeof call?.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call?.function?.arguments ?? call?.arguments ?? {}),
}))
.filter(call => call.name.length > 0);
},
extractStreamingToolCalls(input: unknown): ToolCallData[] {
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
const toolCalls = chunk?.choices?.[0]?.delta?.tool_calls ?? [];
return toolCalls
.map((call, index) => ({
id: typeof call?.id === "string" && call.id.trim().length > 0
? call.id
: `openai_chat_${typeof call?.index === "number" ? call.index : index}`,
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
argumentsText: typeof call?.function?.arguments === "string"
? call.function.arguments
: call?.function?.arguments
? JSON.stringify(call.function.arguments)
: typeof call?.arguments === "string"
? call.arguments
: "",
}))
.filter(call => call.id.length > 0);
},
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
}
},
finalize: baseAdapter.finalize.bind(baseAdapter),
};
}
export async function runOpenAiCompatible(
msg: Message,
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
sourceMessage: Message,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
downloads: AiDownloadedFile[] = [],
): Promise<void> {
void downloads;
const runnerStartedAt = Date.now();
const openAi = createOpenAiClient(config.openAiChatTarget);
const adapter = makeChatCompletionAdapter();
const systemPrompt = buildSystemInstruction(
config,
DEFAULT_AI_RESPONSE_LANGUAGE,
false,
config.openAiChatTarget.systemPromptAdditions,
await buildUserMemoryPrompt(msg.from?.id),
);
let conversationMessages = [...openAiResponseMessagesToChatCompletions(messages)];
if (systemPrompt.trim().length) {
conversationMessages.unshift({role: "system", content: systemPrompt});
}
const availableTools = getOpenAICompatibleTools(msg.from?.id === Environment.CREATOR_ID) as ChatCompletionTool[];
aiLog("info", "openai_compatible.run.start", {
stream,
target: aiLogProviderTarget(config.openAiChatTarget),
inputMessages: messages.length,
sourceMessage: aiLogMessageIdentity(sourceMessage),
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
backend: config.openAiBackend,
});
const toolMemory: ToolExecutionMemory = new Map();
try {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, inputMessages: conversationMessages.length, stream});
const rankResult = await runToolRankStage({
provider: AiProvider.OPENAI,
model: config.openAiChatTarget.model,
round,
config,
availableTools: availableTools as readonly BoundaryValue[],
messages,
streamMessage,
signal,
});
const requestTools = ensureToolsSelected(
availableTools,
rankResult.filteredTools as ChatCompletionTool[],
MEMORY_TOOL_NAMES,
);
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.openAiChatTarget.model,
messages: conversationMessages,
tools: requestTools.length ? requestTools : undefined,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<OpenAiChatCompletionResponseLike>({
openAi,
request,
signal,
stream: false,
})),
}) as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
const responseText = typeof message?.content === "string" ? message.content : "";
streamMessage.append(responseText);
aiLog("debug", "openai_compatible.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
hasToolCalls: !!message?.tool_calls?.length,
});
const calls = adapter.extractToolCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return {shouldContinue: false};
const toolCalls = calls.map(call => ({
id: call.id,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolMessages: OpenAICompatibleChatMessage[] = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolMessages],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
if (toolMessage && toolMessage.role === "tool") {
toolMessage.content = "Error: " + uploadFilesResult.error;
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
return {shouldContinue: true};
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.openAiChatTarget.model,
messages: conversationMessages,
stream: true,
tools: requestTools.length ? requestTools : undefined,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>>({
openAi,
request,
signal,
stream: true,
})),
}) as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
let responseText = "";
let toolCallState: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const deltaText = adapter.extractTextDelta(chunk);
if (deltaText) {
const appendedText = normalizeStreamingTextDelta(responseText, deltaText);
responseText += appendedText;
streamMessage.append(appendedText);
}
const streamedCalls = adapter.extractStreamingToolCalls(chunk);
if (streamedCalls.length) {
toolCallState = mergeToolCallChunks(toolCallState, streamedCalls);
const activeCalls = toolCallState.filter(call => call.name.length > 0);
aiLog("info", "openai_compatible.stream.tool_call.added", {
round,
toolCalls: activeCalls.map(aiLogToolCall),
});
streamMessage.setStatus(Environment.getUseToolText(activeCalls));
await streamMessage.flush();
}
}
const calls = toolCallState.filter(call => call.name.length > 0);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.stream.done", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
calls: calls.map(call => ({
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return {shouldContinue: false};
streamMessage.clearStatus();
await streamMessage.flush();
const toolMessages: OpenAICompatibleChatMessage[] = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolMessages],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
if (toolMessage && toolMessage.role === "tool") {
toolMessage.content = "Error: " + uploadFilesResult.error;
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
return {shouldContinue: true};
},
});
} catch (error) {
aiLog("error", "openai_compatible.run.failed", {
duration: aiLogDuration(runnerStartedAt),
error: describeOpenAiCompatibleError(error),
});
throw error;
} finally {
await adapter.finalize().catch(logError);
}
}
+572
View File
@@ -0,0 +1,572 @@
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
+248
View File
@@ -0,0 +1,248 @@
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() ?? "";
}
}
}
}
+318
View File
@@ -0,0 +1,318 @@
// Facade extracted from unified-ai-runner.ts.
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {ifTrue, logError, replyToMessage} from "../util/utils";
import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments";
import {aiProviderRequestQueue} from "./provider-request-queue";
import {
AI_VOICE_MODE_TRANSCRIPT,
resolveAiContextSizeForUser,
resolveAiImageOutputModeForUser,
resolveAiResponseLanguageForUser,
resolveAiVoiceModeForUser
} from "../common/user-ai-settings";
import {buildAiRegenerateCallbackData} from "./regenerate-callback";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
import {
AI_REQUEST_TIMEOUT_MS,
collectCachedMessageAttachments,
collectRequestedAttachmentKinds,
hasAudioAttachmentKind,
isAbortError,
providerName,
rejectUnsupportedAttachments,
resolveAiRequestQueueTarget,
RuntimeConfigSnapshot,
snapshotModel,
snapshotRuntimeConfig,
UnifiedRunOptions
} from "./unified-ai-runner.shared";
import {prepareUnifiedAiRequestPipeline} from "./unified-ai-request-pipeline";
import {persistErrorArtifactAttachment} from "./final-response-artifact-store";
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
import {AiRequestStore} from "../common/ai-request-store";
import type {StoredAiRequestStatus} from "../model/stored-ai-request";
import {recordAiRequestFinish, recordAiRequestStart} from "../common/ai-observability.js";
export type {ToolCallData} from "./unified-ai-runner.shared";
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
async function executeUnifiedAiRequest(
options: UnifiedRunOptions,
config: RuntimeConfigSnapshot,
downloads: AiDownloadedFile[],
controller: AbortController,
streamMessage: TelegramStreamMessage,
): Promise<void> {
const requestStartedAt = Date.now();
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
aiLog("info", "request.execute.start", {
requestId: options.requestId,
provider: providerName(options.provider),
stream: options.stream ?? true,
think: options.think,
responseLanguage: options.responseLanguage,
contextSize: options.contextSize,
voiceMode: options.voiceMode,
message: aiLogMessageIdentity(options.msg),
downloads: downloads.map(d => ({
kind: d.kind,
fileName: d.fileName,
mimeType: d.mimeType,
sizeBytes: d.buffer.length
})),
});
preparedRequest = await prepareUnifiedAiRequestPipeline({
options,
config,
downloads,
streamMessage,
controller,
});
if (preparedRequest.finishAfterTranscript) return;
aiLog("debug", "request.messages.collected", {
requestId: options.requestId,
provider: providerName(options.provider),
chatMessages: preparedRequest.chatMessages.length,
imageCount: preparedRequest.imageCount,
firstRoundStatus: preparedRequest.firstRoundStatus,
hasToolInputFiles: !!preparedRequest.toolContext.pythonInputFiles?.length,
});
try {
await runUnifiedAiResponsePipeline({
options,
config,
downloads,
prepared: preparedRequest,
streamMessage,
controller,
});
aiLog("success", "request.execute.done", {
requestId: options.requestId,
provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt),
responseChars: streamMessage.getText().length,
mistralLibraryId: preparedRequest?.preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedRequest.preparedDocumentRag.libraryId : undefined,
});
return;
} catch (e) {
aiLog("error", "request.execute.failed", {
requestId: options.requestId,
provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt),
error: e instanceof Error ? e : String(e),
});
throw e;
}
}
export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
const startedAt = Date.now();
const config = snapshotRuntimeConfig();
options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id);
options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id);
options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id);
const imageOutputMode = await resolveAiImageOutputModeForUser(options.msg.from?.id);
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
aiLog("info", "run.start", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider),
model: snapshotModel(options.provider, config),
message: aiLogMessageIdentity(options.msg),
targetMessage: aiLogMessageIdentity(options.targetMessage),
isGuestMsg: options.isGuestMsg,
stream: options.stream,
think: options.think,
responseLanguage: options.responseLanguage,
contextSize: options.contextSize,
voiceMode: options.voiceMode,
requestedAttachmentKinds: [...requestedAttachmentKinds],
textChars: options.text.length,
});
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
aiLog("warn", "run.rejected.unsupported_attachment", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider),
requestedAttachmentKinds: [...requestedAttachmentKinds],
});
return;
}
const cached = await collectCachedMessageAttachments(options.msg);
aiLog("debug", "run.attachments.cache", {
attachments: cached.attachments.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
});
if (cached.missing.length) {
await replyToMessage({
message: options.msg,
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
}).catch(logError);
aiLog("warn", "run.rejected.missing_attachment_cache", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
});
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
let aiRequestStatus: StoredAiRequestStatus = "running";
let aiRequestError: string | undefined;
let responseMessageId: number | undefined;
const cancel = createAiCancelRequest({
chatId: options.msg.chat.id,
fromId: options.msg.from?.id ?? 0,
provider: providerName(options.provider),
controller
});
options.requestId ??= cancel.id;
const requestId = options.requestId;
const streamMessage = new TelegramStreamMessage(
options.msg,
cancel.id,
ifTrue(options.stream),
options.voiceMode === AI_VOICE_MODE_TRANSCRIPT && hasAudioAttachmentKind(requestedAttachmentKinds)
? undefined
: buildAiRegenerateCallbackData(options.provider, !!options.think),
options.targetMessage,
options.provider,
options.isGuestMsg,
imageOutputMode
);
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
aiLog("debug", "run.queue.target", {requestId, target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
const aiRequestStartedAt = new Date().toISOString();
recordAiRequestStart();
await AiRequestStore.put({
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
fromId: options.msg.from?.id ?? 0,
provider: options.provider,
model: snapshotModel(options.provider, config),
status: "running",
startedAt: aiRequestStartedAt,
}).catch(logError);
try {
const queueMessage = await streamMessage.start(Environment.waitThinkText);
responseMessageId = queueMessage.message_id;
await AiRequestStore.put({
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
responseMessageId,
fromId: options.msg.from?.id ?? 0,
provider: options.provider,
model: snapshotModel(options.provider, config),
status: "running",
startedAt: aiRequestStartedAt,
}).catch(logError);
setAiCancelMessageId(requestId, queueMessage.message_id);
aiLog("info", "run.queue.enter", {
requestId,
cancelId: cancel.id,
queueMessageId: queueMessage.message_id,
target: aiLogProviderTarget(queueTarget),
});
await aiProviderRequestQueue.enqueue(queueTarget, {
signal: controller.signal,
onPositionChange: async requestsBefore => {
aiLog("debug", "run.queue.position", {requestId, cancelId: cancel.id, requestsBefore});
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
await streamMessage.flush();
},
run: async (): Promise<null> => {
const queueWaitFinishedAt = Date.now();
aiLog("info", "run.queue.dequeued", {requestId, cancelId: cancel.id});
const downloads = attachmentsToDownloadedFiles(cached.attachments);
aiLog("debug", "run.downloads.ready", {
requestId,
count: downloads.length,
downloads: downloads.map(d => ({
kind: d.kind,
fileName: d.fileName,
mimeType: d.mimeType,
path: d.path,
sizeBytes: d.buffer.length
})),
});
try {
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
aiRequestStatus = "succeeded";
aiLog("success", "run.queue.task.done", {
requestId,
cancelId: cancel.id,
duration: aiLogDuration(queueWaitFinishedAt),
});
} finally {
cleanupDownloads(downloads);
aiLog("debug", "run.downloads.cleaned", {requestId, cancelId: cancel.id, count: downloads.length});
}
return null;
},
});
} catch (e) {
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) {
aiRequestStatus = "aborted";
aiRequestError = e instanceof Error ? e.message : String(e);
aiLog("warn", "run.aborted", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
streamMessage.replaceText(streamMessage.getText());
await streamMessage.finish();
} else {
aiRequestStatus = "failed";
aiRequestError = e instanceof Error ? e.message : String(e);
aiLog("error", "run.failed", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
const errorMessage = e instanceof Error ? e.message : String(e);
await streamMessage.fail(e instanceof Error ? e : String(e));
try {
await streamMessage.storeInternalAttachment(await persistErrorArtifactAttachment({
provider: options.provider,
model: snapshotModel(options.provider, config),
message: errorMessage,
recoverable: false,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
}));
} catch (artifactError) {
logError(artifactError instanceof Error ? artifactError : String(artifactError));
}
logError(errorMessage);
}
} finally {
clearTimeout(timeout);
await AiRequestStore.put({
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
responseMessageId,
fromId: options.msg.from?.id ?? 0,
provider: options.provider,
model: snapshotModel(options.provider, config),
status: aiRequestStatus,
startedAt: aiRequestStartedAt,
finishedAt: new Date().toISOString(),
error: aiRequestError,
}).catch(logError);
recordAiRequestFinish(aiRequestStatus);
finishAiRequest(requestId);
aiLog("success", "run.finished", {
requestId,
cancelId: cancel.id,
provider: providerName(options.provider),
duration: aiLogDuration(startedAt),
aborted: controller.signal.aborted,
});
}
}
+73
View File
@@ -0,0 +1,73 @@
import type {PipelineFallbackPolicy, PipelineStageName} from "./types.js";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./types.js";
export const USER_REQUEST_PIPELINE_STAGES: readonly PipelineStageName[] = [
"receive_request",
"audit_start",
"load_user_settings",
"collect_conversation_context",
"input_size_gate",
"download_attachments",
"normalize_attachments",
"persist_input_attachments",
"prepare_text_context",
"build_system_prompt",
"resolve_runtime",
"speech_to_text",
"document_rag",
"map_provider_messages",
"tool_rank",
"filter_tools",
"model_call",
"tool_loop",
"persist_output_artifacts",
"output_size_gate",
"text_to_speech",
"send_response",
"cleanup",
"audit_finish",
];
export const USER_REQUEST_ATTACHMENT_LIMIT_BYTES = PIPELINE_ATTACHMENT_LIMIT_BYTES;
export const DEFAULT_PIPELINE_FALLBACK_POLICIES: readonly PipelineFallbackPolicy[] = [
{
stage: "input_size_gate",
onUnavailable: "fail_request",
onFailed: "notify_user",
},
{
stage: "speech_to_text",
onUnavailable: "continue_without_stage",
onFailed: "continue_without_stage",
},
{
stage: "document_rag",
onUnavailable: "continue_without_stage",
onFailed: "notify_user",
},
{
stage: "tool_rank",
onUnavailable: "use_alternate_target",
onFailed: "use_alternate_target",
},
{
stage: "tool_loop",
onUnavailable: "continue_without_stage",
onFailed: "notify_user",
},
{
stage: "output_size_gate",
onUnavailable: "fail_request",
onFailed: "notify_user",
},
{
stage: "text_to_speech",
onUnavailable: "continue_without_stage",
onFailed: "continue_without_stage",
},
];
export function isPipelineStageName(value: string): value is PipelineStageName {
return (USER_REQUEST_PIPELINE_STAGES as readonly string[]).includes(value);
}
@@ -0,0 +1,61 @@
import type {
PipelineFallbackAction,
PipelineFallbackPolicy,
PipelineStageName,
PipelineStageStatus,
} from "./types.js";
export type PipelineFallbackReason = "unavailable" | "failed";
export type PipelineFallbackDecision = {
stage: PipelineStageName;
reason: PipelineFallbackReason;
action: PipelineFallbackAction;
shouldContinue: boolean;
shouldNotifyUser: boolean;
shouldFailRequest: boolean;
};
const DEFAULT_ACTION_BY_REASON: Record<PipelineFallbackReason, PipelineFallbackAction> = {
unavailable: "continue_without_stage",
failed: "fail_request",
};
export function resolvePipelineFallbackAction(params: {
stage: PipelineStageName;
reason: PipelineFallbackReason;
policies: readonly PipelineFallbackPolicy[];
}): PipelineFallbackAction {
const policy = params.policies.find(item => item.stage === params.stage);
if (!policy) return DEFAULT_ACTION_BY_REASON[params.reason];
return params.reason === "unavailable"
? policy.onUnavailable
: policy.onFailed;
}
export function decidePipelineFallback(params: {
stage: PipelineStageName;
reason: PipelineFallbackReason;
policies: readonly PipelineFallbackPolicy[];
}): PipelineFallbackDecision {
const action = resolvePipelineFallbackAction(params);
return {
stage: params.stage,
reason: params.reason,
action,
shouldContinue: action === "ignore"
|| action === "continue_without_stage"
|| action === "notify_user"
|| action === "use_alternate_target",
shouldNotifyUser: action === "notify_user",
shouldFailRequest: action === "fail_request",
};
}
export function fallbackReasonFromStageStatus(status: PipelineStageStatus): PipelineFallbackReason | undefined {
if (status === "skipped") return "unavailable";
if (status === "failed") return "failed";
return undefined;
}
@@ -0,0 +1,12 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export class PipelineRequestFailure extends Error {
constructor(public readonly decision: PipelineFallbackDecision, message: string) {
super(message);
this.name = "PipelineRequestFailure";
}
}
export function raisePipelineRequestFailure(decision: PipelineFallbackDecision, stageName: string): never {
throw new PipelineRequestFailure(decision, `Pipeline send failed at stage ${stageName} with fallback action ${decision.action}`);
}
@@ -0,0 +1,16 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export function fallbackNotificationKey(requestId: string, decision: PipelineFallbackDecision): string {
return `${requestId}:${decision.stage}:${decision.action}`;
}
export class PipelineFallbackNotificationRegistry {
private readonly notifiedKeys = new Set<string>();
claim(requestId: string, decision: PipelineFallbackDecision): boolean {
const key = fallbackNotificationKey(requestId, decision);
if (this.notifiedKeys.has(key)) return false;
this.notifiedKeys.add(key);
return true;
}
}
@@ -0,0 +1,26 @@
import {Localization} from "../../common/localization.js";
import type {PipelineFallbackAction, PipelineStageName} from "./types.js";
export function resolvePipelineFallbackText(
stage: PipelineStageName,
action: PipelineFallbackAction,
locale?: string,
): string | undefined {
if (action === "continue_without_stage") return undefined;
if (action === "fail_request") return Localization.text("pipelineFallback.failRequest", {}, "⚠️ I could not finish this request.", locale);
switch (stage) {
case "speech_to_text":
return Localization.text("pipelineFallback.speechToText", {}, "⚠️ Speech transcription failed, so I will continue without the audio transcript.", locale);
case "document_rag":
return Localization.text("pipelineFallback.documentRag", {}, "⚠️ Document retrieval failed, so I will answer without RAG.", locale);
case "tool_loop":
return Localization.text("pipelineFallback.toolLoop", {}, "⚠️ Tool execution failed, so I will continue without that tool.", locale);
case "text_to_speech":
return Localization.text("pipelineFallback.textToSpeech", {}, "⚠️ Text-to-speech failed, so I will continue without audio output.", locale);
default:
return action === "notify_user"
? Localization.text("pipelineFallback.notifyUser", {}, "⚠️ I hit a problem and need to continue with a fallback.", locale)
: Localization.text("pipelineFallback.generic", {}, "⚠️ I had to skip part of the request, but I can continue.", locale);
}
}
@@ -0,0 +1,43 @@
import type {Message} from "typescript-telegram-bot-api";
import {Localization} from "../../common/localization.js";
import {replyToMessage, logError} from "../../util/utils.js";
import type {PipelineFallbackDecision} from "./fallback-executor.js";
import {PipelineFallbackNotificationRegistry} from "./fallback-notifier-registry.js";
import {resolvePipelineFallbackText} from "./fallback-notifier-text.js";
export class PipelineFallbackNotifier {
private readonly registry = new PipelineFallbackNotificationRegistry();
constructor(
private readonly sourceMessage: Message,
private readonly responseLanguage?: string,
private readonly sendFallbackMessage: (text: string) => Promise<void> = async text => {
await replyToMessage({
message: this.sourceMessage,
text,
});
},
) {}
async notify(requestId: string, decision: PipelineFallbackDecision): Promise<{notified: boolean; text?: string}> {
if (!this.registry.claim(requestId, decision)) {
return {notified: false};
}
const locale = this.responseLanguage === "default"
? Localization.currentLocale()
: Localization.normalizeLocale(this.responseLanguage) ?? Localization.currentLocale();
const text = resolvePipelineFallbackText(decision.stage, decision.action, locale);
if (!text) {
return {notified: false};
}
try {
await this.sendFallbackMessage(text);
return {notified: true, text};
} catch (error) {
logError(error instanceof Error ? error : String(error));
return {notified: false, text};
}
}
}
@@ -0,0 +1,15 @@
import {AiProvider} from "../../model/ai-provider.js";
import type {RuntimeConfigSnapshot} from "../unified-ai-runner.shared.js";
import {aiLogProviderTarget} from "../../logging/ai-logger.js";
import {buildRankerTarget} from "../tool-ranker-pipeline.js";
import {providerChatTarget} from "../unified-ai-runner.shared.js";
export function buildToolRankFallbackTargetDetails(provider: AiProvider, config: RuntimeConfigSnapshot) {
const sourceTarget = buildRankerTarget(config, provider);
const alternateTarget = providerChatTarget(provider, config);
return {
sourceTarget: aiLogProviderTarget(sourceTarget),
alternateTarget: aiLogProviderTarget(alternateTarget),
};
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./blueprint.js";
export * from "./fallback-executor.js";
export * from "./pipeline.js";
export * from "./size-gate.js";
export * from "./telegram-message-stages.js";
export * from "./types.js";

Some files were not shown because too many files have changed in this diff Show More