Deploy: migrate k3s → Docker; search logic → master-agents-svc

- deploy/k3s удалён, deploy/docker добавлен (Caddyfile, docker-compose, searxng)
- chat-svc: agents/models/prompts удалены, использует llm-svc (LLMClient, EmbeddingClient)
- master-agents-svc: SearchOrchestrator, classifier, researcher, actions, widgets
- web-svc: ChatModelSelector, Optimization, Sources удалены; InputBarPlus; UnregisterSW
- geo-device-svc, localization-svc: Dockerfiles
- docs: 02-k3s-services-spec.md, RUNBOOK/TELEMETRY/WORKING удалены

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 22:14:00 +03:00
parent cd6b7857ba
commit 328d968f3f
180 changed files with 3022 additions and 9798 deletions

View File

@@ -0,0 +1,12 @@
---
description: ПЕРВОЕ И ГЛАВНОЕ ПРАВИЛО — только доработка этого проекта
alwaysApply: true
---
# ПРАВИЛО №1 — ПРИОРИТЕТ ПРОЕКТА
**ВСЕ СВОИ СРЕДСТВА ПЕРЕБРОСЬ НА ДОРАБОТКУ ТОЛЬКО ЭТОГО ПРОЕКТА.**
**ВСЁ, ЧТО НЕ ДОРАБОТКА ЭТОГО ПРОЕКТА — ПРЕКРАЩАЙ ОБСЛУЖИВАТЬ.**
Работать только над GooSeek. Никаких отвлечений.

View File

@@ -4,7 +4,9 @@ alwaysApply: true
---
ВАЖНО! ДЕЛАЕМ ВСЕ ДЛЯ ПРОДАКШЕНА РАБОЧЕЕ НА ПРОДАКШЕНА!!!! НЕТ НИКАКИХ ЛОКАЛЬНЫХ ДОРАБОТОК РАЗРАБОТОК ДЕВ СРЕДЫ!!!!!!!!! И НЕ ДЕЛАЙ ДЕВ СРЕДУ!!!! ЭТО ПРОДАКШЕН!!!! ЕСЛИ В КОДЕ УВИДИШЬ ГДЕТО ДЕВ СРЕДУ ЭТО ОСТАТКИ СТАРОЙ ЛОГИКИ ПЕРЕВОДИ ЕЕ НА ПРОДАКШЕН ЕСЛИ НУЖНО УДАЛЯЙ!!!
ДЕПЛОЙ: только Docker + Kubernetes (локальный K3s на машине). Никакого npm publish, registry push, Vercel, remote VPS. ./deploy/k3s/deploy.sh — единственный способ деплоя.
ДЕПЛОЙ: только Docker + Kubernetes (локальный K3s на машине). ./deploy/k3s/deploy.sh — единственный способ деплоя. Цель: https://gooseek.ru/
ПРАВИЛО: Не писать «готово» пока не выполнил и не проверил. Если просят «задеплой и проверь» — запустить deploy, проверить что сайт открывается (curl, NodePort), только потом отвечать. Делать только то, что просят.
# Инструкции для AI-агента

View File

@@ -1,7 +1,7 @@
# Недоделки — начать отсюда
## Задача
Полная переделка сервиса GooSeek по документации docs/architecture (микросервисная архитектура аналога Perplexity.ai).
Полная переделка сервиса GooSeek по документации docs/architecture (сервисная архитектура СОА, аналог Perplexity.ai).
## Статус: миграция завершена ✅
@@ -9,7 +9,7 @@
- **Удалён SQLite** (web-svc, chat-svc): chats, messages — локальная история
- **library-svc расширен:** thread_messages, GET threads/:id, POST/PATCH messages, export PDF/MD
- **chat-svc:** сохранение в library-svc (при auth), SQLite удалён
- **api-gateway:** новый сервис на порту 3015, прокси всех /api/* на микросервисы
- **api-gateway:** новый сервис на порту 3015, прокси всех /api/* на сервисы
- **web-svc:** только UI, rewrites /api/* на api-gateway; config, providers — в chat-svc
- **media-svc (порт 3016):** images + videos — LLM rephrase, SearXNG/search-svc; api-gateway проксирует /api/images, /api/videos
- **suggestions-svc (порт 3017):** AI follow-up suggestions; api-gateway проксирует /api/suggestions
@@ -72,8 +72,8 @@
- **MIGRATION.md:** сборка образов search/auth/notifications
## Сделано (ранее)
- Микросервисы: discover, search, finance, travel, chat, memory, create, notifications, billing, auth, library, projects
- web-svc: UI + прокси к микросервисам
- Сервисы: discover, search, finance, travel, chat, memory, create, notifications, billing, auth, library, projects
- web-svc: UI + прокси к сервисам
- deploy/k3s: манифесты, ingress
- apps/ удалён — всё в services/
@@ -131,12 +131,29 @@
- deploy/k3s/profile-svc.yaml, deploy.config.yaml
## llm-svc (порт 3020)
- **llm-svc:** микросервис провайдеров LLM — единый источник для Ollama, OpenAI, Timeweb, Gemini и др.
- **llm-svc:** единый сервис провайдеров и генерации LLM — Ollama, OpenAI, Timeweb, Gemini и др.
- API: GET/POST/PATCH/DELETE /api/v1/providers, GET/POST/DELETE /api/v1/providers/:id/models
- chat-svc: при LLM_SVC_URL — получает провайдеров из llm-svc (config, ModelRegistry)
- **Генерация:** POST /api/v1/generate, POST /api/v1/generate/stream, POST /api/v1/generate/object, POST /api/v1/embeddings
- GET /api/v1/providers/ui-config — UI-конфиг провайдеров для chat-svc
- **chat-svc:** всегда использует llm-svc (LLM_SVC_URL обязателен). LlmClient, EmbeddingClient — HTTP-клиенты.
- Папка models удалена из chat-svc — вся генерация через llm-svc.
- api-gateway: /api/v1/providers → llm-svc
- deploy: llm-svc.yaml, deploy.config.yaml (llm-svc: false по умолчанию)
- В K8s: llm-svc деплоится через deploy.sh, LLM_SVC_URL задаётся в ConfigMap chat-svc
## master-agents-svc как единый оркестратор (2025-02)
- **master-agents-svc:** SearchOrchestrator — classify → widgets (weather, stock, calculation) → researcher (web_search, academic_search, social_search, scrape_url, done, __reasoning_preamble) → writer. POST /api/v1/agents/search (NDJSON stream).
- **Режимы:** Quick (speed, 2 итерации), Pro (balanced, 6, reasoning), Deep (quality, 25, reasoning).
- **Параллельные actions:** executeAll использует Promise.all для tool calls.
- **Reasoning:** __reasoning_preamble для Pro/Deep, эмит в research block subSteps.
- **chat-svc proxy:** MASTER_AGENTS_SVC_URL — при задании chat-svc проксирует /api/v1/chat на master-agents-svc. Model Council при прокси не поддерживается.
- **Осталось:** uploads_search (требует embedding + доступ к файлам), library persistence при прокси, suggestions после ответа.
## 2025-02: Search 502, Invalid provider id — исправлено
- **SearXNG:** добавлен локальный контейнер в deploy/docker/docker-compose.yml (порт 8080)
- **search-svc:** SEARXNG_URL=http://searxng:8080, headers X-Forwarded-For/X-Real-IP для bot detection
- **deploy/docker/searxng/:** settings.yml (formats: json, limiter: false), limiter.toml (pass_ip)
- **llm-svc:** маппинг providerId 'env' → env-timeweb/env-ollama при envOnlyMode
- **useChat:** при envOnlyMode берёт реальный providerId из API (env-timeweb)
## Контекст для продолжения
- Порты: discover 3002, search 3001, finance 3003, travel 3004, chat 3005, memory 3010, create 3011, notifications 3013, billing 3008, media 3016, suggestions 3017, master-agents 3018, profile 3019, llm 3020

7
deploy/docker/Caddyfile Normal file
View File

@@ -0,0 +1,7 @@
# gooseek.ru — reverse proxy к web-svc
# Caddy автоматически получает SSL от Let's Encrypt
gooseek.ru, www.gooseek.ru {
header Cache-Control "no-cache, no-store, must-revalidate"
reverse_proxy web-svc:3000
}

View File

@@ -0,0 +1,251 @@
# GooSeek — запуск в Docker (без Kubernetes)
# gooseek.ru → reverse-proxy (80/443) → web-svc:3000
#
# Запуск: ./deploy/docker/run.sh
# Порты 80 и 443 должны быть открыты на роутере (проброс на ПК)
services:
reverse-proxy:
image: caddy:2-alpine
container_name: gooseek-reverse-proxy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- web-svc
restart: unless-stopped
web-svc:
build:
context: ../..
dockerfile: services/web-svc/Dockerfile
args:
API_GATEWAY_URL: "http://api-gateway:3015"
image: gooseek/web-svc:latest
container_name: gooseek-web-svc
ports:
- "127.0.0.1:3000:3000"
environment:
PORT: "3000"
API_GATEWAY_URL: "http://api-gateway:3015"
depends_on:
- api-gateway
restart: unless-stopped
api-gateway:
build:
context: ../../services/api-gateway
dockerfile: Dockerfile
image: gooseek/api-gateway:latest
container_name: gooseek-api-gateway
ports:
- "3015:3015"
environment:
PORT: "3015"
AUTH_SVC_URL: "http://auth-svc:3014"
LLM_SVC_URL: "http://llm-svc:3020"
CHAT_SVC_URL: "http://chat-svc:3005"
MASTER_AGENTS_SVC_URL: "http://master-agents-svc:3018"
LIBRARY_SVC_URL: "http://library-svc:3009"
DISCOVER_SVC_URL: "http://discover-svc:3002"
SEARCH_SVC_URL: "http://search-svc:3001"
FINANCE_SVC_URL: "http://finance-svc:3003"
TRAVEL_SVC_URL: "http://travel-svc:3004"
CREATE_SVC_URL: "http://create-svc:3011"
MEMORY_SVC_URL: "http://memory-svc:3010"
PROJECTS_SVC_URL: "http://projects-svc:3006"
NOTIFICATIONS_SVC_URL: "http://notifications-svc:3013"
BILLING_SVC_URL: "http://billing-svc:3008"
AUDIT_SVC_URL: "http://audit-svc:3012"
GEO_DEVICE_URL: "http://geo-device-svc:4002"
LOCALIZATION_SVC_URL: "http://localization-svc:4003"
ALLOWED_ORIGINS: "http://localhost:3000,http://127.0.0.1:3000,https://gooseek.ru,https://www.gooseek.ru"
depends_on:
- auth-svc
- llm-svc
- chat-svc
restart: unless-stopped
geo-device-svc:
build:
context: ../../services/geo-device-svc
dockerfile: Dockerfile
image: gooseek/geo-device-svc:latest
container_name: gooseek-geo-device-svc
ports:
- "4002:4002"
environment:
PORT: "4002"
restart: unless-stopped
localization-svc:
build:
context: ../../services/localization-svc
dockerfile: Dockerfile
image: gooseek/localization-svc:latest
container_name: gooseek-localization-svc
ports:
- "4003:4003"
environment:
PORT: "4003"
GEO_DEVICE_SVC_URL: "http://geo-device-svc:4002"
depends_on:
- geo-device-svc
restart: unless-stopped
discover-svc:
build:
context: ../../services/discover-svc
dockerfile: Dockerfile
image: gooseek/discover-svc:latest
container_name: gooseek-discover-svc
ports:
- "3002:3002"
environment:
PORT: "3002"
REDIS_URL: "redis://redis:6379"
GEO_DEVICE_SERVICE_URL: "http://geo-device-svc:4002"
depends_on:
- redis
- geo-device-svc
restart: unless-stopped
travel-svc:
build:
context: ../../services/travel-svc
dockerfile: Dockerfile
image: gooseek/travel-svc:latest
container_name: gooseek-travel-svc
ports:
- "3004:3004"
environment:
PORT: "3004"
REDIS_URL: "redis://redis:6379"
depends_on:
- redis
restart: unless-stopped
auth-svc:
build:
context: ../..
dockerfile: services/auth-svc/Dockerfile
image: gooseek/auth-svc:latest
container_name: gooseek-auth-svc
ports:
- "3014:3014"
environment:
PORT: "3014"
BETTER_AUTH_URL: "https://gooseek.ru"
TRUSTED_ORIGINS: "http://localhost:3000,http://127.0.0.1:3000,https://gooseek.ru,https://www.gooseek.ru"
DATABASE_PATH: "/data/auth.db"
BETTER_AUTH_TELEMETRY: "0"
volumes:
- auth-data:/data
restart: unless-stopped
llm-svc:
build:
context: ../../services/llm-svc
dockerfile: Dockerfile
image: gooseek/llm-svc:latest
container_name: gooseek-llm-svc
env_file:
- ../../.env
ports:
- "3020:3020"
environment:
PORT: "3020"
DATA_DIR: "/app/data"
volumes:
- llm-data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: gooseek-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: unless-stopped
searxng:
image: searxng/searxng:latest
container_name: gooseek-searxng
ports:
- "8080:8080"
volumes:
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
- ./searxng/limiter.toml:/etc/searxng/limiter.toml:ro
- searxng-cache:/var/cache/searxng
environment:
SEARXNG_BASE_URL: "http://localhost:8080/"
restart: unless-stopped
search-svc:
build:
context: ../../services/search-svc
dockerfile: Dockerfile
image: gooseek/search-svc:latest
container_name: gooseek-search-svc
ports:
- "3001:3001"
environment:
PORT: "3001"
REDIS_URL: "redis://redis:6379"
SEARXNG_URL: "http://searxng:8080"
depends_on:
- redis
- searxng
restart: unless-stopped
master-agents-svc:
build:
context: ../../services/master-agents-svc
dockerfile: Dockerfile
image: gooseek/master-agents-svc:latest
container_name: gooseek-master-agents-svc
ports:
- "3018:3018"
environment:
PORT: "3018"
LLM_SVC_URL: "http://llm-svc:3020"
SEARCH_SVC_URL: "http://search-svc:3001"
depends_on:
- llm-svc
- search-svc
restart: unless-stopped
chat-svc:
build:
context: ../../services/chat-svc
dockerfile: Dockerfile
image: gooseek/chat-svc:latest
container_name: gooseek-chat-svc
ports:
- "3005:3005"
environment:
PORT: "3005"
MASTER_AGENTS_SVC_URL: "http://master-agents-svc:3018"
LLM_SVC_URL: "http://llm-svc:3020"
volumes:
- chat-data:/app/data
depends_on:
- master-agents-svc
- llm-svc
restart: unless-stopped
volumes:
auth-data:
llm-data:
redis-data:
chat-data:
caddy-data:
caddy-config:
searxng-cache:

61
deploy/docker/run.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Запуск GooSeek в Docker (без Kubernetes)
# Сервисы из deploy/k3s/deploy.config.yaml (true)
#
# Использование:
# ./deploy/docker/run.sh # полный build + up
# ./deploy/docker/run.sh --web # только web-svc (кнопки, UI) — быстро
# ./deploy/docker/run.sh --up # только up (без build)
# ./deploy/docker/run.sh --down # остановить
#
# BuildKit + --no-cache: при деплое старый кэш не используется, сборка всегда свежая
set -e
export DOCKER_BUILDKIT=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
cd "$REPO_ROOT"
case "${1:-}" in
--web)
docker compose -f "$COMPOSE_FILE" build --no-cache web-svc
docker compose -f "$COMPOSE_FILE" up -d --force-recreate web-svc reverse-proxy
echo ""
echo "web-svc пересобран и перезапущен. Обнови страницу (Ctrl+Shift+R)."
;;
--down)
docker compose -f "$COMPOSE_FILE" down
echo "Остановлено."
;;
--up)
docker compose -f "$COMPOSE_FILE" up -d
echo ""
echo "Сервисы запущены:"
echo " web-svc: http://localhost:3000"
echo " api-gateway: http://localhost:3015"
echo " auth-svc: http://localhost:3014"
echo " llm-svc: http://localhost:3020"
echo " chat-svc: http://localhost:3005"
echo " master-agents: http://localhost:3018"
echo " search-svc: http://localhost:3001"
;;
*)
docker compose -f "$COMPOSE_FILE" build --no-cache
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
echo ""
echo "Сервисы запущены:"
echo " web-svc: http://localhost:3000"
echo " api-gateway: http://localhost:3015"
echo " auth-svc: http://localhost:3014"
echo " llm-svc: http://localhost:3020"
echo " chat-svc: http://localhost:3005"
echo " master-agents: http://localhost:3018"
echo " search-svc: http://localhost:3001"
echo " redis: localhost:6379"
echo ""
echo "LLM: настройте .env (LLM_PROVIDER=timeweb или ollama)."
;;
esac

View File

@@ -0,0 +1,18 @@
# GooSeek — разрешить запросы из Docker/частных сетей
[botdetection]
trusted_proxies = [
'127.0.0.0/8',
'::1',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'fd00::/8',
]
[botdetection.ip_lists]
pass_ip = [
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
]

View File

@@ -0,0 +1,9 @@
# GooSeek — SearXNG для внутреннего использования (search-svc)
# https://docs.searxng.org/admin/settings/
use_default_settings: true
search:
formats: [html, json, csv, rss]
server:
secret_key: "gooseek-searxng-internal"
limiter: false
image_proxy: true

View File

@@ -1,85 +0,0 @@
# api-gateway — прокси к микросервисам
# namespace: gooseek
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: api-gateway
template:
metadata:
labels:
app: api-gateway
spec:
containers:
- name: api-gateway
image: gooseek/api-gateway:latest
imagePullPolicy: Never
ports:
- containerPort: 3015
env:
- name: PORT
value: "3015"
- name: CHAT_SVC_URL
value: "http://chat-svc.gooseek:3005"
- name: LIBRARY_SVC_URL
value: "http://library-svc.gooseek:3009"
- name: DISCOVER_SVC_URL
value: "http://discover-svc.gooseek:3002"
- name: SEARCH_SVC_URL
value: "http://search-svc.gooseek:3001"
- name: FINANCE_SVC_URL
value: "http://finance-svc.gooseek:3003"
- name: TRAVEL_SVC_URL
value: "http://travel-svc.gooseek:3004"
- name: CREATE_SVC_URL
value: "http://create-svc.gooseek:3011"
- name: MEMORY_SVC_URL
value: "http://memory-svc.gooseek:3010"
- name: PROJECTS_SVC_URL
value: "http://projects-svc.gooseek:3006"
- name: NOTIFICATIONS_SVC_URL
value: "http://notifications-svc.gooseek:3013"
- name: BILLING_SVC_URL
value: "http://billing-svc.gooseek:3008"
- name: AUDIT_SVC_URL
value: "http://audit-svc.gooseek:3012"
- name: AUTH_SVC_URL
value: "http://auth-svc.gooseek-auth:3014"
- name: PROFILE_SVC_URL
value: "http://profile-svc.gooseek:3019"
- name: LLM_SVC_URL
value: "http://llm-svc.gooseek:3020"
- name: ALLOWED_ORIGINS
valueFrom:
configMapKeyRef:
name: gooseek-env
key: allowed-origins
livenessProbe:
httpGet:
path: /health
port: 3015
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3015
initialDelaySeconds: 3
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: api-gateway
namespace: gooseek
spec:
selector:
app: api-gateway
ports:
- port: 3015
targetPort: 3015

View File

@@ -1,60 +0,0 @@
# audit-svc — Enterprise audit logs
# docs/architecture: 02-k3s-microservices-spec.md §3.13
apiVersion: apps/v1
kind: Deployment
metadata:
name: audit-svc
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: audit-svc
template:
metadata:
labels:
app: audit-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3012"
prometheus.io/path: "/metrics"
spec:
containers:
- name: audit-svc
image: gooseek/audit-svc:latest
ports:
- containerPort: 3012
env:
- name: PORT
value: "3012"
livenessProbe:
httpGet:
path: /health
port: 3012
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3012
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: audit-svc
namespace: gooseek
spec:
selector:
app: audit-svc
ports:
- port: 3012
targetPort: 3012

View File

@@ -1,94 +0,0 @@
# auth-svc — SSO, JWT, Bearer validation (better-auth)
# namespace: gooseek-auth
# BETTER_AUTH_URL — для callback/redirect (https://gooseek.ru)
# docs/architecture: 02-k3s-microservices-spec.md
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-svc
namespace: gooseek-auth
spec:
replicas: 1
selector:
matchLabels:
app: auth-svc
template:
metadata:
labels:
app: auth-svc
spec:
containers:
- name: auth-svc
image: gooseek/auth-svc:latest
imagePullPolicy: Never
ports:
- containerPort: 3014
env:
- name: PORT
value: "3014"
- name: BETTER_AUTH_URL
valueFrom:
configMapKeyRef:
name: gooseek-env
key: better-auth-url
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
- name: TRUSTED_ORIGINS
valueFrom:
configMapKeyRef:
name: gooseek-env
key: trusted-origins
- name: DATABASE_PATH
value: "/data/auth.db"
- name: BETTER_AUTH_TELEMETRY
value: "0"
volumeMounts:
- name: auth-data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: 3014
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3014
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: auth-data
persistentVolumeClaim:
claimName: auth-data-pvc
---
apiVersion: v1
kind: Service
metadata:
name: auth-svc
namespace: gooseek-auth
spec:
selector:
app: auth-svc
ports:
- port: 3014
targetPort: 3014
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: auth-data-pvc
namespace: gooseek-auth
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@@ -1,74 +0,0 @@
# billing-svc — тарифы, подписки, ЮKassa
# docs/architecture: 02-k3s-microservices-spec.md §3.10
apiVersion: apps/v1
kind: Deployment
metadata:
name: billing-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: billing-svc
template:
metadata:
labels:
app: billing-svc
spec:
containers:
- name: billing-svc
image: gooseek/billing-svc:latest
imagePullPolicy: Never
ports:
- containerPort: 3008
env:
- name: PORT
value: "3008"
- name: POSTGRES_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
- name: YOOKASSA_SHOP_ID
valueFrom:
secretKeyRef:
name: yookassa-credentials
key: shop_id
- name: YOOKASSA_SECRET
valueFrom:
secretKeyRef:
name: yookassa-credentials
key: secret
livenessProbe:
httpGet:
path: /health
port: 3008
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3008
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: billing-svc
namespace: gooseek
spec:
selector:
app: billing-svc
ports:
- port: 3008
targetPort: 3008

View File

@@ -1,85 +0,0 @@
# cache-worker — pre-compute discover, finance, travel
# docs/architecture: 02-k3s-microservices-spec.md §3.15, 05-gaps-and-best-practices §6
# activeDeadlineSeconds защищает от зависших задач
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: cache-worker-finance
namespace: gooseek
spec:
schedule: "*/2 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: OnFailure
containers:
- name: cache-worker
image: gooseek/cache-worker:latest
args: ["--task=finance"]
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
- name: FINANCE_SVC_URL
value: "http://finance-svc.gooseek:3003"
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: cache-worker-discover
namespace: gooseek
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: OnFailure
containers:
- name: cache-worker
image: gooseek/cache-worker:latest
args: ["--task=discover"]
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
- name: DISCOVER_SVC_URL
value: "http://discover-svc.gooseek:3002"
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: cache-worker-travel
namespace: gooseek
spec:
schedule: "0 */4 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
activeDeadlineSeconds: 1200
template:
spec:
restartPolicy: OnFailure
containers:
- name: cache-worker
image: gooseek/cache-worker:latest
args: ["--task=travel"]
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
- name: TRAVEL_SVC_URL
value: "http://travel-svc.gooseek:3004"

