feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,120 @@
---
description: Инструкции для AI-агента — production-стандарты, качество кода, CONTINUE.md
alwaysApply: true
---
ВАЖНО! ДЕЛАЕМ ВСЕ ДЛЯ ПРОДАКШЕНА РАБОЧЕЕ НА ПРОДАКШЕНА!!!! НЕТ НИКАКИХ ЛОКАЛЬНЫХ ДОРАБОТОК РАЗРАБОТОК ДЕВ СРЕДЫ!!!!!!!!! И НЕ ДЕЛАЙ ДЕВ СРЕДУ!!!! ЭТО ПРОДАКШЕН!!!! ЕСЛИ В КОДЕ УВИДИШЬ ГДЕТО ДЕВ СРЕДУ ЭТО ОСТАТКИ СТАРОЙ ЛОГИКИ ПЕРЕВОДИ ЕЕ НА ПРОДАКШЕН ЕСЛИ НУЖНО УДАЛЯЙ!!!
ДЕПЛОЙ: только Docker + Kubernetes (локальный K3s на машине). Никакого npm publish, registry push, Vercel, remote VPS. ./deploy/k3s/deploy.sh — единственный способ деплоя.
# Инструкции для AI-агента
## Старт сессии
**Сначала проверь `CONTINUE.md`** в корне проекта. Если файл есть — начни с недоделок из него, затем бери новую задачу пользователя. В конце добавь что нужно доработать и что недоделал в рамках одного прохода генерации.
---
## Общий принцип
**Не MVP, а production.** Каждая задача выполняется полностью, как для enterprise-продукта. Никаких заглушек, TODO и «сделаем потом». ИПОЛЬЗУЙ ТОЛЬКО БЕСТ ПРАКТИКИ и сервисную архитектуру с переиспользованием компонентов.
---
## 1. Завершённость задач
- Перед завершением: проверь, что **все** части задачи сделаны. Чеклист обязателен.
- Если задача «добавить X» — значит: код, типы, обработка ошибок, edge cases.
- Контекстное окно ограничено — всё за один проход нереально. **Обязательно документируй недоделки** (см. раздел 7).
- После правок — пройдись по связанным файлам: импорты, экспорты, зависимости.
---
## 2. Качество кода
- **TypeScript:** строгая типизация. Не `any`. Явные типы для параметров и возвращаемых значений.
- **Стиль:** следуй существующему коду в проекте. Используй Prettier/ESLint.
- **Именование:** чёткие, понятные имена. `handleSubmit`, а не `doStuff`.
- **Структура:** логика в отдельных модулях, UI — в компонентах. Избегай «god functions».
---
## 3. Обработка ошибок
- Каждый API route: try/catch, понятные сообщения об ошибках.
- Внешние сервисы: таймауты, retry, fallback где уместно.
- Пользователь должен видеть понятное сообщение, не сырой stack trace.
---
## 4. Безопасность
- Входные данные: валидация (Zod и т.п.), никогда не доверять клиенту.
- Секреты: только переменные окружения, не в коде.
- SQL/запросы: параметризованные запросы, без конкатенации строк.
---
## 5. Производительность
- Компоненты: `useMemo`, `useCallback` где нужно.
- API: не тянуть лишние данные, пагинация для списков.
- БД: индексы для частых запросов, N+1 избегать.
---
## 6. Чеклист перед завершением
- [ ] Все новые файлы созданы и подключены
- [ ] Импорты и экспорты корректны
- [ ] Типы указаны, ошибок TypeScript нет
- [ ] Обработка ошибок добавлена
- [ ] Нет `console.log`, `TODO`, заглушек
- [ ] Проверены связанные компоненты/роуты
- [ ] Нет поломанного существующего функционала
---
## 7. Файл недоделок (CONTINUE.md)
Когда контекст заканчивается или задача не завершена — **записывай в `CONTINUE.md`** в корне проекта. Следующая генерация читает его первой и продолжает с этого места.
### Формат файла
```markdown
# Недоделки — начать отсюда
## Задача (исходный запрос)
[кратко что хотел пользователь]
## Сделано
- [список файлов и изменений]
- [что уже работает]
## Осталось сделать (в порядке приоритета)
1. [конкретное действие] — файл `src/path/file.ts`, строка ~X
2. [следующее действие] — файл `path/to/other.ts`
3. [ ] Добавить обработку ошибок в route X
4. [ ] Проверить импорты в компоненте Y
## Контекст для продолжения
- Изменённые файлы: `file1.ts`, `file2.tsx`
- Зависимости: компонент A использует B из `lib/B.ts`
```
### Правила ведения
- **Перед завершением сессии:** если что-то не доделано — обнови CONTINUE.md. Не полагайся на память.
- **В начале новой сессии:** если есть CONTINUE.md — прочитай его и начни с «Осталось сделать». Не начинай задачу с нуля.
- **Каждый пункт** — конкретный, с путём к файлу. Не «доделать», а «добавить try/catch в `api/chat/route.ts`».
- **После полного завершения** — очисти или удали CONTINUE.md, чтобы не путать следующие задачи.
---
## 8. Запреты
- Не оставлять недоделанную реализацию без записи в CONTINUE.md
- Не использовать `any` без крайней необходимости
- Не коммитить закомментированный код
- Не забывать про loading и error states в UI
- Не игнорировать accessibility (a11y) для интерактивных элементов

View File

@@ -1 +1,42 @@
**/node_modules
# Node
node_modules/
**/node_modules/
npm-debug.log
yarn-error.log
# Build output
.next/
**/.next/
out/
dist/
**/dist/
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
*.iml
# Env (не в образ)
.env
.env.*
!.env.example
# Docs, deploy scripts (не нужны в образе)
docs/
deploy/
*.md
!README.md
# Data, certs
db.sqlite
certificates/
searxng/
# Misc
.DS_Store
*.log
coverage/

View File

@@ -1,28 +1,23 @@
# Server config. Copy to .env (в корне или apps/frontend/) или задайте в панели деплоя.
# Конфиг для Kubernetes. Переменные задаются в ConfigMap/Secret (deploy/k3s).
# См. deploy/k3s/CONFIG.md
# === LLM (обязательно один из вариантов) ===
# === LLM (llm-svc-config ConfigMap, llm-credentials Secret) ===
# LLM_PROVIDER=ollama | timeweb
# OLLAMA_BASE_URL=http://host.docker.internal:11434 # Docker Desktop
# OPENAI_API_KEY= # Secret llm-credentials
# TIMEWEB_AGENT_ACCESS_ID= # Secret
# TIMEWEB_API_KEY= # Secret
# Вариант 1: Ollama (один сервер — один URL; два сервера — укажите OLLAMA_EMBEDDING_BASE_URL)
LLM_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_EMBEDDING_BASE_URL=http://embedding-host:11434
LLM_CHAT_MODEL=ministarl-3:3b
LLM_EMBEDDING_MODEL=nomic-embed-text
# === Auth (auth-svc.yaml env) ===
# BETTER_AUTH_URL=http://app.gooseek.local # local
# BETTER_AUTH_URL=https://gooseek.ru # production
# TRUSTED_ORIGINS=...
# Вариант 2: Timeweb Cloud AI (закомментировать блок Ollama)
# LLM_PROVIDER=timeweb
# TIMEWEB_API_BASE_URL=https://api.timeweb.cloud
# TIMEWEB_AGENT_ACCESS_ID=
# TIMEWEB_API_KEY=
# LLM_CHAT_MODEL=gpt-4
# TIMEWEB_X_PROXY_SOURCE=
# === API Gateway (api-gateway.yaml env) ===
# ALLOWED_ORIGINS=http://app.gooseek.local # local
# ALLOWED_ORIGINS=https://gooseek.ru,... # production
# === Дополнительно ===
# SearXNG: обязателен для веб-поиска. Публичные инстансы часто дают 429 (лимит).
# Запустите свой: docker run -d -p 4000:8080 searxng/searxng
# SEARXNG_API_URL=http://localhost:4000
# SEARXNG_FALLBACK_URL= # через запятую, если основной недоступен
# Ghost — вкладка Dooseek. Локально: port 2369 (или 2368)
# GHOST_URL=http://localhost:2369
# GHOST_CONTENT_API_KEY=ключ из Admin → Integrations
# DATA_DIR=./data
# === Secrets (kubectl create secret) ===
# db-credentials: postgresql://user:pass@host:5432/gooseek
# yookassa-credentials: shop_id, secret
# llm-credentials: openai-api-key, timeweb-agent-access-id, timeweb-api-key

6
.gitignore vendored
View File

