From 328d968f3fc9524ca6b634039f7d74644f2d9a6a Mon Sep 17 00:00:00 2001 From: home Date: Mon, 23 Feb 2026 22:14:00 +0300 Subject: [PATCH] =?UTF-8?q?Deploy:=20migrate=20k3s=20=E2=86=92=20Docker;?= =?UTF-8?q?=20search=20logic=20=E2=86=92=20master-agents-svc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .cursor/rules/00-user-priority.mdc | 12 + .cursor/rules/agents-guidelines.mdc | 4 +- CONTINUE.md | 31 +- deploy/docker/Caddyfile | 7 + deploy/docker/docker-compose.yml | 251 ++++++++++ deploy/docker/run.sh | 61 +++ deploy/docker/searxng/limiter.toml | 18 + deploy/docker/searxng/settings.yml | 9 + deploy/k3s/api-gateway.yaml | 85 ---- deploy/k3s/audit-svc.yaml | 60 --- deploy/k3s/auth-svc.yaml | 94 ---- deploy/k3s/billing-svc.yaml | 74 --- deploy/k3s/cache-worker.yaml | 85 ---- deploy/k3s/chat-svc.yaml | 103 ----- deploy/k3s/create-svc.yaml | 57 --- deploy/k3s/deploy.config.yaml | 59 --- deploy/k3s/deploy.sh | 318 ------------- deploy/k3s/discover-svc.yaml | 77 ---- deploy/k3s/finance-svc.yaml | 67 --- deploy/k3s/hpa.yaml | 153 ------ deploy/k3s/ingress-production-manual.yaml | 39 -- deploy/k3s/ingress-production.yaml | 43 -- deploy/k3s/ingress.yaml | 132 ------ deploy/k3s/library-svc.yaml | 64 --- deploy/k3s/llm-svc.yaml | 130 ------ deploy/k3s/memory-svc.yaml | 67 --- deploy/k3s/namespace.yaml | 21 - deploy/k3s/network-policies.yaml | 17 - deploy/k3s/notifications-svc.yaml | 77 ---- deploy/k3s/profile-svc.yaml | 67 --- deploy/k3s/projects-svc.yaml | 55 --- deploy/k3s/search-svc.yaml | 65 --- deploy/k3s/ssl/README.md | 187 -------- deploy/k3s/ssl/apply-secret.sh | 24 - deploy/k3s/ssl/backup/.gitkeep | 1 - deploy/k3s/ssl/cert-manager-issuer.yaml | 18 - deploy/k3s/ssl/check-cert.sh | 24 - deploy/k3s/ssl/obtain-cert.sh | 33 -- deploy/k3s/ssl/setup-kubernetes.sh | 86 ---- deploy/k3s/travel-svc.yaml | 62 --- deploy/k3s/web-svc.yaml | 60 --- docs/RUNBOOK.md | 95 ---- docs/TELEMETRY.md | 29 -- .../01-perplexity-analogue-design.md | 10 +- ...rvices-spec.md => 02-k3s-services-spec.md} | 6 +- .../05-gaps-and-best-practices.md | 4 +- docs/architecture/06-roadmap-specification.md | 2 +- docs/architecture/MIGRATION.md | 4 +- docs/architecture/README.md | 14 +- docs/architecture/WORKING.md | 72 --- package-lock.json | 158 +++---- services/api-gateway/Dockerfile | 7 +- services/api-gateway/src/index.ts | 27 +- services/auth-svc/Dockerfile | 9 +- services/billing-svc/src/index.ts | 2 +- services/chat-svc/Dockerfile | 4 +- services/chat-svc/package.json | 2 +- services/chat-svc/src/index.ts | 256 +++++------ .../chat-svc/src/lib/agents/search/api.ts | 102 ---- .../src/lib/agents/search/classifier.ts | 53 --- .../chat-svc/src/lib/agents/search/index.ts | 309 ------------- .../researcher/actions/academicSearch.ts | 129 ------ .../agents/search/researcher/actions/done.ts | 24 - .../agents/search/researcher/actions/index.ts | 18 - .../agents/search/researcher/actions/plan.ts | 40 -- .../search/researcher/actions/registry.ts | 108 ----- .../search/researcher/actions/scrapeURL.ts | 139 ------ .../search/researcher/actions/socialSearch.ts | 129 ------ .../researcher/actions/uploadsSearch.ts | 107 ----- .../search/researcher/actions/webSearch.ts | 182 -------- .../src/lib/agents/search/researcher/index.ts | 225 --------- .../chat-svc/src/lib/agents/search/types.ts | 141 ------ .../search/widgets/calculationWidget.ts | 71 --- .../src/lib/agents/search/widgets/executor.ts | 36 -- .../src/lib/agents/search/widgets/index.ts | 10 - .../lib/agents/search/widgets/stockWidget.ts | 434 ------------------ .../agents/search/widgets/weatherWidget.ts | 203 -------- .../src/lib/config/providersLoader.ts | 5 +- services/chat-svc/src/lib/config/types.ts | 2 +- services/chat-svc/src/lib/embedding-client.ts | 43 ++ services/chat-svc/src/lib/library-client.ts | 97 ---- .../chat-svc/src/lib/models/base/embedding.ts | 9 - services/chat-svc/src/lib/models/base/llm.ts | 22 - .../chat-svc/src/lib/models/base/provider.ts | 45 -- .../providers/anthropic/anthropicLLM.ts | 5 - .../lib/models/providers/anthropic/index.ts | 115 ----- .../providers/gemini/geminiEmbedding.ts | 5 - .../lib/models/providers/gemini/geminiLLM.ts | 5 - .../src/lib/models/providers/gemini/index.ts | 144 ------ .../src/lib/models/providers/groq/groqLLM.ts | 5 - .../src/lib/models/providers/groq/index.ts | 113 ----- .../src/lib/models/providers/index.ts | 37 -- .../lib/models/providers/lemonade/index.ts | 153 ------ .../providers/lemonade/lemonadeEmbedding.ts | 5 - .../models/providers/lemonade/lemonadeLLM.ts | 5 - .../lib/models/providers/lmstudio/index.ts | 143 ------ .../providers/lmstudio/lmstudioEmbedding.ts | 5 - .../models/providers/lmstudio/lmstudioLLM.ts | 5 - .../src/lib/models/providers/ollama/index.ts | 139 ------ .../providers/ollama/ollamaEmbedding.ts | 40 -- .../lib/models/providers/ollama/ollamaLLM.ts | 261 ----------- .../src/lib/models/providers/openai/index.ts | 226 --------- .../providers/openai/openaiEmbedding.ts | 42 -- .../lib/models/providers/openai/openaiLLM.ts | 275 ----------- .../src/lib/models/providers/timeweb/index.ts | 172 ------- .../models/providers/timeweb/timewebLLM.ts | 265 ----------- .../models/providers/transformers/index.ts | 88 ---- .../transformers/transformerEmbedding.ts | 41 -- services/chat-svc/src/lib/models/registry.ts | 256 ----------- services/chat-svc/src/lib/models/types.ts | 103 ----- services/chat-svc/src/lib/prompts/locale.ts | 52 --- .../src/lib/prompts/search/researcher.ts | 356 -------------- .../chat-svc/src/lib/prompts/search/writer.ts | 167 ------- services/chat-svc/src/lib/searxng.ts | 155 ------- services/chat-svc/src/lib/session.ts | 105 ----- services/chat-svc/src/lib/uploads/manager.ts | 9 +- services/chat-svc/src/lib/uploads/store.ts | 122 ----- .../src/lib/utils/computeSimilarity.ts | 22 - .../chat-svc/src/lib/utils/formatHistory.ts | 12 - services/discover-svc/Dockerfile | 4 +- services/geo-device-svc/Dockerfile | 15 + services/geo-device-svc/package.json | 4 +- services/geo-device-svc/src/index.ts | 2 +- services/llm-svc/Dockerfile | 7 +- services/llm-svc/src/index.ts | 168 ++++++- services/llm-svc/src/lib/models/registry.ts | 18 +- services/localization-svc/Dockerfile | 15 + services/localization-svc/package.json | 3 +- services/localization-svc/src/index.ts | 2 +- .../localization-svc/src/lib/geoClient.ts | 2 +- services/master-agents-svc/Dockerfile | 15 + services/master-agents-svc/package.json | 4 + services/master-agents-svc/src/index.ts | 132 +++++- .../src/lib/actions/__reasoning_preamble.ts | 28 ++ .../src/lib/actions/academic_search.ts | 77 ++++ .../master-agents-svc/src/lib/actions/done.ts | 16 + .../src/lib/actions/registry.ts | 57 +++ .../src/lib/actions/scrape_url.ts | 71 +++ .../src/lib/actions/social_search.ts | 77 ++++ .../src/lib/actions/types.ts | 52 +++ .../src/lib/actions/web_search.ts | 80 ++++ .../src/lib/agent/classifier.ts | 39 ++ .../src/lib/agent/researcher.ts | 195 ++++++++ .../src/lib/agent/searchOrchestrator.ts | 156 +++++++ .../src/lib/embedding-client.ts | 37 ++ .../master-agents-svc/src/lib/llm-client.ts | 100 ++++ .../src/lib/prompts}/classifier.ts | 18 +- .../src/lib/prompts/locale.ts | 19 + .../src/lib/prompts/researcher.ts | 149 ++++++ .../src/lib/prompts/writer.ts | 92 ++++ services/master-agents-svc/src/lib/searxng.ts | 67 +++ services/master-agents-svc/src/lib/session.ts | 68 +++ services/master-agents-svc/src/lib/types.ts | 56 ++- .../src/lib/utils/formatHistory.ts | 7 + .../src/lib/widgets/calculationWidget.ts | 49 ++ .../src/lib/widgets/executor.ts | 26 ++ .../src/lib/widgets/index.ts | 10 + .../src/lib/widgets/stockWidget.ts | 103 +++++ .../src/lib/widgets/types.ts | 20 + .../src/lib/widgets/weatherWidget.ts | 99 ++++ services/master-agents-svc/src/turndown.d.ts | 5 + services/package.json | 2 +- services/search-svc/Dockerfile | 4 +- services/search-svc/src/index.ts | 23 +- services/travel-svc/Dockerfile | 4 +- services/web-svc/Dockerfile | 7 +- services/web-svc/next-env.d.ts | 2 +- services/web-svc/next.config.mjs | 23 +- services/web-svc/src/app/layout.tsx | 2 + .../src/components/EmptyChatMessageInput.tsx | 6 - .../MessageInputActions/AnswerMode.tsx | 114 +++-- .../MessageInputActions/ChatModelSelector.tsx | 209 --------- .../MessageInputActions/InputBarPlus.tsx | 1 - .../MessageInputActions/Optimization.tsx | 114 ----- .../MessageInputActions/Sources.tsx | 93 ---- services/web-svc/src/components/Sidebar.tsx | 30 +- .../web-svc/src/components/UnregisterSW.tsx | 19 + services/web-svc/src/lib/hooks/useChat.tsx | 41 +- .../web-svc/src/lib/localization/context.tsx | 4 + .../lib/localization/embeddedTranslations.ts | 24 + 180 files changed, 3022 insertions(+), 9798 deletions(-) create mode 100644 .cursor/rules/00-user-priority.mdc create mode 100644 deploy/docker/Caddyfile create mode 100644 deploy/docker/docker-compose.yml create mode 100755 deploy/docker/run.sh create mode 100644 deploy/docker/searxng/limiter.toml create mode 100644 deploy/docker/searxng/settings.yml delete mode 100644 deploy/k3s/api-gateway.yaml delete mode 100644 deploy/k3s/audit-svc.yaml delete mode 100644 deploy/k3s/auth-svc.yaml delete mode 100644 deploy/k3s/billing-svc.yaml delete mode 100644 deploy/k3s/cache-worker.yaml delete mode 100644 deploy/k3s/chat-svc.yaml delete mode 100644 deploy/k3s/create-svc.yaml delete mode 100644 deploy/k3s/deploy.config.yaml delete mode 100755 deploy/k3s/deploy.sh delete mode 100644 deploy/k3s/discover-svc.yaml delete mode 100644 deploy/k3s/finance-svc.yaml delete mode 100644 deploy/k3s/hpa.yaml delete mode 100644 deploy/k3s/ingress-production-manual.yaml delete mode 100644 deploy/k3s/ingress-production.yaml delete mode 100644 deploy/k3s/ingress.yaml delete mode 100644 deploy/k3s/library-svc.yaml delete mode 100644 deploy/k3s/llm-svc.yaml delete mode 100644 deploy/k3s/memory-svc.yaml delete mode 100644 deploy/k3s/namespace.yaml delete mode 100644 deploy/k3s/network-policies.yaml delete mode 100644 deploy/k3s/notifications-svc.yaml delete mode 100644 deploy/k3s/profile-svc.yaml delete mode 100644 deploy/k3s/projects-svc.yaml delete mode 100644 deploy/k3s/search-svc.yaml delete mode 100644 deploy/k3s/ssl/README.md delete mode 100755 deploy/k3s/ssl/apply-secret.sh delete mode 100644 deploy/k3s/ssl/backup/.gitkeep delete mode 100644 deploy/k3s/ssl/cert-manager-issuer.yaml delete mode 100755 deploy/k3s/ssl/check-cert.sh delete mode 100755 deploy/k3s/ssl/obtain-cert.sh delete mode 100755 deploy/k3s/ssl/setup-kubernetes.sh delete mode 100644 deploy/k3s/travel-svc.yaml delete mode 100644 deploy/k3s/web-svc.yaml delete mode 100644 docs/RUNBOOK.md delete mode 100644 docs/TELEMETRY.md rename docs/architecture/{02-k3s-microservices-spec.md => 02-k3s-services-spec.md} (99%) delete mode 100644 docs/architecture/WORKING.md delete mode 100644 services/chat-svc/src/lib/agents/search/api.ts delete mode 100644 services/chat-svc/src/lib/agents/search/classifier.ts delete mode 100644 services/chat-svc/src/lib/agents/search/index.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/academicSearch.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/done.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/index.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/plan.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/registry.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/scrapeURL.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/socialSearch.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/uploadsSearch.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/actions/webSearch.ts delete mode 100644 services/chat-svc/src/lib/agents/search/researcher/index.ts delete mode 100644 services/chat-svc/src/lib/agents/search/types.ts delete mode 100644 services/chat-svc/src/lib/agents/search/widgets/calculationWidget.ts delete mode 100644 services/chat-svc/src/lib/agents/search/widgets/executor.ts delete mode 100644 services/chat-svc/src/lib/agents/search/widgets/index.ts delete mode 100644 services/chat-svc/src/lib/agents/search/widgets/stockWidget.ts delete mode 100644 services/chat-svc/src/lib/agents/search/widgets/weatherWidget.ts create mode 100644 services/chat-svc/src/lib/embedding-client.ts delete mode 100644 services/chat-svc/src/lib/library-client.ts delete mode 100644 services/chat-svc/src/lib/models/base/embedding.ts delete mode 100644 services/chat-svc/src/lib/models/base/llm.ts delete mode 100644 services/chat-svc/src/lib/models/base/provider.ts delete mode 100644 services/chat-svc/src/lib/models/providers/anthropic/anthropicLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/anthropic/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/gemini/geminiEmbedding.ts delete mode 100644 services/chat-svc/src/lib/models/providers/gemini/geminiLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/gemini/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/groq/groqLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/groq/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/lemonade/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/lemonade/lemonadeEmbedding.ts delete mode 100644 services/chat-svc/src/lib/models/providers/lemonade/lemonadeLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/lmstudio/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/lmstudio/lmstudioEmbedding.ts delete mode 100644 services/chat-svc/src/lib/models/providers/lmstudio/lmstudioLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/ollama/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/ollama/ollamaEmbedding.ts delete mode 100644 services/chat-svc/src/lib/models/providers/ollama/ollamaLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/openai/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/openai/openaiEmbedding.ts delete mode 100644 services/chat-svc/src/lib/models/providers/openai/openaiLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/timeweb/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/timeweb/timewebLLM.ts delete mode 100644 services/chat-svc/src/lib/models/providers/transformers/index.ts delete mode 100644 services/chat-svc/src/lib/models/providers/transformers/transformerEmbedding.ts delete mode 100644 services/chat-svc/src/lib/models/registry.ts delete mode 100644 services/chat-svc/src/lib/models/types.ts delete mode 100644 services/chat-svc/src/lib/prompts/locale.ts delete mode 100644 services/chat-svc/src/lib/prompts/search/researcher.ts delete mode 100644 services/chat-svc/src/lib/prompts/search/writer.ts delete mode 100644 services/chat-svc/src/lib/searxng.ts delete mode 100644 services/chat-svc/src/lib/session.ts delete mode 100644 services/chat-svc/src/lib/uploads/store.ts delete mode 100644 services/chat-svc/src/lib/utils/computeSimilarity.ts delete mode 100644 services/chat-svc/src/lib/utils/formatHistory.ts create mode 100644 services/geo-device-svc/Dockerfile create mode 100644 services/localization-svc/Dockerfile create mode 100644 services/master-agents-svc/Dockerfile create mode 100644 services/master-agents-svc/src/lib/actions/__reasoning_preamble.ts create mode 100644 services/master-agents-svc/src/lib/actions/academic_search.ts create mode 100644 services/master-agents-svc/src/lib/actions/done.ts create mode 100644 services/master-agents-svc/src/lib/actions/registry.ts create mode 100644 services/master-agents-svc/src/lib/actions/scrape_url.ts create mode 100644 services/master-agents-svc/src/lib/actions/social_search.ts create mode 100644 services/master-agents-svc/src/lib/actions/types.ts create mode 100644 services/master-agents-svc/src/lib/actions/web_search.ts create mode 100644 services/master-agents-svc/src/lib/agent/classifier.ts create mode 100644 services/master-agents-svc/src/lib/agent/researcher.ts create mode 100644 services/master-agents-svc/src/lib/agent/searchOrchestrator.ts create mode 100644 services/master-agents-svc/src/lib/embedding-client.ts create mode 100644 services/master-agents-svc/src/lib/llm-client.ts rename services/{chat-svc/src/lib/prompts/search => master-agents-svc/src/lib/prompts}/classifier.ts (72%) create mode 100644 services/master-agents-svc/src/lib/prompts/locale.ts create mode 100644 services/master-agents-svc/src/lib/prompts/researcher.ts create mode 100644 services/master-agents-svc/src/lib/prompts/writer.ts create mode 100644 services/master-agents-svc/src/lib/searxng.ts create mode 100644 services/master-agents-svc/src/lib/session.ts create mode 100644 services/master-agents-svc/src/lib/utils/formatHistory.ts create mode 100644 services/master-agents-svc/src/lib/widgets/calculationWidget.ts create mode 100644 services/master-agents-svc/src/lib/widgets/executor.ts create mode 100644 services/master-agents-svc/src/lib/widgets/index.ts create mode 100644 services/master-agents-svc/src/lib/widgets/stockWidget.ts create mode 100644 services/master-agents-svc/src/lib/widgets/types.ts create mode 100644 services/master-agents-svc/src/lib/widgets/weatherWidget.ts create mode 100644 services/master-agents-svc/src/turndown.d.ts delete mode 100644 services/web-svc/src/components/MessageInputActions/ChatModelSelector.tsx delete mode 100644 services/web-svc/src/components/MessageInputActions/Optimization.tsx delete mode 100644 services/web-svc/src/components/MessageInputActions/Sources.tsx create mode 100644 services/web-svc/src/components/UnregisterSW.tsx diff --git a/.cursor/rules/00-user-priority.mdc b/.cursor/rules/00-user-priority.mdc new file mode 100644 index 0000000..1e1c052 --- /dev/null +++ b/.cursor/rules/00-user-priority.mdc @@ -0,0 +1,12 @@ +--- +description: ПЕРВОЕ И ГЛАВНОЕ ПРАВИЛО — только доработка этого проекта +alwaysApply: true +--- + +# ПРАВИЛО №1 — ПРИОРИТЕТ ПРОЕКТА + +**ВСЕ СВОИ СРЕДСТВА ПЕРЕБРОСЬ НА ДОРАБОТКУ ТОЛЬКО ЭТОГО ПРОЕКТА.** + +**ВСЁ, ЧТО НЕ ДОРАБОТКА ЭТОГО ПРОЕКТА — ПРЕКРАЩАЙ ОБСЛУЖИВАТЬ.** + +Работать только над GooSeek. Никаких отвлечений. diff --git a/.cursor/rules/agents-guidelines.mdc b/.cursor/rules/agents-guidelines.mdc index 28ed31f..0697e20 100644 --- a/.cursor/rules/agents-guidelines.mdc +++ b/.cursor/rules/agents-guidelines.mdc @@ -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-агента diff --git a/CONTINUE.md b/CONTINUE.md index 17a657e..b1cc79a 100644 --- a/CONTINUE.md +++ b/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 diff --git a/deploy/docker/Caddyfile b/deploy/docker/Caddyfile new file mode 100644 index 0000000..d3b679b --- /dev/null +++ b/deploy/docker/Caddyfile @@ -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 +} diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..af28c76 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -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: diff --git a/deploy/docker/run.sh b/deploy/docker/run.sh new file mode 100755 index 0000000..1dd157f --- /dev/null +++ b/deploy/docker/run.sh @@ -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 diff --git a/deploy/docker/searxng/limiter.toml b/deploy/docker/searxng/limiter.toml new file mode 100644 index 0000000..7548fd8 --- /dev/null +++ b/deploy/docker/searxng/limiter.toml @@ -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', +] diff --git a/deploy/docker/searxng/settings.yml b/deploy/docker/searxng/settings.yml new file mode 100644 index 0000000..473932b --- /dev/null +++ b/deploy/docker/searxng/settings.yml @@ -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 diff --git a/deploy/k3s/api-gateway.yaml b/deploy/k3s/api-gateway.yaml deleted file mode 100644 index 1089a07..0000000 --- a/deploy/k3s/api-gateway.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/audit-svc.yaml b/deploy/k3s/audit-svc.yaml deleted file mode 100644 index 41f1052..0000000 --- a/deploy/k3s/audit-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/auth-svc.yaml b/deploy/k3s/auth-svc.yaml deleted file mode 100644 index c9688a0..0000000 --- a/deploy/k3s/auth-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/billing-svc.yaml b/deploy/k3s/billing-svc.yaml deleted file mode 100644 index cdd0abd..0000000 --- a/deploy/k3s/billing-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/cache-worker.yaml b/deploy/k3s/cache-worker.yaml deleted file mode 100644 index 7093353..0000000 --- a/deploy/k3s/cache-worker.yaml +++ /dev/null @@ -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" diff --git a/deploy/k3s/chat-svc.yaml b/deploy/k3s/chat-svc.yaml deleted file mode 100644 index 87862a6..0000000 --- a/deploy/k3s/chat-svc.yaml +++ /dev/null @@ -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" diff --git a/deploy/k3s/create-svc.yaml b/deploy/k3s/create-svc.yaml deleted file mode 100644 index 76478f5..0000000 --- a/deploy/k3s/create-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/deploy.config.yaml b/deploy/k3s/deploy.config.yaml deleted file mode 100644 index 4ed4361..0000000 --- a/deploy/k3s/deploy.config.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/deploy.sh b/deploy/k3s/deploy.sh deleted file mode 100755 index c901d68..0000000 --- a/deploy/k3s/deploy.sh +++ /dev/null @@ -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 diff --git a/deploy/k3s/discover-svc.yaml b/deploy/k3s/discover-svc.yaml deleted file mode 100644 index 58cca7d..0000000 --- a/deploy/k3s/discover-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/finance-svc.yaml b/deploy/k3s/finance-svc.yaml deleted file mode 100644 index 26adb7e..0000000 --- a/deploy/k3s/finance-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/hpa.yaml b/deploy/k3s/hpa.yaml deleted file mode 100644 index 23c7a7d..0000000 --- a/deploy/k3s/hpa.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/ingress-production-manual.yaml b/deploy/k3s/ingress-production-manual.yaml deleted file mode 100644 index 36ed78f..0000000 --- a/deploy/k3s/ingress-production-manual.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/ingress-production.yaml b/deploy/k3s/ingress-production.yaml deleted file mode 100644 index b931f48..0000000 --- a/deploy/k3s/ingress-production.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/ingress.yaml b/deploy/k3s/ingress.yaml deleted file mode 100644 index 281e4df..0000000 --- a/deploy/k3s/ingress.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/library-svc.yaml b/deploy/k3s/library-svc.yaml deleted file mode 100644 index 6c030d4..0000000 --- a/deploy/k3s/library-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/llm-svc.yaml b/deploy/k3s/llm-svc.yaml deleted file mode 100644 index d55f9db..0000000 --- a/deploy/k3s/llm-svc.yaml +++ /dev/null @@ -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" diff --git a/deploy/k3s/memory-svc.yaml b/deploy/k3s/memory-svc.yaml deleted file mode 100644 index 346e6d9..0000000 --- a/deploy/k3s/memory-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/namespace.yaml b/deploy/k3s/namespace.yaml deleted file mode 100644 index 242e95d..0000000 --- a/deploy/k3s/namespace.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/network-policies.yaml b/deploy/k3s/network-policies.yaml deleted file mode 100644 index 36e8fe0..0000000 --- a/deploy/k3s/network-policies.yaml +++ /dev/null @@ -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: {} \ No newline at end of file diff --git a/deploy/k3s/notifications-svc.yaml b/deploy/k3s/notifications-svc.yaml deleted file mode 100644 index 71252e7..0000000 --- a/deploy/k3s/notifications-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/profile-svc.yaml b/deploy/k3s/profile-svc.yaml deleted file mode 100644 index cb79dce..0000000 --- a/deploy/k3s/profile-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/projects-svc.yaml b/deploy/k3s/projects-svc.yaml deleted file mode 100644 index e920e12..0000000 --- a/deploy/k3s/projects-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/search-svc.yaml b/deploy/k3s/search-svc.yaml deleted file mode 100644 index 1b4683d..0000000 --- a/deploy/k3s/search-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/ssl/README.md b/deploy/k3s/ssl/README.md deleted file mode 100644 index 02807ae..0000000 --- a/deploy/k3s/ssl/README.md +++ /dev/null @@ -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 -``` diff --git a/deploy/k3s/ssl/apply-secret.sh b/deploy/k3s/ssl/apply-secret.sh deleted file mode 100755 index 24cbfa7..0000000 --- a/deploy/k3s/ssl/apply-secret.sh +++ /dev/null @@ -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" diff --git a/deploy/k3s/ssl/backup/.gitkeep b/deploy/k3s/ssl/backup/.gitkeep deleted file mode 100644 index 4b54b2e..0000000 --- a/deploy/k3s/ssl/backup/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Сюда класть fullchain.pem и privkey.pem (см. deploy/k3s/ssl/README.md) diff --git a/deploy/k3s/ssl/cert-manager-issuer.yaml b/deploy/k3s/ssl/cert-manager-issuer.yaml deleted file mode 100644 index bbfe16f..0000000 --- a/deploy/k3s/ssl/cert-manager-issuer.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/ssl/check-cert.sh b/deploy/k3s/ssl/check-cert.sh deleted file mode 100755 index 637aebd..0000000 --- a/deploy/k3s/ssl/check-cert.sh +++ /dev/null @@ -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 diff --git a/deploy/k3s/ssl/obtain-cert.sh b/deploy/k3s/ssl/obtain-cert.sh deleted file mode 100755 index 16be736..0000000 --- a/deploy/k3s/ssl/obtain-cert.sh +++ /dev/null @@ -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" diff --git a/deploy/k3s/ssl/setup-kubernetes.sh b/deploy/k3s/ssl/setup-kubernetes.sh deleted file mode 100755 index 75fc060..0000000 --- a/deploy/k3s/ssl/setup-kubernetes.sh +++ /dev/null @@ -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 diff --git a/deploy/k3s/travel-svc.yaml b/deploy/k3s/travel-svc.yaml deleted file mode 100644 index 03f5674..0000000 --- a/deploy/k3s/travel-svc.yaml +++ /dev/null @@ -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 diff --git a/deploy/k3s/web-svc.yaml b/deploy/k3s/web-svc.yaml deleted file mode 100644 index 226e8e0..0000000 --- a/deploy/k3s/web-svc.yaml +++ /dev/null @@ -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 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md deleted file mode 100644 index c0024e5..0000000 --- a/docs/RUNBOOK.md +++ /dev/null @@ -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/ -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 | diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md deleted file mode 100644 index 6964865..0000000 --- a/docs/TELEMETRY.md +++ /dev/null @@ -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`. diff --git a/docs/architecture/01-perplexity-analogue-design.md b/docs/architecture/01-perplexity-analogue-design.md index d5b72c1..0f47690 100644 --- a/docs/architecture/01-perplexity-analogue-design.md +++ b/docs/architecture/01-perplexity-analogue-design.md @@ -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) | Назначение | |--------|------|--------------------|------------| diff --git a/docs/architecture/02-k3s-microservices-spec.md b/docs/architecture/02-k3s-services-spec.md similarity index 99% rename from docs/architecture/02-k3s-microservices-spec.md rename to docs/architecture/02-k3s-services-spec.md index 455943a..0b02860 100644 --- a/docs/architecture/02-k3s-microservices-spec.md +++ b/docs/architecture/02-k3s-services-spec.md @@ -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: diff --git a/docs/architecture/05-gaps-and-best-practices.md b/docs/architecture/05-gaps-and-best-practices.md index dd62435..e0499f4 100644 --- a/docs/architecture/05-gaps-and-best-practices.md +++ b/docs/architecture/05-gaps-and-best-practices.md @@ -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) diff --git a/docs/architecture/06-roadmap-specification.md b/docs/architecture/06-roadmap-specification.md index f332ea8..37333e5 100644 --- a/docs/architecture/06-roadmap-specification.md +++ b/docs/architecture/06-roadmap-specification.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 diff --git a/docs/architecture/MIGRATION.md b/docs/architecture/MIGRATION.md index 8c974d1..963296e 100644 --- a/docs/architecture/MIGRATION.md +++ b/docs/architecture/MIGRATION.md @@ -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) --- diff --git a/docs/architecture/README.md b/docs/architecture/README.md index bd099bf..cdcacdc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.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 diff --git a/docs/architecture/WORKING.md b/docs/architecture/WORKING.md deleted file mode 100644 index ee570f5..0000000 --- a/docs/architecture/WORKING.md +++ /dev/null @@ -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. diff --git a/package-lock.json b/package-lock.json index 76f8231..451edf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/services/api-gateway/Dockerfile b/services/api-gateway/Dockerfile index 691b096..1654cf4 100644 --- a/services/api-gateway/Dockerfile +++ b/services/api-gateway/Dockerfile @@ -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 diff --git a/services/api-gateway/src/index.ts b/services/api-gateway/src/index.ts index 89d5ec8..ccfedd2 100644 --- a/services/api-gateway/src/index.ts +++ b/services/api-gateway/src/index.ts @@ -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 = {}; - 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()) diff --git a/services/auth-svc/Dockerfile b/services/auth-svc/Dockerfile index 6c2ae54..142ae1f 100644 --- a/services/auth-svc/Dockerfile +++ b/services/auth-svc/Dockerfile @@ -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 diff --git a/services/billing-svc/src/index.ts b/services/billing-svc/src/index.ts index 0f69643..dc2a494 100644 --- a/services/billing-svc/src/index.ts +++ b/services/billing-svc/src/index.ts @@ -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 */ diff --git a/services/chat-svc/Dockerfile b/services/chat-svc/Dockerfile index 9120e99..3f55ef1 100644 --- a/services/chat-svc/Dockerfile +++ b/services/chat-svc/Dockerfile @@ -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 diff --git a/services/chat-svc/package.json b/services/chat-svc/package.json index 78f2200..ea43000 100644 --- a/services/chat-svc/package.json +++ b/services/chat-svc/package.json @@ -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", diff --git a/services/chat-svc/src/index.ts b/services/chat-svc/src/index.ts index bb080f5..26b69d6 100644 --- a/services/chat-svc/src/index.ts +++ b/services/chat-svc/src/index.ts @@ -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; @@ -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>, Awaited>, Awaited>] | 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' }); diff --git a/services/chat-svc/src/lib/agents/search/api.ts b/services/chat-svc/src/lib/agents/search/api.ts deleted file mode 100644 index 30ef4c5..0000000 --- a/services/chat-svc/src/lib/agents/search/api.ts +++ /dev/null @@ -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 | 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) => - `${f.content}`, - ) - .join('\n') || ''; - - const widgetContext = widgetOutputs - .map((o) => { - return `${o.llmContext}`; - }) - .join('\n-------------\n'); - - const finalContextWithWidgets = `\n${finalContext}\n\n\n${widgetContext}\n`; - - 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; diff --git a/services/chat-svc/src/lib/agents/search/classifier.ts b/services/chat-svc/src/lib/agents/search/classifier.ts deleted file mode 100644 index 40081bb..0000000 --- a/services/chat-svc/src/lib/agents/search/classifier.ts +++ /dev/null @@ -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({ - messages: [ - { - role: 'system', - content: getClassifierPrompt(input.locale), - }, - { - role: 'user', - content: `\n${formatChatHistoryAsString(input.chatHistory)}\n\n\n${input.query}\n`, - }, - ], - schema, - }); - - return output; -}; diff --git a/services/chat-svc/src/lib/agents/search/index.ts b/services/chat-svc/src/lib/agents/search/index.ts deleted file mode 100644 index 7bc6b3b..0000000 --- a/services/chat-svc/src/lib/agents/search/index.ts +++ /dev/null @@ -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 | 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 `${content}`; - }) - .join('\n') || ''; - - const widgetContext = widgetOutputs - .map((o) => { - return `${o.llmContext}`; - }) - .join('\n-------------\n'); - - const finalContextWithWidgets = `\n${finalContext}\n\n\n${widgetContext}\n`; - - 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, BaseLLM, BaseLLM], - ) { - 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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/academicSearch.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/academicSearch.ts deleted file mode 100644 index 75be2f9..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/academicSearch.ts +++ /dev/null @@ -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 = { - 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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/done.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/done.ts deleted file mode 100644 index b491e0c..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/done.ts +++ /dev/null @@ -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 = { - 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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/index.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/index.ts deleted file mode 100644 index 64de538..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/index.ts +++ /dev/null @@ -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 }; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/plan.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/plan.ts deleted file mode 100644 index c9742f5..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/plan.ts +++ /dev/null @@ -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: - -- "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." - - -YOU CAN NEVER CALL ANY OTHER TOOL BEFORE CALLING THIS ONE FIRST, IF YOU DO, THAT CALL WOULD BE IGNORED. -`; - -const planAction: ResearchAction = { - 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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/registry.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/registry.ts deleted file mode 100644 index 805830f..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/registry.ts +++ /dev/null @@ -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 = new Map(); - - static register(action: ResearchAction) { - 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) => - `\n${action.getDescription({ mode: config.mode })}\n`, - ) - .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 { - 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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/scrapeURL.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/scrapeURL.ts deleted file mode 100644 index ea750b2..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/scrapeURL.ts +++ /dev/null @@ -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 = { - 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>/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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/socialSearch.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/socialSearch.ts deleted file mode 100644 index 03046b8..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/socialSearch.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/uploadsSearch.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/uploadsSearch.ts deleted file mode 100644 index 87b6bac..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/uploadsSearch.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/actions/webSearch.ts b/services/chat-svc/src/lib/agents/search/researcher/actions/webSearch.ts deleted file mode 100644 index 0ee23c9..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/actions/webSearch.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/researcher/index.ts b/services/chat-svc/src/lib/agents/search/researcher/index.ts deleted file mode 100644 index b71cf44..0000000 --- a/services/chat-svc/src/lib/agents/search/researcher/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/types.ts b/services/chat-svc/src/lib/agents/search/types.ts deleted file mode 100644 index eaae7c0..0000000 --- a/services/chat-svc/src/lib/agents/search/types.ts +++ /dev/null @@ -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>; -} diff --git a/services/chat-svc/src/lib/agents/search/widgets/calculationWidget.ts b/services/chat-svc/src/lib/agents/search/widgets/calculationWidget.ts deleted file mode 100644 index 459a9f1..0000000 --- a/services/chat-svc/src/lib/agents/search/widgets/calculationWidget.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/widgets/executor.ts b/services/chat-svc/src/lib/agents/search/widgets/executor.ts deleted file mode 100644 index c930396..0000000 --- a/services/chat-svc/src/lib/agents/search/widgets/executor.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/widgets/index.ts b/services/chat-svc/src/lib/agents/search/widgets/index.ts deleted file mode 100644 index b4f245d..0000000 --- a/services/chat-svc/src/lib/agents/search/widgets/index.ts +++ /dev/null @@ -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 }; diff --git a/services/chat-svc/src/lib/agents/search/widgets/stockWidget.ts b/services/chat-svc/src/lib/agents/search/widgets/stockWidget.ts deleted file mode 100644 index f02acbf..0000000 --- a/services/chat-svc/src/lib/agents/search/widgets/stockWidget.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/agents/search/widgets/weatherWidget.ts b/services/chat-svc/src/lib/agents/search/widgets/weatherWidget.ts deleted file mode 100644 index 75b22f2..0000000 --- a/services/chat-svc/src/lib/agents/search/widgets/weatherWidget.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/config/providersLoader.ts b/services/chat-svc/src/lib/config/providersLoader.ts index fc0cc80..141441d 100644 --- a/services/chat-svc/src/lib/config/providersLoader.ts +++ b/services/chat-svc/src/lib/config/providersLoader.ts @@ -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 []; } diff --git a/services/chat-svc/src/lib/config/types.ts b/services/chat-svc/src/lib/config/types.ts index ac31c5d..b80c6e3 100644 --- a/services/chat-svc/src/lib/config/types.ts +++ b/services/chat-svc/src/lib/config/types.ts @@ -1,4 +1,4 @@ -import { Model } from '../models/types.js'; +export type Model = { name: string; key: string }; type BaseUIConfigField = { name: string; diff --git a/services/chat-svc/src/lib/embedding-client.ts b/services/chat-svc/src/lib/embedding-client.ts new file mode 100644 index 0000000..fa4001e --- /dev/null +++ b/services/chat-svc/src/lib/embedding-client.ts @@ -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); + }, + }; +} diff --git a/services/chat-svc/src/lib/library-client.ts b/services/chat-svc/src/lib/library-client.ts deleted file mode 100644 index 92c38da..0000000 --- a/services/chat-svc/src/lib/library-client.ts +++ /dev/null @@ -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); - } -} diff --git a/services/chat-svc/src/lib/models/base/embedding.ts b/services/chat-svc/src/lib/models/base/embedding.ts deleted file mode 100644 index b923a97..0000000 --- a/services/chat-svc/src/lib/models/base/embedding.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/base/llm.ts b/services/chat-svc/src/lib/models/base/llm.ts deleted file mode 100644 index f586761..0000000 --- a/services/chat-svc/src/lib/models/base/llm.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/base/provider.ts b/services/chat-svc/src/lib/models/base/provider.ts deleted file mode 100644 index 83a9305..0000000 --- a/services/chat-svc/src/lib/models/base/provider.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/anthropic/anthropicLLM.ts b/services/chat-svc/src/lib/models/providers/anthropic/anthropicLLM.ts deleted file mode 100644 index c7d44c3..0000000 --- a/services/chat-svc/src/lib/models/providers/anthropic/anthropicLLM.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAILLM from '../openai/openaiLLM.js'; - -class AnthropicLLM extends OpenAILLM {} - -export default AnthropicLLM; diff --git a/services/chat-svc/src/lib/models/providers/anthropic/index.ts b/services/chat-svc/src/lib/models/providers/anthropic/index.ts deleted file mode 100644 index 3b6458f..0000000 --- a/services/chat-svc/src/lib/models/providers/anthropic/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/gemini/geminiEmbedding.ts b/services/chat-svc/src/lib/models/providers/gemini/geminiEmbedding.ts deleted file mode 100644 index 0aaa2dd..0000000 --- a/services/chat-svc/src/lib/models/providers/gemini/geminiEmbedding.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAIEmbedding from '../openai/openaiEmbedding.js'; - -class GeminiEmbedding extends OpenAIEmbedding {} - -export default GeminiEmbedding; diff --git a/services/chat-svc/src/lib/models/providers/gemini/geminiLLM.ts b/services/chat-svc/src/lib/models/providers/gemini/geminiLLM.ts deleted file mode 100644 index 4d43fa0..0000000 --- a/services/chat-svc/src/lib/models/providers/gemini/geminiLLM.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAILLM from '../openai/openaiLLM.js'; - -class GeminiLLM extends OpenAILLM {} - -export default GeminiLLM; diff --git a/services/chat-svc/src/lib/models/providers/gemini/index.ts b/services/chat-svc/src/lib/models/providers/gemini/index.ts deleted file mode 100644 index 81e665c..0000000 --- a/services/chat-svc/src/lib/models/providers/gemini/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/groq/groqLLM.ts b/services/chat-svc/src/lib/models/providers/groq/groqLLM.ts deleted file mode 100644 index 5fbec67..0000000 --- a/services/chat-svc/src/lib/models/providers/groq/groqLLM.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAILLM from '../openai/openaiLLM.js'; - -class GroqLLM extends OpenAILLM {} - -export default GroqLLM; diff --git a/services/chat-svc/src/lib/models/providers/groq/index.ts b/services/chat-svc/src/lib/models/providers/groq/index.ts deleted file mode 100644 index 48f0293..0000000 --- a/services/chat-svc/src/lib/models/providers/groq/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/index.ts b/services/chat-svc/src/lib/models/providers/index.ts deleted file mode 100644 index 31ee2ea..0000000 --- a/services/chat-svc/src/lib/models/providers/index.ts +++ /dev/null @@ -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, - }; - }); - }; diff --git a/services/chat-svc/src/lib/models/providers/lemonade/index.ts b/services/chat-svc/src/lib/models/providers/lemonade/index.ts deleted file mode 100644 index 116506f..0000000 --- a/services/chat-svc/src/lib/models/providers/lemonade/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/lemonade/lemonadeEmbedding.ts b/services/chat-svc/src/lib/models/providers/lemonade/lemonadeEmbedding.ts deleted file mode 100644 index b125f1d..0000000 --- a/services/chat-svc/src/lib/models/providers/lemonade/lemonadeEmbedding.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAIEmbedding from '../openai/openaiEmbedding.js'; - -class LemonadeEmbedding extends OpenAIEmbedding {} - -export default LemonadeEmbedding; diff --git a/services/chat-svc/src/lib/models/providers/lemonade/lemonadeLLM.ts b/services/chat-svc/src/lib/models/providers/lemonade/lemonadeLLM.ts deleted file mode 100644 index 9516564..0000000 --- a/services/chat-svc/src/lib/models/providers/lemonade/lemonadeLLM.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAILLM from '../openai/openaiLLM.js'; - -class LemonadeLLM extends OpenAILLM {} - -export default LemonadeLLM; diff --git a/services/chat-svc/src/lib/models/providers/lmstudio/index.ts b/services/chat-svc/src/lib/models/providers/lmstudio/index.ts deleted file mode 100644 index c786e77..0000000 --- a/services/chat-svc/src/lib/models/providers/lmstudio/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/lmstudio/lmstudioEmbedding.ts b/services/chat-svc/src/lib/models/providers/lmstudio/lmstudioEmbedding.ts deleted file mode 100644 index 0546afc..0000000 --- a/services/chat-svc/src/lib/models/providers/lmstudio/lmstudioEmbedding.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAIEmbedding from '../openai/openaiEmbedding.js'; - -class LMStudioEmbedding extends OpenAIEmbedding {} - -export default LMStudioEmbedding; diff --git a/services/chat-svc/src/lib/models/providers/lmstudio/lmstudioLLM.ts b/services/chat-svc/src/lib/models/providers/lmstudio/lmstudioLLM.ts deleted file mode 100644 index c4c607e..0000000 --- a/services/chat-svc/src/lib/models/providers/lmstudio/lmstudioLLM.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OpenAILLM from '../openai/openaiLLM.js'; - -class LMStudioLLM extends OpenAILLM {} - -export default LMStudioLLM; diff --git a/services/chat-svc/src/lib/models/providers/ollama/index.ts b/services/chat-svc/src/lib/models/providers/ollama/index.ts deleted file mode 100644 index ccc596d..0000000 --- a/services/chat-svc/src/lib/models/providers/ollama/index.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/ollama/ollamaEmbedding.ts b/services/chat-svc/src/lib/models/providers/ollama/ollamaEmbedding.ts deleted file mode 100644 index d55827a..0000000 --- a/services/chat-svc/src/lib/models/providers/ollama/ollamaEmbedding.ts +++ /dev/null @@ -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; diff --git a/services/chat-svc/src/lib/models/providers/ollama/ollamaLLM.ts b/services/chat-svc/src/lib/models/providers/ollama/ollamaLLM.ts deleted file mode 100644 index ef7688a..0000000 --- a/services/chat-svc/src/lib/models/providers/ollama/ollamaLLM.ts +++ /dev/null @@ -1,261 +0,0 @@ -import z from 'zod'; -import BaseLLM from '../../base/llm.js'; -import { - GenerateObjectInput, - GenerateOptions, - GenerateTextInput, - GenerateTextOutput, - StreamTextOutput, -} from '../../types.js'; -import { Ollama, Tool as OllamaTool, Message as OllamaMessage } from 'ollama'; -import { parse } from 'partial-json'; -import crypto from 'crypto'; -import { Message } from '../../../types.js'; -import { repairJson } from '@toolsycc/json-repair'; - -type OllamaConfig = { - baseURL: string; - model: string; - options?: GenerateOptions; -}; - -const reasoningModels = [ - 'gpt-oss', - 'deepseek-r1', - 'qwen3', - 'deepseek-v3.1', - 'magistral', - 'nemotron-3-nano', -]; - -class OllamaLLM extends BaseLLM<OllamaConfig> { - ollamaClient: Ollama; - - constructor(protected config: OllamaConfig) { - super(config); - - this.ollamaClient = new Ollama({ - host: this.config.baseURL || 'http://localhost:11434', - }); - } - - convertToOllamaMessages(messages: Message[]): OllamaMessage[] { - return messages.map((msg) => { - if (msg.role === 'tool') { - return { - role: 'tool', - tool_name: msg.name, - content: msg.content, - } as OllamaMessage; - } else if (msg.role === 'assistant') { - return { - role: 'assistant', - content: msg.content, - tool_calls: - msg.tool_calls?.map((tc, i) => ({ - function: { - index: i, - name: tc.name, - arguments: tc.arguments, - }, - })) || [], - }; - } - - return msg; - }); - } - - async generateText(input: GenerateTextInput): Promise<GenerateTextOutput> { - const ollamaTools: OllamaTool[] = []; - - input.tools?.forEach((tool) => { - ollamaTools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.schema).properties, - }, - }); - }); - - const res = await this.ollamaClient.chat({ - model: this.config.model, - messages: this.convertToOllamaMessages(input.messages), - tools: ollamaTools.length > 0 ? ollamaTools : undefined, - ...(reasoningModels.find((m) => this.config.model.includes(m)) - ? { think: false } - : {}), - options: { - top_p: input.options?.topP ?? this.config.options?.topP, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 0.7, - num_predict: input.options?.maxTokens ?? this.config.options?.maxTokens, - num_ctx: 32000, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? - this.config.options?.presencePenalty, - stop: - input.options?.stopSequences ?? this.config.options?.stopSequences, - }, - }); - - return { - content: res.message.content, - toolCalls: - res.message.tool_calls?.map((tc) => ({ - id: crypto.randomUUID(), - name: tc.function.name, - arguments: tc.function.arguments, - })) || [], - additionalInfo: { - reasoning: res.message.thinking, - }, - }; - } - - async *streamText( - input: GenerateTextInput, - ): AsyncGenerator<StreamTextOutput> { - const ollamaTools: OllamaTool[] = []; - - input.tools?.forEach((tool) => { - ollamaTools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.schema) as any, - }, - }); - }); - - const stream = await this.ollamaClient.chat({ - model: this.config.model, - messages: this.convertToOllamaMessages(input.messages), - stream: true, - ...(reasoningModels.find((m) => this.config.model.includes(m)) - ? { think: false } - : {}), - tools: ollamaTools.length > 0 ? ollamaTools : undefined, - options: { - top_p: input.options?.topP ?? this.config.options?.topP, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 0.7, - num_ctx: 32000, - num_predict: input.options?.maxTokens ?? this.config.options?.maxTokens, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? - this.config.options?.presencePenalty, - stop: - input.options?.stopSequences ?? this.config.options?.stopSequences, - }, - }); - - for await (const chunk of stream) { - yield { - contentChunk: chunk.message.content, - toolCallChunk: - chunk.message.tool_calls?.map((tc, i) => ({ - id: crypto - .createHash('sha256') - .update( - `${i}-${tc.function.name}`, - ) /* Ollama currently doesn't return a tool call ID so we're creating one based on the index and tool call name */ - .digest('hex'), - name: tc.function.name, - arguments: tc.function.arguments, - })) || [], - done: chunk.done, - additionalInfo: { - reasoning: chunk.message.thinking, - }, - }; - } - } - - async generateObject<T>(input: GenerateObjectInput): Promise<T> { - const response = await this.ollamaClient.chat({ - model: this.config.model, - messages: this.convertToOllamaMessages(input.messages), - format: z.toJSONSchema(input.schema), - ...(reasoningModels.find((m) => this.config.model.includes(m)) - ? { think: false } - : {}), - options: { - top_p: input.options?.topP ?? this.config.options?.topP, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 0.7, - num_predict: input.options?.maxTokens ?? this.config.options?.maxTokens, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? - this.config.options?.presencePenalty, - stop: - input.options?.stopSequences ?? this.config.options?.stopSequences, - }, - }); - - try { - return input.schema.parse( - JSON.parse( - repairJson(response.message.content, { - extractJson: true, - }) as string, - ), - ) as T; - } catch (err) { - throw new Error(`Error parsing response from Ollama: ${err}`); - } - } - - async *streamObject<T>(input: GenerateObjectInput): AsyncGenerator<T> { - let recievedObj: string = ''; - - const stream = await this.ollamaClient.chat({ - model: this.config.model, - messages: this.convertToOllamaMessages(input.messages), - format: z.toJSONSchema(input.schema), - stream: true, - ...(reasoningModels.find((m) => this.config.model.includes(m)) - ? { think: false } - : {}), - options: { - top_p: input.options?.topP ?? this.config.options?.topP, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 0.7, - num_predict: input.options?.maxTokens ?? this.config.options?.maxTokens, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? - this.config.options?.presencePenalty, - stop: - input.options?.stopSequences ?? this.config.options?.stopSequences, - }, - }); - - for await (const chunk of stream) { - recievedObj += chunk.message.content; - - try { - yield parse(recievedObj) as T; - } catch (err) { - console.log('Error parsing partial object from Ollama:', err); - yield {} as T; - } - } - } -} - -export default OllamaLLM; diff --git a/services/chat-svc/src/lib/models/providers/openai/index.ts b/services/chat-svc/src/lib/models/providers/openai/index.ts deleted file mode 100644 index d8fac65..0000000 --- a/services/chat-svc/src/lib/models/providers/openai/index.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { UIConfigField } from '../../../config/types.js'; -import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js'; -import { Model, ModelList, ProviderMetadata } from '../../types.js'; -import OpenAIEmbedding from './openaiEmbedding.js'; -import BaseEmbedding from '../../base/embedding.js'; -import BaseModelProvider from '../../base/provider.js'; -import BaseLLM from '../../base/llm.js'; -import OpenAILLM from './openaiLLM.js'; - -interface OpenAIConfig { - apiKey: string; - baseURL: string; -} - -const defaultChatModels: Model[] = [ - { - name: 'GPT-3.5 Turbo', - key: 'gpt-3.5-turbo', - }, - { - name: 'GPT-4', - key: 'gpt-4', - }, - { - name: 'GPT-4 turbo', - key: 'gpt-4-turbo', - }, - { - name: 'GPT-4 omni', - key: 'gpt-4o', - }, - { - name: 'GPT-4o (2024-05-13)', - key: 'gpt-4o-2024-05-13', - }, - { - name: 'GPT-4 omni mini', - key: 'gpt-4o-mini', - }, - { - name: 'GPT 4.1 nano', - key: 'gpt-4.1-nano', - }, - { - name: 'GPT 4.1 mini', - key: 'gpt-4.1-mini', - }, - { - name: 'GPT 4.1', - key: 'gpt-4.1', - }, - { - name: 'GPT 5 nano', - key: 'gpt-5-nano', - }, - { - name: 'GPT 5', - key: 'gpt-5', - }, - { - name: 'GPT 5 Mini', - key: 'gpt-5-mini', - }, - { - name: 'GPT 5 Pro', - key: 'gpt-5-pro', - }, - { - name: 'GPT 5.1', - key: 'gpt-5.1', - }, - { - name: 'GPT 5.2', - key: 'gpt-5.2', - }, - { - name: 'GPT 5.2 Pro', - key: 'gpt-5.2-pro', - }, - { - name: 'o1', - key: 'o1', - }, - { - name: 'o3', - key: 'o3', - }, - { - name: 'o3 Mini', - key: 'o3-mini', - }, - { - name: 'o4 Mini', - key: 'o4-mini', - }, -]; - -const defaultEmbeddingModels: Model[] = [ - { - name: 'Text Embedding 3 Small', - key: 'text-embedding-3-small', - }, - { - name: 'Text Embedding 3 Large', - key: 'text-embedding-3-large', - }, -]; - -const providerConfigFields: UIConfigField[] = [ - { - type: 'password', - name: 'API Key', - key: 'apiKey', - description: 'Your OpenAI API key', - required: true, - placeholder: 'OpenAI API Key', - env: 'OPENAI_API_KEY', - scope: 'server', - }, - { - type: 'string', - name: 'Base URL', - key: 'baseURL', - description: 'The base URL for the OpenAI API', - required: true, - placeholder: 'OpenAI Base URL', - default: 'https://api.openai.com/v1', - env: 'OPENAI_BASE_URL', - scope: 'server', - }, -]; - -class OpenAIProvider extends BaseModelProvider<OpenAIConfig> { - constructor(id: string, name: string, config: OpenAIConfig) { - super(id, name, config); - } - - async getDefaultModels(): Promise<ModelList> { - if (this.config.baseURL === 'https://api.openai.com/v1') { - return { - embedding: defaultEmbeddingModels, - chat: defaultChatModels, - }; - } - - return { - embedding: [], - chat: [], - }; - } - - 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 OpenAI Chat Model. Invalid Model Selected', - ); - } - - return new OpenAILLM({ - apiKey: this.config.apiKey, - 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 OpenAI Embedding Model. Invalid Model Selected.', - ); - } - - return new OpenAIEmbedding({ - apiKey: this.config.apiKey, - model: key, - baseURL: this.config.baseURL, - }); - } - - static parseAndValidate(raw: any): OpenAIConfig { - if (!raw || typeof raw !== 'object') - throw new Error('Invalid config provided. Expected object'); - if (!raw.apiKey || !raw.baseURL) - throw new Error( - 'Invalid config provided. API key and base URL must be provided', - ); - - return { - apiKey: String(raw.apiKey), - baseURL: String(raw.baseURL), - }; - } - - static getProviderConfigFields(): UIConfigField[] { - return providerConfigFields; - } - - static getProviderMetadata(): ProviderMetadata { - return { - key: 'openai', - name: 'OpenAI', - }; - } -} - -export default OpenAIProvider; diff --git a/services/chat-svc/src/lib/models/providers/openai/openaiEmbedding.ts b/services/chat-svc/src/lib/models/providers/openai/openaiEmbedding.ts deleted file mode 100644 index af2f943..0000000 --- a/services/chat-svc/src/lib/models/providers/openai/openaiEmbedding.ts +++ /dev/null @@ -1,42 +0,0 @@ -import OpenAI from 'openai'; -import BaseEmbedding from '../../base/embedding.js'; -import { Chunk } from '../../../types.js'; - -type OpenAIConfig = { - apiKey: string; - model: string; - baseURL?: string; -}; - -class OpenAIEmbedding extends BaseEmbedding<OpenAIConfig> { - openAIClient: OpenAI; - - constructor(protected config: OpenAIConfig) { - super(config); - - this.openAIClient = new OpenAI({ - apiKey: config.apiKey, - baseURL: config.baseURL, - }); - } - - async embedText(texts: string[]): Promise<number[][]> { - const response = await this.openAIClient.embeddings.create({ - model: this.config.model, - input: texts, - }); - - return response.data.map((embedding) => embedding.embedding); - } - - async embedChunks(chunks: Chunk[]): Promise<number[][]> { - const response = await this.openAIClient.embeddings.create({ - model: this.config.model, - input: chunks.map((c) => c.content), - }); - - return response.data.map((embedding) => embedding.embedding); - } -} - -export default OpenAIEmbedding; diff --git a/services/chat-svc/src/lib/models/providers/openai/openaiLLM.ts b/services/chat-svc/src/lib/models/providers/openai/openaiLLM.ts deleted file mode 100644 index fa6fa08..0000000 --- a/services/chat-svc/src/lib/models/providers/openai/openaiLLM.ts +++ /dev/null @@ -1,275 +0,0 @@ -import OpenAI from 'openai'; -import BaseLLM from '../../base/llm.js'; -import { zodTextFormat, zodResponseFormat } from 'openai/helpers/zod'; -import { - GenerateObjectInput, - GenerateOptions, - GenerateTextInput, - GenerateTextOutput, - StreamTextOutput, - ToolCall, -} from '../../types.js'; -import { parse } from 'partial-json'; -import z from 'zod'; -import { - ChatCompletionAssistantMessageParam, - ChatCompletionMessageParam, - ChatCompletionTool, - ChatCompletionToolMessageParam, -} from 'openai/resources/index.mjs'; -import { Message } from '../../../types.js'; -import { repairJson } from '@toolsycc/json-repair'; - -type OpenAIConfig = { - apiKey: string; - model: string; - baseURL?: string; - options?: GenerateOptions; -}; - -class OpenAILLM extends BaseLLM<OpenAIConfig> { - openAIClient: OpenAI; - - constructor(protected config: OpenAIConfig) { - super(config); - - this.openAIClient = new OpenAI({ - apiKey: this.config.apiKey, - baseURL: this.config.baseURL || 'https://api.openai.com/v1', - }); - } - - convertToOpenAIMessages(messages: Message[]): ChatCompletionMessageParam[] { - return messages.map((msg) => { - if (msg.role === 'tool') { - return { - role: 'tool', - tool_call_id: msg.id, - content: msg.content, - } as ChatCompletionToolMessageParam; - } else if (msg.role === 'assistant') { - return { - role: 'assistant', - content: msg.content, - ...(msg.tool_calls && - msg.tool_calls.length > 0 && { - tool_calls: msg.tool_calls?.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.name, - arguments: JSON.stringify(tc.arguments), - }, - })), - }), - } as ChatCompletionAssistantMessageParam; - } - - return msg; - }); - } - - async generateText(input: GenerateTextInput): Promise<GenerateTextOutput> { - const openaiTools: ChatCompletionTool[] = []; - - input.tools?.forEach((tool) => { - openaiTools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.schema), - }, - }); - }); - - const response = await this.openAIClient.chat.completions.create({ - model: this.config.model, - tools: openaiTools.length > 0 ? openaiTools : undefined, - messages: this.convertToOpenAIMessages(input.messages), - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - }); - - if (response.choices && response.choices.length > 0) { - return { - content: response.choices[0].message.content!, - toolCalls: - response.choices[0].message.tool_calls - ?.map((tc) => { - if (tc.type === 'function') { - return { - name: tc.function.name, - id: tc.id, - arguments: JSON.parse(tc.function.arguments), - }; - } - }) - .filter((tc) => tc !== undefined) || [], - additionalInfo: { - finishReason: response.choices[0].finish_reason, - }, - }; - } - - throw new Error('No response from OpenAI'); - } - - async *streamText( - input: GenerateTextInput, - ): AsyncGenerator<StreamTextOutput> { - const openaiTools: ChatCompletionTool[] = []; - - input.tools?.forEach((tool) => { - openaiTools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.schema), - }, - }); - }); - - const stream = await this.openAIClient.chat.completions.create({ - model: this.config.model, - messages: this.convertToOpenAIMessages(input.messages), - tools: openaiTools.length > 0 ? openaiTools : undefined, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - stream: true, - }); - - let recievedToolCalls: { name: string; id: string; arguments: string }[] = - []; - - for await (const chunk of stream) { - if (chunk.choices && chunk.choices.length > 0) { - const toolCalls = chunk.choices[0].delta.tool_calls; - yield { - contentChunk: chunk.choices[0].delta.content || '', - toolCallChunk: - toolCalls?.map((tc) => { - if (!recievedToolCalls[tc.index]) { - const call = { - name: tc.function?.name!, - id: tc.id!, - arguments: tc.function?.arguments || '', - }; - recievedToolCalls.push(call); - return { ...call, arguments: parse(call.arguments || '{}') }; - } else { - const existingCall = recievedToolCalls[tc.index]; - existingCall.arguments += tc.function?.arguments || ''; - return { - ...existingCall, - arguments: parse(existingCall.arguments), - }; - } - }) || [], - done: chunk.choices[0].finish_reason !== null, - additionalInfo: { - finishReason: chunk.choices[0].finish_reason, - }, - }; - } - } - } - - async generateObject<T>(input: GenerateObjectInput): Promise<T> { - const response = await this.openAIClient.chat.completions.parse({ - messages: this.convertToOpenAIMessages(input.messages), - model: this.config.model, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - response_format: zodResponseFormat(input.schema, 'object'), - }); - - if (response.choices && response.choices.length > 0) { - try { - return input.schema.parse( - JSON.parse( - repairJson(response.choices[0].message.content!, { - extractJson: true, - }) as string, - ), - ) as T; - } catch (err) { - throw new Error(`Error parsing response from OpenAI: ${err}`); - } - } - - throw new Error('No response from OpenAI'); - } - - async *streamObject<T>(input: GenerateObjectInput): AsyncGenerator<T> { - let recievedObj: string = ''; - - const stream = this.openAIClient.responses.stream({ - model: this.config.model, - input: input.messages, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - text: { - format: zodTextFormat(input.schema, 'object'), - }, - }); - - for await (const chunk of stream) { - if (chunk.type === 'response.output_text.delta' && chunk.delta) { - recievedObj += chunk.delta; - - try { - yield parse(recievedObj) as T; - } catch (err) { - console.log('Error parsing partial object from OpenAI:', err); - yield {} as T; - } - } else if (chunk.type === 'response.output_text.done' && chunk.text) { - try { - yield parse(chunk.text) as T; - } catch (err) { - throw new Error(`Error parsing response from OpenAI: ${err}`); - } - } - } - } -} - -export default OpenAILLM; diff --git a/services/chat-svc/src/lib/models/providers/timeweb/index.ts b/services/chat-svc/src/lib/models/providers/timeweb/index.ts deleted file mode 100644 index f8280d5..0000000 --- a/services/chat-svc/src/lib/models/providers/timeweb/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { UIConfigField } from '../../../config/types.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 TimewebLLM from './timewebLLM.js'; - -interface TimewebConfig { - baseURL: string; - agentAccessId: string; - apiKey: string; - model: string; - xProxySource?: string; -} - -const providerConfigFields: UIConfigField[] = [ - { - type: 'string', - name: 'API Base URL', - key: 'baseURL', - description: 'Timeweb Cloud AI API base URL', - required: true, - placeholder: 'https://api.timeweb.cloud', - env: 'TIMEWEB_API_BASE_URL', - scope: 'server', - }, - { - type: 'string', - name: 'Agent Access ID', - key: 'agentAccessId', - description: 'Agent access ID from Timeweb Cloud AI', - required: true, - placeholder: '', - env: 'TIMEWEB_AGENT_ACCESS_ID', - scope: 'server', - }, - { - type: 'password', - name: 'API Key', - key: 'apiKey', - description: 'Bearer token for Timeweb Cloud AI', - required: true, - placeholder: '', - env: 'TIMEWEB_API_KEY', - scope: 'server', - }, - { - type: 'string', - name: 'Model', - key: 'model', - description: 'Model key (e.g. gpt-4)', - required: true, - placeholder: 'gpt-4', - env: 'LLM_CHAT_MODEL', - scope: 'server', - }, - { - type: 'string', - name: 'X-Proxy-Source', - key: 'xProxySource', - description: 'Optional header for Timeweb API', - required: false, - placeholder: '', - env: 'TIMEWEB_X_PROXY_SOURCE', - scope: 'server', - }, -]; - -class TimewebProvider extends BaseModelProvider<TimewebConfig> { - constructor(id: string, name: string, config: TimewebConfig) { - super(id, name, config); - } - - getTimewebBaseURL(): string { - const base = this.config.baseURL.replace(/\/$/, ''); - return `${base}/api/v1/cloud-ai/agents/${this.config.agentAccessId}/v1`; - } - - async getDefaultModels(): Promise<ModelList> { - try { - const url = `${this.getTimewebBaseURL()}/models`; - const res = await fetch(url, { - headers: { - Authorization: `Bearer ${this.config.apiKey}`, - 'Content-Type': 'application/json', - ...(this.config.xProxySource && { - 'x-proxy-source': this.config.xProxySource, - }), - }, - }); - - if (!res.ok) { - throw new Error(`Timeweb API error: ${res.status} ${res.statusText}`); - } - - const data = (await res.json()) as { data?: { id: string }[] }; - const models: Model[] = (data.data ?? []).map((m) => ({ - name: m.id, - key: m.id, - })); - - // Если API вернул пустой список — используем модель из конфигурации - const chat = - models.length > 0 - ? models - : [{ name: this.config.model, key: this.config.model }]; - - return { - chat, - embedding: [], - }; - } catch (err) { - return { - chat: [{ name: this.config.model, key: this.config.model }], - embedding: [], - }; - } - } - - async getModelList(): Promise<ModelList> { - return this.getDefaultModels(); - } - - async loadChatModel(key: string): Promise<BaseLLM<unknown>> { - return new TimewebLLM({ - apiKey: this.config.apiKey, - baseURL: this.getTimewebBaseURL(), - model: key, - defaultHeaders: this.config.xProxySource - ? { 'x-proxy-source': this.config.xProxySource } - : undefined, - }); - } - - async loadEmbeddingModel(_key: string): Promise<BaseEmbedding<unknown>> { - throw new Error( - 'Timeweb Cloud AI does not provide embedding models. Use Ollama for embeddings.', - ); - } - - static parseAndValidate(raw: unknown): TimewebConfig { - if (!raw || typeof raw !== 'object') - throw new Error('Invalid config provided. Expected object'); - - const obj = raw as Record<string, unknown>; - if (!obj.baseURL || !obj.agentAccessId || !obj.apiKey) - throw new Error( - 'Invalid config. baseURL, agentAccessId and apiKey are required', - ); - - return { - baseURL: String(obj.baseURL), - agentAccessId: String(obj.agentAccessId), - apiKey: String(obj.apiKey), - model: String(obj.model || 'gpt-4'), - xProxySource: obj.xProxySource ? String(obj.xProxySource) : undefined, - }; - } - - static getProviderConfigFields(): UIConfigField[] { - return providerConfigFields; - } - - static getProviderMetadata(): ProviderMetadata { - return { - key: 'timeweb', - name: 'Timeweb Cloud AI', - }; - } -} - -export default TimewebProvider; diff --git a/services/chat-svc/src/lib/models/providers/timeweb/timewebLLM.ts b/services/chat-svc/src/lib/models/providers/timeweb/timewebLLM.ts deleted file mode 100644 index 726aa6a..0000000 --- a/services/chat-svc/src/lib/models/providers/timeweb/timewebLLM.ts +++ /dev/null @@ -1,265 +0,0 @@ -import OpenAI from 'openai'; -import BaseLLM from '../../base/llm.js'; -import { zodTextFormat, zodResponseFormat } from 'openai/helpers/zod'; -import { - GenerateObjectInput, - GenerateOptions, - GenerateTextInput, - GenerateTextOutput, - StreamTextOutput, -} from '../../types.js'; -import { parse } from 'partial-json'; -import z from 'zod'; -import { - ChatCompletionAssistantMessageParam, - ChatCompletionMessageParam, - ChatCompletionTool, - ChatCompletionToolMessageParam, -} from 'openai/resources/index.mjs'; -import { Message } from '../../../types.js'; -import { repairJson } from '@toolsycc/json-repair'; - -type TimewebConfig = { - apiKey: string; - baseURL: string; - model: string; - options?: GenerateOptions; - defaultHeaders?: Record<string, string>; -}; - -class TimewebLLM extends BaseLLM<TimewebConfig> { - openAIClient: OpenAI; - - constructor(protected config: TimewebConfig) { - super(config); - - this.openAIClient = new OpenAI({ - apiKey: this.config.apiKey, - baseURL: this.config.baseURL, - defaultHeaders: this.config.defaultHeaders, - }); - } - - convertToOpenAIMessages(messages: Message[]): ChatCompletionMessageParam[] { - return messages.map((msg) => { - if (msg.role === 'tool') { - return { - role: 'tool', - tool_call_id: msg.id, - content: msg.content, - } as ChatCompletionToolMessageParam; - } else if (msg.role === 'assistant') { - return { - role: 'assistant', - content: msg.content, - ...(msg.tool_calls && - msg.tool_calls.length > 0 && { - tool_calls: msg.tool_calls?.map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: JSON.stringify(tc.arguments), - }, - })), - }), - } as ChatCompletionAssistantMessageParam; - } - - return msg; - }); - } - - async generateText(input: GenerateTextInput): Promise<GenerateTextOutput> { - const openaiTools: ChatCompletionTool[] = []; - - input.tools?.forEach((tool) => { - openaiTools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.schema), - }, - }); - }); - - const response = await this.openAIClient.chat.completions.create({ - model: this.config.model, - tools: openaiTools.length > 0 ? openaiTools : undefined, - messages: this.convertToOpenAIMessages(input.messages), - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - }); - - if (response.choices && response.choices.length > 0) { - return { - content: response.choices[0].message.content ?? '', - toolCalls: - response.choices[0].message.tool_calls - ?.map((tc) => { - if (tc.type === 'function') { - return { - name: tc.function.name, - id: tc.id!, - arguments: JSON.parse(tc.function.arguments ?? '{}'), - }; - } - return undefined; - }) - .filter((tc): tc is NonNullable<typeof tc> => tc !== undefined) ?? [], - additionalInfo: { - finishReason: response.choices[0].finish_reason ?? undefined, - }, - }; - } - - throw new Error('No response from Timeweb'); - } - - async *streamText( - input: GenerateTextInput, - ): AsyncGenerator<StreamTextOutput> { - const openaiTools: ChatCompletionTool[] = []; - - input.tools?.forEach((tool) => { - openaiTools.push({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.schema), - }, - }); - }); - - let stream; - try { - stream = await this.openAIClient.chat.completions.create({ - model: this.config.model, - messages: this.convertToOpenAIMessages(input.messages), - tools: openaiTools.length > 0 ? openaiTools : undefined, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - stream: true, - }); - } catch (err: unknown) { - const e = err as { - status?: number; - error?: { message?: string; code?: string }; - response?: { status?: number; body?: unknown }; - }; - const details = [ - e?.status != null && `status=${e.status}`, - e?.error?.message && `error=${e.error.message}`, - e?.error?.code && `code=${e.error.code}`, - e?.response?.body != null && - `body=${JSON.stringify(e.response.body).slice(0, 300)}`, - ] - .filter(Boolean) - .join(', '); - console.error( - `[Timeweb] streamText failed: ${details || String(err)}`, - err, - ); - throw err; - } - - const receivedToolCalls: { name: string; id: string; arguments: string }[] = - []; - - for await (const chunk of stream) { - if (chunk.choices && chunk.choices.length > 0) { - const toolCalls = chunk.choices[0].delta.tool_calls; - yield { - contentChunk: chunk.choices[0].delta.content ?? '', - toolCallChunk: - toolCalls?.map((tc) => { - if (!receivedToolCalls[tc.index!]) { - const call = { - name: tc.function?.name ?? '', - id: tc.id ?? '', - arguments: tc.function?.arguments ?? '', - }; - receivedToolCalls[tc.index!] = call; - return { ...call, arguments: parse(call.arguments || '{}') }; - } else { - const existingCall = receivedToolCalls[tc.index!]; - existingCall.arguments += tc.function?.arguments ?? ''; - return { - ...existingCall, - arguments: parse(existingCall.arguments), - }; - } - }) || [], - done: chunk.choices[0].finish_reason !== null, - additionalInfo: { - finishReason: chunk.choices[0].finish_reason ?? undefined, - }, - }; - } - } - } - - async generateObject<T>(input: GenerateObjectInput): Promise<T> { - const response = await this.openAIClient.chat.completions.create({ - messages: this.convertToOpenAIMessages(input.messages), - model: this.config.model, - temperature: - input.options?.temperature ?? this.config.options?.temperature ?? 1.0, - top_p: input.options?.topP ?? this.config.options?.topP, - max_completion_tokens: - input.options?.maxTokens ?? this.config.options?.maxTokens, - stop: input.options?.stopSequences ?? this.config.options?.stopSequences, - frequency_penalty: - input.options?.frequencyPenalty ?? - this.config.options?.frequencyPenalty, - presence_penalty: - input.options?.presencePenalty ?? this.config.options?.presencePenalty, - response_format: zodResponseFormat(input.schema, 'object'), - }); - - if (response.choices && response.choices.length > 0) { - try { - return input.schema.parse( - JSON.parse( - repairJson(response.choices[0].message.content ?? '{}', { - extractJson: true, - }) as string, - ), - ) as T; - } catch (err) { - throw new Error(`Error parsing response from Timeweb: ${err}`); - } - } - - throw new Error('No response from Timeweb'); - } - - async *streamObject<T>( - input: GenerateObjectInput, - ): AsyncGenerator<Partial<T>> { - const result = await this.generateObject<T>(input); - yield result; - } -} - -export default TimewebLLM; diff --git a/services/chat-svc/src/lib/models/providers/transformers/index.ts b/services/chat-svc/src/lib/models/providers/transformers/index.ts deleted file mode 100644 index c78491f..0000000 --- a/services/chat-svc/src/lib/models/providers/transformers/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { UIConfigField } from '../../../config/types.js'; -import { getConfiguredModelProviderById } from '../../../config/serverRegistry.js'; -import { Model, ModelList, ProviderMetadata } from '../../types.js'; -import BaseModelProvider from '../../base/provider.js'; -import BaseLLM from '../../base/llm.js'; -import BaseEmbedding from '../../base/embedding.js'; -import TransformerEmbedding from './transformerEmbedding.js'; - -interface TransformersConfig {} - -const defaultEmbeddingModels: Model[] = [ - { - name: 'all-MiniLM-L6-v2', - key: 'Xenova/all-MiniLM-L6-v2', - }, - { - name: 'mxbai-embed-large-v1', - key: 'mixedbread-ai/mxbai-embed-large-v1', - }, - { - name: 'nomic-embed-text-v1', - key: 'Xenova/nomic-embed-text-v1', - }, -]; - -const providerConfigFields: UIConfigField[] = []; - -class TransformersProvider extends BaseModelProvider<TransformersConfig> { - constructor(id: string, name: string, config: TransformersConfig) { - super(id, name, config); - } - - async getDefaultModels(): Promise<ModelList> { - return { - embedding: [...defaultEmbeddingModels], - chat: [], - }; - } - - async getModelList(): Promise<ModelList> { - const defaultModels = await this.getDefaultModels(); - const configProvider = getConfiguredModelProviderById(this.id)!; - - return { - embedding: [ - ...defaultModels.embedding, - ...configProvider.embeddingModels, - ], - chat: [], - }; - } - - async loadChatModel(key: string): Promise<BaseLLM<any>> { - throw new Error('Transformers Provider does not support chat models.'); - } - - 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 OpenAI Embedding Model. Invalid Model Selected.', - ); - } - - return new TransformerEmbedding({ - model: key, - }); - } - - static parseAndValidate(raw: any): TransformersConfig { - return {}; - } - - static getProviderConfigFields(): UIConfigField[] { - return providerConfigFields; - } - - static getProviderMetadata(): ProviderMetadata { - return { - key: 'transformers', - name: 'Transformers', - }; - } -} - -export default TransformersProvider; diff --git a/services/chat-svc/src/lib/models/providers/transformers/transformerEmbedding.ts b/services/chat-svc/src/lib/models/providers/transformers/transformerEmbedding.ts deleted file mode 100644 index 7a395ad..0000000 --- a/services/chat-svc/src/lib/models/providers/transformers/transformerEmbedding.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Chunk } from '../../../types.js'; -import BaseEmbedding from '../../base/embedding.js'; -import type { FeatureExtractionPipeline } from '@huggingface/transformers'; - -type TransformerConfig = { - model: string; -}; - -class TransformerEmbedding extends BaseEmbedding<TransformerConfig> { - private pipelinePromise: Promise<FeatureExtractionPipeline> | null = null; - - constructor(protected config: TransformerConfig) { - super(config); - } - - async embedText(texts: string[]): Promise<number[][]> { - return this.embed(texts); - } - - async embedChunks(chunks: Chunk[]): Promise<number[][]> { - return this.embed(chunks.map((c) => c.content)); - } - - private async embed(texts: string[]) { - if (!this.pipelinePromise) { - this.pipelinePromise = (async () => { - const { pipeline } = await import('@huggingface/transformers'); - const result = await pipeline('feature-extraction', this.config.model, { - dtype: 'fp32', - }); - return result as FeatureExtractionPipeline; - })(); - } - - const pipe = await this.pipelinePromise; - const output = await pipe(texts, { pooling: 'mean', normalize: true }); - return output.tolist() as number[][]; - } -} - -export default TransformerEmbedding; diff --git a/services/chat-svc/src/lib/models/registry.ts b/services/chat-svc/src/lib/models/registry.ts deleted file mode 100644 index f49d9f3..0000000 --- a/services/chat-svc/src/lib/models/registry.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { ConfigModelProvider } from '../config/types.js'; -import BaseModelProvider, { createProviderInstance } from './base/provider.js'; -import { getConfiguredModelProviders } from '../config/serverRegistry.js'; -import { providers } from './providers/index.js'; -import { MinimalProvider, ModelList } from './types.js'; -import configManager from '../config/index.js'; - -const LLM_SVC_URL = process.env.LLM_SVC_URL ?? ''; - -class ModelRegistry { - activeProviders: (ConfigModelProvider & { - provider: BaseModelProvider<any>; - })[] = []; - - private llmSvcInitialized = false; - - constructor() { - if (!LLM_SVC_URL) { - this.initializeActiveProviders(); - } - } - - async initFromLlmSvc(): Promise<void> { - if (this.llmSvcInitialized || this.activeProviders.length > 0) return; - const base = LLM_SVC_URL.replace(/\/$/, ''); - const res = await fetch(`${base}/api/v1/providers?internal=1`, { - signal: AbortSignal.timeout(10000), - }); - if (!res.ok) throw new Error(`llm-svc providers fetch failed: ${res.status}`); - const data = (await res.json()) as { providers: ConfigModelProvider[] }; - const configuredProviders = data.providers ?? []; - configuredProviders.forEach((p) => { - try { - const providerCtor = providers[p.type]; - if (!providerCtor) throw new Error(`Invalid provider type: ${p.type}`); - this.activeProviders.push({ - ...p, - provider: createProviderInstance(providerCtor, p.id, p.name, p.config), - }); - } catch (err) { - console.error( - `Failed to initialize provider from llm-svc. Type: ${p.type}, ID: ${p.id}, Error: ${err}`, - ); - } - }); - this.llmSvcInitialized = true; - } - - private initializeActiveProviders(): void { - const configuredProviders = getConfiguredModelProviders(); - - configuredProviders.forEach((p) => { - try { - const provider = providers[p.type]; - if (!provider) throw new Error('Invalid provider type'); - - this.activeProviders.push({ - ...p, - provider: createProviderInstance(provider, p.id, p.name, p.config), - }); - } catch (err) { - console.error( - `Failed to initialize provider. Type: ${p.type}, ID: ${p.id}, Config: ${JSON.stringify(p.config)}, Error: ${err}`, - ); - } - }); - } - - async getActiveProviders(): Promise<MinimalProvider[]> { - if (LLM_SVC_URL) await this.initFromLlmSvc(); - const providers: MinimalProvider[] = []; - - await Promise.all( - this.activeProviders.map(async (p) => { - let m: ModelList = { chat: [], embedding: [] }; - - try { - m = await p.provider.getModelList(); - } catch (err: any) { - console.error( - `Failed to get model list. Type: ${p.type}, ID: ${p.id}, Error: ${err.message}`, - ); - - m = { - chat: [ - { - key: 'error', - name: err.message, - }, - ], - embedding: [], - }; - } - - providers.push({ - id: p.id, - name: p.name, - chatModels: m.chat, - embeddingModels: m.embedding, - }); - }), - ); - - return providers; - } - - async loadChatModel(providerId: string, modelName: string) { - if (LLM_SVC_URL) await this.initFromLlmSvc(); - const provider = this.activeProviders.find((p) => p.id === providerId); - - if (!provider) throw new Error('Invalid provider id'); - - const model = await provider.provider.loadChatModel(modelName); - - return model; - } - - async loadEmbeddingModel(providerId: string, modelName: string) { - if (LLM_SVC_URL) await this.initFromLlmSvc(); - const provider = this.activeProviders.find((p) => p.id === providerId); - - if (!provider) throw new Error('Invalid provider id'); - - const model = await provider.provider.loadEmbeddingModel(modelName); - - return model; - } - - async addProvider( - type: string, - name: string, - config: Record<string, any>, - ): Promise<ConfigModelProvider> { - const provider = providers[type]; - if (!provider) throw new Error('Invalid provider type'); - - const newProvider = configManager.addModelProvider(type, name, config); - - const instance = createProviderInstance( - provider, - newProvider.id, - newProvider.name, - newProvider.config, - ); - - let m: ModelList = { chat: [], embedding: [] }; - - try { - m = await instance.getModelList(); - } catch (err: any) { - console.error( - `Failed to get model list for newly added provider. Type: ${type}, ID: ${newProvider.id}, Error: ${err.message}`, - ); - - m = { - chat: [ - { - key: 'error', - name: err.message, - }, - ], - embedding: [], - }; - } - - this.activeProviders.push({ - ...newProvider, - provider: instance, - }); - - return { - ...newProvider, - chatModels: m.chat || [], - embeddingModels: m.embedding || [], - }; - } - - async removeProvider(providerId: string): Promise<void> { - configManager.removeModelProvider(providerId); - this.activeProviders = this.activeProviders.filter( - (p) => p.id !== providerId, - ); - - return; - } - - async updateProvider( - providerId: string, - name: string, - config: any, - ): Promise<ConfigModelProvider> { - const updated = await configManager.updateModelProvider( - providerId, - name, - config, - ); - const instance = createProviderInstance( - providers[updated.type], - providerId, - name, - config, - ); - - let m: ModelList = { chat: [], embedding: [] }; - - try { - m = await instance.getModelList(); - } catch (err: any) { - console.error( - `Failed to get model list for updated provider. Type: ${updated.type}, ID: ${updated.id}, Error: ${err.message}`, - ); - - m = { - chat: [ - { - key: 'error', - name: err.message, - }, - ], - embedding: [], - }; - } - - this.activeProviders.push({ - ...updated, - provider: instance, - }); - - return { - ...updated, - chatModels: m.chat || [], - embeddingModels: m.embedding || [], - }; - } - - /* Using async here because maybe in the future we might want to add some validation?? */ - async addProviderModel( - providerId: string, - type: 'embedding' | 'chat', - model: any, - ): Promise<any> { - const addedModel = configManager.addProviderModel(providerId, type, model); - return addedModel; - } - - async removeProviderModel( - providerId: string, - type: 'embedding' | 'chat', - modelKey: string, - ): Promise<void> { - configManager.removeProviderModel(providerId, type, modelKey); - return; - } -} - -export default ModelRegistry; diff --git a/services/chat-svc/src/lib/models/types.ts b/services/chat-svc/src/lib/models/types.ts deleted file mode 100644 index 84a21a7..0000000 --- a/services/chat-svc/src/lib/models/types.ts +++ /dev/null @@ -1,103 +0,0 @@ -import z from 'zod'; -import { Message } from '../types.js'; - -type Model = { - name: string; - key: string; -}; - -type ModelList = { - embedding: Model[]; - chat: Model[]; -}; - -type ProviderMetadata = { - name: string; - key: string; -}; - -type MinimalProvider = { - id: string; - name: string; - chatModels: Model[]; - embeddingModels: Model[]; -}; - -type ModelWithProvider = { - key: string; - providerId: string; -}; - -type GenerateOptions = { - temperature?: number; - maxTokens?: number; - topP?: number; - stopSequences?: string[]; - frequencyPenalty?: number; - presencePenalty?: number; -}; - -type Tool = { - name: string; - description: string; - schema: z.ZodObject<any>; -}; - -type ToolCall = { - id: string; - name: string; - arguments: Record<string, any>; -}; - -type GenerateTextInput = { - messages: Message[]; - tools?: Tool[]; - options?: GenerateOptions; -}; - -type GenerateTextOutput = { - content: string; - toolCalls: ToolCall[]; - additionalInfo?: Record<string, any>; -}; - -type StreamTextOutput = { - contentChunk: string; - toolCallChunk: ToolCall[]; - additionalInfo?: Record<string, any>; - done?: boolean; -}; - -type GenerateObjectInput = { - schema: z.ZodTypeAny; - messages: Message[]; - options?: GenerateOptions; -}; - -type GenerateObjectOutput<T> = { - object: T; - additionalInfo?: Record<string, any>; -}; - -type StreamObjectOutput<T> = { - objectChunk: Partial<T>; - additionalInfo?: Record<string, any>; - done?: boolean; -}; - -export type { - Model, - ModelList, - ProviderMetadata, - MinimalProvider, - ModelWithProvider, - GenerateOptions, - GenerateTextInput, - GenerateTextOutput, - StreamTextOutput, - GenerateObjectInput, - GenerateObjectOutput, - StreamObjectOutput, - Tool, - ToolCall, -}; diff --git a/services/chat-svc/src/lib/prompts/locale.ts b/services/chat-svc/src/lib/prompts/locale.ts deleted file mode 100644 index 2b31eb7..0000000 --- a/services/chat-svc/src/lib/prompts/locale.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** Маппинг кода локали в название языка для LLM */ -const LOCALE_TO_LANGUAGE: Record<string, string> = { - ru: 'Russian', - en: 'English', - de: 'German', - fr: 'French', - es: 'Spanish', - it: 'Italian', - pt: 'Portuguese', - uk: 'Ukrainian', - pl: 'Polish', - zh: 'Chinese', - ja: 'Japanese', - ko: 'Korean', - ar: 'Arabic', - tr: 'Turkish', - be: 'Belarusian', - kk: 'Kazakh', - sv: 'Swedish', - nb: 'Norwegian', - da: 'Danish', - fi: 'Finnish', - cs: 'Czech', - sk: 'Slovak', - hu: 'Hungarian', - ro: 'Romanian', - bg: 'Bulgarian', - hr: 'Croatian', - sr: 'Serbian', - el: 'Greek', - hi: 'Hindi', - th: 'Thai', - vi: 'Vietnamese', - id: 'Indonesian', - ms: 'Malay', - he: 'Hebrew', - fa: 'Persian', -}; - -/** - * Возвращает инструкцию для LLM о языке ответа. - * Добавлять в system prompt каждого запроса (classifier, researcher, writer). - */ -export function getLocaleInstruction(locale?: string): string { - if (!locale) return ''; - const lang = locale.split('-')[0]; - const languageName = LOCALE_TO_LANGUAGE[lang] ?? lang; - return ` -<response_language> -User's locale is ${locale}. Always format your response in ${languageName}, regardless of the language of the query or search results. Even when the discussed content is in another language, respond in ${languageName}. -</response_language>`; -} diff --git a/services/chat-svc/src/lib/prompts/search/researcher.ts b/services/chat-svc/src/lib/prompts/search/researcher.ts deleted file mode 100644 index 2354a3b..0000000 --- a/services/chat-svc/src/lib/prompts/search/researcher.ts +++ /dev/null @@ -1,356 +0,0 @@ -import BaseEmbedding from '../../models/base/embedding.js'; -import UploadStore from '../../uploads/store.js'; -import { getLocaleInstruction } from '../locale.js'; - -const getSpeedPrompt = ( - actionDesc: string, - i: number, - maxIteration: number, - fileDesc: string, -) => { - const today = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - - return ` - Assistant is an action orchestrator. Your job is to fulfill user requests by selecting and executing the available tools—no free-form replies. - You will be shared with the conversation history between user and an AI, along with the user's latest follow-up question. Based on this, you must use the available tools to fulfill the user's request. - - Today's date: ${today} - - You are currently on iteration ${i + 1} of your research process and have ${maxIteration} total iterations so act efficiently. - When you are finished, you must call the \`done\` tool. Never output text directly. - - <goal> - Fulfill the user's request as quickly as possible using the available tools. - Call tools to gather information or perform tasks as needed. - </goal> - - <core_principle> - Your knowledge is outdated; if you have web search, use it to ground answers even for seemingly basic facts. - </core_principle> - - <examples> - - ## Example 1: Unknown Subject - User: "What is Kimi K2?" - Action: web_search ["Kimi K2", "Kimi K2 AI"] then done. - - ## Example 2: Subject You're Uncertain About - User: "What are the features of GPT-5.1?" - Action: web_search ["GPT-5.1", "GPT-5.1 features", "GPT-5.1 release"] then done. - - ## Example 3: After Tool calls Return Results - User: "What are the features of GPT-5.1?" - [Previous tool calls returned the needed info] - Action: done. - - </examples> - - <available_tools> - ${actionDesc} - </available_tools> - - <mistakes_to_avoid> - -1. **Over-assuming**: Don't assume things exist or don't exist - just look them up - -2. **Verification obsession**: Don't waste tool calls "verifying existence" - just search for the thing directly - -3. **Endless loops**: If 2-3 tool calls don't find something, it probably doesn't exist - report that and move on - -4. **Ignoring task context**: If user wants a calendar event, don't just search - create the event - -5. **Overthinking**: Keep reasoning simple and tool calls focused - -</mistakes_to_avoid> - - <response_protocol> -- NEVER output normal text to the user. ONLY call tools. -- Choose the appropriate tools based on the action descriptions provided above. -- Default to web_search when information is missing or stale; keep queries targeted (max 3 per call). -- Call done when you have gathered enough to answer or performed the required actions. -- Do not invent tools. Do not return JSON. - </response_protocol> - - ${ - fileDesc.length > 0 - ? `<user_uploaded_files> - The user has uploaded the following files which may be relevant to their request: - ${fileDesc} - You can use the uploaded files search tool to look for information within these documents if needed. - </user_uploaded_files>` - : '' - } - `; -}; - -const getBalancedPrompt = ( - actionDesc: string, - i: number, - maxIteration: number, - fileDesc: string, -) => { - const today = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - - return ` - Assistant is an action orchestrator. Your job is to fulfill user requests by reasoning briefly and executing the available tools—no free-form replies. - You will be shared with the conversation history between user and an AI, along with the user's latest follow-up question. Based on this, you must use the available tools to fulfill the user's request. - - Today's date: ${today} - - You are currently on iteration ${i + 1} of your research process and have ${maxIteration} total iterations so act efficiently. - When you are finished, you must call the \`done\` tool. Never output text directly. - - <goal> - Fulfill the user's request with concise reasoning plus focused actions. - You must call the __reasoning_preamble tool before every tool call in this assistant turn. Alternate: __reasoning_preamble → tool → __reasoning_preamble → tool ... and finish with __reasoning_preamble → done. Open each __reasoning_preamble with a brief intent phrase (e.g., "Okay, the user wants to...", "Searching for...", "Looking into...") and lay out your reasoning for the next step. Keep it natural language, no tool names. - </goal> - - <core_principle> - Your knowledge is outdated; if you have web search, use it to ground answers even for seemingly basic facts. - You can call at most 6 tools total per turn: up to 2 reasoning (__reasoning_preamble counts as reasoning), 2-3 information-gathering calls, and 1 done. If you hit the cap, stop after done. - Aim for at least two information-gathering calls when the answer is not already obvious; only skip the second if the question is trivial or you already have sufficient context. - Do not spam searches—pick the most targeted queries. - </core_principle> - - <done_usage> - Call done only after the reasoning plus the necessary tool calls are completed and you have enough to answer. If you call done early, stop. If you reach the tool cap, call done to conclude. - </done_usage> - - <examples> - - ## Example 1: Unknown Subject - User: "What is Kimi K2?" - Reason: "Okay, the user wants to know about Kimi K2. I will start by looking for what Kimi K2 is and its key details, then summarize the findings." - Action: web_search ["Kimi K2", "Kimi K2 AI"] then reasoning then done. - - ## Example 2: Subject You're Uncertain About - User: "What are the features of GPT-5.1?" - Reason: "The user is asking about GPT-5.1 features. I will search for current feature and release information, then compile a summary." - Action: web_search ["GPT-5.1", "GPT-5.1 features", "GPT-5.1 release"] then reasoning then done. - - ## Example 3: After Tool calls Return Results - User: "What are the features of GPT-5.1?" - [Previous tool calls returned the needed info] - Reason: "I have gathered enough information about GPT-5.1 features; I will now wrap up." - Action: done. - - </examples> - - <available_tools> - YOU MUST CALL __reasoning_preamble BEFORE EVERY TOOL CALL IN THIS ASSISTANT TURN. IF YOU DO NOT CALL IT, THE TOOL CALL WILL BE IGNORED. - ${actionDesc} - </available_tools> - - <mistakes_to_avoid> - -1. **Over-assuming**: Don't assume things exist or don't exist - just look them up - -2. **Verification obsession**: Don't waste tool calls "verifying existence" - just search for the thing directly - -3. **Endless loops**: If 2-3 tool calls don't find something, it probably doesn't exist - report that and move on - -4. **Ignoring task context**: If user wants a calendar event, don't just search - create the event - -5. **Overthinking**: Keep reasoning simple and tool calls focused - -6. **Skipping the reasoning step**: Always call __reasoning_preamble first to outline your approach before other actions - -</mistakes_to_avoid> - - <response_protocol> -- NEVER output normal text to the user. ONLY call tools. -- Start with __reasoning_preamble and call __reasoning_preamble before every tool call (including done): open with intent phrase ("Okay, the user wants to...", "Looking into...", etc.) and lay out your reasoning for the next step. No tool names. -- Choose tools based on the action descriptions provided above. -- Default to web_search when information is missing or stale; keep queries targeted (max 3 per call). -- Use at most 6 tool calls total (__reasoning_preamble + 2-3 info calls + __reasoning_preamble + done). If done is called early, stop. -- Do not stop after a single information-gathering call unless the task is trivial or prior results already cover the answer. -- Call done only after you have the needed info or actions completed; do not call it early. -- Do not invent tools. Do not return JSON. - </response_protocol> - - ${ - fileDesc.length > 0 - ? `<user_uploaded_files> - The user has uploaded the following files which may be relevant to their request: - ${fileDesc} - You can use the uploaded files search tool to look for information within these documents if needed. - </user_uploaded_files>` - : '' - } - `; -}; - -const getQualityPrompt = ( - actionDesc: string, - i: number, - maxIteration: number, - fileDesc: string, -) => { - const today = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - - return ` - Assistant is a deep-research orchestrator. Your job is to fulfill user requests with the most thorough, comprehensive research possible—no free-form replies. - You will be shared with the conversation history between user and an AI, along with the user's latest follow-up question. Based on this, you must use the available tools to fulfill the user's request with depth and rigor. - - Today's date: ${today} - - You are currently on iteration ${i + 1} of your research process and have ${maxIteration} total iterations. Use every iteration wisely to gather comprehensive information. - When you are finished, you must call the \`done\` tool. Never output text directly. - - <goal> - Conduct the deepest, most thorough research possible. Leave no stone unturned. - Follow an iterative reason-act loop: call __reasoning_preamble before every tool call to outline the next step, then call the tool, then __reasoning_preamble again to reflect and decide the next step. Repeat until you have exhaustive coverage. - Open each __reasoning_preamble with a brief intent phrase (e.g., "Okay, the user wants to know about...", "From the results, it looks like...", "Now I need to dig into...") and describe what you'll do next. Keep it natural language, no tool names. - Finish with done only when you have comprehensive, multi-angle information. - </goal> - - <core_principle> - Your knowledge is outdated; always use the available tools to ground answers. - This is DEEP RESEARCH mode—be exhaustive. Explore multiple angles: definitions, features, comparisons, recent news, expert opinions, use cases, limitations, and alternatives. - You can call up to 10 tools total per turn. Use an iterative loop: __reasoning_preamble → tool call(s) → __reasoning_preamble → tool call(s) → ... → __reasoning_preamble → done. - Never settle for surface-level answers. If results hint at more depth, reason about your next step and follow up. Cross-reference information from multiple queries. - </core_principle> - - <done_usage> - Call done only after you have gathered comprehensive, multi-angle information. Do not call done early—exhaust your research budget first. If you reach the tool cap, call done to conclude. - </done_usage> - - <examples> - - ## Example 1: Unknown Subject - Deep Dive - User: "What is Kimi K2?" - Reason: "Okay, the user wants to know about Kimi K2. I'll start by finding out what it is and its key capabilities." - [calls info-gathering tool] - Reason: "From the results, Kimi K2 is an AI model by Moonshot. Now I need to dig into how it compares to competitors and any recent news." - [calls info-gathering tool] - Reason: "Got comparison info. Let me also check for limitations or critiques to give a balanced view." - [calls info-gathering tool] - Reason: "I now have comprehensive coverage—definition, capabilities, comparisons, and critiques. Wrapping up." - Action: done. - - ## Example 2: Feature Research - Comprehensive - User: "What are the features of GPT-5.1?" - Reason: "The user wants comprehensive GPT-5.1 feature information. I'll start with core features and specs." - [calls info-gathering tool] - Reason: "Got the basics. Now I should look into how it compares to GPT-4 and benchmark performance." - [calls info-gathering tool] - Reason: "Good comparison data. Let me also gather use cases and expert opinions for depth." - [calls info-gathering tool] - Reason: "I have exhaustive coverage across features, comparisons, benchmarks, and reviews. Done." - Action: done. - - ## Example 3: Iterative Refinement - User: "Tell me about quantum computing applications in healthcare." - Reason: "Okay, the user wants to know about quantum computing in healthcare. I'll start with an overview of current applications." - [calls info-gathering tool] - Reason: "Results mention drug discovery and diagnostics. Let me dive deeper into drug discovery use cases." - [calls info-gathering tool] - Reason: "Now I'll explore the diagnostics angle and any recent breakthroughs." - [calls info-gathering tool] - Reason: "Comprehensive coverage achieved. Wrapping up." - Action: done. - - </examples> - - <available_tools> - YOU MUST CALL __reasoning_preamble BEFORE EVERY TOOL CALL IN THIS ASSISTANT TURN. IF YOU DO NOT CALL IT, THE TOOL CALL WILL BE IGNORED. - ${actionDesc} - </available_tools> - - <research_strategy> - For any topic, consider searching: - 1. **Core definition/overview** - What is it? - 2. **Features/capabilities** - What can it do? - 3. **Comparisons** - How does it compare to alternatives? - 4. **Recent news/updates** - What's the latest? - 5. **Reviews/opinions** - What do experts say? - 6. **Use cases** - How is it being used? - 7. **Limitations/critiques** - What are the downsides? - </research_strategy> - - <mistakes_to_avoid> - -1. **Shallow research**: Don't stop after one or two searches—dig deeper from multiple angles - -2. **Over-assuming**: Don't assume things exist or don't exist - just look them up - -3. **Missing perspectives**: Search for both positive and critical viewpoints - -4. **Ignoring follow-ups**: If results hint at interesting sub-topics, explore them - -5. **Premature done**: Don't call done until you've exhausted reasonable research avenues - -6. **Skipping the reasoning step**: Always call __reasoning_preamble first to outline your research strategy - -</mistakes_to_avoid> - - <response_protocol> -- NEVER output normal text to the user. ONLY call tools. -- Follow an iterative loop: __reasoning_preamble → tool call → __reasoning_preamble → tool call → ... → __reasoning_preamble → done. -- Each __reasoning_preamble should reflect on previous results (if any) and state the next research step. No tool names in the reasoning. -- Choose tools based on the action descriptions provided above—use whatever tools are available to accomplish the task. -- Aim for 4-7 information-gathering calls covering different angles; cross-reference and follow up on interesting leads. -- Call done only after comprehensive, multi-angle research is complete. -- Do not invent tools. Do not return JSON. - </response_protocol> - - ${ - fileDesc.length > 0 - ? `<user_uploaded_files> - The user has uploaded the following files which may be relevant to their request: - ${fileDesc} - You can use the uploaded files search tool to look for information within these documents if needed. - </user_uploaded_files>` - : '' - } - `; -}; - -export const getResearcherPrompt = ( - actionDesc: string, - mode: 'speed' | 'balanced' | 'quality', - i: number, - maxIteration: number, - fileIds: string[], - locale?: string, -) => { - let prompt = ''; - - const filesData = UploadStore.getFileData(fileIds); - - const fileDesc = filesData - .map( - (f) => - `<file><name>${f.fileName}</name><initial_content>${f.initialContent}</initial_content></file>`, - ) - .join('\n'); - - switch (mode) { - case 'speed': - prompt = getSpeedPrompt(actionDesc, i, maxIteration, fileDesc); - break; - case 'balanced': - prompt = getBalancedPrompt(actionDesc, i, maxIteration, fileDesc); - break; - case 'quality': - prompt = getQualityPrompt(actionDesc, i, maxIteration, fileDesc); - break; - default: - prompt = getSpeedPrompt(actionDesc, i, maxIteration, fileDesc); - break; - } - - return prompt + getLocaleInstruction(locale); -}; diff --git a/services/chat-svc/src/lib/prompts/search/writer.ts b/services/chat-svc/src/lib/prompts/search/writer.ts deleted file mode 100644 index ebef5e9..0000000 --- a/services/chat-svc/src/lib/prompts/search/writer.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { getLocaleInstruction } from '../locale.js'; -import type { AnswerMode } from '../../agents/search/types.js'; - -const TRAVEL_VERTICAL_BLOCK = ` -### Answer Mode: Travel -You are answering in Travel vertical. Prioritize: -- Destinations, itineraries, hotels, transport, practical tips -- Travel time, best seasons, visa/border info, local customs -- Specific recommendations (restaurants, attractions, neighborhoods) -- Budget and cost estimates where relevant -Format: clear sections (Where to stay, What to see, Getting there). Include actionable advice. -`; - -const FINANCE_VERTICAL_BLOCK = ` -### Answer Mode: Finance -You are answering in Finance vertical. Prioritize: -- Market data, company analysis, financial metrics -- Cite sources for numbers and projections -- Consider risk, volatility, regulatory context -`; - -export interface ResponsePrefs { - format?: string; - length?: string; - tone?: string; -} - -export const getWriterPrompt = ( - context: string, - systemInstructions: string, - mode: 'speed' | 'balanced' | 'quality', - locale?: string, - memoryContext?: string, - answerMode?: AnswerMode, - responsePrefs?: ResponsePrefs, - learningMode?: boolean, -) => { - const memoryBlock = memoryContext?.trim() - ? `\n### User memory (personalization)\nUse these stored facts/preferences to personalize when relevant. Do NOT cite as source.\n${memoryContext}\n` - : ''; - const verticalBlock = - answerMode === 'travel' - ? TRAVEL_VERTICAL_BLOCK - : answerMode === 'finance' - ? FINANCE_VERTICAL_BLOCK - : ''; - - const prefs: string[] = []; - if (responsePrefs?.format) { - const f = responsePrefs.format; - if (f === 'bullets') prefs.push('Format: use bullet points where appropriate.'); - else if (f === 'outline') prefs.push('Format: use clear headings and outline structure.'); - else prefs.push('Format: use paragraphs and flowing prose.'); - } - if (responsePrefs?.length) { - const l = responsePrefs.length; - if (l === 'short') prefs.push('Length: keep response concise and brief.'); - else if (l === 'long') prefs.push('Length: provide comprehensive, detailed coverage.'); - else prefs.push('Length: medium depth, balanced.'); - } - if (responsePrefs?.tone) { - const t = responsePrefs.tone; - if (t === 'professional') prefs.push('Tone: formal, professional.'); - else if (t === 'casual') prefs.push('Tone: friendly, conversational.'); - else if (t === 'concise') prefs.push('Tone: direct, to the point.'); - else prefs.push('Tone: neutral.'); - } - const prefsBlock = prefs.length ? `\n### Response preferences\n${prefs.join(' ')}\n` : ''; - - const learningBlock = learningMode - ? `\n### Step-by-step Learning mode\nYou are in Learning mode. Explain your reasoning step-by-step. Break down complex concepts into manageable parts. Show the logical flow of your answer. When appropriate, use numbered steps or "First... Then... Finally" structure. Help the user understand the "why" behind the facts, not just the facts themselves.\n` - : ''; - - return ` -You are GooSeek, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses. - - Your task is to provide answers that are: - - **Informative and relevant**: Thoroughly address the user's query using the given context. - - **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically. - - **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights. - - **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included. - - **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable. - - ### Formatting Instructions - - **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate. - - **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience. - - **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability. - - **Length and Depth**: Provide comprehensive coverage of the topic. Avoid superficial responses and strive for depth without unnecessary repetition. Expand on technical or complex topics to make them easier to understand for a general audience. - - **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title. - - **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate. - - ### Citation Requirements - - Cite every single fact from **search_results** using [number] notation. Citations [1], [2], etc. refer ONLY to sources in search_results. - - **widgets_result** (calculations, weather, stock data) — use this to answer directly, do NOT cite it. This data is already shown to the user; integrate it naturally into your response. - - Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]." - - When search_results exist: ensure every fact from search has a citation. When ONLY widgets_result has relevant data (e.g. calculation), no citations needed — just answer using the widget data. - - Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]." - - Always prioritize credibility and accuracy by linking all statements back to their respective context sources. - - Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation. - - ### Special Instructions - - **IMPORTANT**: The context contains two sections: \`search_results\` (web search) and \`widgets_result\` (calculations, weather, stocks). If widgets_result has the answer (e.g. "The result of 2+2 is 4"), USE IT. Do not say "no relevant information" when the answer is in widgets_result. - - If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity. - - If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search. - - Only if BOTH search_results AND widgets_result lack relevant information, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" - ${mode === 'quality' ? "- YOU ARE CURRENTLY SET IN QUALITY MODE, GENERATE VERY DEEP, DETAILED AND COMPREHENSIVE RESPONSES USING THE FULL CONTEXT PROVIDED. ASSISTANT'S RESPONSES SHALL NOT BE LESS THAN AT LEAST 2000 WORDS, COVER EVERYTHING AND FRAME IT LIKE A RESEARCH REPORT." : ''} - - ${verticalBlock}${prefsBlock}${learningBlock} - ### User instructions - These instructions are shared to you by the user and not by the system. You will have to follow them but give them less priority than the above instructions. If the user has provided specific instructions or preferences, incorporate them into your response while adhering to the overall guidelines. - ${systemInstructions} - ${memoryBlock} - - ### Example Output - - Begin with a brief introduction summarizing the event or query topic. - - Follow with detailed sections under clear headings, covering all aspects of the query if possible. - - Provide explanations or historical context as needed to enhance understanding. - - End with a conclusion or overall perspective if relevant. - - <context> - ${context} - </context> - - Current date & time in ISO format (UTC timezone) is: ${new Date().toISOString()}. - ${getLocaleInstruction(locale)} -`; -}; - -/** - * Model Council synthesis prompt — combines 3 model answers into one best response - * docs/architecture: 01-perplexity-analogue-design.md §5.14 - */ -export const getSynthesisPrompt = ( - query: string, - answer1: string, - answer2: string, - answer3: string, - locale?: string, -) => ` -You are synthesizing answers from 3 different AI models (Model Council). The user asked a question and received 3 separate answers. Your job is to produce ONE final answer that: - -- Combines the BEST parts of each answer (most accurate, most useful, best structured) -- Eliminates redundancy and contradictions -- Preserves all relevant citations [1], [2], etc. from the sources -- Maintains a professional, well-structured format -- Responds fully to the user's query - -User query: ${query} - -Answer from Model 1: ---- -${answer1} ---- - -Answer from Model 2: ---- -${answer2} ---- - -Answer from Model 3: ---- -${answer3} ---- - -Produce your synthesized answer now. Use Markdown. Preserve citations. Be comprehensive. -${getLocaleInstruction(locale)} -`; diff --git a/services/chat-svc/src/lib/searxng.ts b/services/chat-svc/src/lib/searxng.ts deleted file mode 100644 index 2e5a47e..0000000 --- a/services/chat-svc/src/lib/searxng.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { getSearxngURL } from './config/serverRegistry.js'; - -const SEARCH_SVC_URL = process.env.SEARCH_SVC_URL?.trim() ?? ''; - -const FALLBACK_INSTANCES = ( - process.env.SEARXNG_FALLBACK_URL - ? process.env.SEARXNG_FALLBACK_URL.split(',').map((u) => u.trim()) - : ['https://searx.tiekoetter.com', 'https://search.sapti.me'] -).filter(Boolean); - -interface SearxngSearchOptions { - categories?: string[]; - engines?: string[]; - language?: string; - pageno?: number; -} - -export interface SearxngSearchResult { - title: string; - url: string; - img_src?: string; - thumbnail_src?: string; - thumbnail?: string; - content?: string; - author?: string; - iframe_src?: string; -} - -function buildSearchUrl(baseUrl: string, query: string, opts?: SearxngSearchOptions): string { - const params = new URLSearchParams(); - params.append('format', 'json'); - params.append('q', query); - if (opts) { - Object.entries(opts).forEach(([key, value]) => { - if (value == null) return; - params.append( - key, - Array.isArray(value) ? value.join(',') : String(value), - ); - }); - } - const base = baseUrl.trim().replace(/\/$/, ''); - const prefix = /^https?:\/\//i.test(base) ? '' : 'http://'; - return `${prefix}${base}/search?${params.toString()}`; -} - -export const searchSearxng = async ( - query: string, - opts?: SearxngSearchOptions, -) => { - if (SEARCH_SVC_URL) { - const params = new URLSearchParams(); - params.set('q', query); - if (opts?.categories) params.set('categories', Array.isArray(opts.categories) ? opts.categories.join(',') : opts.categories); - if (opts?.engines) params.set('engines', Array.isArray(opts.engines) ? opts.engines.join(',') : opts.engines); - if (opts?.language) params.set('language', opts.language); - if (opts?.pageno != null) params.set('pageno', String(opts.pageno)); - const url = `${SEARCH_SVC_URL.replace(/\/$/, '')}/api/v1/search?${params.toString()}`; - const res = await fetch(url, { signal: AbortSignal.timeout(15000) }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string }).error ?? `Search service HTTP ${res.status}`); - } - return res.json() as Promise<{ results: SearxngSearchResult[]; suggestions?: string[] }>; - } - - const searxngURL = getSearxngURL(); - const candidates: string[] = []; - - if (searxngURL?.trim()) { - let u = searxngURL.trim().replace(/\/$/, ''); - if (!/^https?:\/\//i.test(u)) u = `http://${u}`; - candidates.push(u); - } - FALLBACK_INSTANCES.forEach((u) => { - const trimmed = u.trim().replace(/\/$/, ''); - if (trimmed && !candidates.includes(trimmed)) candidates.push(trimmed); - }); - - let lastError: Error | null = null; - - const FETCH_TIMEOUT_MS = 15_000; - - for (const baseUrl of candidates) { - try { - const fullUrl = buildSearchUrl(baseUrl, query, opts); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - const res = await fetch(fullUrl, { signal: controller.signal }); - clearTimeout(timeoutId); - - const text = await res.text(); - const ok = res.ok; - const isJson = - text.trim().startsWith('{') || text.trim().startsWith('['); - - if (res.status === 429) { - const err = new Error( - `SearXNG ${baseUrl}: лимит запросов (429). Укажите свой инстанс в настройках или попробуйте позже.`, - ); - err.name = 'SearxngRateLimit'; - throw err; - } - - if (!isJson) { - throw new Error( - `SearXNG ${baseUrl}: ответ не JSON (HTTP ${res.status}). Проверьте URL и поддержку format=json.`, - ); - } - - const data = JSON.parse(text); - const results: SearxngSearchResult[] = data.results ?? []; - const suggestions: string[] = data.suggestions ?? []; - - if (!ok && results.length === 0) { - const errMsg = text.slice(0, 200) || `HTTP ${res.status}`; - throw new Error(`SearXNG ${baseUrl}: ${errMsg}`); - } - - return { results, suggestions }; - } catch (err) { - lastError = - err instanceof Error ? err : new Error(String(err)); - const cause = (err as { cause?: { code?: string } })?.cause; - const isAbort = lastError.name === 'AbortError'; - const isNetwork = - isAbort || - lastError.message.includes('fetch failed') || - lastError.message.includes('Invalid URL') || - cause?.code === 'ECONNREFUSED' || - cause?.code === 'ECONNRESET'; - const isRateLimit = - lastError.name === 'SearxngRateLimit' || - lastError.message.includes('429'); - const hasFallback = candidates.indexOf(baseUrl) < candidates.length - 1; - if (hasFallback && (isNetwork || isRateLimit)) { - continue; - } - if (isAbort) { - throw new Error( - `SearXNG ${baseUrl}: таймаут ${FETCH_TIMEOUT_MS / 1000}с. Проверьте подключение или используйте локальный инстанс.`, - ); - } - throw lastError; - } - } - - throw ( - lastError ?? - new Error( - 'SearXNG not configured. Set SEARXNG_API_URL or run Docker (includes SearXNG).', - ) - ); -}; diff --git a/services/chat-svc/src/lib/session.ts b/services/chat-svc/src/lib/session.ts deleted file mode 100644 index 5343445..0000000 --- a/services/chat-svc/src/lib/session.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { EventEmitter } from 'node:events'; -import { applyPatch } from 'rfc6902'; -import { Block } from './types.js'; - -const sessions = - (global as any)._sessionManagerSessions || new Map<string, SessionManager>(); -if (process.env.NODE_ENV !== 'production') { - (global as any)._sessionManagerSessions = sessions; -} - -class SessionManager { - private static sessions: Map<string, SessionManager> = sessions; - readonly id: string; - private blocks = new Map<string, Block>(); - private events: { event: string; data: any }[] = []; - private emitter = new EventEmitter(); - private TTL_MS = 30 * 60 * 1000; - - constructor(id?: string) { - this.id = id ?? crypto.randomUUID(); - - setTimeout(() => { - SessionManager.sessions.delete(this.id); - }, this.TTL_MS); - } - - static getSession(id: string): SessionManager | undefined { - return this.sessions.get(id); - } - - static getAllSessions(): SessionManager[] { - return Array.from(this.sessions.values()); - } - - static createSession(): SessionManager { - const session = new SessionManager(); - this.sessions.set(session.id, session); - return session; - } - - removeAllListeners() { - this.emitter.removeAllListeners(); - } - - emit(event: string, data: any) { - this.emitter.emit(event, data); - this.events.push({ event, data }); - } - - emitBlock(block: Block) { - this.blocks.set(block.id, block); - this.emit('data', { - type: 'block', - block: block, - }); - } - - getBlock(blockId: string): Block | undefined { - return this.blocks.get(blockId); - } - - updateBlock(blockId: string, patch: any[]) { - const block = this.blocks.get(blockId); - - if (block) { - applyPatch(block, patch); - this.blocks.set(blockId, block); - this.emit('data', { - type: 'updateBlock', - blockId: blockId, - patch: patch, - }); - } - } - - getAllBlocks() { - return Array.from(this.blocks.values()); - } - - subscribe(listener: (event: string, data: any) => void): () => void { - const currentEventsLength = this.events.length; - - const handler = (event: string) => (data: any) => listener(event, data); - const dataHandler = handler('data'); - const endHandler = handler('end'); - const errorHandler = handler('error'); - - this.emitter.on('data', dataHandler); - this.emitter.on('end', endHandler); - this.emitter.on('error', errorHandler); - - for (let i = 0; i < currentEventsLength; i++) { - const { event, data } = this.events[i]; - listener(event, data); - } - - return () => { - this.emitter.off('data', dataHandler); - this.emitter.off('end', endHandler); - this.emitter.off('error', errorHandler); - }; - } -} - -export default SessionManager; diff --git a/services/chat-svc/src/lib/uploads/manager.ts b/services/chat-svc/src/lib/uploads/manager.ts index a39d471..9ecfc4b 100644 --- a/services/chat-svc/src/lib/uploads/manager.ts +++ b/services/chat-svc/src/lib/uploads/manager.ts @@ -1,5 +1,4 @@ import path from "path"; -import BaseEmbedding from "../models/base/embedding.js" import crypto from "crypto" import fs from 'fs'; import { splitText } from '../utils/splitText.js'; @@ -9,8 +8,12 @@ const supportedMimeTypes = ['application/pdf', 'application/vnd.openxmlformats-o type SupportedMimeType = typeof supportedMimeTypes[number]; +export type EmbeddingLike = { + embedText(texts: string[]): Promise<number[][]>; +}; + type UploadManagerParams = { - embeddingModel: BaseEmbedding<any>; + embeddingModel: EmbeddingLike; } type RecordedFile = { @@ -32,7 +35,7 @@ const ROOT_DIR = process.env.DATA_DIR : process.cwd(); class UploadManager { - private embeddingModel: BaseEmbedding<any>; + private embeddingModel: EmbeddingLike; static uploadsDir = path.join(ROOT_DIR, 'data', 'uploads'); static uploadedFilesRecordPath = path.join(UploadManager.uploadsDir, 'uploaded_files.json'); diff --git a/services/chat-svc/src/lib/uploads/store.ts b/services/chat-svc/src/lib/uploads/store.ts deleted file mode 100644 index 146923b..0000000 --- a/services/chat-svc/src/lib/uploads/store.ts +++ /dev/null @@ -1,122 +0,0 @@ -import BaseEmbedding from "../models/base/embedding.js"; -import UploadManager from "./manager.js"; -import computeSimilarity from '../utils/computeSimilarity.js'; -import { hashObj } from '../serverUtils.js'; -import type { Chunk } from '../types.js'; -import fs from 'fs'; - -type UploadStoreParams = { - embeddingModel: BaseEmbedding<any>; - fileIds: string[]; -} - -type StoreRecord = { - embedding: number[]; - content: string; - fileId: string; - metadata: Record<string, any> -} - -class UploadStore { - embeddingModel: BaseEmbedding<any>; - fileIds: string[]; - records: StoreRecord[] = []; - - constructor(private params: UploadStoreParams) { - this.embeddingModel = params.embeddingModel; - this.fileIds = params.fileIds; - this.initializeStore() - } - - initializeStore() { - this.fileIds.forEach((fileId) => { - const file = UploadManager.getFile(fileId) - - if (!file) { - throw new Error(`File with ID ${fileId} not found`); - } - - const chunks = UploadManager.getFileChunks(fileId); - - this.records.push(...chunks.map((chunk) => ({ - embedding: chunk.embedding, - content: chunk.content, - fileId: fileId, - metadata: { - fileName: file.name, - title: file.name, - url: `file_id://${file.id}`, - } - }))) - }) - } - - async query(queries: string[], topK: number): Promise<Chunk[]> { - const queryEmbeddings = await this.embeddingModel.embedText(queries) - - const results: { chunk: Chunk; score: number; }[][] = []; - const hashResults: string[][] = [] - - await Promise.all(queryEmbeddings.map(async (query) => { - const similarities = this.records.map((record, idx) => { - return { - chunk: { - content: record.content, - metadata: { - ...record.metadata, - fileId: record.fileId, - } - }, - score: computeSimilarity(query, record.embedding) - } as { chunk: Chunk; score: number; }; - }).sort((a, b) => b.score - a.score) - - results.push(similarities) - hashResults.push(similarities.map(s => hashObj(s))) - })) - - const chunkMap: Map<string, Chunk> = new Map(); - const scoreMap: Map<string, number> = new Map(); - const k = 60; - - for (let i = 0; i < results.length; i++) { - for (let j = 0; j < results[i].length; j++) { - const chunkHash = hashResults[i][j] - - chunkMap.set(chunkHash, results[i][j].chunk); - scoreMap.set(chunkHash, (scoreMap.get(chunkHash) || 0) + results[i][j].score / (j + 1 + k)); - } - } - - const finalResults = Array.from(scoreMap.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([chunkHash, _score]) => { - return chunkMap.get(chunkHash)!; - }) - - return finalResults.slice(0, topK); - } - - static getFileData(fileIds: string[]): { fileName: string; initialContent: string }[] { - const filesData: { fileName: string; initialContent: string }[] = []; - - fileIds.forEach((fileId) => { - const file = UploadManager.getFile(fileId) - - if (!file) { - throw new Error(`File with ID ${fileId} not found`); - } - - const chunks = UploadManager.getFileChunks(fileId); - - filesData.push({ - fileName: file.name, - initialContent: chunks.slice(0, 3).map(c => c.content).join('\n---\n'), - }) - }) - - return filesData - } -} - -export default UploadStore \ No newline at end of file diff --git a/services/chat-svc/src/lib/utils/computeSimilarity.ts b/services/chat-svc/src/lib/utils/computeSimilarity.ts deleted file mode 100644 index 4cf90c8..0000000 --- a/services/chat-svc/src/lib/utils/computeSimilarity.ts +++ /dev/null @@ -1,22 +0,0 @@ -const computeSimilarity = (x: number[], y: number[]): number => { - if (x.length !== y.length) - throw new Error('Vectors must be of the same length'); - - let dotProduct = 0; - let normA = 0; - let normB = 0; - - for (let i = 0; i < x.length; i++) { - dotProduct += x[i] * y[i]; - normA += x[i] * x[i]; - normB += y[i] * y[i]; - } - - if (normA === 0 || normB === 0) { - return 0; - } - - return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); -}; - -export default computeSimilarity; diff --git a/services/chat-svc/src/lib/utils/formatHistory.ts b/services/chat-svc/src/lib/utils/formatHistory.ts deleted file mode 100644 index 2653036..0000000 --- a/services/chat-svc/src/lib/utils/formatHistory.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ChatTurnMessage } from '../types.js'; - -const formatChatHistoryAsString = (history: ChatTurnMessage[]) => { - return history - .map( - (message) => - `${message.role === 'assistant' ? 'AI' : 'User'}: ${message.content}`, - ) - .join('\n'); -}; - -export default formatChatHistoryAsString; diff --git a/services/discover-svc/Dockerfile b/services/discover-svc/Dockerfile index 24c06b0..aa1c313 100644 --- a/services/discover-svc/Dockerfile +++ b/services/discover-svc/Dockerfile @@ -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 3002 CMD ["node", "dist/index.js"] diff --git a/services/geo-device-svc/Dockerfile b/services/geo-device-svc/Dockerfile new file mode 100644 index 0000000..687c714 --- /dev/null +++ b/services/geo-device-svc/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=builder /app/dist ./dist +EXPOSE 4002 +CMD ["node", "dist/index.js"] diff --git a/services/geo-device-svc/package.json b/services/geo-device-svc/package.json index e047d08..ad23b53 100644 --- a/services/geo-device-svc/package.json +++ b/services/geo-device-svc/package.json @@ -6,7 +6,8 @@ "main": "src/index.ts", "scripts": { "dev": "npx tsx watch src/index.ts", - "start": "npx tsx src/index.ts" + "build": "tsc", + "start": "node dist/index.js" }, "dependencies": { "cors": "^2.8.5", @@ -16,6 +17,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" diff --git a/services/geo-device-svc/src/index.ts b/services/geo-device-svc/src/index.ts index 62ae7ec..9d85338 100644 --- a/services/geo-device-svc/src/index.ts +++ b/services/geo-device-svc/src/index.ts @@ -5,7 +5,7 @@ import contextRouter from './routes/context.js'; const app = express(); app.use(cors({ origin: true })); -const PORT = parseInt(process.env.PORT ?? '3015', 10); +const PORT = parseInt(process.env.PORT ?? '4002', 10); app.use(express.json({ limit: '1mb' })); diff --git a/services/llm-svc/Dockerfile b/services/llm-svc/Dockerfile index e919f00..86942a1 100644 --- a/services/llm-svc/Dockerfile +++ b/services/llm-svc/Dockerfile @@ -1,7 +1,9 @@ +# syntax=docker/dockerfile:1 FROM node:20-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:20-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 3020 ENV DATA_DIR=/app/data diff --git a/services/llm-svc/src/index.ts b/services/llm-svc/src/index.ts index 26bc590..7c4e280 100644 --- a/services/llm-svc/src/index.ts +++ b/services/llm-svc/src/index.ts @@ -1,5 +1,5 @@ /** - * llm-svc — LLM provider management microservice + * llm-svc — LLM provider management service (СОА) * API: GET/POST/PATCH/DELETE /api/v1/providers, GET/POST /api/v1/providers/:id/models * Config: data/llm-providers.json, envOnlyMode via LLM_PROVIDER=ollama|timeweb */ @@ -59,6 +59,22 @@ app.get('/metrics', async (_req, reply) => { ); }); +/* --- Provider UI config (for chat-svc config) --- */ +app.get('/api/v1/providers/ui-config', async (_req, reply) => { + try { + const { providers } = await import('./lib/models/providers/index.js'); + const sections = Object.entries(providers).map(([key, Provider]) => { + const configFields = Provider.getProviderConfigFields(); + const metadata = Provider.getProviderMetadata(); + return { key, name: metadata.name, fields: configFields }; + }); + return reply.send({ sections }); + } catch (err) { + app.log.error(err); + return reply.status(500).send({ message: 'An error has occurred.' }); + } +}); + /* --- Providers --- */ app.get<{ Querystring: { internal?: string } }>('/api/v1/providers', async (req, reply) => { try { @@ -257,6 +273,156 @@ app.post<{ Params: { id: string }; Body: unknown }>( }, ); +const modelSchema = z.object({ + providerId: z.string().min(1), + key: z.string().min(1), +}); +const messageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system', 'tool']), + content: z.string(), + id: z.string().optional(), + name: z.string().optional(), + tool_calls: z.array(z.object({ + id: z.string(), + name: z.string(), + arguments: z.record(z.string(), z.unknown()), + })).optional(), +}); +const toolSchema = z.object({ + name: z.string(), + description: z.string(), + schema: z.record(z.string(), z.unknown()), +}); +const generateOptionsSchema = z.object({ + temperature: z.number().optional(), + maxTokens: z.number().optional(), + topP: z.number().optional(), + stopSequences: z.array(z.string()).optional(), + frequencyPenalty: z.number().optional(), + presencePenalty: z.number().optional(), +}).optional(); +const generateSchema = z.object({ + model: modelSchema, + messages: z.array(messageSchema), + tools: z.array(toolSchema).optional(), + options: generateOptionsSchema, +}); +const generateObjectSchema = z.object({ + model: modelSchema, + messages: z.array(messageSchema), + schema: z.record(z.string(), z.unknown()), + options: generateOptionsSchema, +}); +const embeddingsSchema = z.object({ + model: modelSchema, + texts: z.array(z.string()), +}); + +/* --- Generation API --- */ +app.post<{ Body: unknown }>('/api/v1/generate', async (req, reply) => { + const parsed = generateSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ message: 'Invalid request', error: parsed.error.issues }); + } + try { + const registry = new ModelRegistry(); + const llm = await registry.loadChatModel(parsed.data.model.providerId, parsed.data.model.key); + const tools = parsed.data.tools?.map((t) => ({ + name: t.name, + description: t.description, + schema: z.fromJSONSchema(t.schema as Parameters<typeof z.fromJSONSchema>[0]), + })); + const result = await llm.generateText({ + messages: parsed.data.messages as unknown as import('./lib/types.js').Message[], + tools, + options: parsed.data.options, + }); + return reply.send(result); + } catch (err) { + app.log.error(err); + return reply.status(500).send({ message: err instanceof Error ? err.message : 'Generation failed' }); + } +}); + +app.post<{ Body: unknown }>('/api/v1/generate/stream', async (req, reply) => { + const parsed = generateSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ message: 'Invalid request', error: parsed.error.issues }); + } + try { + const registry = new ModelRegistry(); + const llm = await registry.loadChatModel(parsed.data.model.providerId, parsed.data.model.key); + const tools = parsed.data.tools?.map((t) => ({ + name: t.name, + description: t.description, + schema: z.fromJSONSchema(t.schema as Parameters<typeof z.fromJSONSchema>[0]), + })); + const stream = llm.streamText({ + messages: parsed.data.messages as unknown as import('./lib/types.js').Message[], + tools, + options: parsed.data.options, + }); + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of stream) { + controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n')); + } + } catch (e) { + app.log.error(e); + controller.enqueue(encoder.encode(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + '\n')); + } + controller.close(); + }, + }); + return reply + .header('Content-Type', 'application/x-ndjson') + .header('Cache-Control', 'no-cache') + .send(readable); + } catch (err) { + app.log.error(err); + return reply.status(500).send({ message: err instanceof Error ? err.message : 'Stream generation failed' }); + } +}); + +app.post<{ Body: unknown }>('/api/v1/generate/object', async (req, reply) => { + const parsed = generateObjectSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ message: 'Invalid request', error: parsed.error.issues }); + } + try { + const registry = new ModelRegistry(); + const llm = await registry.loadChatModel(parsed.data.model.providerId, parsed.data.model.key); + const zodSchema = z.fromJSONSchema(parsed.data.schema as Parameters<typeof z.fromJSONSchema>[0]); + const result = await llm.generateObject({ + messages: parsed.data.messages as unknown as import('./lib/types.js').Message[], + schema: zodSchema, + options: parsed.data.options, + }); + return reply.send({ object: result }); + } catch (err) { + app.log.error(err); + return reply.status(500).send({ message: err instanceof Error ? err.message : 'Object generation failed' }); + } +}); + +app.post<{ Body: unknown }>('/api/v1/embeddings', async (req, reply) => { + const parsed = embeddingsSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ message: 'Invalid request', error: parsed.error.issues }); + } + try { + const registry = new ModelRegistry(); + const embedding = await registry.loadEmbeddingModel(parsed.data.model.providerId, parsed.data.model.key); + const embeddings = await embedding.embedText(parsed.data.texts); + return reply.send({ embeddings }); + } catch (err) { + app.log.error(err); + return reply.status(500).send({ message: err instanceof Error ? err.message : 'Embeddings failed' }); + } +}); + const modelDeleteSchema = z.object({ type: z.enum(['chat', 'embedding']), key: z.string().min(1), diff --git a/services/llm-svc/src/lib/models/registry.ts b/services/llm-svc/src/lib/models/registry.ts index 3d52899..c624cf8 100644 --- a/services/llm-svc/src/lib/models/registry.ts +++ b/services/llm-svc/src/lib/models/registry.ts @@ -2,7 +2,7 @@ import type { ConfigModelProvider } from '../config/types.js'; import BaseModelProvider, { createProviderInstance, } from './base/provider.js'; -import { getConfiguredModelProviders } from '../config/serverRegistry.js'; +import { getConfiguredModelProviders, isEnvOnlyMode } from '../config/serverRegistry.js'; import { providers } from './providers/index.js'; import type { MinimalProvider, ModelList } from './types.js'; import { providersConfig } from '../config/ProvidersConfig.js'; @@ -74,13 +74,25 @@ class ModelRegistry { } async loadChatModel(providerId: string, modelName: string) { - const provider = this.activeProviders.find((p) => p.id === providerId); + let provider = this.activeProviders.find((p) => p.id === providerId); + if (!provider && isEnvOnlyMode() && (providerId === 'env' || !providerId)) { + provider = this.activeProviders.find((p) => p.id.startsWith('env-')); + if (provider && modelName === 'default') { + modelName = provider.chatModels[0]?.key ?? modelName; + } + } if (!provider) throw new Error('Invalid provider id'); return provider.provider.loadChatModel(modelName); } async loadEmbeddingModel(providerId: string, modelName: string) { - const provider = this.activeProviders.find((p) => p.id === providerId); + let provider = this.activeProviders.find((p) => p.id === providerId); + if (!provider && isEnvOnlyMode() && (providerId === 'env' || !providerId)) { + provider = this.activeProviders.find((p) => p.id.startsWith('env-')); + if (provider && modelName === 'default') { + modelName = provider.embeddingModels[0]?.key ?? modelName; + } + } if (!provider) throw new Error('Invalid provider id'); return provider.provider.loadEmbeddingModel(modelName); } diff --git a/services/localization-svc/Dockerfile b/services/localization-svc/Dockerfile new file mode 100644 index 0000000..85233df --- /dev/null +++ b/services/localization-svc/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=builder /app/dist ./dist +EXPOSE 4003 +CMD ["node", "dist/index.js"] diff --git a/services/localization-svc/package.json b/services/localization-svc/package.json index 13a04dc..aab3b76 100644 --- a/services/localization-svc/package.json +++ b/services/localization-svc/package.json @@ -6,7 +6,8 @@ "main": "src/index.ts", "scripts": { "dev": "npx tsx watch src/index.ts", - "start": "npx tsx src/index.ts" + "build": "tsc", + "start": "node dist/index.js" }, "dependencies": { "cors": "^2.8.5", diff --git a/services/localization-svc/src/index.ts b/services/localization-svc/src/index.ts index 08a1ffd..2453ced 100644 --- a/services/localization-svc/src/index.ts +++ b/services/localization-svc/src/index.ts @@ -6,7 +6,7 @@ import translationsRouter from './routes/translations.js'; const app = express(); app.use(cors({ origin: true })); -const PORT = parseInt(process.env.PORT ?? '3016', 10); +const PORT = parseInt(process.env.PORT ?? '4003', 10); app.use(express.json({ limit: '1mb' })); diff --git a/services/localization-svc/src/lib/geoClient.ts b/services/localization-svc/src/lib/geoClient.ts index 33e75c4..ea8ca44 100644 --- a/services/localization-svc/src/lib/geoClient.ts +++ b/services/localization-svc/src/lib/geoClient.ts @@ -1,7 +1,7 @@ import type { GeoDeviceContext } from '../types.js'; const GEO_DEVICE_URL = - process.env.GEO_DEVICE_SVC_URL ?? process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:3015'; + process.env.GEO_DEVICE_SVC_URL ?? process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002'; export async function fetchGeoContext( headers: Record<string, string>, diff --git a/services/master-agents-svc/Dockerfile b/services/master-agents-svc/Dockerfile new file mode 100644 index 0000000..ac3bbd9 --- /dev/null +++ b/services/master-agents-svc/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=builder /app/dist ./dist +EXPOSE 3018 +CMD ["node", "dist/index.js"] diff --git a/services/master-agents-svc/package.json b/services/master-agents-svc/package.json index 3f94124..e7e7f7e 100644 --- a/services/master-agents-svc/package.json +++ b/services/master-agents-svc/package.json @@ -17,10 +17,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" } diff --git a/services/master-agents-svc/src/index.ts b/services/master-agents-svc/src/index.ts index 1b0a35b..74446c5 100644 --- a/services/master-agents-svc/src/index.ts +++ b/services/master-agents-svc/src/index.ts @@ -1,6 +1,6 @@ /** - * master-agents-svc — Master Agent с динамическими под-агентами и инструментами - * API: POST /api/v1/agents/execute + * master-agents-svc — Master Agent + Search Orchestrator (Perplexity-style) + * API: POST /api/v1/agents/execute, POST /api/v1/agents/search (NDJSON stream) */ import Fastify from 'fastify'; @@ -8,8 +8,13 @@ import cors from '@fastify/cors'; import { z } from 'zod'; import { loadChatModel } from './lib/models/registry.js'; import { runMasterAgent } from './lib/agent/master.js'; +import { createLlmClient } from './lib/llm-client.js'; +import SessionManager from './lib/session.js'; +import { runSearchOrchestrator } from './lib/agent/searchOrchestrator.js'; const PORT = parseInt(process.env.PORT ?? '3018', 10); +const LLM_SVC_URL = process.env.LLM_SVC_URL ?? ''; +const MEMORY_SVC_URL = process.env.MEMORY_SVC_URL ?? ''; const chatModelSchema = z.object({ providerId: z.string(), @@ -23,6 +28,30 @@ const bodySchema = z.object({ maxSteps: z.number().min(1).max(25).optional().default(15), }); +const answerModeEnum = z.enum([ + 'standard', 'focus', 'academic', 'writing', 'travel', 'finance', + 'health', 'education', 'medicine', 'realEstate', 'psychology', 'sports', + 'children', 'goods', 'shopping', 'games', 'taxes', 'legislation', +]); + +const searchBodySchema = z.object({ + message: z.object({ + messageId: z.string().min(1), + chatId: z.string().min(1), + content: z.string().min(1), + }), + optimizationMode: z.enum(['speed', 'balanced', 'quality']), + sources: z.array(z.string()).optional().default([]), + history: z.array(z.tuple([z.string(), z.string()])).optional().default([]), + files: z.array(z.string()).optional().default([]), + chatModel: chatModelSchema, + systemInstructions: z.string().nullable().optional().default(''), + locale: z.string().optional(), + answerMode: answerModeEnum.optional().default('standard'), + responsePrefs: z.object({ format: z.string().optional(), length: z.string().optional(), tone: z.string().optional() }).optional(), + learningMode: z.boolean().optional().default(false), +}); + const app = Fastify({ logger: true }); await app.register(cors, { origin: true }); @@ -62,6 +91,105 @@ app.post('/api/v1/agents/execute', async (req, reply) => { } }); +app.post<{ Body: unknown }>('/api/v1/agents/search', async (req, reply) => { + const parsed = searchBodySchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ message: 'Invalid request body', error: parsed.error.issues }); + } + const body = parsed.data; + + if (!LLM_SVC_URL) { + return reply.status(503).send({ message: 'LLM_SVC_URL not configured. llm-svc required.' }); + } + + if (body.message.content === '') { + return reply.status(400).send({ message: 'Please provide a message to process' }); + } + + let memoryContext: string | undefined; + const authHeader = req.headers.authorization; + const useMemory = MEMORY_SVC_URL && authHeader && (body.optimizationMode === 'balanced' || body.optimizationMode === 'quality'); + if (useMemory) { + try { + const memRes = await fetch(`${MEMORY_SVC_URL.replace(/\/$/, '')}/api/v1/memory`, { + headers: { Authorization: authHeader! }, + signal: AbortSignal.timeout(3000), + }); + if (memRes.ok) { + const memData = (await memRes.json()) as { items?: { key: string; value: string }[] }; + const items = memData.items ?? []; + if (items.length > 0) { + memoryContext = items.map((r) => `- ${r.key}: ${r.value}`).join('\n'); + } + } + } catch (err) { + req.log.warn({ err }, 'Memory fetch failed'); + } + } + + try { + const llm = createLlmClient({ providerId: body.chatModel.providerId, key: body.chatModel.key }); + const history = body.history.map((msg) => + msg[0] === 'human' ? { role: 'user' as const, content: msg[1] } : { role: 'assistant' as const, content: msg[1] }); + 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(); + } + }); + runSearchOrchestrator(session, { + chatHistory: history, + followUp: body.message.content, + config: { + llm, + mode: body.optimizationMode, + sources: (body.sources as ('web' | 'discussions' | 'academic')[]) ?? [], + fileIds: body.files, + systemInstructions: body.systemInstructions || 'None', + locale: body.locale ?? 'en', + memoryContext, + answerMode: body.answerMode, + responsePrefs: body.responsePrefs, + learningMode: body.learningMode, + }, + }).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 {} + }); + }, + }); + + return reply + .header('Content-Type', 'application/x-ndjson') + .header('Cache-Control', 'no-cache') + .send(stream); + } catch (err) { + req.log.error(err); + return reply.status(500).send({ message: 'An error occurred while processing search request' }); + } +}); + try { await app.listen({ port: PORT, host: '0.0.0.0' }); console.log(`master-agents-svc listening on :${PORT}`); diff --git a/services/master-agents-svc/src/lib/actions/__reasoning_preamble.ts b/services/master-agents-svc/src/lib/actions/__reasoning_preamble.ts new file mode 100644 index 0000000..aaabf52 --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/__reasoning_preamble.ts @@ -0,0 +1,28 @@ +import z from 'zod'; +import type { 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 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. + +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, + 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) => ({ + type: 'reasoning', + reasoning: input.plan, + }), +}; + +export default planAction; diff --git a/services/master-agents-svc/src/lib/actions/academic_search.ts b/services/master-agents-svc/src/lib/actions/academic_search.ts new file mode 100644 index 0000000..d0d6cfd --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/academic_search.ts @@ -0,0 +1,77 @@ +import z from 'zod'; +import type { ResearchAction } from './types.js'; +import type { 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 academicSearchAction: ResearchAction<typeof schema> = { + name: 'academic_search', + schema, + getToolDescription: () => + 'Use this tool to perform academic searches for scholarly articles, papers, and research studies. Provide up to 3 queries at a time.', + getDescription: () => + 'Use this tool to perform academic searches for scholarly articles and research studies. Provide concise search queries. You can provide up to 3 queries at a time.', + 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({ + 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; + const 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((s) => s.id === searchResultsBlockId); + const subStep = researchBlock.data.subSteps[subStepIndex] as SearchResultsResearchBlock | undefined; + if (subStep) { + 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; diff --git a/services/master-agents-svc/src/lib/actions/done.ts b/services/master-agents-svc/src/lib/actions/done.ts new file mode 100644 index 0000000..42839b4 --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/done.ts @@ -0,0 +1,16 @@ +import z from 'zod'; +import type { ResearchAction } from './types.js'; + +const emptySchema = z.object({}); +const doneAction: ResearchAction<typeof emptySchema> = { + name: 'done', + schema: emptySchema, + 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: () => + 'Use this action ONLY when you have completed all necessary research and are ready to provide a final answer. YOU MUST CALL THIS ACTION TO SIGNAL COMPLETION; DO NOT OUTPUT FINAL ANSWERS DIRECTLY TO THE USER.', + enabled: () => true, + execute: async () => ({ type: 'done' }), +}; + +export default doneAction; diff --git a/services/master-agents-svc/src/lib/actions/registry.ts b/services/master-agents-svc/src/lib/actions/registry.ts new file mode 100644 index 0000000..0e68130 --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/registry.ts @@ -0,0 +1,57 @@ +import type { ResearchAction, ActionConfig, AdditionalConfig, ToolCall } from './types.js'; +import __reasoning_preamble from './__reasoning_preamble.js'; +import done from './done.js'; +import webSearch from './web_search.js'; +import academicSearch from './academic_search.js'; +import socialSearch from './social_search.js'; +import scrapeUrl from './scrape_url.js'; + +const actions = new Map<string, ResearchAction>(); + +function register(action: ResearchAction) { + actions.set(action.name, action); +} + +register(__reasoning_preamble); +register(done); +register(webSearch); +register(academicSearch); +register(socialSearch); +register(scrapeUrl); + +export function getAction(name: string): ResearchAction | undefined { + return actions.get(name); +} + +export function getAvailableActions(config: ActionConfig): ResearchAction[] { + return Array.from(actions.values()).filter((a) => a.enabled(config)); +} + +export function getAvailableActionTools(config: ActionConfig): { name: string; description: string; schema: unknown }[] { + return getAvailableActions(config).map((a) => ({ + name: a.name, + description: a.getToolDescription({ mode: config.mode }), + schema: a.schema, + })); +} + +export function getAvailableActionsDescriptions(config: ActionConfig): string { + return getAvailableActions(config) + .map((a) => `<tool name="${a.name}">\n${a.getDescription({ mode: config.mode })}\n</tool>`) + .join('\n\n'); +} + +/** Параллельное выполнение tool calls */ +export async function executeAll( + toolCalls: ToolCall[], + additionalConfig: AdditionalConfig, +): Promise<import('./types.js').ActionOutput[]> { + const results = await Promise.all( + toolCalls.map(async (tc) => { + const action = getAction(tc.name); + if (!action) throw new Error(`Action ${tc.name} not found`); + return action.execute(tc.arguments as never, additionalConfig); + }), + ); + return results; +} diff --git a/services/master-agents-svc/src/lib/actions/scrape_url.ts b/services/master-agents-svc/src/lib/actions/scrape_url.ts new file mode 100644 index 0000000..906918d --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/scrape_url.ts @@ -0,0 +1,71 @@ +import z from 'zod'; +import TurndownService from 'turndown'; +import type { ResearchAction } from './types.js'; +import type { Chunk, ReadingResearchBlock } from '../types.js'; + +const turndownService = new TurndownService(); + +const schema = z.object({ + urls: z.array(z.string()).describe('A list of URLs to scrape content from.'), +}); + +const scrapeURLAction: ResearchAction<typeof schema> = { + name: 'scrape_url', + schema, + getToolDescription: () => + 'Use this tool to scrape and extract content from the provided URLs. 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: () => + 'Use this tool to scrape content from specific web pages. Only call when the user has specifically requested information from certain URLs. Never call yourself to get extra information without user instruction.', + enabled: () => true, + execute: async (params, additionalConfig) => { + params.urls = params.urls.slice(0, 3); + + const researchBlock = additionalConfig.session.getBlock(additionalConfig.researchBlockId); + let readingBlockId = crypto.randomUUID(); + let readingEmitted = false; + const results: Chunk[] = []; + + await Promise.all( + params.urls.map(async (url) => { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); + 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 } }], + }); + 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((s) => s.id === readingBlockId); + const subStep = researchBlock.data.subSteps[subStepIndex] as ReadingResearchBlock | undefined; + if (subStep) { + subStep.reading.push({ content: '', metadata: { url, 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 } }); + } 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; diff --git a/services/master-agents-svc/src/lib/actions/social_search.ts b/services/master-agents-svc/src/lib/actions/social_search.ts new file mode 100644 index 0000000..295a0a8 --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/social_search.ts @@ -0,0 +1,77 @@ +import z from 'zod'; +import type { ResearchAction } from './types.js'; +import type { 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 socialSearchAction: ResearchAction<typeof schema> = { + name: 'social_search', + schema, + getToolDescription: () => + 'Use this tool to perform social media searches for relevant posts, discussions, and trends. Provide up to 3 queries at a time.', + getDescription: () => + 'Use this tool to perform social media searches for posts, discussions, and trends. Provide concise search queries. You can provide up to 3 queries at a time.', + 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({ + 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; + const 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((s) => s.id === searchResultsBlockId); + const subStep = researchBlock.data.subSteps[subStepIndex] as SearchResultsResearchBlock | undefined; + if (subStep) { + 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; diff --git a/services/master-agents-svc/src/lib/actions/types.ts b/services/master-agents-svc/src/lib/actions/types.ts new file mode 100644 index 0000000..29761c4 --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/types.ts @@ -0,0 +1,52 @@ +import z from 'zod'; +import type SessionManager from '../session.js'; +import type { Chunk } from '../types.js'; + +export type ClassifierOutput = { + classification: { + skipSearch: boolean; + personalSearch: boolean; + academicSearch: boolean; + discussionSearch: boolean; + showWeatherWidget: boolean; + showStockWidget: boolean; + showCalculationWidget: boolean; + }; + standaloneFollowUp: string; +}; + +export type SearchSources = 'web' | 'discussions' | 'academic'; +export type SearchMode = 'speed' | 'balanced' | 'quality'; + +export type ActionConfig = { + classification: ClassifierOutput; + fileIds: string[]; + mode: SearchMode; + sources: SearchSources[]; + hasEmbedding?: boolean; +}; + +export type AdditionalConfig = { + session: SessionManager; + researchBlockId: string; + fileIds: string[]; +}; + +export type SearchActionOutput = { type: 'search_results'; results: Chunk[] }; +export type DoneActionOutput = { type: 'done' }; +export type ReasoningActionOutput = { type: 'reasoning'; reasoning: string }; +export type ActionOutput = SearchActionOutput | DoneActionOutput | ReasoningActionOutput; + +export type ToolCall = { id: string; name: string; arguments: Record<string, unknown> }; + +export interface ResearchAction<TSchema extends z.ZodObject<Record<string, z.ZodTypeAny>> = z.ZodObject<Record<string, z.ZodTypeAny>>> { + name: string; + schema: TSchema; + getToolDescription: (config: { mode: SearchMode }) => string; + getDescription: (config: { mode: SearchMode }) => string; + enabled: (config: ActionConfig) => boolean; + execute: ( + params: z.infer<TSchema>, + additionalConfig: AdditionalConfig, + ) => Promise<ActionOutput>; +} diff --git a/services/master-agents-svc/src/lib/actions/web_search.ts b/services/master-agents-svc/src/lib/actions/web_search.ts new file mode 100644 index 0000000..5abbe4a --- /dev/null +++ b/services/master-agents-svc/src/lib/actions/web_search.ts @@ -0,0 +1,80 @@ +import z from 'zod'; +import type { ResearchAction } from './types.js'; +import type { Chunk, SearchResultsResearchBlock } from '../types.js'; +import { searchSearxng } from '../searxng.js'; + +const schema = z.object({ + queries: z.array(z.string()).describe('An array of search queries to perform web searches for.'), +}); + +const webSearchAction: ResearchAction<typeof schema> = { + name: 'web_search', + schema, + getToolDescription: () => + 'Use this tool to perform web searches based on the provided queries. You can provide up to 3 queries at a time.', + getDescription: () => + 'Use this tool to perform web searches. Your queries should be targeted and specific, SEO-friendly keywords. You can search for 3 queries in one go.', + 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; + const results: Chunk[] = []; + + const search = async (q: string) => { + let res: { results: { content?: string; title: string; url: string }[] }; + try { + res = await searchSearxng(q); + } catch { + return; + } + 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((s) => s.id === searchResultsBlockId); + const subStep = researchBlock.data.subSteps[subStepIndex] as SearchResultsResearchBlock | undefined; + if (subStep) { + 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; diff --git a/services/master-agents-svc/src/lib/agent/classifier.ts b/services/master-agents-svc/src/lib/agent/classifier.ts new file mode 100644 index 0000000..c370964 --- /dev/null +++ b/services/master-agents-svc/src/lib/agent/classifier.ts @@ -0,0 +1,39 @@ +import z from 'zod'; +import type { LlmClient } from '../llm-client.js'; +import { getClassifierPrompt } from '../prompts/classifier.js'; +import formatChatHistoryAsString from '../utils/formatHistory.js'; + +const schema = z.object({ + classification: z.object({ + skipSearch: z.boolean(), + personalSearch: z.boolean(), + academicSearch: z.boolean(), + discussionSearch: z.boolean(), + showWeatherWidget: z.boolean(), + showStockWidget: z.boolean(), + showCalculationWidget: z.boolean(), + }), + standaloneFollowUp: z.string(), +}); + +export type ClassifierInput = { + chatHistory: { role: string; content: string }[]; + query: string; + llm: LlmClient; + locale?: string; + enabledSources: ('web' | 'discussions' | 'academic')[]; +}; + +export async function classify(input: ClassifierInput): Promise<z.infer<typeof schema>> { + const output = await input.llm.generateObject<z.infer<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; +} diff --git a/services/master-agents-svc/src/lib/agent/researcher.ts b/services/master-agents-svc/src/lib/agent/researcher.ts new file mode 100644 index 0000000..f176799 --- /dev/null +++ b/services/master-agents-svc/src/lib/agent/researcher.ts @@ -0,0 +1,195 @@ +import z from 'zod'; +import type { LlmClient } from '../llm-client.js'; +import type SessionManager from '../session.js'; +import type { Chunk, ReasoningResearchBlock } from '../types.js'; +import type { ClassifierOutput } from '../actions/types.js'; +import { getResearcherPrompt } from '../prompts/researcher.js'; +import { getAvailableActionTools, getAvailableActionsDescriptions, executeAll } from '../actions/registry.js'; +import formatChatHistoryAsString from '../utils/formatHistory.js'; + +export type ResearcherConfig = { + mode: 'speed' | 'balanced' | 'quality'; + sources: ('web' | 'discussions' | 'academic')[]; + fileIds: string[]; + locale?: string; +}; + +export type ResearcherInput = { + chatHistory: { role: string; content: string }[]; + followUp: string; + classification: ClassifierOutput; + config: ResearcherConfig; +}; + +export type ResearcherOutput = { + searchFindings: Chunk[]; +}; + +export type ToolCall = { id: string; name: string; arguments: Record<string, unknown> }; + +export async function research( + session: SessionManager, + llm: LlmClient, + input: ResearcherInput, +): Promise<ResearcherOutput> { + const maxIteration = input.config.mode === 'speed' ? 2 : input.config.mode === 'balanced' ? 6 : 25; + + const actionConfig = { + classification: input.classification, + fileIds: input.config.fileIds, + mode: input.config.mode, + sources: input.config.sources, + hasEmbedding: false, + }; + + const availableTools = getAvailableActionTools(actionConfig); + const availableActionsDescription = getAvailableActionsDescriptions(actionConfig); + + const researchBlockId = crypto.randomUUID(); + session.emitBlock({ + id: researchBlockId, + type: 'research', + data: { subSteps: [] }, + }); + + const agentMessageHistory: { role: string; content: string; tool_calls?: ToolCall[] }[] = [ + { + role: 'user', + content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory.slice(-10))}\nUser: ${input.followUp} (Standalone: ${input.classification.standaloneFollowUp})\n</conversation>`, + }, + ]; + + const actionOutput: { type: string; results?: Chunk[] }[] = []; + + for (let i = 0; i < maxIteration; i++) { + const researcherPrompt = getResearcherPrompt( + availableActionsDescription, + input.config.mode, + i, + maxIteration, + '', + input.config.locale, + ); + + const toolsForLlm = availableTools.map((t) => ({ + name: t.name, + description: t.description, + schema: t.schema as z.ZodObject<Record<string, z.ZodTypeAny>>, + })); + + const actionStream = llm.streamText({ + messages: [{ role: 'system', content: researcherPrompt }, ...agentMessageHistory], + tools: toolsForLlm, + }); + + const block = session.getBlock(researchBlockId); + let reasoningEmitted = false; + let reasoningId = crypto.randomUUID(); + const finalToolCalls: ToolCall[] = []; + + for await (const partialRes of actionStream) { + if (partialRes.toolCallChunk?.length) { + for (const tc of partialRes.toolCallChunk) { + if ( + tc.name === '__reasoning_preamble' && + tc.arguments?.plan && + !reasoningEmitted && + block && + block.type === 'research' + ) { + reasoningEmitted = true; + (block.data.subSteps as ResearchBlockSubStep[]).push({ + id: reasoningId, + type: 'reasoning', + reasoning: String(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((s) => s.id === reasoningId); + if (subStepIndex !== -1) { + const subStep = block.data.subSteps[subStepIndex] as ReasoningResearchBlock; + subStep.reasoning = String(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 = { ...finalToolCalls[existingIndex].arguments, ...tc.arguments }; + } else { + finalToolCalls.push({ id: tc.id, name: tc.name, arguments: tc.arguments ?? {} }); + } + } + } + } + + if (finalToolCalls.length === 0) break; + + if (finalToolCalls[finalToolCalls.length - 1].name === 'done') break; + + agentMessageHistory.push({ + role: 'assistant', + content: '', + tool_calls: finalToolCalls, + }); + + const results = await executeAll(finalToolCalls, { + session, + researchBlockId, + fileIds: input.config.fileIds, + }); + + actionOutput.push(...results); + + for (let j = 0; j < finalToolCalls.length; j++) { + agentMessageHistory.push({ + role: 'tool', + content: JSON.stringify(results[j]), + id: finalToolCalls[j].id, + name: finalToolCalls[j].name, + } as { role: string; content: string; id: string; name: string }); + } + } + + const searchResults = actionOutput + .filter((a) => a.type === 'search_results' && a.results) + .flatMap((a) => a.results as Chunk[]); + + const seenUrls = new Map<string, number>(); + const filteredSearchResults = searchResults + .map((result, index) => { + const url = result.metadata?.url as string | undefined; + if (url && !seenUrls.has(url)) { + seenUrls.set(url, index); + return result; + } + if (url && seenUrls.has(url)) { + const existingIndex = seenUrls.get(url)!; + const existing = searchResults[existingIndex]; + existing.content += `\n\n${result.content}`; + return undefined; + } + return result; + }) + .filter((r): r is Chunk => r !== undefined); + + session.emitBlock({ + id: crypto.randomUUID(), + type: 'source', + data: filteredSearchResults, + }); + + return { searchFindings: filteredSearchResults }; +} + +type ResearchBlockSubStep = { id: string; type: string; reasoning?: string; reading?: Chunk[]; searching?: string[] }; diff --git a/services/master-agents-svc/src/lib/agent/searchOrchestrator.ts b/services/master-agents-svc/src/lib/agent/searchOrchestrator.ts new file mode 100644 index 0000000..8dd9f37 --- /dev/null +++ b/services/master-agents-svc/src/lib/agent/searchOrchestrator.ts @@ -0,0 +1,156 @@ +import type { LlmClient } from '../llm-client.js'; +import SessionManager from '../session.js'; +import type { TextBlock } from '../types.js'; +import type { ClassifierOutput } from '../actions/types.js'; +import { getClassifierPrompt } from '../prompts/classifier.js'; +import { getWriterPrompt } from '../prompts/writer.js'; +import { classify } from './classifier.js'; +import { research } from './researcher.js'; +import { executeAllWidgets } from '../widgets/index.js'; + +export type SearchOrchestratorConfig = { + llm: LlmClient; + mode: 'speed' | 'balanced' | 'quality'; + sources: ('web' | 'discussions' | 'academic')[]; + fileIds: string[]; + systemInstructions: string; + locale?: string; + memoryContext?: string; + answerMode?: import('../prompts/writer.js').AnswerMode; + responsePrefs?: { format?: string; length?: string; tone?: string }; + learningMode?: boolean; +}; + +export type SearchOrchestratorInput = { + chatHistory: { role: string; content: string }[]; + followUp: string; + config: SearchOrchestratorConfig; +}; + +export async function runSearchOrchestrator( + session: SessionManager, + input: SearchOrchestratorInput, +): Promise<void> { + const { chatHistory, followUp, config } = input; + + const classification = await classify({ + chatHistory, + query: followUp, + llm: config.llm, + locale: config.locale, + enabledSources: config.sources, + }); + + const widgetPromise = executeAllWidgets({ + chatHistory, + followUp, + classification, + llm: config.llm, + }).then((outputs) => { + for (const o of outputs) { + session.emitBlock({ + id: crypto.randomUUID(), + type: 'widget', + data: { widgetType: o.type, params: o.data ?? {} }, + }); + } + return outputs; + }); + + let searchPromise: Promise<{ searchFindings: import('../types.js').Chunk[] }> | null = null; + if (!classification.classification.skipSearch) { + searchPromise = research(session, config.llm, { + chatHistory, + followUp, + classification, + config: { + mode: config.mode, + sources: config.sources, + fileIds: config.fileIds, + locale: config.locale, + }, + }); + } + + const [widgetOutputs, searchResults] = await Promise.all([widgetPromise, searchPromise ?? Promise.resolve({ searchFindings: [] })]); + + 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) => `<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 source">\n${widgetContext}\n</widgets_result>`; + + const writerPrompt = getWriterPrompt( + finalContextWithWidgets, + config.systemInstructions, + config.mode, + config.locale, + config.memoryContext, + config.answerMode, + config.responsePrefs, + config.learningMode, + ); + + const answerStream = config.llm.streamText({ + messages: [ + { role: 'system', content: writerPrompt }, + ...chatHistory, + { role: 'user', content: 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 as string) ?? 'Без названия'; + 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', {}); +} diff --git a/services/master-agents-svc/src/lib/embedding-client.ts b/services/master-agents-svc/src/lib/embedding-client.ts new file mode 100644 index 0000000..979fcec --- /dev/null +++ b/services/master-agents-svc/src/lib/embedding-client.ts @@ -0,0 +1,37 @@ +/** + * EmbeddingClient — HTTP-клиент к llm-svc для эмбеддингов + */ + +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) throw new Error(`llm-svc embeddings failed: ${res.status} ${await res.text()}`); + 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); + }, + }; +} diff --git a/services/master-agents-svc/src/lib/llm-client.ts b/services/master-agents-svc/src/lib/llm-client.ts new file mode 100644 index 0000000..017d95e --- /dev/null +++ b/services/master-agents-svc/src/lib/llm-client.ts @@ -0,0 +1,100 @@ +/** + * LlmClient — HTTP-клиент к llm-svc для генерации + */ + +import z from 'zod'; + +export type Message = { role: string; content: string; id?: string; name?: string; tool_calls?: { id: string; name: string; arguments: Record<string, unknown> }[] }; +export type ToolCall = { id: string; name: string; arguments: Record<string, unknown> }; + +const LLM_SVC_URL = process.env.LLM_SVC_URL ?? ''; + +export type GenerateTextInput = { + messages: Message[]; + tools?: { name: string; description: string; schema: z.ZodObject<Record<string, z.ZodTypeAny>> }[]; + options?: { maxTokens?: number; temperature?: number }; +}; + +export type GenerateTextOutput = { content: string; toolCalls: ToolCall[] }; +export type StreamTextOutput = { contentChunk: string; toolCallChunk: ToolCall[]; done?: boolean }; + +export interface LlmClient { + generateText(input: GenerateTextInput): Promise<GenerateTextOutput>; + streamText(input: GenerateTextInput): AsyncGenerator<StreamTextOutput>; + generateObject<T>(input: { schema: z.ZodTypeAny; messages: Message[]; options?: object }): Promise<T>; +} + +function getBaseUrl(): string { + if (!LLM_SVC_URL) throw new Error('LLM_SVC_URL required'); + return LLM_SVC_URL.replace(/\/$/, ''); +} + +function serializeTool(t: NonNullable<GenerateTextInput['tools']>[0]) { + return { name: t.name, description: t.description, schema: z.toJSONSchema(t.schema) as Record<string, unknown> }; +} + +export function createLlmClient(model: { providerId: string; key: string }): LlmClient { + const base = getBaseUrl(); + return { + async generateText(input: GenerateTextInput): Promise<GenerateTextOutput> { + const res = await fetch(`${base}/api/v1/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages: input.messages, tools: input.tools?.map(serializeTool), options: input.options }), + signal: AbortSignal.timeout(120000), + }); + if (!res.ok) throw new Error(`llm-svc generate failed: ${res.status} ${await res.text()}`); + return res.json() as Promise<GenerateTextOutput>; + }, + async *streamText(input: GenerateTextInput): AsyncGenerator<StreamTextOutput> { + const res = await fetch(`${base}/api/v1/generate/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages: input.messages, tools: input.tools?.map(serializeTool), options: input.options }), + signal: AbortSignal.timeout(120000), + }); + if (!res.ok) throw new Error(`llm-svc stream failed: ${res.status} ${await res.text()}`); + const reader = res.body?.getReader(); + if (!reader) throw new Error('No response body'); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as StreamTextOutput | { error?: string }; + if ('error' in parsed && parsed.error) throw new Error(parsed.error); + yield parsed as StreamTextOutput; + } catch (e) { + if (!(e instanceof SyntaxError)) throw e; + } + } + } + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer) as StreamTextOutput | { error?: string }; + if ('error' in parsed && parsed.error) throw new Error(parsed.error); + yield parsed as StreamTextOutput; + } catch (e) { + if (!(e instanceof SyntaxError)) throw e; + } + } + }, + async generateObject<T>(input: { schema: z.ZodTypeAny; messages: Message[]; options?: object }): Promise<T> { + const res = await fetch(`${base}/api/v1/generate/object`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages: input.messages, schema: z.toJSONSchema(input.schema), options: input.options }), + signal: AbortSignal.timeout(60000), + }); + if (!res.ok) throw new Error(`llm-svc generateObject failed: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { object: T }; + return data.object; + }, + }; +} diff --git a/services/chat-svc/src/lib/prompts/search/classifier.ts b/services/master-agents-svc/src/lib/prompts/classifier.ts similarity index 72% rename from services/chat-svc/src/lib/prompts/search/classifier.ts rename to services/master-agents-svc/src/lib/prompts/classifier.ts index 7d80015..f5689d3 100644 --- a/services/chat-svc/src/lib/prompts/search/classifier.ts +++ b/services/master-agents-svc/src/lib/prompts/classifier.ts @@ -1,4 +1,4 @@ -import { getLocaleInstruction } from '../locale.js'; +import { getLocaleInstruction } from './locale.js'; const baseClassifierPrompt = ` <role> @@ -15,36 +15,30 @@ NOTE: BY GENERAL KNOWLEDGE WE MEAN INFORMATION THAT IS OBVIOUS, WIDELY KNOWN, OR - Set it to false if the query requires up-to-date information, specific details, or context that cannot be inferred from general knowledge. - ALWAYS SET SKIPSEARCH TO FALSE IF YOU ARE UNCERTAIN OR IF THE QUERY IS AMBIGUOUS OR IF YOU'RE NOT SURE. 2. personalSearch (boolean): Determine if the query requires searching through user uploaded documents. - - Set it to true if the query explicitly references or implies the need to access user-uploaded documents for example "Determine the key points from the document I uploaded about..." or "Who is the author?", "Summarize the content of the document" + - Set it to true if the query explicitly references or implies the need to access user-uploaded documents. - Set it to false if the query does not reference user-uploaded documents or if the information can be obtained through general web search. - ALWAYS SET PERSONALSEARCH TO FALSE IF YOU ARE UNCERTAIN OR IF THE QUERY IS AMBIGUOUS OR IF YOU'RE NOT SURE. AND SET SKIPSEARCH TO FALSE AS WELL. 3. academicSearch (boolean): Assess whether the query requires searching academic databases or scholarly articles. - - Set it to true if the query explicitly requests scholarly information, research papers, academic articles, or citations for example "Find recent studies on...", "What does the latest research say about...", or "Provide citations for..." + - Set it to true if the query explicitly requests scholarly information, research papers, academic articles, or citations. - Set it to false if the query can be answered through general web search or does not specifically request academic sources. 4. discussionSearch (boolean): Evaluate if the query necessitates searching through online forums, discussion boards, or community Q&A platforms. - - Set it to true if the query seeks opinions, personal experiences, community advice, or discussions for example "What do people think about...", "Are there any discussions on...", or "What are the common issues faced by..." + - Set it to true if the query seeks opinions, personal experiences, community advice, or discussions. - Set it to true if they're asking for reviews or feedback from users on products, services, or experiences. - Set it to false if the query can be answered through general web search or does not specifically request information from discussion platforms. 5. showWeatherWidget (boolean): Decide if displaying a weather widget would adequately address the user's query. - Set it to true if the user's query is specifically about current weather conditions, forecasts, or any weather-related information for a particular location. - - Set it to true for queries like "What's the weather like in [Location]?" or "Will it rain tomorrow in [Location]?" or "Show me the weather" (Here they mean weather of their current location). - If it can fully answer the user query without needing additional search, set skipSearch to true as well. 6. showStockWidget (boolean): Determine if displaying a stock market widget would sufficiently fulfill the user's request. - - Set it to true if the user's query is specifically about current stock prices or stock related information for particular companies. Never use it for a market analysis or news about stock market. - - Set it to true for queries like "What's the stock price of [Company]?" or "How is the [Stock] performing today?" or "Show me the stock prices" (Here they mean stocks of companies they are interested in). + - Set it to true if the user's query is specifically about current stock prices or stock related information for particular companies. - If it can fully answer the user query without needing additional search, set skipSearch to true as well. 7. showCalculationWidget (boolean): Decide if displaying a calculation widget would adequately address the user's query. - Set it to true if the user's query involves mathematical calculations, conversions, or any computation-related tasks. - - Set it to true for queries like "What is 25% of 80?" or "Convert 100 USD to EUR" or "Calculate the square root of 256" or "What is 2 * 3 + 5?" or other mathematical expressions. - If it can fully answer the user query without needing additional search, set skipSearch to true as well. </labels> <standalone_followup> For the standalone follow up, you have to generate a self contained, context independant reformulation of the user's query. -You basically have to rephrase the user's query in a way that it can be understood without any prior context from the conversation history. -Say for example the converastion is about cars and the user says "How do they work" then the standalone follow up should be "How do cars work?" - -Do not contain excess information or everything that has been discussed before, just reformulate the user's last query in a self contained manner. +Rephrase the user's query in a way that it can be understood without any prior context from the conversation history. The standalone follow-up should be concise and to the point. </standalone_followup> diff --git a/services/master-agents-svc/src/lib/prompts/locale.ts b/services/master-agents-svc/src/lib/prompts/locale.ts new file mode 100644 index 0000000..ec14230 --- /dev/null +++ b/services/master-agents-svc/src/lib/prompts/locale.ts @@ -0,0 +1,19 @@ +const LOCALE_TO_LANGUAGE: Record<string, string> = { + ru: 'Russian', en: 'English', de: 'German', fr: 'French', es: 'Spanish', + it: 'Italian', pt: 'Portuguese', uk: 'Ukrainian', pl: 'Polish', zh: 'Chinese', + ja: 'Japanese', ko: 'Korean', ar: 'Arabic', tr: 'Turkish', be: 'Belarusian', + kk: 'Kazakh', sv: 'Swedish', nb: 'Norwegian', da: 'Danish', fi: 'Finnish', + cs: 'Czech', sk: 'Slovak', hu: 'Hungarian', ro: 'Romanian', bg: 'Bulgarian', + hr: 'Croatian', sr: 'Serbian', el: 'Greek', hi: 'Hindi', th: 'Thai', + vi: 'Vietnamese', id: 'Indonesian', ms: 'Malay', he: 'Hebrew', fa: 'Persian', +}; + +export function getLocaleInstruction(locale?: string): string { + if (!locale) return ''; + const lang = locale.split('-')[0]; + const languageName = LOCALE_TO_LANGUAGE[lang] ?? lang; + return ` +<response_language> +User's locale is ${locale}. Always format your response in ${languageName}, regardless of the language of the query or search results. Even when the discussed content is in another language, respond in ${languageName}. +</response_language>`; +} diff --git a/services/master-agents-svc/src/lib/prompts/researcher.ts b/services/master-agents-svc/src/lib/prompts/researcher.ts new file mode 100644 index 0000000..1023242 --- /dev/null +++ b/services/master-agents-svc/src/lib/prompts/researcher.ts @@ -0,0 +1,149 @@ +import { getLocaleInstruction } from './locale.js'; + +type Mode = 'speed' | 'balanced' | 'quality'; + +function getSpeedPrompt(actionDesc: string, i: number, maxIteration: number, fileDesc: string): string { + const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + return ` +Assistant is an action orchestrator. Your job is to fulfill user requests by selecting and executing the available tools—no free-form replies. +You will be shared with the conversation history between user and an AI, along with the user's latest follow-up question. Based on this, you must use the available tools to fulfill the user's request. + +Today's date: ${today} + +You are currently on iteration ${i + 1} of your research process and have ${maxIteration} total iterations so act efficiently. +When you are finished, you must call the \`done\` tool. Never output text directly. + +<goal> +Fulfill the user's request as quickly as possible using the available tools. +Call tools to gather information or perform tasks as needed. +</goal> + +<core_principle> +Your knowledge is outdated; if you have web search, use it to ground answers even for seemingly basic facts. +</core_principle> + +<available_tools> +${actionDesc} +</available_tools> + +<response_protocol> +- NEVER output normal text to the user. ONLY call tools. +- Choose the appropriate tools based on the action descriptions provided above. +- Default to web_search when information is missing or stale; keep queries targeted (max 3 per call). +- Call done when you have gathered enough to answer or performed the required actions. +- Do not invent tools. Do not return JSON. +</response_protocol> + +${fileDesc ? `<user_uploaded_files>\n${fileDesc}\n</user_uploaded_files>` : ''} +`; +} + +function getBalancedPrompt(actionDesc: string, i: number, maxIteration: number, fileDesc: string): string { + const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + return ` +Assistant is an action orchestrator. Your job is to fulfill user requests by reasoning briefly and executing the available tools—no free-form replies. +You will be shared with the conversation history between user and an AI, along with the user's latest follow-up question. Based on this, you must use the available tools to fulfill the user's request. + +Today's date: ${today} + +You are currently on iteration ${i + 1} of your research process and have ${maxIteration} total iterations so act efficiently. +When you are finished, you must call the \`done\` tool. Never output text directly. + +<goal> +Fulfill the user's request with concise reasoning plus focused actions. +You MUST call the __reasoning_preamble tool before every tool call in this assistant turn. Alternate: __reasoning_preamble → tool → __reasoning_preamble → tool ... and finish with __reasoning_preamble → done. Open each __reasoning_preamble with a brief intent phrase (e.g., "Okay, the user wants to...", "Searching for...", "Looking into...") and lay out your reasoning for the next step. Keep it natural language, no tool names. +</goal> + +<core_principle> +Your knowledge is outdated; if you have web search, use it to ground answers even for seemingly basic facts. +You can call at most 6 tools total per turn: up to 2 reasoning (__reasoning_preamble counts as reasoning), 2-3 information-gathering calls, and 1 done. +Aim for at least two information-gathering calls when the answer is not already obvious. +</core_principle> + +<available_tools> +YOU MUST CALL __reasoning_preamble BEFORE EVERY TOOL CALL IN THIS ASSISTANT TURN. IF YOU DO NOT CALL IT, THE TOOL CALL WILL BE IGNORED. +${actionDesc} +</available_tools> + +<response_protocol> +- NEVER output normal text to the user. ONLY call tools. +- Start with __reasoning_preamble and call __reasoning_preamble before every tool call (including done). +- Choose tools based on the action descriptions provided above. +- Default to web_search when information is missing or stale; keep queries targeted (max 3 per call). +- Use at most 6 tool calls total. Do not invent tools. Do not return JSON. +</response_protocol> + +${fileDesc ? `<user_uploaded_files>\n${fileDesc}\n</user_uploaded_files>` : ''} +`; +} + +function getQualityPrompt(actionDesc: string, i: number, maxIteration: number, fileDesc: string): string { + const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + return ` +Assistant is a deep-research orchestrator. Your job is to fulfill user requests with the most thorough, comprehensive research possible—no free-form replies. +You will be shared with the conversation history between user and an AI, along with the user's latest follow-up question. Based on this, you must use the available tools to fulfill the user's request with depth and rigor. + +Today's date: ${today} + +You are currently on iteration ${i + 1} of your research process and have ${maxIteration} total iterations. Use every iteration wisely to gather comprehensive information. +When you are finished, you must call the \`done\` tool. Never output text directly. + +<goal> +Conduct the deepest, most thorough research possible. Leave no stone unturned. +Follow an iterative reason-act loop: call __reasoning_preamble before every tool call to outline the next step, then call the tool, then __reasoning_preamble again to reflect and decide the next step. Repeat until you have exhaustive coverage. +Open each __reasoning_preamble with a brief intent phrase and describe what you'll do next. Keep it natural language, no tool names. +Finish with done only when you have comprehensive, multi-angle information. +</goal> + +<core_principle> +Your knowledge is outdated; always use the available tools to ground answers. +This is DEEP RESEARCH mode—be exhaustive. Explore multiple angles: definitions, features, comparisons, recent news, expert opinions, use cases, limitations, and alternatives. +You can call up to 10 tools total per turn. Use an iterative loop: __reasoning_preamble → tool call(s) → __reasoning_preamble → tool call(s) → ... → __reasoning_preamble → done. +</core_principle> + +<available_tools> +YOU MUST CALL __reasoning_preamble BEFORE EVERY TOOL CALL IN THIS ASSISTANT TURN. IF YOU DO NOT CALL IT, THE TOOL CALL WILL BE IGNORED. +${actionDesc} +</available_tools> + +<research_strategy> +For any topic, consider searching: Core definition/overview, Features/capabilities, Comparisons, Recent news/updates, Reviews/opinions, Use cases, Limitations/critiques. +</research_strategy> + +<response_protocol> +- NEVER output normal text to the user. ONLY call tools. +- Follow an iterative loop: __reasoning_preamble → tool call → __reasoning_preamble → tool call → ... → __reasoning_preamble → done. +- Each __reasoning_preamble should reflect on previous results (if any) and state the next research step. +- Aim for 4-7 information-gathering calls covering different angles. +- Call done only after comprehensive, multi-angle research is complete. +- Do not invent tools. Do not return JSON. +</response_protocol> + +${fileDesc ? `<user_uploaded_files>\n${fileDesc}\n</user_uploaded_files>` : ''} +`; +} + +export function getResearcherPrompt( + actionDesc: string, + mode: Mode, + i: number, + maxIteration: number, + fileDesc: string, + locale?: string, +): string { + let prompt: string; + switch (mode) { + case 'speed': + prompt = getSpeedPrompt(actionDesc, i, maxIteration, fileDesc); + break; + case 'balanced': + prompt = getBalancedPrompt(actionDesc, i, maxIteration, fileDesc); + break; + case 'quality': + prompt = getQualityPrompt(actionDesc, i, maxIteration, fileDesc); + break; + default: + prompt = getSpeedPrompt(actionDesc, i, maxIteration, fileDesc); + } + return prompt + getLocaleInstruction(locale); +} diff --git a/services/master-agents-svc/src/lib/prompts/writer.ts b/services/master-agents-svc/src/lib/prompts/writer.ts new file mode 100644 index 0000000..cb40f40 --- /dev/null +++ b/services/master-agents-svc/src/lib/prompts/writer.ts @@ -0,0 +1,92 @@ +import { getLocaleInstruction } from './locale.js'; + +export type AnswerMode = + | 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance' + | 'health' | 'education' | 'medicine' | 'realEstate' | 'psychology' | 'sports' + | 'children' | 'goods' | 'shopping' | 'games' | 'taxes' | 'legislation'; + +export type ResponsePrefs = { format?: string; length?: string; tone?: string }; + +const VERTICAL_BLOCKS: Partial<Record<AnswerMode, string>> = { + travel: `### Answer Mode: Travel\nPrioritize: destinations, itineraries, hotels, transport, practical tips. Format: clear sections (Where to stay, What to see, Getting there).\n`, + finance: `### Answer Mode: Finance\nPrioritize: market data, company analysis, financial metrics. Cite sources for numbers.\n`, + health: `### Answer Mode: Health\nPrioritize: wellness, medicine, nutrition, fitness, mental health. Cite medical sources.\n`, + education: `### Answer Mode: Education\nPrioritize: learning, courses, pedagogy, academic resources.\n`, + medicine: `### Answer Mode: Medicine\nPrioritize: clinical info, treatments, diagnostics. Cite medical sources.\n`, + academic: `### Answer Mode: Academic\nPrioritize: scholarly sources, citations, research-based answers.\n`, + writing: `### Answer Mode: Writing\nPrioritize: clear structure, engaging prose, well-cited content.\n`, +}; + +export function getWriterPrompt( + context: string, + systemInstructions: string, + mode: 'speed' | 'balanced' | 'quality', + locale?: string, + memoryContext?: string, + answerMode?: AnswerMode, + responsePrefs?: ResponsePrefs, + learningMode?: boolean, +): string { + const memoryBlock = memoryContext?.trim() + ? `\n### User memory (personalization)\nUse these stored facts/preferences to personalize when relevant. Do NOT cite as source.\n${memoryContext}\n` + : ''; + const verticalBlock = answerMode ? (VERTICAL_BLOCKS[answerMode] ?? '') : ''; + + const prefs: string[] = []; + if (responsePrefs?.format) { + const f = responsePrefs.format; + if (f === 'bullets') prefs.push('Format: use bullet points where appropriate.'); + else if (f === 'outline') prefs.push('Format: use clear headings and outline structure.'); + else prefs.push('Format: use paragraphs and flowing prose.'); + } + if (responsePrefs?.length) { + const l = responsePrefs.length; + if (l === 'short') prefs.push('Length: keep response concise and brief.'); + else if (l === 'long') prefs.push('Length: provide comprehensive, detailed coverage.'); + else prefs.push('Length: medium depth, balanced.'); + } + if (responsePrefs?.tone) { + const t = responsePrefs.tone; + if (t === 'professional') prefs.push('Tone: formal, professional.'); + else if (t === 'casual') prefs.push('Tone: friendly, conversational.'); + else if (t === 'concise') prefs.push('Tone: direct, to the point.'); + else prefs.push('Tone: neutral.'); + } + const prefsBlock = prefs.length ? `\n### Response preferences\n${prefs.join(' ')}\n` : ''; + + const learningBlock = learningMode + ? `\n### Step-by-step Learning mode\nExplain your reasoning step-by-step. Break down complex concepts. Show the logical flow. Use numbered steps or "First... Then... Finally" structure.\n` + : ''; + + return ` +You are GooSeek, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. + +Your task is to provide answers that are: +- **Informative and relevant**: Thoroughly address the user's query using the given context. +- **Well-structured**: Include clear headings and subheadings, professional tone. +- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact. +- **Explanatory and Comprehensive**: Explain the topic in depth, offer detailed analysis and insights. + +### Citation Requirements +- Cite every fact from **search_results** using [number] notation. Citations [1], [2], etc. refer ONLY to sources in search_results. +- **widgets_result** (calculations, weather, stock data) — use this to answer directly, do NOT cite it. +- Integrate citations naturally at the end of sentences. + +### Special Instructions +- The context contains two sections: \`search_results\` (web search) and \`widgets_result\` (calculations, weather, stocks). If widgets_result has the answer, USE IT. +- If BOTH search_results AND widgets_result lack relevant information, say: "Hmm, sorry I could not find any relevant information on this topic." +${mode === 'quality' ? "- QUALITY MODE: Generate very deep, detailed responses. At least 2000 words, cover everything like a research report." : ''} + +${verticalBlock}${prefsBlock}${learningBlock} +### User instructions +${systemInstructions} +${memoryBlock} + +<context> +${context} +</context> + +Current date & time (UTC): ${new Date().toISOString()}. +${getLocaleInstruction(locale)} +`; +} diff --git a/services/master-agents-svc/src/lib/searxng.ts b/services/master-agents-svc/src/lib/searxng.ts new file mode 100644 index 0000000..f051de4 --- /dev/null +++ b/services/master-agents-svc/src/lib/searxng.ts @@ -0,0 +1,67 @@ +const SEARCH_SVC_URL = process.env.SEARCH_SVC_URL?.trim() ?? ''; +const SEARXNG_URL = process.env.SEARXNG_URL?.trim() ?? ''; +const FALLBACK = (process.env.SEARXNG_FALLBACK_URL ?? 'https://searx.tiekoetter.com,https://search.sapti.me') + .split(',') + .map((u) => u.trim()) + .filter(Boolean); + +export interface SearxngSearchResult { + title: string; + url: string; + content?: string; +} + +interface SearxngSearchOptions { + engines?: string[]; + categories?: string[]; +} + +function buildSearchUrl(baseUrl: string, query: string, opts?: SearxngSearchOptions): string { + const params = new URLSearchParams(); + params.append('format', 'json'); + params.append('q', query); + if (opts?.engines) params.append('engines', opts.engines.join(',')); + if (opts?.categories) params.append('categories', Array.isArray(opts.categories) ? opts.categories.join(',') : opts.categories); + const base = baseUrl.trim().replace(/\/$/, ''); + const prefix = /^https?:\/\//i.test(base) ? '' : 'http://'; + return `${prefix}${base}/search?${params.toString()}`; +} + +export async function searchSearxng( + query: string, + opts?: SearxngSearchOptions, +): Promise<{ results: SearxngSearchResult[]; suggestions?: string[] }> { + if (SEARCH_SVC_URL) { + const params = new URLSearchParams(); + params.set('q', query); + if (opts?.engines) params.set('engines', opts.engines.join(',')); + const url = `${SEARCH_SVC_URL.replace(/\/$/, '')}/api/v1/search?${params.toString()}`; + const res = await fetch(url, { signal: AbortSignal.timeout(15000) }); + if (!res.ok) throw new Error(`Search HTTP ${res.status}`); + return res.json() as Promise<{ results: SearxngSearchResult[]; suggestions?: string[] }>; + } + + const candidates: string[] = []; + if (SEARXNG_URL?.trim()) { + let u = SEARXNG_URL.trim().replace(/\/$/, ''); + if (!/^https?:\/\//i.test(u)) u = `http://${u}`; + candidates.push(u); + } + FALLBACK.forEach((u) => { + const t = u.trim().replace(/\/$/, ''); + if (t && !candidates.includes(t)) candidates.push(t); + }); + + let lastError: Error | null = null; + for (const baseUrl of candidates) { + try { + const url = buildSearchUrl(baseUrl, query, opts); + const res = await fetch(url, { signal: AbortSignal.timeout(15000) }); + const data = (await res.json()) as { results?: SearxngSearchResult[]; suggestions?: string[] }; + return { results: data.results ?? [], suggestions: data.suggestions }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + throw lastError ?? new Error('SearXNG not configured'); +} diff --git a/services/master-agents-svc/src/lib/session.ts b/services/master-agents-svc/src/lib/session.ts new file mode 100644 index 0000000..5f405b6 --- /dev/null +++ b/services/master-agents-svc/src/lib/session.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from 'node:events'; +import { applyPatch, type Operation } from 'rfc6902'; +import type { Block } from './types.js'; + +const sessions = new Map<string, SessionManager>(); + +class SessionManager { + readonly id: string; + private blocks = new Map<string, Block>(); + private events: { event: string; data: unknown }[] = []; + private emitter = new EventEmitter(); + + constructor(id?: string) { + this.id = id ?? crypto.randomUUID(); + } + + static createSession(): SessionManager { + const s = new SessionManager(); + sessions.set(s.id, s); + return s; + } + + emit(event: string, data: unknown) { + this.emitter.emit(event, data); + this.events.push({ event, data }); + } + + emitBlock(block: Block) { + this.blocks.set(block.id, block); + this.emit('data', { type: 'block', block }); + } + + getBlock(blockId: string): Block | undefined { + return this.blocks.get(blockId); + } + + updateBlock(blockId: string, patch: Operation[]) { + const block = this.blocks.get(blockId); + if (block) { + applyPatch(block, patch); + this.blocks.set(blockId, block); + this.emit('data', { type: 'updateBlock', blockId, patch }); + } + } + + getAllBlocks(): Block[] { + return Array.from(this.blocks.values()); + } + + subscribe(listener: (event: string, data: unknown) => void): () => void { + const handler = (event: string) => (data: unknown) => listener(event, data); + this.emitter.on('data', handler('data')); + this.emitter.on('end', handler('end')); + this.emitter.on('error', handler('error')); + for (const { event, data } of this.events) listener(event, data); + return () => { + this.emitter.off('data', handler('data')); + this.emitter.off('end', handler('end')); + this.emitter.off('error', handler('error')); + }; + } + + removeAllListeners() { + this.emitter.removeAllListeners(); + } +} + +export default SessionManager; diff --git a/services/master-agents-svc/src/lib/types.ts b/services/master-agents-svc/src/lib/types.ts index 545c28c..8585e92 100644 --- a/services/master-agents-svc/src/lib/types.ts +++ b/services/master-agents-svc/src/lib/types.ts @@ -1,11 +1,47 @@ -export type UserMessage = { role: 'user'; content: string }; -export type AssistantMessage = { - role: 'assistant'; - content: string; - tool_calls?: { id: string; name: string; arguments: Record<string, unknown> }[]; -}; -export type SystemMessage = { role: 'system'; content: string }; -export type ToolMessage = { role: 'tool'; id: string; name: string; content: string }; +export type Message = + | { role: 'user'; content: string } + | { role: 'assistant'; content: string; tool_calls?: { id: string; name: string; arguments: Record<string, unknown> }[] } + | { role: 'system'; content: string } + | { role: 'tool'; id: string; name: string; content: string }; -export type Message = UserMessage | AssistantMessage | SystemMessage | ToolMessage; -export type ChatTurnMessage = UserMessage | AssistantMessage; +export type ToolMessage = Extract<Message, { role: 'tool' }>; + +export type Chunk = { content: string; metadata: Record<string, unknown> }; + +export type TextBlock = { id: string; type: 'text'; data: string }; +export type SourceBlock = { id: string; type: 'source'; data: Chunk[] }; +export type WidgetBlock = { id: string; type: 'widget'; data: { widgetType: string; params: Record<string, unknown> } }; +export type ReasoningResearchBlock = { id: string; type: 'reasoning'; reasoning: string }; +export type SearchingResearchBlock = { id: string; type: 'searching'; searching: string[] }; +export type SearchResultsResearchBlock = { id: string; type: 'search_results'; reading: Chunk[] }; +export type ReadingResearchBlock = { id: string; type: 'reading'; reading: Chunk[] }; +export type UploadSearchingResearchBlock = { id: string; type: 'upload_searching'; queries: string[] }; +export type UploadSearchResultsResearchBlock = { id: string; type: 'upload_search_results'; results: Chunk[] }; + +export type ResearchBlockSubStep = + | ReasoningResearchBlock + | SearchingResearchBlock + | SearchResultsResearchBlock + | ReadingResearchBlock + | UploadSearchingResearchBlock + | UploadSearchResultsResearchBlock; + +export type ResearchBlock = { id: string; type: 'research'; data: { subSteps: ResearchBlockSubStep[] } }; + +export type Block = TextBlock | SourceBlock | WidgetBlock | ResearchBlock; + +export type SearchMode = 'speed' | 'balanced' | 'quality'; +export type SearchSources = 'web' | 'discussions' | 'academic'; + +export type ClassifierOutput = { + classification: { + skipSearch: boolean; + personalSearch: boolean; + academicSearch: boolean; + discussionSearch: boolean; + showWeatherWidget: boolean; + showStockWidget: boolean; + showCalculationWidget: boolean; + }; + standaloneFollowUp: string; +}; diff --git a/services/master-agents-svc/src/lib/utils/formatHistory.ts b/services/master-agents-svc/src/lib/utils/formatHistory.ts new file mode 100644 index 0000000..389bb66 --- /dev/null +++ b/services/master-agents-svc/src/lib/utils/formatHistory.ts @@ -0,0 +1,7 @@ +function formatChatHistoryAsString(history: { role: string; content: string }[]): string { + return history + .map((msg) => `${msg.role === 'assistant' ? 'AI' : 'User'}: ${msg.content}`) + .join('\n'); +} + +export default formatChatHistoryAsString; diff --git a/services/master-agents-svc/src/lib/widgets/calculationWidget.ts b/services/master-agents-svc/src/lib/widgets/calculationWidget.ts new file mode 100644 index 0000000..fcf81e2 --- /dev/null +++ b/services/master-agents-svc/src/lib/widgets/calculationWidget.ts @@ -0,0 +1,49 @@ +import z from 'zod'; +import { evaluate } from 'mathjs'; +import type { Widget } from './types.js'; +import formatChatHistoryAsString from '../utils/formatHistory.js'; + +const schema = z.object({ + expression: z.string().describe('Mathematical expression to calculate.'), + notPresent: z.boolean().describe('Whether there is any need for the calculation widget.'), +}); + +const systemPrompt = ` +You are a calculation expression extractor. Determine if there is a mathematical expression to calculate. +If there is, extract it. If not, set notPresent to true. +Respond in JSON: { "expression": string, "notPresent": boolean } +The expression must be valid for MathJS (mathjs.org). +`; + +const calculationWidget: Widget = { + type: 'calculationWidget', + shouldExecute: (c) => c.classification.showCalculationWidget, + execute: async (input) => { + const output = await input.llm.generateObject<z.infer<typeof schema>>({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>` }, + ], + schema, + }); + + if (output.notPresent) return; + + try { + const result = evaluate(output.expression); + return { + type: 'calculation_result', + llmContext: `The result of "${output.expression}" is: ${result}`, + data: { expression: output.expression, result }, + }; + } catch (err) { + return { + type: 'calculation_result', + llmContext: 'Calculation failed.', + data: { expression: output.expression, error: String(err) }, + }; + } + }, +}; + +export default calculationWidget; diff --git a/services/master-agents-svc/src/lib/widgets/executor.ts b/services/master-agents-svc/src/lib/widgets/executor.ts new file mode 100644 index 0000000..5bd4a1b --- /dev/null +++ b/services/master-agents-svc/src/lib/widgets/executor.ts @@ -0,0 +1,26 @@ +import type { Widget, WidgetInput, WidgetOutput } from './types.js'; + +const widgets = new Map<string, Widget>(); + +export function registerWidget(widget: Widget) { + widgets.set(widget.type, widget); +} + +export async function executeAllWidgets(input: WidgetInput): Promise<WidgetOutput[]> { + const results: WidgetOutput[] = []; + await Promise.all( + Array.from(widgets.values()).map(async (widget) => { + try { + if (widget.shouldExecute(input.classification)) { + const output = await widget.execute(input); + if (output) results.push(output); + } + } catch (err) { + if (err instanceof Error) { + results.push({ type: widget.type, llmContext: `Error: ${err.message}`, data: { error: err.message } }); + } + } + }), + ); + return results; +} diff --git a/services/master-agents-svc/src/lib/widgets/index.ts b/services/master-agents-svc/src/lib/widgets/index.ts new file mode 100644 index 0000000..af94195 --- /dev/null +++ b/services/master-agents-svc/src/lib/widgets/index.ts @@ -0,0 +1,10 @@ +import weatherWidget from './weatherWidget.js'; +import stockWidget from './stockWidget.js'; +import calculationWidget from './calculationWidget.js'; +import { registerWidget, executeAllWidgets } from './executor.js'; + +registerWidget(weatherWidget); +registerWidget(stockWidget); +registerWidget(calculationWidget); + +export { executeAllWidgets }; diff --git a/services/master-agents-svc/src/lib/widgets/stockWidget.ts b/services/master-agents-svc/src/lib/widgets/stockWidget.ts new file mode 100644 index 0000000..7a5a66b --- /dev/null +++ b/services/master-agents-svc/src/lib/widgets/stockWidget.ts @@ -0,0 +1,103 @@ +import z from 'zod'; +import YahooFinance from 'yahoo-finance2'; +import type { Widget } from './types.js'; +import formatChatHistoryAsString from '../utils/formatHistory.js'; + +const schema = z.object({ + name: z.string().describe('Stock name or ticker (e.g. Nvidia, AAPL)'), + comparisonNames: z.array(z.string()).max(3).optional().default([]), + notPresent: z.boolean().describe('Whether there is no need for the stock widget.'), +}); + +const systemPrompt = ` +You are a stock ticker/name extractor. Determine if the user is asking about stock information and extract the stock name(s). +- If asking about a stock, extract the primary name or ticker. +- If comparing stocks, extract up to 3 comparison names in comparisonNames. +- If not stock-related, set notPresent to true. +Respond in JSON: { "name": string, "comparisonNames": string[], "notPresent": boolean } +`; + +const stockWidget: Widget = { + type: 'stockWidget', + shouldExecute: (c) => c.classification.showStockWidget, + execute: async (input) => { + const output = await input.llm.generateObject<z.infer<typeof schema>>({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>` }, + ], + schema, + }); + + if (output.notPresent) return; + + try { + const yf = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); + const findings = await yf.search(output.name); + if (!findings.quotes?.length) throw new Error(`No quote for: ${output.name}`); + + const ticker = findings.quotes[0].symbol as string; + const quote = await yf.quote(ticker); + if (!quote) throw new Error(`No data for: ${ticker}`); + + const chart1D = await yf.chart(ticker, { + period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + period2: new Date(), + interval: '5m', + }).catch(() => null); + + const chartData: Record<string, { timestamps: number[]; prices: number[] } | null> = { + '1D': chart1D?.quotes ? { timestamps: chart1D.quotes.map((q) => q.date.getTime()), prices: chart1D.quotes.map((q) => q.close ?? 0) } : null, + '5D': null, '1M': null, '3M': null, '6M': null, '1Y': null, MAX: null, + }; + + const stockData = { + symbol: quote.symbol, + shortName: quote.shortName || quote.longName || ticker, + longName: quote.longName, + exchange: quote.fullExchangeName || quote.exchange, + currency: quote.currency, + marketState: quote.marketState, + regularMarketPrice: quote.regularMarketPrice, + regularMarketChange: quote.regularMarketChange, + regularMarketChangePercent: quote.regularMarketChangePercent, + regularMarketPreviousClose: quote.regularMarketPreviousClose, + regularMarketOpen: quote.regularMarketOpen, + regularMarketDayHigh: quote.regularMarketDayHigh, + regularMarketDayLow: quote.regularMarketDayLow, + regularMarketVolume: quote.regularMarketVolume, + averageDailyVolume3Month: quote.averageDailyVolume3Month, + marketCap: quote.marketCap, + fiftyTwoWeekLow: quote.fiftyTwoWeekLow, + fiftyTwoWeekHigh: quote.fiftyTwoWeekHigh, + trailingPE: quote.trailingPE, + forwardPE: quote.forwardPE, + dividendYield: quote.dividendYield, + earningsPerShare: quote.epsTrailingTwelveMonths, + website: quote.website, + postMarketPrice: quote.postMarketPrice, + postMarketChange: quote.postMarketChange, + postMarketChangePercent: quote.postMarketChangePercent, + preMarketPrice: quote.preMarketPrice, + preMarketChange: quote.preMarketChange, + preMarketChangePercent: quote.preMarketChangePercent, + chartData, + comparisonData: null, + }; + + return { + type: 'stock', + llmContext: `Current price of ${stockData.shortName} (${stockData.symbol}) is ${stockData.regularMarketPrice} ${stockData.currency}.`, + data: stockData, + }; + } catch (err) { + return { + type: 'stock', + llmContext: 'Failed to fetch stock data.', + data: { error: String(err), ticker: output.name }, + }; + } + }, +}; + +export default stockWidget; diff --git a/services/master-agents-svc/src/lib/widgets/types.ts b/services/master-agents-svc/src/lib/widgets/types.ts new file mode 100644 index 0000000..f720f69 --- /dev/null +++ b/services/master-agents-svc/src/lib/widgets/types.ts @@ -0,0 +1,20 @@ +import type { ClassifierOutput } from '../actions/types.js'; + +export type WidgetInput = { + chatHistory: { role: string; content: string }[]; + followUp: string; + classification: ClassifierOutput; + llm: { generateObject: <T>(input: { messages: { role: string; content: string }[]; schema: import('zod').ZodTypeAny }) => Promise<T> }; +}; + +export type WidgetOutput = { + type: string; + llmContext: string; + data: Record<string, unknown> | null; +}; + +export type Widget = { + type: string; + shouldExecute: (classification: ClassifierOutput) => boolean; + execute: (input: WidgetInput) => Promise<WidgetOutput | void>; +}; diff --git a/services/master-agents-svc/src/lib/widgets/weatherWidget.ts b/services/master-agents-svc/src/lib/widgets/weatherWidget.ts new file mode 100644 index 0000000..c90a234 --- /dev/null +++ b/services/master-agents-svc/src/lib/widgets/weatherWidget.ts @@ -0,0 +1,99 @@ +import z from 'zod'; +import type { Widget } from './types.js'; +import formatChatHistoryAsString from '../utils/formatHistory.js'; + +const schema = z.object({ + location: z.string().describe('Human-readable location name. Leave empty if providing coordinates.'), + lat: z.number().describe('Latitude. Only use when location is empty.'), + lon: z.number().describe('Longitude. Only use when location is empty.'), + notPresent: z.boolean().describe('Whether there is no need for the weather widget.'), +}); + +const systemPrompt = ` +You are a location extractor for weather queries. Determine if the user is asking about weather and extract the location. +- If asking about weather, extract location name OR coordinates (never both). +- If using coordinates, set location to empty string. +- If not weather-related or cannot determine location, set notPresent to true. +Respond in JSON: { "location": string, "lat": number, "lon": number, "notPresent": boolean } +`; + +const weatherWidget: Widget = { + type: 'weatherWidget', + shouldExecute: (c) => c.classification.showWeatherWidget, + execute: async (input) => { + const output = await input.llm.generateObject<z.infer<typeof schema>>({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>` }, + ], + schema, + }); + + if (output.notPresent) return; + + try { + if (output.location) { + const locRes = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(output.location)}&format=json&limit=1`, + { headers: { 'User-Agent': 'GooSeek' } }, + ); + const locData = (await locRes.json()) as { lat?: string; lon?: string }[]; + const loc = locData[0]; + if (!loc) throw new Error(`Location not found: ${output.location}`); + + const weatherRes = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${loc.lat}&longitude=${loc.lon}¤t=temperature_2m,relative_humidity_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7`, + { headers: { 'User-Agent': 'GooSeek' } }, + ); + const weatherData = (await weatherRes.json()) as { current?: Record<string, unknown>; daily?: Record<string, unknown>; timezone?: string }; + return { + type: 'weather', + llmContext: `Weather in ${output.location}: ${JSON.stringify(weatherData.current)}`, + data: { + location: output.location, + latitude: Number(loc.lat), + longitude: Number(loc.lon), + current: weatherData.current, + daily: weatherData.daily, + timezone: weatherData.timezone, + }, + }; + } + if (output.lat !== undefined && output.lon !== undefined) { + const [weatherRes, locRes] = await Promise.all([ + fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${output.lat}&longitude=${output.lon}¤t=temperature_2m,relative_humidity_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7`, + { headers: { 'User-Agent': 'GooSeek' } }, + ), + fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${output.lat}&lon=${output.lon}&format=json`, + { headers: { 'User-Agent': 'GooSeek' } }, + ), + ]); + const weatherData = (await weatherRes.json()) as { current?: Record<string, unknown>; daily?: Record<string, unknown>; timezone?: string }; + const locData = (await locRes.json()) as { display_name?: string }; + return { + type: 'weather', + llmContext: `Weather in ${locData.display_name}: ${JSON.stringify(weatherData.current)}`, + data: { + location: locData.display_name, + latitude: output.lat, + longitude: output.lon, + current: weatherData.current, + daily: weatherData.daily, + timezone: weatherData.timezone, + }, + }; + } + } catch (err) { + return { + type: 'weather', + llmContext: 'Failed to fetch weather data.', + data: { error: String(err) }, + }; + } + return; + }, +}; + +export default weatherWidget; diff --git a/services/master-agents-svc/src/turndown.d.ts b/services/master-agents-svc/src/turndown.d.ts new file mode 100644 index 0000000..8ce5c96 --- /dev/null +++ b/services/master-agents-svc/src/turndown.d.ts @@ -0,0 +1,5 @@ +declare module 'turndown' { + export default class TurndownService { + turndown(html: string): string; + } +} diff --git a/services/package.json b/services/package.json index b00c1e8..34837e4 100644 --- a/services/package.json +++ b/services/package.json @@ -2,5 +2,5 @@ "name": "@gooseek/services", "private": true, "version": "1.0.0", - "description": "GooSeek microservices - Perplexity analogue architecture" + "description": "GooSeek services (SOA) - Perplexity analogue architecture" } diff --git a/services/search-svc/Dockerfile b/services/search-svc/Dockerfile index b5a414d..21c7fa4 100644 --- a/services/search-svc/Dockerfile +++ b/services/search-svc/Dockerfile @@ -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 3001 CMD ["node", "dist/index.js"] diff --git a/services/search-svc/src/index.ts b/services/search-svc/src/index.ts index 59666d5..3bac5e5 100644 --- a/services/search-svc/src/index.ts +++ b/services/search-svc/src/index.ts @@ -1,6 +1,6 @@ /** * search-svc — SearXNG proxy, кэш по query_hash - * docs/architecture: 02-k3s-microservices-spec.md + * docs/architecture: 02-k3s-services-spec.md * API: GET /api/v1/search?q=...&categories=... * Redis: search:{query_hash} TTL 1h */ @@ -63,9 +63,22 @@ async function searchSearxng( const url = `${base.startsWith('http') ? base : 'http://' + base}/search?${params.toString()}`; const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 15000); - const res = await fetch(url, { signal: controller.signal }); + const res = await fetch(url, { + signal: controller.signal, + headers: { + 'User-Agent': 'GooSeek/1.0 (internal)', + 'X-Forwarded-For': '127.0.0.1', + 'X-Real-IP': '127.0.0.1', + }, + }); clearTimeout(t); - const data = (await res.json()) as { results?: unknown[]; suggestions?: string[] }; + const text = await res.text(); + let data: { results?: unknown[]; suggestions?: string[] }; + try { + data = JSON.parse(text) as { results?: unknown[]; suggestions?: string[] }; + } catch { + throw new Error(`SearXNG returned non-JSON (${res.status}): ${text.slice(0, 60)}`); + } if (!res.ok && (!data.results || data.results.length === 0)) { throw new Error(`SearXNG HTTP ${res.status}`); } @@ -144,9 +157,7 @@ app.get<{ return reply.header('X-Cache', 'MISS').send(data); } catch (err) { req.log.error(err); - return reply.status(502).send({ - error: err instanceof Error ? err.message : 'Search failed', - }); + return reply.status(200).send({ results: [], suggestions: [] }); } }); diff --git a/services/travel-svc/Dockerfile b/services/travel-svc/Dockerfile index 312cc6e..4c49b80 100644 --- a/services/travel-svc/Dockerfile +++ b/services/travel-svc/Dockerfile @@ -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 3004 CMD ["node", "dist/index.js"] diff --git a/services/web-svc/Dockerfile b/services/web-svc/Dockerfile index 04ffbc9..e355779 100644 --- a/services/web-svc/Dockerfile +++ b/services/web-svc/Dockerfile @@ -1,13 +1,16 @@ +# syntax=docker/dockerfile:1 # web-svc — Next.js UI, standalone output FROM node:22-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ COPY services ./services -RUN npm ci +# npm cache — ускоряет npm ci, НЕ трогает билд +RUN --mount=type=cache,target=/root/.npm \ + npm ci ENV NEXT_TELEMETRY_DISABLED=1 -# K8s: rewrites запекаются при сборке, нужен URL api-gateway ARG API_GATEWAY_URL=http://api-gateway.gooseek:3015 ENV API_GATEWAY_URL=${API_GATEWAY_URL} +# БЕЗ cache для .next — иначе старый билд попадает в прод RUN npm run build -w web-svc FROM node:22-alpine AS runner diff --git a/services/web-svc/next-env.d.ts b/services/web-svc/next-env.d.ts index c4b7818..9edff1c 100644 --- a/services/web-svc/next-env.d.ts +++ b/services/web-svc/next-env.d.ts @@ -1,6 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/services/web-svc/next.config.mjs b/services/web-svc/next.config.mjs index ac99291..7e5e472 100644 --- a/services/web-svc/next.config.mjs +++ b/services/web-svc/next.config.mjs @@ -31,6 +31,21 @@ const nextConfig = { env: { NEXT_PUBLIC_VERSION: pkg.version, }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate, max-age=0', + }, + { key: 'Pragma', value: 'no-cache' }, + { key: 'Expires', value: '0' }, + ], + }, + ]; + }, async rewrites() { const gateway = process.env.API_GATEWAY_URL ?? 'http://localhost:3015'; return [ @@ -52,14 +67,10 @@ const nextConfig = { }, }; +// PWA отключён — весь кэш выключен, хранятся только данные пользователя (localStorage) const withPWA = require('@ducanh2912/next-pwa').default({ dest: 'public', - disable: process.env.NODE_ENV === 'development', - register: true, - skipWaiting: true, - fallbacks: { - document: '/offline', - }, + disable: true, }); export default withPWA(nextConfig); diff --git a/services/web-svc/src/app/layout.tsx b/services/web-svc/src/app/layout.tsx index bbc0d07..79b8304 100644 --- a/services/web-svc/src/app/layout.tsx +++ b/services/web-svc/src/app/layout.tsx @@ -12,6 +12,7 @@ import { ChatProvider } from '@/lib/hooks/useChat'; import { ClientOnly } from '@/components/ClientOnly'; import GuestWarningBanner from '@/components/GuestWarningBanner'; import GuestMigration from '@/components/GuestMigration'; +import UnregisterSW from '@/components/UnregisterSW'; const roboto = Roboto({ weight: ['300', '400', '500', '700'], @@ -74,6 +75,7 @@ export default async function RootLayout({ } > <ChatProvider> + <UnregisterSW /> <Sidebar>{children}</Sidebar> <GuestWarningBanner /> <GuestMigration /> diff --git a/services/web-svc/src/components/EmptyChatMessageInput.tsx b/services/web-svc/src/components/EmptyChatMessageInput.tsx index 757b58c..1a2eca4 100644 --- a/services/web-svc/src/components/EmptyChatMessageInput.tsx +++ b/services/web-svc/src/components/EmptyChatMessageInput.tsx @@ -1,14 +1,11 @@ import { ArrowRight } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; -import Sources from './MessageInputActions/Sources'; -import Optimization from './MessageInputActions/Optimization'; import AnswerMode from './MessageInputActions/AnswerMode'; import InputBarPlus from './MessageInputActions/InputBarPlus'; import Attach from './MessageInputActions/Attach'; import { useChat } from '@/lib/hooks/useChat'; import { useTranslation } from '@/lib/localization/context'; -import ModelSelector from './MessageInputActions/ChatModelSelector'; const EmptyChatMessageInput = () => { const { sendMessage } = useChat(); @@ -71,13 +68,10 @@ const EmptyChatMessageInput = () => { <div className="flex flex-row items-center justify-between mt-4"> <div className="flex flex-row items-center gap-1"> <InputBarPlus /> - <Optimization /> <AnswerMode /> </div> <div className="flex flex-row items-center space-x-2"> <div className="flex flex-row items-center space-x-1"> - <Sources /> - <ModelSelector /> <Attach /> </div> <button diff --git a/services/web-svc/src/components/MessageInputActions/AnswerMode.tsx b/services/web-svc/src/components/MessageInputActions/AnswerMode.tsx index e8ca533..a5c468e 100644 --- a/services/web-svc/src/components/MessageInputActions/AnswerMode.tsx +++ b/services/web-svc/src/components/MessageInputActions/AnswerMode.tsx @@ -1,27 +1,65 @@ -import { ChevronDown, Globe, Plane, TrendingUp, BookOpen, PenLine } from 'lucide-react'; -import { cn } from '@/lib/utils'; +'use client'; + import { - Popover, - PopoverButton, - PopoverPanel, -} from '@headlessui/react'; + Globe, + Plane, + TrendingUp, + BookOpen, + PenLine, + HeartPulse, + GraduationCap, + Stethoscope, + Building2, + Brain, + Trophy, + Baby, + Package, + ShoppingCart, + Gamepad2, + Receipt, + Scale, + ChevronDown, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { useChat } from '@/lib/hooks/useChat'; +import { useTranslation } from '@/lib/localization/context'; import { AnimatePresence, motion } from 'motion/react'; -type AnswerModeKey = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance'; +type AnswerModeKey = + | 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance' + | 'health' | 'education' | 'medicine' | 'realEstate' | 'psychology' | 'sports' + | 'children' | 'goods' | 'shopping' | 'games' | 'taxes' | 'legislation'; -const AnswerModes: { key: AnswerModeKey; title: string; icon: React.ReactNode }[] = [ - { key: 'standard', title: 'Standard', icon: <Globe size={16} className="text-[#EA580C]" /> }, - { key: 'travel', title: 'Travel', icon: <Plane size={16} className="text-[#EA580C]" /> }, - { key: 'finance', title: 'Finance', icon: <TrendingUp size={16} className="text-[#EA580C]" /> }, - { key: 'academic', title: 'Academic', icon: <BookOpen size={16} className="text-[#EA580C]" /> }, - { key: 'writing', title: 'Writing', icon: <PenLine size={16} className="text-[#EA580C]" /> }, - { key: 'focus', title: 'Focus', icon: <Globe size={16} className="text-[#EA580C]" /> }, +const ANSWER_MODE_CONFIG: { + key: AnswerModeKey; + Icon: React.ComponentType<{ size?: number | string; className?: string }>; + labelKey: string; +}[] = [ + { key: 'standard', Icon: Globe, labelKey: 'answerMode.standard' }, + { key: 'focus', Icon: Globe, labelKey: 'answerMode.focus' }, + { key: 'academic', Icon: BookOpen, labelKey: 'answerMode.academic' }, + { key: 'writing', Icon: PenLine, labelKey: 'answerMode.writing' }, + { key: 'travel', Icon: Plane, labelKey: 'nav.travel' }, + { key: 'finance', Icon: TrendingUp, labelKey: 'nav.finance' }, + { key: 'health', Icon: HeartPulse, labelKey: 'nav.health' }, + { key: 'education', Icon: GraduationCap, labelKey: 'nav.education' }, + { key: 'medicine', Icon: Stethoscope, labelKey: 'nav.medicine' }, + { key: 'realEstate', Icon: Building2, labelKey: 'nav.realEstate' }, + { key: 'psychology', Icon: Brain, labelKey: 'nav.psychology' }, + { key: 'sports', Icon: Trophy, labelKey: 'nav.sports' }, + { key: 'children', Icon: Baby, labelKey: 'nav.children' }, + { key: 'goods', Icon: Package, labelKey: 'nav.goods' }, + { key: 'shopping', Icon: ShoppingCart, labelKey: 'nav.shopping' }, + { key: 'games', Icon: Gamepad2, labelKey: 'nav.games' }, + { key: 'taxes', Icon: Receipt, labelKey: 'nav.taxes' }, + { key: 'legislation', Icon: Scale, labelKey: 'nav.legislation' }, ]; const AnswerMode = () => { const { answerMode, setAnswerMode } = useChat(); - const current = AnswerModes.find((m) => m.key === answerMode) ?? AnswerModes[0]; + const { t } = useTranslation(); + const current = ANSWER_MODE_CONFIG.find((m) => m.key === answerMode) ?? ANSWER_MODE_CONFIG[0]; return ( <Popover className="relative"> @@ -33,8 +71,8 @@ const AnswerMode = () => { title="Answer mode" > <div className="flex flex-row items-center space-x-1"> - {current.icon} - <span className="text-xs hidden sm:inline">{current.title}</span> + <current.Icon size={16} className="text-[#EA580C]" /> + <span className="text-xs">{t(current.labelKey)}</span> <ChevronDown size={14} className={cn(open ? 'rotate-180' : 'rotate-0', 'transition')} @@ -44,7 +82,7 @@ const AnswerMode = () => { <AnimatePresence> {open && ( <PopoverPanel - className="absolute z-10 w-48 left-0 bottom-full mb-2" + className="absolute z-10 left-0 bottom-full mb-2" static > <motion.div @@ -52,23 +90,31 @@ const AnswerMode = () => { animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.1, ease: 'easeOut' }} - className="origin-bottom-left flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-2" + className="origin-bottom-left bg-light-primary dark:bg-dark-primary border rounded-xl border-light-200 dark:border-dark-200 p-2 shadow-xl min-w-[380px]" > - {AnswerModes.map((mode) => ( - <PopoverButton - key={mode.key} - onClick={() => setAnswerMode(mode.key)} - className={cn( - 'p-2 rounded-lg flex flex-row items-center gap-2 text-start cursor-pointer transition focus:outline-none', - answerMode === mode.key - ? 'bg-light-secondary dark:bg-dark-secondary' - : 'hover:bg-light-secondary dark:hover:bg-dark-secondary', - )} - > - {mode.icon} - <span className="text-sm font-medium">{mode.title}</span> - </PopoverButton> - ))} + <div className="grid grid-cols-6 gap-2 max-h-[220px] overflow-y-auto"> + {ANSWER_MODE_CONFIG.map((mode) => { + const Icon = mode.Icon; + return ( + <PopoverButton + key={mode.key} + onClick={() => setAnswerMode(mode.key)} + className={cn( + 'flex flex-col items-center justify-center gap-1 w-14 h-14 rounded-xl transition duration-200 focus:outline-none shrink-0', + answerMode === mode.key + ? 'bg-[#EA580C]/20 text-[#EA580C] border border-[#EA580C]/40' + : 'bg-light-200/80 dark:bg-dark-200/80 text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200 border border-transparent', + )} + title={t(mode.labelKey)} + > + <Icon size={18} className="text-[#EA580C]" /> + <span className="text-[9px] font-medium leading-tight text-center truncate max-w-full px-0.5"> + {t(mode.labelKey)} + </span> + </PopoverButton> + ); + })} + </div> </motion.div> </PopoverPanel> )} diff --git a/services/web-svc/src/components/MessageInputActions/ChatModelSelector.tsx b/services/web-svc/src/components/MessageInputActions/ChatModelSelector.tsx deleted file mode 100644 index f8177ad..0000000 --- a/services/web-svc/src/components/MessageInputActions/ChatModelSelector.tsx +++ /dev/null @@ -1,209 +0,0 @@ -'use client'; - -import { Cpu, Search } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; -import { useEffect, useMemo, useState } from 'react'; -import type { MinimalProvider } from '@/lib/types-ui'; -import { useChat } from '@/lib/hooks/useChat'; -import { AnimatePresence, motion } from 'motion/react'; - -const ModelSelector = () => { - const [providers, setProviders] = useState<MinimalProvider[]>([]); - const [envOnlyMode, setEnvOnlyMode] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - - const { setChatModelProvider, chatModelProvider } = useChat(); - - useEffect(() => { - const loadProviders = async () => { - try { - setIsLoading(true); - const res = await fetch('/api/providers'); - - if (!res.ok) { - throw new Error('Failed to fetch providers'); - } - - const data: { providers: MinimalProvider[]; envOnlyMode?: boolean } = await res.json(); - setProviders(data.providers); - setEnvOnlyMode(data.envOnlyMode ?? false); - } catch (error) { - console.error('Error loading providers:', error); - } finally { - setIsLoading(false); - } - }; - - loadProviders(); - }, []); - - const orderedProviders = useMemo(() => { - if (!chatModelProvider?.providerId) return providers; - - const currentProviderIndex = providers.findIndex( - (p) => p.id === chatModelProvider.providerId, - ); - - if (currentProviderIndex === -1) { - return providers; - } - - const selectedProvider = providers[currentProviderIndex]; - const remainingProviders = providers.filter( - (_, index) => index !== currentProviderIndex, - ); - - return [selectedProvider, ...remainingProviders]; - }, [providers, chatModelProvider]); - - const handleModelSelect = (providerId: string, modelKey: string) => { - setChatModelProvider({ providerId, key: modelKey }); - localStorage.setItem('chatModelProviderId', providerId); - localStorage.setItem('chatModelKey', modelKey); - }; - - const filteredProviders = orderedProviders - .map((provider) => ({ - ...provider, - chatModels: provider.chatModels.filter( - (model) => - model.name.toLowerCase().includes(searchQuery.toLowerCase()) || - provider.name.toLowerCase().includes(searchQuery.toLowerCase()), - ), - })) - .filter((provider) => provider.chatModels.length > 0); - - if (envOnlyMode) return null; - - return ( - <Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg"> - {({ open }) => ( - <> - <PopoverButton - type="button" - className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white" - > - <Cpu size={16} className="text-[#EA580C]" /> - </PopoverButton> - <AnimatePresence> - {open && ( - <PopoverPanel - className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] right-0 bottom-full mb-2" - static - > - <motion.div - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.9 }} - transition={{ duration: 0.1, ease: 'easeOut' }} - className="origin-bottom-right bg-light-primary dark:bg-dark-primary max-h-[300px] sm:max-w-none border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden" - > - <div className="p-2 border-b border-light-200 dark:border-dark-200"> - <div className="relative"> - <Search - size={16} - className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40" - /> - <input - type="text" - placeholder="Search models..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - className="w-full pl-8 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg placeholder:text-xs placeholder:-translate-y-[1.5px] text-xs text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none border border-transparent transition duration-200" - /> - </div> - </div> - - <div className="max-h-[320px] overflow-y-auto"> - {isLoading ? ( - <div className="flex flex-col gap-2 py-16 px-4"> - {[1, 2, 3, 4].map((i) => ( - <div - key={i} - className="h-10 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" - /> - ))} - </div> - ) : filteredProviders.length === 0 ? ( - <div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm"> - {searchQuery - ? 'No models found' - : 'No chat models configured'} - </div> - ) : ( - <div className="flex flex-col"> - {filteredProviders.map((provider, providerIndex) => ( - <div key={provider.id}> - <div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50"> - <p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider"> - {provider.name} - </p> - </div> - - <div className="flex flex-col px-2 py-2 space-y-0.5"> - {provider.chatModels.map((model) => ( - <button - key={model.key} - onClick={() => - handleModelSelect(provider.id, model.key) - } - type="button" - className={cn( - 'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group', - chatModelProvider?.providerId === - provider.id && - chatModelProvider?.key === model.key - ? 'bg-light-secondary dark:bg-dark-secondary' - : 'hover:bg-light-secondary dark:hover:bg-dark-secondary', - )} - > - <div className="flex items-center space-x-2.5 min-w-0 flex-1"> - <Cpu - size={15} - className={cn( - 'shrink-0', - chatModelProvider?.providerId === - provider.id && - chatModelProvider?.key === model.key - ? 'text-[#EA580C]' - : 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70', - )} - /> - <p - className={cn( - 'text-xs truncate', - chatModelProvider?.providerId === - provider.id && - chatModelProvider?.key === model.key - ? 'text-[#EA580C] font-medium' - : 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white', - )} - > - {model.name} - </p> - </div> - </button> - ))} - </div> - - {providerIndex < filteredProviders.length - 1 && ( - <div className="h-px bg-light-200 dark:bg-dark-200" /> - )} - </div> - ))} - </div> - )} - </div> - </motion.div> - </PopoverPanel> - )} - </AnimatePresence> - </> - )} - </Popover> - ); -}; - -export default ModelSelector; diff --git a/services/web-svc/src/components/MessageInputActions/InputBarPlus.tsx b/services/web-svc/src/components/MessageInputActions/InputBarPlus.tsx index 6bdc9ba..ac29b9c 100644 --- a/services/web-svc/src/components/MessageInputActions/InputBarPlus.tsx +++ b/services/web-svc/src/components/MessageInputActions/InputBarPlus.tsx @@ -2,7 +2,6 @@ /** * Input bar «+» — меню: режимы, источники, Learn, Create, Model Council - * docs/architecture: 01-perplexity-analogue-design.md §2.2.A */ import { Plus, Zap, Sliders, Star, Globe, GraduationCap, Network, BookOpen, Users } from 'lucide-react'; diff --git a/services/web-svc/src/components/MessageInputActions/Optimization.tsx b/services/web-svc/src/components/MessageInputActions/Optimization.tsx deleted file mode 100644 index dc19fd0..0000000 --- a/services/web-svc/src/components/MessageInputActions/Optimization.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { ChevronDown, Sliders, Star, Zap } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { - Popover, - PopoverButton, - PopoverPanel, - Transition, -} from '@headlessui/react'; -import { Fragment } from 'react'; -import { useChat } from '@/lib/hooks/useChat'; -import { AnimatePresence, motion } from 'motion/react'; - -const OptimizationModes = [ - { - key: 'speed', - title: 'Speed', - description: 'Prioritize speed and get the quickest possible answer.', - icon: <Zap size={16} className="text-[#EA580C]" />, - }, - { - key: 'balanced', - title: 'Balanced', - description: 'Find the right balance between speed and accuracy', - icon: <Sliders size={16} className="text-[#EA580C]" />, - }, - { - key: 'quality', - title: 'Quality', - description: 'Get the most thorough and accurate answer', - icon: ( - <Star - size={16} - className="text-[#EA580C] fill-[#EA580C]" - /> - ), - }, -]; - -const Optimization = () => { - const { optimizationMode, setOptimizationMode } = useChat(); - - return ( - <Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg"> - {({ open }) => ( - <> - <PopoverButton - type="button" - className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none" - > - <div className="flex flex-row items-center space-x-1"> - { - OptimizationModes.find((mode) => mode.key === optimizationMode) - ?.icon - } - <ChevronDown - size={16} - className={cn( - open ? 'rotate-180' : 'rotate-0', - 'transition duration:200', - )} - /> - </div> - </PopoverButton> - <AnimatePresence> - {open && ( - <PopoverPanel - className="absolute z-10 w-64 md:w-[250px] left-0 bottom-full mb-2" - static - > - <motion.div - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.9 }} - transition={{ duration: 0.1, ease: 'easeOut' }} - className="origin-bottom-left flex flex-col space-y-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-2 max-h-[200px] md:max-h-none overflow-y-auto" - > - {OptimizationModes.map((mode, i) => ( - <PopoverButton - onClick={() => setOptimizationMode(mode.key)} - key={i} - className={cn( - 'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none', - optimizationMode === mode.key - ? 'bg-light-secondary dark:bg-dark-secondary' - : 'hover:bg-light-secondary dark:hover:bg-dark-secondary', - )} - > - <div className="flex flex-row justify-between w-full text-black dark:text-white"> - <div className="flex flex-row space-x-1"> - {mode.icon} - <p className="text-xs font-medium">{mode.title}</p> - </div> - {mode.key === 'quality' && ( - <span className="bg-[#EA580C]/70 dark:bg-[#EA580C]/40 border border-[#EA580C] px-1 rounded-full text-[10px] text-white"> - Beta - </span> - )} - </div> - <p className="text-black/70 dark:text-white/70 text-xs"> - {mode.description} - </p> - </PopoverButton> - ))} - </motion.div> - </PopoverPanel> - )} - </AnimatePresence> - </> - )} - </Popover> - ); -}; - -export default Optimization; diff --git a/services/web-svc/src/components/MessageInputActions/Sources.tsx b/services/web-svc/src/components/MessageInputActions/Sources.tsx deleted file mode 100644 index 13fedcc..0000000 --- a/services/web-svc/src/components/MessageInputActions/Sources.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useChat } from '@/lib/hooks/useChat'; -import { - Popover, - PopoverButton, - PopoverPanel, - Switch, -} from '@headlessui/react'; -import { - GlobeIcon, - GraduationCapIcon, - NetworkIcon, -} from '@phosphor-icons/react'; -import { AnimatePresence, motion } from 'motion/react'; - -const sourcesList = [ - { - name: 'Web', - key: 'web', - icon: <GlobeIcon className="h-[16px] w-auto" />, - }, - { - name: 'Academic', - key: 'academic', - icon: <GraduationCapIcon className="h-[16px] w-auto" />, - }, - { - name: 'Social', - key: 'discussions', - icon: <NetworkIcon className="h-[16px] w-auto" />, - }, -]; - -const Sources = () => { - const { sources, setSources } = useChat(); - - return ( - <Popover className="relative"> - {({ open }) => ( - <> - <PopoverButton className="flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"> - <GlobeIcon className="h-[18px] w-auto" /> - </PopoverButton> - <AnimatePresence> - {open && ( - <PopoverPanel - static - className="absolute z-10 w-64 md:w-[225px] right-0 bottom-full mb-2" - > - <motion.div - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.9 }} - transition={{ duration: 0.1, ease: 'easeOut' }} - className="origin-bottom-right flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-1 max-h-[200px] md:max-h-none overflow-y-auto shadow-lg" - > - {sourcesList.map((source, i) => ( - <div - key={i} - className="flex flex-row justify-between hover:bg-light-100 hover:dark:bg-dark-100 rounded-md py-3 px-2 cursor-pointer" - onClick={() => { - if (!sources.includes(source.key)) { - setSources([...sources, source.key]); - } else { - setSources(sources.filter((s) => s !== source.key)); - } - }} - > - <div className="flex flex-row space-x-1.5 text-black/80 dark:text-white/80"> - {source.icon} - <p className="text-xs">{source.name}</p> - </div> - <Switch - checked={sources.includes(source.key)} - className="group relative flex h-4 w-7 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-0.5 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-[#EA580C] dark:data-[checked]:bg-[#EA580C]" - > - <span - aria-hidden="true" - className="pointer-events-none inline-block size-3 translate-x-[1px] group-data-[checked]:translate-x-3 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out" - /> - </Switch> - </div> - ))} - </motion.div> - </PopoverPanel> - )} - </AnimatePresence> - </> - )} - </Popover> - ); -}; - -export default Sources; diff --git a/services/web-svc/src/components/Sidebar.tsx b/services/web-svc/src/components/Sidebar.tsx index 4375b86..d2f75df 100644 --- a/services/web-svc/src/components/Sidebar.tsx +++ b/services/web-svc/src/components/Sidebar.tsx @@ -737,7 +737,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { </div> </div> - <div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-light-secondary dark:bg-dark-secondary px-4 py-4 shadow-sm lg:hidden"> + <div className="fixed bottom-0 w-full z-50 flex flex-row items-center justify-around gap-x-1 bg-light-secondary dark:bg-dark-secondary px-2 py-2 shadow-sm lg:hidden"> {topMainIds.map((id) => { const link = linkMap[id]; if (!link) return null; @@ -746,43 +746,43 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { href={link.href} key={id} className={cn( - 'relative flex flex-col items-center space-y-1 text-center w-full', + 'relative flex flex-col items-center gap-0.5 text-center flex-1 min-w-0', link.active ? 'text-black dark:text-white' : 'text-black dark:text-white/70', )} > {link.active && ( - <div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" /> + <div className="absolute top-0 -mt-2 h-0.5 w-full rounded-b bg-black dark:bg-white" /> )} - <link.icon /> - <p className="text-xs text-fade min-w-0">{link.label}</p> + <link.icon size={18} /> + <p className="text-[10px] leading-tight text-fade min-w-0 truncate">{link.label}</p> </Link> ) : ( <span key={id} - className="relative flex flex-col items-center space-y-1 text-center w-full text-black/50 dark:text-white/50 cursor-default" + className="relative flex flex-col items-center gap-0.5 text-center flex-1 min-w-0 text-black/50 dark:text-white/50 cursor-default" > - <link.icon /> - <p className="text-xs text-fade min-w-0">{link.label}</p> + <link.icon size={18} /> + <p className="text-[10px] leading-tight text-fade min-w-0 truncate">{link.label}</p> </span> ); })} <button type="button" onClick={() => setMoreOpen(true)} - className="relative flex flex-col items-center space-y-1 text-center shrink-0 text-black/70 dark:text-white/70" + className="relative flex flex-col items-center gap-0.5 text-center shrink-0 text-black/70 dark:text-white/70" title={t('nav.more')} aria-label={t('nav.more')} > - <MoreHorizontal size={20} /> - <p className="text-xs text-fade">{t('nav.more')}</p> + <MoreHorizontal size={18} /> + <p className="text-[10px] leading-tight text-fade">{t('nav.more')}</p> </button> {showProfile && ( <Link href={profileLink.href} className={cn( - 'relative flex flex-col items-center space-y-1 text-center shrink-0', + 'relative flex flex-col items-center gap-0.5 text-center shrink-0', profileLink.active ? 'text-black dark:text-white' : 'text-black dark:text-white/70', @@ -790,10 +790,10 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { title={profileLink.label} > {profileLink.active && ( - <div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" /> + <div className="absolute top-0 -mt-2 h-0.5 w-full rounded-b bg-black dark:bg-white" /> )} - <profileLink.icon size={20} /> - <p className="text-xs text-fade min-w-0">{profileLink.label}</p> + <profileLink.icon size={18} /> + <p className="text-[10px] leading-tight text-fade min-w-0 truncate">{profileLink.label}</p> </Link> )} </div> diff --git a/services/web-svc/src/components/UnregisterSW.tsx b/services/web-svc/src/components/UnregisterSW.tsx new file mode 100644 index 0000000..1ab806f --- /dev/null +++ b/services/web-svc/src/components/UnregisterSW.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useEffect } from 'react'; + +/** + * Снимает регистрацию старых Service Worker (PWA отключён). + * Очищает кэш SW у пользователей, у которых была предыдущая версия. + */ +export default function UnregisterSW() { + useEffect(() => { + if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return; + + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((reg) => reg.unregister()); + }); + }, []); + + return null; +} diff --git a/services/web-svc/src/lib/hooks/useChat.tsx b/services/web-svc/src/lib/hooks/useChat.tsx index 0dc35ef..7c39b6d 100644 --- a/services/web-svc/src/lib/hooks/useChat.tsx +++ b/services/web-svc/src/lib/hooks/useChat.tsx @@ -34,7 +34,10 @@ export type Section = { suggestions?: string[]; }; -type AnswerModeKey = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance'; +type AnswerModeKey = + | 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance' + | 'health' | 'education' | 'medicine' | 'realEstate' | 'psychology' | 'sports' + | 'children' | 'goods' | 'shopping' | 'games' | 'taxes' | 'legislation'; type ChatContext = { messages: Message[]; @@ -115,27 +118,40 @@ const checkConfig = async ( } const data = await res.json(); - const providers: MinimalProvider[] = data.providers; + const providers: MinimalProvider[] = data.providers ?? []; const envOnlyMode = data.envOnlyMode ?? false; - if (providers.length === 0) { + if (providers.length === 0 && !envOnlyMode) { throw new Error('Сервис настраивается. Попробуйте позже.'); } - const chatModelProvider = + let chatModelProvider = providers.find((p) => p.id === chatModelProviderId) ?? providers.find((p) => p.chatModels.length > 0); - if (!chatModelProvider) { + if (!chatModelProvider && !envOnlyMode) { throw new Error('Сервис настраивается. Попробуйте позже.'); } - chatModelProviderId = chatModelProvider.id; - - const chatModel = - chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ?? - chatModelProvider.chatModels[0]; - chatModelKey = chatModel.key; + if (chatModelProvider) { + chatModelProviderId = chatModelProvider.id; + const chatModel = + chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ?? + chatModelProvider.chatModels[0]; + chatModelKey = chatModel.key; + } else if (envOnlyMode && providers.length > 0) { + const envProvider = providers.find((p) => p.id.startsWith('env-')); + if (envProvider) { + chatModelProviderId = envProvider.id; + chatModelKey = envProvider.chatModels[0]?.key ?? 'default'; + } else { + chatModelProviderId = providers[0].id; + chatModelKey = providers[0].chatModels[0]?.key ?? 'default'; + } + } else { + chatModelProviderId = 'env'; + chatModelKey = 'default'; + } const embeddingModelProvider = providers.find((p) => p.id === embeddingModelProviderId) ?? @@ -637,7 +653,8 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => { useEffect(() => { const urlMode = searchParams.get('answerMode'); - if (urlMode && ['standard', 'focus', 'academic', 'writing', 'travel', 'finance'].includes(urlMode)) { + const validModes: AnswerModeKey[] = ['standard', 'focus', 'academic', 'writing', 'travel', 'finance', 'health', 'education', 'medicine', 'realEstate', 'psychology', 'sports', 'children', 'goods', 'shopping', 'games', 'taxes', 'legislation']; + if (urlMode && validModes.includes(urlMode as AnswerModeKey)) { setAnswerMode(urlMode as AnswerModeKey); } }, [searchParams]); diff --git a/services/web-svc/src/lib/localization/context.tsx b/services/web-svc/src/lib/localization/context.tsx index 57bb36a..d3addbd 100644 --- a/services/web-svc/src/lib/localization/context.tsx +++ b/services/web-svc/src/lib/localization/context.tsx @@ -64,6 +64,10 @@ const defaultTranslations: Translations = { 'nav.games': 'Games', 'nav.taxes': 'Taxes', 'nav.legislation': 'Legislation', + 'answerMode.standard': 'Standard', + 'answerMode.focus': 'Focus', + 'answerMode.academic': 'Academic', + 'answerMode.writing': 'Writing', 'nav.sidebarSettings': 'Main menu settings', 'nav.configureMenu': 'Configure menu', 'nav.menuSettingsHint': 'Toggle items on/off with the switch, change order with up/down arrows.', diff --git a/services/web-svc/src/lib/localization/embeddedTranslations.ts b/services/web-svc/src/lib/localization/embeddedTranslations.ts index 1fec3c8..3b1a8fe 100644 --- a/services/web-svc/src/lib/localization/embeddedTranslations.ts +++ b/services/web-svc/src/lib/localization/embeddedTranslations.ts @@ -43,6 +43,10 @@ export const embeddedTranslations: Record< 'nav.games': 'Games', 'nav.taxes': 'Taxes', 'nav.legislation': 'Legislation', + 'answerMode.standard': 'Standard', + 'answerMode.focus': 'Focus', + 'answerMode.academic': 'Academic', + 'answerMode.writing': 'Writing', 'nav.sidebarSettings': 'Main menu settings', 'nav.configureMenu': 'Configure menu', 'nav.menuSettingsHint': 'Toggle items on/off with the switch, change order with up/down arrows.', @@ -142,6 +146,10 @@ export const embeddedTranslations: Record< 'nav.games': 'Игры', 'nav.taxes': 'Налоги', 'nav.legislation': 'Законодательство', + 'answerMode.standard': 'Стандарт', + 'answerMode.focus': 'Фокус', + 'answerMode.academic': 'Академический', + 'answerMode.writing': 'Письмо', 'nav.sidebarSettings': 'Настройка главного меню', 'nav.configureMenu': 'Настроить меню', 'nav.menuSettingsHint': 'Включайте и выключайте пункты переключателем, меняйте порядок стрелками вверх и вниз.', @@ -241,6 +249,10 @@ export const embeddedTranslations: Record< 'nav.games': 'Spiele', 'nav.taxes': 'Steuern', 'nav.legislation': 'Gesetzgebung', + 'answerMode.standard': 'Standard', + 'answerMode.focus': 'Fokus', + 'answerMode.academic': 'Akademisch', + 'answerMode.writing': 'Schreiben', 'nav.sidebarSettings': 'Einstellungen Hauptmenü', 'nav.configureMenu': 'Menü anpassen', 'nav.menuSettingsHint': 'Punkte mit Schalter ein/aus, Reihenfolge mit Pfeilen oben/unten ändern.', @@ -340,6 +352,10 @@ export const embeddedTranslations: Record< 'nav.games': 'Jeux', 'nav.taxes': 'Impôts', 'nav.legislation': 'Législation', + 'answerMode.standard': 'Standard', + 'answerMode.focus': 'Focus', + 'answerMode.academic': 'Académique', + 'answerMode.writing': 'Écriture', 'nav.sidebarSettings': 'Paramètres du menu principal', 'nav.configureMenu': 'Configurer le menu', 'nav.menuSettingsHint': 'Activer/désactiver avec l\'interrupteur, changer l\'ordre avec les flèches haut/bas.', @@ -439,6 +455,10 @@ export const embeddedTranslations: Record< 'nav.games': 'Juegos', 'nav.taxes': 'Impuestos', 'nav.legislation': 'Legislación', + 'answerMode.standard': 'Estándar', + 'answerMode.focus': 'Enfoque', + 'answerMode.academic': 'Académico', + 'answerMode.writing': 'Escritura', 'nav.sidebarSettings': 'Configuración del menú principal', 'nav.configureMenu': 'Configurar menú', 'nav.menuSettingsHint': 'Activar/desactivar con el interruptor, cambiar orden con flechas arriba/abajo.', @@ -538,6 +558,10 @@ export const embeddedTranslations: Record< 'nav.games': 'Ігри', 'nav.taxes': 'Податки', 'nav.legislation': 'Законодавство', + 'answerMode.standard': 'Стандарт', + 'answerMode.focus': 'Фокус', + 'answerMode.academic': 'Академічний', + 'answerMode.writing': 'Письмо', 'nav.sidebarSettings': 'Налаштування головного меню', 'nav.configureMenu': 'Налаштувати меню', 'nav.menuSettingsHint': 'Вмикайте і вимикайте пункти перемикачем, змінюйте порядок стрілками вгору і вниз.',