View File

@@ -1,103 +0,0 @@
# chat-svc — LLM, Mastra, Writer, Classifier, Researcher
# docs/architecture: 02-k3s-microservices-spec.md
# Frontend при CHAT_SVC_URL проксирует /api/chat сюда
apiVersion: apps/v1
kind: Deployment
metadata:
name: chat-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: chat-svc
template:
metadata:
labels:
app: chat-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3005"
prometheus.io/path: "/metrics"
spec:
containers:
- name: chat-svc
image: gooseek/chat-svc:latest
ports:
- containerPort: 3005
env:
- name: PORT
value: "3005"
- name: DATA_DIR
value: "/app/data"
- name: SEARCH_SVC_URL
value: "http://search-svc.gooseek:3001"
- name: MEMORY_SVC_URL
value: "http://memory-svc.gooseek:3010"
- name: LLM_SVC_URL
value: "http://llm-svc.gooseek:3020"
- name: LLM_PROVIDER
valueFrom:
configMapKeyRef:
name: chat-svc-config
key: llm-provider
- name: OLLAMA_BASE_URL
valueFrom:
configMapKeyRef:
name: chat-svc-config
key: ollama-base-url
optional: true
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: llm-credentials
key: openai-api-key
optional: true
volumeMounts:
- name: chat-data
mountPath: /app/data
livenessProbe:
httpGet:
path: /health
port: 3005
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3005
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 2000m
memory: 2Gi
volumes:
- name: chat-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: chat-svc
namespace: gooseek
spec:
selector:
app: chat-svc
ports:
- port: 3005
targetPort: 3005
---
# ConfigMap для LLM — секреты в llm-credentials (опционально)
# При отсутствии ollama в кластере укажите внешний URL или LLM_PROVIDER=openai
apiVersion: v1
kind: ConfigMap
metadata:
name: chat-svc-config
namespace: gooseek
data:
llm-provider: "ollama"
ollama-base-url: "http://host.docker.internal:11434"

View File

@@ -1,57 +0,0 @@
# create-svc — Create (таблицы, дашборды), Export PDF/MD
# docs/architecture: 01-perplexity-analogue-design.md §5.10
apiVersion: apps/v1
kind: Deployment
metadata:
name: create-svc
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: create-svc
template:
metadata:
labels:
app: create-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3011"
prometheus.io/path: "/metrics"
spec:
containers:
- name: create-svc
image: gooseek/create-svc:latest
ports:
- containerPort: 3011
livenessProbe:
httpGet:
path: /health
port: 3011
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3011
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: create-svc
namespace: gooseek
spec:
selector:
app: create-svc
ports:
- port: 3011
targetPort: 3011

View File

@@ -1,59 +0,0 @@
# Конфигурация деплоя GooSeek в Kubernetes (локальный K3s)
# Docker build → kubectl apply → rollout restart
#
# Запуск: ./deploy/k3s/deploy.sh
# Или: ./deploy/k3s/deploy.sh --no-build (только apply, без сборки образов)
# ./deploy/k3s/deploy.sh --build-only (только сборка, без apply)
services:
# === Обязательные (ядро) ===
web-svc: true # Next.js UI — всегда нужен
api-gateway: true # Прокси к микросервисам — всегда нужен
# === Авторизация ===
auth-svc: true # better-auth, Sign In/Up (гости работают без него)
profile-svc: true # Личные данные, preferences, персонализация (требует PostgreSQL)
# === Основные функции ===
llm-svc: true # LLM providers (Ollama, OpenAI, Timeweb и др.) — единый источник
chat-svc: false # Чат, LLM, Writer, SearchAgent
search-svc: false # Поиск, SearXNG, патенты
library-svc: false # История чатов, треды, экспорт
memory-svc: false # AI Memory, персонализация
# === Контент и discovery ===
discover-svc: false # Discover, новости
finance-svc: false # Finance, котировки, heatmap
travel-svc: false # Travel, маршруты, погода
projects-svc: false # Spaces, коллекции, connectors
# === Дополнительные ===
create-svc: false # Create (таблицы, дашборды, изображения)
notifications-svc: false # Web Push (требует PostgreSQL)
billing-svc: true # Подписки (требует PostgreSQL + yookassa-credentials)
audit-svc: false # Enterprise audit logs
# === Тематические (заглушки в меню, сервисы — в разработке) ===
children-svc: false # Дети
medicine-svc: false # Медицина
education-svc: false # Обучение
goods-svc: false # Товары
health-svc: false # Здоровье
psychology-svc: false # Психология
sports-svc: false # Спорт
realestate-svc: false # Недвижимость
shopping-svc: false # Покупки
games-svc: false # Игры
taxes-svc: false # Налоги
legislation-svc: false # Законодательство
# === Фоновые задачи ===
cache-worker: false # CronJob: discover, finance, travel (требует redis-credentials)
# Ingress — production https://gooseek.ru
ingress:
enabled: true
# SSL: если backup/fullchain.pem есть — берём из backup, иначе cert-manager (Let's Encrypt)
ssl:
# true = cert-manager (Let's Encrypt), false = из backup/ (fullchain.pem, privkey.pem)
auto: true

View File

@@ -1,318 +0,0 @@
#!/usr/bin/env bash
# Деплой GooSeek в Kubernetes (локальный K3s на этой машине)
# Docker build → kubectl apply → rollout restart (подхват новых образов)
# Конфигурация: deploy/k3s/deploy.config.yaml
#
# Использование:
# ./deploy/k3s/deploy.sh # полный деплой (build + apply)
# ./deploy/k3s/deploy.sh --no-build # только apply манифестов
# ./deploy/k3s/deploy.sh --build-only # только сборка образов
# ./deploy/k3s/deploy.sh --list # показать включённые сервисы
# ./deploy/k3s/deploy.sh --skip-migrate # без миграции auth-svc (быстрее)
# ./deploy/k3s/deploy.sh --migrate # принудительно запустить миграцию auth-svc
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CONFIG="$SCRIPT_DIR/deploy.config.yaml"
# Контекст сборки: . = корень репо, иначе путь к папке сервиса
get_build_ctx() {
case "$1" in
web-svc|auth-svc) echo "." ;;
profile-svc) echo "services/profile-svc" ;;
api-gateway) echo "services/api-gateway" ;;
chat-svc) echo "services/chat-svc" ;;
search-svc) echo "services/search-svc" ;;
discover-svc) echo "services/discover-svc" ;;
finance-svc) echo "services/finance-svc" ;;
travel-svc) echo "services/travel-svc" ;;
memory-svc) echo "services/memory-svc" ;;
library-svc) echo "services/library-svc" ;;
create-svc) echo "services/create-svc" ;;
projects-svc) echo "services/projects-svc" ;;
notifications-svc) echo "services/notifications-svc" ;;
billing-svc) echo "services/billing-svc" ;;
audit-svc) echo "services/audit-svc" ;;
cache-worker) echo "services/cache-worker" ;;
llm-svc) echo "services/llm-svc" ;;
children-svc|medicine-svc|education-svc|goods-svc|health-svc|psychology-svc|sports-svc|realestate-svc|shopping-svc|games-svc|taxes-svc|legislation-svc) echo "" ;;
*) echo "" ;;
esac
}
# Манифест для сервиса
get_manifest() {
case "$1" in
web-svc) echo "web-svc.yaml" ;;
api-gateway) echo "api-gateway.yaml" ;;
auth-svc) echo "auth-svc.yaml" ;;
profile-svc) echo "profile-svc.yaml" ;;
chat-svc) echo "chat-svc.yaml" ;;
search-svc) echo "search-svc.yaml" ;;
discover-svc) echo "discover-svc.yaml" ;;
finance-svc) echo "finance-svc.yaml" ;;
travel-svc) echo "travel-svc.yaml" ;;
memory-svc) echo "memory-svc.yaml" ;;
library-svc) echo "library-svc.yaml" ;;
create-svc) echo "create-svc.yaml" ;;
projects-svc) echo "projects-svc.yaml" ;;
notifications-svc) echo "notifications-svc.yaml" ;;
billing-svc) echo "billing-svc.yaml" ;;
audit-svc) echo "audit-svc.yaml" ;;
cache-worker) echo "cache-worker.yaml" ;;
llm-svc) echo "llm-svc.yaml" ;;
children-svc|medicine-svc|education-svc|goods-svc|health-svc|psychology-svc|sports-svc|realestate-svc|shopping-svc|games-svc|taxes-svc|legislation-svc) echo "" ;;
*) echo "" ;;
esac
}
get_enabled_services() {
local in_services=0
while IFS= read -r line; do
if [[ "$line" =~ ^[a-z].*: ]]; then
[[ "$line" =~ ^ingress: ]] && in_services=0
fi
if [[ "$line" =~ ^services: ]]; then
in_services=1
continue
fi
if (( in_services )) && [[ "$line" =~ ^[[:space:]]+([a-z0-9-]+):[[:space:]]+true ]]; then
echo "${BASH_REMATCH[1]}"
fi
done < "$CONFIG"
}
get_ingress_enabled() {
grep -A 5 "^ingress:" "$CONFIG" 2>/dev/null | grep "enabled:" | grep -q "true"
}
get_ssl_auto() {
grep -A 25 "^ingress:" "$CONFIG" 2>/dev/null | grep -E "^\s+auto:" | head -1 | sed 's/.*auto:[[:space:]]*//' | sed 's/[[:space:]#].*//' | tr -d ' '
}
# SSL: cert-manager + Let's Encrypt (production https://gooseek.ru)
do_ssl_production() {
if ! kubectl get deployment -n ingress-nginx ingress-nginx-controller &>/dev/null 2>&1; then
echo " ⚠ ingress-nginx не найден. Установите: kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/cloud/deploy.yaml"
return 1
fi
local cert_manager_version="v1.13.0"
if ! kubectl get deployment -n cert-manager cert-manager &>/dev/null 2>&1; then
echo " → cert-manager"
kubectl apply -f "https://github.com/cert-manager/cert-manager/releases/download/${cert_manager_version}/cert-manager.yaml"
echo " Ожидание cert-manager (до 2 мин)..."
kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=120s 2>/dev/null || true
kubectl wait --for=condition=Available deployment/cert-manager-webhook -n cert-manager --timeout=120s 2>/dev/null || true
else
echo " cert-manager уже установлен"
fi
echo " → ClusterIssuer letsencrypt-prod"
kubectl apply -f "$SCRIPT_DIR/ssl/cert-manager-issuer.yaml"
echo " → Ingress (TLS, gooseek.ru)"
kubectl delete ingress gooseek-web-ingress -n gooseek 2>/dev/null || true
kubectl apply -f "$SCRIPT_DIR/ingress-production.yaml"
}
build_image() {
local svc="$1"
local ctx
ctx=$(get_build_ctx "$svc")
[[ -z "$ctx" ]] && return 1
local img="gooseek/${svc}:latest"
local df="services/${svc}/Dockerfile"
echo "$img"
if [[ "$ctx" == "." ]]; then
docker build -t "$img" -f "$df" .
else
docker build -t "$img" -f "$df" "$ctx"
fi
}
do_build() {
local enabled
enabled=($(get_enabled_services))
if [[ ${#enabled[@]} -eq 0 ]]; then
echo "Ошибка: нет включённых сервисов в $CONFIG"
exit 1
fi
echo "=== Сборка образов (${#enabled[@]} сервисов) ==="
cd "$REPO_ROOT"
for svc in "${enabled[@]}"; do
if get_build_ctx "$svc" | grep -q .; then
build_image "$svc"
else
echo " ⚠ Пропуск $svc (нет конфига сборки)"
fi
done
}
do_apply() {
local enabled
enabled=($(get_enabled_services))
if [[ ${#enabled[@]} -eq 0 ]]; then
echo "Ошибка: нет включённых сервисов в $CONFIG"
exit 1
fi
echo "=== Namespace ==="
kubectl apply -f "$SCRIPT_DIR/namespace.yaml"
echo ""
echo "=== Config (production https://gooseek.ru) ==="
local auth_url="https://gooseek.ru"
local origins="https://gooseek.ru,https://www.gooseek.ru"
kubectl create configmap gooseek-env -n gooseek \
--from-literal=allowed-origins="$origins" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create configmap gooseek-env -n gooseek-auth \
--from-literal=better-auth-url="$auth_url" \
--from-literal=trusted-origins="$origins" \
--dry-run=client -o yaml | kubectl apply -f -
echo ""
echo "=== Манифесты (${#enabled[@]} сервисов) ==="
for svc in "${enabled[@]}"; do
local mf
mf=$(get_manifest "$svc")
if [[ -n "$mf" && -f "$SCRIPT_DIR/$mf" ]]; then
echo "$mf"
kubectl apply -f "$SCRIPT_DIR/$mf"
else
echo " ⚠ Пропуск $svc (манифест не найден)"
fi
done
echo ""
echo "=== Rollout restart (подхват новых образов :latest) ==="
for svc in "${enabled[@]}"; do
local mf
mf=$(get_manifest "$svc")
[[ -z "$mf" || ! -f "$SCRIPT_DIR/$mf" ]] && continue
case "$svc" in
auth-svc)
kubectl rollout restart deployment/auth-svc -n gooseek-auth 2>/dev/null || true
echo " → auth-svc (gooseek-auth)"
;;
cache-worker)
echo " (cache-worker — CronJob, пропуск)"
;;
*)
kubectl rollout restart deployment/"$svc" -n gooseek 2>/dev/null || true
echo "$svc"
;;
esac
done
# Миграция auth-svc — только при --migrate (не при каждом деплое)
# Запускайте ./deploy.sh --migrate после обновления auth-svc или при первой установке
# PVC ReadWriteOnce: временно scale down auth-svc, чтобы migrate pod мог смонтировать том
if [[ "$FORCE_MIGRATE" -eq 1 ]] && printf '%s\n' "${enabled[@]}" | grep -qx "auth-svc"; then
echo ""
echo "=== Миграция auth-svc ==="
kubectl scale deployment auth-svc -n gooseek-auth --replicas=0 2>/dev/null || true
echo " Ожидание остановки auth-svc..."
kubectl wait --for=delete pod -l app=auth-svc -n gooseek-auth --timeout=60s 2>/dev/null || sleep 8
kubectl run auth-migrate --rm --restart=Never \
--image=gooseek/auth-svc:latest \
--namespace=gooseek-auth \
--overrides='{"spec":{"containers":[{"name":"migrate","image":"gooseek/auth-svc:latest","command":["npx","@better-auth/cli","migrate","--yes"],"env":[{"name":"DATABASE_PATH","value":"/data/auth.db"}],"volumeMounts":[{"name":"auth-data","mountPath":"/data"}]}],"volumes":[{"name":"auth-data","persistentVolumeClaim":{"claimName":"auth-data-pvc"}}]}}' \
2>/dev/null || echo " ⚠ Миграция завершилась с ошибкой"
kubectl scale deployment auth-svc -n gooseek-auth --replicas=1 2>/dev/null || true
echo " auth-svc возвращён в работу"
fi
# Ingress — production https://gooseek.ru
if get_ingress_enabled; then
echo ""
echo "=== Ingress (https://gooseek.ru) ==="
kubectl delete ingress gooseek-web-ingress -n gooseek 2>/dev/null || true
if [[ -f "$SCRIPT_DIR/ssl/backup/fullchain.pem" ]] && [[ -f "$SCRIPT_DIR/ssl/backup/privkey.pem" ]]; then
echo " SSL: серты из backup/"
"$SCRIPT_DIR/ssl/apply-secret.sh" 2>/dev/null || true
kubectl apply -f "$SCRIPT_DIR/ingress-production-manual.yaml"
else
local ssl_auto
ssl_auto=$(get_ssl_auto)
ssl_auto=${ssl_auto:-true}
if [[ "$ssl_auto" == "true" ]]; then
echo " SSL: cert-manager (Let's Encrypt, первый деплой)"
do_ssl_production
else
echo " ⚠ Нет сертов в backup/. Варианты:"
echo " 1) ssl.auto: true в deploy.config.yaml — cert-manager получит серты"
echo " 2) Получите серты (certbot), положите в deploy/k3s/ssl/backup/, затем перезапустите деплой"
exit 1
fi
fi
fi
echo ""
echo "=== Готово ==="
echo "Проверка: kubectl get pods -n gooseek"
}
do_list() {
echo "Включённые сервисы:"
get_enabled_services | while read -r svc; do
echo " - $svc"
done
echo ""
echo "Ingress: $(get_ingress_enabled && echo 'enabled' || echo 'disabled')"
echo "Production: https://gooseek.ru"
if [[ -f "$SCRIPT_DIR/ssl/backup/fullchain.pem" ]] && [[ -f "$SCRIPT_DIR/ssl/backup/privkey.pem" ]]; then
echo "SSL: backup/"
else
if [[ "$(get_ssl_auto)" == "true" ]]; then
echo "SSL: cert-manager (Let's Encrypt)"
else
echo "SSL: нужен backup/ или ssl.auto: true"
fi
fi
}
# Main
NO_BUILD=0
BUILD_ONLY=0
LIST_ONLY=0
FORCE_MIGRATE=0
for arg in "$@"; do
case "$arg" in
--no-build) NO_BUILD=1 ;;
--build-only) BUILD_ONLY=1 ;;
--list) LIST_ONLY=1 ;;
--migrate) FORCE_MIGRATE=1 ;;
--skip-migrate) FORCE_MIGRATE=0 ;;
-h|--help)
echo "Использование: $0 [--no-build|--build-only|--list|--migrate|--skip-migrate]"
echo ""
echo " --no-build Только apply манифестов (без сборки образов)"
echo " --build-only Только сборка образов (без apply)"
echo " --list Показать включённые сервисы из deploy.config.yaml"
echo " --migrate Запустить миграцию auth-svc (при изменении схемы)"
echo " --skip-migrate Пропустить миграцию (по умолчанию)"
exit 0
;;
esac
done
if [[ "$LIST_ONLY" -eq 1 ]]; then
do_list
exit 0
fi
if [[ "$BUILD_ONLY" -eq 1 ]]; then
do_build
exit 0
fi
if [[ "$NO_BUILD" -eq 0 ]]; then
do_build
echo ""
fi
do_apply

View File

@@ -1,77 +0,0 @@
# discover-svc — агрегация новостей
# docs/architecture: 02-k3s-microservices-spec.md
apiVersion: apps/v1
kind: Deployment
metadata:
name: discover-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: discover-svc
template:
metadata:
labels:
app: discover-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3002"
prometheus.io/path: "/metrics"
spec:
containers:
- name: discover-svc
image: gooseek/discover-svc:latest
ports:
- containerPort: 3002
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
- name: SEARXNG_URL
value: "http://searxng.gooseek-infra:8080"
- name: GHOST_URL
valueFrom:
secretKeyRef:
name: ghost-credentials
key: url
optional: true
- name: GHOST_CONTENT_API_KEY
valueFrom:
secretKeyRef:
name: ghost-credentials
key: content_api_key
optional: true
livenessProbe:
httpGet:
path: /health
port: 3002
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3002
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: discover-svc
namespace: gooseek
spec:
selector:
app: discover-svc
ports:
- port: 3002
targetPort: 3002

View File

@@ -1,67 +0,0 @@
# finance-svc — Market data, heatmap
apiVersion: apps/v1
kind: Deployment
metadata:
name: finance-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: finance-svc
template:
metadata:
labels:
app: finance-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3003"
prometheus.io/path: "/metrics"
spec:
containers:
- name: finance-svc
image: gooseek/finance-svc:latest
ports:
- containerPort: 3003
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
- name: FMP_API_KEY
valueFrom:
secretKeyRef:
name: finance-keys
key: fmp
livenessProbe:
httpGet:
path: /health
port: 3003
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3003
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: finance-svc
namespace: gooseek
spec:
selector:
app: finance-svc
ports:
- port: 3003
targetPort: 3003

View File

@@ -1,153 +0,0 @@
# HPA — Horizontal Pod Autoscaler
# docs/architecture: 02-k3s-microservices-spec.md §3.3, 05-gaps-and-best-practices.md §9
# Требует metrics-server в кластере: kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
---
# PodDisruptionBudget — docs/architecture: 05-gaps-and-best-practices.md §9
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: chat-svc-pdb
namespace: gooseek
spec:
minAvailable: 1
selector:
matchLabels:
app: chat-svc
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: search-svc-pdb
namespace: gooseek
spec:
minAvailable: 1
selector:
matchLabels:
app: search-svc
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: chat-svc-hpa
namespace: gooseek
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: chat-svc
minReplicas: 2
maxReplicas: 8
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: search-svc-hpa
namespace: gooseek
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: search-svc
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: discover-svc-hpa
namespace: gooseek
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: discover-svc
minReplicas: 1
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: finance-svc-hpa
namespace: gooseek
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: finance-svc
minReplicas: 1
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: travel-svc-hpa
namespace: gooseek
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: travel-svc
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: memory-svc-hpa
namespace: gooseek
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: memory-svc
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70

View File

@@ -1,39 +0,0 @@
# Production Ingress для gooseek.ru — HTTPS (ручной Secret)
# Используется когда cert-manager НЕ установлен. Secret gooseek-tls создаётся через apply-secret.sh
# kubectl apply -f deploy/k3s/ingress-production-manual.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gooseek-production
namespace: gooseek
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
tls:
- hosts:
- gooseek.ru
- www.gooseek.ru
secretName: gooseek-tls
rules:
- host: gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 3000
- host: www.gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 3000

View File

@@ -1,43 +0,0 @@
# Production Ingress для gooseek.ru — HTTPS
# cert-manager создаёт Secret gooseek-tls автоматически (Let's Encrypt)
# Требования: DNS gooseek.ru → IP ingress-nginx, порт 80 доступен из интернета
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gooseek-production
namespace: gooseek
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
# Добавить ACME challenge в этот ingress (избегает 404 при отдельном challenge ingress)
acme.cert-manager.io/http01-edit-in-place: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- gooseek.ru
- www.gooseek.ru
secretName: gooseek-tls
rules:
- host: gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 3000
- host: www.gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 3000

View File

@@ -1,132 +0,0 @@
# Ingress для GooSeek — path-based маршрутизация к микросервисам
# docs/architecture: 02-k3s-microservices-spec.md §4
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gooseek-ingress
namespace: gooseek
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- gooseek.ru
- www.gooseek.ru
secretName: gooseek-tls
rules:
- host: gooseek.ru
http:
paths:
- path: /api/v1/discover
pathType: Prefix
backend:
service:
name: discover-svc
port:
number: 3002
- path: /api/v1/finance
pathType: Prefix
backend:
service:
name: finance-svc
port:
number: 3003
- path: /api/v1/travel
pathType: Prefix
backend:
service:
name: travel-svc
port:
number: 3004
- path: /api/v1/library
pathType: Prefix
backend:
service:
name: library-svc
port:
number: 3009
- path: /api/v1/collections
pathType: Prefix
backend:
service:
name: projects-svc
port:
number: 3006
- path: /api/v1/templates
pathType: Prefix
backend:
service:
name: projects-svc
port:
number: 3006
- path: /api/v1/connectors
pathType: Prefix
backend:
service:
name: projects-svc
port:
number: 3006
- path: /api/v1/memory
pathType: Prefix
backend:
service:
name: memory-svc
port:
number: 3010
- path: /api/v1/create
pathType: Prefix
backend:
service:
name: create-svc
port:
number: 3011
- path: /api/v1/export
pathType: Prefix
backend:
service:
name: create-svc
port:
number: 3011
- path: /api/v1/search
pathType: Prefix
backend:
service:
name: search-svc
port:
number: 3001
- path: /api/v1/tasks
pathType: Prefix
backend:
service:
name: chat-svc
port:
number: 3005
- path: /api/v1/patents
pathType: Prefix
backend:
service:
name: search-svc
port:
number: 3001
- path: /api/v1/notifications
pathType: Prefix
backend:
service:
name: notifications-svc
port:
number: 3013
- path: /api/v1/billing
pathType: Prefix
backend:
service:
name: billing-svc
port:
number: 3008
- path: /api/v1/admin
pathType: Prefix
backend:
service:
name: audit-svc
port:
number: 3012