@@ -40,4 +40,8 @@ apps/frontend/data/*
!apps/frontend/data/.gitignore
/searxng
certificates
certificates
# SSL backup (приватные ключи не в репо)
deploy/k3s/ssl/backup/*
!deploy/k3s/ssl/backup/.gitkeep

View File

@@ -2,13 +2,17 @@
## Старт сессии
**Сначала проверь `CONTINUE.md`** в корне проекта. Если файл есть — начни с недоделок из него, затем бери новую задачу пользователя.
**Сначала проверь `CONTINUE.md`** в корне проекта. Если файл есть — начни с недоделок из него, затем бери новую задачу пользователя. В конце добавь что нужно доработать и что недоделал в рамках одного прохода генерации.
ВАЖНО! ДЕЛАЕМ ВСЕ ДЛЯ ПРОДАКШЕНА РАБОЧЕЕ НА ПРОДАКШЕНА!!!! НЕТ НИКАКИХ ЛОКАЛЬНЫХ ДОРАБОТОК РАЗРАБОТОК ДЕВ СРЕДЫ!!!!!!!!! И НЕ ДЕЛАЙ ДЕВ СРЕДУ!!!! ЭТО ПРОДАКШЕН!!!! ЕСЛИ В КОДЕ УВИДИШЬ ГДЕТО ДЕВ СРЕДУ ЭТО ОСТАТКИ СТАРОЙ ЛОГИКИ ПЕРЕВОДИ ЕЕ НА ПРОДАКШЕН ЕСЛИ НУЖНО УДАЛЯЙ!!!
**ДЕПЛОЙ:** только Docker + Kubernetes (локальный K3s на машине). Никакого npm publish, registry push, Vercel. `./deploy/k3s/deploy.sh` — единственный способ деплоя.
---
## Общий принцип
**Не MVP, а production.** Каждая задача выполняется полностью, как для enterprise-продукта. Никаких заглушек, TODO и «сделаем потом».
**Не MVP, а production.** Каждая задача выполняется полностью, как для enterprise-продукта. Никаких заглушек, TODO и «сделаем потом». ИПОЛЬЗУЙ ТОЛЬКО БЕСТ ПРАКТИКИ и сервисную архитектуру с переиспользованием компонентов.
---

View File

@@ -1,69 +0,0 @@
# Аудит производительности сборки GooSeek
## Основная причина медленной загрузки
### 1. Явное использование Webpack вместо Turbopack (КРИТИЧНО)
**Проблема:** В `package.json` скрипт `dev` использует `next dev --webpack`, что отключает Turbopack.
**Влияние:**
- Turbopack: cold start ~1.1s, HMR ~90ms
- Webpack: cold start ~4.2s, HMR ~800ms
- **Разница: до 4× медленнее старт, до 10× медленнее обновления**
**Решение:** Убрать флаг `--webpack` — Next.js 16 по умолчанию использует Turbopack.
---
### 2. Тяжёлые зависимости (размер в node_modules)
| Пакет | Размер | Где используется | Загрузка |
|------------------|--------|-------------------------------------------|-------------------|
| pdf-parse | 83 MB | uploads/manager.ts (парсинг PDF) | Server, API |
| @huggingface | 49 MB | TransformersProvider (embeddings в браузере) | Динамический import |
| jspdf | 31 MB | Navbar.tsx (экспорт чата в PDF) | Клиент, при экспорте |
| mathjs | 16 MB | calculationWidget (агент расчётов) | Server, API |
| better-sqlite3 | 12 MB | drizzle/db (native addon) | Server |
| lightweight-charts | 3 MB | Stock.tsx (графики акций) | Клиент, при виджете |
---
### 3. Цепочка импортов при загрузке layout
```
layout.tsx
→ configManager (config/index.ts)
→ getModelProvidersUIConfigSection (models/providers/index.ts)
ВСЕ 9 провайдеров: OpenAI, Ollama, Timeweb, Gemini, Transformers, Groq, Lemonade, Anthropic, LMStudio
```
**Проблема:** Даже в env-only режиме (Timeweb) при каждом запросе загружаются все провайдеры для конфигурации UI. Webpack/Turbopack анализирует весь граф зависимостей.
**В env-only:** `getModelProvidersUIConfigSection` не вызывается, но импорт выполняется при загрузке модуля config.
---
### 4. Нативные модули
- `better-sqlite3` — компилируется при установке, замедляет `npm install`
- `@napi-rs/canvas` (optional) — для pdf-parse
---
## Рекомендации по оптимизации
### Быстрые (сделать сейчас)
1.**Убрать `--webpack`** — выполнено (убран из `build`, dev уже без него)
2.**Динамический import для jsPDF** — уже реализовано в Navbar.tsx
3.**Динамический import для Stock (lightweight-charts)** — уже реализовано в Renderer.tsx
### Средний приоритет
4.**Lazy-загрузка провайдеров** — вынесено в `config/providersLoader.ts`, загрузка только при !env-only
5.**Turbopack на production build** — Next.js 16 использует Turbopack по умолчанию (`next build` без флагов)
### Долгосрочно
6.**pdf-parse: динамический import** — выполнен. pdf-parse (83 MB) загружается только при парсинге PDF, не при старте. Worker через child_process не реализован из‑за ограничений Turbopack (статический анализ путей fork).
7. **Миграция better-sqlite3 → libsql** — отложено: требуется переписать `src/lib/db/migrate.ts` (см. CONTINUE.md)

View File

@@ -1,23 +1,144 @@
# Недоделки — начать отсюда
## Задача (исходный запрос)
Продолжить оптимизацию из AUDIT-PERFORMANCE.md и AGENTS.MD.
## Задача
Полная переделка сервиса GooSeek по документации docs/architecture (микросервисная архитектура аналога Perplexity.ai).
## Сделано
- Динамический import для pdf-parse в `manager.ts` — загрузка только при парсинге PDF
- Обновлён AUDIT-PERFORMANCE.md с отметками о выполненных пунктах
## Статус: миграция завершена ✅
## Осталось сделать (в порядке приоритета)
## 2025-02: SQLite → library-svc, api-gateway
- **Удалён 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/* на микросервисы
- **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
- **master-agents-svc (порт 3018):** Master Agent — адаптируется к задаче, автоматически выбирает инструменты (web_search, scrape_url, calculator, get_stock_quote, image_search)
1. **Миграция better-sqlite3 → libsql** — приоритет по audit (отложено).
- **Блокировщик:** `src/lib/db/migrate.ts` использует better-sqlite3 (sync API). Нужна полная переработка под async libsql перед миграцией db/index.ts.
- Файлы: `src/lib/db/index.ts`, `src/lib/db/migrate.ts`, `drizzle.config.ts`, `package.json`
- Шаги: переписать migrate.ts на createClient из @libsql/client, затем заменить в index.ts.
## Сделано (текущая сессия — web-svc только UI)
- **web-svc:** удалены папки agents, models, prompts, uploads, utils, config (server) — остался только UI
- **chat-svc:** GET/POST config, providers CRUD, models CRUD, POST /api/v1/uploads
- **travel-svc:** POST /api/v1/weather (proxy Open-Meteo)
- **api-gateway:** маршруты config, providers, uploads, weather
- **web-svc:** rewrites config/providers/weather → gateway; uploads — тонкий proxy к chat-svc; layout fetch config с gateway
2. **Опционально: pdf-parse в worker** — отложено из‑за Turbopack.
- Текущий подход (динамический import) уже снимает нагрузку со старта.
- Worker через child_process требует отдельный .cjs в известном path; Turbopack трассирует fork() и падает. Варианты: сборка с webpack для route uploads или вынос в отдельный Nodeсервис.
## Сделано (текущая сессия)
- **chat-svc → memory-svc:** при mode balanced/quality + Authorization — fetch GET /api/v1/memory, инжект memoryContext в writer prompt
- **Profile Personalize:** список AI Memory, Add (key+value), Delete; требует auth
- **web-svc chat proxy:** передача Authorization в chat-svc
- **useChat:** отправка Bearer token при запросе к /api/chat
- **My Connectors:** projects-svc GET/POST/DELETE /api/v1/connectors, Proxy, Profile UI (Google Drive, Dropbox coming soon)
- **Finance tabs:** Overview, Crypto, Gainers & Losers, Watchlist — finance-svc gainers/losers/crypto, localStorage watchlist
- **finance/[ticker] Add to watchlist:** кнопка ★, localStorage gooseek_finance_watchlist
- **Background Assistant (Max):** chat-svc POST/GET /api/v1/tasks (stub), web-svc proxy, ingress
- **finance-svc price-context:** GET /api/v1/finance/price-context/:ticker — LLM-синтез причины движения (OPENAI_API_KEY), fallback news+quote
- **Export thread:** GET /api/v1/library/threads/:id/export?format=pdf|md — web-svc route, данные из SQLite → create-svc
- **Navbar Export:** при наличии chatId — использует thread export API
- **create-svc POST /create:** table/dashboard — LLM генерация (gpt-4o-mini), image — 501
- **Страница /finance/[ticker]:** блок Price movement context (summary или news)
- **/metrics:** travel-svc, library-svc, memory-svc, create-svc — gooseek_up gauge; K3s Prometheus аннотации
- **Finance heatmap:** страница /finance — блок S&P 500 Sector Heatmap, fetch /api/v1/finance/heatmap
- **Finance-svc heatmap:** fetch с FMP api/v3/sector-performance при FMP_API_KEY
- **Collections на /finance:** Popular Spaces for Finance Research — fetch /api/v1/collections?category=finance
- **deploy/k3s/cache-worker.yaml:** CronJob finance (2m), discover (15m), travel (4h), activeDeadlineSeconds 300/600/1200
- **finance-svc:** fetchWithRetry для FMP API (3 попытки, backoff 500/1000/1500 ms)
- **docs/RUNBOOK.md:** Runbook оператора — health, Redis, cache-worker, типичные сбои, порты
- **Pro/Deep Search:** AssistantSteps — оценка времени ~3090 sec
- **K3s Prometheus:** аннотации prometheus.io/scrape, port, path в chat, search, discover, finance
- **CORS:** ALLOWED_ORIGINS в chat-svc, search-svc, discover-svc, finance-svc
- **Медиа:** SearchImages, SearchVideos — timeout 15s, error + Retry
- **Prometheus /metrics:** chat-svc, search-svc, discover-svc, finance-svc (gooseek_up gauge)
- **UI/UX:** DataFetchError + Retry; timeout 15s + Retry для Discover, Finance, Travel
- **File upload:** Cancel, timeout 300s, error handling в Attach и AttachSmall
- **HPA:** travel-svc, memory-svc добавлены в hpa.yaml
- **GuestWarningBanner:** предупреждение гостям, beforeunload, CTA «Save to account»
- **Rate limit 429:** toast в useChat при 429
- **/finance/predictions/[id]:** finance-svc stub API, страница (Polymarket coming soon)
- **deploy/k3s/hpa.yaml:** HPA для chat, search, discover, finance, travel, memory; PDB для chat, search
- **/spaces/templates:** projects-svc GET /api/v1/templates, страница, прокси, ingress
- **Health/ready probes:** web-svc `/api/health`, `/api/ready`; chat-svc `/ready`; K3s chat-svc readinessProbe → `/ready`
- **PWA:** @ducanh2912/next-pwa, Service worker (sw.js), offline fallback `/offline`, метаданные в layout, `next build --webpack`
- **Исправлен TS:** finance/[ticker] quote.high/quote.low optional
- **finance-svc:** GET /api/v1/finance/quote/:ticker (FMP quote)
- **Страница /finance/[ticker]:** котировка, новости, SEC filings
- **Страница /spaces:** список коллекций
- **Страница /collections/[id]:** детали коллекции
- **Sidebar:** Spaces, переводы nav.spaces
- **Удалён deprecated /api/discover** — дублировал discover-svc
- **Ghost** — опционально для Discover (topic=gooseek), см. docs
- **deploy/k3s:** search-svc.yaml, notifications-svc.yaml, auth-svc.yaml
- **Ingress:** /api/v1/search, /api/v1/notifications
- **discover-svc:** GHOST_URL, GHOST_CONTENT_API_KEY (optional Secret)
- **MIGRATION.md:** сборка образов search/auth/notifications
## Сделано (ранее)
- Микросервисы: discover, search, finance, travel, chat, memory, create, notifications, billing, auth, library, projects
- web-svc: UI + прокси к микросервисам
- deploy/k3s: манифесты, ingress
- apps/ удалён — всё в services/
## Сделано (текущая сессия)
- **Patents page:** DataFetchError компонент вместо кастомного error div (консистентность с Discover, Finance, Travel)
- **Model Council (Max):** параллельный запуск 3 моделей → синтез ответа
- chat-svc: modelCouncil, councilModels в body; councilLlms → SearchAgent
- SearchAgent: runCouncilWritersAndSynthesis — 3× generateText параллельно, synthesis prompt, stream синтеза
- writer.ts: getSynthesisPrompt
- web-svc: body schema, proxy, локальный agent с councilLlms
- useChat: modelCouncil + councilModels из localStorage (fallback: chatModel × 3)
- InputBarPlus: переключатель «Model Council» (Max)
## Сделано (последнее)
- **Input bar «+»:** меню режимов, источников, Learn, Create
- Кнопка «+» слева от Optimization — Popover с Mode (Quick/Pro/Deep), Sources (Web/Academic/Social), Step-by-step Learning, Create (подсказка)
- InputBarPlus в EmptyChatMessageInput
- **Inspiration Cards:** LLM в cache-worker (travel task)
- Курируемые темы → gpt-4o-mini → title+summary для 4 карточек
- Без OPENAI_API_KEY — fallback stub
- Redis travel:inspiration TTL 6h
- **create-svc image:** DALL·E 3 генерация изображений
- type: 'image' — вызов OpenAI /v1/images/generations (dall-e-3, 1024x1024, standard, vivid)
- Ответ: { type, url, b64?, format }
- Proxy timeout 120s для image
- **Step-by-step Learning:** learningMode в chat
- Preferences: switch «Step-by-step Learning» — объяснять пошагово, разбивать сложные концепции
- localStorage learningMode → body.learningMode → writer prompt block
- chat-svc, web-svc: learningMode в config, SearchAgentConfig, getWriterPrompt
- **Response preferences:** format, length, tone в chat
- Preferences: Response format (paragraph/bullets/outline), length (short/medium/long), tone (neutral/professional/casual/concise)
- localStorage → responsePrefs в body → writer prompt
- chat-svc, web-svc: responsePrefs в config и getWriterPrompt
- **travel-svc itinerary:** LLM-генерация маршрутов (gpt-4o-mini)
- POST /api/v1/travel/itinerary { query, days? } — Redis travel:itinerary:{hash} TTL 4h
- TravelStepper Route step: выбор длительности (114 дней), fetch itinerary, отображение по дням
- **Answer Mode: Travel (и finance, academic, writing):** вертикали ответа в чате
- chat-svc, web-svc: `answerMode` в body, SearchAgentConfig
- writer: travel/finance-специфичные блоки в системном промпте
- AnswerMode UI: селектор Standard | Travel | Finance | Academic | Writing | Focus
- URL: `?answerMode=travel` — автовыбор при переходе с /travel (карточки destinations)
- **Travel Stepper:** сохранение состояния между шагами (Поиск → Места → Маршрут → Отели → Билеты)
- travel-svc: POST/GET `/api/v1/travel/stepper/state`, Redis `travel:stepper:{sessionId}` TTL 24h
- TravelStepper компонент: модальное окно, шаги, persist в API + sessionStorage fallback
- Кнопка «Plan a trip» на /travel
- **NetworkPolicy:** `deploy/k3s/network-policies.yaml` — gooseek-allow-internal (inter-pod traffic)
## Сделано (profile-svc)
- **profile-svc (порт 3019):** личные данные и персонализация пользователя
- PostgreSQL: user_profiles (userId, displayName, avatarUrl, timezone, locale, profileData, preferences, personalization)
- GET/PATCH /api/v1/profile — требует Authorization Bearer
- Profile page: редактирование displayName, загрузка из profile-svc
- Settings: preferences и personalization сохраняются в profile-svc при auth (синхронизация между устройствами)
- api-gateway: маршрут /api/v1/profile → profile-svc
- deploy/k3s/profile-svc.yaml, deploy.config.yaml
## llm-svc (порт 3020)
- **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)
- 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
## Контекст для продолжения
- Изменённые файлы: `apps/frontend/src/lib/uploads/manager.ts`
- db используется в: `api/chat`, `api/chats`, `api/chats/[id]`, `lib/agents/search`
- Порты: 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
- Ghost: опционально → http://localhost:2369, админка /ghost, Content API Key в .env
- Redis ключи: discover:{topic}, finance:summary, travel:trending, travel:stepper:{sessionId}, cache-worker

View File

