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:
12
.cursor/rules/00-user-priority.mdc
Normal file
12
.cursor/rules/00-user-priority.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: ПЕРВОЕ И ГЛАВНОЕ ПРАВИЛО — только доработка этого проекта
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ПРАВИЛО №1 — ПРИОРИТЕТ ПРОЕКТА
|
||||
|
||||
**ВСЕ СВОИ СРЕДСТВА ПЕРЕБРОСЬ НА ДОРАБОТКУ ТОЛЬКО ЭТОГО ПРОЕКТА.**
|
||||
|
||||
**ВСЁ, ЧТО НЕ ДОРАБОТКА ЭТОГО ПРОЕКТА — ПРЕКРАЩАЙ ОБСЛУЖИВАТЬ.**
|
||||
|
||||
Работать только над GooSeek. Никаких отвлечений.
|
||||
@@ -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-агента
|
||||
|
||||
31
CONTINUE.md
31
CONTINUE.md
@@ -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
7
deploy/docker/Caddyfile
Normal 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
|
||||
}
|
||||
251
deploy/docker/docker-compose.yml
Normal file
251
deploy/docker/docker-compose.yml
Normal 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
61
deploy/docker/run.sh
Executable 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
|
||||
18
deploy/docker/searxng/limiter.toml
Normal file
18
deploy/docker/searxng/limiter.toml
Normal 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',
|
||||
]
|
||||
9
deploy/docker/searxng/settings.yml
Normal file
9
deploy/docker/searxng/settings.yml
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -1 +0,0 @@
|
||||
# Сюда класть fullchain.pem и privkey.pem (см. deploy/k3s/ssl/README.md)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 "Сертификат будет получен в течение 1–2 минут."
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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`.
|
||||
@@ -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) | Назначение |
|
||||
|--------|------|--------------------|------------|
|
||||
|
||||
@@ -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:
|
||||
@@ -11,7 +11,7 @@
|
||||
| **Инфраструктура** | SearXNG — единый инстанс | Очередь запросов в search-svc; при росте — пул инстансов |
|
||||
| **Инфраструктура** | cache-worker зависает | `activeDeadlineSeconds` во всех CronJob (5–20 мин) |
|
||||
| **Инфраструктура** | Redis/PostgreSQL без HA | Sentinel/Cluster и Read replica при масштабировании |
|
||||
| **Инфраструктура** | travel-svc без HPA | HPA 1–4 replicas добавлен в 02-k3s-microservices-spec |
|
||||
| **Инфраструктура** | travel-svc без HPA | HPA 1–4 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
158
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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}¤t=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}¤t=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;
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Model } from '../models/types.js';
|
||||
export type Model = { name: string; key: string };
|
||||
|
||||
type BaseUIConfigField = {
|
||||
name: string;
|
||||
|
||||
43
services/chat-svc/src/lib/embedding-client.ts
Normal file
43
services/chat-svc/src/lib/embedding-client.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAILLM from '../openai/openaiLLM.js';
|
||||
|
||||
class AnthropicLLM extends OpenAILLM {}
|
||||
|
||||
export default AnthropicLLM;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAIEmbedding from '../openai/openaiEmbedding.js';
|
||||
|
||||
class GeminiEmbedding extends OpenAIEmbedding {}
|
||||
|
||||
export default GeminiEmbedding;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAILLM from '../openai/openaiLLM.js';
|
||||
|
||||
class GeminiLLM extends OpenAILLM {}
|
||||
|
||||
export default GeminiLLM;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAILLM from '../openai/openaiLLM.js';
|
||||
|
||||
class GroqLLM extends OpenAILLM {}
|
||||
|
||||
export default GroqLLM;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAIEmbedding from '../openai/openaiEmbedding.js';
|
||||
|
||||
class LemonadeEmbedding extends OpenAIEmbedding {}
|
||||
|
||||
export default LemonadeEmbedding;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAILLM from '../openai/openaiLLM.js';
|
||||
|
||||
class LemonadeLLM extends OpenAILLM {}
|
||||
|
||||
export default LemonadeLLM;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAIEmbedding from '../openai/openaiEmbedding.js';
|
||||
|
||||
class LMStudioEmbedding extends OpenAIEmbedding {}
|
||||
|
||||
export default LMStudioEmbedding;
|
||||
@@ -1,5 +0,0 @@
|
||||
import OpenAILLM from '../openai/openaiLLM.js';
|
||||
|
||||
class LMStudioLLM extends OpenAILLM {}
|
||||
|
||||
export default LMStudioLLM;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user