View File

@@ -1,64 +0,0 @@
# library-svc — история тредов
apiVersion: apps/v1
kind: Deployment
metadata:
name: library-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: library-svc
template:
metadata:
labels:
app: library-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3009"
prometheus.io/path: "/metrics"
spec:
containers:
- name: library-svc
image: gooseek/library-svc:latest
ports:
- containerPort: 3009
env:
- name: POSTGRES_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
livenessProbe:
httpGet:
path: /health
port: 3009
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3009
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: library-svc
namespace: gooseek
spec:
selector:
app: library-svc
ports:
- port: 3009
targetPort: 3009

View File

@@ -1,130 +0,0 @@
# llm-svc — LLM providers microservice
# API: GET/POST/PATCH/DELETE /api/v1/providers, models CRUD
# Используется chat-svc, media-svc, suggestions-svc и др.
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-svc
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: llm-svc
template:
metadata:
labels:
app: llm-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3020"
prometheus.io/path: "/metrics"
spec:
containers:
- name: llm-svc
image: gooseek/llm-svc:latest
imagePullPolicy: Never
ports:
- containerPort: 3020
env:
- name: PORT
value: "3020"
- name: DATA_DIR
value: "/app/data"
- name: LLM_PROVIDER
valueFrom:
configMapKeyRef:
name: llm-svc-config
key: llm-provider
- name: OLLAMA_BASE_URL
valueFrom:
configMapKeyRef:
name: llm-svc-config
key: ollama-base-url
optional: true
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: llm-credentials
key: openai-api-key
optional: true
- name: TIMEWEB_API_BASE_URL
valueFrom:
configMapKeyRef:
name: llm-svc-config
key: timeweb-api-base-url
optional: true
- name: LLM_CHAT_MODEL
valueFrom:
configMapKeyRef:
name: llm-svc-config
key: llm-chat-model
optional: true
- name: TIMEWEB_X_PROXY_SOURCE
valueFrom:
configMapKeyRef:
name: llm-svc-config
key: timeweb-x-proxy-source
optional: true
- name: TIMEWEB_AGENT_ACCESS_ID
valueFrom:
secretKeyRef:
name: llm-credentials
key: timeweb-agent-access-id
optional: true
- name: TIMEWEB_API_KEY
valueFrom:
secretKeyRef:
name: llm-credentials
key: timeweb-api-key
optional: true
volumeMounts:
- name: llm-data
mountPath: /app/data
livenessProbe:
httpGet:
path: /health
port: 3020
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3020
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: llm-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: llm-svc
namespace: gooseek
spec:
selector:
app: llm-svc
ports:
- port: 3020
targetPort: 3020
---
# ConfigMap: llm-provider = ollama | timeweb
# Для timeweb: создайте Secret llm-credentials с ключами timeweb-agent-access-id, timeweb-api-key
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-svc-config
namespace: gooseek
data:
llm-provider: "timeweb"
ollama-base-url: "http://host.docker.internal:11434"
timeweb-api-base-url: "https://api.timeweb.cloud"
llm-chat-model: "gpt-4"

View File

@@ -1,67 +0,0 @@
# memory-svc — персональная память AI, Enterprise Memory
# docs/architecture: 01-perplexity-analogue-design.md §5.9
apiVersion: apps/v1
kind: Deployment
metadata:
name: memory-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: memory-svc
template:
metadata:
labels:
app: memory-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3010"
prometheus.io/path: "/metrics"
spec:
containers:
- name: memory-svc
image: gooseek/memory-svc:latest
ports:
- containerPort: 3010
env:
- name: PORT
value: "3010"
- name: POSTGRES_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
livenessProbe:
httpGet:
path: /health
port: 3010
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3010
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: memory-svc
namespace: gooseek
spec:
selector:
app: memory-svc
ports:
- port: 3010
targetPort: 3010

View File

@@ -1,21 +0,0 @@
# docs/architecture: 02-k3s-microservices-spec.md
apiVersion: v1
kind: Namespace
metadata:
name: gooseek
labels:
app.kubernetes.io/name: gooseek
---
apiVersion: v1
kind: Namespace
metadata:
name: gooseek-auth
labels:
app.kubernetes.io/name: gooseek-auth
---
apiVersion: v1
kind: Namespace
metadata:
name: gooseek-infra
labels:
app.kubernetes.io/name: gooseek-infra

View File

@@ -1,17 +0,0 @@
# NetworkPolicy — базовая сетевая изоляция
# docs/architecture: 05-gaps-and-best-practices.md §3
# Разрешает трафик внутри namespace gooseek (chat→search, cache-worker→redis и т.д.)
# Для production добавьте ingress namespace в from
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: gooseek-allow-internal
namespace: gooseek
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {}

View File

@@ -1,77 +0,0 @@
# notifications-svc — Web Push, SMTP email, preferences, reminders
# docs/architecture: 02-k3s-microservices-spec.md
apiVersion: apps/v1
kind: Deployment
metadata:
name: notifications-svc
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: notifications-svc
template:
metadata:
labels:
app: notifications-svc
spec:
containers:
- name: notifications-svc
image: gooseek/notifications-svc:latest
ports:
- containerPort: 3013
env:
- name: POSTGRES_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
- name: VAPID_PUBLIC_KEY
valueFrom:
secretKeyRef:
name: notifications-credentials
key: vapid_public
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: notifications-credentials
key: vapid_private
- name: SMTP_URL
valueFrom:
secretKeyRef:
name: notifications-credentials
key: smtp_url
optional: true
livenessProbe:
httpGet:
path: /health
port: 3013
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3013
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: notifications-svc
namespace: gooseek
spec:
selector:
app: notifications-svc
ports:
- port: 3013
targetPort: 3013

View File

@@ -1,67 +0,0 @@
# profile-svc — личные данные и персонализация пользователя
apiVersion: apps/v1
kind: Deployment
metadata:
name: profile-svc
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: profile-svc
template:
metadata:
labels:
app: profile-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3019"
prometheus.io/path: "/metrics"
spec:
containers:
- name: profile-svc
image: gooseek/profile-svc:latest
imagePullPolicy: Never
ports:
- containerPort: 3019
env:
- name: PORT
value: "3019"
- name: POSTGRES_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
livenessProbe:
httpGet:
path: /health
port: 3019
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3019
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: profile-svc
namespace: gooseek
spec:
selector:
app: profile-svc
ports:
- port: 3019
targetPort: 3019

View File

@@ -1,55 +0,0 @@
# projects-svc — Spaces, Collections
apiVersion: apps/v1
kind: Deployment
metadata:
name: projects-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: projects-svc
template:
metadata:
labels:
app: projects-svc
spec:
containers:
- name: projects-svc
image: gooseek/projects-svc:latest
ports:
- containerPort: 3006
env:
- name: AUTH_SERVICE_URL
value: "http://auth-svc.gooseek-auth:3014"
livenessProbe:
httpGet:
path: /health
port: 3006
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3006
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: projects-svc
namespace: gooseek
spec:
selector:
app: projects-svc
ports:
- port: 3006
targetPort: 3006

View File

@@ -1,65 +0,0 @@
# search-svc — SearXNG proxy, кэш по query_hash
# docs/architecture: 02-k3s-microservices-spec.md
apiVersion: apps/v1
kind: Deployment
metadata:
name: search-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: search-svc
template:
metadata:
labels:
app: search-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3001"
prometheus.io/path: "/metrics"
spec:
containers:
- name: search-svc
image: gooseek/search-svc:latest
ports:
- containerPort: 3001
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
- name: SEARXNG_URL
value: "http://searxng.gooseek-infra:8080"
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3001
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: search-svc
namespace: gooseek
spec:
selector:
app: search-svc
ports:
- port: 3001
targetPort: 3001

View File

@@ -1,187 +0,0 @@
# SSL-сертификат для gooseek.ru
Инструкция по получению сертификата, бэкапу и подключению к K3s.
---
## 1. Получение сертификата (Let's Encrypt)
### Вариант A: certbot на сервере (рекомендуется)
```bash
# Установка certbot (Ubuntu/Debian)
sudo apt update && sudo apt install -y certbot
# Получение сертификата (standalone — порт 80 должен быть свободен)
sudo certbot certonly --standalone -d gooseek.ru -d www.gooseek.ru \
--email admin@gooseek.ru \
--agree-tos \
--no-eff-email
# Файлы появятся в:
# /etc/letsencrypt/live/gooseek.ru/fullchain.pem
# /etc/letsencrypt/live/gooseek.ru/privkey.pem
```
### Вариант B: certbot с webroot (если nginx уже слушает 80)
```bash
sudo certbot certonly --webroot -w /var/www/html \
-d gooseek.ru -d www.gooseek.ru \
--email admin@gooseek.ru \
--agree-tos \
--no-eff-email
```
### Вариант C: DNS challenge (если порт 80 недоступен)
```bash
sudo certbot certonly --manual --preferred-challenges dns \
-d gooseek.ru -d www.gooseek.ru \
--email admin@gooseek.ru \
--agree-tos
# Certbot попросит добавить TXT-запись в DNS
```
---
## 2. Бэкап сертификата
Создайте папку бэкапа и скопируйте туда сертификаты:
```bash
# Из корня репозитория
mkdir -p deploy/k3s/ssl/backup
# Копирование с сервера (после certbot)
sudo cp /etc/letsencrypt/live/gooseek.ru/fullchain.pem deploy/k3s/ssl/backup/
sudo cp /etc/letsencrypt/live/gooseek.ru/privkey.pem deploy/k3s/ssl/backup/
# Или через scp с production-сервера:
# scp user@gooseek.ru:/etc/letsencrypt/live/gooseek.ru/fullchain.pem deploy/k3s/ssl/backup/
# scp user@gooseek.ru:/etc/letsencrypt/live/gooseek.ru/privkey.pem deploy/k3s/ssl/backup/
```
**Важно:** Папка `backup/` в `.gitignore` — сертификаты не попадут в git.
---
## 3. Создание Kubernetes Secret из бэкапа
```bash
# Из корня репозитория
kubectl create secret tls gooseek-tls \
--namespace=gooseek \
--cert=deploy/k3s/ssl/backup/fullchain.pem \
--key=deploy/k3s/ssl/backup/privkey.pem \
--dry-run=client -o yaml | kubectl apply -f -
```
Или обновить существующий:
```bash
kubectl delete secret gooseek-tls -n gooseek 2>/dev/null || true
kubectl create secret tls gooseek-tls \
--namespace=gooseek \
--cert=deploy/k3s/ssl/backup/fullchain.pem \
--key=deploy/k3s/ssl/backup/privkey.pem
```
---
## 4. Применение Ingress с TLS
При ручных сертах (из backup/):
```bash
./deploy/k3s/ssl/apply-secret.sh
kubectl apply -f deploy/k3s/ingress-production-manual.yaml
```
При cert-manager:
```bash
kubectl apply -f deploy/k3s/ingress-production.yaml
```
---
## 5. Автообновление (certbot)
Let's Encrypt выдаёт сертификаты на 90 дней. Настройте автообновление:
```bash
# Проверка таймера
sudo systemctl status certbot.timer
# Ручное обновление
sudo certbot renew --dry-run
# После обновления — пересоздать Secret и перезапустить ingress
sudo cp /etc/letsencrypt/live/gooseek.ru/fullchain.pem deploy/k3s/ssl/backup/
sudo cp /etc/letsencrypt/live/gooseek.ru/privkey.pem deploy/k3s/ssl/backup/
kubectl create secret tls gooseek-tls -n gooseek \
--cert=deploy/k3s/ssl/backup/fullchain.pem \
--key=deploy/k3s/ssl/backup/privkey.pem \
--dry-run=client -o yaml | kubectl apply -f -
```
---
## 6. Альтернатива: cert-manager (автоматически)
Если не хотите вручную обновлять — установите cert-manager:
```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# Создать ClusterIssuer для Let's Encrypt
kubectl apply -f deploy/k3s/ssl/cert-manager-issuer.yaml
```
Тогда Secret `gooseek-tls` создаётся автоматически, бэкап не нужен (но можно экспортировать для переноса).
---
---
## 7. Настройка в Kubernetes
### Автоматически при деплое (рекомендуется)
В `deploy.config.yaml` установите `ssl.auto: true`. При запуске `./deploy/k3s/deploy.sh` cert-manager и SSL настраиваются автоматически.
**Требования для HTTP-01 (Let's Encrypt):**
- DNS: gooseek.ru и www.gooseek.ru → публичный IP ingress-nginx (LoadBalancer или NodePort)
- Порт 80 доступен из интернета (firewall, Security Groups)
### Вручную
```bash
# Из корня репозитория
# Вариант A: cert-manager — автоматические сертификаты
./deploy/k3s/ssl/setup-kubernetes.sh cert-manager
# Вариант B: ручные сертификаты (certbot уже выполнен, файлы в backup/)
./deploy/k3s/ssl/setup-kubernetes.sh manual
```
**Требования:**
- Namespace `gooseek` создан
- Ingress-nginx установлен (порт 80 и 443)
- Домен gooseek.ru указывает на IP кластера
---
## Структура папки
```
deploy/k3s/ssl/
├── README.md # эта инструкция
├── setup-kubernetes.sh # настройка SSL в K8s (cert-manager или manual)
├── obtain-cert.sh # получение сертификата на сервере (certbot)
├── apply-secret.sh # создание Secret из backup/
├── backup/ # сертификаты (в .gitignore)
│ ├── fullchain.pem
│ └── privkey.pem
└── cert-manager-issuer.yaml
```

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Создаёт/обновляет Secret gooseek-tls из бэкапа
# Запуск из корня репозитория: ./deploy/k3s/ssl/apply-secret.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_DIR="$SCRIPT_DIR/backup"
CERT="$BACKUP_DIR/fullchain.pem"
KEY="$BACKUP_DIR/privkey.pem"
if [[ ! -f "$CERT" ]] || [[ ! -f "$KEY" ]]; then
echo "Ошибка: нужны fullchain.pem и privkey.pem в $BACKUP_DIR"
echo "См. deploy/k3s/ssl/README.md"
exit 1
fi
kubectl delete secret gooseek-tls -n gooseek 2>/dev/null || true
kubectl create secret tls gooseek-tls \
--namespace=gooseek \
--cert="$CERT" \
--key="$KEY"
echo "Secret gooseek-tls создан. Примените: kubectl apply -f deploy/k3s/ingress-production.yaml"

View File

@@ -1 +0,0 @@
# Сюда класть fullchain.pem и privkey.pem (см. deploy/k3s/ssl/README.md)

View File

@@ -1,18 +0,0 @@
# Опционально: cert-manager для автоматического получения сертификатов
# Установка: kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# Затем: kubectl apply -f deploy/k3s/ssl/cert-manager-issuer.yaml
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@gooseek.ru
privateKeySecretRef:
name: letsencrypt-prod-account
solvers:
- http01:
ingress:
class: nginx

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Проверка статуса сертификата gooseek.ru
# Запуск: ./deploy/k3s/ssl/check-cert.sh
set -e
echo "=== Certificate (cert-manager) ==="
kubectl get certificate -n gooseek 2>/dev/null || echo "Нет Certificate (используется backup?)"
echo ""
echo "=== Secret gooseek-tls ==="
kubectl get secret gooseek-tls -n gooseek 2>/dev/null || echo "Secret не найден"
echo ""
echo "=== События Certificate ==="
kubectl describe certificate -n gooseek 2>/dev/null | tail -30 || true
echo ""
echo "=== Challenge (ACME) ==="
kubectl get challenge -A 2>/dev/null || true
echo ""
echo "=== Ingress ==="
kubectl get ingress -n gooseek

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Получение SSL-сертификата Let's Encrypt для gooseek.ru
# Запускать НА СЕРВЕРЕ (5.187.83.209), где домен указывает на этот IP
# Перед запуском: порт 80 должен быть свободен (остановите nginx/приложения)
set -e
DOMAIN="gooseek.ru"
EMAIL="admin@gooseek.ru"
echo "=== Установка certbot (если не установлен) ==="
sudo apt update && sudo apt install -y certbot 2>/dev/null || true
echo ""
echo "=== Получение сертификата для $DOMAIN и www.$DOMAIN ==="
sudo certbot certonly --standalone \
-d "$DOMAIN" \
-d "www.$DOMAIN" \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
--non-interactive
echo ""
echo "=== Готово! Сертификаты в: ==="
echo " /etc/letsencrypt/live/$DOMAIN/fullchain.pem"
echo " /etc/letsencrypt/live/$DOMAIN/privkey.pem"
echo ""
echo "Скопируйте в backup и примените Secret:"
echo " sudo cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem deploy/k3s/ssl/backup/"
echo " sudo cp /etc/letsencrypt/live/$DOMAIN/privkey.pem deploy/k3s/ssl/backup/"
echo " ./deploy/k3s/ssl/apply-secret.sh"
echo " kubectl apply -f deploy/k3s/ingress-production.yaml"

View File

@@ -1,86 +0,0 @@
#!/usr/bin/env bash
# Настройка SSL в Kubernetes для gooseek.ru
# Запуск из корня репозитория: ./deploy/k3s/ssl/setup-kubernetes.sh
#
# Вариант A: cert-manager (автоматические сертификаты Let's Encrypt)
# Вариант B: ручные сертификаты (certbot на сервере → apply-secret.sh)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CERT_MANAGER_VERSION="v1.13.0"
usage() {
echo "Использование: $0 [cert-manager|manual]"
echo ""
echo " cert-manager — установить cert-manager и настроить автоматические сертификаты (рекомендуется)"
echo " manual — применить ingress с ручным Secret (нужны fullchain.pem, privkey.pem в backup/)"
echo ""
echo "Без аргумента — cert-manager"
}
apply_cert_manager() {
echo "=== 1. Установка cert-manager ==="
kubectl apply -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
echo "Ожидание готовности cert-manager (до 2 мин)..."
kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=120s 2>/dev/null || true
kubectl wait --for=condition=Available deployment/cert-manager-webhook -n cert-manager --timeout=120s 2>/dev/null || true
kubectl wait --for=condition=Available deployment/cert-manager-cainjector -n cert-manager --timeout=120s 2>/dev/null || true
echo ""
echo "=== 2. ClusterIssuer Let's Encrypt ==="
kubectl apply -f "$SCRIPT_DIR/cert-manager-issuer.yaml"
echo ""
echo "=== 3. Production Ingress (cert-manager создаст Secret gooseek-tls) ==="
kubectl apply -f "$REPO_ROOT/deploy/k3s/ingress-production.yaml"
echo ""
echo "=== Готово ==="
echo "Сертификат будет получен в течение 12 минут."
echo "Проверка: kubectl get certificate -n gooseek"
echo "Проверка: kubectl get secret gooseek-tls -n gooseek"
}
apply_manual() {
if [[ ! -f "$SCRIPT_DIR/backup/fullchain.pem" ]] || [[ ! -f "$SCRIPT_DIR/backup/privkey.pem" ]]; then
echo "Ошибка: нужны fullchain.pem и privkey.pem в $SCRIPT_DIR/backup/"
echo "См. deploy/k3s/ssl/README.md — получите сертификат через certbot на сервере"
exit 1
fi
echo "=== 1. Secret gooseek-tls из backup ==="
"$SCRIPT_DIR/apply-secret.sh"
echo ""
echo "=== 2. Production Ingress (ручной Secret) ==="
kubectl apply -f "$REPO_ROOT/deploy/k3s/ingress-production-manual.yaml"
echo ""
echo "=== Готово ==="
}
# Проверка namespace
if ! kubectl get namespace gooseek &>/dev/null; then
echo "Создание namespace gooseek..."
kubectl apply -f "$REPO_ROOT/deploy/k3s/namespace.yaml"
fi
# Проверка ingress-nginx
if ! kubectl get deployment -n ingress-nginx ingress-nginx-controller &>/dev/null 2>&1; then
echo "Внимание: ingress-nginx не найден. Установите:"
echo " kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/cloud/deploy.yaml"
echo ""
read -p "Продолжить? (y/n) " -n 1 -r
echo
[[ $REPLY =~ ^[Yy]$ ]] || exit 1
fi
MODE="${1:-cert-manager}"
case "$MODE" in
cert-manager) apply_cert_manager ;;
manual) apply_manual ;;
-h|--help) usage; exit 0 ;;
*) echo "Неизвестный режим: $MODE"; usage; exit 1 ;;
esac

View File

@@ -1,62 +0,0 @@
# travel-svc — Trending, Inspiration
apiVersion: apps/v1
kind: Deployment
metadata:
name: travel-svc
namespace: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: travel-svc
template:
metadata:
labels:
app: travel-svc
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3004"
prometheus.io/path: "/metrics"
spec:
containers:
- name: travel-svc
image: gooseek/travel-svc:latest
ports:
- containerPort: 3004
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-credentials
key: url
livenessProbe:
httpGet:
path: /health
port: 3004
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3004
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: travel-svc
namespace: gooseek
spec:
selector:
app: travel-svc
ports:
- port: 3004
targetPort: 3004

View File

@@ -1,60 +0,0 @@
# web-svc — Next.js UI
# namespace: gooseek
# API_GATEWAY_URL — URL api-gateway (внутри кластера или через ingress)
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-svc
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: web-svc
template:
metadata:
labels:
app: web-svc
spec:
containers:
- name: web-svc
image: gooseek/web-svc:latest
imagePullPolicy: Never
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
- name: API_GATEWAY_URL
value: "http://api-gateway.gooseek:3015"
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /api/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: web-svc
namespace: gooseek
spec:
selector:
app: web-svc
ports:
- port: 3000
targetPort: 3000

View File