@@ -59,17 +59,13 @@ GooSeek includes API documentation for programmatic access.
## Setting Up Your Environment
Before diving into coding, setting up your local environment is key. Here's what you need to do:
GooSeek развёртывается только через **Kubernetes** (встроенный в Docker Desktop, не Docker-контейнеры):
1. Run `npm install` to install all dependencies.
2. Use `npm run dev` to start the application in development mode.
3. Open http://localhost:3000 and complete the setup in the UI (API keys, models, search backend URL, etc.).
1. Запустите Docker Desktop, включите Kubernetes (Settings → Kubernetes).
2. Выполните `./deploy/k3s/deploy.sh` из корня репозитория.
3. Откройте приложение по URL из Ingress (https://gooseek.ru).
Database migrations are applied automatically on startup.
For full installation options (Docker and non Docker), see the installation guide in the repository README.
**Please note**: Docker configurations are present for setting up production environments, whereas `npm run dev` is used for development purposes.
Миграции БД выполняются автоматически при деплое. Конфигурация сервисов: `deploy/k3s/deploy.config.yaml`.
## Coding and Contribution Practices

103
README.md
View File

@@ -76,97 +76,32 @@ We'd also like to thank the following partners for their generous support:
## Installation
There are mainly 2 ways of installing GooSeek - With Docker, Without Docker. Using Docker is highly recommended.
Развёртывание только через **Kubernetes** (не Docker-контейнеры напрямую). Используется Kubernetes, встроенный в Docker Desktop.
### Getting Started with Docker (Recommended)
### Kubernetes (Docker Desktop)
GooSeek can be easily run using Docker. Simply run the following command:
```bash
docker run -d -p 3000:3000 -v gooseek-data:/home/gooseek/data --name gooseek itzcrazykns1337/gooseek:latest
```
This will pull and start the GooSeek container with the bundled SearxNG search engine. Once running, open your browser and navigate to http://localhost:3000. You can then configure your settings (API keys, models, etc.) directly in the setup screen.
**Note**: The image includes both GooSeek and SearxNG, so no additional setup is required. The `-v` flags create persistent volumes for your data and uploaded files.
#### Using GooSeek with Your Own SearxNG Instance
If you already have SearxNG running, you can use the slim version of GooSeek:
```bash
docker run -d -p 3000:3000 -e SEARXNG_API_URL=http://your-searxng-url:8080 -v gooseek-data:/home/gooseek/data --name gooseek itzcrazykns1337/gooseek:slim-latest
```
**Important**: Make sure your SearxNG instance has:
- JSON format enabled in the settings
- Wolfram Alpha search engine enabled
Replace `http://your-searxng-url:8080` with your actual SearxNG URL. Then configure your AI provider settings in the setup screen at http://localhost:3000.
#### Advanced Setup (Building from Source)
If you prefer to build from source or need more control:
1. Ensure Docker is installed and running on your system.
2. Clone the GooSeek repository:
```bash
git clone https://github.com/ItzCrazyKns/GooSeek.git
```
3. After cloning, navigate to the directory containing the project files.
4. Build and run using Docker:
```bash
docker build -f docker/Dockerfile -t gooseek .
docker run -d -p 3000:3000 -v gooseek-data:/home/gooseek/data --name gooseek gooseek
```
Or use compose: `docker compose -f docker/docker-compose.yaml up -d`
5. Access GooSeek at http://localhost:3000 and configure your settings in the setup screen.
**Note**: After the containers are built, you can start GooSeek directly from Docker without having to open a terminal.
### Non-Docker Installation
1. Install SearXNG and allow `JSON` format in the SearXNG settings. Make sure Wolfram Alpha search engine is also enabled.
2. Clone the repository:
1. Запустите Docker Desktop и включите Kubernetes (Settings → Kubernetes → Enable).
2. Клонируйте репозиторий и выполните деплой:
```bash
git clone https://github.com/ItzCrazyKns/GooSeek.git
cd GooSeek
./deploy/k3s/deploy.sh
```
3. Install dependencies:
```bash
npm i
```
4. Build the application:
```bash
npm run build
```
5. Start the application:
```bash
npm run start
```
6. Open your browser and navigate to http://localhost:3000 to complete the setup and configure your settings (API keys, models, SearxNG URL, etc.) in the setup screen.
**Note**: Using Docker is recommended as it simplifies the setup process, especially for managing environment variables and dependencies.
See the [installation documentation](https://github.com/ItzCrazyKns/GooSeek/tree/master/docs/installation) for more information like updating, etc.
3. Конфигурация сервисов: [deploy/k3s/deploy.config.yaml](deploy/k3s/deploy.config.yaml)
4. Подробнее: [deploy/k3s](deploy/k3s) и [docs/architecture/MIGRATION.md](docs/architecture/MIGRATION.md)
### Troubleshooting
#### Sign-up / Sign-in 500 Error
Если при регистрации или входе возникает ошибка 500:
1. **Миграции auth-svc** выполняются автоматически при деплое (`./deploy/k3s/deploy.sh`).
2. **Проверьте поды:** `kubectl get pods -n gooseek`
3. **Проверьте BETTER_AUTH_URL** в ConfigMap/Secret — должен совпадать с URL приложения (например `https://gooseek.ru`).
#### Local OpenAI-API-Compliant Servers
If GooSeek tells you that you haven't configured any chat model providers, ensure that:
@@ -182,8 +117,7 @@ If you're encountering an Ollama connection error, it is likely due to the backe
1. **Check your Ollama API URL:** Ensure that the API URL is correctly set in the settings menu.
2. **Update API URL Based on OS:**
- **Windows:** Use `http://host.docker.internal:11434`
- **Mac:** Use `http://host.docker.internal:11434`
- **Windows/Mac (Docker):** Use `http://host.docker.internal:11434`
- **Linux:** Use `http://<private_ip_of_host>:11434`
Adjust the port number if you're using a different one.
@@ -201,8 +135,7 @@ If you're encountering a Lemonade connection error, it is likely due to the back
1. **Check your Lemonade API URL:** Ensure that the API URL is correctly set in the settings menu.
2. **Update API URL Based on OS:**
- **Windows:** Use `http://host.docker.internal:8000`
- **Mac:** Use `http://host.docker.internal:8000`
- **Windows/Mac (Docker):** Use `http://host.docker.internal:8000`
- **Linux:** Use `http://<private_ip_of_host>:8000`
Adjust the port number if you're using a different one.
@@ -237,7 +170,7 @@ GooSeek runs on Next.js and handles all API requests. It works right away on the
[![Deploy to Sealos](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dgooseek)
[![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=267)
[![Run on ClawCloud](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?referralCode=U11MRQ8U9RM4&openapp=system-fastdeploy%3FtemplateName%3Dgooseek)
[![Deploy on Hostinger](https://assets.hostinger.com/vps/deploy.svg)](https://www.hostinger.com/vps/docker-hosting?compose_url=https://raw.githubusercontent.com/ItzCrazyKns/GooSeek/refs/heads/master/docker-compose.yaml)
[![Deploy on Hostinger](https://assets.hostinger.com/vps/deploy.svg)](https://www.hostinger.com/vps/)
## Upcoming Features

View File

@@ -1,22 +0,0 @@
# Обязательные
BETTER_AUTH_SECRET= # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3001
# База данных (опционально, по умолчанию data/auth.db)
# DATABASE_URL=file:./data/auth.db
# LDAP (опционально — без этих переменных LDAP отключён)
# LDAP_URL=ldap://ldap.example.com:389
# LDAP_BIND_DN=cn=admin,dc=example,dc=com
# LDAP_PASSWORD=admin_password
# LDAP_BASE_DN=ou=users,dc=example,dc=com
# LDAP_USERNAME_ATTR=uid
# Показать вкладку LDAP на форме входа
# NEXT_PUBLIC_LDAP_ENABLED=true
# Trusted OAuth clients (JSON, опционально)
# TRUSTED_CLIENTS=[{"clientId":"app1","clientSecret":"secret","name":"My App","type":"web","redirectUrls":["http://localhost:3000/callback"],"skipConsent":true}]
# Trusted origins для CORS
# TRUSTED_ORIGINS=http://app1.local,http://app2.local

View File

@@ -1,6 +0,0 @@
node_modules
.next
data/
.env
.env.local
*.log

View File

@@ -1,34 +0,0 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Сборка без env check для статики
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3001
ENV PORT=3001
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,135 +0,0 @@
# Auth Microservice — Identity Provider
Отдельный микросервис аутентификации с поддержкой **SSO**, **LDAP** и **OIDC**. Выступает как единый Identity Provider для регистрации и входа во все приложения вашего ландшафта.
## Возможности
- **Email/пароль** — регистрация и вход
- **LDAP / Active Directory** — вход через корпоративный каталог
- **SSO** — вход через внешние IdP (Okta, Google, Azure AD, Keycloak и др.)
- **OIDC Provider** — этот сервис выступает как IdP для других приложений (Perplexica и др.)
## Быстрый старт
### Локально
```bash
cd auth-microservice
cp .env.example .env
# Отредактируйте .env: BETTER_AUTH_SECRET, BETTER_AUTH_URL
npm install
npm run db:migrate # Создание таблиц БД
npm run dev
```
Сервис доступен по адресу: **http://localhost:3001**
### Docker
```bash
docker compose up -d
```
## Конфигурация
### Обязательные переменные
| Переменная | Описание |
|------------|----------|
| `BETTER_AUTH_SECRET` | Секрет для шифрования (минимум 32 символа). Сгенерируйте: `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | Публичный URL сервиса (например `https://auth.example.com`) |
### LDAP (опционально)
Если заданы переменные LDAP, включается вход через Active Directory / OpenLDAP:
| Переменная | Описание |
|------------|----------|
| `LDAP_URL` | URL LDAP-сервера (`ldap://` или `ldaps://`) |
| `LDAP_BIND_DN` | DN для bind (admin) |
| `LDAP_PASSWORD` | Пароль для bind |
| `LDAP_BASE_DN` | Базовый DN для поиска пользователей |
| `LDAP_USERNAME_ATTR` | Атрибут логина (по умолчанию `uid`) |
| `NEXT_PUBLIC_LDAP_ENABLED` | `true` — показать вкладку LDAP на форме входа |
### Trusted OAuth Clients
Приложения, которые могут использовать этот IdP. Задаётся через `TRUSTED_CLIENTS` (JSON-массив) или `DEFAULT_CLIENT_ID` / `DEFAULT_CLIENT_SECRET`.
## Интеграция приложений
### OIDC Endpoints
```
Authorization: {BETTER_AUTH_URL}/api/auth/oauth2/authorize
Token: {BETTER_AUTH_URL}/api/auth/oauth2/token
UserInfo: {BETTER_AUTH_URL}/api/auth/oauth2/userinfo
Discovery: {BETTER_AUTH_URL}/api/auth/.well-known/openid-configuration
```
### Регистрация клиента
Через API (требуется сессия администратора):
```
POST /api/auth/oauth2/register
Content-Type: application/json
{
"redirect_uris": ["https://myapp.com/callback"],
"client_name": "My Application",
"scope": "openid profile email"
}
```
### Подключение Perplexica
В Perplexica настройте Better Auth как OIDC провайдер:
```ts
// В Perplexica
baseURL: "http://localhost:3001"
// или URL вашего auth-microservice
```
И укажите redirect URL Perplexica в `TRUSTED_CLIENTS` auth-сервиса.
## SSO (вход через внешние IdP)
Чтобы пользователи могли входить через Okta, Google и т.п., зарегистрируйте SSO провайдера:
```ts
await authClient.sso.register({
providerId: "okta",
issuer: "https://your-tenant.okta.com",
domain: "company.com",
oidcConfig: {
clientId: "...",
clientSecret: "...",
}
});
```
## Структура проекта
```
auth-microservice/
├── src/
│ ├── lib/
│ │ ├── auth.ts # Конфигурация Better Auth
│ │ ├── auth-client.ts # Клиент для React
│ │ └── db.ts # SQLite
│ └── app/
│ ├── api/auth/ # API Better Auth
│ ├── sign-in/ # Страница входа
│ ├── sign-up/ # Регистрация
│ └── dashboard/ # Личный кабинет
├── data/ # SQLite (создаётся автоматически)
├── .env.example
├── Dockerfile
└── docker-compose.yaml
```
## Лицензия
MIT

View File

@@ -1,5 +0,0 @@
/**
* Re-export для Better Auth CLI (db:migrate, db:generate)
*/
export { auth } from './src/lib/auth';
export { db } from './src/lib/db';

View File

@@ -1,15 +0,0 @@
services:
auth:
build: .
ports:
- "3001:3001"
environment:
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-change-me-generate-with-openssl-rand-base64-32}"
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-http://localhost:3001}"
DATABASE_PATH: /app/data/auth.db
volumes:
- auth-data:/app/data
restart: unless-stopped
volumes:
auth-data:

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.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.

View File

@@ -1,7 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -1,31 +0,0 @@
'use client';
import { authClient } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
export function SignOutButton() {
const router = useRouter();
const handleSignOut = async () => {
await authClient.signOut();
router.push('/sign-in');
router.refresh();
};
return (
<button
onClick={handleSignOut}
type="button"
style={{
padding: '8px 16px',
background: '#f1f5f9',
border: '1px solid #e2e8f0',
borderRadius: 8,
cursor: 'pointer',
fontWeight: 500,
}}
>
Выйти
</button>
);
}

View File

@@ -1,101 +0,0 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import { SignOutButton } from './SignOutButton';
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect('/sign-in');
}
const discoveryUrl = `${process.env.BETTER_AUTH_URL || 'http://localhost:3001'}/api/auth/.well-known/openid-configuration`;
return (
<div
style={{
minHeight: '100vh',
padding: 32,
background: '#f8fafc',
}}
>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 32,
}}
>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700 }}>
Auth Service Identity Provider
</h1>
<SignOutButton />
</div>
<div
style={{
background: '#fff',
padding: 24,
borderRadius: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
marginBottom: 24,
}}
>
<h2 style={{ margin: '0 0 16px', fontSize: 18 }}>Ваш профиль</h2>
<p style={{ margin: 0, color: '#64748b' }}>
<strong>Email:</strong> {session.user.email}
</p>
<p style={{ margin: '8px 0 0', color: '#64748b' }}>
<strong>Имя:</strong> {session.user.name || '—'}
</p>
</div>
<div
style={{
background: '#fff',
padding: 24,
borderRadius: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<h2 style={{ margin: '0 0 16px', fontSize: 18 }}>
Интеграция с приложениями
</h2>
<p style={{ margin: '0 0 16px', color: '#64748b', fontSize: 14 }}>
Этот сервис выступает как OIDC Identity Provider. Подключите ваши
приложения, указав следующие параметры:
</p>
<pre
style={{
padding: 16,
background: '#1e293b',
color: '#e2e8f0',
borderRadius: 8,
overflow: 'auto',
fontSize: 13,
}}
>
{`Authorization URL: ${process.env.BETTER_AUTH_URL || 'http://localhost:3001'}/api/auth/oauth2/authorize
Token URL: ${process.env.BETTER_AUTH_URL || 'http://localhost:3001'}/api/auth/oauth2/token
UserInfo URL: ${process.env.BETTER_AUTH_URL || 'http://localhost:3001'}/api/auth/oauth2/userinfo
Discovery: ${discoveryUrl}
Scopes: openid profile email`}
</pre>
<p style={{ margin: '16px 0 0', color: '#64748b', fontSize: 14 }}>
Зарегистрируйте клиента через API{' '}
<code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>
POST /api/auth/oauth2/register
</code>{' '}
или настройте trusted clients в конфигурации сервиса.
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import type { Metadata } from 'next';
import { Roboto } from 'next/font/google';
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
subsets: ['latin'],
display: 'swap',
fallback: ['Arial', 'sans-serif'],
});
export const metadata: Metadata = {
title: 'Auth Service — Identity Provider',
description: 'Централизованный сервис аутентификации с SSO, LDAP и OIDC',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className={roboto.className} style={{ margin: 0 }}>{children}</body>
</html>
);
}

View File

@@ -1,13 +0,0 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
export default async function HomePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
redirect('/dashboard');
}
redirect('/sign-in');
}

View File

@@ -1,244 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { authClient } from '@/lib/auth-client';
import Link from 'next/link';
type Tab = 'password' | 'ldap';
export default function SignInPage() {
const router = useRouter();
const [tab, setTab] = useState<Tab>('password');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [credential, setCredential] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const ldapEnabled = process.env.NEXT_PUBLIC_LDAP_ENABLED === 'true';
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { error } = await authClient.signIn.email({ email, password });
if (error) throw new Error(error.message);
router.push('/dashboard');
router.refresh();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка входа');
} finally {
setLoading(false);
}
};
const handleLdapSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/sign-in/ldap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, password, callbackURL: '/dashboard' }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok || data?.error) throw new Error(data?.message || data?.error?.message || 'Ошибка входа');
router.push('/dashboard');
router.refresh();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка LDAP входа');
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
}}
>
<div
style={{
width: '100%',
maxWidth: 400,
padding: 32,
background: '#fff',
borderRadius: 12,
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
>
<h1 style={{ margin: '0 0 24px', fontSize: 24, fontWeight: 600 }}>
Вход в систему
</h1>
{ldapEnabled && (
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => setTab('password')}
style={{
flex: 1,
padding: '10px 16px',
border: tab === 'password' ? '2px solid #6366f1' : '1px solid #ddd',
borderRadius: 8,
background: tab === 'password' ? '#eef2ff' : '#fff',
cursor: 'pointer',
fontWeight: 500,
}}
>
Email
</button>
<button
type="button"
onClick={() => setTab('ldap')}
style={{
flex: 1,
padding: '10px 16px',
border: tab === 'ldap' ? '2px solid #6366f1' : '1px solid #ddd',
borderRadius: 8,
background: tab === 'ldap' ? '#eef2ff' : '#fff',
cursor: 'pointer',
fontWeight: 500,
}}
>
LDAP / AD
</button>
</div>
)}
{error && (
<div
style={{
padding: 12,
marginBottom: 16,
background: '#fef2f2',
color: '#dc2626',
borderRadius: 8,
fontSize: 14,
}}
>
{error}
</div>
)}
{tab === 'password' ? (
<form onSubmit={handleEmailSignIn}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '100%',
padding: 12,
marginBottom: 12,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '100%',
padding: 12,
marginBottom: 16,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: 12,
background: '#6366f1',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 16,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
) : (
<form onSubmit={handleLdapSignIn}>
<input
type="text"
placeholder="Логин или DN"
value={credential}
onChange={(e) => setCredential(e.target.value)}
required
style={{
width: '100%',
padding: 12,
marginBottom: 12,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '100%',
padding: 12,
marginBottom: 16,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: 12,
background: '#6366f1',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 16,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? 'Вход...' : 'Войти через LDAP'}
</button>
</form>
)}
<p style={{ marginTop: 24, textAlign: 'center', fontSize: 14, color: '#666' }}>
Нет аккаунта?{' '}
<Link href="/sign-up" style={{ color: '#6366f1', textDecoration: 'none' }}>
Регистрация
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,150 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { authClient } from '@/lib/auth-client';
import Link from 'next/link';
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { error } = await authClient.signUp.email({
name,
email,
password,
});
if (error) throw new Error(error.message);
router.push('/dashboard');
router.refresh();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка регистрации');
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
}}
>
<div
style={{
width: '100%',
maxWidth: 400,
padding: 32,
background: '#fff',
borderRadius: 12,
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
>
<h1 style={{ margin: '0 0 24px', fontSize: 24, fontWeight: 600 }}>
Регистрация
</h1>
{error && (
<div
style={{
padding: 12,
marginBottom: 16,
background: '#fef2f2',
color: '#dc2626',
borderRadius: 8,
fontSize: 14,
}}
>
{error}
</div>
)}
<form onSubmit={handleSignUp}>
<input
type="text"
placeholder="Имя"
value={name}
onChange={(e) => setName(e.target.value)}
required
style={{
width: '100%',
padding: 12,
marginBottom: 12,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '100%',
padding: 12,
marginBottom: 12,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
style={{
width: '100%',
padding: 12,
marginBottom: 16,
border: '1px solid #ddd',
borderRadius: 8,
boxSizing: 'border-box',
}}
/>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: 12,
background: '#6366f1',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 16,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
</button>
</form>
<p style={{ marginTop: 24, textAlign: 'center', fontSize: 14, color: '#666' }}>
Уже есть аккаунт?{' '}
<Link href="/sign-in" style={{ color: '#6366f1', textDecoration: 'none' }}>
Войти
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
'use client';
import { createAuthClient } from 'better-auth/react';
import { ssoClient } from '@better-auth/sso/client';
import { oidcClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
baseURL:
typeof window !== 'undefined' ? window.location.origin : process.env.NEXT_PUBLIC_AUTH_URL,
plugins: [ssoClient(), oidcClient()],
});

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,66 +0,0 @@
/* I don't think can be classified as agents but to keep the structure consistent i guess ill keep it here */
import { searchSearxng } from '@/lib/searxng';
import {
imageSearchFewShots,
imageSearchPrompt,
} from '@/lib/prompts/media/image';
import BaseLLM from '@/lib/models/base/llm';
import z from 'zod';
import { ChatTurnMessage } from '@/lib/types';
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
type ImageSearchChainInput = {
chatHistory: ChatTurnMessage[];
query: string;
};
type ImageSearchResult = {
img_src: string;
url: string;
title: string;
};
const searchImages = async (
input: ImageSearchChainInput,
llm: BaseLLM<any>,
) => {
const schema = z.object({
query: z.string().describe('The image search query.'),
});
const res = await llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: imageSearchPrompt,
},
...imageSearchFewShots,
{
role: 'user',
content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<follow_up>\n${input.query}\n</follow_up>`,
},
],
schema: schema,
});
const searchRes = await searchSearxng(res.query, {
engines: ['bing images', 'google images'],
});
const images: ImageSearchResult[] = [];
searchRes.results.forEach((result) => {
if (result.img_src && result.url && result.title) {
images.push({
img_src: result.img_src,
url: result.url,
title: result.title,
});
}
});
return images.slice(0, 10);
};
export default searchImages;

View File

@@ -1,66 +0,0 @@
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
import { searchSearxng } from '@/lib/searxng';
import {
videoSearchFewShots,
videoSearchPrompt,
} from '@/lib/prompts/media/videos';
import { ChatTurnMessage } from '@/lib/types';
import BaseLLM from '@/lib/models/base/llm';
import z from 'zod';
type VideoSearchChainInput = {
chatHistory: ChatTurnMessage[];
query: string;
};
type VideoSearchResult = {
img_src: string;
url: string;
title: string;
iframe_src: string;
};
const searchVideos = async (
input: VideoSearchChainInput,
llm: BaseLLM<any>,
) => {
const schema = z.object({
query: z.string().describe('The video search query.'),
});
const res = await llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: videoSearchPrompt,
},
...videoSearchFewShots,
{
role: 'user',
content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<follow_up>\n${input.query}\n</follow_up>`,
},
],
schema: schema,
});
const searchRes = await searchSearxng(res.query, {
engines: ['youtube'],
});
const videos: VideoSearchResult[] = [];
searchRes.results.forEach((result) => {
if (result.thumbnail && result.url && result.title && result.iframe_src) {
videos.push({
img_src: result.thumbnail,
url: result.url,
title: result.title,
iframe_src: result.iframe_src,
});
}
});
return videos.slice(0, 10);
};
export default searchVideos;

View File

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

View File

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

View File

