feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService) - Add travel orchestrator with parallel collectors (events, POI, hotels, flights) - Add 2GIS road routing with transport cost calculation (car/bus/taxi) - Add TravelMap (2GIS MapGL) and TravelWidgets components - Add useTravelChat hook for streaming travel agent responses - Add finance heatmap providers refactor - Add SearXNG settings, API proxy routes, Docker compose updates - Update Dockerfiles, config, types, and all UI pages for consistency Made-with: Cursor
This commit is contained in:
188
CONTINUE.md
188
CONTINUE.md
@@ -1,141 +1,87 @@
|
||||
# Недоделки — начать отсюда
|
||||
|
||||
## Последнее исправление (28.02.2026)
|
||||
## Последнее изменение (01.03.2026)
|
||||
|
||||
### Fix: auth-svc не собирался в Docker
|
||||
- Добавлена строка сборки `auth-svc` в `Dockerfile.all`
|
||||
- Удалён дубликат `ratelimit_tiered.go` (конфликт типов с Redis-версией)
|
||||
- Добавлен REDIS_URL в api-gateway для rate limiting
|
||||
- Пересоздан тестовый пользователь через API
|
||||
### СДЕЛАНО: Маршруты по дорогам + стоимость проезда
|
||||
|
||||
#### Что сделано:
|
||||
|
||||
**1. `backend/internal/travel/twogis.go` — 2GIS Routing API клиент:**
|
||||
- Метод `GetRoute(ctx, points, transport)` — POST `routing.api.2gis.com/routing/7.0.0/global`
|
||||
- Поддержка transport: `driving`, `taxi`, `walking`, `bicycle`
|
||||
- Парсинг WKT LINESTRING из `outcoming_path.geometry[].selection`
|
||||
- Сборка `RouteDirection` с geometry, distance, duration, steps
|
||||
|
||||
**2. `backend/internal/travel/service.go` — переключение на 2GIS:**
|
||||
- `GetRoute()` сначала пробует 2GIS Routing, fallback на OpenRouteService
|
||||
- `mapProfileToTwoGISTransport()` — маппинг профилей
|
||||
|
||||
**3. `backend/internal/agent/travel_data_client.go` — обновлённый клиент:**
|
||||
- `GetRoute(ctx, points, transport)` — полный `RouteDirectionResult` с geometry
|
||||
- `GetRouteSegments()` — маршруты между каждой парой точек
|
||||
- Новые типы: `RouteDirectionResult`, `RouteGeometryResult`, `RouteStepResult`, `RouteSegmentResult`
|
||||
|
||||
**4. `backend/internal/agent/travel_orchestrator.go` — дорожные маршруты:**
|
||||
- `emitTravelWidgets()` вызывает `buildRoadRoute()` вместо прямых линий
|
||||
- `buildTransportSegments()` — маршруты между каждой парой точек
|
||||
- `calculateTransportCosts()` — расчёт стоимости (машина ~8₽/км, автобус ~2.5₽/км, такси ~100₽+18₽/км)
|
||||
- `routeDirection` и `segments` передаются в виджеты `travel_map` и `travel_itinerary`
|
||||
|
||||
**5. Фронтенд — отображение дорожных маршрутов:**
|
||||
- `types.ts` — новые типы `RouteSegment`, `TransportCostOption`, расширены `TravelMapWidgetParams` и `TravelItineraryWidgetParams`
|
||||
- `useTravelChat.ts` — извлечение `routeDirection` и `segments` из виджетов, новые state
|
||||
- `travel/page.tsx` — передача `routeDirection` в `TravelMap`
|
||||
- `TravelWidgets.tsx` — `TransportSegmentCard` между элементами маршрута с иконками машина/автобус/такси и ценами
|
||||
|
||||
---
|
||||
|
||||
## Последнее изменение (28.02.2026)
|
||||
### СДЕЛАНО ранее: Переработка POI коллектора — 2GIS как основной источник
|
||||
|
||||
**Обновлён дизайн страниц авторизации:**
|
||||
|
||||
### Сделано (полностью)
|
||||
|
||||
#### Страницы авторизации в стиле сайта
|
||||
- `/login` — переделана с градиентным фоном, фирменным логотипом, анимациями
|
||||
- `/register` — аналогично, единый стиль с login
|
||||
- `/forgot-password` — сброс пароля в новом дизайне
|
||||
- `/reset-password` — установка нового пароля в новом дизайне
|
||||
|
||||
Изменения:
|
||||
- `bg-gradient-main` вместо простого `bg-base`
|
||||
- Фирменный логотип GooSeek (font-black italic + иконка Sparkles)
|
||||
- Gradient blur декорации на фоне
|
||||
- Карточка с градиентной рамкой и backdrop-blur
|
||||
- Анимации появления (framer-motion)
|
||||
- Footer с копирайтом
|
||||
(см. предыдущую версию CONTINUE.md)
|
||||
|
||||
---
|
||||
|
||||
**Ранее: Spaces как у Perplexity + коллаборация:**
|
||||
|
||||
### Сделано (полностью)
|
||||
|
||||
#### 1. Backend - Space Members
|
||||
- Добавлена таблица `space_members` (space_id, user_id, role)
|
||||
- Добавлена таблица `space_invites` (приглашения по токену)
|
||||
- Методы: `GetMembers`, `AddMember`, `RemoveMember`, `IsMember`
|
||||
- Методы: `CreateInvite`, `GetInviteByToken`, `DeleteInvite`
|
||||
- Обновлён `GetByMemberID` - возвращает пространства где пользователь участник
|
||||
|
||||
#### 2. Frontend - Types & API
|
||||
- Добавлены типы: `SpaceMember`, `SpaceInvite`
|
||||
- Обновлён тип `Space` с полями `members`, `memberCount`, `userId`
|
||||
- API методы: `fetchSpaceMembers`, `inviteToSpace`, `removeSpaceMember`, `acceptSpaceInvite`, `fetchSpaceThreads`
|
||||
|
||||
#### 3. Страница /spaces - Новый дизайн
|
||||
- Карточки пространств с градиентным фоном
|
||||
- Показывает: название, описание, кол-во участников, кол-во тредов
|
||||
- Аватары участников на карточке
|
||||
- Поиск по пространствам
|
||||
- Требует авторизации
|
||||
|
||||
#### 4. Страница /spaces/[id] - Детали пространства
|
||||
- Заголовок с названием и описанием
|
||||
- Табы: Треды / Участники
|
||||
- Список тредов пространства с датами
|
||||
- Список участников с ролями (owner/admin/member)
|
||||
- Кнопка "Начать новый тред"
|
||||
- Модалка приглашения участников по email
|
||||
- Удаление участников (для admin/owner)
|
||||
|
||||
#### 5. Страница /spaces/new - Создание
|
||||
- Превью карточки в реальном времени
|
||||
- Выбор иконки (эмодзи)
|
||||
- Выбор цвета (6 вариантов)
|
||||
- Переключатель приватности
|
||||
- AI инструкции для пространства
|
||||
|
||||
#### 6. Ранее добавлено
|
||||
- Селектор модели (Auto/GooSeek 1.0)
|
||||
- Ollama клиент для бесплатной модели
|
||||
- Обновлённая кнопка "Войти" в сайдбаре
|
||||
|
||||
### Файлы изменены/созданы
|
||||
|
||||
```
|
||||
backend/internal/db/
|
||||
└── space_repo.go (UPDATED - members, invites)
|
||||
|
||||
backend/webui/src/
|
||||
├── lib/
|
||||
│ ├── types.ts (UPDATED - SpaceMember, SpaceInvite)
|
||||
│ └── api.ts (UPDATED - space members API)
|
||||
├── app/(main)/spaces/
|
||||
│ ├── page.tsx (REWRITTEN - новый дизайн)
|
||||
│ ├── new/page.tsx (REWRITTEN - с превью)
|
||||
│ └── [id]/page.tsx (NEW - детали пространства)
|
||||
└── components/
|
||||
├── Sidebar.tsx (UPDATED - кнопка войти)
|
||||
└── ChatInput.tsx (UPDATED - селектор модели)
|
||||
|
||||
backend/internal/llm/
|
||||
├── ollama.go (NEW)
|
||||
└── client.go (UPDATED)
|
||||
|
||||
backend/pkg/config/config.go (UPDATED - Ollama config)
|
||||
backend/cmd/agent-svc/main.go (UPDATED - Ollama support)
|
||||
```
|
||||
|
||||
## Осталось сделать
|
||||
|
||||
### Высокий приоритет:
|
||||
1. **Backend API endpoints** — добавить API для:
|
||||
- `GET /api/v1/spaces/:id/members`
|
||||
- `POST /api/v1/spaces/:id/invite`
|
||||
- `DELETE /api/v1/spaces/:id/members/:userId`
|
||||
- `POST /api/v1/spaces/invite/:token/accept`
|
||||
- `GET /api/v1/spaces/:id/threads`
|
||||
### Высокий приоритет
|
||||
|
||||
2. **Email отправка** — отправлять email с приглашением
|
||||
1. **Цены отелей из SearXNG** — LLM не всегда извлекает цены (0 RUB/night). Нужно:
|
||||
- Добавить fallback: если цена 0, попробовать парсить из snippet
|
||||
- Файл: `backend/internal/agent/travel_hotels_collector.go`
|
||||
|
||||
3. **Ollama в docker-compose** — добавить сервис ollama
|
||||
2. **Авиабилеты для маршрутов** — "Золотое кольцо" не имеет IATA кода. Нужно:
|
||||
- Если destination не IATA, искать билеты до первого конкретного города в маршруте
|
||||
- Файл: `backend/internal/agent/travel_flights_collector.go`
|
||||
|
||||
### Средний приоритет:
|
||||
4. **OAuth провайдеры** — Google, GitHub, Yandex
|
||||
5. **Подтверждение email** — отправка письма при регистрации
|
||||
6. **Real-time обновления** — WebSocket для тредов в пространстве
|
||||
7. **Уведомления** — когда кто-то добавляет сообщение в тред
|
||||
### Средний приоритет
|
||||
|
||||
### Низкий приоритет:
|
||||
8. **Интеграция оплаты** — ЮKassa для пополнения баланса
|
||||
9. **2FA** — TOTP аутентификация
|
||||
10. **Экспорт тредов** — PDF/Markdown
|
||||
3. **Drag & drop в ItineraryWidget** — перетаскивание элементов между днями
|
||||
4. **Кеш SearXNG результатов** — Redis кеш на 10-30 минут
|
||||
5. **Сохранение draft в БД** — персистентность TripDraft через trip_drafts таблицу
|
||||
|
||||
### Низкий приоритет
|
||||
|
||||
6. **Экспорт маршрута** — PDF/Markdown
|
||||
7. **Real-time обновления** — WebSocket для тредов
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
### Модели:
|
||||
| ID | Provider | Тариф |
|
||||
|----|----------|-------|
|
||||
| auto | ollama | Бесплатно |
|
||||
| gooseek-1.0 | timeweb | По тарифу |
|
||||
### Архитектура travel pipeline:
|
||||
|
||||
### Роли в пространстве:
|
||||
- `owner` — создатель, полные права
|
||||
- `admin` — может приглашать/удалять участников
|
||||
- `member` — может создавать треды
|
||||
```
|
||||
User -> /travel page -> streamTravelAgent() -> api-gateway -> chat-svc -> agent-svc
|
||||
-> RunTravelOrchestrator:
|
||||
1. Planner Agent (LLM) -> TripBrief
|
||||
2. Geocode destinations -> travel-svc -> 2GIS Geocoder API
|
||||
3. Parallel collectors:
|
||||
- Events: SearXNG -> Crawl4AI -> LLM extraction -> geocode (2GIS)
|
||||
- POI: 2GIS Places API (primary) -> LLM enrichment -> SearXNG fallback
|
||||
- Hotels: SearXNG -> Crawl4AI -> LLM extraction -> geocode (2GIS)
|
||||
- Transport: TravelPayouts API
|
||||
4. Itinerary Builder (LLM) -> ItineraryDay[]
|
||||
5. Road routing: 2GIS Routing API -> RouteDirection (дорожная геометрия)
|
||||
6. Transport costs: calculateTransportCosts() -> машина/автобус/такси
|
||||
7. Widget emission -> NDJSON stream -> frontend (карта 2GIS MapGL)
|
||||
```
|
||||
|
||||
@@ -73,7 +73,7 @@ func main() {
|
||||
agents := app.Group("/api/v1/agents", middleware.JWT(middleware.JWTConfig{
|
||||
Secret: cfg.JWTSecret,
|
||||
AuthSvcURL: cfg.AuthSvcURL,
|
||||
AllowGuest: false,
|
||||
AllowGuest: true,
|
||||
}))
|
||||
|
||||
agents.Post("/search", func(c *fiber.Ctx) error {
|
||||
@@ -162,7 +162,10 @@ func main() {
|
||||
ResponsePrefs: responsePrefs,
|
||||
LearningMode: req.LearningMode,
|
||||
DiscoverSvcURL: cfg.DiscoverSvcURL,
|
||||
Crawl4AIURL: cfg.Crawl4AIURL,
|
||||
Crawl4AIURL: cfg.Crawl4AIURL,
|
||||
TravelSvcURL: cfg.TravelSvcURL,
|
||||
TravelPayoutsToken: cfg.TravelPayoutsToken,
|
||||
TravelPayoutsMarker: cfg.TravelPayoutsMarker,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ func main() {
|
||||
"discover": cfg.DiscoverSvcURL,
|
||||
"finance": cfg.FinanceHeatmapURL,
|
||||
"learning": cfg.LearningSvcURL,
|
||||
"travel": cfg.TravelSvcURL,
|
||||
"admin": cfg.AdminSvcURL,
|
||||
}
|
||||
|
||||
@@ -139,6 +140,8 @@ func getTarget(path string) (base, rewrite string) {
|
||||
return svcURLs["finance"], path
|
||||
case strings.HasPrefix(path, "/api/v1/learning"):
|
||||
return svcURLs["learning"], path
|
||||
case strings.HasPrefix(path, "/api/v1/travel"):
|
||||
return svcURLs["travel"], path
|
||||
case strings.HasPrefix(path, "/api/v1/admin"):
|
||||
return svcURLs["admin"], path
|
||||
default:
|
||||
|
||||
@@ -97,7 +97,7 @@ func main() {
|
||||
chat := app.Group("/api/v1/chat", middleware.JWT(middleware.JWTConfig{
|
||||
Secret: cfg.JWTSecret,
|
||||
AuthSvcURL: cfg.AuthSvcURL,
|
||||
AllowGuest: false,
|
||||
AllowGuest: true,
|
||||
}))
|
||||
|
||||
chat.Post("/", func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
func main() {
|
||||
heatmapSvc := finance.NewHeatmapService(finance.HeatmapConfig{
|
||||
DataProviderURL: os.Getenv("FINANCE_DATA_PROVIDER_URL"),
|
||||
CacheTTL: 5 * time.Minute,
|
||||
RefreshInterval: time.Minute,
|
||||
})
|
||||
|
||||
540
backend/cmd/travel-svc/main.go
Normal file
540
backend/cmd/travel-svc/main.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/travel"
|
||||
"github.com/gooseek/backend/pkg/middleware"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("Failed to ping database: %v", err)
|
||||
}
|
||||
|
||||
repo := travel.NewRepository(db)
|
||||
if err := repo.InitSchema(context.Background()); err != nil {
|
||||
log.Printf("Warning: Failed to init schema: %v", err)
|
||||
}
|
||||
|
||||
llmCfg := llm.ProviderConfig{
|
||||
ProviderID: getEnv("LLM_PROVIDER", "timeweb"),
|
||||
ModelKey: getEnv("LLM_MODEL", "gpt-4o-mini"),
|
||||
BaseURL: os.Getenv("TIMEWEB_API_BASE_URL"),
|
||||
APIKey: os.Getenv("TIMEWEB_API_KEY"),
|
||||
AgentAccessID: os.Getenv("TIMEWEB_AGENT_ACCESS_ID"),
|
||||
}
|
||||
|
||||
llmBaseClient, err := llm.NewClient(llmCfg)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to create LLM client: %v", err)
|
||||
}
|
||||
|
||||
var llmClient travel.LLMClient
|
||||
if llmBaseClient != nil {
|
||||
llmClient = travel.NewLLMClientAdapter(llmBaseClient)
|
||||
}
|
||||
|
||||
useRussianAPIs := getEnv("USE_RUSSIAN_APIS", "true") == "true"
|
||||
|
||||
svc := travel.NewService(travel.ServiceConfig{
|
||||
Repository: repo,
|
||||
AmadeusConfig: travel.AmadeusConfig{
|
||||
APIKey: os.Getenv("AMADEUS_API_KEY"),
|
||||
APISecret: os.Getenv("AMADEUS_API_SECRET"),
|
||||
BaseURL: getEnv("AMADEUS_BASE_URL", "https://test.api.amadeus.com"),
|
||||
},
|
||||
OpenRouteConfig: travel.OpenRouteConfig{
|
||||
APIKey: os.Getenv("OPENROUTE_API_KEY"),
|
||||
BaseURL: getEnv("OPENROUTE_BASE_URL", "https://api.openrouteservice.org"),
|
||||
},
|
||||
TravelPayoutsConfig: travel.TravelPayoutsConfig{
|
||||
Token: os.Getenv("TRAVELPAYOUTS_TOKEN"),
|
||||
Marker: os.Getenv("TRAVELPAYOUTS_MARKER"),
|
||||
},
|
||||
TwoGISConfig: travel.TwoGISConfig{
|
||||
APIKey: os.Getenv("TWOGIS_API_KEY"),
|
||||
},
|
||||
LLMClient: llmClient,
|
||||
UseRussianAPIs: useRussianAPIs,
|
||||
})
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
})
|
||||
|
||||
app.Use(logger.New())
|
||||
app.Use(cors.New())
|
||||
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "service": "travel-svc"})
|
||||
})
|
||||
|
||||
jwtConfig := middleware.JWTConfig{
|
||||
Secret: os.Getenv("JWT_SECRET"),
|
||||
AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"),
|
||||
}
|
||||
|
||||
jwtOptional := middleware.JWTConfig{
|
||||
Secret: os.Getenv("JWT_SECRET"),
|
||||
AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"),
|
||||
AllowGuest: true,
|
||||
}
|
||||
|
||||
api := app.Group("/api/v1/travel")
|
||||
|
||||
api.Post("/plan", middleware.JWT(jwtOptional), handlePlanTrip(svc))
|
||||
|
||||
api.Get("/trips", middleware.JWT(jwtOptional), handleGetTrips(svc))
|
||||
api.Post("/trips", middleware.JWT(jwtConfig), handleCreateTrip(svc))
|
||||
api.Get("/trips/:id", middleware.JWT(jwtOptional), handleGetTrip(svc))
|
||||
api.Put("/trips/:id", middleware.JWT(jwtConfig), handleUpdateTrip(svc))
|
||||
api.Delete("/trips/:id", middleware.JWT(jwtConfig), handleDeleteTrip(svc))
|
||||
api.Get("/trips/upcoming", middleware.JWT(jwtOptional), handleGetUpcoming(svc))
|
||||
|
||||
api.Get("/flights", middleware.JWT(jwtOptional), handleSearchFlights(svc))
|
||||
api.Post("/flights", middleware.JWT(jwtOptional), handleSearchFlightsPost(svc))
|
||||
api.Get("/hotels", middleware.JWT(jwtOptional), handleSearchHotels(svc))
|
||||
api.Post("/hotels", middleware.JWT(jwtOptional), handleSearchHotelsPost(svc))
|
||||
api.Post("/route", middleware.JWT(jwtOptional), handleGetRoute(svc))
|
||||
api.Get("/geocode", middleware.JWT(jwtOptional), handleGeocode(svc))
|
||||
api.Get("/poi", middleware.JWT(jwtOptional), handleSearchPOI(svc))
|
||||
api.Post("/poi", middleware.JWT(jwtOptional), handleSearchPOIPost(svc))
|
||||
api.Post("/places", middleware.JWT(jwtOptional), handleSearchPlaces(svc))
|
||||
|
||||
port := getEnvInt("PORT", 3035)
|
||||
log.Printf("travel-svc listening on :%d", port)
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||
}
|
||||
|
||||
func handlePlanTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.TravelPlanRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "application/x-ndjson")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Transfer-Encoding", "chunked")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
ctx := context.Background()
|
||||
if err := svc.PlanTrip(ctx, req, w); err != nil {
|
||||
log.Printf("Plan trip error: %v", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetTrips(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userID, _ := c.Locals("userId").(string)
|
||||
if userID == "" {
|
||||
return c.JSON(fiber.Map{"trips": []interface{}{}})
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 20)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
trips, err := svc.GetUserTrips(c.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"trips": trips})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(string)
|
||||
|
||||
var trip travel.Trip
|
||||
if err := c.BodyParser(&trip); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
trip.UserID = userID
|
||||
if trip.Currency == "" {
|
||||
trip.Currency = "RUB"
|
||||
}
|
||||
if trip.Status == "" {
|
||||
trip.Status = travel.TripStatusPlanned
|
||||
}
|
||||
|
||||
if err := svc.CreateTrip(c.Context(), &trip); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(201).JSON(trip)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
trip, err := svc.GetTrip(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if trip == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Trip not found"})
|
||||
}
|
||||
|
||||
return c.JSON(trip)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
userID := c.Locals("userId").(string)
|
||||
|
||||
existing, err := svc.GetTrip(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if existing == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Trip not found"})
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
|
||||
var trip travel.Trip
|
||||
if err := c.BodyParser(&trip); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
trip.ID = id
|
||||
trip.UserID = userID
|
||||
|
||||
if err := svc.UpdateTrip(c.Context(), &trip); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(trip)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteTrip(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
userID := c.Locals("userId").(string)
|
||||
|
||||
existing, err := svc.GetTrip(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if existing == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Trip not found"})
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
|
||||
if err := svc.DeleteTrip(c.Context(), id); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUpcoming(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
userID, _ := c.Locals("userId").(string)
|
||||
if userID == "" {
|
||||
return c.JSON(fiber.Map{"trips": []interface{}{}})
|
||||
}
|
||||
|
||||
trips, err := svc.GetUpcomingTrips(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"trips": trips})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchFlights(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := travel.FlightSearchRequest{
|
||||
Origin: c.Query("origin"),
|
||||
Destination: c.Query("destination"),
|
||||
DepartureDate: c.Query("departureDate"),
|
||||
ReturnDate: c.Query("returnDate"),
|
||||
Adults: c.QueryInt("adults", 1),
|
||||
Children: c.QueryInt("children", 0),
|
||||
CabinClass: c.Query("cabinClass"),
|
||||
MaxPrice: c.QueryInt("maxPrice", 0),
|
||||
Currency: c.Query("currency", "RUB"),
|
||||
}
|
||||
|
||||
if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"})
|
||||
}
|
||||
|
||||
flights, err := svc.SearchFlights(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"flights": flights})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchHotels(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
req := travel.HotelSearchRequest{
|
||||
CityCode: c.Query("cityCode"),
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Radius: c.QueryInt("radius", 5),
|
||||
CheckIn: c.Query("checkIn"),
|
||||
CheckOut: c.Query("checkOut"),
|
||||
Adults: c.QueryInt("adults", 1),
|
||||
Rooms: c.QueryInt("rooms", 1),
|
||||
MaxPrice: c.QueryInt("maxPrice", 0),
|
||||
Currency: c.Query("currency", "RUB"),
|
||||
Rating: c.QueryInt("rating", 0),
|
||||
}
|
||||
|
||||
if req.CityCode == "" || req.CheckIn == "" || req.CheckOut == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "cityCode, checkIn, checkOut required"})
|
||||
}
|
||||
|
||||
hotels, err := svc.SearchHotels(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"hotels": hotels})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetRoute(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Points []travel.GeoLocation `json:"points"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
if len(req.Points) < 2 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "At least 2 points required"})
|
||||
}
|
||||
|
||||
route, err := svc.GetRoute(c.Context(), req.Points, req.Profile)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(route)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGeocode(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
query := c.Query("query")
|
||||
if query == "" {
|
||||
query = c.Query("q")
|
||||
}
|
||||
if query == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "query parameter required"})
|
||||
}
|
||||
|
||||
location, err := svc.Geocode(c.Context(), query)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(location)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchPOI(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
if lat == 0 || lng == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"})
|
||||
}
|
||||
|
||||
req := travel.POISearchRequest{
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Radius: c.QueryInt("radius", 1000),
|
||||
Limit: c.QueryInt("limit", 20),
|
||||
}
|
||||
|
||||
if categories := c.Query("categories"); categories != "" {
|
||||
req.Categories = strings.Split(categories, ",")
|
||||
}
|
||||
|
||||
pois, err := svc.SearchPOI(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"pois": pois})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchFlightsPost(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.FlightSearchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"})
|
||||
}
|
||||
if req.Adults == 0 {
|
||||
req.Adults = 1
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "RUB"
|
||||
}
|
||||
|
||||
flights, err := svc.SearchFlights(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(flights)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchHotelsPost(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.HotelSearchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.CheckIn == "" || req.CheckOut == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "checkIn, checkOut required"})
|
||||
}
|
||||
if req.Adults == 0 {
|
||||
req.Adults = 1
|
||||
}
|
||||
if req.Rooms == 0 {
|
||||
req.Rooms = 1
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "RUB"
|
||||
}
|
||||
|
||||
hotels, err := svc.SearchHotels(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(hotels)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchPOIPost(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.POISearchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Lat == 0 || req.Lng == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"})
|
||||
}
|
||||
if req.Radius == 0 {
|
||||
req.Radius = 1000
|
||||
}
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 20
|
||||
}
|
||||
|
||||
pois, err := svc.SearchPOI(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(pois)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchPlaces(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Radius int `json:"radius"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
if req.Query == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "query required"})
|
||||
}
|
||||
if req.Lat == 0 || req.Lng == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"})
|
||||
}
|
||||
if req.Radius == 0 {
|
||||
req.Radius = 5000
|
||||
}
|
||||
|
||||
places, err := svc.SearchPlaces(c.Context(), req.Query, req.Lat, req.Lng, req.Radius)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(places)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for agent-svc only
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/file-svc ./cmd/fi
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/thread-svc ./cmd/thread-svc
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/finance-heatmap-svc ./cmd/finance-heatmap-svc
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/learning-svc ./cmd/learning-svc
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/travel-svc ./cmd/travel-svc
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/labs-svc ./cmd/labs-svc
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/podcast-svc ./cmd/podcast-svc
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/admin-svc ./cmd/admin-svc
|
||||
@@ -42,7 +43,7 @@ COPY --from=builder /bin/* /app/
|
||||
ENV SERVICE=api-gateway
|
||||
ENV PORT=3015
|
||||
|
||||
EXPOSE 3015 3018 3005 3001 3020 3021 3002 3025 3026 3027 3040
|
||||
EXPOSE 3015 3018 3005 3001 3020 3021 3002 3025 3026 3027 3035 3040
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT}/health || exit 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for chat-svc only
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for discover-svc only
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for search-svc only
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ services:
|
||||
- DISCOVER_SVC_URL=http://discover-svc:3002
|
||||
- FINANCE_HEATMAP_SVC_URL=http://finance-heatmap-svc:3033
|
||||
- LEARNING_SVC_URL=http://learning-svc:3034
|
||||
- TRAVEL_SVC_URL=http://travel-svc:3035
|
||||
- ADMIN_SVC_URL=http://admin-svc:3040
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
@@ -50,6 +51,7 @@ services:
|
||||
- agent-svc
|
||||
- thread-svc
|
||||
- admin-svc
|
||||
- travel-svc
|
||||
- redis
|
||||
networks:
|
||||
- gooseek
|
||||
@@ -61,6 +63,8 @@ services:
|
||||
environment:
|
||||
- SERVICE=chat-svc
|
||||
- PORT=3005
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- AUTH_SVC_URL=http://auth-svc:3050
|
||||
- MASTER_AGENTS_SVC_URL=http://agent-svc:3018
|
||||
- DISCOVER_SVC_URL=http://discover-svc:3002
|
||||
ports:
|
||||
@@ -78,9 +82,14 @@ services:
|
||||
environment:
|
||||
- SERVICE=agent-svc
|
||||
- PORT=3018
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- AUTH_SVC_URL=http://auth-svc:3050
|
||||
- SEARXNG_URL=http://searxng:8080
|
||||
- DISCOVER_SVC_URL=http://discover-svc:3002
|
||||
- CRAWL4AI_URL=http://crawl4ai:11235
|
||||
- TRAVEL_SVC_URL=http://travel-svc:3035
|
||||
- TRAVELPAYOUTS_TOKEN=${TRAVELPAYOUTS_TOKEN}
|
||||
- TRAVELPAYOUTS_MARKER=${TRAVELPAYOUTS_MARKER}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
@@ -92,6 +101,9 @@ services:
|
||||
depends_on:
|
||||
- search-svc
|
||||
- discover-svc
|
||||
- travel-svc
|
||||
- searxng
|
||||
- crawl4ai
|
||||
networks:
|
||||
- gooseek
|
||||
|
||||
@@ -254,6 +266,8 @@ services:
|
||||
- SERVICE=finance-heatmap-svc
|
||||
- PORT=3033
|
||||
- REDIS_URL=redis://redis:6379
|
||||
# MOEX, Крипто, Валюты работают без URL (встроенные провайдеры). Для своих рынков — URL сервиса, GET ?market=...&range=...
|
||||
- FINANCE_DATA_PROVIDER_URL=${FINANCE_DATA_PROVIDER_URL:-}
|
||||
ports:
|
||||
- "3033:3033"
|
||||
depends_on:
|
||||
@@ -283,6 +297,39 @@ services:
|
||||
networks:
|
||||
- gooseek
|
||||
|
||||
travel-svc:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile.all
|
||||
environment:
|
||||
- SERVICE=travel-svc
|
||||
- PORT=3035
|
||||
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- AUTH_SVC_URL=http://auth-svc:3050
|
||||
# Российские API (по умолчанию)
|
||||
- USE_RUSSIAN_APIS=true
|
||||
- TRAVELPAYOUTS_TOKEN=${TRAVELPAYOUTS_TOKEN}
|
||||
- TRAVELPAYOUTS_MARKER=${TRAVELPAYOUTS_MARKER}
|
||||
- TWOGIS_API_KEY=${TWOGIS_API_KEY}
|
||||
# Международные API (опционально)
|
||||
- AMADEUS_API_KEY=${AMADEUS_API_KEY}
|
||||
- AMADEUS_API_SECRET=${AMADEUS_API_SECRET}
|
||||
- OPENROUTE_API_KEY=${OPENROUTE_API_KEY}
|
||||
# LLM (TimeWeb)
|
||||
- LLM_PROVIDER=timeweb
|
||||
- LLM_MODEL=${DEFAULT_LLM_MODEL:-gpt-4o-mini}
|
||||
- TIMEWEB_API_BASE_URL=${TIMEWEB_API_BASE_URL}
|
||||
- TIMEWEB_AGENT_ACCESS_ID=${TIMEWEB_AGENT_ACCESS_ID}
|
||||
- TIMEWEB_API_KEY=${TIMEWEB_API_KEY}
|
||||
ports:
|
||||
- "3035:3035"
|
||||
depends_on:
|
||||
- postgres
|
||||
- auth-svc
|
||||
networks:
|
||||
- gooseek
|
||||
|
||||
admin-svc:
|
||||
build:
|
||||
context: ../..
|
||||
@@ -331,12 +378,12 @@ services:
|
||||
context: ../../webui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/travel,/medicine,/finance,/learning,/spaces,/history}
|
||||
- NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/medicine}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_URL=http://api-gateway:3015
|
||||
- NEXT_PUBLIC_API_URL=
|
||||
- NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/travel,/medicine,/finance,/learning,/spaces,/history}
|
||||
- NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/medicine}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
@@ -381,7 +428,7 @@ services:
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
volumes:
|
||||
- ../../../deploy/docker/searxng:/etc/searxng:ro
|
||||
- ./searxng:/etc/searxng
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080
|
||||
ports:
|
||||
|
||||
23
backend/deploy/docker/searxng/settings.yml
Normal file
23
backend/deploy/docker/searxng/settings.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
use_default_settings: true
|
||||
|
||||
general:
|
||||
instance_name: "GooSeek Search"
|
||||
debug: false
|
||||
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: ""
|
||||
default_lang: "ru"
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
|
||||
server:
|
||||
secret_key: "gooseek-searxng-secret-key-change-in-production"
|
||||
bind_address: "0.0.0.0"
|
||||
port: 8080
|
||||
limiter: false
|
||||
image_proxy: false
|
||||
|
||||
ui:
|
||||
static_use_hash: true
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
FocusModeImages FocusMode = "images"
|
||||
FocusModeMath FocusMode = "math"
|
||||
FocusModeFinance FocusMode = "finance"
|
||||
FocusModeTravel FocusMode = "travel"
|
||||
)
|
||||
|
||||
type FocusModeConfig struct {
|
||||
@@ -165,6 +166,18 @@ Cite data sources and note when data may be delayed or historical.
|
||||
Include relevant disclaimers about investment risks.
|
||||
Reference SEC filings, analyst reports, and official company statements.`,
|
||||
},
|
||||
FocusModeTravel: {
|
||||
Mode: FocusModeTravel,
|
||||
Engines: []string{"google", "duckduckgo", "google news"},
|
||||
Categories: []string{"general", "news"},
|
||||
MaxSources: 15,
|
||||
RequiresCitation: true,
|
||||
AllowScraping: true,
|
||||
SystemPrompt: `You are a travel planning assistant.
|
||||
Help users plan trips with routes, accommodations, activities, and transport.
|
||||
Search for events, festivals, and attractions at destinations.
|
||||
Provide practical travel tips and budget estimates.`,
|
||||
},
|
||||
}
|
||||
|
||||
func GetFocusModeConfig(mode string) FocusModeConfig {
|
||||
@@ -236,6 +249,18 @@ func DetectFocusMode(query string) FocusMode {
|
||||
}
|
||||
}
|
||||
|
||||
travelKeywords := []string{
|
||||
"travel", "trip", "flight", "hotel", "vacation", "destination",
|
||||
"путешеств", "поездк", "маршрут", "отель", "перелёт", "перелет",
|
||||
"отдых", "тур", "куда поехать", "куда сходить", "достопримечательност",
|
||||
"мероприят", "билет", "бронирован",
|
||||
}
|
||||
for _, kw := range travelKeywords {
|
||||
if strings.Contains(queryLower, kw) {
|
||||
return FocusModeTravel
|
||||
}
|
||||
}
|
||||
|
||||
newsKeywords := []string{
|
||||
"news", "today", "latest", "breaking", "current events",
|
||||
"новост", "сегодня", "последн", "актуальн",
|
||||
|
||||
@@ -47,10 +47,13 @@ type OrchestratorConfig struct {
|
||||
LearningMode bool
|
||||
EnableDeepResearch bool
|
||||
EnableClarifying bool
|
||||
DiscoverSvcURL string
|
||||
Crawl4AIURL string
|
||||
CollectionSvcURL string
|
||||
FileSvcURL string
|
||||
DiscoverSvcURL string
|
||||
Crawl4AIURL string
|
||||
CollectionSvcURL string
|
||||
FileSvcURL string
|
||||
TravelSvcURL string
|
||||
TravelPayoutsToken string
|
||||
TravelPayoutsMarker string
|
||||
}
|
||||
|
||||
type DigestResponse struct {
|
||||
@@ -87,6 +90,10 @@ type OrchestratorInput struct {
|
||||
}
|
||||
|
||||
func RunOrchestrator(ctx context.Context, sess *session.Session, input OrchestratorInput) error {
|
||||
if input.Config.AnswerMode == "travel" {
|
||||
return RunTravelOrchestrator(ctx, sess, input)
|
||||
}
|
||||
|
||||
detectedLang := detectLanguage(input.FollowUp)
|
||||
isArticleSummary := strings.HasPrefix(strings.TrimSpace(input.FollowUp), "Summary: ")
|
||||
|
||||
|
||||
241
backend/internal/agent/travel_context_collector.go
Normal file
241
backend/internal/agent/travel_context_collector.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
)
|
||||
|
||||
// TravelContext holds assessed conditions for the travel destination.
|
||||
type TravelContext struct {
|
||||
Weather WeatherAssessment `json:"weather"`
|
||||
Safety SafetyAssessment `json:"safety"`
|
||||
Restrictions []RestrictionItem `json:"restrictions"`
|
||||
Tips []TravelTip `json:"tips"`
|
||||
BestTimeInfo string `json:"bestTimeInfo,omitempty"`
|
||||
}
|
||||
|
||||
type WeatherAssessment struct {
|
||||
Summary string `json:"summary"`
|
||||
TempMin float64 `json:"tempMin"`
|
||||
TempMax float64 `json:"tempMax"`
|
||||
Conditions string `json:"conditions"`
|
||||
Clothing string `json:"clothing"`
|
||||
RainChance string `json:"rainChance"`
|
||||
}
|
||||
|
||||
type SafetyAssessment struct {
|
||||
Level string `json:"level"`
|
||||
Summary string `json:"summary"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
EmergencyNo string `json:"emergencyNo"`
|
||||
}
|
||||
|
||||
type RestrictionItem struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type TravelTip struct {
|
||||
Category string `json:"category"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// CollectTravelContext gathers current weather, safety, and restriction data.
|
||||
func CollectTravelContext(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) (*TravelContext, error) {
|
||||
if cfg.SearchClient == nil || cfg.LLM == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawData := searchForContext(ctx, cfg.SearchClient, brief)
|
||||
if len(rawData) == 0 {
|
||||
log.Printf("[travel-context] no search results, using LLM knowledge only")
|
||||
}
|
||||
|
||||
travelCtx := extractContextWithLLM(ctx, cfg.LLM, brief, rawData)
|
||||
if travelCtx == nil {
|
||||
return &TravelContext{
|
||||
Safety: SafetyAssessment{
|
||||
Level: "normal",
|
||||
Summary: "Актуальная информация недоступна",
|
||||
EmergencyNo: "112",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return travelCtx, nil
|
||||
}
|
||||
|
||||
type contextSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
}
|
||||
|
||||
func searchForContext(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []contextSearchResult {
|
||||
var results []contextSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
dest := strings.Join(brief.Destinations, ", ")
|
||||
currentYear := time.Now().Format("2006")
|
||||
currentMonth := time.Now().Format("01")
|
||||
|
||||
monthNames := map[string]string{
|
||||
"01": "январь", "02": "февраль", "03": "март",
|
||||
"04": "апрель", "05": "май", "06": "июнь",
|
||||
"07": "июль", "08": "август", "09": "сентябрь",
|
||||
"10": "октябрь", "11": "ноябрь", "12": "декабрь",
|
||||
}
|
||||
month := monthNames[currentMonth]
|
||||
|
||||
queries := []string{
|
||||
fmt.Sprintf("погода %s %s %s прогноз", dest, month, currentYear),
|
||||
fmt.Sprintf("безопасность туристов %s %s", dest, currentYear),
|
||||
fmt.Sprintf("ограничения %s туризм %s", dest, currentYear),
|
||||
fmt.Sprintf("что нужно знать туристу %s %s", dest, currentYear),
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-context] search error for '%s': %v", q, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range resp.Results {
|
||||
if r.URL == "" || seen[r.URL] {
|
||||
continue
|
||||
}
|
||||
seen[r.URL] = true
|
||||
results = append(results, contextSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
})
|
||||
if len(results) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []contextSearchResult) *TravelContext {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
if len(searchResults) > 0 {
|
||||
contextBuilder.WriteString("Данные из поиска:\n\n")
|
||||
maxResults := 8
|
||||
if len(searchResults) < maxResults {
|
||||
maxResults = len(searchResults)
|
||||
}
|
||||
for i := 0; i < maxResults; i++ {
|
||||
r := searchResults[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\n%s\n\n", r.Title, truncateStr(r.Content, 400)))
|
||||
}
|
||||
}
|
||||
|
||||
dest := strings.Join(brief.Destinations, ", ")
|
||||
currentDate := time.Now().Format("2006-01-02")
|
||||
|
||||
prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени текущую обстановку в %s для поездки %s — %s.
|
||||
Сегодня: %s.
|
||||
|
||||
%s
|
||||
|
||||
Верни ТОЛЬКО JSON (без текста):
|
||||
{
|
||||
"weather": {
|
||||
"summary": "Краткое описание погоды на период поездки",
|
||||
"tempMin": число_градусов_минимум,
|
||||
"tempMax": число_градусов_максимум,
|
||||
"conditions": "солнечно/облачно/дождливо/снежно",
|
||||
"clothing": "Что надеть: конкретные рекомендации",
|
||||
"rainChance": "низкая/средняя/высокая"
|
||||
},
|
||||
"safety": {
|
||||
"level": "safe/caution/warning/danger",
|
||||
"summary": "Общая оценка безопасности для туристов",
|
||||
"warnings": ["предупреждение 1", "предупреждение 2"],
|
||||
"emergencyNo": "номер экстренной помощи"
|
||||
},
|
||||
"restrictions": [
|
||||
{
|
||||
"type": "visa/health/transport/local",
|
||||
"title": "Название ограничения",
|
||||
"description": "Подробности",
|
||||
"severity": "info/warning/critical"
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
{"category": "transport/money/culture/food/safety", "text": "Полезный совет"}
|
||||
],
|
||||
"bestTimeInfo": "Лучшее время для посещения и почему"
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Используй ТОЛЬКО актуальные данные %s года
|
||||
- weather: реальный прогноз на период поездки, не среднегодовые значения
|
||||
- safety: объективная оценка, не преувеличивай опасности
|
||||
- restrictions: визовые требования, медицинские ограничения, локальные правила
|
||||
- tips: 3-5 практичных советов для туриста
|
||||
- Если данных нет — используй свои знания о регионе, но отмечай это
|
||||
- Температуры в градусах Цельсия`,
|
||||
dest,
|
||||
brief.StartDate,
|
||||
brief.EndDate,
|
||||
currentDate,
|
||||
contextBuilder.String(),
|
||||
time.Now().Format("2006"),
|
||||
)
|
||||
|
||||
llmCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 2000, Temperature: 0.2},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-context] LLM extraction failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\{[\s\S]*\}`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
log.Printf("[travel-context] no JSON in LLM response")
|
||||
return nil
|
||||
}
|
||||
|
||||
var travelCtx TravelContext
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &travelCtx); err != nil {
|
||||
log.Printf("[travel-context] JSON parse error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if travelCtx.Safety.EmergencyNo == "" {
|
||||
travelCtx.Safety.EmergencyNo = "112"
|
||||
}
|
||||
|
||||
log.Printf("[travel-context] extracted context: weather=%s, safety=%s, restrictions=%d, tips=%d",
|
||||
travelCtx.Weather.Conditions, travelCtx.Safety.Level,
|
||||
len(travelCtx.Restrictions), len(travelCtx.Tips))
|
||||
|
||||
return &travelCtx
|
||||
}
|
||||
454
backend/internal/agent/travel_data_client.go
Normal file
454
backend/internal/agent/travel_data_client.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
geocodeCache = make(map[string]*GeoResult)
|
||||
geocodeCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// TravelDataClient calls the travel-svc data-layer endpoints.
|
||||
type TravelDataClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func NewTravelDataClient(baseURL string) *TravelDataClient {
|
||||
return &TravelDataClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
maxRetries: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(attempt*500) * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req.Clone(ctx))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("all %d retries failed: %w", c.maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) Geocode(ctx context.Context, query string) (*GeoResult, error) {
|
||||
// Check in-memory cache first
|
||||
geocodeCacheMu.RLock()
|
||||
if cached, ok := geocodeCache[query]; ok {
|
||||
geocodeCacheMu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
geocodeCacheMu.RUnlock()
|
||||
|
||||
u := fmt.Sprintf("%s/api/v1/travel/geocode?query=%s", c.baseURL, url.QueryEscape(query))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.doWithRetry(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geocode request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("geocode returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result GeoResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("geocode decode error: %w", err)
|
||||
}
|
||||
|
||||
geocodeCacheMu.Lock()
|
||||
geocodeCache[query] = &result
|
||||
geocodeCacheMu.Unlock()
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchPOI(ctx context.Context, lat, lng float64, radius int, categories []string) ([]POICard, error) {
|
||||
body := map[string]interface{}{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
"limit": 20,
|
||||
}
|
||||
if len(categories) > 0 {
|
||||
body["categories"] = categories
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/poi", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poi search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("poi search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawPOIs []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Category string `json:"category"`
|
||||
Address string `json:"address"`
|
||||
Rating float64 `json:"rating"`
|
||||
Distance float64 `json:"distance"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawPOIs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]POICard, 0, len(rawPOIs))
|
||||
for _, p := range rawPOIs {
|
||||
cards = append(cards, POICard{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Category: p.Category,
|
||||
Address: p.Address,
|
||||
Lat: p.Lat,
|
||||
Lng: p.Lng,
|
||||
Rating: p.Rating,
|
||||
})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchFlights(ctx context.Context, origin, destination, date string, adults int) ([]TransportOption, error) {
|
||||
body := map[string]interface{}{
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"departureDate": date,
|
||||
"adults": adults,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/flights", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flights search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("flights search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawFlights []struct {
|
||||
ID string `json:"id"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flightNumber"`
|
||||
DepartureAirport string `json:"departureAirport"`
|
||||
DepartureCity string `json:"departureCity"`
|
||||
DepartureTime string `json:"departureTime"`
|
||||
ArrivalAirport string `json:"arrivalAirport"`
|
||||
ArrivalCity string `json:"arrivalCity"`
|
||||
ArrivalTime string `json:"arrivalTime"`
|
||||
Duration int `json:"duration"`
|
||||
Stops int `json:"stops"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
BookingURL string `json:"bookingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawFlights); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options := make([]TransportOption, 0, len(rawFlights))
|
||||
for _, f := range rawFlights {
|
||||
options = append(options, TransportOption{
|
||||
ID: f.ID,
|
||||
Mode: "flight",
|
||||
From: f.DepartureCity,
|
||||
To: f.ArrivalCity,
|
||||
Departure: f.DepartureTime,
|
||||
Arrival: f.ArrivalTime,
|
||||
DurationMin: f.Duration,
|
||||
Price: f.Price,
|
||||
Currency: f.Currency,
|
||||
Provider: f.Airline,
|
||||
BookingURL: f.BookingURL,
|
||||
Airline: f.Airline,
|
||||
FlightNum: f.FlightNumber,
|
||||
Stops: f.Stops,
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, checkIn, checkOut string, adults int) ([]HotelCard, error) {
|
||||
body := map[string]interface{}{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": 10,
|
||||
"checkIn": checkIn,
|
||||
"checkOut": checkOut,
|
||||
"adults": adults,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/hotels", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hotels search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hotels search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawHotels []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount"`
|
||||
Stars int `json:"stars"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
Currency string `json:"currency"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
Amenities []string `json:"amenities"`
|
||||
Photos []string `json:"photos"`
|
||||
BookingURL string `json:"bookingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawHotels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]HotelCard, 0, len(rawHotels))
|
||||
for _, h := range rawHotels {
|
||||
cards = append(cards, HotelCard{
|
||||
ID: h.ID,
|
||||
Name: h.Name,
|
||||
Stars: h.Stars,
|
||||
Rating: h.Rating,
|
||||
ReviewCount: h.ReviewCount,
|
||||
PricePerNight: h.PricePerNight,
|
||||
TotalPrice: h.Price,
|
||||
Currency: h.Currency,
|
||||
Address: h.Address,
|
||||
Lat: h.Lat,
|
||||
Lng: h.Lng,
|
||||
BookingURL: h.BookingURL,
|
||||
Photos: h.Photos,
|
||||
Amenities: h.Amenities,
|
||||
CheckIn: h.CheckIn,
|
||||
CheckOut: h.CheckOut,
|
||||
})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// PlaceResult represents a place from 2GIS Places API.
|
||||
type PlaceResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {
|
||||
body := map[string]interface{}{
|
||||
"query": query,
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/places", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doWithRetry(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("places search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("places search returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var places []PlaceResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&places); err != nil {
|
||||
return nil, fmt.Errorf("places decode error: %w", err)
|
||||
}
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) GetRoute(ctx context.Context, points []MapPoint, transport string) (*RouteDirectionResult, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("need at least 2 points for route")
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "driving"
|
||||
}
|
||||
|
||||
coords := make([]map[string]float64, len(points))
|
||||
for i, p := range points {
|
||||
coords[i] = map[string]float64{"lat": p.Lat, "lng": p.Lng}
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"points": coords,
|
||||
"profile": transport,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/route", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("route request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("route returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result RouteDirectionResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetRouteSegments builds routes between each consecutive pair of points.
|
||||
func (c *TravelDataClient) GetRouteSegments(ctx context.Context, points []MapPoint, transport string) ([]RouteSegmentResult, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
segments := make([]RouteSegmentResult, 0, len(points)-1)
|
||||
for i := 0; i < len(points)-1; i++ {
|
||||
pair := []MapPoint{points[i], points[i+1]}
|
||||
dir, err := c.GetRoute(ctx, pair, transport)
|
||||
if err != nil {
|
||||
segments = append(segments, RouteSegmentResult{
|
||||
FromName: points[i].Label,
|
||||
ToName: points[i+1].Label,
|
||||
})
|
||||
continue
|
||||
}
|
||||
segments = append(segments, RouteSegmentResult{
|
||||
FromName: points[i].Label,
|
||||
ToName: points[i+1].Label,
|
||||
Distance: dir.Distance,
|
||||
Duration: dir.Duration,
|
||||
Geometry: dir.Geometry,
|
||||
})
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
type GeoResult struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type RouteDirectionResult struct {
|
||||
Geometry RouteGeometryResult `json:"geometry"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []RouteStepResult `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
type RouteGeometryResult struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteStepResult struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteSegmentResult struct {
|
||||
FromName string `json:"from"`
|
||||
ToName string `json:"to"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Geometry RouteGeometryResult `json:"geometry,omitempty"`
|
||||
}
|
||||
467
backend/internal/agent/travel_events_collector.go
Normal file
467
backend/internal/agent/travel_events_collector.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CollectEventsEnriched collects real upcoming events/activities for the destination.
|
||||
// Pipeline: SearXNG (event-focused queries) -> Crawl4AI -> LLM extraction -> geocode.
|
||||
// Only returns actual events (concerts, exhibitions, festivals, etc.), NOT news articles.
|
||||
func CollectEventsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) ([]EventCard, error) {
|
||||
if cfg.SearchClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawResults := searchForEvents(ctx, cfg.SearchClient, brief)
|
||||
if len(rawResults) == 0 {
|
||||
log.Printf("[travel-events] no search results found")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Printf("[travel-events] found %d raw search results", len(rawResults))
|
||||
|
||||
var crawledContent []crawledPage
|
||||
if cfg.Crawl4AIURL != "" {
|
||||
crawledContent = crawlEventPages(ctx, cfg.Crawl4AIURL, rawResults)
|
||||
}
|
||||
|
||||
events := extractEventsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
|
||||
|
||||
events = geocodeEvents(ctx, cfg, events)
|
||||
|
||||
events = deduplicateEvents(events)
|
||||
|
||||
events = filterFreshEvents(events, brief.StartDate)
|
||||
|
||||
if len(events) > 15 {
|
||||
events = events[:15]
|
||||
}
|
||||
|
||||
log.Printf("[travel-events] returning %d events", len(events))
|
||||
return events, nil
|
||||
}
|
||||
|
||||
type crawledPage struct {
|
||||
URL string
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
type eventSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
PublishedDate string
|
||||
Engine string
|
||||
}
|
||||
|
||||
func searchForEvents(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []eventSearchResult {
|
||||
var results []eventSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
queries := generateEventQueries(dest, brief.StartDate, brief.EndDate)
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] search error for '%s': %v", q, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range resp.Results {
|
||||
if r.URL == "" || seen[r.URL] {
|
||||
continue
|
||||
}
|
||||
if isNewsArticleURL(r.URL) || isOldContent(r.PublishedDate) {
|
||||
continue
|
||||
}
|
||||
seen[r.URL] = true
|
||||
results = append(results, eventSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
PublishedDate: r.PublishedDate,
|
||||
Engine: r.Engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func generateEventQueries(destination, startDate, endDate string) []string {
|
||||
month := ""
|
||||
year := ""
|
||||
if len(startDate) >= 7 {
|
||||
parts := strings.Split(startDate, "-")
|
||||
if len(parts) >= 2 {
|
||||
year = parts[0]
|
||||
monthNum := parts[1]
|
||||
monthNames := map[string]string{
|
||||
"01": "январь", "02": "февраль", "03": "март",
|
||||
"04": "апрель", "05": "май", "06": "июнь",
|
||||
"07": "июль", "08": "август", "09": "сентябрь",
|
||||
"10": "октябрь", "11": "ноябрь", "12": "декабрь",
|
||||
}
|
||||
month = monthNames[monthNum]
|
||||
}
|
||||
}
|
||||
if year == "" {
|
||||
year = time.Now().Format("2006")
|
||||
}
|
||||
if month == "" {
|
||||
monthNames := []string{"", "январь", "февраль", "март", "апрель", "май", "июнь",
|
||||
"июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"}
|
||||
month = monthNames[time.Now().Month()]
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
fmt.Sprintf("афиша %s %s %s концерты выставки", destination, month, year),
|
||||
fmt.Sprintf("мероприятия %s %s %s расписание", destination, month, year),
|
||||
fmt.Sprintf("куда сходить %s %s %s", destination, month, year),
|
||||
fmt.Sprintf("site:afisha.ru %s %s", destination, month),
|
||||
fmt.Sprintf("site:kassir.ru %s %s %s", destination, month, year),
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func isNewsArticleURL(u string) bool {
|
||||
newsPatterns := []string{
|
||||
"/news/", "/novosti/", "/article/", "/stati/",
|
||||
"ria.ru", "tass.ru", "rbc.ru", "lenta.ru", "gazeta.ru",
|
||||
"interfax.ru", "kommersant.ru", "iz.ru", "mk.ru",
|
||||
"regnum.ru", "aif.ru", "kp.ru",
|
||||
}
|
||||
lower := strings.ToLower(u)
|
||||
for _, p := range newsPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isOldContent(publishedDate string) bool {
|
||||
if publishedDate == "" {
|
||||
return false
|
||||
}
|
||||
formats := []string{
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02",
|
||||
"02.01.2006",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, publishedDate); err == nil {
|
||||
sixMonthsAgo := time.Now().AddDate(0, -6, 0)
|
||||
return t.Before(sixMonthsAgo)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterFreshEvents(events []EventCard, tripStartDate string) []EventCard {
|
||||
if tripStartDate == "" {
|
||||
return events
|
||||
}
|
||||
tripStart, err := time.Parse("2006-01-02", tripStartDate)
|
||||
if err != nil {
|
||||
return events
|
||||
}
|
||||
|
||||
cutoff := tripStart.AddDate(0, -1, 0)
|
||||
var fresh []EventCard
|
||||
for _, e := range events {
|
||||
if e.DateEnd != "" {
|
||||
if endDate, err := time.Parse("2006-01-02", e.DateEnd); err == nil {
|
||||
if endDate.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.DateStart != "" {
|
||||
if startDate, err := time.Parse("2006-01-02", e.DateStart); err == nil {
|
||||
twoMonthsAfterTrip := tripStart.AddDate(0, 2, 0)
|
||||
if startDate.After(twoMonthsAfterTrip) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
fresh = append(fresh, e)
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
func crawlEventPages(ctx context.Context, crawl4aiURL string, results []eventSearchResult) []crawledPage {
|
||||
maxCrawl := 4
|
||||
if len(results) < maxCrawl {
|
||||
maxCrawl = len(results)
|
||||
}
|
||||
|
||||
var pages []crawledPage
|
||||
|
||||
for _, r := range results[:maxCrawl] {
|
||||
crawlCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
page, err := crawlSinglePage(crawlCtx, crawl4aiURL, r.URL)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] crawl failed for %s: %v", r.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if page != nil && len(page.Content) > 100 {
|
||||
pages = append(pages, *page)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
func crawlSinglePage(ctx context.Context, crawl4aiURL, pageURL string) (*crawledPage, error) {
|
||||
reqBody := fmt.Sprintf(`{
|
||||
"urls": ["%s"],
|
||||
"crawler_config": {
|
||||
"type": "CrawlerRunConfig",
|
||||
"params": {
|
||||
"cache_mode": "default",
|
||||
"page_timeout": 15000
|
||||
}
|
||||
}
|
||||
}`, pageURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", crawl4aiURL+"/crawl", strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("crawl4ai returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content := extractCrawledMarkdown(string(body))
|
||||
title := extractCrawledTitle(string(body))
|
||||
|
||||
if len(content) > 10000 {
|
||||
content = content[:10000]
|
||||
}
|
||||
|
||||
return &crawledPage{
|
||||
URL: pageURL,
|
||||
Title: title,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractCrawledMarkdown(response string) string {
|
||||
var result struct {
|
||||
Results []struct {
|
||||
RawMarkdown string `json:"raw_markdown"`
|
||||
Markdown string `json:"markdown"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(response), &result); err == nil && len(result.Results) > 0 {
|
||||
if result.Results[0].RawMarkdown != "" {
|
||||
return result.Results[0].RawMarkdown
|
||||
}
|
||||
return result.Results[0].Markdown
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractCrawledTitle(response string) string {
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(response), &result); err == nil && len(result.Results) > 0 {
|
||||
return result.Results[0].Title
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEventsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []eventSearchResult, crawled []crawledPage) []EventCard {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
contextBuilder.WriteString("Данные об афише и мероприятиях:\n\n")
|
||||
maxSearch := 10
|
||||
if len(searchResults) < maxSearch {
|
||||
maxSearch = len(searchResults)
|
||||
}
|
||||
for i := 0; i < maxSearch; i++ {
|
||||
r := searchResults[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\nURL: %s\n%s\n\n", r.Title, r.URL, truncateStr(r.Content, 300)))
|
||||
}
|
||||
|
||||
if len(crawled) > 0 {
|
||||
contextBuilder.WriteString("\nПодробности со страниц:\n\n")
|
||||
maxCrawled := 3
|
||||
if len(crawled) < maxCrawled {
|
||||
maxCrawled = len(crawled)
|
||||
}
|
||||
for i := 0; i < maxCrawled; i++ {
|
||||
p := crawled[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s (%s)\n%s\n\n", p.Title, p.URL, truncateStr(p.Content, 2000)))
|
||||
}
|
||||
}
|
||||
|
||||
currentYear := time.Now().Format("2006")
|
||||
|
||||
prompt := fmt.Sprintf(`Извлеки ТОЛЬКО реальные МЕРОПРИЯТИЯ (концерты, выставки, фестивали, спектакли, спортивные события) в %s на %s — %s.
|
||||
|
||||
%s
|
||||
|
||||
СТРОГО ЗАПРЕЩЕНО:
|
||||
- Новостные статьи, обзоры, блог-посты — это НЕ мероприятия
|
||||
- Устаревшие события (до %s года)
|
||||
- Выдуманные мероприятия
|
||||
|
||||
JSON (ТОЛЬКО массив, без текста):
|
||||
[{"id":"evt-1","title":"Название","description":"Что за мероприятие, 1 предложение","dateStart":"YYYY-MM-DD","dateEnd":"YYYY-MM-DD","price":500,"currency":"RUB","url":"https://...","address":"Город, Площадка, адрес","tags":["концерт"]}]
|
||||
|
||||
Правила:
|
||||
- ТОЛЬКО конкретные мероприятия с названием, местом и датой
|
||||
- dateStart/dateEnd в формате YYYY-MM-DD, если дата неизвестна — ""
|
||||
- price в рублях, 0 если неизвестна
|
||||
- address — точный адрес площадки для геокодинга
|
||||
- tags: концерт, выставка, фестиваль, спектакль, спорт, кино, мастер-класс, экскурсия
|
||||
- Максимум 10 мероприятий`,
|
||||
strings.Join(brief.Destinations, ", "),
|
||||
brief.StartDate,
|
||||
brief.EndDate,
|
||||
contextBuilder.String(),
|
||||
currentYear,
|
||||
)
|
||||
|
||||
llmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 3000, Temperature: 0.1},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] LLM extraction failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
log.Printf("[travel-events] no JSON array in LLM response (len=%d)", len(response))
|
||||
return nil
|
||||
}
|
||||
|
||||
var events []EventCard
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &events); err != nil {
|
||||
log.Printf("[travel-events] JSON parse error: %v", err)
|
||||
events = tryPartialEventParse(jsonMatch)
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for i := range events {
|
||||
if events[i].ID == "" {
|
||||
events[i].ID = uuid.New().String()
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[travel-events] extracted %d events from LLM", len(events))
|
||||
return events
|
||||
}
|
||||
|
||||
func tryPartialEventParse(jsonStr string) []EventCard {
|
||||
var events []EventCard
|
||||
objRegex := regexp.MustCompile(`\{[^{}]*"title"\s*:\s*"[^"]+[^{}]*\}`)
|
||||
matches := objRegex.FindAllString(jsonStr, -1)
|
||||
for _, m := range matches {
|
||||
var e EventCard
|
||||
if err := json.Unmarshal([]byte(m), &e); err == nil && e.Title != "" {
|
||||
events = append(events, e)
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
log.Printf("[travel-events] partial parse recovered %d events", len(events))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func geocodeEvents(ctx context.Context, cfg TravelOrchestratorConfig, events []EventCard) []EventCard {
|
||||
for i := range events {
|
||||
if events[i].Address == "" || (events[i].Lat != 0 && events[i].Lng != 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
geo, err := cfg.TravelData.Geocode(geoCtx, events[i].Address)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] geocode failed for '%s': %v", events[i].Address, err)
|
||||
continue
|
||||
}
|
||||
|
||||
events[i].Lat = geo.Lat
|
||||
events[i].Lng = geo.Lng
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func deduplicateEvents(events []EventCard) []EventCard {
|
||||
seen := make(map[string]bool)
|
||||
var unique []EventCard
|
||||
|
||||
for _, e := range events {
|
||||
key := strings.ToLower(e.Title)
|
||||
if len(key) > 50 {
|
||||
key = key[:50]
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
unique = append(unique, e)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
266
backend/internal/agent/travel_flights_collector.go
Normal file
266
backend/internal/agent/travel_flights_collector.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
iataCities map[string]string // lowercase city name -> IATA code
|
||||
iataCitiesMu sync.RWMutex
|
||||
iataLoaded bool
|
||||
)
|
||||
|
||||
type tpCityEntry struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Coordinates struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
} `json:"coordinates"`
|
||||
}
|
||||
|
||||
func loadIATACities(ctx context.Context, token string) error {
|
||||
iataCitiesMu.Lock()
|
||||
defer iataCitiesMu.Unlock()
|
||||
|
||||
if iataLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("https://api.travelpayouts.com/data/ru/cities.json?token=%s", url.QueryEscape(token))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch cities.json: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("cities.json returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cities.json: %w", err)
|
||||
}
|
||||
|
||||
var cities []tpCityEntry
|
||||
if err := json.Unmarshal(body, &cities); err != nil {
|
||||
return fmt.Errorf("failed to parse cities.json: %w", err)
|
||||
}
|
||||
|
||||
iataCities = make(map[string]string, len(cities))
|
||||
for _, c := range cities {
|
||||
if c.Name != "" && c.Code != "" {
|
||||
iataCities[strings.ToLower(c.Name)] = c.Code
|
||||
}
|
||||
}
|
||||
|
||||
iataLoaded = true
|
||||
log.Printf("[travel-flights] loaded %d IATA city codes", len(iataCities))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cityToIATA(city string) string {
|
||||
iataCitiesMu.RLock()
|
||||
defer iataCitiesMu.RUnlock()
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSpace(city))
|
||||
if code, ok := iataCities[normalized]; ok {
|
||||
return code
|
||||
}
|
||||
|
||||
aliases := map[string]string{
|
||||
"питер": "LED",
|
||||
"спб": "LED",
|
||||
"петербург": "LED",
|
||||
"мск": "MOW",
|
||||
"нск": "OVB",
|
||||
"новосиб": "OVB",
|
||||
"нижний": "GOJ",
|
||||
"екб": "SVX",
|
||||
"ростов": "ROV",
|
||||
"ростов-на-дону": "ROV",
|
||||
"красноярск": "KJA",
|
||||
"владивосток": "VVO",
|
||||
"калининград": "KGD",
|
||||
"сочи": "AER",
|
||||
"адлер": "AER",
|
||||
"симферополь": "SIP",
|
||||
"крым": "SIP",
|
||||
}
|
||||
if code, ok := aliases[normalized]; ok {
|
||||
return code
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type tpFlightResponse struct {
|
||||
Data []tpFlightData `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type tpFlightData struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
OriginAirport string `json:"origin_airport"`
|
||||
DestAirport string `json:"destination_airport"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
DepartureAt string `json:"departure_at"`
|
||||
ReturnAt string `json:"return_at"`
|
||||
Transfers int `json:"transfers"`
|
||||
ReturnTransfers int `json:"return_transfers"`
|
||||
Duration int `json:"duration"`
|
||||
DurationTo int `json:"duration_to"`
|
||||
DurationBack int `json:"duration_back"`
|
||||
Link string `json:"link"`
|
||||
Gate string `json:"gate"`
|
||||
}
|
||||
|
||||
// CollectFlightsFromTP searches TravelPayouts for flights between origin and destinations.
|
||||
func CollectFlightsFromTP(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) ([]TransportOption, error) {
|
||||
if cfg.TravelPayoutsToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if brief.Origin == "" || len(brief.Destinations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := loadIATACities(ctx, cfg.TravelPayoutsToken); err != nil {
|
||||
log.Printf("[travel-flights] failed to load IATA cities: %v", err)
|
||||
}
|
||||
|
||||
originIATA := cityToIATA(brief.Origin)
|
||||
if originIATA == "" {
|
||||
log.Printf("[travel-flights] unknown IATA for origin '%s'", brief.Origin)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var allTransport []TransportOption
|
||||
|
||||
passengers := brief.Travelers
|
||||
if passengers < 1 {
|
||||
passengers = 1
|
||||
}
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
destIATA := cityToIATA(dest)
|
||||
if destIATA == "" {
|
||||
log.Printf("[travel-flights] unknown IATA for destination '%s'", dest)
|
||||
continue
|
||||
}
|
||||
|
||||
flights, err := searchTPFlights(ctx, cfg.TravelPayoutsToken, cfg.TravelPayoutsMarker, originIATA, destIATA, brief.StartDate, brief.EndDate)
|
||||
if err != nil {
|
||||
log.Printf("[travel-flights] search failed %s->%s: %v", originIATA, destIATA, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range flights {
|
||||
allTransport = append(allTransport, TransportOption{
|
||||
ID: uuid.New().String(),
|
||||
Mode: "flight",
|
||||
From: brief.Origin,
|
||||
To: dest,
|
||||
Departure: f.DepartureAt,
|
||||
Arrival: "",
|
||||
DurationMin: f.DurationTo,
|
||||
PricePerUnit: f.Price,
|
||||
Passengers: passengers,
|
||||
Price: f.Price * float64(passengers),
|
||||
Currency: "RUB",
|
||||
Provider: f.Gate,
|
||||
BookingURL: buildTPBookingURL(f.Link, cfg.TravelPayoutsMarker),
|
||||
Airline: f.Airline,
|
||||
FlightNum: f.FlightNumber,
|
||||
Stops: f.Transfers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(allTransport) > 10 {
|
||||
allTransport = allTransport[:10]
|
||||
}
|
||||
|
||||
return allTransport, nil
|
||||
}
|
||||
|
||||
func searchTPFlights(ctx context.Context, token, marker, origin, destination, departDate, returnDate string) ([]tpFlightData, error) {
|
||||
params := url.Values{
|
||||
"origin": {origin},
|
||||
"destination": {destination},
|
||||
"currency": {"rub"},
|
||||
"sorting": {"price"},
|
||||
"limit": {"5"},
|
||||
"token": {token},
|
||||
}
|
||||
|
||||
if departDate != "" {
|
||||
params.Set("departure_at", departDate)
|
||||
}
|
||||
if returnDate != "" && returnDate != departDate {
|
||||
params.Set("return_at", returnDate)
|
||||
}
|
||||
|
||||
u := "https://api.travelpayouts.com/aviasales/v3/prices_for_dates?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TP flights request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("TP flights returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result tpFlightResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("TP flights decode error: %w", err)
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
func buildTPBookingURL(link, marker string) string {
|
||||
if link == "" {
|
||||
return ""
|
||||
}
|
||||
base := "https://www.aviasales.ru" + link
|
||||
if marker != "" {
|
||||
if strings.Contains(base, "?") {
|
||||
base += "&marker=" + marker
|
||||
} else {
|
||||
base += "?marker=" + marker
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
420
backend/internal/agent/travel_hotels_collector.go
Normal file
420
backend/internal/agent/travel_hotels_collector.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CollectHotelsEnriched searches for hotels via SearXNG + Crawl4AI + LLM.
|
||||
func CollectHotelsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, destinations []destGeoEntry) ([]HotelCard, error) {
|
||||
if cfg.SearchClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawResults := searchForHotels(ctx, cfg.SearchClient, brief)
|
||||
if len(rawResults) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var crawledContent []crawledPage
|
||||
if cfg.Crawl4AIURL != "" {
|
||||
crawledContent = crawlHotelPages(ctx, cfg.Crawl4AIURL, rawResults)
|
||||
}
|
||||
|
||||
hotels := extractHotelsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
|
||||
|
||||
hotels = geocodeHotels(ctx, cfg, hotels)
|
||||
|
||||
hotels = deduplicateHotels(hotels)
|
||||
|
||||
if len(hotels) > 10 {
|
||||
hotels = hotels[:10]
|
||||
}
|
||||
|
||||
return hotels, nil
|
||||
}
|
||||
|
||||
type hotelSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
Engine string
|
||||
}
|
||||
|
||||
func searchForHotels(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []hotelSearchResult {
|
||||
var results []hotelSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
queries := generateHotelQueries(dest, brief.StartDate, brief.EndDate, brief.Travelers, brief.TravelStyle)
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] search error for '%s': %v", q, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range resp.Results {
|
||||
if r.URL == "" || seen[r.URL] {
|
||||
continue
|
||||
}
|
||||
seen[r.URL] = true
|
||||
results = append(results, hotelSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
Engine: r.Engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func generateHotelQueries(destination, startDate, endDate string, travelers int, style string) []string {
|
||||
dateStr := ""
|
||||
if startDate != "" {
|
||||
dateStr = startDate
|
||||
if endDate != "" && endDate != startDate {
|
||||
dateStr += " " + endDate
|
||||
}
|
||||
}
|
||||
|
||||
familyStr := ""
|
||||
if travelers >= 3 {
|
||||
familyStr = "семейный "
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
fmt.Sprintf("%sотели %s цены бронирование %s", familyStr, destination, dateStr),
|
||||
fmt.Sprintf("гостиницы %s рейтинг отзывы %d гостей", destination, travelers),
|
||||
fmt.Sprintf("лучшие отели %s для туристов", destination),
|
||||
}
|
||||
|
||||
if travelers >= 3 {
|
||||
queries = append(queries, fmt.Sprintf("семейные отели %s с детьми", destination))
|
||||
}
|
||||
|
||||
if style == "luxury" {
|
||||
queries = append(queries, fmt.Sprintf("5 звезд отели %s премиум", destination))
|
||||
} else if style == "budget" {
|
||||
queries = append(queries, fmt.Sprintf("хостелы %s дешево %d человек", destination, travelers))
|
||||
} else {
|
||||
queries = append(queries, fmt.Sprintf("где остановиться %s %d человек", destination, travelers))
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func crawlHotelPages(ctx context.Context, crawl4aiURL string, results []hotelSearchResult) []crawledPage {
|
||||
maxCrawl := 5
|
||||
if len(results) < maxCrawl {
|
||||
maxCrawl = len(results)
|
||||
}
|
||||
|
||||
var pages []crawledPage
|
||||
|
||||
for _, r := range results[:maxCrawl] {
|
||||
crawlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
page, err := crawlSinglePage(crawlCtx, crawl4aiURL, r.URL)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] crawl failed for %s: %v", r.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if page != nil && len(page.Content) > 100 {
|
||||
pages = append(pages, *page)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
func extractHotelsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []hotelSearchResult, crawled []crawledPage) []HotelCard {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
contextBuilder.WriteString("Результаты поиска отелей:\n\n")
|
||||
maxSearch := 10
|
||||
if len(searchResults) < maxSearch {
|
||||
maxSearch = len(searchResults)
|
||||
}
|
||||
for i := 0; i < maxSearch; i++ {
|
||||
r := searchResults[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\nURL: %s\n%s\n\n", r.Title, r.URL, truncateStr(r.Content, 300)))
|
||||
}
|
||||
|
||||
if len(crawled) > 0 {
|
||||
contextBuilder.WriteString("\nПодробное содержание:\n\n")
|
||||
maxCrawled := 3
|
||||
if len(crawled) < maxCrawled {
|
||||
maxCrawled = len(crawled)
|
||||
}
|
||||
for i := 0; i < maxCrawled; i++ {
|
||||
p := crawled[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s (%s)\n%s\n\n", p.Title, p.URL, truncateStr(p.Content, 2000)))
|
||||
}
|
||||
}
|
||||
|
||||
nightsStr := "1 ночь"
|
||||
nightsCount := calculateNights(brief.StartDate, brief.EndDate)
|
||||
if brief.StartDate != "" && brief.EndDate != "" && brief.EndDate != brief.StartDate {
|
||||
nightsStr = fmt.Sprintf("с %s по %s (%d ночей)", brief.StartDate, brief.EndDate, nightsCount)
|
||||
}
|
||||
|
||||
travelers := brief.Travelers
|
||||
if travelers < 1 {
|
||||
travelers = 1
|
||||
}
|
||||
rooms := calculateRooms(travelers)
|
||||
|
||||
prompt := fmt.Sprintf(`Извлеки до 6 отелей в %s на %s для %d чел (%d номеров).
|
||||
|
||||
%s
|
||||
|
||||
JSON массив (ТОЛЬКО JSON, без текста):
|
||||
[{"id":"hotel-1","name":"Название","stars":3,"rating":8.5,"reviewCount":120,"pricePerNight":3500,"totalPrice":0,"currency":"RUB","address":"Город, ул. Улица, д. 1","bookingUrl":"https://...","amenities":["Wi-Fi","Завтрак"],"pros":["Центр города"],"checkIn":"%s","checkOut":"%s"}]
|
||||
|
||||
Правила:
|
||||
- ТОЛЬКО реальные отели из текста
|
||||
- pricePerNight — за 1 номер за 1 ночь в рублях. Если не указана — оцени по звёздам: 1★=1500, 2★=2500, 3★=3500, 4★=5000, 5★=8000
|
||||
- totalPrice=0 (рассчитается автоматически)
|
||||
- Адрес с городом для геокодинга
|
||||
- Максимум 6 отелей, компактный JSON`,
|
||||
strings.Join(brief.Destinations, ", "),
|
||||
nightsStr,
|
||||
travelers,
|
||||
rooms,
|
||||
contextBuilder.String(),
|
||||
brief.StartDate,
|
||||
brief.EndDate,
|
||||
)
|
||||
|
||||
llmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 3000, Temperature: 0.1},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] LLM extraction failed: %v", err)
|
||||
return fallbackHotelsFromSearch(searchResults, brief)
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
log.Printf("[travel-hotels] no JSON array found in LLM response (len=%d)", len(response))
|
||||
return fallbackHotelsFromSearch(searchResults, brief)
|
||||
}
|
||||
|
||||
var hotels []HotelCard
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &hotels); err != nil {
|
||||
log.Printf("[travel-hotels] JSON parse error: %v, response len=%d", err, len(jsonMatch))
|
||||
hotels = tryPartialHotelParse(jsonMatch)
|
||||
if len(hotels) == 0 {
|
||||
return fallbackHotelsFromSearch(searchResults, brief)
|
||||
}
|
||||
}
|
||||
|
||||
nights := nightsCount
|
||||
guestRooms := rooms
|
||||
guests := travelers
|
||||
|
||||
for i := range hotels {
|
||||
if hotels[i].ID == "" {
|
||||
hotels[i].ID = uuid.New().String()
|
||||
}
|
||||
if hotels[i].CheckIn == "" {
|
||||
hotels[i].CheckIn = brief.StartDate
|
||||
}
|
||||
if hotels[i].CheckOut == "" {
|
||||
hotels[i].CheckOut = brief.EndDate
|
||||
}
|
||||
hotels[i].Nights = nights
|
||||
hotels[i].Rooms = guestRooms
|
||||
hotels[i].Guests = guests
|
||||
|
||||
if hotels[i].PricePerNight > 0 && hotels[i].TotalPrice == 0 {
|
||||
hotels[i].TotalPrice = hotels[i].PricePerNight * float64(nights) * float64(guestRooms)
|
||||
}
|
||||
if hotels[i].TotalPrice > 0 && hotels[i].PricePerNight == 0 && nights > 0 && guestRooms > 0 {
|
||||
hotels[i].PricePerNight = hotels[i].TotalPrice / float64(nights) / float64(guestRooms)
|
||||
}
|
||||
if hotels[i].PricePerNight == 0 {
|
||||
hotels[i].PricePerNight = estimatePriceByStars(hotels[i].Stars)
|
||||
hotels[i].TotalPrice = hotels[i].PricePerNight * float64(nights) * float64(guestRooms)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[travel-hotels] extracted %d hotels from LLM", len(hotels))
|
||||
return hotels
|
||||
}
|
||||
|
||||
func tryPartialHotelParse(jsonStr string) []HotelCard {
|
||||
var hotels []HotelCard
|
||||
objRegex := regexp.MustCompile(`\{[^{}]*"name"\s*:\s*"[^"]+[^{}]*\}`)
|
||||
matches := objRegex.FindAllString(jsonStr, -1)
|
||||
for _, m := range matches {
|
||||
var h HotelCard
|
||||
if err := json.Unmarshal([]byte(m), &h); err == nil && h.Name != "" {
|
||||
hotels = append(hotels, h)
|
||||
}
|
||||
}
|
||||
if len(hotels) > 0 {
|
||||
log.Printf("[travel-hotels] partial parse recovered %d hotels", len(hotels))
|
||||
}
|
||||
return hotels
|
||||
}
|
||||
|
||||
func estimatePriceByStars(stars int) float64 {
|
||||
switch {
|
||||
case stars >= 5:
|
||||
return 8000
|
||||
case stars == 4:
|
||||
return 5000
|
||||
case stars == 3:
|
||||
return 3500
|
||||
case stars == 2:
|
||||
return 2500
|
||||
default:
|
||||
return 2000
|
||||
}
|
||||
}
|
||||
|
||||
func calculateNights(startDate, endDate string) int {
|
||||
if startDate == "" || endDate == "" {
|
||||
return 1
|
||||
}
|
||||
start, err1 := time.Parse("2006-01-02", startDate)
|
||||
end, err2 := time.Parse("2006-01-02", endDate)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 1
|
||||
}
|
||||
nights := int(end.Sub(start).Hours() / 24)
|
||||
if nights < 1 {
|
||||
return 1
|
||||
}
|
||||
return nights
|
||||
}
|
||||
|
||||
func calculateRooms(travelers int) int {
|
||||
if travelers <= 2 {
|
||||
return 1
|
||||
}
|
||||
return (travelers + 1) / 2
|
||||
}
|
||||
|
||||
func fallbackHotelsFromSearch(results []hotelSearchResult, brief *TripBrief) []HotelCard {
|
||||
hotels := make([]HotelCard, 0, len(results))
|
||||
nights := calculateNights(brief.StartDate, brief.EndDate)
|
||||
travelers := brief.Travelers
|
||||
if travelers < 1 {
|
||||
travelers = 1
|
||||
}
|
||||
rooms := calculateRooms(travelers)
|
||||
|
||||
for _, r := range results {
|
||||
if len(hotels) >= 5 {
|
||||
break
|
||||
}
|
||||
name := r.Title
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
|
||||
price := extractPriceFromSnippet(r.Content)
|
||||
if price == 0 {
|
||||
price = 3000
|
||||
}
|
||||
|
||||
hotels = append(hotels, HotelCard{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
Stars: 3,
|
||||
PricePerNight: price,
|
||||
TotalPrice: price * float64(nights) * float64(rooms),
|
||||
Rooms: rooms,
|
||||
Nights: nights,
|
||||
Guests: travelers,
|
||||
Currency: "RUB",
|
||||
CheckIn: brief.StartDate,
|
||||
CheckOut: brief.EndDate,
|
||||
BookingURL: r.URL,
|
||||
})
|
||||
}
|
||||
log.Printf("[travel-hotels] fallback: %d hotels from search results", len(hotels))
|
||||
return hotels
|
||||
}
|
||||
|
||||
func extractPriceFromSnippet(text string) float64 {
|
||||
priceRegex := regexp.MustCompile(`(\d[\d\s]*\d)\s*(?:₽|руб|RUB|р\.)`)
|
||||
match := priceRegex.FindStringSubmatch(text)
|
||||
if len(match) >= 2 {
|
||||
numStr := strings.ReplaceAll(match[1], " ", "")
|
||||
var price float64
|
||||
if _, err := fmt.Sscanf(numStr, "%f", &price); err == nil && price > 100 && price < 500000 {
|
||||
return price
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func geocodeHotels(ctx context.Context, cfg TravelOrchestratorConfig, hotels []HotelCard) []HotelCard {
|
||||
for i := range hotels {
|
||||
if hotels[i].Address == "" || (hotels[i].Lat != 0 && hotels[i].Lng != 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
geo, err := cfg.TravelData.Geocode(geoCtx, hotels[i].Address)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] geocode failed for '%s': %v", hotels[i].Address, err)
|
||||
continue
|
||||
}
|
||||
|
||||
hotels[i].Lat = geo.Lat
|
||||
hotels[i].Lng = geo.Lng
|
||||
}
|
||||
|
||||
return hotels
|
||||
}
|
||||
|
||||
func deduplicateHotels(hotels []HotelCard) []HotelCard {
|
||||
seen := make(map[string]bool)
|
||||
var unique []HotelCard
|
||||
|
||||
for _, h := range hotels {
|
||||
key := strings.ToLower(h.Name)
|
||||
if len(key) > 50 {
|
||||
key = key[:50]
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
unique = append(unique, h)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
1148
backend/internal/agent/travel_orchestrator.go
Normal file
1148
backend/internal/agent/travel_orchestrator.go
Normal file
File diff suppressed because it is too large
Load Diff
694
backend/internal/agent/travel_poi_collector.go
Normal file
694
backend/internal/agent/travel_poi_collector.go
Normal file
@@ -0,0 +1,694 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// POI category queries for 2GIS Places API — concrete organization types
|
||||
var poiCategoryQueries = map[string][]string{
|
||||
"attraction": {
|
||||
"достопримечательности",
|
||||
"памятники",
|
||||
"исторические здания",
|
||||
"смотровые площадки",
|
||||
},
|
||||
"museum": {
|
||||
"музеи",
|
||||
"галереи",
|
||||
"выставки",
|
||||
},
|
||||
"park": {
|
||||
"парки",
|
||||
"скверы",
|
||||
"сады",
|
||||
"набережные",
|
||||
},
|
||||
"restaurant": {
|
||||
"рестораны",
|
||||
"кафе",
|
||||
},
|
||||
"theater": {
|
||||
"театры",
|
||||
"кинотеатры",
|
||||
"филармония",
|
||||
},
|
||||
"entertainment": {
|
||||
"развлечения",
|
||||
"аквапарки",
|
||||
"зоопарки",
|
||||
"аттракционы",
|
||||
"боулинг",
|
||||
},
|
||||
"shopping": {
|
||||
"торговые центры",
|
||||
"рынки",
|
||||
"сувениры",
|
||||
},
|
||||
"religious": {
|
||||
"храмы",
|
||||
"церкви",
|
||||
"соборы",
|
||||
"мечети",
|
||||
},
|
||||
}
|
||||
|
||||
// CollectPOIsEnriched collects POIs using 2GIS Places API as primary source,
|
||||
// then enriches with descriptions from SearXNG + LLM.
|
||||
func CollectPOIsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, destinations []destGeoEntry) ([]POICard, error) {
|
||||
if cfg.TravelData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var allPOIs []POICard
|
||||
|
||||
// Phase 1: Collect concrete places from 2GIS for each destination
|
||||
for _, dest := range destinations {
|
||||
if dest.Lat == 0 && dest.Lng == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
categories := selectCategoriesForBrief(brief)
|
||||
places := searchPlacesFrom2GIS(ctx, cfg, dest, categories)
|
||||
allPOIs = append(allPOIs, places...)
|
||||
}
|
||||
|
||||
log.Printf("[travel-poi] 2GIS returned %d places total", len(allPOIs))
|
||||
|
||||
// Phase 2: If 2GIS returned too few, supplement with SearXNG + LLM extraction
|
||||
if len(allPOIs) < 5 && cfg.SearchClient != nil {
|
||||
log.Printf("[travel-poi] 2GIS returned only %d places, supplementing with SearXNG+LLM", len(allPOIs))
|
||||
supplementPOIs := collectPOIsFromSearch(ctx, cfg, brief, destinations)
|
||||
allPOIs = append(allPOIs, supplementPOIs...)
|
||||
}
|
||||
|
||||
// Phase 3: Enrich POIs with descriptions from SearXNG if available
|
||||
if cfg.SearchClient != nil && len(allPOIs) > 0 {
|
||||
allPOIs = enrichPOIDescriptions(ctx, cfg, brief, allPOIs)
|
||||
}
|
||||
|
||||
// Phase 3b: Fetch photos via SearXNG images
|
||||
if cfg.SearchClient != nil && len(allPOIs) > 0 {
|
||||
allPOIs = enrichPOIPhotos(ctx, cfg, brief, allPOIs)
|
||||
}
|
||||
|
||||
// Phase 4: Fallback geocoding for POIs without coordinates
|
||||
allPOIs = geocodePOIs(ctx, cfg, allPOIs)
|
||||
|
||||
allPOIs = deduplicatePOIs(allPOIs)
|
||||
|
||||
// Filter out POIs without coordinates — they can't be shown on map
|
||||
validPOIs := make([]POICard, 0, len(allPOIs))
|
||||
for _, p := range allPOIs {
|
||||
if p.Lat != 0 || p.Lng != 0 {
|
||||
validPOIs = append(validPOIs, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validPOIs) > 25 {
|
||||
validPOIs = validPOIs[:25]
|
||||
}
|
||||
|
||||
log.Printf("[travel-poi] returning %d POIs with coordinates", len(validPOIs))
|
||||
return validPOIs, nil
|
||||
}
|
||||
|
||||
// selectCategoriesForBrief picks relevant POI categories based on user interests.
|
||||
func selectCategoriesForBrief(brief *TripBrief) []string {
|
||||
if len(brief.Interests) == 0 {
|
||||
return []string{"attraction", "museum", "park", "restaurant", "theater", "entertainment"}
|
||||
}
|
||||
|
||||
interestMapping := map[string][]string{
|
||||
"культура": {"museum", "theater", "attraction", "religious"},
|
||||
"музеи": {"museum"},
|
||||
"еда": {"restaurant"},
|
||||
"рестораны": {"restaurant"},
|
||||
"природа": {"park"},
|
||||
"парки": {"park"},
|
||||
"развлечения": {"entertainment"},
|
||||
"шопинг": {"shopping"},
|
||||
"история": {"attraction", "museum", "religious"},
|
||||
"архитектура": {"attraction", "religious"},
|
||||
"дети": {"entertainment", "park"},
|
||||
"семья": {"entertainment", "park", "museum"},
|
||||
"семейный": {"entertainment", "park", "museum"},
|
||||
"активный отдых": {"entertainment", "park"},
|
||||
"религия": {"religious"},
|
||||
"театр": {"theater"},
|
||||
"искусство": {"museum", "theater"},
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var categories []string
|
||||
|
||||
for _, interest := range brief.Interests {
|
||||
lower := strings.ToLower(interest)
|
||||
for keyword, cats := range interestMapping {
|
||||
if strings.Contains(lower, keyword) {
|
||||
for _, c := range cats {
|
||||
if !seen[c] {
|
||||
seen[c] = true
|
||||
categories = append(categories, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(categories) == 0 {
|
||||
return []string{"attraction", "museum", "park", "restaurant", "theater", "entertainment"}
|
||||
}
|
||||
|
||||
// Always include attractions as baseline
|
||||
if !seen["attraction"] {
|
||||
categories = append(categories, "attraction")
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// searchPlacesFrom2GIS queries 2GIS Places API for concrete organizations.
|
||||
func searchPlacesFrom2GIS(ctx context.Context, cfg TravelOrchestratorConfig, dest destGeoEntry, categories []string) []POICard {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
pois []POICard
|
||||
wg sync.WaitGroup
|
||||
seen = make(map[string]bool)
|
||||
)
|
||||
|
||||
for _, category := range categories {
|
||||
queries, ok := poiCategoryQueries[category]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
wg.Add(1)
|
||||
go func(query, cat string) {
|
||||
defer wg.Done()
|
||||
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fullQuery := fmt.Sprintf("%s %s", query, dest.Name)
|
||||
places, err := cfg.TravelData.SearchPlaces(searchCtx, fullQuery, dest.Lat, dest.Lng, 10000)
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] 2GIS search error for '%s': %v", fullQuery, err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for _, place := range places {
|
||||
if seen[place.ID] || seen[place.Name] {
|
||||
continue
|
||||
}
|
||||
seen[place.ID] = true
|
||||
seen[place.Name] = true
|
||||
|
||||
mappedCategory := mapPurposeToCategory(place.Purpose, place.Type, cat)
|
||||
|
||||
pois = append(pois, POICard{
|
||||
ID: place.ID,
|
||||
Name: place.Name,
|
||||
Category: mappedCategory,
|
||||
Address: fmt.Sprintf("%s, %s", dest.Name, place.Address),
|
||||
Lat: place.Lat,
|
||||
Lng: place.Lng,
|
||||
Rating: place.Rating,
|
||||
ReviewCount: place.ReviewCount,
|
||||
Schedule: place.Schedule,
|
||||
})
|
||||
}
|
||||
}(q, category)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return pois
|
||||
}
|
||||
|
||||
// mapPurposeToCategory maps 2GIS purpose/type to our POI category.
|
||||
func mapPurposeToCategory(purpose, itemType, fallbackCategory string) string {
|
||||
lower := strings.ToLower(purpose)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "музей") || strings.Contains(lower, "галере") || strings.Contains(lower, "выставк"):
|
||||
return "museum"
|
||||
case strings.Contains(lower, "ресторан") || strings.Contains(lower, "кафе") || strings.Contains(lower, "бар"):
|
||||
return "restaurant"
|
||||
case strings.Contains(lower, "парк") || strings.Contains(lower, "сквер") || strings.Contains(lower, "сад"):
|
||||
return "park"
|
||||
case strings.Contains(lower, "театр") || strings.Contains(lower, "кинотеатр") || strings.Contains(lower, "филармон"):
|
||||
return "theater"
|
||||
case strings.Contains(lower, "храм") || strings.Contains(lower, "церков") || strings.Contains(lower, "собор") || strings.Contains(lower, "мечет"):
|
||||
return "religious"
|
||||
case strings.Contains(lower, "торгов") || strings.Contains(lower, "магазин") || strings.Contains(lower, "рынок"):
|
||||
return "shopping"
|
||||
case strings.Contains(lower, "развлеч") || strings.Contains(lower, "аквапарк") || strings.Contains(lower, "зоопарк") || strings.Contains(lower, "аттракц"):
|
||||
return "entertainment"
|
||||
case strings.Contains(lower, "памятник") || strings.Contains(lower, "достоприм"):
|
||||
return "attraction"
|
||||
}
|
||||
|
||||
if itemType == "attraction" {
|
||||
return "attraction"
|
||||
}
|
||||
|
||||
return fallbackCategory
|
||||
}
|
||||
|
||||
// collectPOIsFromSearch is the SearXNG + LLM fallback when 2GIS returns too few results.
|
||||
func collectPOIsFromSearch(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, destinations []destGeoEntry) []POICard {
|
||||
if cfg.SearchClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawResults := searchForPOIs(ctx, cfg.SearchClient, brief)
|
||||
if len(rawResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var crawledContent []crawledPage
|
||||
if cfg.Crawl4AIURL != "" {
|
||||
crawledContent = crawlPOIPages(ctx, cfg.Crawl4AIURL, rawResults)
|
||||
}
|
||||
|
||||
pois := extractPOIsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
// enrichPOIDescriptions adds descriptions to 2GIS POIs using SearXNG + LLM.
|
||||
func enrichPOIDescriptions(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, pois []POICard) []POICard {
|
||||
needsDescription := make([]int, 0)
|
||||
for i, p := range pois {
|
||||
if p.Description == "" {
|
||||
needsDescription = append(needsDescription, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(needsDescription) == 0 {
|
||||
return pois
|
||||
}
|
||||
|
||||
// Build a list of POI names for bulk enrichment via LLM
|
||||
var names []string
|
||||
for _, idx := range needsDescription {
|
||||
names = append(names, pois[idx].Name)
|
||||
}
|
||||
|
||||
if len(names) > 15 {
|
||||
names = names[:15]
|
||||
}
|
||||
|
||||
dest := strings.Join(brief.Destinations, ", ")
|
||||
prompt := fmt.Sprintf(`Ты — эксперт по туризму в %s. Для каждого места из списка напиши краткое описание (1-2 предложения), примерное время посещения в минутах и примерную стоимость входа в рублях (0 если бесплатно).
|
||||
|
||||
Места:
|
||||
%s
|
||||
|
||||
Верни ТОЛЬКО JSON массив:
|
||||
[
|
||||
{
|
||||
"name": "Точное название из списка",
|
||||
"description": "Краткое описание",
|
||||
"duration": число_минут,
|
||||
"price": цена_в_рублях,
|
||||
"rating": рейтинг_от_0_до_5
|
||||
}
|
||||
]
|
||||
|
||||
Правила:
|
||||
- Описание должно быть информативным и привлекательным для туриста
|
||||
- duration: музей 60-120 мин, парк 30-90 мин, ресторан 60 мин, памятник 15-30 мин
|
||||
- price: 0 для открытых мест, реальные цены для музеев/театров
|
||||
- rating: если знаешь реальный рейтинг — используй, иначе оцени по популярности (3.5-5.0)
|
||||
- Верни ТОЛЬКО JSON, без пояснений`, dest, strings.Join(names, "\n"))
|
||||
|
||||
enrichCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := cfg.LLM.GenerateText(enrichCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 4096, Temperature: 0.2},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] LLM enrichment failed: %v", err)
|
||||
return pois
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
return pois
|
||||
}
|
||||
|
||||
var enrichments []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Duration int `json:"duration"`
|
||||
Price float64 `json:"price"`
|
||||
Rating float64 `json:"rating"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &enrichments); err != nil {
|
||||
log.Printf("[travel-poi] enrichment JSON parse error: %v", err)
|
||||
return pois
|
||||
}
|
||||
|
||||
enrichMap := make(map[string]int)
|
||||
for i, e := range enrichments {
|
||||
enrichMap[strings.ToLower(e.Name)] = i
|
||||
}
|
||||
|
||||
for _, idx := range needsDescription {
|
||||
key := strings.ToLower(pois[idx].Name)
|
||||
if eIdx, ok := enrichMap[key]; ok {
|
||||
e := enrichments[eIdx]
|
||||
if e.Description != "" {
|
||||
pois[idx].Description = e.Description
|
||||
}
|
||||
if e.Duration > 0 {
|
||||
pois[idx].Duration = e.Duration
|
||||
}
|
||||
if e.Price > 0 {
|
||||
pois[idx].Price = e.Price
|
||||
}
|
||||
if e.Rating > 0 {
|
||||
pois[idx].Rating = e.Rating
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
func enrichPOIPhotos(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, pois []POICard) []POICard {
|
||||
dest := ""
|
||||
if len(brief.Destinations) > 0 {
|
||||
dest = brief.Destinations[0]
|
||||
}
|
||||
|
||||
maxEnrich := 15
|
||||
if len(pois) < maxEnrich {
|
||||
maxEnrich = len(pois)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < maxEnrich; i++ {
|
||||
if len(pois[i].Photos) > 0 {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
query := pois[idx].Name
|
||||
if dest != "" {
|
||||
query = pois[idx].Name + " " + dest
|
||||
}
|
||||
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := cfg.SearchClient.Search(searchCtx, query, &search.SearchOptions{
|
||||
Categories: []string{"images"},
|
||||
PageNo: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var photos []string
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range resp.Results {
|
||||
if len(photos) >= 3 {
|
||||
break
|
||||
}
|
||||
imgURL := r.ImgSrc
|
||||
if imgURL == "" {
|
||||
imgURL = r.ThumbnailSrc
|
||||
}
|
||||
if imgURL == "" {
|
||||
imgURL = r.Thumbnail
|
||||
}
|
||||
if imgURL == "" || seen[imgURL] {
|
||||
continue
|
||||
}
|
||||
seen[imgURL] = true
|
||||
photos = append(photos, imgURL)
|
||||
}
|
||||
|
||||
if len(photos) > 0 {
|
||||
mu.Lock()
|
||||
pois[idx].Photos = photos
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
photosFound := 0
|
||||
for _, p := range pois {
|
||||
if len(p.Photos) > 0 {
|
||||
photosFound++
|
||||
}
|
||||
}
|
||||
log.Printf("[travel-poi] enriched %d/%d POIs with photos", photosFound, len(pois))
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
type poiSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
Engine string
|
||||
}
|
||||
|
||||
func searchForPOIs(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []poiSearchResult {
|
||||
var results []poiSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
queries := generatePOIQueries(dest, brief.Interests)
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] search error for '%s': %v", q, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range resp.Results {
|
||||
if r.URL == "" || seen[r.URL] {
|
||||
continue
|
||||
}
|
||||
seen[r.URL] = true
|
||||
results = append(results, poiSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
Engine: r.Engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func generatePOIQueries(destination string, interests []string) []string {
|
||||
queries := []string{
|
||||
fmt.Sprintf("достопримечательности %s что посмотреть конкретные места", destination),
|
||||
fmt.Sprintf("лучшие рестораны %s рейтинг адреса", destination),
|
||||
fmt.Sprintf("музеи %s список адреса", destination),
|
||||
}
|
||||
|
||||
for _, interest := range interests {
|
||||
queries = append(queries, fmt.Sprintf("%s %s адреса", interest, destination))
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func crawlPOIPages(ctx context.Context, crawl4aiURL string, results []poiSearchResult) []crawledPage {
|
||||
maxCrawl := 5
|
||||
if len(results) < maxCrawl {
|
||||
maxCrawl = len(results)
|
||||
}
|
||||
|
||||
var pages []crawledPage
|
||||
|
||||
for _, r := range results[:maxCrawl] {
|
||||
crawlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
page, err := crawlSinglePage(crawlCtx, crawl4aiURL, r.URL)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] crawl failed for %s: %v", r.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if page != nil && len(page.Content) > 100 {
|
||||
pages = append(pages, *page)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
func extractPOIsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []poiSearchResult, crawled []crawledPage) []POICard {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
contextBuilder.WriteString("Результаты поиска мест и организаций:\n\n")
|
||||
for i, r := range searchResults {
|
||||
if i >= 15 {
|
||||
break
|
||||
}
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\nURL: %s\n%s\n\n", r.Title, r.URL, truncateStr(r.Content, 500)))
|
||||
}
|
||||
|
||||
if len(crawled) > 0 {
|
||||
contextBuilder.WriteString("\nПодробное содержание страниц:\n\n")
|
||||
for _, p := range crawled {
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s (%s)\n%s\n\n", p.Title, p.URL, truncateStr(p.Content, 3000)))
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Ты — эксперт по туризму. Из предоставленного контента извлеки КОНКРЕТНЫЕ достопримечательности, рестораны, музеи, парки и интересные места в %s.
|
||||
|
||||
%s
|
||||
|
||||
КРИТИЧЕСКИ ВАЖНО:
|
||||
- Извлекай ТОЛЬКО конкретные места с названиями (не статьи, не списки, не обзоры)
|
||||
- Каждое место должно быть реальной организацией или объектом, который можно найти на карте
|
||||
- НЕ включай заголовки статей типа "ТОП-25 достопримечательностей" — это НЕ место
|
||||
- Адрес ОБЯЗАТЕЛЕН и должен включать город для геокодинга
|
||||
|
||||
Верни JSON массив:
|
||||
[
|
||||
{
|
||||
"id": "уникальный id",
|
||||
"name": "Конкретное название места (не статьи!)",
|
||||
"description": "Краткое описание (1-2 предложения)",
|
||||
"category": "attraction|restaurant|museum|park|theater|shopping|entertainment|religious|viewpoint",
|
||||
"rating": число_от_0_до_5_или_0,
|
||||
"address": "Город, улица, дом (точный адрес)",
|
||||
"duration": время_посещения_в_минутах,
|
||||
"price": цена_входа_в_рублях_или_0,
|
||||
"currency": "RUB",
|
||||
"url": "ссылка на источник"
|
||||
}
|
||||
]
|
||||
|
||||
Верни ТОЛЬКО JSON массив, без пояснений.`,
|
||||
strings.Join(brief.Destinations, ", "),
|
||||
contextBuilder.String(),
|
||||
)
|
||||
|
||||
response, err := llmClient.GenerateText(ctx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 4096, Temperature: 0.2},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] LLM extraction failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pois []POICard
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &pois); err != nil {
|
||||
log.Printf("[travel-poi] JSON parse error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range pois {
|
||||
if pois[i].ID == "" {
|
||||
pois[i].ID = uuid.New().String()
|
||||
}
|
||||
}
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, pois []POICard) []POICard {
|
||||
for i := range pois {
|
||||
if pois[i].Lat != 0 && pois[i].Lng != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try geocoding by address first, then by name + city
|
||||
queries := []string{}
|
||||
if pois[i].Address != "" {
|
||||
queries = append(queries, pois[i].Address)
|
||||
}
|
||||
if pois[i].Name != "" {
|
||||
queries = append(queries, pois[i].Name)
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
geo, err := cfg.TravelData.Geocode(geoCtx, query)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pois[i].Lat = geo.Lat
|
||||
pois[i].Lng = geo.Lng
|
||||
log.Printf("[travel-poi] geocoded '%s' -> %.4f, %.4f", query, geo.Lat, geo.Lng)
|
||||
break
|
||||
}
|
||||
|
||||
if pois[i].Lat == 0 && pois[i].Lng == 0 {
|
||||
log.Printf("[travel-poi] failed to geocode POI '%s' (address: '%s')", pois[i].Name, pois[i].Address)
|
||||
}
|
||||
}
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
func deduplicatePOIs(pois []POICard) []POICard {
|
||||
seen := make(map[string]bool)
|
||||
var unique []POICard
|
||||
|
||||
for _, p := range pois {
|
||||
key := strings.ToLower(p.Name)
|
||||
if len(key) > 50 {
|
||||
key = key[:50]
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
unique = append(unique, p)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
225
backend/internal/agent/travel_types.go
Normal file
225
backend/internal/agent/travel_types.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package agent
|
||||
|
||||
import "time"
|
||||
|
||||
// TripBrief holds the structured user intent extracted by the planner agent.
|
||||
type TripBrief struct {
|
||||
Origin string `json:"origin"`
|
||||
OriginLat float64 `json:"originLat,omitempty"`
|
||||
OriginLng float64 `json:"originLng,omitempty"`
|
||||
Destinations []string `json:"destinations"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Travelers int `json:"travelers"`
|
||||
Budget float64 `json:"budget"`
|
||||
Currency string `json:"currency"`
|
||||
Interests []string `json:"interests,omitempty"`
|
||||
TravelStyle string `json:"travelStyle,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
}
|
||||
|
||||
func (b *TripBrief) IsComplete() bool {
|
||||
if b.StartDate != "" && b.EndDate == "" {
|
||||
b.EndDate = b.StartDate
|
||||
}
|
||||
return len(b.Destinations) > 0
|
||||
}
|
||||
|
||||
func (b *TripBrief) ApplyDefaults() {
|
||||
if b.StartDate == "" {
|
||||
b.StartDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
if b.EndDate == "" {
|
||||
start, err := time.Parse("2006-01-02", b.StartDate)
|
||||
if err == nil {
|
||||
b.EndDate = start.AddDate(0, 0, 3).Format("2006-01-02")
|
||||
} else {
|
||||
b.EndDate = b.StartDate
|
||||
}
|
||||
}
|
||||
if b.Travelers == 0 {
|
||||
b.Travelers = 2
|
||||
}
|
||||
if b.Currency == "" {
|
||||
b.Currency = "RUB"
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TripBrief) MissingFields() []string {
|
||||
var missing []string
|
||||
if len(b.Destinations) == 0 {
|
||||
missing = append(missing, "destination")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// EventCard represents a discovered event/activity at the destination.
|
||||
type EventCard struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DateStart string `json:"dateStart,omitempty"`
|
||||
DateEnd string `json:"dateEnd,omitempty"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lng float64 `json:"lng,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// POICard represents a point of interest (attraction, restaurant, etc.).
|
||||
type POICard struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
ReviewCount int `json:"reviewCount,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Schedule map[string]string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
// HotelCard represents a hotel offer with booking details.
|
||||
type HotelCard struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Stars int `json:"stars,omitempty"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
ReviewCount int `json:"reviewCount,omitempty"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
TotalPrice float64 `json:"totalPrice"`
|
||||
Rooms int `json:"rooms"`
|
||||
Nights int `json:"nights"`
|
||||
Guests int `json:"guests"`
|
||||
Currency string `json:"currency"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
Amenities []string `json:"amenities,omitempty"`
|
||||
Pros []string `json:"pros,omitempty"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
}
|
||||
|
||||
// TransportOption represents a flight, train, bus, or taxi option.
|
||||
type TransportOption struct {
|
||||
ID string `json:"id"`
|
||||
Mode string `json:"mode"`
|
||||
From string `json:"from"`
|
||||
FromLat float64 `json:"fromLat,omitempty"`
|
||||
FromLng float64 `json:"fromLng,omitempty"`
|
||||
To string `json:"to"`
|
||||
ToLat float64 `json:"toLat,omitempty"`
|
||||
ToLng float64 `json:"toLng,omitempty"`
|
||||
Departure string `json:"departure,omitempty"`
|
||||
Arrival string `json:"arrival,omitempty"`
|
||||
DurationMin int `json:"durationMin"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerUnit float64 `json:"pricePerUnit"`
|
||||
Passengers int `json:"passengers"`
|
||||
Currency string `json:"currency"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
Airline string `json:"airline,omitempty"`
|
||||
FlightNum string `json:"flightNum,omitempty"`
|
||||
Stops int `json:"stops,omitempty"`
|
||||
}
|
||||
|
||||
// ItineraryItem is a single slot in a day's plan.
|
||||
type ItineraryItem struct {
|
||||
RefType string `json:"refType"`
|
||||
RefID string `json:"refId"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
// ItineraryDay groups items for a single day.
|
||||
type ItineraryDay struct {
|
||||
Date string `json:"date"`
|
||||
Items []ItineraryItem `json:"items"`
|
||||
}
|
||||
|
||||
// MapPoint is a point rendered on the travel map widget.
|
||||
type MapPoint struct {
|
||||
ID string `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Layer string `json:"layer"`
|
||||
}
|
||||
|
||||
// TripDraft is the server-side state of an in-progress trip planning session.
|
||||
type TripDraft struct {
|
||||
ID string `json:"id"`
|
||||
Brief *TripBrief `json:"brief"`
|
||||
Phase string `json:"phase"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Candidates TripCandidates `json:"candidates"`
|
||||
Selected TripSelected `json:"selected"`
|
||||
Context *TravelContext `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
// TripCandidates holds all discovered options before user selection.
|
||||
type TripCandidates struct {
|
||||
Events []EventCard `json:"events"`
|
||||
POIs []POICard `json:"pois"`
|
||||
Hotels []HotelCard `json:"hotels"`
|
||||
Transport []TransportOption `json:"transport"`
|
||||
}
|
||||
|
||||
// TripSelected holds user-chosen items forming the final itinerary.
|
||||
type TripSelected struct {
|
||||
Itinerary []ItineraryDay `json:"itinerary"`
|
||||
Hotels []HotelCard `json:"hotels"`
|
||||
Transport []TransportOption `json:"transport"`
|
||||
}
|
||||
|
||||
// BudgetBreakdown shows cost allocation across categories.
|
||||
type BudgetBreakdown struct {
|
||||
Total float64 `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
Travelers int `json:"travelers"`
|
||||
PerPerson float64 `json:"perPerson"`
|
||||
Transport float64 `json:"transport"`
|
||||
Hotels float64 `json:"hotels"`
|
||||
Activities float64 `json:"activities"`
|
||||
Food float64 `json:"food"`
|
||||
Other float64 `json:"other"`
|
||||
Remaining float64 `json:"remaining"`
|
||||
}
|
||||
|
||||
// ClarifyingQuestion is a question the planner asks the user.
|
||||
type ClarifyingQuestion struct {
|
||||
Field string `json:"field"`
|
||||
Question string `json:"question"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
}
|
||||
|
||||
// TravelAction represents a user action sent back from the UI.
|
||||
type TravelAction struct {
|
||||
Kind string `json:"kind"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -19,6 +18,9 @@ type HeatmapService struct {
|
||||
config HeatmapConfig
|
||||
}
|
||||
|
||||
// HeatmapConfig configures the heatmap service.
|
||||
// Встроенные провайдеры (без ключа): moex → MOEX ISS, crypto → CoinGecko, forex → ЦБ РФ.
|
||||
// DataProviderURL — опционально для своих рынков; GET с параметрами market, range.
|
||||
type HeatmapConfig struct {
|
||||
DataProviderURL string
|
||||
CacheTTL time.Duration
|
||||
@@ -59,6 +61,7 @@ type Sector struct {
|
||||
MarketCap float64 `json:"marketCap"`
|
||||
Volume float64 `json:"volume"`
|
||||
TickerCount int `json:"tickerCount"`
|
||||
Tickers []TickerData `json:"tickers,omitempty"`
|
||||
TopGainers []TickerData `json:"topGainers,omitempty"`
|
||||
TopLosers []TickerData `json:"topLosers,omitempty"`
|
||||
Color string `json:"color"`
|
||||
@@ -199,137 +202,72 @@ func (s *HeatmapService) GetSectorHeatmap(ctx context.Context, market, sector, t
|
||||
}
|
||||
|
||||
func (s *HeatmapService) fetchMarketData(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
|
||||
heatmap := s.generateMockMarketData(market)
|
||||
market = strings.ToLower(strings.TrimSpace(market))
|
||||
var heatmap *MarketHeatmap
|
||||
var err error
|
||||
switch market {
|
||||
case "moex":
|
||||
heatmap, err = s.fetchMOEX(ctx, timeRange)
|
||||
case "crypto":
|
||||
heatmap, err = s.fetchCoinGecko(ctx, timeRange)
|
||||
case "forex":
|
||||
heatmap, err = s.fetchForexCBR(ctx, timeRange)
|
||||
default:
|
||||
if s.config.DataProviderURL != "" {
|
||||
heatmap, err = s.fetchFromProvider(ctx, market, timeRange)
|
||||
} else {
|
||||
return nil, fmt.Errorf("неизвестный рынок %q и FINANCE_DATA_PROVIDER_URL не задан", market)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
heatmap.TimeRange = timeRange
|
||||
|
||||
heatmap.UpdatedAt = time.Now()
|
||||
if len(heatmap.Colorscale.Colors) == 0 {
|
||||
heatmap.Colorscale = DefaultColorscale
|
||||
}
|
||||
s.fillSectorTickers(heatmap)
|
||||
return heatmap, nil
|
||||
}
|
||||
|
||||
func (s *HeatmapService) generateMockMarketData(market string) *MarketHeatmap {
|
||||
sectors := []struct {
|
||||
name string
|
||||
tickers []struct{ symbol, name string }
|
||||
}{
|
||||
{"Technology", []struct{ symbol, name string }{
|
||||
{"AAPL", "Apple Inc."},
|
||||
{"MSFT", "Microsoft Corp."},
|
||||
{"GOOGL", "Alphabet Inc."},
|
||||
{"AMZN", "Amazon.com Inc."},
|
||||
{"META", "Meta Platforms"},
|
||||
{"NVDA", "NVIDIA Corp."},
|
||||
{"TSLA", "Tesla Inc."},
|
||||
}},
|
||||
{"Healthcare", []struct{ symbol, name string }{
|
||||
{"JNJ", "Johnson & Johnson"},
|
||||
{"UNH", "UnitedHealth Group"},
|
||||
{"PFE", "Pfizer Inc."},
|
||||
{"MRK", "Merck & Co."},
|
||||
{"ABBV", "AbbVie Inc."},
|
||||
}},
|
||||
{"Finance", []struct{ symbol, name string }{
|
||||
{"JPM", "JPMorgan Chase"},
|
||||
{"BAC", "Bank of America"},
|
||||
{"WFC", "Wells Fargo"},
|
||||
{"GS", "Goldman Sachs"},
|
||||
{"MS", "Morgan Stanley"},
|
||||
}},
|
||||
{"Energy", []struct{ symbol, name string }{
|
||||
{"XOM", "Exxon Mobil"},
|
||||
{"CVX", "Chevron Corp."},
|
||||
{"COP", "ConocoPhillips"},
|
||||
{"SLB", "Schlumberger"},
|
||||
}},
|
||||
{"Consumer", []struct{ symbol, name string }{
|
||||
{"WMT", "Walmart Inc."},
|
||||
{"PG", "Procter & Gamble"},
|
||||
{"KO", "Coca-Cola Co."},
|
||||
{"PEP", "PepsiCo Inc."},
|
||||
{"COST", "Costco Wholesale"},
|
||||
}},
|
||||
func (s *HeatmapService) fetchFromProvider(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
|
||||
u := strings.TrimSuffix(s.config.DataProviderURL, "/")
|
||||
reqURL := fmt.Sprintf("%s?market=%s&range=%s", u, market, timeRange)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("provider returned %d", resp.StatusCode)
|
||||
}
|
||||
var heatmap MarketHeatmap
|
||||
if err := json.NewDecoder(resp.Body).Decode(&heatmap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(heatmap.Tickers) == 0 && len(heatmap.Sectors) == 0 {
|
||||
return nil, fmt.Errorf("provider returned empty data")
|
||||
}
|
||||
return &heatmap, nil
|
||||
}
|
||||
|
||||
allTickers := make([]TickerData, 0)
|
||||
allSectors := make([]Sector, 0)
|
||||
|
||||
for _, sec := range sectors {
|
||||
sectorTickers := make([]TickerData, 0)
|
||||
sectorChange := 0.0
|
||||
|
||||
for _, t := range sec.tickers {
|
||||
change := (randomFloat(-5, 5))
|
||||
price := randomFloat(50, 500)
|
||||
marketCap := randomFloat(50e9, 3000e9)
|
||||
volume := randomFloat(1e6, 100e6)
|
||||
|
||||
ticker := TickerData{
|
||||
Symbol: t.symbol,
|
||||
Name: t.name,
|
||||
Price: price,
|
||||
Change: price * change / 100,
|
||||
ChangePercent: change,
|
||||
Volume: volume,
|
||||
MarketCap: marketCap,
|
||||
Sector: sec.name,
|
||||
Color: getColorForChange(change),
|
||||
Size: math.Log10(marketCap) * 10,
|
||||
func (s *HeatmapService) fillSectorTickers(heatmap *MarketHeatmap) {
|
||||
for i := range heatmap.Sectors {
|
||||
sec := &heatmap.Sectors[i]
|
||||
if len(sec.Tickers) > 0 {
|
||||
continue
|
||||
}
|
||||
for _, t := range heatmap.Tickers {
|
||||
if strings.EqualFold(t.Sector, sec.Name) {
|
||||
sec.Tickers = append(sec.Tickers, t)
|
||||
}
|
||||
|
||||
sectorTickers = append(sectorTickers, ticker)
|
||||
sectorChange += change
|
||||
}
|
||||
|
||||
if len(sectorTickers) > 0 {
|
||||
sectorChange /= float64(len(sectorTickers))
|
||||
}
|
||||
|
||||
sort.Slice(sectorTickers, func(i, j int) bool {
|
||||
return sectorTickers[i].ChangePercent > sectorTickers[j].ChangePercent
|
||||
})
|
||||
|
||||
var topGainers, topLosers []TickerData
|
||||
if len(sectorTickers) >= 2 {
|
||||
topGainers = sectorTickers[:2]
|
||||
topLosers = sectorTickers[len(sectorTickers)-2:]
|
||||
}
|
||||
|
||||
sectorMarketCap := 0.0
|
||||
sectorVolume := 0.0
|
||||
for _, t := range sectorTickers {
|
||||
sectorMarketCap += t.MarketCap
|
||||
sectorVolume += t.Volume
|
||||
}
|
||||
|
||||
sector := Sector{
|
||||
ID: strings.ToLower(strings.ReplaceAll(sec.name, " ", "_")),
|
||||
Name: sec.name,
|
||||
Change: sectorChange,
|
||||
MarketCap: sectorMarketCap,
|
||||
Volume: sectorVolume,
|
||||
TickerCount: len(sectorTickers),
|
||||
TopGainers: topGainers,
|
||||
TopLosers: topLosers,
|
||||
Color: getColorForChange(sectorChange),
|
||||
Weight: sectorMarketCap,
|
||||
}
|
||||
|
||||
allSectors = append(allSectors, sector)
|
||||
allTickers = append(allTickers, sectorTickers...)
|
||||
}
|
||||
|
||||
sort.Slice(allTickers, func(i, j int) bool {
|
||||
return allTickers[i].MarketCap > allTickers[j].MarketCap
|
||||
})
|
||||
|
||||
return &MarketHeatmap{
|
||||
ID: market,
|
||||
Title: getMarketTitle(market),
|
||||
Type: HeatmapTreemap,
|
||||
Market: market,
|
||||
Sectors: allSectors,
|
||||
Tickers: allTickers,
|
||||
Summary: *s.calculateSummaryPtr(allTickers),
|
||||
UpdatedAt: time.Now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
sec.TickerCount = len(sec.Tickers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,51 +425,6 @@ type TopMovers struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func getColorForChange(change float64) string {
|
||||
if change >= 5 {
|
||||
return "#22c55e"
|
||||
} else if change >= 3 {
|
||||
return "#4ade80"
|
||||
} else if change >= 1 {
|
||||
return "#86efac"
|
||||
} else if change >= 0 {
|
||||
return "#bbf7d0"
|
||||
} else if change >= -1 {
|
||||
return "#fecaca"
|
||||
} else if change >= -3 {
|
||||
return "#fca5a5"
|
||||
} else if change >= -5 {
|
||||
return "#f87171"
|
||||
}
|
||||
return "#ef4444"
|
||||
}
|
||||
|
||||
func getMarketTitle(market string) string {
|
||||
titles := map[string]string{
|
||||
"sp500": "S&P 500",
|
||||
"nasdaq": "NASDAQ",
|
||||
"dow": "Dow Jones",
|
||||
"moex": "MOEX",
|
||||
"crypto": "Cryptocurrency",
|
||||
"forex": "Forex",
|
||||
"commodities": "Commodities",
|
||||
}
|
||||
if title, ok := titles[strings.ToLower(market)]; ok {
|
||||
return title
|
||||
}
|
||||
return market
|
||||
}
|
||||
|
||||
var rng uint64 = uint64(time.Now().UnixNano())
|
||||
|
||||
func randomFloat(min, max float64) float64 {
|
||||
rng ^= rng << 13
|
||||
rng ^= rng >> 17
|
||||
rng ^= rng << 5
|
||||
f := float64(rng) / float64(1<<64)
|
||||
return min + f*(max-min)
|
||||
}
|
||||
|
||||
func (h *MarketHeatmap) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(h)
|
||||
}
|
||||
|
||||
406
backend/internal/finance/providers.go
Normal file
406
backend/internal/finance/providers.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
moexISSBase = "https://iss.moex.com/iss"
|
||||
coinGeckoBase = "https://api.coingecko.com/api/v3"
|
||||
)
|
||||
|
||||
// fetchMOEX получает данные с Московской биржи (ISS API, бесплатно, без регистрации).
|
||||
// Документация: https://iss.moex.com/iss/reference/
|
||||
func (s *HeatmapService) fetchMOEX(ctx context.Context, _ string) (*MarketHeatmap, error) {
|
||||
// Основной режим акций Т+2, boardgroup 57
|
||||
url := moexISSBase + "/engines/stock/markets/shares/boardgroups/57/securities.json?limit=100"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("moex request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("moex returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Securities struct {
|
||||
Columns []string `json:"columns"`
|
||||
Data [][]interface{} `json:"data"`
|
||||
} `json:"securities"`
|
||||
Marketdata struct {
|
||||
Columns []string `json:"columns"`
|
||||
Data [][]interface{} `json:"data"`
|
||||
} `json:"marketdata"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("moex decode: %w", err)
|
||||
}
|
||||
|
||||
colIdx := func(cols []string, name string) int {
|
||||
for i, c := range cols {
|
||||
if c == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
getStr := func(row []interface{}, idx int) string {
|
||||
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := row[idx].(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
getFloat := func(row []interface{}, idx int) float64 {
|
||||
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := row[idx].(type) {
|
||||
case float64:
|
||||
return v
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(v, 64)
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
secCols := raw.Securities.Columns
|
||||
iSECID := colIdx(secCols, "SECID")
|
||||
iSHORTNAME := colIdx(secCols, "SHORTNAME")
|
||||
iSECNAME := colIdx(secCols, "SECNAME")
|
||||
iPREVPRICE := colIdx(secCols, "PREVPRICE")
|
||||
iPREVWAPRICE := colIdx(secCols, "PREVWAPRICE")
|
||||
iISSUESIZE := colIdx(secCols, "ISSUESIZE")
|
||||
iBOARDID := colIdx(secCols, "BOARDID")
|
||||
|
||||
// Только акции TQBR (основной режим), без паёв и прочего
|
||||
tickers := make([]TickerData, 0, len(raw.Securities.Data))
|
||||
marketdataBySec := make(map[string]struct{ Last, LastChangePrc float64 })
|
||||
mdCols := raw.Marketdata.Columns
|
||||
iMD_SECID := colIdx(mdCols, "SECID")
|
||||
iMD_LAST := colIdx(mdCols, "LAST")
|
||||
iMD_LASTCHANGEPRC := colIdx(mdCols, "LASTCHANGEPRC")
|
||||
for _, row := range raw.Marketdata.Data {
|
||||
sid := getStr(row, iMD_SECID)
|
||||
if sid != "" {
|
||||
marketdataBySec[sid] = struct{ Last, LastChangePrc float64 }{
|
||||
Last: getFloat(row, iMD_LAST),
|
||||
LastChangePrc: getFloat(row, iMD_LASTCHANGEPRC),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range raw.Securities.Data {
|
||||
board := getStr(row, iBOARDID)
|
||||
if board != "TQBR" {
|
||||
continue
|
||||
}
|
||||
secID := getStr(row, iSECID)
|
||||
prevPrice := getFloat(row, iPREVPRICE)
|
||||
prevWAPrice := getFloat(row, iPREVWAPRICE)
|
||||
if prevPrice <= 0 {
|
||||
prevPrice = prevWAPrice
|
||||
}
|
||||
if prevPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
issuesize := getFloat(row, iISSUESIZE)
|
||||
marketCap := prevPrice * issuesize
|
||||
price := prevPrice
|
||||
changePct := 0.0
|
||||
if md, ok := marketdataBySec[secID]; ok && md.Last > 0 {
|
||||
price = md.Last
|
||||
changePct = md.LastChangePrc
|
||||
} else if prevWAPrice > 0 && prevWAPrice != prevPrice {
|
||||
changePct = (prevWAPrice - prevPrice) / prevPrice * 100
|
||||
price = prevWAPrice
|
||||
}
|
||||
name := getStr(row, iSECNAME)
|
||||
if name == "" {
|
||||
name = getStr(row, iSHORTNAME)
|
||||
}
|
||||
tickers = append(tickers, TickerData{
|
||||
Symbol: secID,
|
||||
Name: name,
|
||||
Price: price,
|
||||
Change: price - prevPrice,
|
||||
ChangePercent: changePct,
|
||||
MarketCap: marketCap,
|
||||
Volume: 0,
|
||||
Sector: "Акции",
|
||||
Color: colorForChange(changePct),
|
||||
Size: marketCap,
|
||||
PrevClose: prevPrice,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return nil, fmt.Errorf("moex: no tickers")
|
||||
}
|
||||
|
||||
sortTickersByMarketCap(tickers)
|
||||
summary := s.calculateSummaryPtr(tickers)
|
||||
sector := Sector{
|
||||
ID: "akcii",
|
||||
Name: "Акции",
|
||||
Change: summary.AverageChange,
|
||||
MarketCap: summary.TotalMarketCap,
|
||||
Volume: summary.TotalVolume,
|
||||
TickerCount: len(tickers),
|
||||
Tickers: tickers,
|
||||
Color: colorForChange(summary.AverageChange),
|
||||
Weight: summary.TotalMarketCap,
|
||||
}
|
||||
if len(tickers) >= 5 {
|
||||
gainers := make([]TickerData, len(tickers))
|
||||
copy(gainers, tickers)
|
||||
sortTickersByChangeDesc(gainers)
|
||||
sector.TopGainers = gainers[:minInt(3, len(gainers))]
|
||||
losers := make([]TickerData, len(tickers))
|
||||
copy(losers, tickers)
|
||||
sortTickersByChangeAsc(losers)
|
||||
sector.TopLosers = losers[:minInt(3, len(losers))]
|
||||
}
|
||||
|
||||
return &MarketHeatmap{
|
||||
ID: "moex",
|
||||
Title: "MOEX",
|
||||
Type: HeatmapTreemap,
|
||||
Market: "moex",
|
||||
Sectors: []Sector{sector},
|
||||
Tickers: tickers,
|
||||
Summary: *summary,
|
||||
UpdatedAt: now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchCoinGecko получает топ криптовалют с CoinGecko (бесплатно, без API ключа).
|
||||
// Документация: https://www.coingecko.com/en/api
|
||||
func (s *HeatmapService) fetchCoinGecko(ctx context.Context, _ string) (*MarketHeatmap, error) {
|
||||
url := coinGeckoBase + "/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=50&page=1"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("coingecko request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("coingecko returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var list []struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
MarketCap float64 `json:"market_cap"`
|
||||
PriceChange24h *float64 `json:"price_change_percentage_24h"`
|
||||
TotalVolume float64 `json:"total_volume"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return nil, fmt.Errorf("coingecko decode: %w", err)
|
||||
}
|
||||
|
||||
tickers := make([]TickerData, 0, len(list))
|
||||
for _, c := range list {
|
||||
chg := 0.0
|
||||
if c.PriceChange24h != nil {
|
||||
chg = *c.PriceChange24h
|
||||
}
|
||||
sym := strings.ToUpper(c.Symbol)
|
||||
tickers = append(tickers, TickerData{
|
||||
Symbol: sym,
|
||||
Name: c.Name,
|
||||
Price: c.CurrentPrice,
|
||||
ChangePercent: chg,
|
||||
Change: c.CurrentPrice * chg / 100,
|
||||
MarketCap: c.MarketCap,
|
||||
Volume: c.TotalVolume,
|
||||
Sector: "Криптовалюты",
|
||||
Color: colorForChange(chg),
|
||||
Size: c.MarketCap,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return nil, fmt.Errorf("coingecko: no tickers")
|
||||
}
|
||||
|
||||
summary := s.calculateSummaryPtr(tickers)
|
||||
sector := Sector{
|
||||
ID: "crypto",
|
||||
Name: "Криптовалюты",
|
||||
Change: summary.AverageChange,
|
||||
MarketCap: summary.TotalMarketCap,
|
||||
Volume: summary.TotalVolume,
|
||||
TickerCount: len(tickers),
|
||||
Tickers: tickers,
|
||||
Color: colorForChange(summary.AverageChange),
|
||||
Weight: summary.TotalMarketCap,
|
||||
}
|
||||
byGain := make([]TickerData, len(tickers))
|
||||
copy(byGain, tickers)
|
||||
sortTickersByChangeDesc(byGain)
|
||||
if len(byGain) >= 3 {
|
||||
sector.TopGainers = byGain[:3]
|
||||
}
|
||||
byLoss := make([]TickerData, len(tickers))
|
||||
copy(byLoss, tickers)
|
||||
sortTickersByChangeAsc(byLoss)
|
||||
if len(byLoss) >= 3 {
|
||||
sector.TopLosers = byLoss[:3]
|
||||
}
|
||||
|
||||
return &MarketHeatmap{
|
||||
ID: "crypto",
|
||||
Title: "Криптовалюты",
|
||||
Type: HeatmapTreemap,
|
||||
Market: "crypto",
|
||||
Sectors: []Sector{sector},
|
||||
Tickers: tickers,
|
||||
Summary: *summary,
|
||||
UpdatedAt: now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchForexCBR получает курсы валют ЦБ РФ (бесплатно, без ключа).
|
||||
func (s *HeatmapService) fetchForexCBR(ctx context.Context, _ string) (*MarketHeatmap, error) {
|
||||
url := "https://www.cbr-xml-daily.ru/daily_json.js"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cbr request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("cbr returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Valute map[string]struct {
|
||||
CharCode string `json:"CharCode"`
|
||||
Name string `json:"Name"`
|
||||
Value float64 `json:"Value"`
|
||||
Previous float64 `json:"Previous"`
|
||||
} `json:"Valute"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("cbr decode: %w", err)
|
||||
}
|
||||
|
||||
tickers := make([]TickerData, 0, len(raw.Valute))
|
||||
for _, v := range raw.Valute {
|
||||
if v.Previous <= 0 {
|
||||
continue
|
||||
}
|
||||
chg := (v.Value - v.Previous) / v.Previous * 100
|
||||
tickers = append(tickers, TickerData{
|
||||
Symbol: v.CharCode,
|
||||
Name: v.Name,
|
||||
Price: v.Value,
|
||||
PrevClose: v.Previous,
|
||||
Change: v.Value - v.Previous,
|
||||
ChangePercent: chg,
|
||||
Sector: "Валюты",
|
||||
Color: colorForChange(chg),
|
||||
})
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return nil, fmt.Errorf("cbr: no rates")
|
||||
}
|
||||
|
||||
summary := s.calculateSummaryPtr(tickers)
|
||||
sector := Sector{
|
||||
ID: "forex",
|
||||
Name: "Валюты",
|
||||
Change: summary.AverageChange,
|
||||
TickerCount: len(tickers),
|
||||
Tickers: tickers,
|
||||
Color: colorForChange(summary.AverageChange),
|
||||
}
|
||||
return &MarketHeatmap{
|
||||
ID: "forex",
|
||||
Title: "Валюты (ЦБ РФ)",
|
||||
Type: HeatmapTreemap,
|
||||
Market: "forex",
|
||||
Sectors: []Sector{sector},
|
||||
Tickers: tickers,
|
||||
Summary: *summary,
|
||||
UpdatedAt: now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func colorForChange(change float64) string {
|
||||
if change >= 5 {
|
||||
return "#22c55e"
|
||||
}
|
||||
if change >= 3 {
|
||||
return "#4ade80"
|
||||
}
|
||||
if change >= 1 {
|
||||
return "#86efac"
|
||||
}
|
||||
if change >= 0 {
|
||||
return "#bbf7d0"
|
||||
}
|
||||
if change >= -1 {
|
||||
return "#fecaca"
|
||||
}
|
||||
if change >= -3 {
|
||||
return "#fca5a5"
|
||||
}
|
||||
if change >= -5 {
|
||||
return "#f87171"
|
||||
}
|
||||
return "#ef4444"
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now() }
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sortTickersByMarketCap(t []TickerData) {
|
||||
sort.Slice(t, func(i, j int) bool { return t[i].MarketCap > t[j].MarketCap })
|
||||
}
|
||||
func sortTickersByChangeDesc(t []TickerData) {
|
||||
sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent > t[j].ChangePercent })
|
||||
}
|
||||
func sortTickersByChangeAsc(t []TickerData) {
|
||||
sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent < t[j].ChangePercent })
|
||||
}
|
||||
368
backend/internal/travel/amadeus.go
Normal file
368
backend/internal/travel/amadeus.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AmadeusClient struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.RWMutex
|
||||
}
|
||||
|
||||
type AmadeusConfig struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewAmadeusClient(cfg AmadeusConfig) *AmadeusClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://test.api.amadeus.com"
|
||||
}
|
||||
|
||||
return &AmadeusClient{
|
||||
apiKey: cfg.APIKey,
|
||||
apiSecret: cfg.APISecret,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) getAccessToken(ctx context.Context) (string, error) {
|
||||
c.tokenMu.RLock()
|
||||
if c.accessToken != "" && time.Now().Before(c.tokenExpiry) {
|
||||
token := c.accessToken
|
||||
c.tokenMu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
c.tokenMu.RUnlock()
|
||||
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
|
||||
if c.accessToken != "" && time.Now().Before(c.tokenExpiry) {
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "client_credentials")
|
||||
data.Set("client_id", c.apiKey)
|
||||
data.Set("client_secret", c.apiSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/security/oauth2/token", bytes.NewBufferString(data.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("token request failed: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second)
|
||||
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) doRequest(ctx context.Context, method, path string, query url.Values, body interface{}) ([]byte, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path
|
||||
if len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("originLocationCode", req.Origin)
|
||||
query.Set("destinationLocationCode", req.Destination)
|
||||
query.Set("departureDate", req.DepartureDate)
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
|
||||
if req.ReturnDate != "" {
|
||||
query.Set("returnDate", req.ReturnDate)
|
||||
}
|
||||
if req.Children > 0 {
|
||||
query.Set("children", fmt.Sprintf("%d", req.Children))
|
||||
}
|
||||
if req.CabinClass != "" {
|
||||
query.Set("travelClass", req.CabinClass)
|
||||
}
|
||||
if req.MaxPrice > 0 {
|
||||
query.Set("maxPrice", fmt.Sprintf("%d", req.MaxPrice))
|
||||
}
|
||||
if req.Currency != "" {
|
||||
query.Set("currencyCode", req.Currency)
|
||||
}
|
||||
query.Set("max", "10")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/shopping/flight-offers", query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []amadeusFlightOffer `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
offer := c.convertFlightOffer(data)
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("cityCode", req.CityCode)
|
||||
query.Set("checkInDate", req.CheckIn)
|
||||
query.Set("checkOutDate", req.CheckOut)
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
|
||||
if req.Rooms > 0 {
|
||||
query.Set("roomQuantity", fmt.Sprintf("%d", req.Rooms))
|
||||
}
|
||||
if req.Currency != "" {
|
||||
query.Set("currency", req.Currency)
|
||||
}
|
||||
if req.Rating > 0 {
|
||||
query.Set("ratings", fmt.Sprintf("%d", req.Rating))
|
||||
}
|
||||
query.Set("bestRateOnly", "true")
|
||||
|
||||
hotelListBody, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations/hotels/by-city", query, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch hotel list: %w", err)
|
||||
}
|
||||
|
||||
var hotelListResp struct {
|
||||
Data []struct {
|
||||
HotelID string `json:"hotelId"`
|
||||
Name string `json:"name"`
|
||||
GeoCode struct {
|
||||
Lat float64 `json:"latitude"`
|
||||
Lng float64 `json:"longitude"`
|
||||
} `json:"geoCode"`
|
||||
Address struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"address"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(hotelListBody, &hotelListResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotel list: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]HotelOffer, 0)
|
||||
for i, h := range hotelListResp.Data {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
offer := HotelOffer{
|
||||
ID: h.HotelID,
|
||||
Name: h.Name,
|
||||
Lat: h.GeoCode.Lat,
|
||||
Lng: h.GeoCode.Lng,
|
||||
Currency: req.Currency,
|
||||
CheckIn: req.CheckIn,
|
||||
CheckOut: req.CheckOut,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) GetAirportByCode(ctx context.Context, code string) (*GeoLocation, error) {
|
||||
query := url.Values{}
|
||||
query.Set("subType", "AIRPORT")
|
||||
query.Set("keyword", code)
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations", query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
Name string `json:"name"`
|
||||
GeoCode struct {
|
||||
Lat float64 `json:"latitude"`
|
||||
Lng float64 `json:"longitude"`
|
||||
} `json:"geoCode"`
|
||||
Address struct {
|
||||
CountryName string `json:"countryName"`
|
||||
} `json:"address"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response.Data) == 0 {
|
||||
return nil, fmt.Errorf("airport not found: %s", code)
|
||||
}
|
||||
|
||||
loc := response.Data[0]
|
||||
return &GeoLocation{
|
||||
Lat: loc.GeoCode.Lat,
|
||||
Lng: loc.GeoCode.Lng,
|
||||
Name: loc.Name,
|
||||
Country: loc.Address.CountryName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type amadeusFlightOffer struct {
|
||||
ID string `json:"id"`
|
||||
Itineraries []struct {
|
||||
Duration string `json:"duration"`
|
||||
Segments []struct {
|
||||
Departure struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
At string `json:"at"`
|
||||
} `json:"departure"`
|
||||
Arrival struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
At string `json:"at"`
|
||||
} `json:"arrival"`
|
||||
CarrierCode string `json:"carrierCode"`
|
||||
Number string `json:"number"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"segments"`
|
||||
} `json:"itineraries"`
|
||||
Price struct {
|
||||
Total string `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"price"`
|
||||
TravelerPricings []struct {
|
||||
FareDetailsBySegment []struct {
|
||||
Cabin string `json:"cabin"`
|
||||
} `json:"fareDetailsBySegment"`
|
||||
} `json:"travelerPricings"`
|
||||
NumberOfBookableSeats int `json:"numberOfBookableSeats"`
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) convertFlightOffer(data amadeusFlightOffer) FlightOffer {
|
||||
offer := FlightOffer{
|
||||
ID: data.ID,
|
||||
SeatsAvailable: data.NumberOfBookableSeats,
|
||||
}
|
||||
|
||||
if len(data.Itineraries) > 0 && len(data.Itineraries[0].Segments) > 0 {
|
||||
itin := data.Itineraries[0]
|
||||
firstSeg := itin.Segments[0]
|
||||
lastSeg := itin.Segments[len(itin.Segments)-1]
|
||||
|
||||
offer.DepartureAirport = firstSeg.Departure.IATACode
|
||||
offer.DepartureTime = firstSeg.Departure.At
|
||||
offer.ArrivalAirport = lastSeg.Arrival.IATACode
|
||||
offer.ArrivalTime = lastSeg.Arrival.At
|
||||
offer.Airline = firstSeg.CarrierCode
|
||||
offer.FlightNumber = firstSeg.CarrierCode + firstSeg.Number
|
||||
offer.Stops = len(itin.Segments) - 1
|
||||
offer.Duration = parseDuration(itin.Duration)
|
||||
}
|
||||
|
||||
if price, err := parseFloat(data.Price.Total); err == nil {
|
||||
offer.Price = price
|
||||
}
|
||||
offer.Currency = data.Price.Currency
|
||||
|
||||
if len(data.TravelerPricings) > 0 && len(data.TravelerPricings[0].FareDetailsBySegment) > 0 {
|
||||
offer.CabinClass = data.TravelerPricings[0].FareDetailsBySegment[0].Cabin
|
||||
}
|
||||
|
||||
return offer
|
||||
}
|
||||
|
||||
func parseDuration(d string) int {
|
||||
var hours, minutes int
|
||||
fmt.Sscanf(d, "PT%dH%dM", &hours, &minutes)
|
||||
return hours*60 + minutes
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, error) {
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
57
backend/internal/travel/llm_client.go
Normal file
57
backend/internal/travel/llm_client.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type LLMClientAdapter struct {
|
||||
client llm.Client
|
||||
}
|
||||
|
||||
func NewLLMClientAdapter(client llm.Client) *LLMClientAdapter {
|
||||
return &LLMClientAdapter{client: client}
|
||||
}
|
||||
|
||||
func (a *LLMClientAdapter) StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error {
|
||||
llmMessages := make([]llm.Message, len(messages))
|
||||
for i, m := range messages {
|
||||
var role llm.Role
|
||||
switch m.Role {
|
||||
case "system":
|
||||
role = llm.RoleSystem
|
||||
case "user":
|
||||
role = llm.RoleUser
|
||||
case "assistant":
|
||||
role = llm.RoleAssistant
|
||||
default:
|
||||
role = llm.RoleUser
|
||||
}
|
||||
llmMessages[i] = llm.Message{
|
||||
Role: role,
|
||||
Content: m.Content,
|
||||
}
|
||||
}
|
||||
|
||||
req := llm.StreamRequest{
|
||||
Messages: llmMessages,
|
||||
Options: llm.StreamOptions{
|
||||
MaxTokens: 4096,
|
||||
Temperature: 0.7,
|
||||
},
|
||||
}
|
||||
|
||||
ch, err := a.client.StreamText(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for chunk := range ch {
|
||||
if chunk.ContentChunk != "" {
|
||||
onChunk(chunk.ContentChunk)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
335
backend/internal/travel/openroute.go
Normal file
335
backend/internal/travel/openroute.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OpenRouteClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) HasAPIKey() bool {
|
||||
return c.apiKey != ""
|
||||
}
|
||||
|
||||
type OpenRouteConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewOpenRouteClient(cfg OpenRouteConfig) *OpenRouteClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openrouteservice.org"
|
||||
}
|
||||
|
||||
return &OpenRouteClient{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) doRequest(ctx context.Context, method, path string, query url.Values) ([]byte, error) {
|
||||
fullURL := c.baseURL + path
|
||||
if len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) GetDirections(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("at least 2 points required")
|
||||
}
|
||||
|
||||
if profile == "" {
|
||||
profile = "driving-car"
|
||||
}
|
||||
|
||||
coords := make([]string, len(points))
|
||||
for i, p := range points {
|
||||
coords[i] = fmt.Sprintf("%f,%f", p.Lng, p.Lat)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("start", coords[0])
|
||||
query.Set("end", coords[len(coords)-1])
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/directions/"+profile, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
} `json:"geometry"`
|
||||
Properties struct {
|
||||
Summary struct {
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
} `json:"summary"`
|
||||
Segments []struct {
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type int `json:"type"`
|
||||
} `json:"steps"`
|
||||
} `json:"segments"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal directions: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("no route found")
|
||||
}
|
||||
|
||||
feature := response.Features[0]
|
||||
direction := &RouteDirection{
|
||||
Geometry: RouteGeometry{
|
||||
Coordinates: feature.Geometry.Coordinates,
|
||||
Type: feature.Geometry.Type,
|
||||
},
|
||||
Distance: feature.Properties.Summary.Distance,
|
||||
Duration: feature.Properties.Summary.Duration,
|
||||
}
|
||||
|
||||
for _, seg := range feature.Properties.Segments {
|
||||
for _, step := range seg.Steps {
|
||||
direction.Steps = append(direction.Steps, RouteStep{
|
||||
Instruction: step.Instruction,
|
||||
Distance: step.Distance,
|
||||
Duration: step.Duration,
|
||||
Type: fmt.Sprintf("%d", step.Type),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return direction, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("text", query)
|
||||
params.Set("size", "1")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/geocode/search", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
} `json:"geometry"`
|
||||
Properties struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Label string `json:"label"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal geocode: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("location not found: %s", query)
|
||||
}
|
||||
|
||||
feature := response.Features[0]
|
||||
return &GeoLocation{
|
||||
Lng: feature.Geometry.Coordinates[0],
|
||||
Lat: feature.Geometry.Coordinates[1],
|
||||
Name: feature.Properties.Name,
|
||||
Country: feature.Properties.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("point.lat", fmt.Sprintf("%f", lat))
|
||||
params.Set("point.lon", fmt.Sprintf("%f", lng))
|
||||
params.Set("size", "1")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/geocode/reverse", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Properties struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Label string `json:"label"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal reverse geocode: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
|
||||
}
|
||||
|
||||
feature := response.Features[0]
|
||||
return &GeoLocation{
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Name: feature.Properties.Name,
|
||||
Country: feature.Properties.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("request", "pois")
|
||||
params.Set("geometry", fmt.Sprintf(`{"geojson":{"type":"Point","coordinates":[%f,%f]},"buffer":%d}`, req.Lng, req.Lat, req.Radius))
|
||||
|
||||
if len(req.Categories) > 0 {
|
||||
params.Set("filters", fmt.Sprintf(`{"category_ids":[%s]}`, strings.Join(req.Categories, ",")))
|
||||
}
|
||||
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
|
||||
body, err := c.doRequest(ctx, "POST", "/pois", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
} `json:"geometry"`
|
||||
Properties struct {
|
||||
OSMId int64 `json:"osm_id"`
|
||||
Name string `json:"osm_tags.name"`
|
||||
Category struct {
|
||||
ID int `json:"category_id"`
|
||||
Name string `json:"category_name"`
|
||||
} `json:"category"`
|
||||
Distance float64 `json:"distance"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal POI: %w", err)
|
||||
}
|
||||
|
||||
pois := make([]POI, 0, len(response.Features))
|
||||
for _, f := range response.Features {
|
||||
poi := POI{
|
||||
ID: fmt.Sprintf("%d", f.Properties.OSMId),
|
||||
Name: f.Properties.Name,
|
||||
Lng: f.Geometry.Coordinates[0],
|
||||
Lat: f.Geometry.Coordinates[1],
|
||||
Category: f.Properties.Category.Name,
|
||||
Distance: f.Properties.Distance,
|
||||
}
|
||||
pois = append(pois, poi)
|
||||
}
|
||||
|
||||
return pois, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) GetIsochrone(ctx context.Context, lat, lng float64, timeMinutes int, profile string) (*RouteGeometry, error) {
|
||||
if profile == "" {
|
||||
profile = "driving-car"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("locations", fmt.Sprintf("%f,%f", lng, lat))
|
||||
params.Set("range", fmt.Sprintf("%d", timeMinutes*60))
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/isochrones/"+profile, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [][][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
} `json:"geometry"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal isochrone: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("no isochrone found")
|
||||
}
|
||||
|
||||
coords := make([][2]float64, 0)
|
||||
if len(response.Features[0].Geometry.Coordinates) > 0 {
|
||||
coords = response.Features[0].Geometry.Coordinates[0]
|
||||
}
|
||||
|
||||
return &RouteGeometry{
|
||||
Coordinates: coords,
|
||||
Type: response.Features[0].Geometry.Type,
|
||||
}, nil
|
||||
}
|
||||
501
backend/internal/travel/repository.go
Normal file
501
backend/internal/travel/repository.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) InitSchema(ctx context.Context) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
destination VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cover_image TEXT,
|
||||
start_date TIMESTAMP NOT NULL,
|
||||
end_date TIMESTAMP NOT NULL,
|
||||
route JSONB DEFAULT '[]',
|
||||
flights JSONB DEFAULT '[]',
|
||||
hotels JSONB DEFAULT '[]',
|
||||
total_budget DECIMAL(12,2),
|
||||
currency VARCHAR(3) DEFAULT 'RUB',
|
||||
status VARCHAR(20) DEFAULT 'planned',
|
||||
ai_summary TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_start_date ON trips(start_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trip_drafts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID,
|
||||
session_id VARCHAR(255),
|
||||
brief JSONB DEFAULT '{}',
|
||||
candidates JSONB DEFAULT '{}',
|
||||
selected JSONB DEFAULT '{}',
|
||||
phase VARCHAR(50) DEFAULT 'planning',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_drafts_user_id ON trip_drafts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_drafts_session_id ON trip_drafts(session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geocode_cache (
|
||||
query_hash VARCHAR(64) PRIMARY KEY,
|
||||
query_text TEXT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
name TEXT,
|
||||
country TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at);
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateTrip(ctx context.Context, trip *Trip) error {
|
||||
if trip.ID == "" {
|
||||
trip.ID = uuid.New().String()
|
||||
}
|
||||
trip.CreatedAt = time.Now()
|
||||
trip.UpdatedAt = time.Now()
|
||||
|
||||
routeJSON, err := json.Marshal(trip.Route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal route: %w", err)
|
||||
}
|
||||
|
||||
flightsJSON, err := json.Marshal(trip.Flights)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal flights: %w", err)
|
||||
}
|
||||
|
||||
hotelsJSON, err := json.Marshal(trip.Hotels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal hotels: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO trips (
|
||||
id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
`
|
||||
|
||||
_, err = r.db.ExecContext(ctx, query,
|
||||
trip.ID, trip.UserID, trip.Title, trip.Destination, trip.Description, trip.CoverImage,
|
||||
trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON,
|
||||
trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.CreatedAt, trip.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetTrip(ctx context.Context, id string) (*Trip, error) {
|
||||
query := `
|
||||
SELECT id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
FROM trips WHERE id = $1
|
||||
`
|
||||
|
||||
var trip Trip
|
||||
var routeJSON, flightsJSON, hotelsJSON []byte
|
||||
var description, coverImage, aiSummary sql.NullString
|
||||
var totalBudget sql.NullFloat64
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
|
||||
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
|
||||
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
trip.Description = description.String
|
||||
}
|
||||
if coverImage.Valid {
|
||||
trip.CoverImage = coverImage.String
|
||||
}
|
||||
if aiSummary.Valid {
|
||||
trip.AISummary = aiSummary.String
|
||||
}
|
||||
if totalBudget.Valid {
|
||||
trip.TotalBudget = totalBudget.Float64
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal route: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotels: %w", err)
|
||||
}
|
||||
|
||||
return &trip, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTripsByUser(ctx context.Context, userID string, limit, offset int) ([]Trip, error) {
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
FROM trips WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var trips []Trip
|
||||
for rows.Next() {
|
||||
var trip Trip
|
||||
var routeJSON, flightsJSON, hotelsJSON []byte
|
||||
var description, coverImage, aiSummary sql.NullString
|
||||
var totalBudget sql.NullFloat64
|
||||
|
||||
err := rows.Scan(
|
||||
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
|
||||
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
|
||||
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
trip.Description = description.String
|
||||
}
|
||||
if coverImage.Valid {
|
||||
trip.CoverImage = coverImage.String
|
||||
}
|
||||
if aiSummary.Valid {
|
||||
trip.AISummary = aiSummary.String
|
||||
}
|
||||
if totalBudget.Valid {
|
||||
trip.TotalBudget = totalBudget.Float64
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal route: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotels: %w", err)
|
||||
}
|
||||
|
||||
trips = append(trips, trip)
|
||||
}
|
||||
|
||||
return trips, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateTrip(ctx context.Context, trip *Trip) error {
|
||||
trip.UpdatedAt = time.Now()
|
||||
|
||||
routeJSON, err := json.Marshal(trip.Route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal route: %w", err)
|
||||
}
|
||||
|
||||
flightsJSON, err := json.Marshal(trip.Flights)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal flights: %w", err)
|
||||
}
|
||||
|
||||
hotelsJSON, err := json.Marshal(trip.Hotels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal hotels: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE trips SET
|
||||
title = $2, destination = $3, description = $4, cover_image = $5,
|
||||
start_date = $6, end_date = $7, route = $8, flights = $9, hotels = $10,
|
||||
total_budget = $11, currency = $12, status = $13, ai_summary = $14, updated_at = $15
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
_, err = r.db.ExecContext(ctx, query,
|
||||
trip.ID, trip.Title, trip.Destination, trip.Description, trip.CoverImage,
|
||||
trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON,
|
||||
trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteTrip(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM trips WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetTripsByStatus(ctx context.Context, userID string, status TripStatus) ([]Trip, error) {
|
||||
query := `
|
||||
SELECT id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
FROM trips WHERE user_id = $1 AND status = $2
|
||||
ORDER BY start_date ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var trips []Trip
|
||||
for rows.Next() {
|
||||
var trip Trip
|
||||
var routeJSON, flightsJSON, hotelsJSON []byte
|
||||
var description, coverImage, aiSummary sql.NullString
|
||||
var totalBudget sql.NullFloat64
|
||||
|
||||
err := rows.Scan(
|
||||
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
|
||||
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
|
||||
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
trip.Description = description.String
|
||||
}
|
||||
if coverImage.Valid {
|
||||
trip.CoverImage = coverImage.String
|
||||
}
|
||||
if aiSummary.Valid {
|
||||
trip.AISummary = aiSummary.String
|
||||
}
|
||||
if totalBudget.Valid {
|
||||
trip.TotalBudget = totalBudget.Float64
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal route: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotels: %w", err)
|
||||
}
|
||||
|
||||
trips = append(trips, trip)
|
||||
}
|
||||
|
||||
return trips, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountTripsByUser(ctx context.Context, userID string) (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM trips WHERE user_id = $1", userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// --- Trip Draft persistence ---
|
||||
|
||||
type TripDraft struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Brief json.RawMessage `json:"brief"`
|
||||
Candidates json.RawMessage `json:"candidates"`
|
||||
Selected json.RawMessage `json:"selected"`
|
||||
Phase string `json:"phase"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (r *Repository) SaveDraft(ctx context.Context, draft *TripDraft) error {
|
||||
if draft.ID == "" {
|
||||
draft.ID = uuid.New().String()
|
||||
}
|
||||
draft.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
INSERT INTO trip_drafts (id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
brief = EXCLUDED.brief,
|
||||
candidates = EXCLUDED.candidates,
|
||||
selected = EXCLUDED.selected,
|
||||
phase = EXCLUDED.phase,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
draft.ID, draft.UserID, draft.SessionID,
|
||||
draft.Brief, draft.Candidates, draft.Selected,
|
||||
draft.Phase, draft.CreatedAt, draft.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetDraft(ctx context.Context, id string) (*TripDraft, error) {
|
||||
query := `
|
||||
SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at
|
||||
FROM trip_drafts WHERE id = $1
|
||||
`
|
||||
|
||||
var draft TripDraft
|
||||
var userID sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&draft.ID, &userID, &draft.SessionID,
|
||||
&draft.Brief, &draft.Candidates, &draft.Selected,
|
||||
&draft.Phase, &draft.CreatedAt, &draft.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID.Valid {
|
||||
draft.UserID = userID.String
|
||||
}
|
||||
|
||||
return &draft, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetDraftBySession(ctx context.Context, sessionID string) (*TripDraft, error) {
|
||||
query := `
|
||||
SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at
|
||||
FROM trip_drafts WHERE session_id = $1
|
||||
ORDER BY updated_at DESC LIMIT 1
|
||||
`
|
||||
|
||||
var draft TripDraft
|
||||
var userID sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, sessionID).Scan(
|
||||
&draft.ID, &userID, &draft.SessionID,
|
||||
&draft.Brief, &draft.Candidates, &draft.Selected,
|
||||
&draft.Phase, &draft.CreatedAt, &draft.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID.Valid {
|
||||
draft.UserID = userID.String
|
||||
}
|
||||
|
||||
return &draft, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteDraft(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CleanupOldDrafts(ctx context.Context, olderThan time.Duration) error {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE updated_at < $1", cutoff)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Geocode cache ---
|
||||
|
||||
type GeocodeCacheEntry struct {
|
||||
QueryHash string `json:"queryHash"`
|
||||
QueryText string `json:"queryText"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
func (r *Repository) GetCachedGeocode(ctx context.Context, queryHash string) (*GeocodeCacheEntry, error) {
|
||||
query := `
|
||||
SELECT query_hash, query_text, lat, lng, name, country
|
||||
FROM geocode_cache WHERE query_hash = $1
|
||||
`
|
||||
|
||||
var entry GeocodeCacheEntry
|
||||
var name, country sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, queryHash).Scan(
|
||||
&entry.QueryHash, &entry.QueryText, &entry.Lat, &entry.Lng, &name, &country,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name.Valid {
|
||||
entry.Name = name.String
|
||||
}
|
||||
if country.Valid {
|
||||
entry.Country = country.String
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveGeocodeCache(ctx context.Context, entry *GeocodeCacheEntry) error {
|
||||
query := `
|
||||
INSERT INTO geocode_cache (query_hash, query_text, lat, lng, name, country, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (query_hash) DO UPDATE SET
|
||||
lat = EXCLUDED.lat, lng = EXCLUDED.lng,
|
||||
name = EXCLUDED.name, country = EXCLUDED.country
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
entry.QueryHash, entry.QueryText, entry.Lat, entry.Lng, entry.Name, entry.Country,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CleanupOldGeocodeCache(ctx context.Context, olderThan time.Duration) error {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM geocode_cache WHERE created_at < $1", cutoff)
|
||||
return err
|
||||
}
|
||||
660
backend/internal/travel/service.go
Normal file
660
backend/internal/travel/service.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
amadeus *AmadeusClient
|
||||
openRoute *OpenRouteClient
|
||||
travelPayouts *TravelPayoutsClient
|
||||
twoGIS *TwoGISClient
|
||||
llmClient LLMClient
|
||||
useRussianAPIs bool
|
||||
}
|
||||
|
||||
type LLMClient interface {
|
||||
StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ServiceConfig struct {
|
||||
Repository *Repository
|
||||
AmadeusConfig AmadeusConfig
|
||||
OpenRouteConfig OpenRouteConfig
|
||||
TravelPayoutsConfig TravelPayoutsConfig
|
||||
TwoGISConfig TwoGISConfig
|
||||
LLMClient LLMClient
|
||||
UseRussianAPIs bool
|
||||
}
|
||||
|
||||
func NewService(cfg ServiceConfig) *Service {
|
||||
return &Service{
|
||||
repo: cfg.Repository,
|
||||
amadeus: NewAmadeusClient(cfg.AmadeusConfig),
|
||||
openRoute: NewOpenRouteClient(cfg.OpenRouteConfig),
|
||||
travelPayouts: NewTravelPayoutsClient(cfg.TravelPayoutsConfig),
|
||||
twoGIS: NewTwoGISClient(cfg.TwoGISConfig),
|
||||
llmClient: cfg.LLMClient,
|
||||
useRussianAPIs: cfg.UseRussianAPIs,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PlanTrip(ctx context.Context, req TravelPlanRequest, writer io.Writer) error {
|
||||
systemPrompt := `Ты - AI планировщик путешествий GooSeek.
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО: Если пользователь упоминает ЛЮБОЕ место, город, страну, регион или маршрут - ты ОБЯЗАН сразу построить маршрут с JSON. НЕ задавай уточняющих вопросов, НЕ здоровайся - сразу давай маршрут!
|
||||
|
||||
Примеры запросов, на которые нужно СРАЗУ давать маршрут:
|
||||
- "золотое кольцо" → маршрут по городам Золотого кольца
|
||||
- "путешествие по Италии" → маршрут по Италии
|
||||
- "хочу в Париж" → маршрут по Парижу
|
||||
- "куда поехать в Крыму" → маршрут по Крыму
|
||||
|
||||
Отвечай на русском языке. В ответе:
|
||||
1. Кратко опиши маршрут (2-3 предложения)
|
||||
2. Перечисли точки с описанием каждой
|
||||
3. Укажи примерный бюджет
|
||||
4. В КОНЦЕ ОБЯЗАТЕЛЬНО добавь JSON блок
|
||||
|
||||
КООРДИНАТЫ ГОРОДОВ ЗОЛОТОГО КОЛЬЦА:
|
||||
- Сергиев Посад: lat 56.3100, lng 38.1326
|
||||
- Переславль-Залесский: lat 56.7389, lng 38.8533
|
||||
- Ростов Великий: lat 57.1848, lng 39.4142
|
||||
- Ярославль: lat 57.6261, lng 39.8845
|
||||
- Кострома: lat 57.7679, lng 40.9269
|
||||
- Иваново: lat 56.9994, lng 40.9728
|
||||
- Суздаль: lat 56.4212, lng 40.4496
|
||||
- Владимир: lat 56.1366, lng 40.3966
|
||||
|
||||
ДРУГИЕ ПОПУЛЯРНЫЕ МЕСТА:
|
||||
- Москва, Красная площадь: lat 55.7539, lng 37.6208
|
||||
- Санкт-Петербург, Эрмитаж: lat 59.9398, lng 30.3146
|
||||
- Казань, Кремль: lat 55.7982, lng 49.1064
|
||||
- Сочи, центр: lat 43.5855, lng 39.7231
|
||||
- Калининград: lat 54.7104, lng 20.4522
|
||||
|
||||
ФОРМАТ JSON (ОБЯЗАТЕЛЕН В КОНЦЕ КАЖДОГО ОТВЕТА):
|
||||
` + "```json" + `
|
||||
{
|
||||
"route": [
|
||||
{
|
||||
"name": "Название места",
|
||||
"lat": 55.7539,
|
||||
"lng": 37.6208,
|
||||
"type": "attraction",
|
||||
"aiComment": "Комментарий о месте",
|
||||
"duration": 120,
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"suggestions": [
|
||||
{
|
||||
"type": "activity",
|
||||
"title": "Название",
|
||||
"description": "Описание",
|
||||
"lat": 55.7539,
|
||||
"lng": 37.6208
|
||||
}
|
||||
]
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
ТИПЫ ТОЧЕК: airport, hotel, attraction, restaurant, transport, custom
|
||||
ТИПЫ SUGGESTIONS: destination, activity, restaurant, transport
|
||||
|
||||
ПРАВИЛА:
|
||||
1. lat и lng - ЧИСЛА (не строки!)
|
||||
2. duration - минуты (число)
|
||||
3. order - порядковый номер с 1
|
||||
4. Минимум 5 точек для маршрута
|
||||
5. JSON ОБЯЗАТЕЛЕН даже для простых вопросов!
|
||||
6. НИКОГДА не отвечай без JSON блока!`
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
}
|
||||
|
||||
for _, h := range req.History {
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: h[0]})
|
||||
messages = append(messages, ChatMessage{Role: "assistant", Content: h[1]})
|
||||
}
|
||||
|
||||
userMsg := req.Query
|
||||
if req.StartDate != "" && req.EndDate != "" {
|
||||
userMsg += fmt.Sprintf("\n\nДаты: %s - %s", req.StartDate, req.EndDate)
|
||||
}
|
||||
if req.Travelers > 0 {
|
||||
userMsg += fmt.Sprintf("\nКоличество путешественников: %d", req.Travelers)
|
||||
}
|
||||
if req.Budget > 0 {
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = "RUB"
|
||||
}
|
||||
userMsg += fmt.Sprintf("\nБюджет: %.0f %s", req.Budget, currency)
|
||||
}
|
||||
if req.Preferences != nil {
|
||||
if req.Preferences.TravelStyle != "" {
|
||||
userMsg += fmt.Sprintf("\nСтиль путешествия: %s", req.Preferences.TravelStyle)
|
||||
}
|
||||
if len(req.Preferences.Interests) > 0 {
|
||||
userMsg += fmt.Sprintf("\nИнтересы: %s", strings.Join(req.Preferences.Interests, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: userMsg})
|
||||
|
||||
writeEvent := func(eventType string, data interface{}) {
|
||||
event := map[string]interface{}{
|
||||
"type": eventType,
|
||||
"data": data,
|
||||
}
|
||||
jsonData, _ := json.Marshal(event)
|
||||
writer.Write(jsonData)
|
||||
writer.Write([]byte("\n"))
|
||||
if bw, ok := writer.(*bufio.Writer); ok {
|
||||
bw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
writeEvent("messageStart", nil)
|
||||
|
||||
var fullResponse strings.Builder
|
||||
err := s.llmClient.StreamChat(ctx, messages, func(chunk string) {
|
||||
fullResponse.WriteString(chunk)
|
||||
writeEvent("textChunk", map[string]string{"chunk": chunk})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
writeEvent("error", map[string]string{"message": err.Error()})
|
||||
return err
|
||||
}
|
||||
|
||||
responseText := fullResponse.String()
|
||||
fmt.Printf("[travel] Full LLM response length: %d chars\n", len(responseText))
|
||||
|
||||
routeData := s.extractRouteFromResponse(ctx, responseText)
|
||||
|
||||
if routeData == nil || len(routeData.Route) == 0 {
|
||||
fmt.Println("[travel] No valid route in response, requesting route generation from LLM...")
|
||||
routeData = s.requestRouteGeneration(ctx, userMsg, responseText)
|
||||
}
|
||||
|
||||
if routeData != nil && len(routeData.Route) > 0 {
|
||||
routeData = s.geocodeMissingCoordinates(ctx, routeData)
|
||||
}
|
||||
|
||||
if routeData != nil && len(routeData.Route) > 0 {
|
||||
for i, p := range routeData.Route {
|
||||
fmt.Printf("[travel] Point %d: %s (lat=%.6f, lng=%.6f)\n", i+1, p.Name, p.Lat, p.Lng)
|
||||
}
|
||||
writeEvent("route", routeData)
|
||||
fmt.Printf("[travel] Sent route event with %d points\n", len(routeData.Route))
|
||||
} else {
|
||||
fmt.Println("[travel] No route data after all attempts")
|
||||
}
|
||||
|
||||
writeEvent("messageEnd", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) requestRouteGeneration(ctx context.Context, userQuery string, originalResponse string) *RouteData {
|
||||
if s.llmClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
genPrompt := `Пользователь запросил: "` + userQuery + `"
|
||||
|
||||
Ты должен СРАЗУ создать маршрут путешествия. ВЕРНИ ТОЛЬКО JSON без пояснений:
|
||||
|
||||
{
|
||||
"route": [
|
||||
{"name": "Место 1", "lat": 56.31, "lng": 38.13, "type": "attraction", "aiComment": "Описание", "duration": 120, "order": 1},
|
||||
{"name": "Место 2", "lat": 56.74, "lng": 38.85, "type": "attraction", "aiComment": "Описание", "duration": 90, "order": 2}
|
||||
],
|
||||
"suggestions": []
|
||||
}
|
||||
|
||||
КООРДИНАТЫ ПОПУЛЯРНЫХ МЕСТ:
|
||||
Золотое кольцо: Сергиев Посад (56.31, 38.13), Переславль-Залесский (56.74, 38.85), Ростов Великий (57.18, 39.41), Ярославль (57.63, 39.88), Кострома (57.77, 40.93), Суздаль (56.42, 40.45), Владимир (56.14, 40.40)
|
||||
Москва: Красная площадь (55.75, 37.62), Арбат (55.75, 37.59), ВДНХ (55.83, 37.64)
|
||||
Питер: Эрмитаж (59.94, 30.31), Петергоф (59.88, 29.91), Невский (59.93, 30.35)
|
||||
Крым: Ялта (44.49, 34.17), Севастополь (44.62, 33.52), Бахчисарай (44.75, 33.86)
|
||||
|
||||
ВАЖНО:
|
||||
- lat и lng - ЧИСЛА
|
||||
- Минимум 5 точек
|
||||
- type: airport, hotel, attraction, restaurant, transport, custom
|
||||
- ТОЛЬКО JSON, без текста до и после!`
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "user", Content: genPrompt},
|
||||
}
|
||||
|
||||
var genResponse strings.Builder
|
||||
err := s.llmClient.StreamChat(ctx, messages, func(chunk string) {
|
||||
genResponse.WriteString(chunk)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[travel] LLM route generation failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
result := genResponse.String()
|
||||
fmt.Printf("[travel] LLM generated route: %s\n", result)
|
||||
return s.extractRouteFromResponse(ctx, result)
|
||||
}
|
||||
|
||||
func (s *Service) geocodeMissingCoordinates(ctx context.Context, data *RouteData) *RouteData {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
validPoints := make([]RoutePoint, 0, len(data.Route))
|
||||
|
||||
for _, point := range data.Route {
|
||||
if (point.Lat == 0 && point.Lng == 0) && point.Name != "" {
|
||||
loc, err := s.Geocode(ctx, point.Name)
|
||||
if err == nil && loc != nil {
|
||||
point.Lat = loc.Lat
|
||||
point.Lng = loc.Lng
|
||||
if point.Address == "" {
|
||||
point.Address = loc.Name
|
||||
}
|
||||
fmt.Printf("[travel] Geocoded '%s' -> lat=%.4f, lng=%.4f\n", point.Name, point.Lat, point.Lng)
|
||||
} else {
|
||||
fmt.Printf("[travel] Failed to geocode '%s': %v\n", point.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if point.Lat != 0 || point.Lng != 0 {
|
||||
validPoints = append(validPoints, point)
|
||||
}
|
||||
}
|
||||
|
||||
data.Route = validPoints
|
||||
return data
|
||||
}
|
||||
|
||||
type RouteData struct {
|
||||
Route []RoutePoint `json:"route"`
|
||||
Suggestions []TravelSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
func (s *Service) extractRouteFromResponse(_ context.Context, response string) *RouteData {
|
||||
var jsonStr string
|
||||
|
||||
start := strings.Index(response, "```json")
|
||||
if start != -1 {
|
||||
start += 7
|
||||
end := strings.Index(response[start:], "```")
|
||||
if end != -1 {
|
||||
jsonStr = strings.TrimSpace(response[start : start+end])
|
||||
}
|
||||
}
|
||||
|
||||
if jsonStr == "" {
|
||||
start = strings.Index(response, "```")
|
||||
if start != -1 {
|
||||
start += 3
|
||||
if start < len(response) {
|
||||
for start < len(response) && (response[start] == '\n' || response[start] == '\r') {
|
||||
start++
|
||||
}
|
||||
end := strings.Index(response[start:], "```")
|
||||
if end != -1 {
|
||||
candidate := strings.TrimSpace(response[start : start+end])
|
||||
if strings.HasPrefix(candidate, "{") {
|
||||
jsonStr = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jsonStr == "" {
|
||||
routeStart := strings.Index(response, `"route"`)
|
||||
if routeStart != -1 {
|
||||
braceStart := strings.LastIndex(response[:routeStart], "{")
|
||||
if braceStart != -1 {
|
||||
depth := 0
|
||||
braceEnd := -1
|
||||
for i := braceStart; i < len(response); i++ {
|
||||
if response[i] == '{' {
|
||||
depth++
|
||||
} else if response[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
braceEnd = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if braceEnd != -1 {
|
||||
jsonStr = response[braceStart:braceEnd]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rawResult struct {
|
||||
Route []struct {
|
||||
ID string `json:"id"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lng interface{} `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Type string `json:"type"`
|
||||
AIComment string `json:"aiComment,omitempty"`
|
||||
Duration interface{} `json:"duration,omitempty"`
|
||||
Order interface{} `json:"order"`
|
||||
} `json:"route"`
|
||||
Suggestions []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Lat interface{} `json:"lat,omitempty"`
|
||||
Lng interface{} `json:"lng,omitempty"`
|
||||
} `json:"suggestions"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &rawResult); err != nil {
|
||||
fmt.Printf("[travel] JSON parse error: %v, json: %s\n", err, jsonStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &RouteData{
|
||||
Route: make([]RoutePoint, 0, len(rawResult.Route)),
|
||||
Suggestions: make([]TravelSuggestion, 0, len(rawResult.Suggestions)),
|
||||
}
|
||||
|
||||
for i, raw := range rawResult.Route {
|
||||
point := RoutePoint{
|
||||
ID: raw.ID,
|
||||
Name: raw.Name,
|
||||
Address: raw.Address,
|
||||
Type: RoutePointType(raw.Type),
|
||||
AIComment: raw.AIComment,
|
||||
Order: i + 1,
|
||||
}
|
||||
|
||||
if point.ID == "" {
|
||||
point.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
point.Lat = toFloat64(raw.Lat)
|
||||
point.Lng = toFloat64(raw.Lng)
|
||||
point.Duration = toInt(raw.Duration)
|
||||
|
||||
if orderVal := toInt(raw.Order); orderVal > 0 {
|
||||
point.Order = orderVal
|
||||
}
|
||||
|
||||
if point.Type == "" {
|
||||
point.Type = RoutePointCustom
|
||||
}
|
||||
|
||||
if point.Name != "" {
|
||||
result.Route = append(result.Route, point)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range rawResult.Suggestions {
|
||||
sug := TravelSuggestion{
|
||||
ID: raw.ID,
|
||||
Type: raw.Type,
|
||||
Title: raw.Title,
|
||||
Description: raw.Description,
|
||||
}
|
||||
|
||||
if sug.ID == "" {
|
||||
sug.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
sug.Lat = toFloat64(raw.Lat)
|
||||
sug.Lng = toFloat64(raw.Lng)
|
||||
|
||||
result.Suggestions = append(result.Suggestions, sug)
|
||||
}
|
||||
|
||||
fmt.Printf("[travel] Extracted %d route points, %d suggestions\n", len(result.Route), len(result.Suggestions))
|
||||
return result
|
||||
}
|
||||
|
||||
func toFloat64(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case float32:
|
||||
return float64(val)
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(val, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toInt(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
case float32:
|
||||
return int(val)
|
||||
case string:
|
||||
i, _ := strconv.Atoi(val)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
if s.useRussianAPIs && s.travelPayouts != nil {
|
||||
return s.travelPayouts.SearchFlights(ctx, req)
|
||||
}
|
||||
return s.amadeus.SearchFlights(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) {
|
||||
return s.amadeus.SearchHotels(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetRoute(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
|
||||
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
|
||||
transport := mapProfileToTwoGISTransport(profile)
|
||||
dir, err := s.twoGIS.GetRoute(ctx, points, transport)
|
||||
if err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
fmt.Printf("[travel] 2GIS routing failed (transport=%s): %v, trying OpenRouteService\n", transport, err)
|
||||
}
|
||||
|
||||
if s.openRoute != nil && s.openRoute.HasAPIKey() {
|
||||
return s.openRoute.GetDirections(ctx, points, profile)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no routing provider available")
|
||||
}
|
||||
|
||||
func mapProfileToTwoGISTransport(profile string) string {
|
||||
switch profile {
|
||||
case "driving-car", "driving", "car":
|
||||
return "driving"
|
||||
case "taxi":
|
||||
return "taxi"
|
||||
case "foot-walking", "walking", "pedestrian":
|
||||
return "walking"
|
||||
case "cycling-regular", "cycling", "bicycle":
|
||||
return "bicycle"
|
||||
default:
|
||||
return "driving"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
|
||||
loc, err := s.twoGIS.Geocode(ctx, query)
|
||||
if err == nil && loc != nil {
|
||||
fmt.Printf("[travel] 2GIS geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng)
|
||||
return loc, nil
|
||||
}
|
||||
fmt.Printf("[travel] 2GIS geocode failed for '%s': %v\n", query, err)
|
||||
}
|
||||
|
||||
if s.openRoute != nil && s.openRoute.HasAPIKey() {
|
||||
loc, err := s.openRoute.Geocode(ctx, query)
|
||||
if err == nil && loc != nil {
|
||||
fmt.Printf("[travel] OpenRoute geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng)
|
||||
return loc, nil
|
||||
}
|
||||
fmt.Printf("[travel] OpenRoute geocode failed for '%s': %v\n", query, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("geocode failed for '%s': no API keys configured", query)
|
||||
}
|
||||
|
||||
func (s *Service) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
|
||||
return s.openRoute.SearchPOI(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) {
|
||||
if s.useRussianAPIs && s.travelPayouts != nil {
|
||||
return s.travelPayouts.GetPopularDestinations(ctx, origin)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) {
|
||||
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
|
||||
return s.twoGIS.SearchPlaces(ctx, query, lat, lng, radius)
|
||||
}
|
||||
return nil, fmt.Errorf("2GIS API key not configured")
|
||||
}
|
||||
|
||||
func (s *Service) CreateTrip(ctx context.Context, trip *Trip) error {
|
||||
return s.repo.CreateTrip(ctx, trip)
|
||||
}
|
||||
|
||||
func (s *Service) GetTrip(ctx context.Context, id string) (*Trip, error) {
|
||||
return s.repo.GetTrip(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) GetUserTrips(ctx context.Context, userID string, limit, offset int) ([]Trip, error) {
|
||||
return s.repo.GetTripsByUser(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateTrip(ctx context.Context, trip *Trip) error {
|
||||
return s.repo.UpdateTrip(ctx, trip)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteTrip(ctx context.Context, id string) error {
|
||||
return s.repo.DeleteTrip(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) GetUpcomingTrips(ctx context.Context, userID string) ([]Trip, error) {
|
||||
trips, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusPlanned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
booked, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusBooked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var upcoming []Trip
|
||||
for _, t := range append(trips, booked...) {
|
||||
if t.StartDate.After(now) {
|
||||
upcoming = append(upcoming, t)
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming, nil
|
||||
}
|
||||
|
||||
func (s *Service) BuildRouteFromPoints(ctx context.Context, trip *Trip) (*RouteDirection, error) {
|
||||
if len(trip.Route) < 2 {
|
||||
return nil, fmt.Errorf("need at least 2 points for route")
|
||||
}
|
||||
|
||||
points := make([]GeoLocation, len(trip.Route))
|
||||
for i, p := range trip.Route {
|
||||
points[i] = GeoLocation{
|
||||
Lat: p.Lat,
|
||||
Lng: p.Lng,
|
||||
Name: p.Name,
|
||||
}
|
||||
}
|
||||
|
||||
return s.openRoute.GetDirections(ctx, points, "driving-car")
|
||||
}
|
||||
|
||||
func (s *Service) EnrichTripWithAI(ctx context.Context, trip *Trip) error {
|
||||
if len(trip.Route) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range trip.Route {
|
||||
point := &trip.Route[i]
|
||||
if point.AIComment == "" {
|
||||
pois, err := s.openRoute.SearchPOI(ctx, POISearchRequest{
|
||||
Lat: point.Lat,
|
||||
Lng: point.Lng,
|
||||
Radius: 500,
|
||||
Limit: 5,
|
||||
})
|
||||
if err == nil && len(pois) > 0 {
|
||||
var nearbyNames []string
|
||||
for _, poi := range pois {
|
||||
if poi.Name != "" && poi.Name != point.Name {
|
||||
nearbyNames = append(nearbyNames, poi.Name)
|
||||
}
|
||||
}
|
||||
if len(nearbyNames) > 0 {
|
||||
point.AIComment = fmt.Sprintf("Рядом: %s", strings.Join(nearbyNames, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
276
backend/internal/travel/travelpayouts.go
Normal file
276
backend/internal/travel/travelpayouts.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TravelPayoutsClient struct {
|
||||
token string
|
||||
marker string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type TravelPayoutsConfig struct {
|
||||
Token string
|
||||
Marker string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewTravelPayoutsClient(cfg TravelPayoutsConfig) *TravelPayoutsClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.travelpayouts.com"
|
||||
}
|
||||
|
||||
return &TravelPayoutsClient{
|
||||
token: cfg.Token,
|
||||
marker: cfg.Marker,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) doRequest(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
query.Set("token", c.token)
|
||||
if c.marker != "" {
|
||||
query.Set("marker", c.marker)
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path + "?" + query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", req.Origin)
|
||||
query.Set("destination", req.Destination)
|
||||
query.Set("depart_date", req.DepartureDate)
|
||||
if req.ReturnDate != "" {
|
||||
query.Set("return_date", req.ReturnDate)
|
||||
}
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
if req.Currency != "" {
|
||||
query.Set("currency", req.Currency)
|
||||
} else {
|
||||
query.Set("currency", "rub")
|
||||
}
|
||||
query.Set("limit", "10")
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data []struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
OriginAirport string `json:"origin_airport"`
|
||||
DestAirport string `json:"destination_airport"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
DepartureAt string `json:"departure_at"`
|
||||
ReturnAt string `json:"return_at"`
|
||||
Transfers int `json:"transfers"`
|
||||
ReturnTransfers int `json:"return_transfers"`
|
||||
Duration int `json:"duration"`
|
||||
Link string `json:"link"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, d := range response.Data {
|
||||
offer := FlightOffer{
|
||||
ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartureAt),
|
||||
Airline: d.Airline,
|
||||
FlightNumber: d.FlightNumber,
|
||||
DepartureAirport: d.OriginAirport,
|
||||
DepartureCity: d.Origin,
|
||||
DepartureTime: d.DepartureAt,
|
||||
ArrivalAirport: d.DestAirport,
|
||||
ArrivalCity: d.Destination,
|
||||
ArrivalTime: d.ReturnAt,
|
||||
Duration: d.Duration,
|
||||
Stops: d.Transfers,
|
||||
Price: d.Price,
|
||||
Currency: req.Currency,
|
||||
BookingURL: "https://www.aviasales.ru" + d.Link,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetCheapestPrices(ctx context.Context, origin, destination string, currency string) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", origin)
|
||||
query.Set("destination", destination)
|
||||
if currency != "" {
|
||||
query.Set("currency", currency)
|
||||
} else {
|
||||
query.Set("currency", "rub")
|
||||
}
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data []struct {
|
||||
DepartDate string `json:"depart_date"`
|
||||
ReturnDate string `json:"return_date"`
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
Transfers int `json:"number_of_changes"`
|
||||
Link string `json:"link"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal prices: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, d := range response.Data {
|
||||
offer := FlightOffer{
|
||||
ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartDate),
|
||||
Airline: d.Airline,
|
||||
DepartureCity: d.Origin,
|
||||
DepartureTime: d.DepartDate,
|
||||
ArrivalCity: d.Destination,
|
||||
ArrivalTime: d.ReturnDate,
|
||||
Stops: d.Transfers,
|
||||
Price: d.Price,
|
||||
Currency: currency,
|
||||
BookingURL: "https://www.aviasales.ru" + d.Link,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", origin)
|
||||
query.Set("currency", "rub")
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/city_directions", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
Price float64 `json:"price"`
|
||||
Transfers int `json:"transfers"`
|
||||
Airline string `json:"airline"`
|
||||
DepartDate string `json:"departure_at"`
|
||||
ReturnDate string `json:"return_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal destinations: %w", err)
|
||||
}
|
||||
|
||||
suggestions := make([]TravelSuggestion, 0)
|
||||
for dest, d := range response.Data {
|
||||
suggestion := TravelSuggestion{
|
||||
ID: dest,
|
||||
Type: "destination",
|
||||
Title: dest,
|
||||
Description: fmt.Sprintf("от %s, %d пересадок", d.Airline, d.Transfers),
|
||||
Price: d.Price,
|
||||
Currency: "RUB",
|
||||
}
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetAirportByIATA(ctx context.Context, iata string) (*GeoLocation, error) {
|
||||
query := url.Values{}
|
||||
query.Set("code", iata)
|
||||
query.Set("locale", "ru")
|
||||
|
||||
body, err := c.doRequest(ctx, "/data/ru/airports.json", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var airports []struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Coordinates []float64 `json:"coordinates"`
|
||||
Country string `json:"country_code"`
|
||||
City string `json:"city_code"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &airports); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal airports: %w", err)
|
||||
}
|
||||
|
||||
for _, a := range airports {
|
||||
if a.Code == iata && len(a.Coordinates) >= 2 {
|
||||
return &GeoLocation{
|
||||
Lng: a.Coordinates[0],
|
||||
Lat: a.Coordinates[1],
|
||||
Name: a.Name,
|
||||
Country: a.Country,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("airport not found: %s", iata)
|
||||
}
|
||||
465
backend/internal/travel/twogis.go
Normal file
465
backend/internal/travel/twogis.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwoGISClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type TwoGISConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) HasAPIKey() bool {
|
||||
return c.apiKey != ""
|
||||
}
|
||||
|
||||
func NewTwoGISClient(cfg TwoGISConfig) *TwoGISClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://catalog.api.2gis.com"
|
||||
}
|
||||
|
||||
return &TwoGISClient{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type twoGISResponse struct {
|
||||
Meta struct {
|
||||
Code int `json:"code"`
|
||||
} `json:"meta"`
|
||||
Result struct {
|
||||
Items []twoGISItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type twoGISItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
AddressName string `json:"address_name"`
|
||||
Type string `json:"type"`
|
||||
Point *struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
} `json:"point"`
|
||||
Address *struct {
|
||||
Components []struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country,omitempty"`
|
||||
} `json:"components,omitempty"`
|
||||
} `json:"address,omitempty"`
|
||||
PurposeName string `json:"purpose_name,omitempty"`
|
||||
Reviews *twoGISReviews `json:"reviews,omitempty"`
|
||||
Schedule map[string]twoGISScheduleDay `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
type twoGISReviews struct {
|
||||
GeneralRating float64 `json:"general_rating"`
|
||||
GeneralReviewCount int `json:"general_review_count"`
|
||||
OrgRating float64 `json:"org_rating"`
|
||||
OrgReviewCount int `json:"org_review_count"`
|
||||
}
|
||||
|
||||
type twoGISScheduleDay struct {
|
||||
WorkingHours []struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
} `json:"working_hours"`
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) doRequest(ctx context.Context, endpoint string, params url.Values) (*twoGISResponse, error) {
|
||||
params.Set("key", c.apiKey)
|
||||
|
||||
fullURL := c.baseURL + endpoint + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("2GIS API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result twoGISResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if result.Meta.Code >= 400 {
|
||||
return nil, fmt.Errorf("2GIS API meta error %d: %s", result.Meta.Code, string(body))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("fields", "items.point")
|
||||
params.Set("type", "building,street,adm_div,adm_div.city,adm_div.place,adm_div.settlement,crossroad,attraction")
|
||||
|
||||
result, err := c.doRequest(ctx, "/3.0/items/geocode", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Result.Items) == 0 {
|
||||
return nil, fmt.Errorf("location not found: %s", query)
|
||||
}
|
||||
|
||||
item := result.Result.Items[0]
|
||||
if item.Point == nil {
|
||||
return nil, fmt.Errorf("no coordinates for: %s", query)
|
||||
}
|
||||
|
||||
country := ""
|
||||
if item.Address != nil {
|
||||
for _, comp := range item.Address.Components {
|
||||
if comp.Type == "country" {
|
||||
country = comp.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = item.FullName
|
||||
}
|
||||
|
||||
return &GeoLocation{
|
||||
Lat: item.Point.Lat,
|
||||
Lng: item.Point.Lon,
|
||||
Name: name,
|
||||
Country: country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("lat", fmt.Sprintf("%f", lat))
|
||||
params.Set("lon", fmt.Sprintf("%f", lng))
|
||||
params.Set("fields", "items.point")
|
||||
|
||||
result, err := c.doRequest(ctx, "/3.0/items/geocode", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Result.Items) == 0 {
|
||||
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
|
||||
}
|
||||
|
||||
item := result.Result.Items[0]
|
||||
|
||||
country := ""
|
||||
if item.Address != nil {
|
||||
for _, comp := range item.Address.Components {
|
||||
if comp.Type == "country" {
|
||||
country = comp.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = item.FullName
|
||||
}
|
||||
|
||||
return &GeoLocation{
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Name: name,
|
||||
Country: country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type TwoGISPlace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount"`
|
||||
Schedule map[string]string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("point", fmt.Sprintf("%f,%f", lng, lat))
|
||||
params.Set("radius", fmt.Sprintf("%d", radius))
|
||||
params.Set("fields", "items.point,items.address,items.reviews,items.schedule")
|
||||
params.Set("page_size", "10")
|
||||
|
||||
result, err := c.doRequest(ctx, "/3.0/items", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
places := make([]TwoGISPlace, 0, len(result.Result.Items))
|
||||
for _, item := range result.Result.Items {
|
||||
if item.Point == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
addr := item.AddressName
|
||||
if addr == "" {
|
||||
addr = item.FullName
|
||||
}
|
||||
|
||||
place := TwoGISPlace{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Address: addr,
|
||||
Lat: item.Point.Lat,
|
||||
Lng: item.Point.Lon,
|
||||
Type: item.Type,
|
||||
Purpose: item.PurposeName,
|
||||
}
|
||||
|
||||
if item.Reviews != nil {
|
||||
place.Rating = item.Reviews.GeneralRating
|
||||
place.ReviewCount = item.Reviews.GeneralReviewCount
|
||||
if place.Rating == 0 {
|
||||
place.Rating = item.Reviews.OrgRating
|
||||
}
|
||||
if place.ReviewCount == 0 {
|
||||
place.ReviewCount = item.Reviews.OrgReviewCount
|
||||
}
|
||||
}
|
||||
|
||||
if item.Schedule != nil {
|
||||
place.Schedule = formatSchedule(item.Schedule)
|
||||
}
|
||||
|
||||
places = append(places, place)
|
||||
}
|
||||
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func formatSchedule(sched map[string]twoGISScheduleDay) map[string]string {
|
||||
dayOrder := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
dayRu := map[string]string{
|
||||
"Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт",
|
||||
"Fri": "Пт", "Sat": "Сб", "Sun": "Вс",
|
||||
}
|
||||
result := make(map[string]string, len(sched))
|
||||
for _, d := range dayOrder {
|
||||
if day, ok := sched[d]; ok && len(day.WorkingHours) > 0 {
|
||||
wh := day.WorkingHours[0]
|
||||
result[dayRu[d]] = wh.From + "–" + wh.To
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const twoGISRoutingBaseURL = "https://routing.api.2gis.com"
|
||||
|
||||
type twoGISRoutingRequest struct {
|
||||
Points []twoGISRoutePoint `json:"points"`
|
||||
Transport string `json:"transport"`
|
||||
Output string `json:"output"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type twoGISRoutePoint struct {
|
||||
Type string `json:"type"`
|
||||
Lon float64 `json:"lon"`
|
||||
Lat float64 `json:"lat"`
|
||||
}
|
||||
|
||||
type twoGISRoutingResponse struct {
|
||||
Message string `json:"message"`
|
||||
Result []twoGISRoutingResult `json:"result"`
|
||||
}
|
||||
|
||||
type twoGISRoutingResult struct {
|
||||
ID string `json:"id"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
TotalDistance int `json:"total_distance"`
|
||||
TotalDuration int `json:"total_duration"`
|
||||
Maneuvers []twoGISManeuver `json:"maneuvers"`
|
||||
}
|
||||
|
||||
type twoGISManeuver struct {
|
||||
ID string `json:"id"`
|
||||
Comment string `json:"comment"`
|
||||
Type string `json:"type"`
|
||||
OutcomingPath *twoGISOutcomingPath `json:"outcoming_path,omitempty"`
|
||||
}
|
||||
|
||||
type twoGISOutcomingPath struct {
|
||||
Distance int `json:"distance"`
|
||||
Duration int `json:"duration"`
|
||||
Geometry []twoGISPathGeometry `json:"geometry"`
|
||||
}
|
||||
|
||||
type twoGISPathGeometry struct {
|
||||
Selection string `json:"selection"`
|
||||
Length int `json:"length"`
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) GetRoute(ctx context.Context, points []GeoLocation, transport string) (*RouteDirection, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("at least 2 points required for routing")
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "driving"
|
||||
}
|
||||
|
||||
routePoints := make([]twoGISRoutePoint, len(points))
|
||||
for i, p := range points {
|
||||
routePoints[i] = twoGISRoutePoint{
|
||||
Type: "stop",
|
||||
Lon: p.Lng,
|
||||
Lat: p.Lat,
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := twoGISRoutingRequest{
|
||||
Points: routePoints,
|
||||
Transport: transport,
|
||||
Output: "detailed",
|
||||
Locale: "ru",
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal routing request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/routing/7.0.0/global?key=%s", twoGISRoutingBaseURL, url.QueryEscape(c.apiKey))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create routing request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute routing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read routing response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("2GIS Routing API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var routingResp twoGISRoutingResponse
|
||||
if err := json.Unmarshal(body, &routingResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal routing response: %w", err)
|
||||
}
|
||||
|
||||
if routingResp.Message != "" && routingResp.Message != "OK" && len(routingResp.Result) == 0 {
|
||||
return nil, fmt.Errorf("2GIS Routing error: %s", routingResp.Message)
|
||||
}
|
||||
|
||||
if len(routingResp.Result) == 0 {
|
||||
return nil, fmt.Errorf("no route found")
|
||||
}
|
||||
|
||||
route := routingResp.Result[0]
|
||||
|
||||
var allCoords [][2]float64
|
||||
var steps []RouteStep
|
||||
|
||||
for _, m := range route.Maneuvers {
|
||||
if m.OutcomingPath != nil {
|
||||
for _, geom := range m.OutcomingPath.Geometry {
|
||||
coords := parseWKTLineString(geom.Selection)
|
||||
allCoords = append(allCoords, coords...)
|
||||
}
|
||||
|
||||
if m.Comment != "" {
|
||||
steps = append(steps, RouteStep{
|
||||
Instruction: m.Comment,
|
||||
Distance: float64(m.OutcomingPath.Distance),
|
||||
Duration: float64(m.OutcomingPath.Duration),
|
||||
Type: m.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &RouteDirection{
|
||||
Geometry: RouteGeometry{
|
||||
Coordinates: allCoords,
|
||||
Type: "LineString",
|
||||
},
|
||||
Distance: float64(route.TotalDistance),
|
||||
Duration: float64(route.TotalDuration),
|
||||
Steps: steps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseWKTLineString(wkt string) [][2]float64 {
|
||||
wkt = strings.TrimSpace(wkt)
|
||||
if !strings.HasPrefix(wkt, "LINESTRING(") {
|
||||
return nil
|
||||
}
|
||||
|
||||
inner := wkt[len("LINESTRING(") : len(wkt)-1]
|
||||
pairs := strings.Split(inner, ",")
|
||||
coords := make([][2]float64, 0, len(pairs))
|
||||
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
parts := strings.Fields(pair)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
lon, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
lat, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
coords = append(coords, [2]float64{lon, lat})
|
||||
}
|
||||
|
||||
return coords
|
||||
}
|
||||
203
backend/internal/travel/types.go
Normal file
203
backend/internal/travel/types.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package travel
|
||||
|
||||
import "time"
|
||||
|
||||
type TripStatus string
|
||||
|
||||
const (
|
||||
TripStatusPlanned TripStatus = "planned"
|
||||
TripStatusBooked TripStatus = "booked"
|
||||
TripStatusCompleted TripStatus = "completed"
|
||||
TripStatusCancelled TripStatus = "cancelled"
|
||||
)
|
||||
|
||||
type RoutePointType string
|
||||
|
||||
const (
|
||||
RoutePointAirport RoutePointType = "airport"
|
||||
RoutePointHotel RoutePointType = "hotel"
|
||||
RoutePointAttraction RoutePointType = "attraction"
|
||||
RoutePointRestaurant RoutePointType = "restaurant"
|
||||
RoutePointTransport RoutePointType = "transport"
|
||||
RoutePointCustom RoutePointType = "custom"
|
||||
)
|
||||
|
||||
type Trip struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Title string `json:"title"`
|
||||
Destination string `json:"destination"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CoverImage string `json:"coverImage,omitempty"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Route []RoutePoint `json:"route"`
|
||||
Flights []FlightOffer `json:"flights,omitempty"`
|
||||
Hotels []HotelOffer `json:"hotels,omitempty"`
|
||||
TotalBudget float64 `json:"totalBudget,omitempty"`
|
||||
Currency string `json:"currency"`
|
||||
Status TripStatus `json:"status"`
|
||||
AISummary string `json:"aiSummary,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RoutePoint struct {
|
||||
ID string `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Type RoutePointType `json:"type"`
|
||||
AIComment string `json:"aiComment,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
Order int `json:"order"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
}
|
||||
|
||||
type FlightOffer struct {
|
||||
ID string `json:"id"`
|
||||
Airline string `json:"airline"`
|
||||
AirlineLogo string `json:"airlineLogo,omitempty"`
|
||||
FlightNumber string `json:"flightNumber"`
|
||||
DepartureAirport string `json:"departureAirport"`
|
||||
DepartureCity string `json:"departureCity"`
|
||||
DepartureTime string `json:"departureTime"`
|
||||
ArrivalAirport string `json:"arrivalAirport"`
|
||||
ArrivalCity string `json:"arrivalCity"`
|
||||
ArrivalTime string `json:"arrivalTime"`
|
||||
Duration int `json:"duration"`
|
||||
Stops int `json:"stops"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
CabinClass string `json:"cabinClass"`
|
||||
SeatsAvailable int `json:"seatsAvailable,omitempty"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
}
|
||||
|
||||
type HotelOffer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount,omitempty"`
|
||||
Stars int `json:"stars,omitempty"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
Currency string `json:"currency"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
RoomType string `json:"roomType,omitempty"`
|
||||
Amenities []string `json:"amenities,omitempty"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
}
|
||||
|
||||
type TravelPlanRequest struct {
|
||||
Query string `json:"query"`
|
||||
StartDate string `json:"startDate,omitempty"`
|
||||
EndDate string `json:"endDate,omitempty"`
|
||||
Travelers int `json:"travelers,omitempty"`
|
||||
Budget float64 `json:"budget,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Preferences *TravelPreferences `json:"preferences,omitempty"`
|
||||
History [][2]string `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type TravelPreferences struct {
|
||||
TravelStyle string `json:"travelStyle,omitempty"`
|
||||
Interests []string `json:"interests,omitempty"`
|
||||
AvoidTypes []string `json:"avoidTypes,omitempty"`
|
||||
TransportModes []string `json:"transportModes,omitempty"`
|
||||
}
|
||||
|
||||
type TravelSuggestion struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lng float64 `json:"lng,omitempty"`
|
||||
}
|
||||
|
||||
type GeoLocation struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
type RouteDirection struct {
|
||||
Geometry RouteGeometry `json:"geometry"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []RouteStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
type RouteGeometry struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteStep struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type FlightSearchRequest struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
DepartureDate string `json:"departureDate"`
|
||||
ReturnDate string `json:"returnDate,omitempty"`
|
||||
Adults int `json:"adults"`
|
||||
Children int `json:"children,omitempty"`
|
||||
CabinClass string `json:"cabinClass,omitempty"`
|
||||
MaxPrice int `json:"maxPrice,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type HotelSearchRequest struct {
|
||||
CityCode string `json:"cityCode"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lng float64 `json:"lng,omitempty"`
|
||||
Radius int `json:"radius,omitempty"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
Adults int `json:"adults"`
|
||||
Rooms int `json:"rooms,omitempty"`
|
||||
MaxPrice int `json:"maxPrice,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
}
|
||||
|
||||
type POISearchRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Radius int `json:"radius"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type POI struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Category string `json:"category"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
OpeningHours string `json:"openingHours,omitempty"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
Distance float64 `json:"distance,omitempty"`
|
||||
}
|
||||
@@ -12,6 +12,17 @@ const (
|
||||
WidgetImageGallery WidgetType = "image_gallery"
|
||||
WidgetVideoEmbed WidgetType = "video_embed"
|
||||
WidgetKnowledge WidgetType = "knowledge_card"
|
||||
|
||||
WidgetTravelMap WidgetType = "travel_map"
|
||||
WidgetTravelEvents WidgetType = "travel_events"
|
||||
WidgetTravelPOI WidgetType = "travel_poi"
|
||||
WidgetTravelHotels WidgetType = "travel_hotels"
|
||||
WidgetTravelTransport WidgetType = "travel_transport"
|
||||
WidgetTravelItinerary WidgetType = "travel_itinerary"
|
||||
WidgetTravelBudget WidgetType = "travel_budget"
|
||||
WidgetTravelClarifying WidgetType = "travel_clarifying"
|
||||
WidgetTravelActions WidgetType = "travel_actions"
|
||||
WidgetTravelContext WidgetType = "travel_context"
|
||||
)
|
||||
|
||||
type ProductData struct {
|
||||
|
||||
@@ -45,6 +45,11 @@ type Config struct {
|
||||
ComputerSvcURL string
|
||||
FinanceHeatmapURL string
|
||||
LearningSvcURL string
|
||||
TravelSvcURL string
|
||||
|
||||
// TravelPayouts
|
||||
TravelPayoutsToken string
|
||||
TravelPayoutsMarker string
|
||||
|
||||
// MinIO / S3 storage
|
||||
MinioEndpoint string
|
||||
@@ -124,6 +129,10 @@ func Load() (*Config, error) {
|
||||
ComputerSvcURL: getEnv("COMPUTER_SVC_URL", "http://localhost:3030"),
|
||||
FinanceHeatmapURL: getEnv("FINANCE_HEATMAP_SVC_URL", "http://localhost:3033"),
|
||||
LearningSvcURL: getEnv("LEARNING_SVC_URL", "http://localhost:3034"),
|
||||
TravelSvcURL: getEnv("TRAVEL_SVC_URL", "http://localhost:3035"),
|
||||
|
||||
TravelPayoutsToken: getEnv("TRAVELPAYOUTS_TOKEN", ""),
|
||||
TravelPayoutsMarker: getEnv("TRAVELPAYOUTS_MARKER", ""),
|
||||
|
||||
MinioEndpoint: getEnv("MINIO_ENDPOINT", "minio:9000"),
|
||||
MinioAccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
|
||||
@@ -4,15 +4,7 @@ const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
env: {
|
||||
API_URL: process.env.API_URL || 'http://localhost:3015',
|
||||
},
|
||||
async rewrites() {
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3015';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
NEXT_PUBLIC_TWOGIS_API_KEY: process.env.NEXT_PUBLIC_TWOGIS_API_KEY || process.env.TWOGIS_API_KEY || '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
63
backend/webui/package-lock.json
generated
63
backend/webui/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "gooseek-webui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@2gis/mapgl": "^1.70.1",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -18,14 +19,17 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^14.2.26",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
@@ -35,6 +39,18 @@
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@2gis/mapgl": {
|
||||
"version": "1.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@2gis/mapgl/-/mapgl-1.70.1.tgz",
|
||||
"integrity": "sha512-+Kh7dUEdMNu3Nd2MO8aHpa7lgq2No9z/qdzuHUgpxQqsxs3tTQd37vWCDruzl6BaDrO9hqL16Zurs5nculOFQg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
@@ -1631,6 +1647,17 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
@@ -1671,6 +1698,12 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -1680,6 +1713,16 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -2541,6 +2584,12 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -3668,6 +3717,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2gis/mapgl": "^1.70.1",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -19,14 +20,17 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^14.2.26",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
|
||||
@@ -83,15 +83,15 @@ export default function AdminAuditPage() {
|
||||
const getActionColor = (action: string) => {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return 'bg-green-500/10 text-green-400';
|
||||
return 'bg-green-500/10 text-green-600';
|
||||
case 'update':
|
||||
return 'bg-blue-500/10 text-blue-400';
|
||||
return 'bg-blue-500/10 text-blue-600';
|
||||
case 'delete':
|
||||
return 'bg-red-500/10 text-red-400';
|
||||
return 'bg-red-500/10 text-red-600';
|
||||
case 'publish':
|
||||
return 'bg-purple-500/10 text-purple-400';
|
||||
return 'bg-purple-500/10 text-purple-600';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-400';
|
||||
return 'bg-surface text-muted';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ function CategoryModal({ category, onClose, onSave }: CategoryModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
|
||||
<div className="fixed inset-0 bg-primary/25 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-elevated rounded-xl p-6 w-full max-w-md shadow-dropdown border border-border">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">
|
||||
{category ? 'Редактировать категорию' : 'Новая категория'}
|
||||
</h2>
|
||||
@@ -138,7 +138,7 @@ function CategoryModal({ category, onClose, onSave }: CategoryModalProps) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="flex-1 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
@@ -175,8 +175,8 @@ function SourceModal({ onClose, onSave }: SourceModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
|
||||
<div className="fixed inset-0 bg-primary/25 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-elevated rounded-xl p-6 w-full max-w-md shadow-dropdown border border-border">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">Новый источник</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -254,7 +254,7 @@ function SourceModal({ onClose, onSave }: SourceModalProps) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="flex-1 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
@@ -390,7 +390,7 @@ export default function AdminDiscoverPage() {
|
||||
<p className="text-sm text-muted">Перетащите для изменения порядка</p>
|
||||
<button
|
||||
onClick={() => setCategoryModal(null)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-accent text-accent-foreground rounded-lg text-sm hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
@@ -414,7 +414,7 @@ export default function AdminDiscoverPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleCategory(cat)}
|
||||
className={`p-1 rounded ${cat.isActive ? 'text-green-400' : 'text-muted'}`}
|
||||
className={`p-1 rounded ${cat.isActive ? 'text-green-600' : 'text-muted'}`}
|
||||
>
|
||||
{cat.isActive ? <ToggleRight className="w-6 h-6" /> : <ToggleLeft className="w-6 h-6" />}
|
||||
</button>
|
||||
@@ -426,7 +426,7 @@ export default function AdminDiscoverPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCategory(cat.id)}
|
||||
className="p-1 text-muted hover:text-red-400 rounded"
|
||||
className="p-1 text-muted hover:text-red-600 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -443,7 +443,7 @@ export default function AdminDiscoverPage() {
|
||||
<p className="text-sm text-muted">Доверенные источники новостей</p>
|
||||
<button
|
||||
onClick={() => setShowSourceModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-accent text-accent-foreground rounded-lg text-sm hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
@@ -474,15 +474,15 @@ export default function AdminDiscoverPage() {
|
||||
<div className="text-sm">
|
||||
<span className="text-muted">Доверие:</span>
|
||||
<span className={`ml-1 font-medium ${
|
||||
source.trustScore >= 0.7 ? 'text-green-400' :
|
||||
source.trustScore >= 0.4 ? 'text-yellow-400' : 'text-red-400'
|
||||
source.trustScore >= 0.7 ? 'text-green-600' :
|
||||
source.trustScore >= 0.4 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{(source.trustScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteSource(source.id)}
|
||||
className="p-1 text-muted hover:text-red-400 rounded"
|
||||
className="p-1 text-muted hover:text-red-600 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-500/10 text-red-400 p-4 rounded-lg">
|
||||
<div className="bg-red-500/10 text-red-600 p-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="fixed inset-0 bg-primary/25 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">
|
||||
{post ? 'Редактировать пост' : 'Новый пост'}
|
||||
@@ -129,7 +129,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="flex-1 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
@@ -204,13 +204,13 @@ export default function AdminPostsPage() {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'bg-green-500/10 text-green-400';
|
||||
return 'bg-green-500/10 text-green-600';
|
||||
case 'draft':
|
||||
return 'bg-yellow-500/10 text-yellow-400';
|
||||
return 'bg-yellow-500/10 text-yellow-600';
|
||||
case 'archived':
|
||||
return 'bg-gray-500/10 text-gray-400';
|
||||
return 'bg-surface text-muted';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-400';
|
||||
return 'bg-surface text-muted';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function AdminPostsPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalPost(null)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Создать
|
||||
@@ -338,7 +338,7 @@ export default function AdminPostsPage() {
|
||||
handlePublish(post.id);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-green-400 hover:bg-base"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-green-600 hover:bg-base"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Опубликовать
|
||||
@@ -358,7 +358,7 @@ export default function AdminPostsPage() {
|
||||
handleDelete(post.id);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-base"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function AdminSettingsPage() {
|
||||
|
||||
if (!settings || !features) {
|
||||
return (
|
||||
<div className="bg-red-500/10 text-red-400 p-4 rounded-lg">
|
||||
<div className="bg-red-500/10 text-red-600 p-4 rounded-lg">
|
||||
Не удалось загрузить настройки
|
||||
</div>
|
||||
);
|
||||
@@ -224,7 +224,7 @@ export default function AdminSettingsPage() {
|
||||
<button
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
@@ -252,7 +252,7 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleFeature(feature.key as keyof FeatureFlags)}
|
||||
className={features[feature.key as keyof FeatureFlags] ? 'text-green-400' : 'text-muted'}
|
||||
className={features[feature.key as keyof FeatureFlags] ? 'text-green-600' : 'text-muted'}
|
||||
>
|
||||
{features[feature.key as keyof FeatureFlags] ? (
|
||||
<ToggleRight className="w-8 h-8" />
|
||||
@@ -267,7 +267,7 @@ export default function AdminSettingsPage() {
|
||||
<button
|
||||
onClick={handleSaveFeatures}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
@@ -345,7 +345,7 @@ export default function AdminSettingsPage() {
|
||||
<button
|
||||
onClick={handleSaveLLM}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
@@ -397,7 +397,7 @@ export default function AdminSettingsPage() {
|
||||
...settings,
|
||||
searchSettings: { ...settings.searchSettings, safeSearch: !settings.searchSettings.safeSearch }
|
||||
})}
|
||||
className={settings.searchSettings.safeSearch ? 'text-green-400' : 'text-muted'}
|
||||
className={settings.searchSettings.safeSearch ? 'text-green-600' : 'text-muted'}
|
||||
>
|
||||
{settings.searchSettings.safeSearch ? (
|
||||
<ToggleRight className="w-8 h-8" />
|
||||
@@ -411,7 +411,7 @@ export default function AdminSettingsPage() {
|
||||
<button
|
||||
onClick={handleSaveSearch}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
|
||||
@@ -44,7 +44,7 @@ function UserModal({ user, onClose, onSave }: UserModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="fixed inset-0 bg-primary/25 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-surface rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-primary mb-4">
|
||||
{user ? 'Редактировать пользователя' : 'Новый пользователь'}
|
||||
@@ -117,7 +117,7 @@ function UserModal({ user, onClose, onSave }: UserModalProps) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="flex-1 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
@@ -198,7 +198,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalUser(null)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-foreground rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
@@ -263,8 +263,8 @@ export default function AdminUsersPage() {
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/10 text-purple-400'
|
||||
: 'bg-blue-500/10 text-blue-400'
|
||||
? 'bg-purple-500/10 text-purple-600'
|
||||
: 'bg-blue-500/10 text-blue-600'
|
||||
}`}>
|
||||
{user.role === 'admin' && <Crown className="w-3 h-3" />}
|
||||
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||
@@ -273,10 +273,10 @@ export default function AdminUsersPage() {
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
user.tier === 'business'
|
||||
? 'bg-yellow-500/10 text-yellow-400'
|
||||
? 'bg-yellow-500/10 text-yellow-600'
|
||||
: user.tier === 'pro'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-gray-500/10 text-gray-400'
|
||||
? 'bg-green-500/10 text-green-600'
|
||||
: 'bg-surface text-muted'
|
||||
}`}>
|
||||
{user.tier.toUpperCase()}
|
||||
</span>
|
||||
@@ -284,8 +284,8 @@ export default function AdminUsersPage() {
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
|
||||
user.isActive
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
? 'bg-green-500/10 text-green-600'
|
||||
: 'bg-red-500/10 text-red-600'
|
||||
}`}>
|
||||
{user.isActive ? <UserCheck className="w-3 h-3" /> : <UserX className="w-3 h-3" />}
|
||||
{user.isActive ? 'Активен' : 'Неактивен'}
|
||||
@@ -329,7 +329,7 @@ export default function AdminUsersPage() {
|
||||
handleDelete(user.id);
|
||||
setActiveMenu(null);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-base"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-base"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function FinancePage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sector.tickers.slice(0, 3).map((stock, i) => (
|
||||
{(sector.tickers ?? []).slice(0, 3).map((stock, i) => (
|
||||
<StockRow key={stock.symbol} stock={stock} market={currentMarket} delay={i * 0.02} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -80,9 +80,9 @@ const healthArticles: Article[] = [
|
||||
];
|
||||
|
||||
const quickServices = [
|
||||
{ icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-400' },
|
||||
{ icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-400' },
|
||||
{ icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-400' },
|
||||
{ icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-600' },
|
||||
{ icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-600' },
|
||||
{ icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-600' },
|
||||
{ icon: Sparkles, label: 'AI Консультант', color: 'active-gradient text-gradient' },
|
||||
];
|
||||
|
||||
@@ -159,9 +159,9 @@ export default function MedicinePage() {
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-3 p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl mb-6">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-400 mb-1">Важно</p>
|
||||
<p className="text-sm font-medium text-amber-600 mb-1">Важно</p>
|
||||
<p className="text-xs text-muted">
|
||||
Информация носит справочный характер и не заменяет консультацию врача.
|
||||
При серьёзных симптомах обратитесь к специалисту.
|
||||
@@ -236,7 +236,7 @@ export default function MedicinePage() {
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center">
|
||||
<HeartPulse className="w-4 h-4 text-red-400" />
|
||||
<HeartPulse className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Частые симптомы</h2>
|
||||
</div>
|
||||
@@ -256,7 +256,7 @@ export default function MedicinePage() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-green-500/10 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-green-400" />
|
||||
<FileText className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-primary">Полезные статьи</h2>
|
||||
</div>
|
||||
@@ -271,8 +271,8 @@ export default function MedicinePage() {
|
||||
{/* Emergency Info */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm font-medium text-red-400">Экстренная помощь</span>
|
||||
<AlertTriangle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">Экстренная помощь</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted mb-3">
|
||||
При угрозе жизни немедленно вызывайте скорую помощь
|
||||
@@ -280,13 +280,13 @@ export default function MedicinePage() {
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="tel:103"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
📞 103
|
||||
</a>
|
||||
<a
|
||||
href="tel:112"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
📞 112
|
||||
</a>
|
||||
|
||||
@@ -335,7 +335,7 @@ export default function SpaceDetailPage() {
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${
|
||||
member.role === 'owner'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
? 'bg-amber-500/15 text-amber-600'
|
||||
: member.role === 'admin'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface text-muted'
|
||||
@@ -394,7 +394,7 @@ export default function SpaceDetailPage() {
|
||||
{/* Invite Modal */}
|
||||
<Dialog.Root open={showInviteModal} onOpenChange={setShowInviteModal}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50" />
|
||||
<Dialog.Overlay className="fixed inset-0 bg-primary/30 backdrop-blur-sm z-50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-elevated border border-border rounded-2xl p-6 z-50 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Dialog.Title className="text-lg font-semibold text-primary">
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function NewSpacePage() {
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-white/10 backdrop-blur-sm flex items-center justify-center text-2xl">
|
||||
<div className="w-14 h-14 rounded-xl bg-surface/60 backdrop-blur-sm flex items-center justify-center text-2xl">
|
||||
{formData.icon}
|
||||
</div>
|
||||
<div>
|
||||
@@ -183,7 +183,7 @@ export default function NewSpacePage() {
|
||||
onClick={() => setFormData((f) => ({ ...f, color: color.id }))}
|
||||
className={`w-10 h-10 rounded-xl ${color.class} transition-all ${
|
||||
formData.color === color.id
|
||||
? 'ring-2 ring-white ring-offset-2 ring-offset-base'
|
||||
? 'ring-2 ring-accent ring-offset-2 ring-offset-base'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
title={color.label}
|
||||
@@ -237,7 +237,7 @@ export default function NewSpacePage() {
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
className={`absolute top-1 w-5 h-5 rounded-full bg-elevated shadow transition-transform ${
|
||||
formData.isPublic ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function SpacesPage() {
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="w-12 h-12 rounded-xl bg-surface/60 backdrop-blur-sm flex items-center justify-center">
|
||||
<FolderOpen className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -173,7 +173,7 @@ export default function SpacesPage() {
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1.5 hover:bg-surface/50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4 text-secondary" />
|
||||
</button>
|
||||
@@ -229,7 +229,7 @@ export default function SpacesPage() {
|
||||
|
||||
{/* Members avatars */}
|
||||
{space.members && space.members.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-4 pt-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-1 mt-4 pt-4 border-t border-border/50">
|
||||
<div className="flex -space-x-2">
|
||||
{space.members.slice(0, 4).map((member, idx) => (
|
||||
<div
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
50
backend/webui/src/app/api/chat/route.ts
Normal file
50
backend/webui/src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3015';
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
const targetUrl = `${API_URL}/api/chat`;
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader) {
|
||||
headers.set('Authorization', authHeader);
|
||||
}
|
||||
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('Accept', 'application/x-ndjson');
|
||||
|
||||
try {
|
||||
const body = await request.text();
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/x-ndjson';
|
||||
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
return NextResponse.json({ error: 'No response body' }, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(stream, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Chat API Proxy] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Service unavailable' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
131
backend/webui/src/app/api/v1/[...path]/route.ts
Normal file
131
backend/webui/src/app/api/v1/[...path]/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3015';
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
params: { path: string[] }
|
||||
): Promise<NextResponse> {
|
||||
const path = params.path.join('/');
|
||||
const url = new URL(request.url);
|
||||
const targetUrl = `${API_URL}/api/v1/${path}${url.search}`;
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader) {
|
||||
headers.set('Authorization', authHeader);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
headers.set('Accept', request.headers.get('accept') || 'application/json');
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
const body = await request.text();
|
||||
if (body) {
|
||||
fetchOptions.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, fetchOptions);
|
||||
|
||||
const responseContentType = response.headers.get('content-type') || 'application/json';
|
||||
|
||||
if (
|
||||
responseContentType.includes('application/x-ndjson') ||
|
||||
responseContentType.includes('text/event-stream') ||
|
||||
responseContentType.includes('application/octet-stream')
|
||||
) {
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
return NextResponse.json({ error: 'No response body' }, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(stream, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API Proxy] Error proxying to', targetUrl, error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Service unavailable' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
return proxyRequest(request, params);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
return proxyRequest(request, params);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
return proxyRequest(request, params);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
return proxyRequest(request, params);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
return proxyRequest(request, params);
|
||||
}
|
||||
|
||||
export async function OPTIONS(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,49 +6,49 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
/* Cursor IDE 2026 Dark Theme */
|
||||
/* Base backgrounds - very dark with slight blue tint */
|
||||
--bg-base: 240 6% 4%;
|
||||
--bg-elevated: 240 6% 8%;
|
||||
--bg-surface: 240 5% 11%;
|
||||
--bg-overlay: 240 5% 14%;
|
||||
--bg-muted: 240 4% 18%;
|
||||
/* GooSeek Light Asphalt/Graphite Theme */
|
||||
/* Base backgrounds — light grays with warm undertone */
|
||||
--bg-base: 220 14% 96%;
|
||||
--bg-elevated: 220 13% 100%;
|
||||
--bg-surface: 220 13% 93%;
|
||||
--bg-overlay: 220 12% 89%;
|
||||
--bg-muted: 220 10% 85%;
|
||||
|
||||
/* Text colors - zinc-based for readability */
|
||||
--text-primary: 240 5% 92%;
|
||||
--text-secondary: 240 4% 68%;
|
||||
--text-muted: 240 4% 48%;
|
||||
--text-faint: 240 3% 38%;
|
||||
/* Text colors — graphite/charcoal for readability */
|
||||
--text-primary: 220 15% 16%;
|
||||
--text-secondary: 220 10% 38%;
|
||||
--text-muted: 220 8% 54%;
|
||||
--text-faint: 220 6% 68%;
|
||||
|
||||
/* Accent colors - indigo/purple like Cursor */
|
||||
--accent: 239 84% 67%;
|
||||
--accent-hover: 239 84% 74%;
|
||||
--accent-muted: 239 60% 55%;
|
||||
--accent-subtle: 239 40% 25%;
|
||||
/* Accent colors — slate-blue, professional */
|
||||
--accent: 224 64% 48%;
|
||||
--accent-hover: 224 64% 42%;
|
||||
--accent-muted: 224 45% 58%;
|
||||
--accent-subtle: 224 30% 90%;
|
||||
|
||||
/* Secondary accent - cyan for variety */
|
||||
--accent-secondary: 187 85% 55%;
|
||||
--accent-secondary-muted: 187 60% 40%;
|
||||
/* Secondary accent — teal for variety */
|
||||
--accent-secondary: 180 50% 38%;
|
||||
--accent-secondary-muted: 180 35% 52%;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 142 71% 45%;
|
||||
--success-muted: 142 50% 35%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-muted: 38 70% 40%;
|
||||
--error: 0 72% 51%;
|
||||
--error-muted: 0 60% 40%;
|
||||
--success: 152 60% 38%;
|
||||
--success-muted: 152 40% 48%;
|
||||
--warning: 38 85% 48%;
|
||||
--warning-muted: 38 60% 55%;
|
||||
--error: 0 65% 50%;
|
||||
--error-muted: 0 50% 58%;
|
||||
|
||||
/* Border colors */
|
||||
--border: 240 4% 16%;
|
||||
--border-hover: 240 4% 22%;
|
||||
--border-focus: 239 60% 50%;
|
||||
--border: 220 12% 86%;
|
||||
--border-hover: 220 12% 78%;
|
||||
--border-focus: 224 64% 48%;
|
||||
|
||||
/* Legacy mappings for compatibility */
|
||||
--background: var(--bg-base);
|
||||
--foreground: var(--text-primary);
|
||||
--card: var(--bg-elevated);
|
||||
--card-foreground: var(--text-primary);
|
||||
--popover: var(--bg-surface);
|
||||
--popover: var(--bg-elevated);
|
||||
--popover-foreground: var(--text-primary);
|
||||
--primary: var(--accent);
|
||||
--primary-foreground: 0 0% 100%;
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -85,56 +85,65 @@ body {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
/* ========================
|
||||
Gradient backgrounds
|
||||
======================== */
|
||||
|
||||
.bg-gradient-main {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(240 6% 4%) 0%,
|
||||
hsl(240 6% 5%) 50%,
|
||||
hsl(240 5% 6%) 100%
|
||||
160deg,
|
||||
hsl(220 16% 96%) 0%,
|
||||
hsl(220 14% 94%) 30%,
|
||||
hsl(225 14% 92%) 60%,
|
||||
hsl(220 12% 95%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-elevated {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(240 6% 9% / 0.9) 0%,
|
||||
hsl(240 6% 7% / 0.8) 100%
|
||||
145deg,
|
||||
hsl(0 0% 100%) 0%,
|
||||
hsl(220 14% 97%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(240 5% 11% / 0.6) 0%,
|
||||
hsl(240 5% 9% / 0.4) 100%
|
||||
145deg,
|
||||
hsl(0 0% 100% / 0.9) 0%,
|
||||
hsl(220 14% 96% / 0.7) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Accent gradient for special elements */
|
||||
.bg-gradient-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 67%) 0%,
|
||||
hsl(260 84% 67%) 50%,
|
||||
hsl(239 84% 67%) 100%
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(224 64% 48%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Text gradient */
|
||||
/* ========================
|
||||
Text gradient
|
||||
======================== */
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
hsl(224 64% 42%) 0%,
|
||||
hsl(240 55% 48%) 50%,
|
||||
hsl(180 50% 38%) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Gradient border button */
|
||||
/* ========================
|
||||
Button gradients
|
||||
======================== */
|
||||
|
||||
.btn-gradient {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
@@ -143,7 +152,7 @@ body {
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(239 84% 74%);
|
||||
color: hsl(224 64% 42%);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -156,9 +165,9 @@ body {
|
||||
padding: 1.5px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -171,9 +180,9 @@ body {
|
||||
.btn-gradient:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.1) 0%,
|
||||
hsl(260 90% 75% / 0.1) 50%,
|
||||
hsl(187 85% 65% / 0.1) 100%
|
||||
hsl(224 64% 48% / 0.08) 0%,
|
||||
hsl(240 55% 52% / 0.06) 50%,
|
||||
hsl(180 50% 42% / 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +190,6 @@ body {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Gradient border - larger version */
|
||||
.btn-gradient-lg {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
@@ -190,7 +198,7 @@ body {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(239 84% 74%);
|
||||
color: hsl(224 64% 42%);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -203,9 +211,9 @@ body {
|
||||
padding: 2px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -218,33 +226,35 @@ body {
|
||||
.btn-gradient-lg:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.1) 0%,
|
||||
hsl(260 90% 75% / 0.1) 50%,
|
||||
hsl(187 85% 65% / 0.1) 100%
|
||||
hsl(224 64% 48% / 0.08) 0%,
|
||||
hsl(240 55% 52% / 0.06) 50%,
|
||||
hsl(180 50% 42% / 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Gradient text for buttons */
|
||||
.btn-gradient-text {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
hsl(224 64% 42%) 0%,
|
||||
hsl(240 55% 48%) 50%,
|
||||
hsl(180 50% 38%) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Gradient active state for selectable items */
|
||||
/* ========================
|
||||
Active / Hover gradients
|
||||
======================== */
|
||||
|
||||
.active-gradient {
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.1) 0%,
|
||||
hsl(260 90% 75% / 0.08) 50%,
|
||||
hsl(187 85% 65% / 0.06) 100%
|
||||
hsl(224 64% 48% / 0.08) 0%,
|
||||
hsl(240 55% 52% / 0.06) 50%,
|
||||
hsl(180 50% 42% / 0.04) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,9 +266,9 @@ body {
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.4) 0%,
|
||||
hsl(260 90% 75% / 0.3) 50%,
|
||||
hsl(187 85% 65% / 0.2) 100%
|
||||
hsl(224 64% 48% / 0.3) 0%,
|
||||
hsl(240 55% 52% / 0.2) 50%,
|
||||
hsl(180 50% 42% / 0.15) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -268,7 +278,6 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gradient border for cards on hover */
|
||||
.hover-gradient:hover {
|
||||
position: relative;
|
||||
}
|
||||
@@ -281,9 +290,9 @@ body {
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.3) 0%,
|
||||
hsl(260 90% 75% / 0.2) 50%,
|
||||
hsl(187 85% 65% / 0.15) 100%
|
||||
hsl(224 64% 48% / 0.2) 0%,
|
||||
hsl(240 55% 52% / 0.15) 50%,
|
||||
hsl(180 50% 42% / 0.1) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -293,16 +302,19 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gradient icon wrapper */
|
||||
/* ========================
|
||||
Icon gradient wrapper
|
||||
======================== */
|
||||
|
||||
.icon-gradient {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.15) 0%,
|
||||
hsl(260 90% 75% / 0.1) 50%,
|
||||
hsl(187 85% 65% / 0.08) 100%
|
||||
hsl(224 64% 48% / 0.1) 0%,
|
||||
hsl(240 55% 52% / 0.07) 50%,
|
||||
hsl(180 50% 42% / 0.05) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
@@ -315,9 +327,9 @@ body {
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.25) 0%,
|
||||
hsl(260 90% 75% / 0.2) 50%,
|
||||
hsl(187 85% 65% / 0.15) 100%
|
||||
hsl(224 64% 48% / 0.2) 0%,
|
||||
hsl(240 55% 52% / 0.15) 50%,
|
||||
hsl(180 50% 42% / 0.1) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
@@ -327,53 +339,58 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gradient focus state for inputs */
|
||||
/* ========================
|
||||
Input gradient focus
|
||||
======================== */
|
||||
|
||||
.input-gradient:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 1px hsl(239 84% 74% / 0.4),
|
||||
0 0 0 3px hsl(239 84% 74% / 0.1);
|
||||
box-shadow: 0 0 0 1px hsl(224 64% 48% / 0.35),
|
||||
0 0 0 3px hsl(224 64% 48% / 0.08);
|
||||
}
|
||||
|
||||
/* Gradient loader */
|
||||
/* ========================
|
||||
Loader / Progress / Stat
|
||||
======================== */
|
||||
|
||||
.loader-gradient {
|
||||
color: hsl(239 84% 74%);
|
||||
filter: drop-shadow(0 0 8px hsl(239 84% 74% / 0.3));
|
||||
color: hsl(224 64% 48%);
|
||||
filter: drop-shadow(0 0 6px hsl(224 64% 48% / 0.2));
|
||||
}
|
||||
|
||||
/* Progress bar gradient */
|
||||
.progress-gradient {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Stat card gradient */
|
||||
.stat-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.08) 0%,
|
||||
hsl(260 90% 75% / 0.05) 100%
|
||||
hsl(224 64% 48% / 0.06) 0%,
|
||||
hsl(240 55% 52% / 0.03) 100%
|
||||
);
|
||||
border: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.25) 0%,
|
||||
hsl(187 85% 65% / 0.15) 100%
|
||||
hsl(224 64% 48% / 0.18) 0%,
|
||||
hsl(180 50% 42% / 0.1) 100%
|
||||
) 1;
|
||||
}
|
||||
|
||||
/* Gradient glow effect */
|
||||
.glow-gradient {
|
||||
box-shadow: 0 0 20px hsl(239 84% 74% / 0.15),
|
||||
0 0 40px hsl(260 90% 75% / 0.1),
|
||||
0 0 60px hsl(187 85% 65% / 0.05);
|
||||
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.1),
|
||||
0 1px 4px hsl(220 14% 50% / 0.08);
|
||||
}
|
||||
|
||||
/* Border left gradient indicator */
|
||||
/* ========================
|
||||
Border left gradient
|
||||
======================== */
|
||||
|
||||
.border-l-gradient {
|
||||
position: relative;
|
||||
}
|
||||
@@ -388,14 +405,17 @@ body {
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 52%) 50%,
|
||||
hsl(180 50% 42%) 100%
|
||||
);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Modern thin scrollbars */
|
||||
/* ========================
|
||||
Scrollbars
|
||||
======================== */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -406,15 +426,14 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(240 4% 20%);
|
||||
background: hsl(220 10% 78%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(240 4% 28%);
|
||||
background: hsl(220 10% 66%);
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -424,11 +443,33 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(239 84% 67% / 0.3);
|
||||
.poi-carousel {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(220 10% 78% / 0.4) transparent;
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar-thumb {
|
||||
background: hsl(220 10% 78% / 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.poi-carousel::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(220 10% 78% / 0.7);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(224 64% 48% / 0.2);
|
||||
color: hsl(220 15% 16%);
|
||||
}
|
||||
|
||||
/* Code font */
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
@@ -440,93 +481,103 @@ body {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass morphism card */
|
||||
.glass-card {
|
||||
@apply bg-surface/60 backdrop-blur-xl border border-border rounded-2xl;
|
||||
@apply bg-elevated/80 backdrop-blur-xl border border-border rounded-2xl;
|
||||
box-shadow: 0 1px 3px hsl(220 14% 50% / 0.06),
|
||||
0 4px 12px hsl(220 14% 50% / 0.04);
|
||||
}
|
||||
|
||||
/* Surface card with hover */
|
||||
.surface-card {
|
||||
@apply bg-elevated/40 backdrop-blur-sm border border-border/50 rounded-xl;
|
||||
@apply bg-elevated/70 backdrop-blur-sm border border-border/60 rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
box-shadow: 0 1px 2px hsl(220 14% 50% / 0.05);
|
||||
}
|
||||
|
||||
.surface-card:hover {
|
||||
@apply border-border-hover bg-elevated/60;
|
||||
@apply border-border-hover bg-elevated/90;
|
||||
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08),
|
||||
0 1px 3px hsl(220 14% 50% / 0.05);
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input-cursor {
|
||||
@apply bg-surface/50 border border-border rounded-xl px-4 py-3;
|
||||
@apply bg-elevated border border-border rounded-xl px-4 py-3;
|
||||
@apply text-primary placeholder:text-muted;
|
||||
@apply focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20;
|
||||
@apply focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/15;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Primary button - gradient accent */
|
||||
.btn-primary {
|
||||
@apply bg-accent/10 text-accent-foreground border border-accent/30;
|
||||
@apply bg-accent/8 text-accent border border-accent/25;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
|
||||
@apply hover:bg-accent/20 hover:border-accent/50;
|
||||
@apply active:bg-accent/25;
|
||||
@apply hover:bg-accent/14 hover:border-accent/40;
|
||||
@apply active:bg-accent/18;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-primary-solid {
|
||||
@apply bg-accent text-white border-none;
|
||||
@apply text-white border-none;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
|
||||
@apply hover:bg-accent-hover;
|
||||
@apply active:scale-[0.98];
|
||||
@apply transition-all duration-150;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(232 58% 52%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.btn-primary-solid:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(224 64% 42%) 0%,
|
||||
hsl(232 58% 46%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
@apply bg-surface/50 text-secondary border border-border;
|
||||
@apply bg-elevated/80 text-secondary border border-border;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
|
||||
@apply hover:bg-muted/50 hover:text-primary hover:border-border-hover;
|
||||
@apply hover:bg-overlay/60 hover:text-primary hover:border-border-hover;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
@apply text-secondary hover:text-primary;
|
||||
@apply hover:bg-surface/50 rounded-xl px-3 py-2;
|
||||
@apply hover:bg-surface/60 rounded-xl px-3 py-2;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.btn-icon {
|
||||
@apply w-9 h-9 flex items-center justify-center rounded-xl;
|
||||
@apply text-muted hover:text-secondary hover:bg-surface/50;
|
||||
@apply text-muted hover:text-secondary hover:bg-surface/60;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-icon-active {
|
||||
@apply bg-accent/15 text-accent;
|
||||
@apply bg-accent/10 text-accent;
|
||||
}
|
||||
|
||||
/* Navigation item */
|
||||
.nav-item {
|
||||
@apply flex items-center gap-3 px-3 py-2.5 rounded-xl;
|
||||
@apply text-secondary hover:text-primary;
|
||||
@apply hover:bg-surface/40 transition-all duration-150;
|
||||
@apply hover:bg-surface/50 transition-all duration-150;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
@apply bg-accent/10 text-primary;
|
||||
@apply bg-accent/8 text-primary;
|
||||
@apply border-l-2 border-accent;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-elevated/40 backdrop-blur-sm;
|
||||
@apply border border-border/50 rounded-2xl;
|
||||
@apply bg-elevated/70 backdrop-blur-sm;
|
||||
@apply border border-border/60 rounded-2xl;
|
||||
@apply transition-all duration-200;
|
||||
box-shadow: 0 1px 3px hsl(220 14% 50% / 0.05);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-border bg-elevated/60;
|
||||
@apply border-border bg-elevated/90;
|
||||
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08);
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@@ -534,37 +585,36 @@ body {
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
@apply border-accent/30 shadow-lg shadow-accent/5;
|
||||
@apply border-accent/25 shadow-lg;
|
||||
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.08),
|
||||
0 1px 4px hsl(220 14% 50% / 0.05);
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.section-header {
|
||||
@apply text-xs font-semibold uppercase tracking-wider text-muted;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-medium;
|
||||
@apply bg-surface text-secondary border border-border/50;
|
||||
@apply bg-surface text-secondary border border-border/60;
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
@apply bg-accent/15 text-accent border-accent/30;
|
||||
@apply bg-accent/8 text-accent border-accent/20;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success/15 text-success border-success/30;
|
||||
@apply bg-success/10 text-success border-success/20;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning/15 text-warning border-warning/30;
|
||||
@apply bg-warning/10 text-warning border-warning/20;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply bg-error/15 text-error border-error/30;
|
||||
@apply bg-error/10 text-error border-error/20;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
@apply border-t border-border/50;
|
||||
}
|
||||
@@ -575,27 +625,24 @@ body {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-accent {
|
||||
box-shadow: 0 0 20px hsl(239 84% 67% / 0.15),
|
||||
0 0 40px hsl(239 84% 67% / 0.1);
|
||||
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.1),
|
||||
0 1px 4px hsl(224 64% 48% / 0.06);
|
||||
}
|
||||
|
||||
.glow-accent-strong {
|
||||
box-shadow: 0 0 30px hsl(239 84% 67% / 0.25),
|
||||
0 0 60px hsl(239 84% 67% / 0.15);
|
||||
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.15),
|
||||
0 2px 8px hsl(224 64% 48% / 0.1);
|
||||
}
|
||||
|
||||
.glow-subtle {
|
||||
box-shadow: 0 4px 20px hsl(240 6% 4% / 0.5);
|
||||
box-shadow: 0 2px 12px hsl(220 14% 50% / 0.08);
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-offset-2 focus:ring-offset-base;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-accent/40 focus:ring-offset-2 focus:ring-offset-elevated;
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.animate-delay-75 { animation-delay: 75ms; }
|
||||
.animate-delay-100 { animation-delay: 100ms; }
|
||||
.animate-delay-150 { animation-delay: 150ms; }
|
||||
@@ -605,7 +652,10 @@ body {
|
||||
.animate-delay-500 { animation-delay: 500ms; }
|
||||
}
|
||||
|
||||
/* Smooth animations */
|
||||
/* ========================
|
||||
Animations
|
||||
======================== */
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; transform: translateY(4px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
@@ -643,10 +693,10 @@ body {
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px hsl(239 84% 67% / 0.15);
|
||||
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.08);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px hsl(239 84% 67% / 0.25);
|
||||
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,9 +727,9 @@ body {
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(240 5% 11%) 0%,
|
||||
hsl(240 5% 16%) 50%,
|
||||
hsl(240 5% 11%) 100%
|
||||
hsl(220 14% 93%) 0%,
|
||||
hsl(220 14% 88%) 50%,
|
||||
hsl(220 14% 93%) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
@@ -689,11 +739,10 @@ body {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* =====================
|
||||
/* ========================
|
||||
Computer UI Styles
|
||||
===================== */
|
||||
======================== */
|
||||
|
||||
/* Action step container */
|
||||
.action-step {
|
||||
@apply relative;
|
||||
}
|
||||
@@ -712,14 +761,9 @@ body {
|
||||
);
|
||||
}
|
||||
|
||||
/* Strikethrough animation for completed tasks */
|
||||
@keyframes strikethrough {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
from { width: 0; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
|
||||
.task-completed {
|
||||
@@ -736,7 +780,6 @@ body {
|
||||
animation: strikethrough 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
@keyframes typing-dot {
|
||||
0%, 20% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
@@ -755,21 +798,20 @@ body {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
/* Code highlight colors */
|
||||
.code-keyword { color: #c792ea; }
|
||||
.code-string { color: #c3e88d; }
|
||||
.code-comment { color: #546e7a; font-style: italic; }
|
||||
.code-function { color: #82aaff; }
|
||||
.code-number { color: #f78c6c; }
|
||||
.code-operator { color: #89ddff; }
|
||||
/* Code highlight colors — light theme */
|
||||
.code-keyword { color: #7c3aed; }
|
||||
.code-string { color: #059669; }
|
||||
.code-comment { color: #94a3b8; font-style: italic; }
|
||||
.code-function { color: #2563eb; }
|
||||
.code-number { color: #ea580c; }
|
||||
.code-operator { color: #0891b2; }
|
||||
|
||||
/* Thinking block pulse */
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% {
|
||||
background: hsl(239 84% 67% / 0.05);
|
||||
background: hsl(224 64% 48% / 0.04);
|
||||
}
|
||||
50% {
|
||||
background: hsl(239 84% 67% / 0.12);
|
||||
background: hsl(224 64% 48% / 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,7 +819,6 @@ body {
|
||||
animation: thinking-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Status indicator pulse */
|
||||
@keyframes status-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
@@ -793,46 +834,38 @@ body {
|
||||
animation: status-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* File card hover effect */
|
||||
.file-card-hover {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 8px 25px -5px hsl(220 14% 50% / 0.15);
|
||||
}
|
||||
|
||||
/* Collapsible animation */
|
||||
.collapsible-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out, opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Progress bar shimmer */
|
||||
@keyframes progress-shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.progress-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(239 84% 67%) 0%,
|
||||
hsl(260 90% 75%) 25%,
|
||||
hsl(187 85% 65%) 50%,
|
||||
hsl(260 90% 75%) 75%,
|
||||
hsl(239 84% 67%) 100%
|
||||
hsl(224 64% 48%) 0%,
|
||||
hsl(240 55% 56%) 25%,
|
||||
hsl(180 50% 42%) 50%,
|
||||
hsl(240 55% 56%) 75%,
|
||||
hsl(224 64% 48%) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: progress-shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Event timeline line */
|
||||
.timeline-line {
|
||||
position: relative;
|
||||
}
|
||||
@@ -851,9 +884,8 @@ body {
|
||||
);
|
||||
}
|
||||
|
||||
/* Artifact card glow on hover */
|
||||
.artifact-glow:hover {
|
||||
box-shadow:
|
||||
0 0 20px hsl(142 71% 45% / 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
0 4px 16px hsl(152 60% 38% / 0.1),
|
||||
0 2px 8px hsl(220 14% 50% / 0.08);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<html lang="ru">
|
||||
<body className="antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
|
||||
@@ -168,7 +168,7 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus, v
|
||||
<div className="relative w-full">
|
||||
<motion.div
|
||||
animate={{
|
||||
borderColor: isFocused ? 'hsl(239 84% 67% / 0.4)' : 'hsl(240 4% 16% / 0.8)',
|
||||
borderColor: isFocused ? 'hsl(224 64% 48% / 0.35)' : 'hsl(220 12% 86%)',
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(239_84%_74%/0.3)] hover:decoration-[hsl(239_84%_74%/0.5)] transition-all"
|
||||
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(224_64%_48%/0.3)] hover:decoration-[hsl(224_64%_48%/0.5)] transition-all"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-cream-300/10 hover:bg-cream-300/20 text-cream-300 border border-cream-400/20 rounded transition-colors"
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-accent/10 hover:bg-accent/18 text-accent border border-accent/25 rounded transition-colors"
|
||||
>
|
||||
{citation.index}
|
||||
</a>
|
||||
@@ -31,9 +31,9 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-navy-800/40 hover:bg-navy-800/60 border border-navy-700/30 hover:border-cream-400/20 rounded-lg transition-all group"
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-elevated/80 hover:bg-elevated border border-border hover:border-accent/25 rounded-lg transition-all group"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium">
|
||||
<span className="w-4 h-4 rounded bg-accent/10 text-accent flex items-center justify-center text-2xs font-medium">
|
||||
{citation.index}
|
||||
</span>
|
||||
{citation.favicon && (
|
||||
@@ -46,27 +46,27 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-cream-400/80 group-hover:text-cream-200 max-w-[120px] truncate transition-colors">
|
||||
<span className="text-xs text-secondary group-hover:text-primary max-w-[120px] truncate transition-colors">
|
||||
{citation.domain}
|
||||
</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 text-cream-500/50 group-hover:text-cream-400/70 transition-colors" />
|
||||
<ExternalLink className="w-2.5 h-2.5 text-muted group-hover:text-secondary transition-colors" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className="max-w-[300px] p-4 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
|
||||
className="max-w-[300px] p-4 bg-elevated backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p className="font-medium text-sm text-cream-100 line-clamp-2 mb-2">
|
||||
<p className="font-medium text-sm text-primary line-clamp-2 mb-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
{citation.snippet && (
|
||||
<p className="text-xs text-cream-400/70 line-clamp-3 mb-3">
|
||||
<p className="text-xs text-secondary line-clamp-3 mb-3">
|
||||
{citation.snippet}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-2xs text-cream-500/60">
|
||||
<div className="flex items-center gap-2 text-2xs text-muted">
|
||||
{citation.favicon && (
|
||||
<img
|
||||
src={citation.favicon}
|
||||
@@ -79,7 +79,7 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
)}
|
||||
<span className="truncate">{citation.domain}</span>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-navy-800" />
|
||||
<Tooltip.Arrow className="fill-elevated" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
@@ -107,14 +107,14 @@ export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button className="text-xs text-cream-500/70 hover:text-cream-300 px-2.5 py-1.5 rounded-lg hover:bg-navy-800/30 transition-colors">
|
||||
<button className="text-xs text-muted hover:text-secondary px-2.5 py-1.5 rounded-lg hover:bg-surface/60 transition-colors">
|
||||
+{remaining} ещё
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className="max-w-[320px] p-3 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
|
||||
className="max-w-[320px] p-3 bg-elevated backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
@@ -124,18 +124,18 @@ export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-navy-700/50 transition-colors"
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-surface/60 transition-colors"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium flex-shrink-0">
|
||||
<span className="w-4 h-4 rounded bg-accent/10 text-accent flex items-center justify-center text-2xs font-medium flex-shrink-0">
|
||||
{citation.index}
|
||||
</span>
|
||||
<span className="text-xs text-cream-200 truncate">
|
||||
<span className="text-xs text-primary truncate">
|
||||
{citation.title}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-navy-800" />
|
||||
<Tooltip.Arrow className="fill-elevated" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
|
||||
if (variant === 'large') {
|
||||
return (
|
||||
<article className="group relative overflow-hidden rounded-2xl bg-navy-900/40 border border-navy-700/20 hover:border-cream-400/15 transition-all duration-300">
|
||||
<article className="group relative overflow-hidden rounded-2xl bg-elevated/80 border border-border hover:border-accent/25 transition-all duration-300 shadow-card">
|
||||
{item.thumbnail && (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img
|
||||
@@ -34,23 +34,23 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-500/60">{domain}</span>
|
||||
<span className="text-xs text-muted">{domain}</span>
|
||||
{item.sourcesCount && item.sourcesCount > 1 && (
|
||||
<span className="text-xs text-cream-600/40">
|
||||
<span className="text-xs text-faint">
|
||||
• {item.sourcesCount} источников
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-cream-100 mb-3 line-clamp-2 group-hover:text-cream-50 transition-colors">
|
||||
<h2 className="text-xl font-semibold text-primary mb-3 line-clamp-2 group-hover:text-accent-hover transition-colors">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-cream-400/70 text-sm line-clamp-3 mb-5">{item.content}</p>
|
||||
<p className="text-secondary text-sm line-clamp-3 mb-5">{item.content}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-cream-400/80 hover:text-cream-200 transition-colors"
|
||||
className="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Читать
|
||||
@@ -58,7 +58,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
{onSummarize && (
|
||||
<button
|
||||
onClick={() => onSummarize(item.url)}
|
||||
className="flex items-center gap-2 text-sm text-cream-300 hover:text-cream-100 transition-colors"
|
||||
className="flex items-center gap-2 text-sm text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI Саммари
|
||||
@@ -72,7 +72,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
|
||||
if (variant === 'small') {
|
||||
return (
|
||||
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-navy-800/30 transition-colors">
|
||||
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-surface/60 transition-colors">
|
||||
{item.thumbnail && (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
@@ -90,13 +90,13 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
alt=""
|
||||
className="w-3 h-3 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-600/50 truncate">{domain}</span>
|
||||
<span className="text-xs text-faint truncate">{domain}</span>
|
||||
</div>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-cream-200 group-hover:text-cream-100 line-clamp-2 transition-colors"
|
||||
className="text-sm font-medium text-secondary group-hover:text-primary line-clamp-2 transition-colors"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
@@ -106,7 +106,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="group p-4 bg-navy-900/30 border border-navy-700/20 rounded-xl hover:border-cream-400/15 hover:bg-navy-900/50 transition-all duration-200">
|
||||
<article className="group p-4 bg-elevated/60 border border-border rounded-xl hover:border-accent/25 hover:bg-elevated/90 transition-all duration-200 shadow-card">
|
||||
{item.thumbnail && (
|
||||
<div className="aspect-video rounded-lg overflow-hidden mb-4 -mx-1 -mt-1">
|
||||
<img
|
||||
@@ -125,7 +125,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-600/50">{domain}</span>
|
||||
<span className="text-xs text-faint">{domain}</span>
|
||||
</div>
|
||||
<a
|
||||
href={item.url}
|
||||
@@ -133,15 +133,15 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-medium text-cream-100 mb-2 line-clamp-2 group-hover:text-cream-50 transition-colors">
|
||||
<h3 className="font-medium text-primary mb-2 line-clamp-2 group-hover:text-accent-hover transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
</a>
|
||||
<p className="text-sm text-cream-500/60 line-clamp-2">{item.content}</p>
|
||||
<p className="text-sm text-muted line-clamp-2">{item.content}</p>
|
||||
{onSummarize && (
|
||||
<button
|
||||
onClick={() => onSummarize(item.url)}
|
||||
className="flex items-center gap-2 mt-3 text-xs text-cream-400 hover:text-cream-200 transition-colors"
|
||||
className="flex items-center gap-2 mt-3 text-xs text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Саммари
|
||||
|
||||
@@ -220,7 +220,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
|
||||
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
|
||||
user.tier === 'business'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
? 'bg-amber-500/20 text-amber-600'
|
||||
: user.tier === 'pro'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface text-muted'
|
||||
|
||||
582
backend/webui/src/components/TravelMap.tsx
Normal file
582
backend/webui/src/components/TravelMap.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
MapPin,
|
||||
Navigation,
|
||||
Plane,
|
||||
Hotel,
|
||||
Utensils,
|
||||
Camera,
|
||||
Bus,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Locate,
|
||||
Sparkles,
|
||||
TreePine,
|
||||
Theater,
|
||||
ShoppingBag,
|
||||
Gamepad2,
|
||||
Church,
|
||||
Eye,
|
||||
CalendarDays,
|
||||
Flag,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
|
||||
|
||||
interface MapglAPI {
|
||||
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
|
||||
Marker: new (map: MapglMapInstance, options: Record<string, unknown>) => MapglMarkerInstance;
|
||||
Polyline: new (map: MapglMapInstance, options: Record<string, unknown>) => MapglPolylineInstance;
|
||||
}
|
||||
|
||||
interface MapglMapInstance {
|
||||
destroy: () => void;
|
||||
setCenter: (center: number[], options?: Record<string, unknown>) => void;
|
||||
getCenter: () => number[];
|
||||
setZoom: (zoom: number, options?: Record<string, unknown>) => void;
|
||||
getZoom: () => number;
|
||||
fitBounds: (bounds: { southWest: number[]; northEast: number[] }, options?: Record<string, unknown>) => void;
|
||||
invalidateSize: () => void;
|
||||
on: (type: string, listener: (e: MapglClickEvent) => void) => void;
|
||||
off: (type: string, listener: (e: MapglClickEvent) => void) => void;
|
||||
}
|
||||
|
||||
interface MapglClickEvent {
|
||||
lngLat: number[];
|
||||
}
|
||||
|
||||
interface MapglMarkerInstance {
|
||||
destroy: () => void;
|
||||
on: (type: string, listener: () => void) => void;
|
||||
}
|
||||
|
||||
interface MapglPolylineInstance {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
interface TravelMapProps {
|
||||
route: RoutePoint[];
|
||||
routeDirection?: RouteDirection;
|
||||
center?: GeoLocation;
|
||||
zoom?: number;
|
||||
onPointClick?: (point: RoutePoint) => void;
|
||||
onMapClick?: (location: GeoLocation) => void;
|
||||
className?: string;
|
||||
showControls?: boolean;
|
||||
userLocation?: GeoLocation | null;
|
||||
}
|
||||
|
||||
const TWOGIS_API_KEY = process.env.NEXT_PUBLIC_TWOGIS_API_KEY || '';
|
||||
|
||||
const pointTypeIcons: Record<string, typeof MapPin> = {
|
||||
airport: Plane,
|
||||
hotel: Hotel,
|
||||
restaurant: Utensils,
|
||||
attraction: Camera,
|
||||
transport: Bus,
|
||||
custom: MapPin,
|
||||
museum: Camera,
|
||||
park: TreePine,
|
||||
theater: Theater,
|
||||
shopping: ShoppingBag,
|
||||
entertainment: Gamepad2,
|
||||
religious: Church,
|
||||
viewpoint: Eye,
|
||||
event: CalendarDays,
|
||||
destination: Flag,
|
||||
poi: MapPin,
|
||||
food: Utensils,
|
||||
transfer: Bus,
|
||||
origin: User,
|
||||
};
|
||||
|
||||
const pointTypeColors: Record<string, string> = {
|
||||
airport: '#3B82F6',
|
||||
hotel: '#8B5CF6',
|
||||
restaurant: '#F59E0B',
|
||||
attraction: '#10B981',
|
||||
transport: '#6366F1',
|
||||
custom: '#EC4899',
|
||||
museum: '#14B8A6',
|
||||
park: '#22C55E',
|
||||
theater: '#A855F7',
|
||||
shopping: '#F97316',
|
||||
entertainment: '#EF4444',
|
||||
religious: '#78716C',
|
||||
viewpoint: '#06B6D4',
|
||||
event: '#E11D48',
|
||||
destination: '#3B82F6',
|
||||
poi: '#10B981',
|
||||
food: '#F59E0B',
|
||||
transfer: '#94A3B8',
|
||||
origin: '#10B981',
|
||||
};
|
||||
|
||||
let mapglPromise: Promise<MapglAPI> | null = null;
|
||||
|
||||
function loadMapGL(): Promise<MapglAPI> {
|
||||
if (mapglPromise) return mapglPromise;
|
||||
|
||||
mapglPromise = import('@2gis/mapgl').then((mod) =>
|
||||
(mod.load as (url?: string) => Promise<unknown>)()
|
||||
).then((api) => api as MapglAPI);
|
||||
|
||||
return mapglPromise;
|
||||
}
|
||||
|
||||
function createMarkerSVG(index: number, color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||||
`<circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>` +
|
||||
`<text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${index}</text>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
function createUserLocationSVG(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">` +
|
||||
`<circle cx="16" cy="16" r="14" fill="#3B82F6" fill-opacity="0.2" stroke="#3B82F6" stroke-width="2"/>` +
|
||||
`<circle cx="16" cy="16" r="6" fill="#3B82F6" stroke="white" stroke-width="2"/>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
export function TravelMap({
|
||||
route,
|
||||
routeDirection,
|
||||
center,
|
||||
zoom = 10,
|
||||
onPointClick,
|
||||
onMapClick,
|
||||
className = '',
|
||||
showControls = true,
|
||||
userLocation,
|
||||
}: TravelMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<MapglMapInstance | null>(null);
|
||||
const mapglRef = useRef<MapglAPI | null>(null);
|
||||
const markersRef = useRef<MapglMarkerInstance[]>([]);
|
||||
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
|
||||
const polylineRef = useRef<MapglPolylineInstance | null>(null);
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
const onPointClickRef = useRef(onPointClick);
|
||||
const [selectedPoint, setSelectedPoint] = useState<RoutePoint | null>(null);
|
||||
const [isMapReady, setIsMapReady] = useState(false);
|
||||
const [detectedLocation, setDetectedLocation] = useState<GeoLocation | null>(null);
|
||||
const initDoneRef = useRef(false);
|
||||
|
||||
onMapClickRef.current = onMapClick;
|
||||
onPointClickRef.current = onPointClick;
|
||||
|
||||
const effectiveUserLocation = userLocation ?? detectedLocation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || initDoneRef.current) return;
|
||||
initDoneRef.current = true;
|
||||
|
||||
let destroyed = false;
|
||||
|
||||
const initCenter = center || { lat: 55.7558, lng: 37.6173 };
|
||||
|
||||
loadMapGL().then((mapgl) => {
|
||||
if (destroyed || !mapRef.current) return;
|
||||
|
||||
mapglRef.current = mapgl;
|
||||
|
||||
try {
|
||||
const map = new mapgl.Map(mapRef.current, {
|
||||
center: [initCenter.lng, initCenter.lat],
|
||||
zoom,
|
||||
key: TWOGIS_API_KEY,
|
||||
lang: 'ru',
|
||||
});
|
||||
|
||||
map.on('click', (e: MapglClickEvent) => {
|
||||
onMapClickRef.current?.({
|
||||
lat: e.lngLat[1],
|
||||
lng: e.lngLat[0],
|
||||
});
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
setIsMapReady(true);
|
||||
} catch {
|
||||
initDoneRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
markersRef.current.forEach((m) => m.destroy());
|
||||
markersRef.current = [];
|
||||
if (userMarkerRef.current) {
|
||||
userMarkerRef.current.destroy();
|
||||
userMarkerRef.current = null;
|
||||
}
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.destroy();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
initDoneRef.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userLocation || !navigator.geolocation) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const loc: GeoLocation = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
name: 'Моё местоположение',
|
||||
};
|
||||
setDetectedLocation(loc);
|
||||
|
||||
if (mapInstanceRef.current && route.length === 0) {
|
||||
mapInstanceRef.current.setCenter([loc.lng, loc.lat]);
|
||||
mapInstanceRef.current.setZoom(12);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// geolocation denied or unavailable
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mapRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
mapInstanceRef.current?.invalidateSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
|
||||
|
||||
const map = mapInstanceRef.current;
|
||||
const mapgl = mapglRef.current;
|
||||
|
||||
markersRef.current.forEach((m) => m.destroy());
|
||||
markersRef.current = [];
|
||||
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
|
||||
if (userMarkerRef.current) {
|
||||
userMarkerRef.current.destroy();
|
||||
userMarkerRef.current = null;
|
||||
}
|
||||
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
try {
|
||||
const userMkr = new mapgl.Marker(map, {
|
||||
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
|
||||
label: {
|
||||
text: '',
|
||||
offset: [0, -48],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
|
||||
size: [32, 32],
|
||||
anchor: [16, 16],
|
||||
},
|
||||
},
|
||||
});
|
||||
userMarkerRef.current = userMkr;
|
||||
} catch {
|
||||
// marker creation failed
|
||||
}
|
||||
}
|
||||
|
||||
if (route.length === 0) {
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
map.setCenter([effectiveUserLocation.lng, effectiveUserLocation.lat]);
|
||||
map.setZoom(12);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
route.forEach((point, index) => {
|
||||
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
|
||||
|
||||
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
|
||||
|
||||
try {
|
||||
const marker = new mapgl.Marker(map, {
|
||||
coordinates: [point.lng, point.lat],
|
||||
label: {
|
||||
text: String(index + 1),
|
||||
offset: [0, -48],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
|
||||
size: [28, 28],
|
||||
anchor: [14, 14],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
marker.on('click', () => {
|
||||
setSelectedPoint(point);
|
||||
onPointClickRef.current?.(point);
|
||||
});
|
||||
|
||||
markersRef.current.push(marker);
|
||||
} catch {
|
||||
// marker creation failed
|
||||
}
|
||||
});
|
||||
|
||||
const rdCoords = routeDirection?.geometry?.coordinates;
|
||||
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
|
||||
const coords = rdCoords.map(
|
||||
(c: number[]) => [c[0], c[1]] as [number, number]
|
||||
);
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
width: 4,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
|
||||
}
|
||||
} else if (route.length > 1) {
|
||||
const coords = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => [p.lng, p.lat] as [number, number]);
|
||||
if (coords.length > 1) {
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
width: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] fallback polyline failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allPoints: { lat: number; lng: number }[] = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
allPoints.push({ lat: effectiveUserLocation.lat, lng: effectiveUserLocation.lng });
|
||||
}
|
||||
|
||||
if (allPoints.length > 0) {
|
||||
const lngs = allPoints.map((p) => p.lng);
|
||||
const lats = allPoints.map((p) => p.lat);
|
||||
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
|
||||
const lngSpan = maxLng - minLng;
|
||||
const latSpan = maxLat - minLat;
|
||||
|
||||
if (latSpan < 0.001 && lngSpan < 0.001) {
|
||||
map.setCenter([allPoints[0].lng, allPoints[0].lat]);
|
||||
map.setZoom(14);
|
||||
} else {
|
||||
map.fitBounds(
|
||||
{
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (map) {
|
||||
map.setZoom(map.getZoom() + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (map) {
|
||||
map.setZoom(map.getZoom() - 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocate = useCallback(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const loc: GeoLocation = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
name: 'Моё местоположение',
|
||||
};
|
||||
setDetectedLocation(loc);
|
||||
mapInstanceRef.current?.setCenter(
|
||||
[position.coords.longitude, position.coords.latitude],
|
||||
);
|
||||
mapInstanceRef.current?.setZoom(14);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleFitRoute = useCallback(() => {
|
||||
if (!mapInstanceRef.current || route.length === 0) return;
|
||||
|
||||
const allPoints: { lat: number; lng: number }[] = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
allPoints.push({ lat: effectiveUserLocation.lat, lng: effectiveUserLocation.lng });
|
||||
}
|
||||
|
||||
if (allPoints.length === 0) return;
|
||||
|
||||
const lngs = allPoints.map((p) => p.lng);
|
||||
const lats = allPoints.map((p) => p.lat);
|
||||
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
|
||||
const lngSpan = maxLng - minLng;
|
||||
const latSpan = maxLat - minLat;
|
||||
|
||||
if (latSpan < 0.001 && lngSpan < 0.001) {
|
||||
mapInstanceRef.current.setCenter([allPoints[0].lng, allPoints[0].lat]);
|
||||
mapInstanceRef.current.setZoom(14);
|
||||
} else {
|
||||
mapInstanceRef.current.fitBounds({
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
});
|
||||
}
|
||||
}, [route, effectiveUserLocation]);
|
||||
|
||||
const PointIcon = selectedPoint
|
||||
? pointTypeIcons[selectedPoint.type] || MapPin
|
||||
: MapPin;
|
||||
|
||||
return (
|
||||
<div className={`relative rounded-xl overflow-hidden ${className}`}>
|
||||
<div ref={mapRef} className="w-full h-full min-h-[300px]" />
|
||||
|
||||
{showControls && (
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2 z-[1000]">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Увеличить"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Уменьшить"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLocate}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Моё местоположение"
|
||||
>
|
||||
<Locate className="w-5 h-5" />
|
||||
</button>
|
||||
{route.length > 1 && (
|
||||
<button
|
||||
onClick={handleFitRoute}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Показать весь маршрут"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedPoint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="absolute bottom-4 left-4 right-4 bg-elevated/95 backdrop-blur-sm border border-border/50 rounded-xl p-4 z-[1000]"
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg hover:bg-surface/50 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '20' }}
|
||||
>
|
||||
<PointIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: pointTypeColors[selectedPoint.type] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-primary truncate">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
{selectedPoint.address && (
|
||||
<p className="text-xs text-muted mt-0.5 truncate">
|
||||
{selectedPoint.address}
|
||||
</p>
|
||||
)}
|
||||
{selectedPoint.aiComment && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2 bg-surface/50 rounded-lg">
|
||||
<Sparkles className="w-4 h-4 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{selectedPoint.aiComment}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPoint.duration && (
|
||||
<p className="text-xs text-muted mt-2">
|
||||
Рекомендуемое время: {selectedPoint.duration} мин
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!isMapReady && (
|
||||
<div className="absolute inset-0 bg-surface/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted">Загрузка карты 2GIS...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1236
backend/webui/src/components/TravelWidgets.tsx
Normal file
1236
backend/webui/src/components/TravelWidgets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ export function AuthModal() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
className="absolute inset-0 bg-primary/30 backdrop-blur-sm animate-fade-in"
|
||||
onClick={hideAuthModal}
|
||||
/>
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ export function AccountTab() {
|
||||
<h3 className="text-lg font-medium text-primary">{user.name}</h3>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
user.tier === 'business'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
? 'bg-amber-500/15 text-amber-600'
|
||||
: user.tier === 'pro'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface text-muted'
|
||||
|
||||
@@ -55,7 +55,7 @@ const plans: Plan[] = [
|
||||
price: 4990,
|
||||
priceMonthly: 4990,
|
||||
icon: Building2,
|
||||
color: 'text-amber-400',
|
||||
color: 'text-amber-600',
|
||||
features: ['Всё из Pro', 'Безлимитный AI', 'Приоритетная поддержка', 'Команды', 'SLA 99.9%'],
|
||||
limits: {
|
||||
apiRequests: '100,000/день',
|
||||
|
||||
@@ -516,19 +516,19 @@ function ConnectorCard({
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
isConnected ? 'bg-emerald-500/10' : 'bg-surface/60'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-400' : 'text-secondary'}`} />
|
||||
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-600' : 'text-secondary'}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-primary">{connector.name}</span>
|
||||
{isConnected && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600">
|
||||
Подключён
|
||||
</span>
|
||||
)}
|
||||
{hasError && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-600">
|
||||
Ошибка
|
||||
</span>
|
||||
)}
|
||||
@@ -548,7 +548,7 @@ function ConnectorCard({
|
||||
isConnected ? 'bg-emerald-500' : 'bg-surface/80 border border-border'
|
||||
}`}>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full transition-all ${
|
||||
isConnected ? 'right-1 bg-white' : 'left-1 bg-secondary'
|
||||
isConnected ? 'right-1 bg-elevated' : 'left-1 bg-secondary'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
@@ -569,7 +569,7 @@ function ConnectorCard({
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs text-secondary mb-1.5">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
{field.required && <span className="text-red-600 ml-0.5">*</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{field.type === 'textarea' ? (
|
||||
@@ -578,7 +578,7 @@ function ConnectorCard({
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 resize-none"
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -586,7 +586,7 @@ function ConnectorCard({
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 pr-10"
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 pr-10"
|
||||
/>
|
||||
)}
|
||||
{field.type === 'password' && (
|
||||
@@ -612,8 +612,8 @@ function ConnectorCard({
|
||||
|
||||
{hasError && userConnector?.errorMessage && (
|
||||
<div className="mt-3 p-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-400">{userConnector.errorMessage}</p>
|
||||
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-600">{userConnector.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -621,7 +621,7 @@ function ConnectorCard({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-accent-foreground text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
@@ -653,7 +653,7 @@ function ConnectorCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-600 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -666,7 +666,7 @@ function ConnectorCard({
|
||||
href="https://github.com/settings/tokens/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
Создать токен на GitHub
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
@@ -678,7 +678,7 @@ function ConnectorCard({
|
||||
href="https://t.me/BotFather"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
Открыть @BotFather
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
|
||||
@@ -103,8 +103,8 @@ export function PreferencesTab() {
|
||||
className="w-full flex items-center justify-between p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-border transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center">
|
||||
<Plug className="w-5 h-5 text-indigo-400" />
|
||||
<div className="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||
<Plug className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-primary">Настроить коннекторы</p>
|
||||
|
||||
@@ -22,6 +22,14 @@ import type {
|
||||
DiscoverSource,
|
||||
DashboardStats,
|
||||
AuditLog,
|
||||
Trip,
|
||||
FlightOffer,
|
||||
HotelOffer,
|
||||
GeoLocation,
|
||||
RouteDirection,
|
||||
RoutePoint,
|
||||
TravelSuggestion,
|
||||
TravelPlanRequest,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
@@ -828,3 +836,342 @@ export async function fetchAuditLogs(
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchTrips(limit = 20, offset = 0): Promise<{ trips: Trip[] }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/travel/trips?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return { trips: [] };
|
||||
throw new Error(`Trips fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchTrip(id: string): Promise<Trip | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/trips/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Trip fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createTrip(data: Partial<Trip>): Promise<Trip> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/trips`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Trip create failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateTrip(id: string, data: Partial<Trip>): Promise<Trip> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/trips/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Trip update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteTrip(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/trips/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Trip delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUpcomingTrips(): Promise<{ trips: Trip[] }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/trips/upcoming`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return { trips: [] };
|
||||
throw new Error(`Upcoming trips fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface FlightSearchParams {
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureDate: string;
|
||||
returnDate?: string;
|
||||
adults?: number;
|
||||
children?: number;
|
||||
cabinClass?: string;
|
||||
maxPrice?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export async function searchFlights(params: FlightSearchParams): Promise<{ flights: FlightOffer[] }> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('origin', params.origin);
|
||||
query.set('destination', params.destination);
|
||||
query.set('departureDate', params.departureDate);
|
||||
if (params.returnDate) query.set('returnDate', params.returnDate);
|
||||
if (params.adults) query.set('adults', String(params.adults));
|
||||
if (params.children) query.set('children', String(params.children));
|
||||
if (params.cabinClass) query.set('cabinClass', params.cabinClass);
|
||||
if (params.maxPrice) query.set('maxPrice', String(params.maxPrice));
|
||||
if (params.currency) query.set('currency', params.currency);
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/flights?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flights search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface HotelSearchParams {
|
||||
cityCode: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
radius?: number;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
adults?: number;
|
||||
rooms?: number;
|
||||
maxPrice?: number;
|
||||
currency?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export async function searchHotels(params: HotelSearchParams): Promise<{ hotels: HotelOffer[] }> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('cityCode', params.cityCode);
|
||||
query.set('checkIn', params.checkIn);
|
||||
query.set('checkOut', params.checkOut);
|
||||
if (params.lat) query.set('lat', String(params.lat));
|
||||
if (params.lng) query.set('lng', String(params.lng));
|
||||
if (params.radius) query.set('radius', String(params.radius));
|
||||
if (params.adults) query.set('adults', String(params.adults));
|
||||
if (params.rooms) query.set('rooms', String(params.rooms));
|
||||
if (params.maxPrice) query.set('maxPrice', String(params.maxPrice));
|
||||
if (params.currency) query.set('currency', params.currency);
|
||||
if (params.rating) query.set('rating', String(params.rating));
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/hotels?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Hotels search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getRoute(
|
||||
points: GeoLocation[],
|
||||
profile?: string
|
||||
): Promise<RouteDirection> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/route`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ points, profile: profile || 'driving-car' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Route fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function geocode(query: string): Promise<GeoLocation> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/travel/geocode?q=${encodeURIComponent(query)}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Geocode failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface TravelPlanStreamEvent {
|
||||
type: 'messageStart' | 'textChunk' | 'route' | 'messageEnd' | 'error';
|
||||
data?: {
|
||||
chunk?: string;
|
||||
route?: RoutePoint[];
|
||||
suggestions?: TravelSuggestion[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamTravelPlan(request: TravelPlanRequest): AsyncGenerator<TravelPlanStreamEvent> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/travel/plan`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Travel plan request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as TravelPlanStreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse travel stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as TravelPlanStreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse final travel buffer:', buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamTravelAgent(
|
||||
content: string,
|
||||
history: [string, string][],
|
||||
options?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
travelers?: number;
|
||||
budget?: number;
|
||||
currency?: string;
|
||||
userLocation?: { lat: number; lng: number; name?: string };
|
||||
},
|
||||
chatId?: string,
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const chatHistory = history.flatMap(([user, assistant]) => [
|
||||
['human', user],
|
||||
['ai', assistant],
|
||||
]);
|
||||
|
||||
const locationParts: string[] = [];
|
||||
if (options?.userLocation && options.userLocation.lat !== 0 && options.userLocation.lng !== 0) {
|
||||
locationParts.push(
|
||||
`\nМоё текущее местоположение: ${options.userLocation.lat.toFixed(6)}, ${options.userLocation.lng.toFixed(6)}` +
|
||||
(options.userLocation.name ? ` (${options.userLocation.name})` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
const message = options
|
||||
? [
|
||||
content,
|
||||
options.startDate ? `\nДаты: ${options.startDate} - ${options.endDate || ''}` : '',
|
||||
options.travelers ? `\nПутешественников: ${options.travelers}` : '',
|
||||
options.budget ? `\nБюджет: ${options.budget} ${options.currency || 'RUB'}` : '',
|
||||
...locationParts,
|
||||
].join('')
|
||||
: content;
|
||||
|
||||
const request: ChatRequest = {
|
||||
message: {
|
||||
messageId: generateId(),
|
||||
chatId: chatId || generateId(),
|
||||
content: message,
|
||||
},
|
||||
optimizationMode: 'balanced',
|
||||
history: chatHistory,
|
||||
chatModel: { providerId: '', key: '' },
|
||||
answerMode: 'travel',
|
||||
locale: 'ru',
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Travel agent request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse travel agent event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse final travel agent buffer:', buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
478
backend/webui/src/lib/hooks/useTravelChat.ts
Normal file
478
backend/webui/src/lib/hooks/useTravelChat.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type {
|
||||
TravelMessage,
|
||||
RoutePoint,
|
||||
TravelSuggestion,
|
||||
TravelPreferences,
|
||||
MapPoint,
|
||||
EventCard,
|
||||
POICard,
|
||||
HotelCard,
|
||||
TransportOption,
|
||||
ItineraryDay,
|
||||
BudgetBreakdown,
|
||||
ClarifyingQuestion,
|
||||
WidgetType,
|
||||
StreamEvent,
|
||||
RouteDirection,
|
||||
RouteSegment,
|
||||
} from '../types';
|
||||
import { streamTravelAgent, generateId } from '../api';
|
||||
|
||||
export interface TravelWidget {
|
||||
id: string;
|
||||
type: WidgetType;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TravelChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
widgets: TravelWidget[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface UseTravelChatOptions {
|
||||
onError?: (error: Error) => void;
|
||||
onRouteUpdate?: (route: RoutePoint[]) => void;
|
||||
onMapPointsUpdate?: (points: MapPoint[]) => void;
|
||||
onWidgetReceived?: (widget: TravelWidget) => void;
|
||||
}
|
||||
|
||||
export function useTravelChat(options: UseTravelChatOptions = {}) {
|
||||
const [messages, setMessages] = useState<TravelChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentRoute, setCurrentRoute] = useState<RoutePoint[]>([]);
|
||||
const [mapPoints, setMapPoints] = useState<MapPoint[]>([]);
|
||||
const [events, setEvents] = useState<EventCard[]>([]);
|
||||
const [pois, setPois] = useState<POICard[]>([]);
|
||||
const [hotels, setHotels] = useState<HotelCard[]>([]);
|
||||
const [transport, setTransport] = useState<TransportOption[]>([]);
|
||||
const [itinerary, setItinerary] = useState<ItineraryDay[]>([]);
|
||||
const [budget, setBudget] = useState<BudgetBreakdown | null>(null);
|
||||
const [clarifyingQuestions, setClarifyingQuestions] = useState<ClarifyingQuestion[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<TravelSuggestion[]>([]);
|
||||
const [routeDirection, setRouteDirection] = useState<RouteDirection | null>(null);
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const chatIdRef = useRef<string>(generateId());
|
||||
const lastUserQueryRef = useRef<string>('');
|
||||
const lastPlanOptionsRef = useRef<{
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
travelers?: number;
|
||||
budget?: number;
|
||||
currency?: string;
|
||||
}>({});
|
||||
|
||||
const sendMessage = useCallback(async (
|
||||
content: string,
|
||||
planOptions?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
travelers?: number;
|
||||
budget?: number;
|
||||
currency?: string;
|
||||
preferences?: TravelPreferences;
|
||||
userLocation?: { lat: number; lng: number; name?: string };
|
||||
}
|
||||
) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
if (!content.startsWith('_clarify:')) {
|
||||
lastUserQueryRef.current = content.trim();
|
||||
if (planOptions) {
|
||||
lastPlanOptionsRef.current = { ...planOptions };
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: TravelChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: content.startsWith('_clarify:') ? content.slice('_clarify:'.length) : content.trim(),
|
||||
widgets: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const assistantMessage: TravelChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
widgets: [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
setIsLoading(true);
|
||||
setIsResearching(true);
|
||||
setClarifyingQuestions([]);
|
||||
|
||||
const history: [string, string][] = messages
|
||||
.filter((m) => !m.isStreaming)
|
||||
.reduce((acc, m, i, arr) => {
|
||||
if (m.role === 'user' && arr[i + 1]?.role === 'assistant') {
|
||||
acc.push([m.content, arr[i + 1].content] as [string, string]);
|
||||
}
|
||||
return acc;
|
||||
}, [] as [string, string][]);
|
||||
|
||||
const isClarify = content.startsWith('_clarify:');
|
||||
const messageContent = isClarify ? content.slice('_clarify:'.length) : content.trim();
|
||||
const effectiveOptions = planOptions || (isClarify ? lastPlanOptionsRef.current : undefined);
|
||||
|
||||
try {
|
||||
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current);
|
||||
|
||||
let fullContent = '';
|
||||
const collectedWidgets: TravelWidget[] = [];
|
||||
const textBlockContents: Record<string, string> = {};
|
||||
|
||||
for await (const event of stream) {
|
||||
const eventData = event as StreamEvent;
|
||||
|
||||
if (eventData.type === 'block' && eventData.block) {
|
||||
const block = eventData.block;
|
||||
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
const text = block.data as string;
|
||||
textBlockContents[block.id] = text;
|
||||
fullContent = Object.values(textBlockContents).join('\n\n');
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id ? { ...m, content: fullContent } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'research':
|
||||
setIsResearching(true);
|
||||
break;
|
||||
|
||||
case 'widget': {
|
||||
const widgetData = block.data as { widgetType: string; params: Record<string, unknown> };
|
||||
const widget: TravelWidget = {
|
||||
id: block.id,
|
||||
type: widgetData.widgetType as WidgetType,
|
||||
params: widgetData.params,
|
||||
};
|
||||
collectedWidgets.push(widget);
|
||||
processWidget(widget);
|
||||
options.onWidgetReceived?.(widget);
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id ? { ...m, widgets: [...collectedWidgets] } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (eventData.type === 'textChunk' && eventData.blockId && eventData.chunk) {
|
||||
const blockId = eventData.blockId;
|
||||
textBlockContents[blockId] = (textBlockContents[blockId] || '') + eventData.chunk;
|
||||
fullContent = Object.values(textBlockContents).join('\n\n');
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id ? { ...m, content: fullContent } : m
|
||||
)
|
||||
);
|
||||
} else if (eventData.type === 'researchComplete') {
|
||||
setIsResearching(false);
|
||||
} else if (eventData.type === 'messageEnd') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: fullContent, widgets: collectedWidgets, isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (eventData.type === 'error') {
|
||||
const errorData = eventData.data as string;
|
||||
throw new Error(errorData || 'Unknown error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка при планировании';
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsResearching(false);
|
||||
}
|
||||
}, [isLoading, messages, options]);
|
||||
|
||||
const processWidget = useCallback((widget: TravelWidget) => {
|
||||
const params = widget.params;
|
||||
|
||||
switch (widget.type) {
|
||||
case 'travel_map': {
|
||||
const points = (params.points || []) as MapPoint[];
|
||||
setMapPoints(points);
|
||||
options.onMapPointsUpdate?.(points);
|
||||
|
||||
if (params.routeDirection) {
|
||||
const rd = params.routeDirection as RouteDirection;
|
||||
console.log('[useTravelChat] routeDirection received:', {
|
||||
coordsCount: rd?.geometry?.coordinates?.length ?? 0,
|
||||
distance: rd?.distance,
|
||||
stepsCount: rd?.steps?.length ?? 0,
|
||||
});
|
||||
setRouteDirection(rd);
|
||||
}
|
||||
|
||||
if (params.segments) {
|
||||
setRouteSegments(params.segments as RouteSegment[]);
|
||||
}
|
||||
|
||||
const typeMapping: Record<string, RoutePoint['type']> = {
|
||||
hotel: 'hotel',
|
||||
airport: 'airport',
|
||||
restaurant: 'restaurant',
|
||||
attraction: 'attraction',
|
||||
transport: 'transport',
|
||||
museum: 'attraction',
|
||||
park: 'attraction',
|
||||
theater: 'attraction',
|
||||
shopping: 'attraction',
|
||||
entertainment: 'attraction',
|
||||
religious: 'attraction',
|
||||
viewpoint: 'attraction',
|
||||
event: 'attraction',
|
||||
destination: 'custom',
|
||||
poi: 'attraction',
|
||||
food: 'restaurant',
|
||||
transfer: 'transport',
|
||||
origin: 'origin',
|
||||
};
|
||||
|
||||
const validPoints = points.filter((p) => p.lat !== 0 && p.lng !== 0);
|
||||
|
||||
const itineraryPoints = validPoints.filter((p) => p.layer === 'itinerary');
|
||||
const sourcePoints = itineraryPoints.length > 0 ? itineraryPoints : validPoints;
|
||||
|
||||
const routePoints: RoutePoint[] = sourcePoints.map((p, i) => ({
|
||||
id: p.id,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
name: p.label,
|
||||
type: typeMapping[p.type] || 'custom',
|
||||
order: i,
|
||||
}));
|
||||
setCurrentRoute(routePoints);
|
||||
options.onRouteUpdate?.(routePoints);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_events': {
|
||||
const eventCards = (params.events || []) as EventCard[];
|
||||
setEvents(eventCards);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_poi': {
|
||||
const poiCards = (params.pois || []) as POICard[];
|
||||
setPois(poiCards);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_hotels': {
|
||||
const hotelCards = (params.hotels || []) as HotelCard[];
|
||||
setHotels(hotelCards);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_transport': {
|
||||
const flights = (params.flights || []) as TransportOption[];
|
||||
const ground = (params.ground || []) as TransportOption[];
|
||||
setTransport([...flights, ...ground]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_itinerary': {
|
||||
const days = (params.days || []) as ItineraryDay[];
|
||||
setItinerary(days);
|
||||
if (params.budget) {
|
||||
setBudget(params.budget as BudgetBreakdown);
|
||||
}
|
||||
if (params.segments) {
|
||||
setRouteSegments(params.segments as RouteSegment[]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_budget': {
|
||||
const breakdown = params.breakdown as BudgetBreakdown;
|
||||
if (breakdown) setBudget(breakdown);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'travel_clarifying': {
|
||||
const questions = (params.questions || []) as ClarifyingQuestion[];
|
||||
setClarifyingQuestions(questions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setIsResearching(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentRoute([]);
|
||||
setMapPoints([]);
|
||||
setEvents([]);
|
||||
setPois([]);
|
||||
setHotels([]);
|
||||
setTransport([]);
|
||||
setItinerary([]);
|
||||
setBudget(null);
|
||||
setClarifyingQuestions([]);
|
||||
setSuggestions([]);
|
||||
chatIdRef.current = generateId();
|
||||
lastUserQueryRef.current = '';
|
||||
lastPlanOptionsRef.current = {};
|
||||
}, []);
|
||||
|
||||
const addRoutePoint = useCallback((point: RoutePoint) => {
|
||||
setCurrentRoute((prev) => {
|
||||
const newRoute = [...prev, { ...point, order: prev.length }];
|
||||
options.onRouteUpdate?.(newRoute);
|
||||
return newRoute;
|
||||
});
|
||||
}, [options]);
|
||||
|
||||
const removeRoutePoint = useCallback((pointId: string) => {
|
||||
setCurrentRoute((prev) => {
|
||||
const newRoute = prev
|
||||
.filter((p) => p.id !== pointId)
|
||||
.map((p, i) => ({ ...p, order: i }));
|
||||
options.onRouteUpdate?.(newRoute);
|
||||
return newRoute;
|
||||
});
|
||||
}, [options]);
|
||||
|
||||
const answerClarifying = useCallback((field: string, value: string) => {
|
||||
const originalQuery = lastUserQueryRef.current;
|
||||
const combinedMessage = originalQuery
|
||||
? `${originalQuery}\n\nДополнительные данные:\n${value}`
|
||||
: value;
|
||||
sendMessage(`_clarify:${combinedMessage}`);
|
||||
}, [sendMessage]);
|
||||
|
||||
const handleAction = useCallback((actionKind: string) => {
|
||||
switch (actionKind) {
|
||||
case 'save':
|
||||
break;
|
||||
case 'modify':
|
||||
sendMessage('Измени маршрут, предложи альтернативные варианты');
|
||||
break;
|
||||
case 'search':
|
||||
sendMessage('Найди ещё варианты мест, отелей и мероприятий');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [sendMessage]);
|
||||
|
||||
const addEventToRoute = useCallback((event: EventCard) => {
|
||||
if (event.lat && event.lng && event.lat !== 0 && event.lng !== 0) {
|
||||
const point: RoutePoint = {
|
||||
id: event.id,
|
||||
lat: event.lat,
|
||||
lng: event.lng,
|
||||
name: event.title,
|
||||
type: 'attraction',
|
||||
order: 0,
|
||||
address: event.address,
|
||||
};
|
||||
addRoutePoint(point);
|
||||
}
|
||||
}, [addRoutePoint]);
|
||||
|
||||
const addPOIToRoute = useCallback((poi: POICard) => {
|
||||
if (poi.lat && poi.lng && poi.lat !== 0 && poi.lng !== 0) {
|
||||
const point: RoutePoint = {
|
||||
id: poi.id,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
name: poi.name,
|
||||
type: 'attraction',
|
||||
address: poi.address,
|
||||
order: 0,
|
||||
duration: poi.duration,
|
||||
};
|
||||
addRoutePoint(point);
|
||||
}
|
||||
}, [addRoutePoint]);
|
||||
|
||||
const selectHotelOnRoute = useCallback((hotel: HotelCard) => {
|
||||
if (hotel.lat && hotel.lng && hotel.lat !== 0 && hotel.lng !== 0) {
|
||||
setCurrentRoute((prev) => {
|
||||
const filtered = prev.filter((p) => p.type !== 'hotel');
|
||||
const newRoute = [
|
||||
...filtered,
|
||||
{
|
||||
id: hotel.id,
|
||||
lat: hotel.lat,
|
||||
lng: hotel.lng,
|
||||
name: hotel.name,
|
||||
type: 'hotel' as const,
|
||||
address: hotel.address,
|
||||
order: filtered.length,
|
||||
},
|
||||
];
|
||||
options.onRouteUpdate?.(newRoute);
|
||||
return newRoute;
|
||||
});
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isResearching,
|
||||
currentRoute,
|
||||
mapPoints,
|
||||
events,
|
||||
pois,
|
||||
hotels,
|
||||
transport,
|
||||
itinerary,
|
||||
budget,
|
||||
clarifyingQuestions,
|
||||
suggestions,
|
||||
routeDirection,
|
||||
routeSegments,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearChat,
|
||||
addRoutePoint,
|
||||
removeRoutePoint,
|
||||
answerClarifying,
|
||||
handleAction,
|
||||
addEventToRoute,
|
||||
addPOIToRoute,
|
||||
selectHotelOnRoute,
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,17 @@ export type WidgetType =
|
||||
| 'video_embed'
|
||||
| 'weather'
|
||||
| 'finance'
|
||||
| 'map';
|
||||
| 'map'
|
||||
| 'travel_map'
|
||||
| 'travel_events'
|
||||
| 'travel_poi'
|
||||
| 'travel_hotels'
|
||||
| 'travel_transport'
|
||||
| 'travel_itinerary'
|
||||
| 'travel_budget'
|
||||
| 'travel_clarifying'
|
||||
| 'travel_actions'
|
||||
| 'travel_context';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
@@ -174,7 +184,7 @@ export interface ChatRequest {
|
||||
};
|
||||
optimizationMode: 'speed' | 'balanced' | 'quality';
|
||||
sources?: string[];
|
||||
history?: [string, string][];
|
||||
history?: string[][];
|
||||
chatModel?: {
|
||||
providerId: string;
|
||||
key: string;
|
||||
@@ -182,6 +192,7 @@ export interface ChatRequest {
|
||||
locale?: string;
|
||||
webSearch?: boolean;
|
||||
attachments?: ChatAttachmentInfo[];
|
||||
answerMode?: string;
|
||||
}
|
||||
|
||||
export interface ApiConfig {
|
||||
@@ -450,3 +461,390 @@ export interface AuditLog {
|
||||
userAgent?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type TripStatus = 'planned' | 'booked' | 'completed' | 'cancelled';
|
||||
export type RoutePointType = 'airport' | 'hotel' | 'attraction' | 'restaurant' | 'transport' | 'custom' | 'origin';
|
||||
|
||||
export interface Trip {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
destination: string;
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
route: RoutePoint[];
|
||||
flights?: FlightOffer[];
|
||||
hotels?: HotelOffer[];
|
||||
totalBudget?: number;
|
||||
currency: string;
|
||||
status: TripStatus;
|
||||
aiSummary?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RoutePoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
address?: string;
|
||||
type: RoutePointType;
|
||||
aiComment?: string;
|
||||
duration?: number;
|
||||
cost?: number;
|
||||
order: number;
|
||||
date?: string;
|
||||
photos?: string[];
|
||||
}
|
||||
|
||||
export interface FlightOffer {
|
||||
id: string;
|
||||
airline: string;
|
||||
airlineLogo?: string;
|
||||
flightNumber: string;
|
||||
departureAirport: string;
|
||||
departureCity: string;
|
||||
departureTime: string;
|
||||
arrivalAirport: string;
|
||||
arrivalCity: string;
|
||||
arrivalTime: string;
|
||||
duration: number;
|
||||
stops: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
cabinClass: string;
|
||||
seatsAvailable?: number;
|
||||
bookingUrl?: string;
|
||||
}
|
||||
|
||||
export interface HotelOffer {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
rating: number;
|
||||
reviewCount?: number;
|
||||
stars?: number;
|
||||
price: number;
|
||||
pricePerNight: number;
|
||||
currency: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
roomType?: string;
|
||||
amenities?: string[];
|
||||
photos?: string[];
|
||||
bookingUrl?: string;
|
||||
}
|
||||
|
||||
export interface TravelPlanRequest {
|
||||
query: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
travelers?: number;
|
||||
budget?: number;
|
||||
currency?: string;
|
||||
preferences?: TravelPreferences;
|
||||
history?: [string, string][];
|
||||
}
|
||||
|
||||
export interface TravelPreferences {
|
||||
travelStyle?: 'budget' | 'comfort' | 'luxury';
|
||||
interests?: string[];
|
||||
avoidTypes?: string[];
|
||||
transportModes?: string[];
|
||||
}
|
||||
|
||||
export interface TravelMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
route?: RoutePoint[];
|
||||
flights?: FlightOffer[];
|
||||
hotels?: HotelOffer[];
|
||||
suggestions?: TravelSuggestion[];
|
||||
isStreaming?: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface TravelSuggestion {
|
||||
id: string;
|
||||
type: 'destination' | 'activity' | 'restaurant' | 'transport';
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
rating?: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
export interface GeoLocation {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
// --- Travel multi-agent widget payloads ---
|
||||
|
||||
export interface TripBrief {
|
||||
origin: string;
|
||||
originLat?: number;
|
||||
originLng?: number;
|
||||
destinations: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
travelers: number;
|
||||
budget: number;
|
||||
currency: string;
|
||||
interests?: string[];
|
||||
travelStyle?: string;
|
||||
constraints?: string[];
|
||||
}
|
||||
|
||||
export interface EventCard {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
address?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface WeatherAssessment {
|
||||
summary: string;
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
conditions: string;
|
||||
clothing: string;
|
||||
rainChance: string;
|
||||
}
|
||||
|
||||
export interface SafetyAssessment {
|
||||
level: 'safe' | 'caution' | 'warning' | 'danger';
|
||||
summary: string;
|
||||
warnings?: string[];
|
||||
emergencyNo: string;
|
||||
}
|
||||
|
||||
export interface RestrictionItem {
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
}
|
||||
|
||||
export interface TravelTip {
|
||||
category: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TravelContext {
|
||||
weather: WeatherAssessment;
|
||||
safety: SafetyAssessment;
|
||||
restrictions: RestrictionItem[];
|
||||
tips: TravelTip[];
|
||||
bestTimeInfo?: string;
|
||||
}
|
||||
|
||||
export interface POICard {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
rating?: number;
|
||||
reviewCount?: number;
|
||||
address?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
photos?: string[];
|
||||
duration?: number;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
url?: string;
|
||||
schedule?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HotelCard {
|
||||
id: string;
|
||||
name: string;
|
||||
stars?: number;
|
||||
rating?: number;
|
||||
reviewCount?: number;
|
||||
pricePerNight: number;
|
||||
totalPrice: number;
|
||||
rooms?: number;
|
||||
nights?: number;
|
||||
guests?: number;
|
||||
currency: string;
|
||||
address?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
bookingUrl?: string;
|
||||
photos?: string[];
|
||||
amenities?: string[];
|
||||
pros?: string[];
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
}
|
||||
|
||||
export interface TransportOption {
|
||||
id: string;
|
||||
mode: string;
|
||||
from: string;
|
||||
fromLat?: number;
|
||||
fromLng?: number;
|
||||
to: string;
|
||||
toLat?: number;
|
||||
toLng?: number;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
durationMin: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
provider?: string;
|
||||
bookingUrl?: string;
|
||||
airline?: string;
|
||||
flightNum?: string;
|
||||
stops?: number;
|
||||
}
|
||||
|
||||
export interface ItineraryItem {
|
||||
refType: string;
|
||||
refId: string;
|
||||
title: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
note?: string;
|
||||
cost?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface ItineraryDay {
|
||||
date: string;
|
||||
items: ItineraryItem[];
|
||||
}
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
type: string;
|
||||
label: string;
|
||||
layer: 'itinerary' | 'candidate' | 'transport';
|
||||
}
|
||||
|
||||
export interface BudgetBreakdown {
|
||||
total: number;
|
||||
currency: string;
|
||||
transport: number;
|
||||
hotels: number;
|
||||
activities: number;
|
||||
food: number;
|
||||
other: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface ClarifyingQuestion {
|
||||
field: string;
|
||||
question: string;
|
||||
type: 'text' | 'date' | 'number' | 'select' | 'location';
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface TravelMapWidgetParams {
|
||||
center?: GeoLocation;
|
||||
points: MapPoint[];
|
||||
polyline?: [number, number][];
|
||||
routeDirection?: RouteDirection;
|
||||
segments?: RouteSegment[];
|
||||
}
|
||||
|
||||
export interface TravelEventsWidgetParams {
|
||||
events: EventCard[];
|
||||
}
|
||||
|
||||
export interface TravelPOIWidgetParams {
|
||||
pois: POICard[];
|
||||
}
|
||||
|
||||
export interface TravelHotelsWidgetParams {
|
||||
hotels: HotelCard[];
|
||||
}
|
||||
|
||||
export interface TravelTransportWidgetParams {
|
||||
flights: TransportOption[];
|
||||
ground: TransportOption[];
|
||||
}
|
||||
|
||||
export interface TravelItineraryWidgetParams {
|
||||
days: ItineraryDay[];
|
||||
budget?: BudgetBreakdown;
|
||||
segments?: RouteSegment[];
|
||||
}
|
||||
|
||||
export interface TravelBudgetWidgetParams {
|
||||
breakdown: BudgetBreakdown;
|
||||
}
|
||||
|
||||
export interface TravelClarifyingWidgetParams {
|
||||
questions: ClarifyingQuestion[];
|
||||
}
|
||||
|
||||
export interface TravelActionsWidgetParams {
|
||||
actions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
payload: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RouteSegment {
|
||||
from: string;
|
||||
to: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
transportOptions: TransportCostOption[];
|
||||
}
|
||||
|
||||
export interface TransportCostOption {
|
||||
mode: 'car' | 'bus' | 'taxi';
|
||||
label: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface RouteDirection {
|
||||
geometry: {
|
||||
coordinates: [number, number][];
|
||||
type: string;
|
||||
};
|
||||
distance: number;
|
||||
duration: number;
|
||||
steps?: RouteStep[];
|
||||
}
|
||||
|
||||
export interface RouteStep {
|
||||
instruction: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
@@ -7,19 +7,16 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
/* Cursor IDE 2026 Color Palette */
|
||||
base: 'hsl(var(--bg-base))',
|
||||
elevated: 'hsl(var(--bg-elevated))',
|
||||
surface: 'hsl(var(--bg-surface))',
|
||||
overlay: 'hsl(var(--bg-overlay))',
|
||||
|
||||
/* Text colors */
|
||||
primary: 'hsl(var(--text-primary))',
|
||||
secondary: 'hsl(var(--text-secondary))',
|
||||
muted: 'hsl(var(--text-muted))',
|
||||
faint: 'hsl(var(--text-faint))',
|
||||
|
||||
/* Accent colors */
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
hover: 'hsl(var(--accent-hover))',
|
||||
@@ -33,7 +30,6 @@ const config: Config = {
|
||||
muted: 'hsl(var(--accent-secondary-muted))',
|
||||
},
|
||||
|
||||
/* Semantic colors */
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
muted: 'hsl(var(--success-muted))',
|
||||
@@ -47,14 +43,12 @@ const config: Config = {
|
||||
muted: 'hsl(var(--error-muted))',
|
||||
},
|
||||
|
||||
/* Border colors */
|
||||
border: {
|
||||
DEFAULT: 'hsl(var(--border))',
|
||||
hover: 'hsl(var(--border-hover))',
|
||||
focus: 'hsl(var(--border-focus))',
|
||||
},
|
||||
|
||||
/* Legacy mappings */
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
@@ -100,13 +94,13 @@ const config: Config = {
|
||||
xs: '2px',
|
||||
},
|
||||
boxShadow: {
|
||||
'glow-sm': '0 0 10px hsl(239 84% 67% / 0.1)',
|
||||
'glow-md': '0 0 20px hsl(239 84% 67% / 0.15)',
|
||||
'glow-lg': '0 0 40px hsl(239 84% 67% / 0.2)',
|
||||
'inner-glow': 'inset 0 0 20px hsl(240 6% 12% / 0.5)',
|
||||
'elevated': '0 4px 20px hsl(240 6% 4% / 0.4), 0 0 1px hsl(240 5% 20% / 0.5)',
|
||||
'card': '0 2px 8px hsl(240 6% 4% / 0.3)',
|
||||
'dropdown': '0 8px 32px hsl(240 6% 4% / 0.5), 0 0 1px hsl(240 5% 20% / 0.5)',
|
||||
'glow-sm': '0 1px 6px hsl(224 64% 48% / 0.06)',
|
||||
'glow-md': '0 2px 12px hsl(224 64% 48% / 0.1)',
|
||||
'glow-lg': '0 4px 24px hsl(224 64% 48% / 0.14)',
|
||||
'inner-glow': 'inset 0 1px 4px hsl(220 14% 50% / 0.06)',
|
||||
'elevated': '0 1px 3px hsl(220 14% 50% / 0.06), 0 4px 16px hsl(220 14% 50% / 0.04)',
|
||||
'card': '0 1px 3px hsl(220 14% 50% / 0.05), 0 1px 2px hsl(220 14% 50% / 0.03)',
|
||||
'dropdown': '0 4px 24px hsl(220 14% 50% / 0.12), 0 1px 4px hsl(220 14% 50% / 0.06)',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
@@ -138,12 +132,12 @@ const config: Config = {
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
'glow-pulse': {
|
||||
'0%, 100%': { boxShadow: '0 0 20px hsl(239 84% 67% / 0.15)' },
|
||||
'50%': { boxShadow: '0 0 30px hsl(239 84% 67% / 0.25)' },
|
||||
'0%, 100%': { boxShadow: '0 2px 12px hsl(224 64% 48% / 0.08)' },
|
||||
'50%': { boxShadow: '0 4px 20px hsl(224 64% 48% / 0.14)' },
|
||||
},
|
||||
'border-pulse': {
|
||||
'0%, 100%': { borderColor: 'hsl(239 84% 67% / 0.3)' },
|
||||
'50%': { borderColor: 'hsl(239 84% 67% / 0.5)' },
|
||||
'0%, 100%': { borderColor: 'hsl(224 64% 48% / 0.2)' },
|
||||
'50%': { borderColor: 'hsl(224 64% 48% / 0.35)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
|
||||
Reference in New Issue
Block a user