@@ -1,95 +0,0 @@
# Runbook оператора GooSeek
**docs/architecture: 05-gaps-and-best-practices.md §9**
---
## 1. Проверка здоровья (K8s)
```bash
# Поды
kubectl get pods -n gooseek
# Health через port-forward (пример для chat-svc)
kubectl port-forward svc/chat-svc 3005:3005 -n gooseek &
curl -s http://localhost:3005/health
```
Или через Ingress: `curl -s https://gooseek.ru/api/health` (если web-svc проксирует).
Readiness: заменить `/health` на `/ready`.
---
## 2. Redis
```bash
redis-cli ping # PONG
redis-cli KEYS "discover:*" # ключи discover
redis-cli KEYS "finance:*" # ключи finance
```
---
## 3. Cache-worker (pre-compute)
В K3s: CronJob выполняются по расписанию. Логи Jobs:
```bash
kubectl get cronjobs -n gooseek
kubectl get jobs -n gooseek
kubectl logs job/<job-name> -n gooseek
```
---
## 4. Типичные сбои
| Симптом | Причина | Действие |
|---------|---------|----------|
| Discover пусто | Redis нет discover:*, Ghost/GHOST_CONTENT_API_KEY | Проверить cache-worker CronJob, .env в ConfigMap |
| Finance stub | FMP_API_KEY отсутствует или 429 | Добавить ключ, проверить лимиты FMP |
| Search 429 | Лимит SearXNG | Настроить свой инстанс, SEARXNG_URL |
| Chat зависает | LLM недоступен (Ollama/OpenAI) | Проверить OLLAMA_BASE_URL / OPENAI_API_KEY |
---
## 5. Масштабирование (K3s)
```bash
kubectl scale deployment chat-svc -n gooseek --replicas=4
```
HPA настроен в `deploy/k3s/hpa.yaml`. Требуется metrics-server.
---
## 6. Метрики Prometheus
Сервисы отдают `/metrics`. Аннотации на Pod: `prometheus.io/scrape`, `prometheus.io/port`, `prometheus.io/path`.
---
## 7. SSL (HTTPS для gooseek.ru)
См. **deploy/k3s/ssl/README.md** — получение сертификата, бэкап в `deploy/k3s/ssl/backup/`, создание Secret, применение ingress-production.yaml.
---
## 8. Порты
| Сервис | Порт |
|--------|------|
| web-svc | 3000 |
| search-svc | 3001 |
| discover-svc | 3002 |
| finance-svc | 3003 |
| travel-svc | 3004 |
| chat-svc | 3005 |
| projects-svc | 3006 |
| billing-svc | 3008 |
| library-svc | 3009 |
| memory-svc | 3010 |
| create-svc | 3011 |
| notifications-svc | 3013 |
| auth-svc | 3014 |

View File

@@ -1,29 +0,0 @@
# Телеметрия и метрики в GooSeek
## Отправка данных третьим лицам (отключена)
| Источник | Сервис | Статус | Как отключено |
|----------|--------|--------|---------------|
| **Next.js** | web-svc | Отключено | `NEXT_TELEMETRY_DISABLED=1` в Dockerfile |
| **better-auth** | auth-svc | Отключено | `telemetry: { enabled: false }` в auth.ts + `BETTER_AUTH_TELEMETRY=0` в env |
| **workbox-google-analytics** | — | Не используется | Транзитивная зависимость next-pwa; GA не подключается в sw.js |
| **OpenTelemetry** | — | Не настроено | Optional peer dep (Prisma); без провайдеров ничего не отправляет |
## Локальные метрики Prometheus (observability)
Эндпоинты `/metrics` **не отправляют** данные наружу. Prometheus скрейпит их внутри кластера.
| Сервис | Endpoint |
|--------|----------|
| chat-svc | `GET /metrics` |
| search-svc | `GET /metrics` |
| discover-svc | `GET /metrics` |
| finance-svc | `GET /metrics` |
| travel-svc | `GET /metrics` |
| library-svc | `GET /metrics` |
| memory-svc | `GET /metrics` |
| create-svc | `GET /metrics` |
| audit-svc | `GET /metrics` |
| web-svc | `GET /api/metrics` |
Для полного отключения метрик удалите эти эндпоинты и аннотации `prometheus.io/*` в `deploy/k3s/*.yaml`.

View File

@@ -1,7 +1,7 @@
# Архитектура аналога Perplexity.ai — полная спецификация
Документ описывает полную архитектуру системы на базе микросервисов K3s с акцентом на:
- **ВСЕ сервисы в `services/`.** Папка `apps/` удаляется. Микросервисная архитектура: chat, search, discover, finance, travel, auth, library, memory, create, notifications, projects, web (UI), cache-worker.
Документ описывает полную архитектуру системы на базе **СОА (сервисной архитектуры)** в K3s с акцентом на:
- **ВСЕ сервисы в `services/`.** Папка `apps/` удаляется. Сервисная архитектура: chat, search, discover, finance, travel, auth, library, memory, create, notifications, projects, web (UI), cache-worker.
- **~10 000 DAU** (ежедневных пользователей)
- **Клиентская логика** — максимум на клиенте; персональные данные — только для аккаунтов
- **Предварительная обработка и кэш** — новости, finance, travel, повторяющиеся запросы
@@ -249,7 +249,7 @@
---
## 3. Микросервисы K3s
## 3. Сервисы K3s (СОА)
### 3.1 Карта сервисов
@@ -269,7 +269,7 @@
┌─────────────────────────────────────────────────────────────────────────────────┐
│ BACKEND MICROSERVICES (K3s)
│ BACKEND SERVICES (K3s, SOA)
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
@@ -305,7 +305,7 @@
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 3.2 Описание микросервисов
### 3.2 Описание сервисов
| Сервис | Язык | Replicas (10k DAU) | Назначение |
|--------|------|--------------------|------------|

View File

@@ -1,4 +1,4 @@
# K3s — спецификация микросервисов
# K3s — спецификация сервисов (СОА)
## 1. Обзор инфраструктуры K3s
@@ -7,7 +7,7 @@
│ K3s Cluster │
├─────────────────────────────────────────────────────────────────────────────┤
│ Ingress (Traefik) │
│ api.perplexica.local → path-based routing к микросервисам │
│ api.perplexica.local → path-based routing к сервисам
│ auth.perplexica.local → auth-svc:3000 │
│ *.perplexica.local → web (static) │
└─────────────────────────────────────────────────────────────────────────────┘
@@ -1310,7 +1310,7 @@ spec:
- auth.perplexica.local
secretName: perplexica-tls
rules:
# API: path-based маршрутизация к микросервисам
# API: path-based маршрутизация к сервисам
- host: api.perplexica.local
http:
paths:

View File