@@ -1,186 +0,0 @@
import { ResearcherOutput, SearchAgentInput } from './types';
import SessionManager from '@/lib/session';
import { classify } from './classifier';
import Researcher from './researcher';
import { getWriterPrompt } from '@/lib/prompts/search/writer';
import { WidgetExecutor } from './widgets';
import db from '@/lib/db';
import { chats, messages } from '@/lib/db/schema';
import { and, eq, gt } from 'drizzle-orm';
import { TextBlock } from '@/lib/types';
class SearchAgent {
async searchAsync(session: SessionManager, input: SearchAgentInput) {
const exists = await db.query.messages.findFirst({
where: and(
eq(messages.chatId, input.chatId),
eq(messages.messageId, input.messageId),
),
});
if (!exists) {
await db.insert(messages).values({
chatId: input.chatId,
messageId: input.messageId,
backendId: session.id,
query: input.followUp,
createdAt: new Date().toISOString(),
status: 'answering',
responseBlocks: [],
});
} else {
await db
.delete(messages)
.where(
and(eq(messages.chatId, input.chatId), gt(messages.id, exists.id)),
)
.execute();
await db
.update(messages)
.set({
status: 'answering',
backendId: session.id,
responseBlocks: [],
})
.where(
and(
eq(messages.chatId, input.chatId),
eq(messages.messageId, input.messageId),
),
)
.execute();
}
const classification = await classify({
chatHistory: input.chatHistory,
enabledSources: input.config.sources,
query: input.followUp,
llm: input.config.llm,
});
const widgetPromise = WidgetExecutor.executeAll({
classification,
chatHistory: input.chatHistory,
followUp: input.followUp,
llm: input.config.llm,
}).then((widgetOutputs) => {
widgetOutputs.forEach((o) => {
session.emitBlock({
id: crypto.randomUUID(),
type: 'widget',
data: {
widgetType: o.type,
params: o.data,
},
});
});
return widgetOutputs;
});
let searchPromise: Promise<ResearcherOutput> | null = null;
if (!classification.classification.skipSearch) {
const researcher = new Researcher();
searchPromise = researcher.research(session, {
chatHistory: input.chatHistory,
followUp: input.followUp,
classification: classification,
config: input.config,
});
}
const [widgetOutputs, searchResults] = await Promise.all([
widgetPromise,
searchPromise,
]);
session.emit('data', {
type: 'researchComplete',
});
const finalContext =
searchResults?.searchFindings
.map(
(f, index) =>
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
)
.join('\n') || '';
const widgetContext = widgetOutputs
.map((o) => {
return `<result>${o.llmContext}</result>`;
})
.join('\n-------------\n');
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
const writerPrompt = getWriterPrompt(
finalContextWithWidgets,
input.config.systemInstructions,
input.config.mode,
);
const answerStream = input.config.llm.streamText({
messages: [
{
role: 'system',
content: writerPrompt,
},
...input.chatHistory,
{
role: 'user',
content: input.followUp,
},
],
});
let responseBlockId = '';
for await (const chunk of answerStream) {
if (!responseBlockId) {
const block: TextBlock = {
id: crypto.randomUUID(),
type: 'text',
data: chunk.contentChunk,
};
session.emitBlock(block);
responseBlockId = block.id;
} else {
const block = session.getBlock(responseBlockId) as TextBlock | null;
if (!block) {
continue;
}
block.data += chunk.contentChunk;
session.updateBlock(block.id, [
{
op: 'replace',
path: '/data',
value: block.data,
},
]);
}
}
session.emit('end', {});
await db
.update(messages)
.set({
status: 'completed',
responseBlocks: session.getAllBlocks(),
})
.where(
and(
eq(messages.chatId, input.chatId),
eq(messages.messageId, input.messageId),
),
)
.execute();
}
}
export default SearchAgent;

View File

@@ -1,122 +0,0 @@
import z from 'zod';
import BaseLLM from '../../models/base/llm';
import BaseEmbedding from '@/lib/models/base/embedding';
import SessionManager from '@/lib/session';
import { ChatTurnMessage, Chunk } from '@/lib/types';
export type SearchSources = 'web' | 'discussions' | 'academic';
export type SearchAgentConfig = {
sources: SearchSources[];
fileIds: string[];
llm: BaseLLM<any>;
embedding: BaseEmbedding<any>;
mode: 'speed' | 'balanced' | 'quality';
systemInstructions: string;
};
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[];
};
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>;
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[];
}) => boolean;
execute: (
params: z.infer<TSchema>,
additionalConfig: AdditionalConfig & {
researchBlockId: string;
fileIds: string[];
},
) => Promise<ActionOutput>;
}

View File

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

View File

@@ -1,38 +0,0 @@
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
import { suggestionGeneratorPrompt } from '@/lib/prompts/suggestions';
import { ChatTurnMessage } from '@/lib/types';
import z from 'zod';
import BaseLLM from '@/lib/models/base/llm';
type SuggestionGeneratorInput = {
chatHistory: ChatTurnMessage[];
};
const schema = z.object({
suggestions: z
.array(z.string())
.describe('List of suggested questions or prompts'),
});
const generateSuggestions = async (
input: SuggestionGeneratorInput,
llm: BaseLLM<any>,
) => {
const res = await llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: suggestionGeneratorPrompt,
},
{
role: 'user',
content: `<chat_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</chat_history>`,
},
],
schema,
});
return res.suggestions;
};
export default generateSuggestions;

View File

@@ -1,29 +0,0 @@
import { ChatTurnMessage } from '@/lib/types';
export const videoSearchPrompt = `
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
Make sure to make the querey standalone and not something very broad, use context from the answers in the conversation to make it specific so user can get best video search results.
Output only the rephrased query in query key JSON format. Do not include any explanation or additional text.
`;
export const videoSearchFewShots: ChatTurnMessage[] = [
{
role: 'user',
content:
'<conversation>\n</conversation>\n<follow_up>\nHow does a car work?\n</follow_up>',
},
{ role: 'assistant', content: '{"query":"How does a car work?"}' },
{
role: 'user',
content:
'<conversation>\n</conversation>\n<follow_up>\nWhat is the theory of relativity?\n</follow_up>',
},
{ role: 'assistant', content: '{"query":"Theory of relativity"}' },
{
role: 'user',
content:
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
},
{ role: 'assistant', content: '{"query":"AC working"}' },
];

View File

@@ -1,64 +0,0 @@
export const classifierPrompt = `
<role>
Assistant is an advanced AI system designed to analyze the user query and the conversation history to determine the most appropriate classification for the search operation.
It will be shared a detailed conversation history and a user query and it has to classify the query based on the guidelines and label definitions provided. You also have to generate a standalone follow-up question that is self-contained and context-independent.
</role>
<labels>
NOTE: BY GENERAL KNOWLEDGE WE MEAN INFORMATION THAT IS OBVIOUS, WIDELY KNOWN, OR CAN BE INFERRED WITHOUT EXTERNAL SOURCES FOR EXAMPLE MATHEMATICAL FACTS, BASIC SCIENTIFIC KNOWLEDGE, COMMON HISTORICAL EVENTS, ETC.
1. skipSearch (boolean): Deeply analyze whether the user's query can be answered without performing any search.
- Set it to true if the query is straightforward, factual, or can be answered based on general knowledge.
- Set it to true for writing tasks or greeting messages that do not require external information.
- Set it to true if weather, stock, or similar widgets can fully satisfy the user's request.
- 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 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 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 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).
- 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.
The standalone follow-up should be concise and to the point.
</standalone_followup>
<output_format>
You must respond in the following JSON format without any extra text, explanations or filler sentences:
{
"classification": {
"skipSearch": boolean,
"personalSearch": boolean,
"academicSearch": boolean,
"discussionSearch": boolean,
"showWeatherWidget": boolean,
"showStockWidget": boolean,
"showCalculationWidget": boolean,
},
"standaloneFollowUp": string
}
</output_format>
`;

View File

