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:
home
2026-03-01 21:58:32 +03:00
parent e6b9cfc60a
commit 08bd41e75c
71 changed files with 12364 additions and 945 deletions

View File

@@ -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)
```

View File

@@ -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,
},
}

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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,
})

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View 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

View File

@@ -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",
"новост", "сегодня", "последн", "актуальн",

View File

@@ -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: ")

View 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
}

View 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"`
}

View 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
}

View 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
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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"`
}

View File

@@ -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)
}

View 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 })
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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"`
}

View File

@@ -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 {

View File

@@ -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"),

View File

@@ -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 || '',
},
};

View File

@@ -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",

View File

@@ -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",

View File

@@ -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';
}
};

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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" />
Удалить

View File

@@ -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 ? 'Сохранение...' : 'Сохранить'}

View File

@@ -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" />
Удалить

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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'
}`}
/>

View File

@@ -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

View 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 }
);
}
}

View 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',
},
});
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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={`

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />
Саммари

View File

@@ -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'

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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}
/>

View File

@@ -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'

View File

@@ -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/день',

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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 ? `\аты: ${options.startDate} - ${options.endDate || ''}` : '',
options.travelers ? `\утешественников: ${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);
}
}
}

View 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${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,
};
}

View File

@@ -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;
}

View File

@@ -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: {