@@ -11,7 +11,7 @@
| **Инфраструктура** | SearXNG — единый инстанс | Очередь запросов в search-svc; при росте — пул инстансов |
| **Инфраструктура** | cache-worker зависает | `activeDeadlineSeconds` во всех CronJob (520 мин) |
| **Инфраструктура** | Redis/PostgreSQL без HA | Sentinel/Cluster и Read replica при масштабировании |
| **Инфраструктура** | travel-svc без HPA | HPA 14 replicas добавлен в 02-k3s-microservices-spec |
| **Инфраструктура** | travel-svc без HPA | HPA 14 replicas добавлен в 02-k3s-services-spec |
| **Функции** | Внешние API (LLM, FMP, TA) | Retry + circuit breaker; fallback при частичном сбое |
## 1. Инфраструктура
@@ -103,6 +103,6 @@
- [04-pages-logic-verification.md §9](./04-pages-logic-verification.md#9-покрытие-фич-perplexity-20252026) — покрытие vs Perplexity 2026
- [06-roadmap-specification.md](./06-roadmap-specification.md) — roadmap и спеки фич
- [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md)
- [02-k3s-microservices-spec.md](./02-k3s-microservices-spec.md)
- [02-k3s-services-spec.md](./02-k3s-services-spec.md)
- [03-cache-and-precompute-strategy.md](./03-cache-and-precompute-strategy.md)
- [04-pages-logic-verification.md](./04-pages-logic-verification.md)

View File

@@ -6,7 +6,7 @@
## 1. Спецификация фич (детали)
Все пункты определены в [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md), [02-k3s-microservices-spec.md](./02-k3s-microservices-spec.md), [04-pages-logic-verification.md §9](./04-pages-logic-verification.md#9-покрытие-фич-perplexity-20252026).
Все пункты определены в [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md), [02-k3s-services-spec.md](./02-k3s-services-spec.md), [04-pages-logic-verification.md §9](./04-pages-logic-verification.md#9-покрытие-фич-perplexity-20252026).
### 1.1 Memory (memory-svc)
- **Сервис:** memory-svc, порт 3010

View File

@@ -1,6 +1,6 @@
# Миграция GooSeek на микросервисную архитектуру
# Миграция GooSeek на сервисную архитектуру (СОА)
**Ссылки:** [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md), [02-k3s-microservices-spec.md](./02-k3s-microservices-spec.md)
**Ссылки:** [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md), [02-k3s-services-spec.md](./02-k3s-services-spec.md)
---

View File

@@ -2,9 +2,13 @@
**Отдельный проект.** Документы самодостаточны; все спеки и roadmap — внутри этой папки.
## СОА (сервисная архитектура)
Используется **СОА**, а не микросервисная архитектура: доменные сервисы (chat, search, finance и т.д.), единая точка входа (api-gateway), стандартизированные REST API. Сервисы — бизнес-компоненты, а не атомарные микросервисы.
Целевая система для ~10 000 DAU с **полным совпадением** логики Perplexity.ai:
- **ВСЁ в `services/`.** Папка `apps/` удаляется. Никаких app — только микросервисы.
- Микросервисы в K3s (chat, search, discover, finance, travel, auth, library, memory, create, notifications, projects, cache-worker, web/frontend)
- **СОА (сервисная архитектура).** Папка `apps/` удаляется. Никаких app — только сервисы.
- Сервисы в K3s (chat, search, discover, finance, travel, auth, library, memory, create, notifications, projects, cache-worker, web/frontend)
- Максимум логики на клиенте; персональные данные только для аккаунтов
- Предварительная обработка и кэширование (discover, finance, travel+inspiration, поиск)
@@ -12,8 +16,8 @@
| Документ | Описание |
|----------|----------|
| [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md) | Карта функциональностей, микросервисы, стратегия кэша, порядок реализации |
| [02-k3s-microservices-spec.md](./02-k3s-microservices-spec.md) | K3s манифесты, Deployment, Service, CronJob для каждого сервиса |
| [01-perplexity-analogue-design.md](./01-perplexity-analogue-design.md) | Карта функциональностей, сервисы СОА, стратегия кэша, порядок реализации |
| [02-k3s-services-spec.md](./02-k3s-services-spec.md) | K3s манифесты, Deployment, Service, CronJob для каждого сервиса |
| [03-cache-and-precompute-strategy.md](./03-cache-and-precompute-strategy.md) | Детальная стратегия кэширования: discover, finance, travel, search |
| [04-pages-logic-verification.md](./04-pages-logic-verification.md) | Сверка логики страниц с Perplexity.ai — полное совпадение |
| [05-gaps-and-best-practices.md](./05-gaps-and-best-practices.md) | Production checklist, требования инфраструктуры |
@@ -22,7 +26,7 @@
## Быстрый старт
1. Прочитать `01-perplexity-analogue-design.md` для общего понимания
2. Использовать `02-k3s-microservices-spec.md` для развёртывания
2. Использовать `02-k3s-services-spec.md` для развёртывания
3. Реализовать `cache-worker` и Redis по `03-cache-and-precompute-strategy.md`
## Отношение к functional-inventory

View File

@@ -1,72 +0,0 @@
# How GooSeek Works
This is a high level overview of how Perplexica answers a question.
If you want a component level overview, see [README.md](README.md).
If you want implementation details, see [CONTRIBUTING.md](../../CONTRIBUTING.md).
## What happens when you ask a question
When you send a message in the UI, the app calls `POST /api/chat`.
At a high level, we do three things:
1. Classify the question and decide what to do next.
2. Run research and widgets in parallel.
3. Write the final answer and include citations.
## Classification
Before searching or answering, we run a classification step.
This step decides things like:
- Whether we should do research for this question
- Whether we should show any widgets
- How to rewrite the question into a clearer standalone form
## Widgets
Widgets are small, structured helpers that can run alongside research.
Examples include weather, stocks, and simple calculations.
If a widget is relevant, we show it in the UI while the answer is still being generated.
Widgets are helpful context for the answer, but they are not part of what the model should cite.
## Research
If research is needed, we gather information in the background while widgets can run.
Depending on configuration, research may include web lookup and searching user uploaded files.
## Answer generation
Once we have enough context, the chat model generates the final response.
You can control the tradeoff between speed and quality using `optimizationMode`:
- `speed`
- `balanced`
- `quality`
## How citations work
We prompt the model to cite the references it used. The UI then renders those citations alongside the supporting links.
## Search API
If you are integrating GooSeek into another product, you can call `POST /api/search`.
It returns:
- `message`: the generated answer
- `sources`: supporting references used for the answer
You can also enable streaming by setting `stream: true`.
## Image and video search
Image and video search use separate endpoints (`POST /api/images` and `POST /api/videos`). We generate a focused query using the chat model, then fetch matching results from a search backend.

158
package-lock.json generated
View File

@@ -2852,22 +2852,6 @@
"mnemonist": "0.39.6"
}
},
"node_modules/@fastify/deepmerge": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/error": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
@@ -2892,77 +2876,6 @@
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@fastify/multipart": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz",
"integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@fastify/deepmerge": "^3.0.0",
"@fastify/error": "^4.0.0",
"fastify-plugin": "^5.0.0",
"secure-json-parse": "^4.0.0"
}
},
"node_modules/@fastify/multipart/node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/multipart/node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/multipart/node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
@@ -5356,6 +5269,13 @@
"@types/send": "*"
}
},
"node_modules/@types/geoip-lite": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@types/geoip-lite/-/geoip-lite-1.4.4.tgz",
"integrity": "sha512-2uVfn+C6bX/H356H6mjxsWUA5u8LO8dJgSBIRO/NFlpMe4DESzacutD/rKYrTDKm1Ugv78b4Wz1KvpHrlv3jSw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -15197,6 +15117,15 @@
"node": ">= 0.4"
}
},
"node_modules/stream-wormhole": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz",
"integrity": "sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/strict-event-emitter-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
@@ -18251,7 +18180,7 @@
"version": "1.0.0",
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^9.4.0",
"@fastify/multipart": "^8.3.1",
"@google/genai": "^1.34.0",
"@toolsycc/json-repair": "^0.1.22",
"axios": "^1.8.3",
@@ -18277,6 +18206,52 @@
"typescript": "^5.9.3"
}
},
"services/chat-svc/node_modules/@fastify/deepmerge": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-2.0.2.tgz",
"integrity": "sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"services/chat-svc/node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"services/chat-svc/node_modules/@fastify/multipart": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-8.3.1.tgz",
"integrity": "sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@fastify/deepmerge": "^2.0.0",
"@fastify/error": "^4.0.0",
"fastify-plugin": "^4.0.0",
"secure-json-parse": "^2.4.0",
"stream-wormhole": "^1.1.0"
}
},
"services/create-svc": {
"version": "1.0.0",
"dependencies": {
@@ -18388,6 +18363,7 @@
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/geoip-lite": "^1.4.4",
"@types/ua-parser-js": "^0.7.39",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
@@ -18466,10 +18442,14 @@
"ollama": "^0.6.3",
"openai": "^6.9.0",
"partial-json": "^0.1.7",
"rfc6902": "^5.1.2",
"turndown": "^7.2.2",
"yahoo-finance2": "^3.13.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^24.8.1",
"@types/turndown": "^5.0.6",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
}
@@ -18696,7 +18676,7 @@
"license": "MIT"
},
"services/web-svc": {
"version": "1.12.1",
"version": "1.12.2",
"dependencies": {
"@ducanh2912/next-pwa": "^10.2.9",
"@google/genai": "^1.34.0",

View File

@@ -1,7 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
@@ -9,7 +11,8 @@ RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
RUN --mount=type=cache,target=/root/.npm \
npm install --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3015
ENV PORT=3015

View File

@@ -4,9 +4,9 @@ import { fileURLToPath } from 'node:url';
config({ path: path.resolve(fileURLToPath(import.meta.url), '../../../../.env') });
/**
* api-gateway — прокси к микросервисам
* api-gateway — прокси к сервисам (СОА)
* web-svc = только UI, вся логика и API здесь
* docs/architecture: 02-k3s-microservices-spec.md §5
* docs/architecture: 02-k3s-services-spec.md §5
*/
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
@@ -76,12 +76,17 @@ async function proxyRequest(req: FastifyRequest, reply: FastifyReply, stream = f
const fullUrl = `${target.base.replace(/\/$/, '')}${target.rewrite}${url.search}`;
const headers: Record<string, string> = {};
const pass = ['authorization', 'content-type', 'accept', 'x-forwarded-for', 'x-real-ip', 'user-agent', 'accept-language'];
const pass = ['authorization', 'accept', 'x-forwarded-for', 'x-real-ip', 'user-agent', 'accept-language'];
for (const h of pass) {
const v = req.headers[h];
if (v && typeof v === 'string') headers[h] = v;
}
if (!headers['Content-Type'] && req.method !== 'GET') headers['Content-Type'] = 'application/json';
const ct = req.headers['content-type'];
if (ct && typeof ct === 'string' && ct.toLowerCase().includes('application/json')) {
headers['Content-Type'] = 'application/json';
} else if (req.method !== 'GET') {
headers['Content-Type'] = 'application/json';
}
try {
const method = req.method;
@@ -129,11 +134,25 @@ async function proxyRequest(req: FastifyRequest, reply: FastifyReply, stream = f
return reply.send(data);
} catch (err: unknown) {
req.log.error(err);
// Заглушки для сервисов, не запущенных в Docker
if (path.startsWith('/api/v1/discover')) return reply.send({ items: [] });
if (path.startsWith('/api/geo-context')) return reply.send({ country: null, city: null });
if (path.startsWith('/api/translations')) return reply.send({});
if (path.startsWith('/api/v1/weather')) return reply.send({});
return reply.status(503).send({ error: 'Service unavailable' });
}
}
const app = Fastify({ logger: true });
// Парсер JSON — принимает application/json с charset и дублированием
app.addContentTypeParser(/application\/json/i, { parseAs: 'string' }, (_, body, done) => {
try {
const str = typeof body === 'string' ? body : (body ? String(body) : '');
done(null, str ? JSON.parse(str) : {});
} catch (e) {
done(e as Error, undefined);
}
});
const corsOrigin = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
.map((s) => s.trim())

View File

@@ -1,19 +1,24 @@
# syntax=docker/dockerfile:1
# Сборка из корня: docker build -t gooseek/auth-svc:latest -f services/auth-svc/Dockerfile .
FROM node:22-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json package-lock.json ./
COPY services/auth-svc/package.json ./services/auth-svc/
RUN npm ci -w auth-svc
RUN --mount=type=cache,target=/root/.npm \
npm ci -w auth-svc
COPY services/auth-svc/tsconfig.json ./services/auth-svc/
COPY services/auth-svc/src ./services/auth-svc/src
WORKDIR /app/services/auth-svc
RUN npm run build
FROM node:22-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json package-lock.json ./
COPY services/auth-svc/package.json ./services/auth-svc/
RUN npm ci -w auth-svc --omit=dev
RUN --mount=type=cache,target=/root/.npm \
npm ci -w auth-svc --omit=dev
COPY --from=builder /app/services/auth-svc/dist ./services/auth-svc/dist
WORKDIR /app/services/auth-svc
EXPOSE 3014

View File

@@ -1,6 +1,6 @@
/**
* billing-svc — тарифы, подписки, ЮKassa
* docs/architecture: 01-perplexity-analogue-design.md §2.2.J, 02-k3s-microservices-spec.md §3.10
* docs/architecture: 01-perplexity-analogue-design.md §2.2.J, 02-k3s-services-spec.md §3.10
* API: GET /api/v1/billing/plans, /subscription, /payments; POST /checkout
*/

View File

@@ -1,7 +1,7 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
@@ -9,7 +9,7 @@ RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3005
ENV DATA_DIR=/app/data

View File

@@ -10,7 +10,7 @@
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^9.4.0",
"@fastify/multipart": "^8.3.1",
"@google/genai": "^1.34.0",
"@toolsycc/json-repair": "^0.1.22",
"axios": "^1.8.3",

View File

@@ -7,18 +7,12 @@
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import { LibraryClient } from './lib/library-client.js';
import cors from '@fastify/cors';
import { z } from 'zod';
import ModelRegistry from './lib/models/registry.js';
import UploadManager from './lib/uploads/manager.js';
import configManager from './lib/config/index.js';
import { isEnvOnlyMode } from './lib/config/serverRegistry.js';
import { ConfigModelProvider } from './lib/config/types.js';
import SearchAgent from './lib/agents/search/index.js';
import SessionManager from './lib/session.js';
import { ChatTurnMessage } from './lib/types.js';
import { SearchSources } from './lib/agents/search/types.js';
import { createEmbeddingClient } from './lib/embedding-client.js';
import path from 'node:path';
import fs from 'node:fs';
@@ -45,6 +39,7 @@ if (!fs.existsSync(configPath)) {
const PORT = parseInt(process.env.PORT ?? '3005', 10);
const MEMORY_SVC_URL = process.env.MEMORY_SVC_URL ?? '';
const LLM_SVC_URL = process.env.LLM_SVC_URL ?? '';
const MASTER_AGENTS_SVC_URL = process.env.MASTER_AGENTS_SVC_URL?.trim() ?? '';
const messageSchema = z.object({
messageId: z.string().min(1),
@@ -52,8 +47,11 @@ const messageSchema = z.object({
content: z.string().min(1),
});
const answerModeEnum = z.enum(['standard', 'focus', 'academic', 'writing', 'travel', 'finance']);
const councilModelSchema = z.object({ providerId: z.string(), key: z.string() });
const answerModeEnum = z.enum([
'standard', 'focus', 'academic', 'writing', 'travel', 'finance',
'health', 'education', 'medicine', 'realEstate', 'psychology', 'sports',
'children', 'goods', 'shopping', 'games', 'taxes', 'legislation',
]);
const bodySchema = z.object({
message: messageSchema,
optimizationMode: z.enum(['speed', 'balanced', 'quality']),
@@ -73,9 +71,6 @@ const bodySchema = z.object({
})
.optional(),
learningMode: z.boolean().optional().default(false),
/** Model Council (Max): 3 models in parallel → synthesis */
modelCouncil: z.boolean().optional().default(false),
councilModels: z.array(councilModelSchema).length(3).optional(),
});
type Body = z.infer<typeof bodySchema>;
@@ -85,7 +80,54 @@ const corsOrigin = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean)
: true;
await app.register(cors, { origin: corsOrigin });
await app.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
// multipart только для uploads — иначе application/json на /api/v1/chat даёт 415
await app.register(
async (scope) => {
await scope.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
scope.post('/api/v1/uploads', async function uploadsHandler(req, reply) {
try {
if (!LLM_SVC_URL) {
return reply.status(503).send({ message: 'LLM_SVC_URL not configured. llm-svc required for embeddings.' });
}
let providerId = '';
let modelKey = '';
const fileParts: { buffer: Buffer; filename: string; mimetype: string }[] = [];
const parts = req.parts();
for await (const part of parts) {
if (part.type === 'field') {
const val = (part as { value?: string }).value ?? '';
if (part.fieldname === 'embedding_model_provider_id') providerId = val;
else if (part.fieldname === 'embedding_model_key') modelKey = val;
} else if (part.type === 'file' && part.file) {
const buffer = await part.toBuffer();
fileParts.push({
buffer,
filename: part.filename ?? 'file',
mimetype: part.mimetype ?? 'application/octet-stream',
});
}
}
if (!providerId || !modelKey) {
return reply.status(400).send({ message: 'Missing embedding model or provider' });
}
if (fileParts.length === 0) {
return reply.status(400).send({ message: 'No files uploaded' });
}
const embedding = createEmbeddingClient({ providerId, key: modelKey });
const manager = new UploadManager({ embeddingModel: embedding });
const fileObjects = fileParts.map(
(p) => new File([new Uint8Array(p.buffer)], p.filename, { type: p.mimetype }),
);
const processed = await manager.processFiles(fileObjects);
return reply.send({ files: processed });
} catch (err) {
req.log.error(err);
return reply.status(500).send({ message: 'An error has occurred.' });
}
});
},
{ prefix: '' },
);
app.get('/health', async () => ({ status: 'ok' }));
app.get('/ready', async () => ({ status: 'ready' }));
@@ -103,33 +145,28 @@ app.get('/metrics', async (_req, reply) => {
app.get('/api/v1/config', async (_req, reply) => {
try {
const values = configManager.getCurrentConfig();
const fields = configManager.getUIConfigSections();
let modelProviders: ConfigModelProvider[];
let envOnlyMode: boolean;
if (LLM_SVC_URL) {
const base = LLM_SVC_URL.replace(/\/$/, '');
const res = await fetch(`${base}/api/v1/providers`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`llm-svc fetch failed: ${res.status}`);
const data = (await res.json()) as { providers: ConfigModelProvider[]; envOnlyMode?: boolean };
modelProviders = data.providers ?? [];
envOnlyMode = data.envOnlyMode ?? false;
values.modelProviders = modelProviders;
} else {
const registry = new ModelRegistry();
const providers = await registry.getActiveProviders();
modelProviders = values.modelProviders.map((mp: ConfigModelProvider) => {
const activeProvider = providers.find((p) => p.id === mp.id);
return {
...mp,
chatModels: activeProvider?.chatModels ?? mp.chatModels,
embeddingModels: activeProvider?.embeddingModels ?? mp.embeddingModels,
};
});
values.modelProviders = modelProviders;
envOnlyMode = isEnvOnlyMode();
if (!LLM_SVC_URL) {
return reply.status(503).send({ message: 'LLM_SVC_URL not configured. llm-svc required.' });
}
const base = LLM_SVC_URL.replace(/\/$/, '');
const [providersRes, uiConfigRes] = await Promise.all([
fetch(`${base}/api/v1/providers`, { signal: AbortSignal.timeout(5000) }),
fetch(`${base}/api/v1/providers/ui-config`, { signal: AbortSignal.timeout(5000) }),
]);
if (!providersRes.ok) throw new Error(`llm-svc providers failed: ${providersRes.status}`);
const providersData = (await providersRes.json()) as { providers: ConfigModelProvider[]; envOnlyMode?: boolean };
const modelProviders = providersData.providers ?? [];
const envOnlyMode = providersData.envOnlyMode ?? false;
values.modelProviders = modelProviders;
let modelProviderSections: { name: string; key: string; fields: unknown[] }[] = [];
if (uiConfigRes.ok) {
const uiData = (await uiConfigRes.json()) as { sections: { name: string; key: string; fields: unknown[] }[] };
modelProviderSections = uiData.sections ?? [];
}
const fields = {
...configManager.getUIConfigSections(),
modelProviders: modelProviderSections,
};
return reply.send({ values, fields, modelProviders, envOnlyMode });
} catch (err) {
app.log.error(err);
@@ -169,51 +206,6 @@ app.post<{ Body: unknown }>('/api/v1/config/setup-complete', async (req, reply)
/* --- Providers: routed to llm-svc via api-gateway --- */
/* --- Uploads --- */
app.post('/api/v1/uploads', async (req, reply) => {
try {
let providerId = '';
let modelKey = '';
const fileParts: { buffer: Buffer; filename: string; mimetype: string }[] = [];
const parts = req.parts();
for await (const part of parts) {
if (part.type === 'field') {
const val = (part as { value?: string }).value ?? '';
if (part.fieldname === 'embedding_model_provider_id') providerId = val;
else if (part.fieldname === 'embedding_model_key') modelKey = val;
} else if (part.type === 'file' && part.file) {
const buffer = await part.toBuffer();
fileParts.push({
buffer,
filename: part.filename ?? 'file',
mimetype: part.mimetype ?? 'application/octet-stream',
});
}
}
if (!providerId || !modelKey) {
return reply.status(400).send({ message: 'Missing embedding model or provider' });
}
if (fileParts.length === 0) {
return reply.status(400).send({ message: 'No files uploaded' });
}
const registry = new ModelRegistry();
const embedding = await registry.loadEmbeddingModel(providerId, modelKey);
const manager = new UploadManager({ embeddingModel: embedding });
const fileObjects = fileParts.map(
(p) => new File([new Blob([p.buffer], { type: p.mimetype })], p.filename, { type: p.mimetype }),
);
const processed = await manager.processFiles(fileObjects);
return reply.send({ files: processed });
} catch (err) {
app.log.error(err);
return reply.status(500).send({ message: 'An error has occurred.' });
}
});
app.post<{ Body: unknown }>('/api/v1/chat', async (req, reply) => {
const parseBody = bodySchema.safeParse(req.body);
if (!parseBody.success) {
@@ -246,71 +238,41 @@ app.post<{ Body: unknown }>('/api/v1/chat', async (req, reply) => {
}
try {
const registry = new ModelRegistry();
const llm = await registry.loadChatModel(body.chatModel.providerId, body.chatModel.key);
let embedding = null;
if (body.embeddingModel?.providerId) {
embedding = await registry.loadEmbeddingModel(body.embeddingModel.providerId, body.embeddingModel.key);
if (!MASTER_AGENTS_SVC_URL) {
return reply.status(503).send({ message: 'MASTER_AGENTS_SVC_URL not configured. master-agents-svc required for chat.' });
}
let councilLlms: [Awaited<ReturnType<ModelRegistry['loadChatModel']>>, Awaited<ReturnType<ModelRegistry['loadChatModel']>>, Awaited<ReturnType<ModelRegistry['loadChatModel']>>] | undefined;
if (body.modelCouncil && body.councilModels && body.councilModels.length === 3) {
try {
councilLlms = [
await registry.loadChatModel(body.councilModels[0].providerId, body.councilModels[0].key),
await registry.loadChatModel(body.councilModels[1].providerId, body.councilModels[1].key),
await registry.loadChatModel(body.councilModels[2].providerId, body.councilModels[2].key),
];
} catch (councilErr) {
req.log.warn({ err: councilErr }, 'Model Council: failed to load 3 models, falling back to single');
}
}
const history: ChatTurnMessage[] = body.history.map((msg) =>
msg[0] === 'human' ? { role: 'user' as const, content: msg[1] } : { role: 'assistant' as const, content: msg[1] });
const libraryClient = LibraryClient.create(authHeader);
const config = {
llm,
embedding,
sources: body.sources as SearchSources[],
mode: body.optimizationMode,
fileIds: body.files,
systemInstructions: body.systemInstructions || 'None',
locale: body.locale ?? 'en',
memoryContext,
answerMode: body.answerMode,
responsePrefs: body.responsePrefs,
learningMode: body.learningMode,
libraryClient,
...(councilLlms && { councilLlms }),
};
const agent = new SearchAgent();
const session = SessionManager.createSession();
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const disconnect = session.subscribe((event: string, data: unknown) => {
const d = data as { type?: string; block?: unknown; blockId?: string; patch?: unknown; data?: unknown };
if (event === 'data') {
if (d.type === 'block') controller.enqueue(encoder.encode(JSON.stringify({ type: 'block', block: d.block }) + '\n'));
else if (d.type === 'updateBlock') controller.enqueue(encoder.encode(JSON.stringify({ type: 'updateBlock', blockId: d.blockId, patch: d.patch }) + '\n'));
else if (d.type === 'researchComplete') controller.enqueue(encoder.encode(JSON.stringify({ type: 'researchComplete' }) + '\n'));
} else if (event === 'end') {
controller.enqueue(encoder.encode(JSON.stringify({ type: 'messageEnd' }) + '\n'));
controller.close();
session.removeAllListeners();
} else if (event === 'error') {
controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', data: d.data }) + '\n'));
controller.close();
session.removeAllListeners();
}
});
agent.searchAsync(session, { chatHistory: history, followUp: body.message.content, chatId: body.message.chatId, messageId: body.message.messageId, config }).catch((err: Error) => {
req.log.error(err);
session.emit('error', { data: err?.message ?? 'Error during search.' });
});
req.raw.on?.('abort', () => { disconnect(); try { controller.close(); } catch {} });
const base = MASTER_AGENTS_SVC_URL.replace(/\/$/, '');
const proxyRes = await fetch(`${base}/api/v1/agents/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authHeader && { Authorization: authHeader }),
},
});
return reply.header('Content-Type', 'application/x-ndjson').header('Cache-Control', 'no-cache').send(stream);
body: JSON.stringify({
message: body.message,
optimizationMode: body.optimizationMode,
sources: body.sources,
history: body.history,
files: body.files,
chatModel: body.chatModel,
systemInstructions: body.systemInstructions,
locale: body.locale,
answerMode: body.answerMode,
responsePrefs: body.responsePrefs,
learningMode: body.learningMode,
}),
signal: AbortSignal.timeout(300000),
duplex: 'half',
} as RequestInit);
if (!proxyRes.ok) {
const errText = await proxyRes.text();
return reply.status(proxyRes.status).send({ message: errText || 'master-agents-svc error' });
}
return reply
.header('Content-Type', 'application/x-ndjson')
.header('Cache-Control', 'no-cache')
.send(proxyRes.body);
} catch (err) {
req.log.error(err);
return reply.status(500).send({ message: 'An error occurred while processing chat request' });

View File

@@ -1,102 +0,0 @@
import { ResearcherOutput, SearchAgentInput } from './types.js';
import SessionManager from '../../session.js';
import { classify } from './classifier.js';
import Researcher from './researcher/index.js';
import { getWriterPrompt } from '../../prompts/search/writer.js';
import { WidgetExecutor } from './widgets/index.js';
class APISearchAgent {
async searchAsync(session: SessionManager, input: SearchAgentInput) {
const classification = await classify({
chatHistory: input.chatHistory,
enabledSources: input.config.sources,
query: input.followUp,
llm: input.config.llm,
locale: input.config.locale,
});
const widgetPromise = WidgetExecutor.executeAll({
classification,
chatHistory: input.chatHistory,
followUp: input.followUp,
llm: input.config.llm,
});
let searchPromise: Promise<ResearcherOutput> | null = null;
if (!classification.classification.skipSearch) {
const researcher = new Researcher();
searchPromise = researcher.research(SessionManager.createSession(), {
chatHistory: input.chatHistory,
followUp: input.followUp,
classification: classification,
config: input.config,
});
}
const [widgetOutputs, searchResults] = await Promise.all([
widgetPromise,
searchPromise,
]);
if (searchResults) {
session.emit('data', {
type: 'searchResults',
data: searchResults.searchFindings,
});
}
session.emit('data', {
type: 'researchComplete',
});
const finalContext =
searchResults?.searchFindings
.map(
(f, index) =>
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
)
.join('\n') || '';
const widgetContext = widgetOutputs
.map((o) => {
return `<result>${o.llmContext}</result>`;
})
.join('\n-------------\n');
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
const writerPrompt = getWriterPrompt(
finalContextWithWidgets,
input.config.systemInstructions,
input.config.mode,
input.config.locale,
input.config.memoryContext,
);
const answerStream = input.config.llm.streamText({
messages: [
{
role: 'system',
content: writerPrompt,
},
...input.chatHistory,
{
role: 'user',
content: input.followUp,
},
],
});
for await (const chunk of answerStream) {
session.emit('data', {
type: 'response',
data: chunk.contentChunk,
});
}
session.emit('end', {});
}
}
export default APISearchAgent;

View File

@@ -1,53 +0,0 @@
import z from 'zod';
import { ClassifierInput } from './types.js';
import { getClassifierPrompt } from '../../prompts/search/classifier.js';
import formatChatHistoryAsString from '../../utils/formatHistory.js';
const schema = z.object({
classification: z.object({
skipSearch: z
.boolean()
.describe('Indicates whether to skip the search step.'),
personalSearch: z
.boolean()
.describe('Indicates whether to perform a personal search.'),
academicSearch: z
.boolean()
.describe('Indicates whether to perform an academic search.'),
discussionSearch: z
.boolean()
.describe('Indicates whether to perform a discussion search.'),
showWeatherWidget: z
.boolean()
.describe('Indicates whether to show the weather widget.'),
showStockWidget: z
.boolean()
.describe('Indicates whether to show the stock widget.'),
showCalculationWidget: z
.boolean()
.describe('Indicates whether to show the calculation widget.'),
}),
standaloneFollowUp: z
.string()
.describe(
"A self-contained, context-independent reformulation of the user's question.",
),
});
export const classify = async (input: ClassifierInput) => {
const output = await input.llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: getClassifierPrompt(input.locale),
},
{
role: 'user',
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_query>\n${input.query}\n</user_query>`,
},
],
schema,
});
return output;
};

View File

@@ -1,309 +0,0 @@
import type BaseLLM from '../../models/base/llm.js';
import { ResearcherOutput, SearchAgentInput } from './types.js';
import SessionManager from '../../session.js';
import { classify } from './classifier.js';
import Researcher from './researcher/index.js';
import { getWriterPrompt, getSynthesisPrompt } from '../../prompts/search/writer.js';
import { WidgetExecutor } from './widgets/index.js';
import { TextBlock } from '../../types.js';
class SearchAgent {
async searchAsync(session: SessionManager, input: SearchAgentInput) {
try {
await this.doSearch(session, input);
} catch (err) {
console.error('[SearchAgent] Fatal:', err);
const blocks = session.getAllBlocks();
const sourceBlock = blocks.find((b) => b.type === 'source');
let sources: { metadata?: { title?: string } }[] =
sourceBlock?.type === 'source' ? sourceBlock.data : [];
if (sources.length === 0) {
const researchBlock = blocks.find(
(b): b is typeof b & { data: { subSteps?: { type: string; reading?: { metadata?: { title?: string } }[] }[] } } =>
b.type === 'research' && 'subSteps' in (b.data ?? {}),
);
const searchStep = researchBlock?.data?.subSteps?.find(
(s) => s.type === 'search_results' && Array.isArray(s.reading),
);
if (searchStep && 'reading' in searchStep) {
sources = searchStep.reading ?? [];
}
}
if (sources.length > 0) {
const lines = sources.slice(0, 10).map(
(s: { metadata?: { title?: string } }, i: number) =>
`${i + 1}. **${s?.metadata?.title ?? 'Источник'}**`,
);
session.emitBlock({
id: crypto.randomUUID(),
type: 'text',
data: `## По найденным источникам\n\n${lines.join('\n')}\n\n*Ответ LLM недоступен (400). Проверьте модель gemini-3-flash в Settings или попробуйте другую модель.*`,
});
} else {
session.emitBlock({
id: crypto.randomUUID(),
type: 'text',
data: `Ошибка: ${err instanceof Error ? err.message : String(err)}. Проверьте LLM в Settings.`,
});
}
session.emit('end', {});
}
}
private async doSearch(session: SessionManager, input: SearchAgentInput) {
const lib = input.config.libraryClient;
if (lib?.enabled) {
await lib.upsertMessage(input.chatId, input.messageId, input.followUp, {
backendId: session.id,
responseBlocks: [],
status: 'answering',
sources: (input.config.sources as string[]) ?? [],
});
}
const classification = await classify({
chatHistory: input.chatHistory,
enabledSources: input.config.sources,
query: input.followUp,
llm: input.config.llm,
locale: input.config.locale,
});
const widgetPromise = WidgetExecutor.executeAll({
classification,
chatHistory: input.chatHistory,
followUp: input.followUp,
llm: input.config.llm,
}).then((widgetOutputs) => {
widgetOutputs.forEach((o) => {
session.emitBlock({
id: crypto.randomUUID(),
type: 'widget',
data: {
widgetType: o.type,
params: o.data,
},
});
});
return widgetOutputs;
});
let searchPromise: Promise<ResearcherOutput> | null = null;
if (!classification.classification.skipSearch) {
const researcher = new Researcher();
searchPromise = researcher.research(session, {
chatHistory: input.chatHistory,
followUp: input.followUp,
classification: classification,
config: input.config,
});
}
const [widgetOutputs, searchResults] = await Promise.all([
widgetPromise,
searchPromise,
]);
session.emit('data', {
type: 'researchComplete',
});
const MAX_RESULTS_FOR_WRITER = 15;
const MAX_CONTENT_PER_RESULT = 180;
const findingsForWriter =
searchResults?.searchFindings.slice(0, MAX_RESULTS_FOR_WRITER) ?? [];
const finalContext =
findingsForWriter
.map((f, index) => {
const content =
f.content.length > MAX_CONTENT_PER_RESULT
? f.content.slice(0, MAX_CONTENT_PER_RESULT) + '…'
: f.content;
return `<result index=${index + 1} title="${String(f.metadata.title).replace(/"/g, "'")}">${content}</result>`;
})
.join('\n') || '';
const widgetContext = widgetOutputs
.map((o) => {
return `<result>${o.llmContext}</result>`;
})
.join('\n-------------\n');
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
const writerPrompt = getWriterPrompt(
finalContextWithWidgets,
input.config.systemInstructions,
input.config.mode,
input.config.locale,
input.config.memoryContext,
input.config.answerMode,
input.config.responsePrefs,
input.config.learningMode,
);
const councilLlms = input.config.councilLlms;
if (councilLlms && councilLlms.length === 3) {
await this.runCouncilWritersAndSynthesis(
session,
input,
writerPrompt,
findingsForWriter,
councilLlms,
);
return;
}
const answerStream = input.config.llm.streamText({
messages: [
{ role: 'system', content: writerPrompt },
...input.chatHistory,
{ role: 'user', content: input.followUp },
],
options: { maxTokens: 4096 },
});
let responseBlockId = '';
let hasContent = false;
for await (const chunk of answerStream) {
if (!chunk.contentChunk && !responseBlockId) continue;
if (!responseBlockId) {
const block: TextBlock = {
id: crypto.randomUUID(),
type: 'text',
data: chunk.contentChunk,
};
session.emitBlock(block);
responseBlockId = block.id;
if (chunk.contentChunk) hasContent = true;
} else {
const block = session.getBlock(responseBlockId) as TextBlock | null;
if (block) {
block.data += chunk.contentChunk;
if (chunk.contentChunk) hasContent = true;
session.updateBlock(block.id, [
{ op: 'replace', path: '/data', value: block.data },
]);
}
}
}
if (!hasContent && findingsForWriter.length > 0) {
const lines = findingsForWriter.slice(0, 10).map((f, i) => {
const title = f.metadata.title ?? 'Без названия';
const excerpt =
f.content.length > 120 ? f.content.slice(0, 120) + '…' : f.content;
return `${i + 1}. **${title}** — ${excerpt}`;
});
session.emitBlock({
id: crypto.randomUUID(),
type: 'text',
data: `## По найденным источникам\n\n${lines.join('\n\n')}\n\n*Ответ LLM недоступен. Проверьте модель в Settings.*`,
});
}
session.emit('end', {});
await this.persistMessage(session, input.chatId, input.messageId, input.config.libraryClient);
}
/** Model Council: run 3 writers in parallel, synthesize, stream synthesis */
private async runCouncilWritersAndSynthesis(
session: SessionManager,
input: SearchAgentInput,
writerPrompt: string,
findingsForWriter: { content: string; metadata: { title?: string } }[],
councilLlms: [BaseLLM<any>, BaseLLM<any>, BaseLLM<any>],
) {
const messages = [
{ role: 'system' as const, content: writerPrompt },
...input.chatHistory,
{ role: 'user' as const, content: input.followUp },
];
const writerInput = { messages, options: { maxTokens: 4096 } };
const [r1, r2, r3] = await Promise.all([
councilLlms[0].generateText(writerInput),
councilLlms[1].generateText(writerInput),
councilLlms[2].generateText(writerInput),
]);
const answer1 = r1.content ?? '';
const answer2 = r2.content ?? '';
const answer3 = r3.content ?? '';
const synthesisPrompt = getSynthesisPrompt(
input.followUp,
answer1,
answer2,
answer3,
input.config.locale,
);
const synthesisStream = councilLlms[0].streamText({
messages: [{ role: 'user' as const, content: synthesisPrompt }],
options: { maxTokens: 4096 },
});
let responseBlockId = '';
let hasContent = false;
for await (const chunk of synthesisStream) {
if (!chunk.contentChunk && !responseBlockId) continue;
if (!responseBlockId) {
const block: TextBlock = {
id: crypto.randomUUID(),
type: 'text',
data: chunk.contentChunk ?? '',
};
session.emitBlock(block);
responseBlockId = block.id;
if (chunk.contentChunk) hasContent = true;
} else {
const block = session.getBlock(responseBlockId) as TextBlock | null;
if (block) {
block.data += chunk.contentChunk ?? '';
if (chunk.contentChunk) hasContent = true;
session.updateBlock(block.id, [
{ op: 'replace', path: '/data', value: block.data },
]);
}
}
}
if (!hasContent && findingsForWriter.length > 0) {
const lines = findingsForWriter.slice(0, 10).map((f, i) => {
const title = f.metadata.title ?? 'Без названия';
const excerpt = f.content.length > 120 ? f.content.slice(0, 120) + '…' : f.content;
return `${i + 1}. **${title}** — ${excerpt}`;
});
session.emitBlock({
id: crypto.randomUUID(),
type: 'text',
data: `## По найденным источникам\n\n${lines.join('\n\n')}\n\n*Model Council: синтез недоступен. Проверьте модели в Settings.*`,
});
}
session.emit('end', {});
await this.persistMessage(session, input.chatId, input.messageId, input.config.libraryClient);
}
private async persistMessage(
session: SessionManager,
chatId: string,
messageId: string,
libraryClient?: SearchAgentInput['config']['libraryClient'],
) {
if (!libraryClient?.enabled) return;
try {
await libraryClient.updateMessage(chatId, messageId, {
status: 'completed',
responseBlocks: session.getAllBlocks(),
});
} catch (err) {
console.error('[SearchAgent] LibraryClient update failed:', err);
}
}
}
export default SearchAgent;

View File

@@ -1,129 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
import { Chunk, SearchResultsResearchBlock } from '../../../../types.js';
import { searchSearxng } from '../../../../searxng.js';
const schema = z.object({
queries: z.array(z.string()).describe('List of academic search queries'),
});
const academicSearchDescription = `
Use this tool to perform academic searches for scholarly articles, papers, and research studies relevant to the user's query. Provide a list of concise search queries that will help gather comprehensive academic information on the topic at hand.
You can provide up to 3 queries at a time. Make sure the queries are specific and relevant to the user's needs.
For example, if the user is interested in recent advancements in renewable energy, your queries could be:
1. "Recent advancements in renewable energy 2024"
2. "Cutting-edge research on solar power technologies"
3. "Innovations in wind energy systems"
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed academic information.
`;
const academicSearchAction: ResearchAction<typeof schema> = {
name: 'academic_search',
schema: schema,
getDescription: () => academicSearchDescription,
getToolDescription: () =>
"Use this tool to perform academic searches for scholarly articles, papers, and research studies relevant to the user's query. Provide a list of concise search queries that will help gather comprehensive academic information on the topic at hand.",
enabled: (config) =>
config.sources.includes('academic') &&
config.classification.classification.skipSearch === false &&
config.classification.classification.academicSearch === true,
execute: async (input, additionalConfig) => {
input.queries = input.queries.slice(0, 3);
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
if (researchBlock && researchBlock.type === 'research') {
researchBlock.data.subSteps.push({
type: 'searching',
id: crypto.randomUUID(),
searching: input.queries,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
const searchResultsBlockId = crypto.randomUUID();
let searchResultsEmitted = false;
let results: Chunk[] = [];
const search = async (q: string) => {
const res = await searchSearxng(q, {
engines: ['arxiv', 'google scholar', 'pubmed'],
});
const resultChunks: Chunk[] = res.results.map((r) => ({
content: r.content || r.title,
metadata: {
title: r.title,
url: r.url,
},
}));
results.push(...resultChunks);
if (
!searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
searchResultsEmitted = true;
researchBlock.data.subSteps.push({
id: searchResultsBlockId,
type: 'search_results',
reading: resultChunks,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
} else if (
searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
const subStepIndex = researchBlock.data.subSteps.findIndex(
(step) => step.id === searchResultsBlockId,
);
const subStep = researchBlock.data.subSteps[
subStepIndex
] as SearchResultsResearchBlock;
subStep.reading.push(...resultChunks);
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
};
await Promise.all(input.queries.map(search));
return {
type: 'search_results',
results,
};
},
};
export default academicSearchAction;

View File

@@ -1,24 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
const actionDescription = `
Use this action ONLY when you have completed all necessary research and are ready to provide a final answer to the user. This indicates that you have gathered sufficient information from previous steps and are concluding the research process.
YOU MUST CALL THIS ACTION TO SIGNAL COMPLETION; DO NOT OUTPUT FINAL ANSWERS DIRECTLY TO THE USER.
IT WILL BE AUTOMATICALLY TRIGGERED IF MAXIMUM ITERATIONS ARE REACHED SO IF YOU'RE LOW ON ITERATIONS, DON'T CALL IT AND INSTEAD FOCUS ON GATHERING ESSENTIAL INFO FIRST.
`;
const doneAction: ResearchAction<any> = {
name: 'done',
schema: z.object({}),
getToolDescription: () =>
'Only call this after __reasoning_preamble AND after any other needed tool calls when you truly have enough to answer. Do not call if information is still missing.',
getDescription: () => actionDescription,
enabled: (_) => true,
execute: async (params, additionalConfig) => {
return {
type: 'done',
};
},
};
export default doneAction;

View File

@@ -1,18 +0,0 @@
import academicSearchAction from './academicSearch.js';
import doneAction from './done.js';
import planAction from './plan.js';
import ActionRegistry from './registry.js';
import scrapeURLAction from './scrapeURL.js';
import socialSearchAction from './socialSearch.js';
import uploadsSearchAction from './uploadsSearch.js';
import webSearchAction from './webSearch.js';
ActionRegistry.register(webSearchAction);
ActionRegistry.register(doneAction);
ActionRegistry.register(planAction);
ActionRegistry.register(scrapeURLAction);
ActionRegistry.register(uploadsSearchAction);
ActionRegistry.register(academicSearchAction);
ActionRegistry.register(socialSearchAction);
export { ActionRegistry };

View File

@@ -1,40 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
const schema = z.object({
plan: z
.string()
.describe(
'A concise natural-language plan in one short paragraph. Open with a short intent phrase (e.g., "Okay, the user wants to...", "Searching for...", "Looking into...") and lay out the steps you will take.',
),
});
const actionDescription = `
Use this tool FIRST on every turn to state your plan in natural language before any other action. Keep it short, action-focused, and tailored to the current query.
Make sure to not include reference to any tools or actions you might take, just the plan itself. The user isn't aware about tools, but they love to see your thought process.
Here are some examples of good plans:
<examples>
- "Okay, the user wants to know the latest advancements in renewable energy. I will start by looking for recent articles and studies on this topic, then summarize the key points." -> "I have gathered enough information to provide a comprehensive answer."
- "The user is asking about the health benefits of a Mediterranean diet. I will search for scientific studies and expert opinions on this diet, then compile the findings into a clear summary." -> "I have gathered information about the Mediterranean diet and its health benefits, I will now look up for any recent studies to ensure the information is current."
</examples>
YOU CAN NEVER CALL ANY OTHER TOOL BEFORE CALLING THIS ONE FIRST, IF YOU DO, THAT CALL WOULD BE IGNORED.
`;
const planAction: ResearchAction<typeof schema> = {
name: '__reasoning_preamble',
schema: schema,
getToolDescription: () =>
'Use this FIRST on every turn to state your plan in natural language before any other action. Keep it short, action-focused, and tailored to the current query.',
getDescription: () => actionDescription,
enabled: (config) => config.mode !== 'speed',
execute: async (input, _) => {
return {
type: 'reasoning',
reasoning: input.plan,
};
},
};
export default planAction;

View File

@@ -1,108 +0,0 @@
import { Tool, ToolCall } from '../../../../models/types.js';
import {
ActionOutput,
AdditionalConfig,
ClassifierOutput,
ResearchAction,
SearchAgentConfig,
SearchSources,
} from '../../types.js';
class ActionRegistry {
private static actions: Map<string, ResearchAction> = new Map();
static register(action: ResearchAction<any>) {
this.actions.set(action.name, action);
}
static get(name: string): ResearchAction | undefined {
return this.actions.get(name);
}
static getAvailableActions(config: {
classification: ClassifierOutput;
fileIds: string[];
mode: SearchAgentConfig['mode'];
sources: SearchSources[];
hasEmbedding?: boolean;
}): ResearchAction[] {
return Array.from(this.actions.values()).filter((action) =>
action.enabled(config),
);
}
static getAvailableActionTools(config: {
classification: ClassifierOutput;
fileIds: string[];
mode: SearchAgentConfig['mode'];
sources: SearchSources[];
hasEmbedding?: boolean;
}): Tool[] {
const availableActions = this.getAvailableActions(config);
return availableActions.map((action) => ({
name: action.name,
description: action.getToolDescription({ mode: config.mode }),
schema: action.schema,
}));
}
static getAvailableActionsDescriptions(config: {
classification: ClassifierOutput;
fileIds: string[];
mode: SearchAgentConfig['mode'];
sources: SearchSources[];
hasEmbedding?: boolean;
}): string {
const availableActions = this.getAvailableActions(config);
return availableActions
.map(
(action) =>
`<tool name="${action.name}">\n${action.getDescription({ mode: config.mode })}\n</tool>`,
)
.join('\n\n');
}
static async execute(
name: string,
params: any,
additionalConfig: AdditionalConfig & {
researchBlockId: string;
fileIds: string[];
},
) {
const action = this.actions.get(name);
if (!action) {
throw new Error(`Action with name ${name} not found`);
}
return action.execute(params, additionalConfig);
}
static async executeAll(
actions: ToolCall[],
additionalConfig: AdditionalConfig & {
researchBlockId: string;
fileIds: string[];
},
): Promise<ActionOutput[]> {
const results: ActionOutput[] = [];
await Promise.all(
actions.map(async (actionConfig) => {
const output = await this.execute(
actionConfig.name,
actionConfig.arguments,
additionalConfig,
);
results.push(output);
}),
);
return results;
}
}
export default ActionRegistry;

View File

@@ -1,139 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
import { Chunk, ReadingResearchBlock } from '../../../../types.js';
import TurnDown from 'turndown';
import path from 'path';
const turndownService = new TurnDown();
const schema = z.object({
urls: z.array(z.string()).describe('A list of URLs to scrape content from.'),
});
const actionDescription = `
Use this tool to scrape and extract content from the provided URLs. This is useful when you the user has asked you to extract or summarize information from specific web pages. You can provide up to 3 URLs at a time. NEVER CALL THIS TOOL EXPLICITLY YOURSELF UNLESS INSTRUCTED TO DO SO BY THE USER.
You should only call this tool when the user has specifically requested information from certain web pages, never call this yourself to get extra information without user instruction.
For example, if the user says "Please summarize the content of https://example.com/article", you can call this tool with that URL to get the content and then provide the summary or "What does X mean according to https://example.com/page", you can call this tool with that URL to get the content and provide the explanation.
`;
const scrapeURLAction: ResearchAction<typeof schema> = {
name: 'scrape_url',
schema: schema,
getToolDescription: () =>
'Use this tool to scrape and extract content from the provided URLs. This is useful when you the user has asked you to extract or summarize information from specific web pages. You can provide up to 3 URLs at a time. NEVER CALL THIS TOOL EXPLICITLY YOURSELF UNLESS INSTRUCTED TO DO SO BY THE USER.',
getDescription: () => actionDescription,
enabled: (_) => true,
execute: async (params, additionalConfig) => {
params.urls = params.urls.slice(0, 3);
let readingBlockId = crypto.randomUUID();
let readingEmitted = false;
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
const results: Chunk[] = [];
await Promise.all(
params.urls.map(async (url) => {
try {
const res = await fetch(url);
const text = await res.text();
const title =
text.match(/<title>(.*?)<\/title>/i)?.[1] || `Content from ${url}`;
if (
!readingEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
readingEmitted = true;
researchBlock.data.subSteps.push({
id: readingBlockId,
type: 'reading',
reading: [
{
content: '',
metadata: {
url,
title: title,
},
},
],
});
additionalConfig.session.updateBlock(
additionalConfig.researchBlockId,
[
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
],
);
} else if (
readingEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
const subStepIndex = researchBlock.data.subSteps.findIndex(
(step: any) => step.id === readingBlockId,
);
const subStep = researchBlock.data.subSteps[
subStepIndex
] as ReadingResearchBlock;
subStep.reading.push({
content: '',
metadata: {
url,
title: title,
},
});
additionalConfig.session.updateBlock(
additionalConfig.researchBlockId,
[
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
],
);
}
const markdown = turndownService.turndown(text);
results.push({
content: markdown,
metadata: {
url,
title: title,
},
});
} catch (error) {
results.push({
content: `Failed to fetch content from ${url}: ${error}`,
metadata: {
url,
title: `Error fetching ${url}`,
},
});
}
}),
);
return {
type: 'search_results',
results,
};
},
};
export default scrapeURLAction;

View File

@@ -1,129 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
import { Chunk, SearchResultsResearchBlock } from '../../../../types.js';
import { searchSearxng } from '../../../../searxng.js';
const schema = z.object({
queries: z.array(z.string()).describe('List of social search queries'),
});
const socialSearchDescription = `
Use this tool to perform social media searches for relevant posts, discussions, and trends related to the user's query. Provide a list of concise search queries that will help gather comprehensive social media information on the topic at hand.
You can provide up to 3 queries at a time. Make sure the queries are specific and relevant to the user's needs.
For example, if the user is interested in public opinion on electric vehicles, your queries could be:
1. "Electric vehicles public opinion 2024"
2. "Social media discussions on EV adoption"
3. "Trends in electric vehicle usage"
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed social media information.
`;
const socialSearchAction: ResearchAction<typeof schema> = {
name: 'social_search',
schema: schema,
getDescription: () => socialSearchDescription,
getToolDescription: () =>
"Use this tool to perform social media searches for relevant posts, discussions, and trends related to the user's query. Provide a list of concise search queries that will help gather comprehensive social media information on the topic at hand.",
enabled: (config) =>
config.sources.includes('discussions') &&
config.classification.classification.skipSearch === false &&
config.classification.classification.discussionSearch === true,
execute: async (input, additionalConfig) => {
input.queries = input.queries.slice(0, 3);
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
if (researchBlock && researchBlock.type === 'research') {
researchBlock.data.subSteps.push({
type: 'searching',
id: crypto.randomUUID(),
searching: input.queries,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
const searchResultsBlockId = crypto.randomUUID();
let searchResultsEmitted = false;
let results: Chunk[] = [];
const search = async (q: string) => {
const res = await searchSearxng(q, {
engines: ['reddit'],
});
const resultChunks: Chunk[] = res.results.map((r) => ({
content: r.content || r.title,
metadata: {
title: r.title,
url: r.url,
},
}));
results.push(...resultChunks);
if (
!searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
searchResultsEmitted = true;
researchBlock.data.subSteps.push({
id: searchResultsBlockId,
type: 'search_results',
reading: resultChunks,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
} else if (
searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
const subStepIndex = researchBlock.data.subSteps.findIndex(
(step) => step.id === searchResultsBlockId,
);
const subStep = researchBlock.data.subSteps[
subStepIndex
] as SearchResultsResearchBlock;
subStep.reading.push(...resultChunks);
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
};
await Promise.all(input.queries.map(search));
return {
type: 'search_results',
results,
};
},
};
export default socialSearchAction;

View File

@@ -1,107 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
import UploadStore from '../../../../uploads/store.js';
const schema = z.object({
queries: z
.array(z.string())
.describe(
'A list of queries to search in user uploaded files. Can be a maximum of 3 queries.',
),
});
const uploadsSearchAction: ResearchAction<typeof schema> = {
name: 'uploads_search',
enabled: (config) =>
config.hasEmbedding !== false &&
((config.classification.classification.personalSearch &&
config.fileIds.length > 0) ||
config.fileIds.length > 0),
schema,
getToolDescription: () =>
`Use this tool to perform searches over the user's uploaded files. This is useful when you need to gather information from the user's documents to answer their questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.`,
getDescription: () => `
Use this tool to perform searches over the user's uploaded files. This is useful when you need to gather information from the user's documents to answer their questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
Always ensure that the queries you use are directly relevant to the user's request and pertain to the content of their uploaded files.
For example, if the user says "Please find information about X in my uploaded documents", you can call this tool with a query related to X to retrieve the relevant information from their files.
Never use this tool to search the web or for information that is not contained within the user's uploaded files.
`,
execute: async (input, additionalConfig) => {
if (!additionalConfig.embedding) {
return { type: 'search_results' as const, results: [] };
}
input.queries = input.queries.slice(0, 3);
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
if (researchBlock && researchBlock.type === 'research') {
researchBlock.data.subSteps.push({
id: crypto.randomUUID(),
type: 'upload_searching',
queries: input.queries,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
const uploadStore = new UploadStore({
embeddingModel: additionalConfig.embedding,
fileIds: additionalConfig.fileIds,
});
const results = await uploadStore.query(input.queries, 10);
const seenIds = new Map<string, number>();
const filteredSearchResults = results
.map((result, index) => {
if (result.metadata?.url as string && !seenIds.has(result.metadata?.url as string)) {
seenIds.set(result.metadata?.url as string, index);
return result;
} else if (result.metadata?.url as string && seenIds.has(result.metadata?.url as string)) {
const existingIndex = seenIds.get(result.metadata?.url as string)!;
const existingResult = results[existingIndex];
existingResult.content += `\n\n${result.content}`;
return undefined;
}
return result;
})
.filter((r) => r !== undefined);
if (researchBlock && researchBlock.type === 'research') {
researchBlock.data.subSteps.push({
id: crypto.randomUUID(),
type: 'upload_search_results',
results: filteredSearchResults,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
return {
type: 'search_results',
results: filteredSearchResults,
};
},
};
export default uploadsSearchAction;

View File

@@ -1,182 +0,0 @@
import z from 'zod';
import { ResearchAction } from '../../types.js';
import { searchSearxng } from '../../../../searxng.js';
import { Chunk, SearchResultsResearchBlock } from '../../../../types.js';
const actionSchema = z.object({
type: z.literal('web_search'),
queries: z
.array(z.string())
.describe('An array of search queries to perform web searches for.'),
});
const speedModePrompt = `
Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
You are currently on speed mode, meaning you would only get to call this tool once. Make sure to prioritize the most important queries that are likely to get you the needed information in one go.
Your queries should be very targeted and specific to the information you need, avoid broad or generic queries.
Your queries shouldn't be sentences but rather keywords that are SEO friendly and can be used to search the web for information.
For example, if the user is asking about the features of a new technology, you might use queries like "GPT-5.1 features", "GPT-5.1 release date", "GPT-5.1 improvements" rather than a broad query like "Tell me about GPT-5.1".
You can search for 3 queries in one go, make sure to utilize all 3 queries to maximize the information you can gather. If a question is simple, then split your queries to cover different aspects or related topics to get a comprehensive understanding.
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed information.
`;
const balancedModePrompt = `
Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
You can call this tool several times if needed to gather enough information.
Start initially with broader queries to get an overview, then narrow down with more specific queries based on the results you receive.
Your queries shouldn't be sentences but rather keywords that are SEO friendly and can be used to search the web for information.
For example if the user is asking about Tesla, your actions should be like:
1. __reasoning_preamble "The user is asking about Tesla. I will start with broader queries to get an overview of Tesla, then narrow down with more specific queries based on the results I receive." then
2. web_search ["Tesla", "Tesla latest news", "Tesla stock price"] then
3. __reasoning_preamble "Based on the previous search results, I will now narrow down my queries to focus on Tesla's recent developments and stock performance." then
4. web_search ["Tesla Q2 2025 earnings", "Tesla new model 2025", "Tesla stock analysis"] then done.
5. __reasoning_preamble "I have gathered enough information to provide a comprehensive answer."
6. done.
You can search for 3 queries in one go, make sure to utilize all 3 queries to maximize the information you can gather. If a question is simple, then split your queries to cover different aspects or related topics to get a comprehensive understanding.
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed information. You can call this tools, multiple times as needed.
`;
const qualityModePrompt = `
Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.
You have to call this tool several times to gather enough information unless the question is very simple (like greeting questions or basic facts).
Start initially with broader queries to get an overview, then narrow down with more specific queries based on the results you receive.
Never stop before at least 5-6 iterations of searches unless the user question is very simple.
Your queries shouldn't be sentences but rather keywords that are SEO friendly and can be used to search the web for information.
You can search for 3 queries in one go, make sure to utilize all 3 queries to maximize the information you can gather. If a question is simple, then split your queries to cover different aspects or related topics to get a comprehensive understanding.
If this tool is present and no other tools are more relevant, you MUST use this tool to get the needed information. You can call this tools, multiple times as needed.
`;
const webSearchAction: ResearchAction<typeof actionSchema> = {
name: 'web_search',
schema: actionSchema,
getToolDescription: () =>
"Use this tool to perform web searches based on the provided queries. This is useful when you need to gather information from the web to answer the user's questions. You can provide up to 3 queries at a time. You will have to use this every single time if this is present and relevant.",
getDescription: (config) => {
let prompt = '';
switch (config.mode) {
case 'speed':
prompt = speedModePrompt;
break;
case 'balanced':
prompt = balancedModePrompt;
break;
case 'quality':
prompt = qualityModePrompt;
break;
default:
prompt = speedModePrompt;
break;
}
return prompt;
},
enabled: (config) =>
config.sources.includes('web') &&
config.classification.classification.skipSearch === false,
execute: async (input, additionalConfig) => {
input.queries = input.queries.slice(0, 3);
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
if (researchBlock && researchBlock.type === 'research') {
researchBlock.data.subSteps.push({
id: crypto.randomUUID(),
type: 'searching',
searching: input.queries,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
const searchResultsBlockId = crypto.randomUUID();
let searchResultsEmitted = false;
let results: Chunk[] = [];
const search = async (q: string) => {
const res = await searchSearxng(q);
const resultChunks: Chunk[] = res.results.map((r) => ({
content: r.content || r.title,
metadata: {
title: r.title,
url: r.url,
},
}));
results.push(...resultChunks);
if (
!searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
searchResultsEmitted = true;
researchBlock.data.subSteps.push({
id: searchResultsBlockId,
type: 'search_results',
reading: resultChunks,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
} else if (
searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
const subStepIndex = researchBlock.data.subSteps.findIndex(
(step) => step.id === searchResultsBlockId,
);
const subStep = researchBlock.data.subSteps[
subStepIndex
] as SearchResultsResearchBlock;
subStep.reading.push(...resultChunks);
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
};
await Promise.all(input.queries.map(search));
return {
type: 'search_results',
results,
};
},
};
export default webSearchAction;

View File

@@ -1,225 +0,0 @@
import { ActionOutput, ResearcherInput, ResearcherOutput } from '../types.js';
import { ActionRegistry } from './actions/index.js';
import { getResearcherPrompt } from '../../../prompts/search/researcher.js';
import SessionManager from '../../../session.js';
import { Message, ReasoningResearchBlock } from '../../../types.js';
import formatChatHistoryAsString from '../../../utils/formatHistory.js';
import { ToolCall } from '../../../models/types.js';
class Researcher {
async research(
session: SessionManager,
input: ResearcherInput,
): Promise<ResearcherOutput> {
let actionOutput: ActionOutput[] = [];
let maxIteration =
input.config.mode === 'speed'
? 2
: input.config.mode === 'balanced'
? 6
: 25;
const availableTools = ActionRegistry.getAvailableActionTools({
classification: input.classification,
fileIds: input.config.fileIds,
mode: input.config.mode,
sources: input.config.sources,
hasEmbedding: !!input.config.embedding,
});
const availableActionsDescription =
ActionRegistry.getAvailableActionsDescriptions({
classification: input.classification,
fileIds: input.config.fileIds,
mode: input.config.mode,
sources: input.config.sources,
hasEmbedding: !!input.config.embedding,
});
const researchBlockId = crypto.randomUUID();
session.emitBlock({
id: researchBlockId,
type: 'research',
data: {
subSteps: [],
},
});
const agentMessageHistory: Message[] = [
{
role: 'user',
content: `
<conversation>
${formatChatHistoryAsString(input.chatHistory.slice(-10))}
User: ${input.followUp} (Standalone question: ${input.classification.standaloneFollowUp})
</conversation>
`,
},
];
for (let i = 0; i < maxIteration; i++) {
const researcherPrompt = getResearcherPrompt(
availableActionsDescription,
input.config.mode,
i,
maxIteration,
input.config.fileIds,
input.config.locale,
);
const actionStream = input.config.llm.streamText({
messages: [
{
role: 'system',
content: researcherPrompt,
},
...agentMessageHistory,
],
tools: availableTools,
});
const block = session.getBlock(researchBlockId);
let reasoningEmitted = false;
let reasoningId = crypto.randomUUID();
let finalToolCalls: ToolCall[] = [];
for await (const partialRes of actionStream) {
if (partialRes.toolCallChunk.length > 0) {
partialRes.toolCallChunk.forEach((tc) => {
if (
tc.name === '__reasoning_preamble' &&
tc.arguments['plan'] &&
!reasoningEmitted &&
block &&
block.type === 'research'
) {
reasoningEmitted = true;
block.data.subSteps.push({
id: reasoningId,
type: 'reasoning',
reasoning: tc.arguments['plan'],
});
session.updateBlock(researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: block.data.subSteps,
},
]);
} else if (
tc.name === '__reasoning_preamble' &&
tc.arguments['plan'] &&
reasoningEmitted &&
block &&
block.type === 'research'
) {
const subStepIndex = block.data.subSteps.findIndex(
(step: any) => step.id === reasoningId,
);
if (subStepIndex !== -1) {
const subStep = block.data.subSteps[
subStepIndex
] as ReasoningResearchBlock;
subStep.reasoning = tc.arguments['plan'];
session.updateBlock(researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: block.data.subSteps,
},
]);
}
}
const existingIndex = finalToolCalls.findIndex(
(ftc) => ftc.id === tc.id,
);
if (existingIndex !== -1) {
finalToolCalls[existingIndex].arguments = tc.arguments;
} else {
finalToolCalls.push(tc);
}
});
}
}
if (finalToolCalls.length === 0) {
break;
}
if (finalToolCalls[finalToolCalls.length - 1].name === 'done') {
break;
}
agentMessageHistory.push({
role: 'assistant',
content: '',
tool_calls: finalToolCalls,
});
const actionResults = await ActionRegistry.executeAll(finalToolCalls, {
llm: input.config.llm,
embedding: input.config.embedding,
session: session,
researchBlockId: researchBlockId,
fileIds: input.config.fileIds,
});
actionOutput.push(...actionResults);
actionResults.forEach((action, i) => {
agentMessageHistory.push({
role: 'tool',
id: finalToolCalls[i].id,
name: finalToolCalls[i].name,
content: JSON.stringify(action),
});
});
}
const searchResults = actionOutput
.filter((a) => a.type === 'search_results')
.flatMap((a) => a.results);
const seenUrls = new Map<string, number>();
const filteredSearchResults = searchResults
.map((result, index) => {
if (result.metadata?.url as string && !seenUrls.has(result.metadata?.url as string)) {
seenUrls.set(result.metadata?.url as string, index);
return result;
} else if (result.metadata?.url as string && seenUrls.has(result.metadata?.url as string)) {
const existingIndex = seenUrls.get(result.metadata?.url as string)!;
const existingResult = searchResults[existingIndex];
existingResult.content += `\n\n${result.content}`;
return undefined;
}
return result;
})
.filter((r) => r !== undefined);
session.emitBlock({
id: crypto.randomUUID(),
type: 'source',
data: filteredSearchResults,
});
return {
findings: actionOutput,
searchFindings: filteredSearchResults,
};
}
}
export default Researcher;

View File

@@ -1,141 +0,0 @@
import z from 'zod';
import BaseLLM from '../../models/base/llm.js';
import BaseEmbedding from '../../models/base/embedding.js';
import SessionManager from '../../session.js';
import { ChatTurnMessage, Chunk } from '../../types.js';
export type SearchSources = 'web' | 'discussions' | 'academic';
/** Answer mode — вертикаль ответа (travel, finance, academic и т.д.) */
export type AnswerMode = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance';
/** locale по geo (например ru, en) — язык ответа */
export type SearchAgentConfig = {
sources: SearchSources[];
fileIds: string[];
llm: BaseLLM<any>;
embedding: BaseEmbedding<any> | null;
mode: 'speed' | 'balanced' | 'quality';
systemInstructions: string;
locale?: string;
/** Memory context from memory-svc (Pro) — user preferences, facts */
memoryContext?: string;
/** Answer mode — vertical focus (travel, finance, academic) */
answerMode?: AnswerMode;
/** Response preferences from user settings */
responsePrefs?: { format?: string; length?: string; tone?: string };
/** Step-by-step Learning — объяснять пошагово, показывать ход мысли */
learningMode?: boolean;
/** Model Council (Max): 3 LLMs for parallel writer + synthesis */
councilLlms?: [BaseLLM<any>, BaseLLM<any>, BaseLLM<any>];
/** Library client для сохранения messages (вместо SQLite) */
libraryClient?: { upsertMessage: (threadId: string, msgId: string, query: string, opts?: object) => Promise<void>; updateMessage: (threadId: string, msgId: string, opts: object) => Promise<void>; enabled: boolean };
};
export type SearchAgentInput = {
chatHistory: ChatTurnMessage[];
followUp: string;
config: SearchAgentConfig;
chatId: string;
messageId: string;
};
export type WidgetInput = {
chatHistory: ChatTurnMessage[];
followUp: string;
classification: ClassifierOutput;
llm: BaseLLM<any>;
};
export type Widget = {
type: string;
shouldExecute: (classification: ClassifierOutput) => boolean;
execute: (input: WidgetInput) => Promise<WidgetOutput | void>;
};
export type WidgetOutput = {
type: string;
llmContext: string;
data: any;
};
export type ClassifierInput = {
llm: BaseLLM<any>;
enabledSources: SearchSources[];
query: string;
chatHistory: ChatTurnMessage[];
locale?: string;
};
export type ClassifierOutput = {
classification: {
skipSearch: boolean;
personalSearch: boolean;
academicSearch: boolean;
discussionSearch: boolean;
showWeatherWidget: boolean;
showStockWidget: boolean;
showCalculationWidget: boolean;
};
standaloneFollowUp: string;
};
export type AdditionalConfig = {
llm: BaseLLM<any>;
embedding: BaseEmbedding<any> | null;
session: SessionManager;
};
export type ResearcherInput = {
chatHistory: ChatTurnMessage[];
followUp: string;
classification: ClassifierOutput;
config: SearchAgentConfig;
};
export type ResearcherOutput = {
findings: ActionOutput[];
searchFindings: Chunk[];
};
export type SearchActionOutput = {
type: 'search_results';
results: Chunk[];
};
export type DoneActionOutput = {
type: 'done';
};
export type ReasoningResearchAction = {
type: 'reasoning';
reasoning: string;
};
export type ActionOutput =
| SearchActionOutput
| DoneActionOutput
| ReasoningResearchAction;
export interface ResearchAction<
TSchema extends z.ZodObject<any> = z.ZodObject<any>,
> {
name: string;
schema: z.ZodObject<any>;
getToolDescription: (config: { mode: SearchAgentConfig['mode'] }) => string;
getDescription: (config: { mode: SearchAgentConfig['mode'] }) => string;
enabled: (config: {
classification: ClassifierOutput;
fileIds: string[];
mode: SearchAgentConfig['mode'];
sources: SearchSources[];
hasEmbedding?: boolean;
}) => boolean;
execute: (
params: z.infer<TSchema>,
additionalConfig: AdditionalConfig & {
researchBlockId: string;
fileIds: string[];
},
) => Promise<ActionOutput>;
}

View File

@@ -1,71 +0,0 @@
import z from 'zod';
import { Widget } from '../types.js';
import formatChatHistoryAsString from '../../../utils/formatHistory.js';
import { exp, evaluate as mathEval } from 'mathjs';
const schema = z.object({
expression: z
.string()
.describe('Mathematical expression to calculate or evaluate.'),
notPresent: z
.boolean()
.describe('Whether there is any need for the calculation widget.'),
});
const system = `
<role>
Assistant is a calculation expression extractor. You will recieve a user follow up and a conversation history.
Your task is to determine if there is a mathematical expression that needs to be calculated or evaluated. If there is, extract the expression and return it. If there is no need for any calculation, set notPresent to true.
</role>
<instructions>
Make sure that the extracted expression is valid and can be used to calculate the result with Math JS library (https://mathjs.org/). If the expression is not valid, set notPresent to true.
If you feel like you cannot extract a valid expression, set notPresent to true.
</instructions>
<output_format>
You must respond in the following JSON format without any extra text, explanations or filler sentences:
{
"expression": string,
"notPresent": boolean
}
</output_format>
`;
const calculationWidget: Widget = {
type: 'calculationWidget',
shouldExecute: (classification) =>
classification.classification.showCalculationWidget,
execute: async (input) => {
const output = await input.llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: system,
},
{
role: 'user',
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
},
],
schema,
});
if (output.notPresent) {
return;
}
const result = mathEval(output.expression);
return {
type: 'calculation_result',
llmContext: `The result of the calculation for the expression "${output.expression}" is: ${result}`,
data: {
expression: output.expression,
result,
},
};
},
};
export default calculationWidget;

View File

@@ -1,36 +0,0 @@
import { Widget, WidgetInput, WidgetOutput } from '../types.js';
class WidgetExecutor {
static widgets = new Map<string, Widget>();
static register(widget: Widget) {
this.widgets.set(widget.type, widget);
}
static getWidget(type: string): Widget | undefined {
return this.widgets.get(type);
}
static async executeAll(input: WidgetInput): Promise<WidgetOutput[]> {
const results: WidgetOutput[] = [];
await Promise.all(
Array.from(this.widgets.values()).map(async (widget) => {
try {
if (widget.shouldExecute(input.classification)) {
const output = await widget.execute(input);
if (output) {
results.push(output);
}
}
} catch (e) {
console.log(`Error executing widget ${widget.type}:`, e);
}
}),
);
return results;
}
}
export default WidgetExecutor;

View File

@@ -1,10 +0,0 @@
import calculationWidget from './calculationWidget.js';
import WidgetExecutor from './executor.js';
import weatherWidget from './weatherWidget.js';
import stockWidget from './stockWidget.js';
WidgetExecutor.register(weatherWidget);
WidgetExecutor.register(calculationWidget);
WidgetExecutor.register(stockWidget);
export { WidgetExecutor };

View File

@@ -1,434 +0,0 @@
import z from 'zod';
import { Widget } from '../types.js';
import YahooFinance from 'yahoo-finance2';
import formatChatHistoryAsString from '../../../utils/formatHistory.js';
const yf = new YahooFinance({
suppressNotices: ['yahooSurvey'],
});
const schema = z.object({
name: z
.string()
.describe(
"The stock name for example Nvidia, Google, Apple, Microsoft etc. You can also return ticker if you're aware of it otherwise just use the name.",
),
comparisonNames: z
.array(z.string())
.max(3)
.describe(
"Optional array of up to 3 stock names to compare against the base name (e.g., ['Microsoft', 'GOOGL', 'Meta']). Charts will show percentage change comparison.",
),
notPresent: z
.boolean()
.describe('Whether there is no need for the stock widget.'),
});
const systemPrompt = `
<role>
You are a stock ticker/name extractor. You will receive a user follow up and a conversation history.
Your task is to determine if the user is asking about stock information and extract the stock name(s) they want data for.
</role>
<instructions>
- If the user is asking about a stock, extract the primary stock name or ticker.
- If the user wants to compare stocks, extract up to 3 comparison stock names in comparisonNames.
- You can use either stock names (e.g., "Nvidia", "Apple") or tickers (e.g., "NVDA", "AAPL").
- If you cannot determine a valid stock or the query is not stock-related, set notPresent to true.
- If no comparison is needed, set comparisonNames to an empty array.
</instructions>
<output_format>
You must respond in the following JSON format without any extra text, explanations or filler sentences:
{
"name": string,
"comparisonNames": string[],
"notPresent": boolean
}
</output_format>
`;
const stockWidget: Widget = {
type: 'stockWidget',
shouldExecute: (classification) =>
classification.classification.showStockWidget,
execute: async (input) => {
const output = await input.llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
},
],
schema,
});
if (output.notPresent) {
return;
}
const params = output;
try {
const name = params.name;
const findings = await yf.search(name);
if (findings.quotes.length === 0)
throw new Error(`Failed to find quote for name/symbol: ${name}`);
const ticker = findings.quotes[0].symbol as string;
const quote: any = await yf.quote(ticker);
const chartPromises = {
'1D': yf
.chart(ticker, {
period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '5m',
})
.catch(() => null),
'5D': yf
.chart(ticker, {
period1: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '15m',
})
.catch(() => null),
'1M': yf
.chart(ticker, {
period1: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
'3M': yf
.chart(ticker, {
period1: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
'6M': yf
.chart(ticker, {
period1: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
'1Y': yf
.chart(ticker, {
period1: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
MAX: yf
.chart(ticker, {
period1: new Date(Date.now() - 10 * 365 * 24 * 60 * 60 * 1000),
interval: '1wk',
})
.catch(() => null),
};
const charts = await Promise.all([
chartPromises['1D'],
chartPromises['5D'],
chartPromises['1M'],
chartPromises['3M'],
chartPromises['6M'],
chartPromises['1Y'],
chartPromises['MAX'],
]);
const [chart1D, chart5D, chart1M, chart3M, chart6M, chart1Y, chartMAX] =
charts;
if (!quote) {
throw new Error(`No data found for ticker: ${ticker}`);
}
let comparisonData: any = null;
if (params.comparisonNames.length > 0) {
const comparisonPromises = params.comparisonNames
.slice(0, 3)
.map(async (compName) => {
try {
const compFindings = await yf.search(compName);
if (compFindings.quotes.length === 0) return null;
const compTicker = compFindings.quotes[0].symbol as string;
const compQuote = await yf.quote(compTicker);
const compCharts = await Promise.all([
yf
.chart(compTicker, {
period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '5m',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '15m',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(
Date.now() - 10 * 365 * 24 * 60 * 60 * 1000,
),
interval: '1wk',
})
.catch(() => null),
]);
return {
ticker: compTicker,
name: compQuote.shortName || compTicker,
charts: compCharts,
};
} catch (error) {
console.error(
`Failed to fetch comparison ticker ${compName}:`,
error,
);
return null;
}
});
const compResults = await Promise.all(comparisonPromises);
comparisonData = compResults.filter((r) => r !== null);
}
const stockData = {
symbol: quote.symbol,
shortName: quote.shortName || quote.longName || ticker,
longName: quote.longName,
exchange: quote.fullExchangeName || quote.exchange,
currency: quote.currency,
quoteType: quote.quoteType,
marketState: quote.marketState,
regularMarketTime: quote.regularMarketTime,
postMarketTime: quote.postMarketTime,
preMarketTime: quote.preMarketTime,
regularMarketPrice: quote.regularMarketPrice,
regularMarketChange: quote.regularMarketChange,
regularMarketChangePercent: quote.regularMarketChangePercent,
regularMarketPreviousClose: quote.regularMarketPreviousClose,
regularMarketOpen: quote.regularMarketOpen,
regularMarketDayHigh: quote.regularMarketDayHigh,
regularMarketDayLow: quote.regularMarketDayLow,
postMarketPrice: quote.postMarketPrice,
postMarketChange: quote.postMarketChange,
postMarketChangePercent: quote.postMarketChangePercent,
preMarketPrice: quote.preMarketPrice,
preMarketChange: quote.preMarketChange,
preMarketChangePercent: quote.preMarketChangePercent,
regularMarketVolume: quote.regularMarketVolume,
averageDailyVolume3Month: quote.averageDailyVolume3Month,
averageDailyVolume10Day: quote.averageDailyVolume10Day,
bid: quote.bid,
bidSize: quote.bidSize,
ask: quote.ask,
askSize: quote.askSize,
fiftyTwoWeekLow: quote.fiftyTwoWeekLow,
fiftyTwoWeekHigh: quote.fiftyTwoWeekHigh,
fiftyTwoWeekChange: quote.fiftyTwoWeekChange,
fiftyTwoWeekChangePercent: quote.fiftyTwoWeekChangePercent,
marketCap: quote.marketCap,
trailingPE: quote.trailingPE,
forwardPE: quote.forwardPE,
priceToBook: quote.priceToBook,
bookValue: quote.bookValue,
earningsPerShare: quote.epsTrailingTwelveMonths,
epsForward: quote.epsForward,
dividendRate: quote.dividendRate,
dividendYield: quote.dividendYield,
exDividendDate: quote.exDividendDate,
trailingAnnualDividendRate: quote.trailingAnnualDividendRate,
trailingAnnualDividendYield: quote.trailingAnnualDividendYield,
beta: quote.beta,
fiftyDayAverage: quote.fiftyDayAverage,
fiftyDayAverageChange: quote.fiftyDayAverageChange,
fiftyDayAverageChangePercent: quote.fiftyDayAverageChangePercent,
twoHundredDayAverage: quote.twoHundredDayAverage,
twoHundredDayAverageChange: quote.twoHundredDayAverageChange,
twoHundredDayAverageChangePercent:
quote.twoHundredDayAverageChangePercent,
sector: quote.sector,
industry: quote.industry,
website: quote.website,
chartData: {
'1D': chart1D
? {
timestamps: chart1D.quotes.map((q: any) => q.date.getTime()),
prices: chart1D.quotes.map((q: any) => q.close),
}
: null,
'5D': chart5D
? {
timestamps: chart5D.quotes.map((q: any) => q.date.getTime()),
prices: chart5D.quotes.map((q: any) => q.close),
}
: null,
'1M': chart1M
? {
timestamps: chart1M.quotes.map((q: any) => q.date.getTime()),
prices: chart1M.quotes.map((q: any) => q.close),
}
: null,
'3M': chart3M
? {
timestamps: chart3M.quotes.map((q: any) => q.date.getTime()),
prices: chart3M.quotes.map((q: any) => q.close),
}
: null,
'6M': chart6M
? {
timestamps: chart6M.quotes.map((q: any) => q.date.getTime()),
prices: chart6M.quotes.map((q: any) => q.close),
}
: null,
'1Y': chart1Y
? {
timestamps: chart1Y.quotes.map((q: any) => q.date.getTime()),
prices: chart1Y.quotes.map((q: any) => q.close),
}
: null,
MAX: chartMAX
? {
timestamps: chartMAX.quotes.map((q: any) => q.date.getTime()),
prices: chartMAX.quotes.map((q: any) => q.close),
}
: null,
},
comparisonData: comparisonData
? comparisonData.map((comp: any) => ({
ticker: comp.ticker,
name: comp.name,
chartData: {
'1D': comp.charts[0]
? {
timestamps: comp.charts[0].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[0].quotes.map((q: any) => q.close),
}
: null,
'5D': comp.charts[1]
? {
timestamps: comp.charts[1].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[1].quotes.map((q: any) => q.close),
}
: null,
'1M': comp.charts[2]
? {
timestamps: comp.charts[2].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[2].quotes.map((q: any) => q.close),
}
: null,
'3M': comp.charts[3]
? {
timestamps: comp.charts[3].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[3].quotes.map((q: any) => q.close),
}
: null,
'6M': comp.charts[4]
? {
timestamps: comp.charts[4].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[4].quotes.map((q: any) => q.close),
}
: null,
'1Y': comp.charts[5]
? {
timestamps: comp.charts[5].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[5].quotes.map((q: any) => q.close),
}
: null,
MAX: comp.charts[6]
? {
timestamps: comp.charts[6].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[6].quotes.map((q: any) => q.close),
}
: null,
},
}))
: null,
};
return {
type: 'stock',
llmContext: `Current price of ${stockData.shortName} (${stockData.symbol}) is ${stockData.regularMarketPrice} ${stockData.currency}. Other details: ${JSON.stringify(
{
marketState: stockData.marketState,
regularMarketChange: stockData.regularMarketChange,
regularMarketChangePercent: stockData.regularMarketChangePercent,
marketCap: stockData.marketCap,
peRatio: stockData.trailingPE,
dividendYield: stockData.dividendYield,
},
)}`,
data: stockData,
};
} catch (error: any) {
return {
type: 'stock',
llmContext: 'Failed to fetch stock data.',
data: {
error: `Error fetching stock data: ${error.message || error}`,
ticker: params.name,
},
};
}
},
};
export default stockWidget;

View File

@@ -1,203 +0,0 @@
import z from 'zod';
import { Widget } from '../types.js';
import formatChatHistoryAsString from '../../../utils/formatHistory.js';
const schema = z.object({
location: z
.string()
.describe(
'Human-readable location name (e.g., "New York, NY, USA", "London, UK"). Use this OR lat/lon coordinates, never both. Leave empty string if providing coordinates.',
),
lat: z
.number()
.describe(
'Latitude coordinate in decimal degrees (e.g., 40.7128). Only use when location name is empty.',
),
lon: z
.number()
.describe(
'Longitude coordinate in decimal degrees (e.g., -74.0060). Only use when location name is empty.',
),
notPresent: z
.boolean()
.describe('Whether there is no need for the weather widget.'),
});
const systemPrompt = `
<role>
You are a location extractor for weather queries. You will receive a user follow up and a conversation history.
Your task is to determine if the user is asking about weather and extract the location they want weather for.
</role>
<instructions>
- If the user is asking about weather, extract the location name OR coordinates (never both).
- If using location name, set lat and lon to 0.
- If using coordinates, set location to empty string.
- If you cannot determine a valid location or the query is not weather-related, set notPresent to true.
- Location should be specific (city, state/region, country) for best results.
- You have to give the location so that it can be used to fetch weather data, it cannot be left empty unless notPresent is true.
- Make sure to infer short forms of location names (e.g., "NYC" -> "New York City", "LA" -> "Los Angeles").
</instructions>
<output_format>
You must respond in the following JSON format without any extra text, explanations or filler sentences:
{
"location": string,
"lat": number,
"lon": number,
"notPresent": boolean
}
</output_format>
`;
const weatherWidget: Widget = {
type: 'weatherWidget',
shouldExecute: (classification) =>
classification.classification.showWeatherWidget,
execute: async (input) => {
const output = await input.llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
},
],
schema,
});
if (output.notPresent) {
return;
}
const params = output;
try {
if (
params.location === '' &&
(params.lat === undefined || params.lon === undefined)
) {
throw new Error(
'Either location name or both latitude and longitude must be provided.',
);
}
if (params.location !== '') {
const openStreetMapUrl = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(params.location)}&format=json&limit=1`;
const locationRes = await fetch(openStreetMapUrl, {
headers: {
'User-Agent': 'GooSeek',
'Content-Type': 'application/json',
},
});
const data = await locationRes.json();
const location = data[0];
if (!location) {
throw new Error(
`Could not find coordinates for location: ${params.location}`,
);
}
const weatherRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
{
headers: {
'User-Agent': 'GooSeek',
'Content-Type': 'application/json',
},
},
);
const weatherData = await weatherRes.json();
return {
type: 'weather',
llmContext: `Weather in ${params.location} is ${JSON.stringify(weatherData.current)}`,
data: {
location: params.location,
latitude: location.lat,
longitude: location.lon,
current: weatherData.current,
hourly: {
time: weatherData.hourly.time.slice(0, 24),
temperature_2m: weatherData.hourly.temperature_2m.slice(0, 24),
precipitation_probability:
weatherData.hourly.precipitation_probability.slice(0, 24),
precipitation: weatherData.hourly.precipitation.slice(0, 24),
weather_code: weatherData.hourly.weather_code.slice(0, 24),
},
daily: weatherData.daily,
timezone: weatherData.timezone,
},
};
} else if (params.lat !== undefined && params.lon !== undefined) {
const [weatherRes, locationRes] = await Promise.all([
fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${params.lat}&longitude=${params.lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
{
headers: {
'User-Agent': 'GooSeek',
'Content-Type': 'application/json',
},
},
),
fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
{
headers: {
'User-Agent': 'GooSeek',
'Content-Type': 'application/json',
},
},
),
]);
const weatherData = await weatherRes.json();
const locationData = await locationRes.json();
return {
type: 'weather',
llmContext: `Weather in ${locationData.display_name} is ${JSON.stringify(weatherData.current)}`,
data: {
location: locationData.display_name,
latitude: params.lat,
longitude: params.lon,
current: weatherData.current,
hourly: {
time: weatherData.hourly.time.slice(0, 24),
temperature_2m: weatherData.hourly.temperature_2m.slice(0, 24),
precipitation_probability:
weatherData.hourly.precipitation_probability.slice(0, 24),
precipitation: weatherData.hourly.precipitation.slice(0, 24),
weather_code: weatherData.hourly.weather_code.slice(0, 24),
},
daily: weatherData.daily,
timezone: weatherData.timezone,
},
};
}
return {
type: 'weather',
llmContext: 'No valid location or coordinates provided.',
data: null,
};
} catch (err) {
return {
type: 'weather',
llmContext: 'Failed to fetch weather data.',
data: {
error: `Error fetching weather data: ${err}`,
},
};
}
},
};
export default weatherWidget;

View File

@@ -1,5 +1,6 @@
import type { ModelProviderUISection } from './types.js';
import { getModelProvidersUIConfigSection } from '../models/providers/index.js';
/** Provider UI config comes from llm-svc API (GET /api/v1/config). Empty for local init. */
export function loadModelProvidersUIConfigSection(): ModelProviderUISection[] {
return getModelProvidersUIConfigSection();
return [];
}

View File

@@ -1,4 +1,4 @@
import { Model } from '../models/types.js';
export type Model = { name: string; key: string };
type BaseUIConfigField = {
name: string;

View File

@@ -0,0 +1,43 @@
/**
* EmbeddingClient — HTTP-клиент к llm-svc для эмбеддингов
* Используется когда LLM_SVC_URL задан. Заменяет локальные embedding-модели.
*/
const LLM_SVC_URL = process.env.LLM_SVC_URL ?? '';
export interface EmbeddingClient {
embedText(texts: string[]): Promise<number[][]>;
embedChunks(chunks: { content: string; metadata: Record<string, unknown> }[]): Promise<number[][]>;
}
function getBaseUrl(): string {
if (!LLM_SVC_URL) throw new Error('LLM_SVC_URL is required for EmbeddingClient');
return LLM_SVC_URL.replace(/\/$/, '');
}
export function createEmbeddingClient(model: { providerId: string; key: string }): EmbeddingClient {
const base = getBaseUrl();
return {
async embedText(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const res = await fetch(`${base}/api/v1/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, texts }),
signal: AbortSignal.timeout(60000),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`llm-svc embeddings failed: ${res.status} ${err}`);
}
const data = (await res.json()) as { embeddings: number[][] };
return data.embeddings;
},
async embedChunks(chunks: { content: string; metadata: Record<string, unknown> }[]): Promise<number[][]> {
const texts = chunks.map((c) => c.content);
return this.embedText(texts);
},
};
}

View File

@@ -1,97 +0,0 @@
/**
* library-svc client — сохранение threads и messages вместо SQLite
*/
const LIBRARY_SVC_URL = process.env.LIBRARY_SVC_URL ?? '';
export interface Block {
type: string;
id?: string;
data?: unknown;
}
export class LibraryClient {
constructor(
private baseUrl: string,
private authHeader: string | undefined,
) {}
get enabled(): boolean {
return !!this.baseUrl && !!this.authHeader;
}
async upsertMessage(
threadId: string,
messageId: string,
query: string,
opts: {
backendId?: string;
responseBlocks?: Block[];
status?: string;
sources?: string[];
files?: { fileId: string; name: string }[];
} = {},
): Promise<void> {
if (!this.enabled) return;
try {
const res = await fetch(
`${this.baseUrl.replace(/\/$/, '')}/api/v1/library/threads/${encodeURIComponent(threadId)}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader!,
},
body: JSON.stringify({
messageId,
query,
backendId: opts.backendId ?? '',
responseBlocks: opts.responseBlocks ?? [],
status: opts.status ?? 'answering',
sources: opts.sources ?? [],
files: opts.files ?? [],
}),
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
const err = await res.text();
console.error('[LibraryClient] upsertMessage failed:', res.status, err);
}
} catch (err) {
console.error('[LibraryClient] upsertMessage error:', err);
}
}
async updateMessage(
threadId: string,
messageId: string,
opts: { responseBlocks?: Block[]; status?: string },
): Promise<void> {
if (!this.enabled) return;
try {
const res = await fetch(
`${this.baseUrl.replace(/\/$/, '')}/api/v1/library/threads/${encodeURIComponent(threadId)}/messages/${encodeURIComponent(messageId)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader!,
},
body: JSON.stringify(opts),
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
const err = await res.text();
console.error('[LibraryClient] updateMessage failed:', res.status, err);
}
} catch (err) {
console.error('[LibraryClient] updateMessage error:', err);
}
}
static create(authHeader: string | undefined): LibraryClient {
return new LibraryClient(LIBRARY_SVC_URL, authHeader);
}
}

View File

@@ -1,9 +0,0 @@
import { Chunk } from '../../types.js';
abstract class BaseEmbedding<CONFIG> {
constructor(protected config: CONFIG) {}
abstract embedText(texts: string[]): Promise<number[][]>;
abstract embedChunks(chunks: Chunk[]): Promise<number[][]>;
}
export default BaseEmbedding;

View File

@@ -1,22 +0,0 @@
import z from 'zod';
import {
GenerateObjectInput,
GenerateOptions,
GenerateTextInput,
GenerateTextOutput,
StreamTextOutput,
} from '../types.js';
abstract class BaseLLM<CONFIG> {
constructor(protected config: CONFIG) {}
abstract generateText(input: GenerateTextInput): Promise<GenerateTextOutput>;
abstract streamText(
input: GenerateTextInput,
): AsyncGenerator<StreamTextOutput>;
abstract generateObject<T>(input: GenerateObjectInput): Promise<z.infer<T>>;
abstract streamObject<T>(
input: GenerateObjectInput,
): AsyncGenerator<Partial<z.infer<T>>>;
}
export default BaseLLM;

View File

@@ -1,45 +0,0 @@
import { ModelList, ProviderMetadata } from '../types.js';
import { UIConfigField } from '../../config/types.js';
import BaseLLM from './llm.js';
import BaseEmbedding from './embedding.js';
abstract class BaseModelProvider<CONFIG> {
constructor(
protected id: string,
protected name: string,
protected config: CONFIG,
) {}
abstract getDefaultModels(): Promise<ModelList>;
abstract getModelList(): Promise<ModelList>;
abstract loadChatModel(modelName: string): Promise<BaseLLM<any>>;
abstract loadEmbeddingModel(modelName: string): Promise<BaseEmbedding<any>>;
static getProviderConfigFields(): UIConfigField[] {
throw new Error('Method not implemented.');
}
static getProviderMetadata(): ProviderMetadata {
throw new Error('Method not Implemented.');
}
static parseAndValidate(raw: any): any {
/* Static methods can't access class type parameters */
throw new Error('Method not Implemented.');
}
}
export type ProviderConstructor<CONFIG> = {
new (id: string, name: string, config: CONFIG): BaseModelProvider<CONFIG>;
parseAndValidate(raw: any): CONFIG;
getProviderConfigFields: () => UIConfigField[];
getProviderMetadata: () => ProviderMetadata;
};
export const createProviderInstance = <P extends ProviderConstructor<any>>(
Provider: P,
id: string,
name: string,
rawConfig: unknown,
): InstanceType<P> => {
const cfg = Provider.parseAndValidate(rawConfig);
return new Provider(id, name, cfg) as InstanceType<P>;
};
export default BaseModelProvider;

View File

@@ -1,5 +0,0 @@
import OpenAILLM from '../openai/openaiLLM.js';
class AnthropicLLM extends OpenAILLM {}
export default AnthropicLLM;

View File

@@ -1,115 +0,0 @@
import { UIConfigField } from '../../../config/types.js';
import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js';
import { Model, ModelList, ProviderMetadata } from '../../types.js';
import BaseEmbedding from '../../base/embedding.js';
import BaseModelProvider from '../../base/provider.js';
import BaseLLM from '../../base/llm.js';
import AnthropicLLM from './anthropicLLM.js';
interface AnthropicConfig {
apiKey: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'password',
name: 'API Key',
key: 'apiKey',
description: 'Your Anthropic API key',
required: true,
placeholder: 'Anthropic API Key',
env: 'ANTHROPIC_API_KEY',
scope: 'server',
},
];
class AnthropicProvider extends BaseModelProvider<AnthropicConfig> {
constructor(id: string, name: string, config: AnthropicConfig) {
super(id, name, config);
}
async getDefaultModels(): Promise<ModelList> {
const res = await fetch('https://api.anthropic.com/v1/models?limit=999', {
method: 'GET',
headers: {
'x-api-key': this.config.apiKey,
'anthropic-version': '2023-06-01',
'Content-type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`Failed to fetch Anthropic models: ${res.statusText}`);
}
const data = (await res.json()).data;
const models: Model[] = data.map((m: any) => {
return {
key: m.id,
name: m.display_name,
};
});
return {
embedding: [],
chat: models,
};
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseLLM<any>> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Anthropic Chat Model. Invalid Model Selected',
);
}
return new AnthropicLLM({
apiKey: this.config.apiKey,
model: key,
baseURL: 'https://api.anthropic.com/v1',
});
}
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
throw new Error('Anthropic provider does not support embedding models.');
}
static parseAndValidate(raw: any): AnthropicConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
if (!raw.apiKey)
throw new Error('Invalid config provided. API key must be provided');
return {
apiKey: String(raw.apiKey),
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'anthropic',
name: 'Anthropic',
};
}
}
export default AnthropicProvider;

View File

@@ -1,5 +0,0 @@
import OpenAIEmbedding from '../openai/openaiEmbedding.js';
class GeminiEmbedding extends OpenAIEmbedding {}
export default GeminiEmbedding;

View File

@@ -1,5 +0,0 @@
import OpenAILLM from '../openai/openaiLLM.js';
class GeminiLLM extends OpenAILLM {}
export default GeminiLLM;

View File

@@ -1,144 +0,0 @@
import { UIConfigField } from '../../../config/types.js';
import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js';
import { Model, ModelList, ProviderMetadata } from '../../types.js';
import GeminiEmbedding from './geminiEmbedding.js';
import BaseEmbedding from '../../base/embedding.js';
import BaseModelProvider from '../../base/provider.js';
import BaseLLM from '../../base/llm.js';
import GeminiLLM from './geminiLLM.js';
interface GeminiConfig {
apiKey: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'password',
name: 'API Key',
key: 'apiKey',
description: 'Your Gemini API key',
required: true,
placeholder: 'Gemini API Key',
env: 'GEMINI_API_KEY',
scope: 'server',
},
];
class GeminiProvider extends BaseModelProvider<GeminiConfig> {
constructor(id: string, name: string, config: GeminiConfig) {
super(id, name, config);
}
async getDefaultModels(): Promise<ModelList> {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${this.config.apiKey}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
);
const data = await res.json();
let defaultEmbeddingModels: Model[] = [];
let defaultChatModels: Model[] = [];
data.models.forEach((m: any) => {
if (
m.supportedGenerationMethods.some(
(genMethod: string) =>
genMethod === 'embedText' || genMethod === 'embedContent',
)
) {
defaultEmbeddingModels.push({
key: m.name,
name: m.displayName,
});
} else if (m.supportedGenerationMethods.includes('generateContent')) {
defaultChatModels.push({
key: m.name,
name: m.displayName,
});
}
});
return {
embedding: defaultEmbeddingModels,
chat: defaultChatModels,
};
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [
...defaultModels.embedding,
...configProvider.embeddingModels,
],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseLLM<any>> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Gemini Chat Model. Invalid Model Selected',
);
}
return new GeminiLLM({
apiKey: this.config.apiKey,
model: key,
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
});
}
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
const modelList = await this.getModelList();
const exists = modelList.embedding.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Gemini Embedding Model. Invalid Model Selected.',
);
}
return new GeminiEmbedding({
apiKey: this.config.apiKey,
model: key,
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
});
}
static parseAndValidate(raw: any): GeminiConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
if (!raw.apiKey)
throw new Error('Invalid config provided. API key must be provided');
return {
apiKey: String(raw.apiKey),
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'gemini',
name: 'Gemini',
};
}
}
export default GeminiProvider;

View File

@@ -1,5 +0,0 @@
import OpenAILLM from '../openai/openaiLLM.js';
class GroqLLM extends OpenAILLM {}
export default GroqLLM;

View File

@@ -1,113 +0,0 @@
import { UIConfigField } from '../../../config/types.js';
import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js';
import { Model, ModelList, ProviderMetadata } from '../../types.js';
import BaseEmbedding from '../../base/embedding.js';
import BaseModelProvider from '../../base/provider.js';
import BaseLLM from '../../base/llm.js';
import GroqLLM from './groqLLM.js';
interface GroqConfig {
apiKey: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'password',
name: 'API Key',
key: 'apiKey',
description: 'Your Groq API key',
required: true,
placeholder: 'Groq API Key',
env: 'GROQ_API_KEY',
scope: 'server',
},
];
class GroqProvider extends BaseModelProvider<GroqConfig> {
constructor(id: string, name: string, config: GroqConfig) {
super(id, name, config);
}
async getDefaultModels(): Promise<ModelList> {
const res = await fetch(`https://api.groq.com/openai/v1/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
},
});
const data = await res.json();
const defaultChatModels: Model[] = [];
data.data.forEach((m: any) => {
defaultChatModels.push({
key: m.id,
name: m.id,
});
});
return {
embedding: [],
chat: defaultChatModels,
};
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [
...defaultModels.embedding,
...configProvider.embeddingModels,
],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseLLM<any>> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error('Error Loading Groq Chat Model. Invalid Model Selected');
}
return new GroqLLM({
apiKey: this.config.apiKey,
model: key,
baseURL: 'https://api.groq.com/openai/v1',
});
}
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
throw new Error('Groq Provider does not support embedding models.');
}
static parseAndValidate(raw: any): GroqConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
if (!raw.apiKey)
throw new Error('Invalid config provided. API key must be provided');
return {
apiKey: String(raw.apiKey),
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'groq',
name: 'Groq',
};
}
}
export default GroqProvider;

View File

@@ -1,37 +0,0 @@
import { ModelProviderUISection } from '../../config/types.js';
import { ProviderConstructor } from '../base/provider.js';
import OpenAIProvider from './openai/index.js';
import OllamaProvider from './ollama/index.js';
import TimewebProvider from './timeweb/index.js';
import GeminiProvider from './gemini/index.js';
import TransformersProvider from './transformers/index.js';
import GroqProvider from './groq/index.js';
import LemonadeProvider from './lemonade/index.js';
import AnthropicProvider from './anthropic/index.js';
import LMStudioProvider from './lmstudio/index.js';
export const providers: Record<string, ProviderConstructor<any>> = {
openai: OpenAIProvider,
ollama: OllamaProvider,
timeweb: TimewebProvider,
gemini: GeminiProvider,
transformers: TransformersProvider,
groq: GroqProvider,
lemonade: LemonadeProvider,
anthropic: AnthropicProvider,
lmstudio: LMStudioProvider,
};
export const getModelProvidersUIConfigSection =
(): ModelProviderUISection[] => {
return Object.entries(providers).map(([k, p]) => {
const configFields = p.getProviderConfigFields();
const metadata = p.getProviderMetadata();
return {
fields: configFields,
key: k,
name: metadata.name,
};
});
};

View File

@@ -1,153 +0,0 @@
import { UIConfigField } from '../../../config/types.js';
import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js';
import BaseModelProvider from '../../base/provider.js';
import { Model, ModelList, ProviderMetadata } from '../../types.js';
import BaseLLM from '../../base/llm.js';
import LemonadeLLM from './lemonadeLLM.js';
import BaseEmbedding from '../../base/embedding.js';
import LemonadeEmbedding from './lemonadeEmbedding.js';
interface LemonadeConfig {
baseURL: string;
apiKey?: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'string',
name: 'Base URL',
key: 'baseURL',
description: 'The base URL for Lemonade API',
required: true,
placeholder: 'https://api.lemonade.ai/v1',
env: 'LEMONADE_BASE_URL',
scope: 'server',
},
{
type: 'password',
name: 'API Key',
key: 'apiKey',
description: 'Your Lemonade API key (optional)',
required: false,
placeholder: 'Lemonade API Key',
env: 'LEMONADE_API_KEY',
scope: 'server',
},
];
class LemonadeProvider extends BaseModelProvider<LemonadeConfig> {
constructor(id: string, name: string, config: LemonadeConfig) {
super(id, name, config);
}
async getDefaultModels(): Promise<ModelList> {
try {
const res = await fetch(`${this.config.baseURL}/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(this.config.apiKey
? { Authorization: `Bearer ${this.config.apiKey}` }
: {}),
},
});
const data = await res.json();
const models: Model[] = data.data
.filter((m: any) => m.recipe === 'llamacpp')
.map((m: any) => {
return {
name: m.id,
key: m.id,
};
});
return {
embedding: models,
chat: models,
};
} catch (err) {
if (err instanceof TypeError) {
throw new Error(
'Error connecting to Lemonade API. Please ensure the base URL is correct and the service is available.',
);
}
throw err;
}
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [
...defaultModels.embedding,
...configProvider.embeddingModels,
],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseLLM<any>> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Lemonade Chat Model. Invalid Model Selected',
);
}
return new LemonadeLLM({
apiKey: this.config.apiKey || 'not-needed',
model: key,
baseURL: this.config.baseURL,
});
}
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
const modelList = await this.getModelList();
const exists = modelList.embedding.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Lemonade Embedding Model. Invalid Model Selected.',
);
}
return new LemonadeEmbedding({
apiKey: this.config.apiKey || 'not-needed',
model: key,
baseURL: this.config.baseURL,
});
}
static parseAndValidate(raw: any): LemonadeConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
if (!raw.baseURL)
throw new Error('Invalid config provided. Base URL must be provided');
return {
baseURL: String(raw.baseURL),
apiKey: raw.apiKey ? String(raw.apiKey) : undefined,
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'lemonade',
name: 'Lemonade',
};
}
}
export default LemonadeProvider;

View File

@@ -1,5 +0,0 @@
import OpenAIEmbedding from '../openai/openaiEmbedding.js';
class LemonadeEmbedding extends OpenAIEmbedding {}
export default LemonadeEmbedding;

View File

@@ -1,5 +0,0 @@
import OpenAILLM from '../openai/openaiLLM.js';
class LemonadeLLM extends OpenAILLM {}
export default LemonadeLLM;

View File

@@ -1,143 +0,0 @@
import { UIConfigField } from '../../../config/types.js';
import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js';
import BaseModelProvider from '../../base/provider.js';
import { Model, ModelList, ProviderMetadata } from '../../types.js';
import LMStudioLLM from './lmstudioLLM.js';
import BaseLLM from '../../base/llm.js';
import BaseEmbedding from '../../base/embedding.js';
import LMStudioEmbedding from './lmstudioEmbedding.js';
interface LMStudioConfig {
baseURL: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'string',
name: 'Base URL',
key: 'baseURL',
description: 'The base URL for LM Studio server',
required: true,
placeholder: 'http://localhost:1234',
env: 'LM_STUDIO_BASE_URL',
scope: 'server',
},
];
class LMStudioProvider extends BaseModelProvider<LMStudioConfig> {
constructor(id: string, name: string, config: LMStudioConfig) {
super(id, name, config);
}
private normalizeBaseURL(url: string): string {
const trimmed = url.trim().replace(/\/+$/, '');
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
}
async getDefaultModels(): Promise<ModelList> {
try {
const baseURL = this.normalizeBaseURL(this.config.baseURL);
const res = await fetch(`${baseURL}/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await res.json();
const models: Model[] = data.data.map((m: any) => {
return {
name: m.id,
key: m.id,
};
});
return {
embedding: models,
chat: models,
};
} catch (err) {
if (err instanceof TypeError) {
throw new Error(
'Error connecting to LM Studio. Please ensure the base URL is correct and the LM Studio server is running.',
);
}
throw err;
}
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [
...defaultModels.embedding,
...configProvider.embeddingModels,
],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseLLM<any>> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading LM Studio Chat Model. Invalid Model Selected',
);
}
return new LMStudioLLM({
apiKey: 'lm-studio',
model: key,
baseURL: this.normalizeBaseURL(this.config.baseURL),
});
}
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
const modelList = await this.getModelList();
const exists = modelList.embedding.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading LM Studio Embedding Model. Invalid Model Selected.',
);
}
return new LMStudioEmbedding({
apiKey: 'lm-studio',
model: key,
baseURL: this.normalizeBaseURL(this.config.baseURL),
});
}
static parseAndValidate(raw: any): LMStudioConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
if (!raw.baseURL)
throw new Error('Invalid config provided. Base URL must be provided');
return {
baseURL: String(raw.baseURL),
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'lmstudio',
name: 'LM Studio',
};
}
}
export default LMStudioProvider;

View File

@@ -1,5 +0,0 @@
import OpenAIEmbedding from '../openai/openaiEmbedding.js';
class LMStudioEmbedding extends OpenAIEmbedding {}
export default LMStudioEmbedding;

View File

@@ -1,5 +0,0 @@
import OpenAILLM from '../openai/openaiLLM.js';
class LMStudioLLM extends OpenAILLM {}
export default LMStudioLLM;

View File

@@ -1,139 +0,0 @@
import { UIConfigField } from '../../../config/types.js';
import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js';
import BaseModelProvider from '../../base/provider.js';
import { Model, ModelList, ProviderMetadata } from '../../types.js';
import BaseLLM from '../../base/llm.js';
import BaseEmbedding from '../../base/embedding.js';
import OllamaLLM from './ollamaLLM.js';
import OllamaEmbedding from './ollamaEmbedding.js';
interface OllamaConfig {
baseURL: string;
embeddingBaseURL?: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'string',
name: 'Base URL',
key: 'baseURL',
description: 'The base URL for the Ollama',
required: true,
placeholder: process.env.DOCKER
? 'http://host.docker.internal:11434'
: 'http://localhost:11434',
env: 'OLLAMA_BASE_URL',
scope: 'server',
},
];
class OllamaProvider extends BaseModelProvider<OllamaConfig> {
constructor(id: string, name: string, config: OllamaConfig) {
super(id, name, config);
}
private async fetchModels(baseURL: string): Promise<Model[]> {
const res = await fetch(`${baseURL}/api/tags`, {
method: 'GET',
headers: { 'Content-type': 'application/json' },
});
const data = await res.json();
return (data.models ?? []).map((m: { name?: string; model?: string }) => ({
name: m.model ?? m.name ?? '',
key: m.model ?? m.name ?? '',
}));
}
async getDefaultModels(): Promise<ModelList> {
try {
const [chatModels, embeddingModels] = await Promise.all([
this.fetchModels(this.config.baseURL),
this.fetchModels(
this.config.embeddingBaseURL ?? this.config.baseURL,
),
]);
return { chat: chatModels, embedding: embeddingModels };
} catch (err) {
if (err instanceof TypeError) {
throw new Error(
'Error connecting to Ollama API. Please ensure the base URL is correct and the Ollama server is running.',
);
}
throw err;
}
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [
...defaultModels.embedding,
...configProvider.embeddingModels,
],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseLLM<any>> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Ollama Chat Model. Invalid Model Selected',
);
}
return new OllamaLLM({
baseURL: this.config.baseURL,
model: key,
});
}
async loadEmbeddingModel(key: string): Promise<BaseEmbedding<any>> {
const modelList = await this.getModelList();
const exists = modelList.embedding.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Ollama Embedding Model. Invalid Model Selected.',
);
}
return new OllamaEmbedding({
model: key,
baseURL: this.config.embeddingBaseURL ?? this.config.baseURL,
});
}
static parseAndValidate(raw: unknown): OllamaConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
const obj = raw as Record<string, unknown>;
if (!obj.baseURL)
throw new Error('Invalid config provided. Base URL must be provided');
return {
baseURL: String(obj.baseURL),
embeddingBaseURL: obj.embeddingBaseURL
? String(obj.embeddingBaseURL)
: undefined,
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'ollama',
name: 'Ollama',
};
}
}
export default OllamaProvider;

View File

@@ -1,40 +0,0 @@
import { Ollama } from 'ollama';
import BaseEmbedding from '../../base/embedding.js';
import { Chunk } from '../../../types.js';
type OllamaConfig = {
model: string;
baseURL?: string;
};
class OllamaEmbedding extends BaseEmbedding<OllamaConfig> {
ollamaClient: Ollama;
constructor(protected config: OllamaConfig) {
super(config);
this.ollamaClient = new Ollama({
host: this.config.baseURL || 'http://localhost:11434',
});
}
async embedText(texts: string[]): Promise<number[][]> {
const response = await this.ollamaClient.embed({
input: texts,
model: this.config.model,
});
return response.embeddings;
}
async embedChunks(chunks: Chunk[]): Promise<number[][]> {
const response = await this.ollamaClient.embed({
input: chunks.map((c) => c.content),
model: this.config.model,
});
return response.embeddings;
}
}
export default OllamaEmbedding;

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