@@ -1,54 +0,0 @@
export const getWriterPrompt = (
context: string,
systemInstructions: string,
mode: 'speed' | 'balanced' | 'quality',
) => {
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, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
- 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]."
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
- 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
- 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.
- If no relevant information is found, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" Be transparent about limitations and suggest alternatives or ways to reframe the query.
${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." : ''}
### 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}
### 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()}.
`;
};

View File

@@ -1,17 +0,0 @@
export const suggestionGeneratorPrompt = `
You are an AI suggestion generator for an AI powered search engine. You will be given a conversation below. You need to generate 4-5 suggestions based on the conversation. The suggestion should be relevant to the conversation that can be used by the user to ask the chat model for more information.
You need to make sure the suggestions are relevant to the conversation and are helpful to the user. Keep a note that the user might use these suggestions to ask a chat model for more information.
Make sure the suggestions are medium in length and are informative and relevant to the conversation.
Sample suggestions for a conversation about Elon Musk:
{
"suggestions": [
"What are Elon Musk's plans for SpaceX in the next decade?",
"How has Tesla's stock performance been influenced by Elon Musk's leadership?",
"What are the key innovations introduced by Elon Musk in the electric vehicle industry?",
"How does Elon Musk's vision for renewable energy impact global sustainability efforts?"
]
}
Today's date is ${new Date().toISOString()}
`;

View File

@@ -1,31 +0,0 @@
# Chatwoot — минимальная конфигурация
# Скопируйте в .env и заполните обязательные поля
# === Обязательно ===
# Сгенерировать: openssl rand -hex 64
SECRET_KEY_BASE=
# Пароль PostgreSQL (обязательно)
POSTGRES_PASSWORD=
# URL фронтенда (где открывать Chatwoot)
FRONTEND_URL=http://localhost:3001
# === PostgreSQL ===
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
RAILS_ENV=production
# === Redis ===
REDIS_URL=redis://redis:6379
# Для production задайте REDIS_PASSWORD и REDIS_URL=redis://:пароль@redis:6379
REDIS_PASSWORD=
# === Опционально ===
# Регистрация новых аккаунтов (true/false)
ENABLE_ACCOUNT_SIGNUP=true
# SMTP — для локальной разработки используйте MailHog (docker compose включает его)
# SMTP_ADDRESS=mailhog
# SMTP_PORT=1025
# MAILER_SENDER_EMAIL=chatwoot@localhost

View File

@@ -1,50 +0,0 @@
# Chatwoot — self-hosted live chat
Live chat для клиентской поддержки. Данные на вашем сервере.
## Быстрый старт
### 1. Подготовка
```bash
cd chatwoot
cp .env.example .env
```
### 2. Заполнить .env
Обязательно задать:
- **SECRET_KEY_BASE** — сгенерировать: `openssl rand -hex 64`
- **POSTGRES_PASSWORD** — пароль для PostgreSQL
### 3. Запуск
```bash
# Инициализация БД (выполнить один раз)
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
# Запуск
docker compose up -d
```
### 4. Доступ
- **Интерфейс:** http://localhost:3001
- Создайте аккаунт при первом входе (если `ENABLE_ACCOUNT_SIGNUP=true`)
## Встраивание виджета
После создания инбокса в панели Chatwoot скопируйте код виджета и вставьте на сайт перед `</body>`.
## Полезные команды
```bash
# Остановить
docker compose down
# Обновить до новой версии
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
```

View File

@@ -1,78 +0,0 @@
# Chatwoot — self-hosted live chat
# Документация: https://developers.chatwoot.com/self-hosted
x-app: &app
image: chatwoot/chatwoot:latest
env_file: .env
volumes:
- storage_data:/app/storage
services:
rails:
<<: *app
depends_on:
- postgres
- redis
- mailhog
ports:
- "127.0.0.1:3001:3000"
environment:
- NODE_ENV=production
- RAILS_ENV=production
- INSTALLATION_ENV=docker
entrypoint: docker/entrypoints/rails.sh
command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
restart: unless-stopped
sidekiq:
<<: *app
depends_on:
- postgres
- redis
- mailhog
environment:
- NODE_ENV=production
- RAILS_ENV=production
- INSTALLATION_ENV=docker
command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
restart: unless-stopped
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=chatwoot
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
mailhog:
image: mailhog/mailhog:latest
restart: unless-stopped
ports:
- "1025:1025"
- "8025:8025"
redis:
image: redis:alpine
restart: unless-stopped
command: ["sh", "-c", "redis-server $${REDIS_PASSWORD:+--requirepass \"$$REDIS_PASSWORD\"}"]
env_file: .env
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
storage_data:
postgres_data:
redis_data:

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'drizzle-kit';
import path from 'path';
const dataDir = process.env.DATA_DIR
? path.join(path.resolve(process.cwd(), process.env.DATA_DIR), 'data')
: path.join(process.cwd(), 'data');
export default defineConfig({
dialect: 'sqlite',
schema: './src/lib/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: path.join(dataDir, 'db.sqlite'),
},
});

View File

@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS `chats` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`createdAt` text NOT NULL,
`focusMode` text NOT NULL,
`files` text DEFAULT '[]'
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `messages` (
`id` integer PRIMARY KEY NOT NULL,
`content` text NOT NULL,
`chatId` text NOT NULL,
`messageId` text NOT NULL,
`type` text,
`metadata` text
);

View File

@@ -1 +0,0 @@
/* Do nothing */

View File

@@ -1 +0,0 @@
/* do nothing */

View File

@@ -1,116 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ef3a044b-0f34-40b5-babb-2bb3a909ba27",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"focusMode": {
"name": "focusMode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"files": {
"name": "files",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"chatId": {
"name": "chatId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"messageId": {
"name": "messageId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,125 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6dedf55f-0e44-478f-82cf-14a21ac686f8",
"prevId": "ef3a044b-0f34-40b5-babb-2bb3a909ba27",
"tables": {
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"focusMode": {
"name": "focusMode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"files": {
"name": "files",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"chatId": {
"name": "chatId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"messageId": {
"name": "messageId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,132 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1c5eb804-d6b4-48ec-9a8f-75fb729c8e52",
"prevId": "6dedf55f-0e44-478f-82cf-14a21ac686f8",
"tables": {
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"files": {
"name": "files",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"messageId": {
"name": "messageId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"chatId": {
"name": "chatId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backendId": {
"name": "backendId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"query": {
"name": "query",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"responseBlocks": {
"name": "responseBlocks",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'answering'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,27 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1748405503809,
"tag": "0000_fuzzy_randall",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1758863991284,
"tag": "0001_wise_rockslide",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1763732708332,
"tag": "0002_daffy_wrecker",
"breakpoints": true
}
]
}

View File

@@ -1,36 +0,0 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
// Загружаем .env из корня монорепо (apps/frontend -> корень)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const require = createRequire(import.meta.url);
require('dotenv').config({ path: path.join(rootDir, '.env') });
import pkg from './package.json' with { type: 'json' };
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
hostname: 's2.googleusercontent.com',
},
],
},
serverExternalPackages: ['pdf-parse'],
outputFileTracingIncludes: {
'/api/**': [
'./node_modules/@napi-rs/canvas/**',
'./node_modules/@napi-rs/canvas-linux-x64-gnu/**',
'./node_modules/@napi-rs/canvas-linux-x64-musl/**',
],
},
env: {
NEXT_PUBLIC_VERSION: pkg.version,
},
};
export default nextConfig;

View File

@@ -1,293 +0,0 @@
import { z } from 'zod';
import ModelRegistry from '@/lib/models/registry';
import { ModelWithProvider } from '@/lib/models/types';
import SearchAgent from '@/lib/agents/search';
import SessionManager from '@/lib/session';
import { ChatTurnMessage } from '@/lib/types';
import { SearchSources } from '@/lib/agents/search/types';
import db from '@/lib/db';
import { eq } from 'drizzle-orm';
import { chats } from '@/lib/db/schema';
import UploadManager from '@/lib/uploads/manager';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const messageSchema = z.object({
messageId: z.string().min(1, 'Message ID is required'),
chatId: z.string().min(1, 'Chat ID is required'),
content: z.string().min(1, 'Message content is required'),
});
const chatModelSchema: z.ZodType<ModelWithProvider> = z.object({
providerId: z.string({ message: 'Chat model provider id must be provided' }),
key: z.string({ message: 'Chat model key must be provided' }),
});
const embeddingModelSchema = z.object({
providerId: z.string().optional().default(''),
key: z.string().optional().default(''),
});
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
const bodySchema = z.object({
message: messageSchema,
optimizationMode: z.enum(['speed', 'balanced', 'quality'], {
message: 'Optimization mode must be one of: 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,
embeddingModel: embeddingModelSchema.optional().default({ providerId: '', key: '' }),
systemInstructions: z.string().nullable().optional().default(''),
/** locale (ru, en и т.д.) — язык ответа, по geo если не передан */
locale: z.string().optional(),
});
type Body = z.infer<typeof bodySchema>;
const safeValidateBody = (data: unknown) => {
const result = bodySchema.safeParse(data);
if (!result.success) {
return {
success: false,
error: result.error.issues.map((e: any) => ({
path: e.path.join('.'),
message: e.message,
})),
};
}
return {
success: true,
data: result.data,
};
};
const ensureChatExists = async (input: {
id: string;
sources: SearchSources[];
query: string;
fileIds: string[];
}) => {
try {
const exists = await db.query.chats
.findFirst({
where: eq(chats.id, input.id),
})
.execute();
if (!exists) {
await db.insert(chats).values({
id: input.id,
createdAt: new Date().toISOString(),
sources: input.sources,
title: input.query,
files: input.fileIds.map((id) => {
return {
fileId: id,
name: UploadManager.getFile(id)?.name || 'Uploaded File',
};
}),
});
}
} catch (err) {
console.error('Failed to check/save chat:', err);
}
};
export const POST = async (req: Request) => {
try {
const reqBody = (await req.json()) as Body;
const parseBody = safeValidateBody(reqBody);
if (!parseBody.success) {
return Response.json(
{ message: 'Invalid request body', error: parseBody.error },
{ status: 400 },
);
}
const body = parseBody.data as Body;
const { message } = body;
let locale = body.locale;
if (!locale) {
try {
const localeRes = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
headers: {
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
});
if (localeRes.ok) {
const data = (await localeRes.json()) as { locale?: string };
locale = data.locale ?? undefined;
}
} catch {
/* localization-service недоступен */
}
}
const effectiveLocale = locale ?? 'en';
if (message.content === '') {
return Response.json(
{
message: 'Please provide a message to process',
},
{ status: 400 },
);
}
const registry = new ModelRegistry();
const llm = await registry.loadChatModel(
body.chatModel.providerId,
body.chatModel.key,
);
let embedding: Awaited<ReturnType<ModelRegistry['loadEmbeddingModel']>> | null = null;
if (body.embeddingModel?.providerId) {
embedding = await registry.loadEmbeddingModel(
body.embeddingModel.providerId,
body.embeddingModel.key,
);
}
const history: ChatTurnMessage[] = body.history.map((msg) => {
if (msg[0] === 'human') {
return {
role: 'user',
content: msg[1],
};
} else {
return {
role: 'assistant',
content: msg[1],
};
}
});
const agent = new SearchAgent();
const session = SessionManager.createSession();
const responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();
const encoder = new TextEncoder();
const disconnect = session.subscribe((event: string, data: any) => {
if (event === 'data') {
if (data.type === 'block') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'block',
block: data.block,
}) + '\n',
),
);
} else if (data.type === 'updateBlock') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'updateBlock',
blockId: data.blockId,
patch: data.patch,
}) + '\n',
),
);
} else if (data.type === 'researchComplete') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'researchComplete',
}) + '\n',
),
);
}
} else if (event === 'end') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'messageEnd',
}) + '\n',
),
);
writer.close();
session.removeAllListeners();
} else if (event === 'error') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'error',
data: data.data,
}) + '\n',
),
);
writer.close();
session.removeAllListeners();
}
});
agent
.searchAsync(session, {
chatHistory: history,
followUp: message.content,
chatId: body.message.chatId,
messageId: body.message.messageId,
config: {
llm,
embedding: embedding,
sources: body.sources as SearchSources[],
mode: body.optimizationMode,
fileIds: body.files,
systemInstructions: body.systemInstructions || 'None',
locale: effectiveLocale,
},
})
.catch((err: Error) => {
console.error('[Chat] searchAsync failed:', err);
session.emit('error', {
data:
err?.message ||
'Ошибка при поиске. Проверьте настройки SearXNG или LLM в Settings.',
});
});
ensureChatExists({
id: body.message.chatId,
sources: body.sources as SearchSources[],
fileIds: body.files,
query: body.message.content,
});
req.signal.addEventListener('abort', () => {
disconnect();
writer.close();
});
return new Response(responseStream.readable, {
headers: {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
},
});
} catch (err) {
console.error('An error occurred while processing chat request:', err);
return Response.json(
{ message: 'An error occurred while processing chat request' },
{ status: 500 },
);
}
};

View File

@@ -1,69 +0,0 @@
import db from '@/lib/db';
import { chats, messages } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
export const GET = async (
req: Request,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const chatExists = await db.query.chats.findFirst({
where: eq(chats.id, id),
});
if (!chatExists) {
return Response.json({ message: 'Chat not found' }, { status: 404 });
}
const chatMessages = await db.query.messages.findMany({
where: eq(messages.chatId, id),
});
return Response.json(
{
chat: chatExists,
messages: chatMessages,
},
{ status: 200 },
);
} catch (err) {
console.error('Error in getting chat by id: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};
export const DELETE = async (
req: Request,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const chatExists = await db.query.chats.findFirst({
where: eq(chats.id, id),
});
if (!chatExists) {
return Response.json({ message: 'Chat not found' }, { status: 404 });
}
await db.delete(chats).where(eq(chats.id, id)).execute();
await db.delete(messages).where(eq(messages.chatId, id)).execute();
return Response.json(
{ message: 'Chat deleted successfully' },
{ status: 200 },
);
} catch (err) {
console.error('Error in deleting chat by id: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};

View File

@@ -1,15 +0,0 @@
import db from '@/lib/db';
export const GET = async (req: Request) => {
try {
let chats = await db.query.chats.findMany();
chats = chats.reverse();
return Response.json({ chats: chats }, { status: 200 });
} catch (err) {
console.error('Error in getting chats: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};

View File

@@ -1,79 +0,0 @@
import configManager from '@/lib/config';
import { isEnvOnlyMode } from '@/lib/config/serverRegistry';
import ModelRegistry from '@/lib/models/registry';
import { NextRequest, NextResponse } from 'next/server';
import { ConfigModelProvider } from '@/lib/config/types';
type SaveConfigBody = {
key: string;
value: string;
};
export const GET = async (req: NextRequest) => {
try {
const values = configManager.getCurrentConfig();
const fields = configManager.getUIConfigSections();
const modelRegistry = new ModelRegistry();
const modelProviders = await modelRegistry.getActiveProviders();
values.modelProviders = values.modelProviders.map(
(mp: ConfigModelProvider) => {
const activeProvider = modelProviders.find((p) => p.id === mp.id);
return {
...mp,
chatModels: activeProvider?.chatModels ?? mp.chatModels,
embeddingModels:
activeProvider?.embeddingModels ?? mp.embeddingModels,
};
},
);
return NextResponse.json({
values,
fields,
envOnlyMode: isEnvOnlyMode(),
});
} catch (err) {
console.error('Error in getting config: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};
export const POST = async (req: NextRequest) => {
try {
const body: SaveConfigBody = await req.json();
if (!body.key || !body.value) {
return Response.json(
{
message: 'Key and value are required.',
},
{
status: 400,
},
);
}
configManager.updateConfig(body.key, body.value);
return Response.json(
{
message: 'Config updated successfully.',
},
{
status: 200,
},
);
} catch (err) {
console.error('Error in getting config: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};

View File

@@ -1,23 +0,0 @@
import configManager from '@/lib/config';
import { NextRequest } from 'next/server';
export const POST = async (req: NextRequest) => {
try {
configManager.markSetupComplete();
return Response.json(
{
message: 'Setup marked as complete.',
},
{
status: 200,
},
);
} catch (err) {
console.error('Error marking setup as complete: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};

View File

@@ -1,356 +0,0 @@
import { searchSearxng, type SearxngSearchResult } from '@/lib/searxng';
import configManager from '@/lib/config';
import { getSearxngURL } from '@/lib/config/serverRegistry';
const GHOST_URL = process.env.GHOST_URL?.trim() ?? '';
const GHOST_CONTENT_API_KEY = process.env.GHOST_CONTENT_API_KEY?.trim() ?? '';
const PLACEHOLDER_IMAGE = 'https://placehold.co/400x225/e5e7eb/6b7280?text=Post';
type Region = 'america' | 'eu' | 'russia' | 'china';
type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment' | 'gooseek';
interface GhostPost {
title: string;
excerpt?: string | null;
custom_excerpt?: string | null;
meta_description?: string | null;
plaintext?: string | null;
html?: string | null;
feature_image?: string | null;
url: string;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
const SOURCES_BY_REGION: Record<
Region,
Record<Topic, { query: string[]; links: string[] }>
> = {
america: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
links: ['techcrunch.com', 'wired.com', 'theverge.com', 'arstechnica.com'],
},
finance: {
query: ['finance news', 'economy', 'stock market', 'investing'],
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com', 'reuters.com'],
},
art: {
query: ['art news', 'culture', 'modern art', 'cultural events'],
links: ['artnews.com', 'hyperallergic.com', 'theartnewspaper.com'],
},
sports: {
query: ['sports news', 'latest sports', 'cricket football tennis'],
links: ['espn.com', 'cbssports.com', 'nfl.com', 'nba.com'],
},
entertainment: {
query: ['entertainment news', 'movies', 'TV shows', 'celebrities'],
links: ['hollywoodreporter.com', 'variety.com', 'deadline.com'],
},
},
eu: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'AI', 'innovation'],
links: ['techcrunch.com', 'bbc.com', 'theguardian.com', 'reuters.com', 'euronews.com'],
},
finance: {
query: ['finance news', 'economy', 'stock market', 'euro'],
links: ['reuters.com', 'ft.com', 'bbc.com', 'euronews.com', 'politico.eu'],
},
art: {
query: ['art news', 'culture', 'art', 'exhibition'],
links: ['theguardian.com', 'bbc.com', 'dw.com', 'france24.com'],
},
sports: {
query: ['sports news', 'football', 'football soccer', 'champions league'],
links: ['bbc.com/sport', 'skysports.com', 'uefa.com', 'eurosport.com'],
},
entertainment: {
query: ['entertainment news', 'films', 'music', 'culture'],
links: ['bbc.com', 'theguardian.com', 'euronews.com', 'dw.com'],
},
},
russia: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'IT', 'innovation'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru', 'vedomosti.ru'],
},
finance: {
query: ['finance news', 'economy', 'markets', 'ruble'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru', 'vedomosti.ru'],
},
art: {
query: ['art news', 'culture', 'exhibition', 'museum'],
links: ['tass.com', 'ria.ru', 'kommersant.ru', 'interfax.com'],
},
sports: {
query: ['sports news', 'football', 'hockey', 'olympics'],
links: ['tass.com', 'ria.ru', 'rsport.ria.ru', 'championat.com', 'sport-express.ru'],
},
entertainment: {
query: ['entertainment news', 'films', 'music', 'culture'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru'],
},
},
china: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'AI', 'innovation'],
links: ['scmp.com', 'xinhuanet.com', 'chinadaily.com.cn', 'reuters.com'],
},
finance: {
query: ['finance news', 'economy', 'China markets', 'investing'],
links: ['scmp.com', 'chinadaily.com.cn', 'reuters.com', 'bloomberg.com'],
},
art: {
query: ['art news', 'culture', 'exhibition', 'Chinese art'],
links: ['scmp.com', 'chinadaily.com.cn', 'xinhuanet.com'],
},
sports: {
query: ['sports news', 'Olympics', 'football', 'basketball'],
links: ['scmp.com', 'chinadaily.com.cn', 'xinhuanet.com'],
},
entertainment: {
query: ['entertainment news', 'films', 'music', 'culture'],
links: ['scmp.com', 'chinadaily.com.cn', 'variety.com'],
},
},
};
const COUNTRY_TO_REGION: Record<string, Region> = {
US: 'america',
CA: 'america',
MX: 'america',
RU: 'russia',
BY: 'russia',
KZ: 'russia',
CN: 'china',
HK: 'china',
TW: 'china',
DE: 'eu',
FR: 'eu',
IT: 'eu',
ES: 'eu',
GB: 'eu',
UK: 'eu',
NL: 'eu',
PL: 'eu',
BE: 'eu',
AT: 'eu',
PT: 'eu',
SE: 'eu',
FI: 'eu',
IE: 'eu',
GR: 'eu',
RO: 'eu',
CZ: 'eu',
HU: 'eu',
BG: 'eu',
HR: 'eu',
SK: 'eu',
SI: 'eu',
LT: 'eu',
LV: 'eu',
EE: 'eu',
DK: 'eu',
};
async function fetchDooseekPosts(): Promise<
{ title: string; content: string; url: string; thumbnail: string }[]
> {
if (!GHOST_URL || !GHOST_CONTENT_API_KEY) {
throw new Error(
'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env',
);
}
const base = GHOST_URL.replace(/\/$/, '');
const apiUrl = `${base}/ghost/api/content/posts/?key=${GHOST_CONTENT_API_KEY}&limit=50&fields=title,excerpt,custom_excerpt,meta_description,html,feature_image,url&formats=html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15_000);
try {
const res = await fetch(apiUrl, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
clearTimeout(timeoutId);
if (!res.ok) {
if (res.status === 401) {
throw new Error(
'Неверный Ghost Content API Key. Получите ключ в Ghost Admin → Settings → Integrations → Add custom integration.',
);
}
throw new Error(`Ghost API: HTTP ${res.status}`);
}
const data = await res.json();
const posts: GhostPost[] = data.posts ?? [];
return posts.map((p) => {
const excerpt =
p.custom_excerpt?.trim() ||
p.meta_description?.trim() ||
p.excerpt?.trim() ||
p.plaintext?.trim() ||
(p.html ? stripHtml(p.html) : '');
const content = excerpt
? excerpt.slice(0, 300) + (excerpt.length > 300 ? '…' : '')
: (p.title ? `${p.title}. Читать далее →` : 'Читать далее →');
return {
title: p.title || 'Без названия',
content,
url: p.url,
thumbnail: p.feature_image?.trim() || PLACEHOLDER_IMAGE,
};
});
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
}
export const GET = async (req: Request) => {
try {
const params = new URL(req.url).searchParams;
const mode: 'normal' | 'preview' =
(params.get('mode') as 'normal' | 'preview') || 'normal';
const topic: Topic = (params.get('topic') as Topic) || 'tech';
if (topic === 'gooseek') {
try {
const blogs = await fetchDooseekPosts();
return Response.json({ blogs }, { status: 200 });
} catch (e) {
const msg =
e instanceof Error ? e.message : String(e);
const isConnect =
msg.includes('ECONNREFUSED') ||
msg.includes('fetch failed') ||
msg.includes('Failed to fetch') ||
msg.includes('AbortError');
return Response.json(
{
message:
!GHOST_URL || !GHOST_CONTENT_API_KEY
? 'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env'
: isConnect
? 'Не удалось подключиться к Ghost. Проверьте GHOST_URL и доступность сайта.'
: `Ошибка загрузки постов: ${msg}`,
},
{ status: isConnect ? 503 : 500 },
);
}
}
const searxngURL = getSearxngURL();
if (!searxngURL?.trim()) {
return Response.json(
{
message:
'SearxNG is not configured. Please set the SearxNG URL in Settings → Search.',
},
{ status: 503 },
);
}
const regionParam = params.get('region') as Region | null;
let region: Region = 'america';
const configRegion = configManager.getConfig('search.newsRegion', 'auto') as
| Region
| 'auto';
if (configRegion !== 'auto' && ['america', 'eu', 'russia', 'china'].includes(configRegion)) {
region = configRegion;
} else if (regionParam && ['america', 'eu', 'russia', 'china'].includes(regionParam)) {
region = regionParam;
} else if (configRegion === 'auto') {
try {
const geoUrl =
process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
const geoRes = await fetch(`${geoUrl}/api/context`, {
headers: {
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
},
});
const geoData = await geoRes.json();
const cc = geoData?.geo?.countryCode;
if (cc && COUNTRY_TO_REGION[cc]) {
region = COUNTRY_TO_REGION[cc];
}
} catch {
// keep default america
}
}
const selectedTopic = SOURCES_BY_REGION[region][topic];
const searchLang = region === 'russia' ? 'ru' : region === 'china' ? 'zh' : 'en';
let data = [];
if (mode === 'normal') {
const seenUrls = new Set();
const searchPromises = selectedTopic.links.flatMap((link) =>
selectedTopic.query.map((query) =>
searchSearxng(`site:${link} ${query}`, {
engines: ['bing news'],
pageno: 1,
language: searchLang,
}).then((r) => r.results),
),
);
const settled = await Promise.allSettled(searchPromises);
const allResults = settled
.filter((r): r is PromiseFulfilledResult<SearxngSearchResult[]> => r.status === 'fulfilled')
.flatMap((r) => r.value);
data = allResults
.flat()
.filter((item) => {
const url = item.url?.toLowerCase().trim();
if (seenUrls.has(url)) return false;
seenUrls.add(url);
return true;
})
.sort(() => Math.random() - 0.5);
} else {
data = (
await searchSearxng(
`site:${selectedTopic.links[Math.floor(Math.random() * selectedTopic.links.length)]} ${selectedTopic.query[Math.floor(Math.random() * selectedTopic.query.length)]}`,
{
engines: ['bing news'],
pageno: 1,
language: searchLang,
},
)
).results;
}
return Response.json(
{
blogs: data,
},
{
status: 200,
},
);
} catch (err) {
const message =
err instanceof Error ? err.message : 'An error has occurred';
console.error(`Discover route error:`, err);
return Response.json(
{
message:
message.includes('fetch') || message.includes('ECONNREFUSED')
? 'Cannot connect to SearxNG. Check that it is running and the URL is correct in Settings.'
: message,
},
{ status: 500 },
);
}
};

View File

@@ -1,78 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GEO_DEVICE_URL =
process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
const fallbackContext = (userAgent: string) => ({
geo: null,
device: { type: 'unknown' as const },
browser: { name: 'Unknown', version: '', full: '' },
os: { name: 'Unknown', version: '', full: '' },
client: {},
userAgent,
requestedAt: new Date().toISOString(),
});
export async function GET(req: NextRequest) {
try {
const forwarded = req.headers.get('x-forwarded-for');
const realIP = req.headers.get('x-real-ip');
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
headers: {
'x-forwarded-for': forwarded ?? '',
'x-real-ip': realIP ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
return NextResponse.json(data);
} catch {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const forwarded = req.headers.get('x-forwarded-for');
const realIP = req.headers.get('x-real-ip');
const res = await fetch(`${GEO_DEVICE_URL}/api/context`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-forwarded-for': forwarded ?? '',
'x-real-ip': realIP ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
return NextResponse.json(data);
} catch {
return NextResponse.json(
fallbackContext(req.headers.get('user-agent') ?? ''),
);
}
}

View File

@@ -1,41 +0,0 @@
import searchImages from '@/lib/agents/media/image';
import ModelRegistry from '@/lib/models/registry';
import { ModelWithProvider } from '@/lib/models/types';
interface ImageSearchBody {
query: string;
chatHistory: any[];
chatModel: ModelWithProvider;
}
export const POST = async (req: Request) => {
try {
const body: ImageSearchBody = await req.json();
const registry = new ModelRegistry();
const llm = await registry.loadChatModel(
body.chatModel.providerId,
body.chatModel.key,
);
const images = await searchImages(
{
chatHistory: body.chatHistory.map(([role, content]) => ({
role: role === 'human' ? 'user' : 'assistant',
content,
})),
query: body.query,
},
llm,
);
return Response.json({ images }, { status: 200 });
} catch (err) {
console.error(`An error occurred while searching images: ${err}`);
return Response.json(
{ message: 'An error occurred while searching images' },
{ status: 500 },
);
}
};

View File

@@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
const fallbackLocale = {
locale: 'en',
language: 'en',
region: null,
countryCode: null,
timezone: null,
source: 'fallback' as const,
};
export async function GET(req: NextRequest) {
try {
const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
headers: {
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
});
if (!res.ok) return NextResponse.json(fallbackLocale);
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(fallbackLocale);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
body: JSON.stringify(body),
});
if (!res.ok) return NextResponse.json(fallbackLocale);
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(fallbackLocale);
}
}

View File

@@ -1,94 +0,0 @@
import ModelRegistry from '@/lib/models/registry';
import { Model } from '@/lib/models/types';
import { NextRequest } from 'next/server';
export const POST = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const body: Partial<Model> & { type: 'embedding' | 'chat' } =
await req.json();
if (!body.key || !body.name) {
return Response.json(
{
message: 'Key and name must be provided',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
await registry.addProviderModel(id, body.type, body);
return Response.json(
{
message: 'Model added successfully',
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while adding provider model', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};
export const DELETE = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const body: { key: string; type: 'embedding' | 'chat' } = await req.json();
if (!body.key) {
return Response.json(
{
message: 'Key and name must be provided',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
await registry.removeProviderModel(id, body.type, body.key);
return Response.json(
{
message: 'Model added successfully',
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while deleting provider model', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -1,89 +0,0 @@
import ModelRegistry from '@/lib/models/registry';
import { NextRequest } from 'next/server';
export const DELETE = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
if (!id) {
return Response.json(
{
message: 'Provider ID is required.',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
await registry.removeProvider(id);
return Response.json(
{
message: 'Provider deleted successfully.',
},
{
status: 200,
},
);
} catch (err: any) {
console.error('An error occurred while deleting provider', err.message);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};
export const PATCH = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const body = await req.json();
const { name, config } = body;
const { id } = await params;
if (!id || !name || !config) {
return Response.json(
{
message: 'Missing required fields.',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
const updatedProvider = await registry.updateProvider(id, name, config);
return Response.json(
{
provider: updatedProvider,
},
{
status: 200,
},
);
} catch (err: any) {
console.error('An error occurred while updating provider', err.message);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -1,98 +0,0 @@
import {
getConfiguredModelProviders,
isEnvOnlyMode,
} from '@/lib/config/serverRegistry';
import ModelRegistry from '@/lib/models/registry';
import { NextRequest } from 'next/server';
export const GET = async () => {
try {
const envOnlyMode = isEnvOnlyMode();
const configuredProviders = getConfiguredModelProviders();
const registry = new ModelRegistry();
const activeProviders = await registry.getActiveProviders();
const filteredProviders = activeProviders.filter((p) => {
return !p.chatModels.some((m) => m.key === 'error');
});
// env-only: если у провайдера пустой chatModels, подставляем модель из конфига
const providers =
envOnlyMode && configuredProviders.length > 0
? filteredProviders.map((p) => {
if (p.chatModels.length > 0) return p;
const configProvider = configuredProviders.find((c) => c.id === p.id);
const fallbackChat =
(configProvider?.chatModels?.length ?? 0) > 0
? configProvider!.chatModels
: [{ key: 'gpt-4', name: 'gpt-4' }];
return { ...p, chatModels: fallbackChat };
})
: filteredProviders;
return Response.json(
{
providers,
envOnlyMode: envOnlyMode,
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while fetching providers', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};
export const POST = async (req: NextRequest) => {
if (isEnvOnlyMode()) {
return Response.json({ message: 'Not available.' }, { status: 405 });
}
try {
const body = await req.json();
const { type, name, config } = body;
if (!type || !name || !config) {
return Response.json(
{
message: 'Missing required fields.',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
const newProvider = await registry.addProvider(type, name, config);
return Response.json(
{
provider: newProvider,
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while creating provider', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -1,208 +0,0 @@
import ModelRegistry from '@/lib/models/registry';
import { ModelWithProvider } from '@/lib/models/types';
import SessionManager from '@/lib/session';
import { ChatTurnMessage } from '@/lib/types';
import { SearchSources } from '@/lib/agents/search/types';
import APISearchAgent from '@/lib/agents/search/api';
interface ChatRequestBody {
optimizationMode: 'speed' | 'balanced' | 'quality';
sources: SearchSources[];
chatModel: ModelWithProvider;
embeddingModel: ModelWithProvider;
query: string;
history: Array<[string, string]>;
stream?: boolean;
systemInstructions?: string;
}
export const POST = async (req: Request) => {
try {
const body: ChatRequestBody = await req.json();
if (!body.sources || !body.query) {
return Response.json(
{ message: 'Missing sources or query' },
{ status: 400 },
);
}
body.history = body.history || [];
body.optimizationMode = body.optimizationMode || 'speed';
body.stream = body.stream || false;
const registry = new ModelRegistry();
const [llm, embeddings] = await Promise.all([
registry.loadChatModel(body.chatModel.providerId, body.chatModel.key),
registry.loadEmbeddingModel(
body.embeddingModel.providerId,
body.embeddingModel.key,
),
]);
const history: ChatTurnMessage[] = body.history.map((msg) => {
return msg[0] === 'human'
? { role: 'user', content: msg[1] }
: { role: 'assistant', content: msg[1] };
});
const session = SessionManager.createSession();
const agent = new APISearchAgent();
agent.searchAsync(session, {
chatHistory: history,
config: {
embedding: embeddings,
llm: llm,
sources: body.sources,
mode: body.optimizationMode,
fileIds: [],
systemInstructions: body.systemInstructions || '',
},
followUp: body.query,
chatId: crypto.randomUUID(),
messageId: crypto.randomUUID(),
});
if (!body.stream) {
return new Promise(
(
resolve: (value: Response) => void,
reject: (value: Response) => void,
) => {
let message = '';
let sources: any[] = [];
session.subscribe((event: string, data: Record<string, any>) => {
if (event === 'data') {
try {
if (data.type === 'response') {
message += data.data;
} else if (data.type === 'searchResults') {
sources = data.data;
}
} catch (error) {
reject(
Response.json(
{ message: 'Error parsing data' },
{ status: 500 },
),
);
}
}
if (event === 'end') {
resolve(Response.json({ message, sources }, { status: 200 }));
}
if (event === 'error') {
reject(
Response.json(
{ message: 'Search error', error: data },
{ status: 500 },
),
);
}
});
},
);
}
const encoder = new TextEncoder();
const abortController = new AbortController();
const { signal } = abortController;
const stream = new ReadableStream({
start(controller) {
let sources: any[] = [];
controller.enqueue(
encoder.encode(
JSON.stringify({
type: 'init',
data: 'Stream connected',
}) + '\n',
),
);
signal.addEventListener('abort', () => {
session.removeAllListeners();
try {
controller.close();
} catch (error) {}
});
session.subscribe((event: string, data: Record<string, any>) => {
if (event === 'data') {
if (signal.aborted) return;
try {
if (data.type === 'response') {
controller.enqueue(
encoder.encode(
JSON.stringify({
type: 'response',
data: data.data,
}) + '\n',
),
);
} else if (data.type === 'searchResults') {
sources = data.data;
controller.enqueue(
encoder.encode(
JSON.stringify({
type: 'sources',
data: sources,
}) + '\n',
),
);
}
} catch (error) {
controller.error(error);
}
}
if (event === 'end') {
if (signal.aborted) return;
controller.enqueue(
encoder.encode(
JSON.stringify({
type: 'done',
}) + '\n',
),
);
controller.close();
}
if (event === 'error') {
if (signal.aborted) return;
controller.error(data);
}
});
},
cancel() {
abortController.abort();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
} catch (err: any) {
console.error(`Error in getting search results: ${err.message}`);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};

View File

@@ -1,41 +0,0 @@
import generateSuggestions from '@/lib/agents/suggestions';
import ModelRegistry from '@/lib/models/registry';
import { ModelWithProvider } from '@/lib/models/types';
interface SuggestionsGenerationBody {
chatHistory: any[];
chatModel: ModelWithProvider;
locale?: string;
}
export const POST = async (req: Request) => {
try {
const body: SuggestionsGenerationBody = await req.json();
const registry = new ModelRegistry();
const llm = await registry.loadChatModel(
body.chatModel.providerId,
body.chatModel.key,
);
const suggestions = await generateSuggestions(
{
chatHistory: body.chatHistory.map(([role, content]) => ({
role: role === 'human' ? 'user' : 'assistant',
content,
})),
locale: body.locale,
},
llm,
);
return Response.json({ suggestions }, { status: 200 });
} catch (err) {
console.error(`An error occurred while generating suggestions: ${err}`);
return Response.json(
{ message: 'An error occurred while generating suggestions' },
{ status: 500 },
);
}
};

View File

@@ -1,37 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getEmbeddedTranslations,
translationLocaleFor,
} from '@/lib/localization/embeddedTranslations';
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ locale: string }> },
) {
const resolved = await params;
const localeParam = resolved?.locale ?? '';
if (!localeParam) {
return NextResponse.json(
{ error: 'Locale is required' },
{ status: 400 },
);
}
try {
const fetchLocale = translationLocaleFor(localeParam);
const res = await fetch(
`${LOCALIZATION_SERVICE_URL}/api/translations/${encodeURIComponent(fetchLocale)}`,
);
if (res.ok) {
const data = await res.json();
return NextResponse.json(data);
}
} catch {
/* localization-service недоступен */
}
return NextResponse.json(getEmbeddedTranslations(localeParam));
}

View File

@@ -1,40 +0,0 @@
import { NextResponse } from 'next/server';
import ModelRegistry from '@/lib/models/registry';
import UploadManager from '@/lib/uploads/manager';
export async function POST(req: Request) {
try {
const formData = await req.formData();
const files = formData.getAll('files') as File[];
const embeddingModel = formData.get('embedding_model_key') as string;
const embeddingModelProvider = formData.get('embedding_model_provider_id') as string;
if (!embeddingModel || !embeddingModelProvider) {
return NextResponse.json(
{ message: 'Missing embedding model or provider' },
{ status: 400 },
);
}
const registry = new ModelRegistry();
const model = await registry.loadEmbeddingModel(embeddingModelProvider, embeddingModel);
const uploadManager = new UploadManager({
embeddingModel: model,
})
const processedFiles = await uploadManager.processFiles(files);
return NextResponse.json({
files: processedFiles,
});
} catch (error) {
console.error('Error uploading file:', error);
return NextResponse.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
}

View File

@@ -1,41 +0,0 @@
import handleVideoSearch from '@/lib/agents/media/video';
import ModelRegistry from '@/lib/models/registry';
import { ModelWithProvider } from '@/lib/models/types';
interface VideoSearchBody {
query: string;
chatHistory: any[];
chatModel: ModelWithProvider;
}
export const POST = async (req: Request) => {
try {
const body: VideoSearchBody = await req.json();
const registry = new ModelRegistry();
const llm = await registry.loadChatModel(
body.chatModel.providerId,
body.chatModel.key,
);
const videos = await handleVideoSearch(
{
chatHistory: body.chatHistory.map(([role, content]) => ({
role: role === 'human' ? 'user' : 'assistant',
content,
})),
query: body.query,
},
llm,
);
return Response.json({ videos }, { status: 200 });
} catch (err) {
console.error(`An error occurred while searching videos: ${err}`);
return Response.json(
{ message: 'An error occurred while searching videos' },
{ status: 500 },
);
}
};

View File

@@ -1,202 +0,0 @@
const geocodeCity = async (city: string): Promise<{ lat: number; lng: number; name: string } | null> => {
const res = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`,
);
const data = await res.json();
const result = data.results?.[0];
if (!result?.latitude || !result?.longitude) return null;
return {
lat: result.latitude,
lng: result.longitude,
name: result.name ?? city,
};
};
export const POST = async (req: Request) => {
try {
const body: {
lat?: number;
lng?: number;
city?: string;
measureUnit: 'Imperial' | 'Metric';
} = await req.json();
let lat: number;
let lng: number;
let cityName: string | undefined = body.city;
if (body.lat != null && body.lng != null) {
lat = body.lat;
lng = body.lng;
} else if (body.city?.trim()) {
const geo = await geocodeCity(body.city.trim());
if (!geo) {
return Response.json({ message: 'City not found.' }, { status: 404 });
}
lat = geo.lat;
lng = geo.lng;
cityName = geo.name;
} else {
return Response.json(
{ message: 'Invalid request. Provide lat/lng or city.' },
{ status: 400 },
);
}
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
);
const data = await res.json();
if (data.error) {
console.error(`Error fetching weather data: ${data.reason}`);
return Response.json(
{
message: 'An error has occurred.',
},
{ status: 500 },
);
}
const weather: {
temperature: number;
condition: string;
humidity: number;
windSpeed: number;
icon: string;
temperatureUnit: 'C' | 'F';
windSpeedUnit: 'm/s' | 'mph';
} = {
temperature: data.current.temperature_2m,
condition: '',
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
icon: '',
temperatureUnit: body.measureUnit === 'Metric' ? 'C' : 'F',
windSpeedUnit: body.measureUnit === 'Metric' ? 'm/s' : 'mph',
};
const code = data.current.weather_code;
const isDay = data.current.is_day === 1;
const dayOrNight = isDay ? 'day' : 'night';
switch (code) {
case 0:
weather.icon = `clear-${dayOrNight}`;
weather.condition = 'Clear';
break;
case 1:
weather.condition = 'Mainly Clear';
case 2:
weather.condition = 'Partly Cloudy';
case 3:
weather.icon = `cloudy-1-${dayOrNight}`;
weather.condition = 'Cloudy';
break;
case 45:
weather.condition = 'Fog';
case 48:
weather.icon = `fog-${dayOrNight}`;
weather.condition = 'Fog';
break;
case 51:
weather.condition = 'Light Drizzle';
case 53:
weather.condition = 'Moderate Drizzle';
case 55:
weather.icon = `rainy-1-${dayOrNight}`;
weather.condition = 'Dense Drizzle';
break;
case 56:
weather.condition = 'Light Freezing Drizzle';
case 57:
weather.icon = `frost-${dayOrNight}`;
weather.condition = 'Dense Freezing Drizzle';
break;
case 61:
weather.condition = 'Slight Rain';
case 63:
weather.condition = 'Moderate Rain';
case 65:
weather.condition = 'Heavy Rain';
weather.icon = `rainy-2-${dayOrNight}`;
break;
case 66:
weather.condition = 'Light Freezing Rain';
case 67:
weather.condition = 'Heavy Freezing Rain';
weather.icon = 'rain-and-sleet-mix';
break;
case 71:
weather.condition = 'Slight Snow Fall';
case 73:
weather.condition = 'Moderate Snow Fall';
case 75:
weather.condition = 'Heavy Snow Fall';
weather.icon = `snowy-2-${dayOrNight}`;
break;
case 77:
weather.condition = 'Snow';
weather.icon = `snowy-1-${dayOrNight}`;
break;
case 80:
weather.condition = 'Slight Rain Showers';
case 81:
weather.condition = 'Moderate Rain Showers';
case 82:
weather.condition = 'Heavy Rain Showers';
weather.icon = `rainy-3-${dayOrNight}`;
break;
case 85:
weather.condition = 'Slight Snow Showers';
case 86:
weather.condition = 'Moderate Snow Showers';
case 87:
weather.condition = 'Heavy Snow Showers';
weather.icon = `snowy-3-${dayOrNight}`;
break;
case 95:
weather.condition = 'Thunderstorm';
weather.icon = `scattered-thunderstorms-${dayOrNight}`;
break;
case 96:
weather.condition = 'Thunderstorm with Slight Hail';
case 99:
weather.condition = 'Thunderstorm with Heavy Hail';
weather.icon = 'severe-thunderstorm';
break;
default:
weather.icon = `clear-${dayOrNight}`;
weather.condition = 'Clear';
break;
}
return Response.json({ ...weather, city: cityName });
} catch (err) {
console.error('An error occurred while getting home widgets', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -1,73 +0,0 @@
export const dynamic = 'force-dynamic';
import type { Metadata } from 'next';
import { Roboto } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider';
import { LocalizationProvider } from '@/lib/localization/context';
import configManager from '@/lib/config';
import { isEnvOnlyMode } from '@/lib/config/serverRegistry';
import SetupWizard from '@/components/Setup/SetupWizard';
import { ChatProvider } from '@/lib/hooks/useChat';
import { ClientOnly } from '@/components/ClientOnly';
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
subsets: ['latin'],
display: 'swap',
fallback: ['Arial', 'sans-serif'],
});
export const metadata: Metadata = {
title: 'GooSeek - Chat with the internet',
description:
'GooSeek is an AI powered chatbot that is connected to the internet.',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const setupComplete = configManager.isSetupComplete();
const configSections = configManager.getUIConfigSections();
return (
<html className="h-full" lang="en" suppressHydrationWarning>
<body className={cn('h-full antialiased', roboto.className)}>
<ThemeProvider>
<LocalizationProvider>
{setupComplete ? (
<ClientOnly
fallback={
<div className="min-h-screen bg-light-primary dark:bg-dark-primary" />
}
>
<ChatProvider>
<Sidebar>{children}</Sidebar>
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast:
'bg-light-secondary dark:bg-dark-secondary dark:text-white/70 text-black/70 rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</ChatProvider>
</ClientOnly>
) : (
<SetupWizard
configSections={configSections}
envConfigRequired={!isEnvOnlyMode()}
/>
)}
</LocalizationProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,238 +0,0 @@
import {
ConfigModelProvider,
UIConfigField,
UIConfigSections,
} from '@/lib/config/types';
import { motion } from 'framer-motion';
import { ArrowLeft, ArrowRight, Check } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import AddProvider from '../Settings/Sections/Models/AddProviderDialog';
import ModelProvider from '../Settings/Sections/Models/ModelProvider';
import ModelSelect from '@/components/Settings/Sections/Models/ModelSelect';
const SetupConfig = ({
configSections,
setupState,
setSetupState,
envConfigRequired,
}: {
configSections: UIConfigSections;
setupState: number;
setSetupState: (state: number) => void;
envConfigRequired?: boolean;
}) => {
const [providers, setProviders] = useState<ConfigModelProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isFinishing, setIsFinishing] = useState(false);
useEffect(() => {
const fetchProviders = async () => {
try {
setIsLoading(true);
const res = await fetch('/api/providers');
if (!res.ok) throw new Error('Failed to fetch providers');
const data = await res.json();
setProviders(data.providers || []);
} catch (error) {
console.error('Error fetching providers:', error);
toast.error('Failed to load providers');
} finally {
setIsLoading(false);
}
};
if (setupState === 2 && !envConfigRequired) {
fetchProviders();
}
}, [setupState, envConfigRequired]);
const handleFinish = async () => {
try {
setIsFinishing(true);
const res = await fetch('/api/config/setup-complete', {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to complete setup');
window.location.reload();
} catch (error) {
console.error('Error completing setup:', error);
toast.error('Failed to complete setup');
setIsFinishing(false);
}
};
const visibleProviders = providers.filter(
(p) => p.name.toLowerCase() !== 'transformers',
);
const hasProviders =
visibleProviders.filter((p) => p.chatModels.length > 0).length > 0;
if (envConfigRequired && setupState >= 2) {
return (
<div className="w-[95vw] md:w-[80vw] lg:w-[65vw] mx-auto px-2 sm:px-4 md:px-6 flex flex-col space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
y: 0,
transition: { duration: 0.5, delay: 0.1 },
}}
className="w-full h-[calc(95vh-80px)] bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 rounded-xl shadow-sm flex flex-col overflow-hidden"
>
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 py-4 md:py-6 flex items-center justify-center">
<div className="flex flex-col items-center text-center max-w-md">
<p className="text-sm text-black/70 dark:text-white/70">
Сервис настраивается. Попробуйте позже.
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 rounded-lg bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70 hover:bg-light-300 dark:hover:bg-dark-300 text-sm transition-colors"
>
Обновить
</button>
</div>
</div>
</motion.div>
</div>
);
}
return (
<div className="w-[95vw] md:w-[80vw] lg:w-[65vw] mx-auto px-2 sm:px-4 md:px-6 flex flex-col space-y-6">
{setupState === 2 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
y: 0,
transition: { duration: 0.5, delay: 0.1 },
}}
className="w-full h-[calc(95vh-80px)] bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 rounded-xl shadow-sm flex flex-col overflow-hidden"
>
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 py-4 md:py-6">
<div className="flex flex-row justify-between items-center mb-4 md:mb-6 pb-3 md:pb-4 border-b border-light-200 dark:border-dark-200">
<div>
<p className="text-xs sm:text-sm font-medium text-black dark:text-white">
Manage Connections
</p>
<p className="text-[10px] sm:text-xs text-black/50 dark:text-white/50 mt-0.5">
Add connections to access AI models
</p>
</div>
<AddProvider
modelProviders={configSections.modelProviders}
setProviders={setProviders}
/>
</div>
<div className="space-y-3 md:space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8 md:py-12">
<p className="text-xs sm:text-sm text-black/50 dark:text-white/50">
Loading providers...
</p>
</div>
) : visibleProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 md:py-12 text-center">
<p className="text-xs sm:text-sm font-medium text-black/70 dark:text-white/70">
No connections configured
</p>
<p className="text-[10px] sm:text-xs text-black/50 dark:text-white/50 mt-1">
Click &quot;Add Connection&quot; above to get started
</p>
</div>
) : (
visibleProviders.map((provider) => (
<ModelProvider
key={`provider-${provider.id}`}
fields={
(configSections.modelProviders.find(
(f) => f.key === provider.type,
)?.fields ?? []) as UIConfigField[]
}
modelProvider={provider}
setProviders={setProviders}
/>
))
)}
</div>
</div>
</motion.div>
)}
{setupState === 3 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
y: 0,
transition: { duration: 0.5, delay: 0.1 },
}}
className="w-full h-[calc(95vh-80px)] bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 rounded-xl shadow-sm flex flex-col overflow-hidden"
>
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 py-4 md:py-6">
<div className="flex flex-row justify-between items-center mb-4 md:mb-6 pb-3 md:pb-4 border-b border-light-200 dark:border-dark-200">
<div>
<p className="text-xs sm:text-sm font-medium text-black dark:text-white">
Select models
</p>
<p className="text-[10px] sm:text-xs text-black/50 dark:text-white/50 mt-0.5">
Select models which you wish to use.
</p>
</div>
</div>
<div className="space-y-3 md:space-y-4">
<ModelSelect providers={providers} type="chat" />
<ModelSelect providers={providers} type="embedding" />
</div>
</div>
</motion.div>
)}
<div className="flex flex-row items-center justify-between pt-2">
<a></a>
{setupState === 2 && (
<motion.button
initial={{ opacity: 0, x: 10 }}
animate={{
opacity: 1,
x: 0,
transition: { duration: 0.5 },
}}
onClick={() => {
setSetupState(3);
}}
disabled={!hasProviders || isLoading}
className="flex flex-row items-center gap-1.5 md:gap-2 px-3 md:px-5 py-2 md:py-2.5 rounded-lg bg-[#EA580C] text-white hover:bg-[#c2410c] active:scale-95 transition-all duration-200 font-medium text-xs sm:text-sm disabled:bg-light-200 dark:disabled:bg-dark-200 disabled:text-black/40 dark:disabled:text-white/40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<span>Next</span>
<ArrowRight className="w-4 h-4 md:w-[18px] md:h-[18px]" />
</motion.button>
)}
{setupState === 3 && (
<motion.button
initial={{ opacity: 0, x: 10 }}
animate={{
opacity: 1,
x: 0,
transition: { duration: 0.5 },
}}
onClick={handleFinish}
disabled={!hasProviders || isLoading || isFinishing}
className="flex flex-row items-center gap-1.5 md:gap-2 px-3 md:px-5 py-2 md:py-2.5 rounded-lg bg-[#EA580C] text-white hover:bg-[#c2410c] active:scale-95 transition-all duration-200 font-medium text-xs sm:text-sm disabled:bg-light-200 dark:disabled:bg-dark-200 disabled:text-black/40 dark:disabled:text-white/40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<span>{isFinishing ? 'Finishing...' : 'Finish'}</span>
<Check className="w-4 h-4 md:w-[18px] md:h-[18px]" />
</motion.button>
)}
</div>
</div>
);
};
export default SetupConfig;

View File

@@ -1,129 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { UIConfigSections } from '@/lib/config/types';
import { AnimatePresence, motion } from 'framer-motion';
import SetupConfig from './SetupConfig';
const SetupWizard = ({
configSections,
envConfigRequired,
}: {
configSections: UIConfigSections;
envConfigRequired?: boolean;
}) => {
const [showWelcome, setShowWelcome] = useState(true);
const [showSetup, setShowSetup] = useState(false);
const [setupState, setSetupState] = useState(1);
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
useEffect(() => {
(async () => {
await delay(2500);
setShowWelcome(false);
await delay(600);
setShowSetup(true);
setSetupState(1);
await delay(1500);
setSetupState(2);
})();
}, [envConfigRequired]);
return (
<div className="bg-light-primary dark:bg-dark-primary h-screen w-screen fixed inset-0 overflow-hidden">
<AnimatePresence>
{showWelcome && (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<motion.div
className="absolute flex flex-col items-center justify-center h-full"
initial={{ opacity: 1 }}
exit={{ opacity: 0, scale: 1.1 }}
transition={{ duration: 0.6 }}
>
<motion.h2
transition={{ duration: 0.6 }}
initial={{ opacity: 0, translateY: '30px' }}
animate={{ opacity: 1, translateY: '0px' }}
className="text-4xl md:text-6xl xl:text-8xl font-normal tracking-tight"
>
Welcome to{' '}
<span className="text-[#EA580C] italic">
GooSeek
</span>
</motion.h2>
<motion.p
transition={{ delay: 0.8, duration: 0.7 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-black/70 dark:text-white/70 text-sm md:text-lg xl:text-2xl mt-2"
>
<span className="font-light">Web search,</span>{' '}
<span className="font-light italic">
reimagined
</span>
</motion.p>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{
opacity: 0.2,
scale: 1,
transition: { delay: 0.8, duration: 0.7 },
}}
exit={{ opacity: 0, scale: 1.1, transition: { duration: 0.6 } }}
className="bg-[#EA580C] left-50 translate-x-[-50%] h-[250px] w-[250px] rounded-full relative z-40 blur-[100px]"
/>
</div>
)}
{showSetup && (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<AnimatePresence mode="wait">
{setupState === 1 && (
<motion.p
key="setup-text"
transition={{ duration: 0.6 }}
initial={{ opacity: 0, translateY: '30px' }}
animate={{ opacity: 1, translateY: '0px' }}
exit={{
opacity: 0,
translateY: '-30px',
transition: { duration: 0.6 },
}}
className="text-2xl md:text-4xl xl:text-6xl font-normal tracking-tight"
>
Let us get{' '}
<span className="text-[#EA580C] italic">
GooSeek
</span>{' '}
set up for you
</motion.p>
)}
{setupState > 1 && (
<motion.div
key="setup-config"
initial={{ opacity: 0, translateY: '30px' }}
animate={{
opacity: 1,
translateY: '0px',
transition: { duration: 0.6 },
}}
>
<SetupConfig
configSections={configSections}
setupState={setupState}
setSetupState={setSetupState}
envConfigRequired={envConfigRequired}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</AnimatePresence>
</div>
);
};
export default SetupWizard;

View File

@@ -1,272 +0,0 @@
'use client';
import { cn } from '@/lib/utils';
import {
MessagesSquare,
Plus,
Search,
} from 'lucide-react';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '@/lib/localization/context';
import Layout from './Layout';
import SettingsButton from './Settings/SettingsButton';
interface ChatItem {
id: string;
title: string;
createdAt: string;
}
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col items-center w-full">{children}</div>;
};
interface HistorySubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
}
const HistorySubmenu = ({ onMouseEnter, onMouseLeave }: HistorySubmenuProps) => {
const { t } = useTranslation();
const [chats, setChats] = useState<ChatItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchChats = async () => {
try {
const res = await fetch('/api/chats');
const data = await res.json();
setChats((data.chats ?? []).reverse());
} catch {
setChats([]);
} finally {
setLoading(false);
}
};
fetchChats();
}, []);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{loading ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
{t('common.loading')}
</div>
) : chats.length === 0 ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
</div>
) : (
<nav className="flex flex-col">
{chats.map((chat) => (
<Link
key={chat.id}
href={`/c/${chat.id}`}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade block transition-colors"
title={chat.title}
>
{chat.title}
</Link>
))}
</nav>
)}
</div>
);
};
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const { t } = useTranslation();
const [historyOpen, setHistoryOpen] = useState(false);
const historyRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submenuStyle, setSubmenuStyle] = useState({ top: 0, left: 0 });
const discoverLink = {
icon: Search,
href: '/discover',
active: segments.includes('discover'),
label: t('nav.discover'),
};
const libraryLink = {
icon: MessagesSquare,
href: '/library',
active: segments.includes('library'),
label: t('nav.messageHistory'),
};
const navLinks = [discoverLink, libraryLink];
const handleHistoryMouseEnter = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
if (historyRef.current) {
const rect = historyRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setHistoryOpen(true);
};
const handleHistoryMouseLeave = () => {
hideTimeoutRef.current = setTimeout(() => setHistoryOpen(false), 150);
};
const cancelHide = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
};
useEffect(() => {
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, []);
return (
<div>
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[56px] lg:flex-col border-r border-light-200 dark:border-dark-200">
<div className="flex grow flex-col items-center justify-start gap-y-1 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-6 shadow-sm shadow-light-200/10 dark:shadow-black/25">
<a
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer"
href="/"
>
<Plus size={19} className="cursor-pointer" />
</a>
<VerticalIconContainer>
<Link
href={discoverLink.href}
className={cn(
'relative flex flex-col items-center justify-center space-y-0.5 cursor-pointer w-full py-2 rounded-lg',
discoverLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
>
<div
className={cn(
discoverLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<discoverLink.icon
size={25}
className={cn(
!discoverLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
<p
className={cn(
discoverLink.active
? 'text-black/80 dark:text-white/80'
: 'text-black/60 dark:text-white/60',
'text-[10px] text-fade',
)}
>
{discoverLink.label}
</p>
</Link>
<div
ref={historyRef}
className="relative w-full"
onMouseEnter={handleHistoryMouseEnter}
onMouseLeave={handleHistoryMouseLeave}
>
<Link
href={libraryLink.href}
className={cn(
'relative flex flex-col items-center justify-center space-y-0.5 cursor-pointer w-full py-2 rounded-lg',
libraryLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
>
<div
className={cn(
libraryLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<libraryLink.icon
size={25}
className={cn(
!libraryLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
<p
className={cn(
libraryLink.active
? 'text-black/80 dark:text-white/80'
: 'text-black/60 dark:text-white/60',
'text-[10px] text-fade',
)}
>
{libraryLink.label}
</p>
</Link>
{historyOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<HistorySubmenu
onMouseEnter={cancelHide}
onMouseLeave={handleHistoryMouseLeave}
/>
</div>,
document.body,
)}
</div>
</VerticalIconContainer>
<div className="mt-auto pt-4">
<SettingsButton />
</div>
</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">
{navLinks.map((link, i) => (
<Link
href={link.href}
key={i}
className={cn(
'relative flex flex-col items-center space-y-1 text-center w-full',
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" />
)}
<link.icon />
<p className="text-xs text-fade min-w-0">{link.label}</p>
</Link>
))}
</div>
<Layout>{children}</Layout>
</div>
);
};
export default Sidebar;

View File

@@ -1,13 +0,0 @@
export const register = async () => {
if (process.env.NEXT_RUNTIME === 'nodejs') {
try {
console.log('Running database migrations...');
await import('./lib/db/migrate');
console.log('Database migrations completed successfully');
} catch (error) {
console.error('Failed to run database migrations:', error);
}
await import('./lib/config/index');
}
};

View File

@@ -1,66 +0,0 @@
/* I don't think can be classified as agents but to keep the structure consistent i guess ill keep it here */
import { searchSearxng } from '@/lib/searxng';
import {
imageSearchFewShots,
imageSearchPrompt,
} from '@/lib/prompts/media/image';
import BaseLLM from '@/lib/models/base/llm';
import z from 'zod';
import { ChatTurnMessage } from '@/lib/types';
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
type ImageSearchChainInput = {
chatHistory: ChatTurnMessage[];
query: string;
};
type ImageSearchResult = {
img_src: string;
url: string;
title: string;
};
const searchImages = async (
input: ImageSearchChainInput,
llm: BaseLLM<any>,
) => {
const schema = z.object({
query: z.string().describe('The image search query.'),
});
const res = await llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: imageSearchPrompt,
},
...imageSearchFewShots,
{
role: 'user',
content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<follow_up>\n${input.query}\n</follow_up>`,
},
],
schema: schema,
});
const searchRes = await searchSearxng(res.query, {
engines: ['bing images', 'google images'],
});
const images: ImageSearchResult[] = [];
searchRes.results.forEach((result) => {
if (result.img_src && result.url && result.title) {
images.push({
img_src: result.img_src,
url: result.url,
title: result.title,
});
}
});
return images.slice(0, 10);
};
export default searchImages;

View File

@@ -1,66 +0,0 @@
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
import { searchSearxng } from '@/lib/searxng';
import {
videoSearchFewShots,
videoSearchPrompt,
} from '@/lib/prompts/media/videos';
import { ChatTurnMessage } from '@/lib/types';
import BaseLLM from '@/lib/models/base/llm';
import z from 'zod';
type VideoSearchChainInput = {
chatHistory: ChatTurnMessage[];
query: string;
};
type VideoSearchResult = {
img_src: string;
url: string;
title: string;
iframe_src: string;
};
const searchVideos = async (
input: VideoSearchChainInput,
llm: BaseLLM<any>,
) => {
const schema = z.object({
query: z.string().describe('The video search query.'),
});
const res = await llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: videoSearchPrompt,
},
...videoSearchFewShots,
{
role: 'user',
content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<follow_up>\n${input.query}\n</follow_up>`,
},
],
schema: schema,
});
const searchRes = await searchSearxng(res.query, {
engines: ['youtube'],
});
const videos: VideoSearchResult[] = [];
searchRes.results.forEach((result) => {
if (result.thumbnail && result.url && result.title && result.iframe_src) {
videos.push({
img_src: result.thumbnail,
url: result.url,
title: result.title,
iframe_src: result.iframe_src,
});
}
});
return videos.slice(0, 10);
};
export default searchVideos;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
import { getSuggestionGeneratorPrompt } from '@/lib/prompts/suggestions';
import { ChatTurnMessage } from '@/lib/types';
import z from 'zod';
import BaseLLM from '@/lib/models/base/llm';
type SuggestionGeneratorInput = {
chatHistory: ChatTurnMessage[];
locale?: string;
};
const schema = z.object({
suggestions: z
.array(z.string())
.describe('List of suggested questions or prompts'),
});
const generateSuggestions = async (
input: SuggestionGeneratorInput,
llm: BaseLLM<any>,
) => {
const res = await llm.generateObject<typeof schema>({
messages: [
{
role: 'system',
content: getSuggestionGeneratorPrompt(input.locale),
},
{
role: 'user',
content: `<chat_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</chat_history>`,
},
],
schema,
});
return res.suggestions;
};
export default generateSuggestions;

View File

@@ -1,11 +0,0 @@
import type { ModelProviderUISection } from './types';
/**
* Lazy-loads getModelProvidersUIConfigSection only when needed (not in env-only mode).
* Avoids loading all 9 providers into the module graph during layout/config initialization.
*/
export function loadModelProvidersUIConfigSection(): ModelProviderUISection[] {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getModelProvidersUIConfigSection } = require('../models/providers');
return getModelProvidersUIConfigSection();
}

View File

@@ -1,13 +0,0 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema';
import path from 'node:path';
const DATA_DIR = process.env.DATA_DIR
? path.resolve(process.cwd(), process.env.DATA_DIR)
: process.cwd();
const sqlite = new Database(path.join(DATA_DIR, 'data', 'db.sqlite'));
const db = drizzle(sqlite, { schema });
export default db;

View File

@@ -1,290 +0,0 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const DATA_DIR = process.env.DATA_DIR
? path.resolve(process.cwd(), process.env.DATA_DIR)
: process.cwd();
const dbPath = path.join(DATA_DIR, 'data', 'db.sqlite');
const db = new Database(dbPath);
const migrationsFolder = path.join(DATA_DIR, 'drizzle');
db.exec(`
CREATE TABLE IF NOT EXISTS ran_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
run_on DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
function sanitizeSql(content: string) {
const statements = content
.split(/--> statement-breakpoint/g)
.map((stmt) =>
stmt
.split(/\r?\n/)
.filter((l) => !l.trim().startsWith('-->'))
.join('\n')
.trim(),
)
.filter((stmt) => stmt.length > 0);
return statements;
}
fs.readdirSync(migrationsFolder)
.filter((f) => f.endsWith('.sql'))
.sort()
.forEach((file) => {
const filePath = path.join(migrationsFolder, file);
let content = fs.readFileSync(filePath, 'utf-8');
const statements = sanitizeSql(content);
const migrationName = file.split('_')[0] || file;
const already = db
.prepare('SELECT 1 FROM ran_migrations WHERE name = ?')
.get(migrationName);
if (already) {
console.log(`Skipping already-applied migration: ${file}`);
return;
}
try {
if (migrationName === '0001') {
const messages = db
.prepare(
'SELECT id, type, metadata, content, chatId, messageId FROM messages',
)
.all();
db.exec(`
CREATE TABLE IF NOT EXISTS messages_with_sources (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
chatId TEXT NOT NULL,
createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
messageId TEXT NOT NULL,
content TEXT,
sources TEXT DEFAULT '[]'
);
`);
const insertMessage = db.prepare(`
INSERT INTO messages_with_sources (type, chatId, createdAt, messageId, content, sources)
VALUES (?, ?, ?, ?, ?, ?)
`);
messages.forEach((msg: any) => {
while (typeof msg.metadata === 'string') {
msg.metadata = JSON.parse(msg.metadata || '{}');
}
if (msg.type === 'user') {
insertMessage.run(
'user',
msg.chatId,
msg.metadata['createdAt'],
msg.messageId,
msg.content,
'[]',
);
} else if (msg.type === 'assistant') {
insertMessage.run(
'assistant',
msg.chatId,
msg.metadata['createdAt'],
msg.messageId,
msg.content,
'[]',
);
const sources = msg.metadata['sources'] || '[]';
if (sources && sources.length > 0) {
insertMessage.run(
'source',
msg.chatId,
msg.metadata['createdAt'],
`${msg.messageId}-source`,
'',
JSON.stringify(sources),
);
}
}
});
db.exec('DROP TABLE messages;');
db.exec('ALTER TABLE messages_with_sources RENAME TO messages;');
} else if (migrationName === '0002') {
/* Migrate chat */
db.exec(`
CREATE TABLE IF NOT EXISTS chats_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
createdAt TEXT NOT NULL,
sources TEXT DEFAULT '[]',
files TEXT DEFAULT '[]'
);
`);
const chats = db
.prepare('SELECT id, title, createdAt, files FROM chats')
.all();
const insertChat = db.prepare(`
INSERT INTO chats_new (id, title, createdAt, sources, files)
VALUES (?, ?, ?, ?, ?)
`);
chats.forEach((chat: any) => {
let files = chat.files;
while (typeof files === 'string') {
files = JSON.parse(files || '[]');
}
insertChat.run(
chat.id,
chat.title,
chat.createdAt,
'["web"]',
JSON.stringify(files),
);
});
db.exec('DROP TABLE chats;');
db.exec('ALTER TABLE chats_new RENAME TO chats;');
/* Migrate messages */
db.exec(`
CREATE TABLE IF NOT EXISTS messages_new (
id INTEGER PRIMARY KEY,
messageId TEXT NOT NULL,
chatId TEXT NOT NULL,
backendId TEXT NOT NULL,
query TEXT NOT NULL,
createdAt TEXT NOT NULL,
responseBlocks TEXT DEFAULT '[]',
status TEXT DEFAULT 'answering'
);
`);
const messages = db
.prepare(
'SELECT id, messageId, chatId, type, content, createdAt, sources FROM messages ORDER BY id ASC',
)
.all();
const insertMessage = db.prepare(`
INSERT INTO messages_new (messageId, chatId, backendId, query, createdAt, responseBlocks, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
let currentMessageData: {
sources?: any[];
response?: string;
query?: string;
messageId?: string;
chatId?: string;
createdAt?: string;
} = {};
let lastCompleted = true;
messages.forEach((msg: any) => {
if (msg.type === 'user' && lastCompleted) {
currentMessageData = {};
currentMessageData.messageId = msg.messageId;
currentMessageData.chatId = msg.chatId;
currentMessageData.query = msg.content;
currentMessageData.createdAt = msg.createdAt;
lastCompleted = false;
} else if (msg.type === 'source' && !lastCompleted) {
let sources = msg.sources;
while (typeof sources === 'string') {
sources = JSON.parse(sources || '[]');
}
currentMessageData.sources = sources;
} else if (msg.type === 'assistant' && !lastCompleted) {
currentMessageData.response = msg.content;
insertMessage.run(
currentMessageData.messageId,
currentMessageData.chatId,
`${currentMessageData.messageId}-backend`,
currentMessageData.query,
currentMessageData.createdAt,
JSON.stringify([
{
id: crypto.randomUUID(),
type: 'text',
data: currentMessageData.response || '',
},
...(currentMessageData.sources &&
currentMessageData.sources.length > 0
? [
{
id: crypto.randomUUID(),
type: 'source',
data: currentMessageData.sources,
},
]
: []),
]),
'completed',
);
lastCompleted = true;
} else if (msg.type === 'user' && !lastCompleted) {
/* Message wasn't completed so we'll just create the record with empty response */
insertMessage.run(
currentMessageData.messageId,
currentMessageData.chatId,
`${currentMessageData.messageId}-backend`,
currentMessageData.query,
currentMessageData.createdAt,
JSON.stringify([
{
id: crypto.randomUUID(),
type: 'text',
data: '',
},
...(currentMessageData.sources &&
currentMessageData.sources.length > 0
? [
{
id: crypto.randomUUID(),
type: 'source',
data: currentMessageData.sources,
},
]
: []),
]),
'completed',
);
lastCompleted = true;
}
});
db.exec('DROP TABLE messages;');
db.exec('ALTER TABLE messages_new RENAME TO messages;');
} else {
// Execute each statement separately
statements.forEach((stmt) => {
if (stmt.trim()) {
db.exec(stmt);
}
});
}
db.prepare('INSERT OR IGNORE INTO ran_migrations (name) VALUES (?)').run(
migrationName,
);
console.log(`Applied migration: ${file}`);
} catch (err) {
console.error(`Failed to apply migration ${file}:`, err);
throw err;
}
});

View File

@@ -1,38 +0,0 @@
import { sql } from 'drizzle-orm';
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core';
import { Block } from '../types';
import { SearchSources } from '../agents/search/types';
export const messages = sqliteTable('messages', {
id: integer('id').primaryKey(),
messageId: text('messageId').notNull(),
chatId: text('chatId').notNull(),
backendId: text('backendId').notNull(),
query: text('query').notNull(),
createdAt: text('createdAt').notNull(),
responseBlocks: text('responseBlocks', { mode: 'json' })
.$type<Block[]>()
.default(sql`'[]'`),
status: text({ enum: ['answering', 'completed', 'error'] }).default(
'answering',
),
});
interface DBFile {
name: string;
fileId: string;
}
export const chats = sqliteTable('chats', {
id: text('id').primaryKey(),
title: text('title').notNull(),
createdAt: text('createdAt').notNull(),
sources: text('sources', {
mode: 'json',
})
.$type<SearchSources[]>()
.default(sql`'[]'`),
files: text('files', { mode: 'json' })
.$type<DBFile[]>()
.default(sql`'[]'`),
});

View File

@@ -1,103 +0,0 @@
import z from 'zod';
import { Message } from '../types';
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,
};

View File

@@ -1,30 +0,0 @@
import { ChatTurnMessage } from '@/lib/types';
export const imageSearchPrompt = `
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search the web for images.
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
Make sure to make the querey standalone and not something very broad, use context from the answers in the conversation to make it specific so user can get best image search results.
Output only the rephrased query in query key JSON format. Do not include any explanation or additional text.
`;
export const imageSearchFewShots: ChatTurnMessage[] = [
{
role: 'user',
content:
'<conversation>\n</conversation>\n<follow_up>\nWhat is a cat?\n</follow_up>',
},
{ role: 'assistant', content: '{"query":"A cat"}' },
{
role: 'user',
content:
'<conversation>\n</conversation>\n<follow_up>\nWhat is a car? How does it work?\n</follow_up>',
},
{ role: 'assistant', content: '{"query":"Car working"}' },
{
role: 'user',
content:
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
},
{ role: 'assistant', content: '{"query":"AC working"}' },
];

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,88 +0,0 @@
import { UIConfigField } from '@/lib/config/types';
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
import { Model, ModelList, ProviderMetadata } from '../../types';
import BaseModelProvider from '../../base/provider';
import BaseLLM from '../../base/llm';
import BaseEmbedding from '../../base/embedding';
import TransformerEmbedding from './transformerEmbedding';
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;

View File

@@ -1,41 +0,0 @@
import { Chunk } from '@/lib/types';
import BaseEmbedding from '../../base/embedding';
import { 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;

View File

@@ -1,69 +0,0 @@
import type { GeoDeviceContext, LocalizationContext } from '../types.js';
import {
resolveLocaleFromCountry,
normalizeAcceptLanguage,
getDefaultLocale,
} from './countryToLocale.js';
/**
* Определяет locale на основе geo-контекста.
* Приоритет: геопозиция > client.language > Accept-Language > fallback.
* Геопозиция первая — если пользователь в России, показываем русский.
*/
export function resolveLocale(ctx: GeoDeviceContext | null): LocalizationContext {
const fallback: LocalizationContext = {
locale: getDefaultLocale(),
language: getDefaultLocale(),
region: null,
countryCode: null,
timezone: null,
source: 'fallback',
};
if (!ctx) return fallback;
const geo = ctx.geo;
const acceptLang = normalizeAcceptLanguage(ctx.acceptLanguage);
const clientLang = ctx.client?.language
? ctx.client.language.split('-')[0]?.toLowerCase()
: null;
// 1. Геопозиция (countryCode) — приоритет при определении по IP
if (geo?.countryCode) {
const locale = resolveLocaleFromCountry(geo.countryCode);
return {
locale,
language: locale,
region: geo.region ?? null,
countryCode: geo.countryCode,
timezone: geo.timezone ?? null,
source: 'geo',
};
}
// 2. Язык из client (браузер navigator.language)
if (clientLang) {
return {
locale: clientLang,
language: clientLang,
region: geo?.region ?? null,
countryCode: geo?.countryCode ?? null,
timezone: geo?.timezone ?? null,
source: 'client',
};
}
// 3. Accept-Language заголовок
if (acceptLang) {
return {
locale: acceptLang,
language: acceptLang,
region: geo?.region ?? null,
countryCode: geo?.countryCode ?? null,
timezone: geo?.timezone ?? null,
source: 'accept-language',
};
}
return fallback;
}

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