feat: CI/CD pipeline + Learning/Medicine/Travel services
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped

- Add Gitea Actions workflow for automated build & deploy
- Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc
- Update kustomization for localhost:5000 registry
- Add ingress for gooseek.ru and api.gooseek.ru
- Learning cabinet with onboarding, courses, sandbox integration
- Medicine service with symptom analysis and doctor matching
- Travel service with itinerary planning
- Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner)

Made-with: Cursor
This commit is contained in:
home
2026-03-02 20:25:44 +03:00
parent 08bd41e75c
commit ab48a0632b
92 changed files with 15562 additions and 2198 deletions

View File

@@ -3,40 +3,74 @@ node_modules/
**/node_modules/
npm-debug.log
yarn-error.log
package-lock.json
# Build output
.next/
**/.next/
# Build output (кроме webui .next для standalone)
out/
dist/
**/dist/
*.tsbuildinfo
# Git
.git/
.gitignore
.gitattributes
.github/
.gitea/
# IDE
.vscode/
.idea/
*.iml
*.swp
*.swo
# Env (не в образ)
# Env (NEVER in image)
.env
.env.*
!.env.example
# Docs, deploy scripts (не нужны в образе)
# Docs (не нужны в образе)
docs/
deploy/
*.md
!README.md
LICENSE
CHANGELOG*
CONTINUE.md
AGENTS.md
# Deploy scripts (не в образ, только код)
backend/deploy/scripts/
backend/deploy/k8s/*.sh
*.sh
# Data, certs
db.sqlite
*.sqlite
certificates/
searxng/
*.pem
*.key
*.crt
# Misc
.DS_Store
Thumbs.db
*.log
coverage/
**/coverage/
*.bak
*.tmp
# Test files
**/*_test.go
**/test/
**/tests/
**/__tests__/
# CI temp
.runner
*.runner
# Cursor/IDE
.cursor/
.cursorignore

View File

@@ -0,0 +1,81 @@
name: Build and Deploy GooSeek
on:
push:
branches:
- main
- master
env:
REGISTRY: localhost:5000
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build backend image (all services)
working-directory: backend
run: |
docker build \
-f deploy/docker/Dockerfile.all \
-t $REGISTRY/gooseek/backend:${{ github.sha }} \
-t $REGISTRY/gooseek/backend:latest \
.
- name: Push backend image
run: |
docker push $REGISTRY/gooseek/backend:${{ github.sha }}
docker push $REGISTRY/gooseek/backend:latest
build-webui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build WebUI image
working-directory: backend/webui
run: |
docker build \
-t $REGISTRY/gooseek/webui:${{ github.sha }} \
-t $REGISTRY/gooseek/webui:latest \
.
- name: Push WebUI image
run: |
docker push $REGISTRY/gooseek/webui:${{ github.sha }}
docker push $REGISTRY/gooseek/webui:latest
deploy:
needs: [build-backend, build-webui]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Kubernetes
run: |
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
cd backend/deploy/k8s
# Apply kustomization
kubectl apply -k .
# Restart deployments to pull new images
kubectl -n gooseek rollout restart deployment/api-gateway
kubectl -n gooseek rollout restart deployment/webui
kubectl -n gooseek rollout restart deployment/chat-svc
kubectl -n gooseek rollout restart deployment/agent-svc
kubectl -n gooseek rollout restart deployment/search-svc
kubectl -n gooseek rollout restart deployment/discover-svc
kubectl -n gooseek rollout restart deployment/learning-svc
kubectl -n gooseek rollout restart deployment/medicine-svc
kubectl -n gooseek rollout restart deployment/travel-svc
kubectl -n gooseek rollout restart deployment/sandbox-svc
# Wait for critical deployments
kubectl -n gooseek rollout status deployment/api-gateway --timeout=180s
kubectl -n gooseek rollout status deployment/webui --timeout=180s
echo "=== Deploy completed ==="
kubectl -n gooseek get pods

42
.gitignore vendored
View File

@@ -1,24 +1,29 @@
# Node.js
node_modules/
**/node_modules/
npm-debug.log
yarn-error.log
package-lock.json
# Build output
.next/
**/.next/
out/
dist/
**/dist/
*.tsbuildinfo
# IDE/Editor specific
.vscode/
.idea/
*.iml
*.swp
*.swo
# Environment variables
# Environment variables (SECRETS - NEVER COMMIT!)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*
!.env.example
# Config files
config.toml
@@ -29,22 +34,41 @@ logs/
# Testing
/coverage/
**/coverage/
# Miscellaneous
.DS_Store
Thumbs.db
*.bak
*.tmp
# Db & data
db.sqlite
*.sqlite
apps/frontend/data/*
!apps/frontend/data/.gitignore
/searxng
certificates
# Certificates and secrets
certificates/
*.pem
*.key
*.crt
# SSL backup (приватные ключи не в репо)
# SSL backup
deploy/k3s/ssl/backup/*
!deploy/k3s/ssl/backup/.gitkeep
# Vendor cache (npm + Docker images для оффлайн-билда)
vendor/
# Vendor cache
vendor/
# CI/CD temp files
.runner
*.runner
# K8s secrets (generated)
backend/deploy/k8s/*-secrets.yaml
# Go
backend/cmd/*/main
backend/**/*.exe

View File

@@ -1,87 +1,39 @@
# Недоделки — начать отсюда
## Последнее изменение (01.03.2026)
## CI/CD готов — осталось создать репозиторий в Gitea
### СДЕЛАНО: Маршруты по дорогам + стоимость проезда
### Сделано (CI/CD подготовка репозитория)
- [x] Обновлён `.gitignore`: игнорируются секреты, временные файлы, кэши
- [x] Обновлён `.dockerignore`: оптимизирован для сборки образов
- [x] Созданы K8s манифесты:
- `backend/deploy/k8s/webui.yaml` — новый
- `backend/deploy/k8s/travel-svc.yaml` — новый
- [x] Обновлён `backend/deploy/k8s/kustomization.yaml`:
- images используют `localhost:5000/gooseek/*`
- добавлены webui.yaml и travel-svc.yaml
- [x] Обновлён `backend/deploy/k8s/ingress.yaml`:
- gooseek.ru → webui:3000
- api.gooseek.ru → api-gateway:3015
- [x] Обновлён `backend/deploy/k8s/deploy.sh`:
- push в localhost:5000 registry
- rolling restart всех сервисов
- [x] Создан `.gitea/workflows/deploy.yaml`:
- CI/CD workflow для Gitea Actions
- Сборка backend + webui
- Автодеплой в K8s
#### Что сделано:
### Осталось сделать
1. [ ] Создать репозиторий `gooseek` в Gitea (https://git.gooseek.ru)
2. [ ] Пуш кода: `git remote add gitea https://git.gooseek.ru/admin/gooseek.git && git push -u gitea main`
3. [ ] Проверить что CI/CD workflow запустился и задеплоился
**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
### Ранее сделано
- Learning кабинет полностью готов
- Medicine сервис полностью готов
- Все K8s манифесты для всех сервисов
**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` между элементами маршрута с иконками машина/автобус/такси и ценами
---
### СДЕЛАНО ранее: Переработка POI коллектора — 2GIS как основной источник
(см. предыдущую версию CONTINUE.md)
---
## Осталось сделать
### Высокий приоритет
1. **Цены отелей из SearXNG** — LLM не всегда извлекает цены (0 RUB/night). Нужно:
- Добавить fallback: если цена 0, попробовать парсить из snippet
- Файл: `backend/internal/agent/travel_hotels_collector.go`
2. **Авиабилеты для маршрутов** — "Золотое кольцо" не имеет IATA кода. Нужно:
- Если destination не IATA, искать билеты до первого конкретного города в маршруте
- Файл: `backend/internal/agent/travel_flights_collector.go`
### Средний приоритет
3. **Drag & drop в ItineraryWidget** — перетаскивание элементов между днями
4. **Кеш SearXNG результатов** — Redis кеш на 10-30 минут
5. **Сохранение draft в БД** — персистентность TripDraft через trip_drafts таблицу
### Низкий приоритет
6. **Экспорт маршрута** — PDF/Markdown
7. **Real-time обновления** — WebSocket для тредов
---
## Контекст
### Архитектура travel pipeline:
```
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)
```
### Контекст для продолжения
- Сервер: 192.168.31.59 (внутренний IP), 5.187.77.89 (внешний)
- Gitea: https://git.gooseek.ru
- Registry: localhost:5000 (внутренний, без внешнего доступа)
- K3s + Nginx Ingress + Cert-Manager уже установлены

View File

@@ -18,6 +18,7 @@ import (
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
"github.com/gooseek/backend/pkg/ndjson"
"github.com/gooseek/backend/pkg/storage"
)
type SearchRequest struct {
@@ -55,6 +56,24 @@ func main() {
searchClient := search.NewSearXNGClient(cfg)
var photoCache *agent.PhotoCacheService
if cfg.MinioEndpoint != "" {
minioStorage, err := storage.NewMinioStorage(storage.MinioConfig{
Endpoint: cfg.MinioEndpoint,
AccessKey: cfg.MinioAccessKey,
SecretKey: cfg.MinioSecretKey,
Bucket: cfg.MinioBucket,
UseSSL: cfg.MinioUseSSL,
PublicURL: cfg.MinioPublicURL,
})
if err != nil {
log.Printf("Warning: MinIO init failed (photo cache disabled): %v", err)
} else {
photoCache = agent.NewPhotoCacheService(minioStorage)
log.Printf("Photo cache enabled: MinIO at %s, bucket=%s, publicURL=%s", cfg.MinioEndpoint, cfg.MinioBucket, cfg.MinioPublicURL)
}
}
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 10 * 1024 * 1024,
@@ -166,6 +185,7 @@ func main() {
TravelSvcURL: cfg.TravelSvcURL,
TravelPayoutsToken: cfg.TravelPayoutsToken,
TravelPayoutsMarker: cfg.TravelPayoutsMarker,
PhotoCache: photoCache,
},
}

View File

@@ -53,7 +53,9 @@ func main() {
"discover": cfg.DiscoverSvcURL,
"finance": cfg.FinanceHeatmapURL,
"learning": cfg.LearningSvcURL,
"sandbox": cfg.SandboxSvcURL,
"travel": cfg.TravelSvcURL,
"medicine": cfg.MedicineSvcURL,
"admin": cfg.AdminSvcURL,
}
@@ -140,8 +142,12 @@ 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/sandbox"):
return svcURLs["sandbox"], path
case strings.HasPrefix(path, "/api/v1/travel"):
return svcURLs["travel"], path
case strings.HasPrefix(path, "/api/v1/medicine"):
return svcURLs["medicine"], path
case strings.HasPrefix(path, "/api/v1/admin"):
return svcURLs["admin"], path
default:

View File

@@ -2,119 +2,75 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"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/db"
"github.com/gooseek/backend/internal/learning"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/search"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
)
type LessonStore struct {
lessons map[string]*learning.StepByStepLesson
}
func NewLessonStore() *LessonStore {
return &LessonStore{
lessons: make(map[string]*learning.StepByStepLesson),
}
}
func (s *LessonStore) Save(lesson *learning.StepByStepLesson) {
s.lessons[lesson.ID] = lesson
}
func (s *LessonStore) Get(id string) *learning.StepByStepLesson {
return s.lessons[id]
}
func (s *LessonStore) List(limit, offset int) []*learning.StepByStepLesson {
result := make([]*learning.StepByStepLesson, 0)
i := 0
for _, l := range s.lessons {
if i >= offset && len(result) < limit {
result = append(result, l)
}
i++
}
return result
}
func (s *LessonStore) Delete(id string) bool {
if _, ok := s.lessons[id]; ok {
delete(s.lessons, id)
return true
}
return false
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var llmClient llm.Client
var database *db.PostgresDB
var repo *db.LearningRepository
// Priority 1: Timeweb Cloud AI (recommended for production)
if cfg.TimewebAgentAccessID != "" && cfg.TimewebAPIKey != "" {
client, err := llm.NewTimewebClient(llm.TimewebConfig{
ProviderID: "timeweb",
BaseURL: cfg.TimewebAPIBaseURL,
AgentAccessID: cfg.TimewebAgentAccessID,
APIKey: cfg.TimewebAPIKey,
ModelKey: cfg.DefaultLLMModel,
ProxySource: cfg.TimewebProxySource,
})
if err != nil {
log.Printf("Warning: Failed to create Timeweb client: %v", err)
} else {
llmClient = client
log.Println("Using Timeweb Cloud AI as LLM provider")
}
}
// Priority 2: Anthropic
if llmClient == nil && cfg.AnthropicAPIKey != "" && !isJWT(cfg.AnthropicAPIKey) {
client, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
APIKey: cfg.AnthropicAPIKey,
ModelKey: "claude-3-5-sonnet-20241022",
})
if err != nil {
log.Printf("Warning: Failed to create Anthropic client: %v", err)
} else {
llmClient = client
log.Println("Using Anthropic as LLM provider")
}
}
// Priority 3: OpenAI (only if it's a real OpenAI key, not Timeweb JWT)
if llmClient == nil && cfg.OpenAIAPIKey != "" && !isJWT(cfg.OpenAIAPIKey) {
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
APIKey: cfg.OpenAIAPIKey,
ModelKey: "gpt-4o-mini",
})
if err != nil {
log.Printf("Warning: Failed to create OpenAI client: %v", err)
} else {
llmClient = client
log.Println("Using OpenAI as LLM provider")
if cfg.DatabaseURL != "" {
maxRetries := 30
for i := 0; i < maxRetries; i++ {
database, err = db.NewPostgresDB(cfg.DatabaseURL)
if err == nil {
break
}
log.Printf("Waiting for database (attempt %d/%d): %v", i+1, maxRetries, err)
time.Sleep(2 * time.Second)
}
if err != nil {
log.Fatal("Database required for learning-svc:", err)
}
defer database.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := database.RunMigrations(ctx); err != nil {
log.Printf("Base migrations warning: %v", err)
}
repo = db.NewLearningRepository(database)
if err := repo.RunMigrations(ctx); err != nil {
log.Printf("Learning migrations warning: %v", err)
}
log.Println("PostgreSQL connected, learning migrations complete")
} else {
log.Fatal("DATABASE_URL required for learning-svc")
}
llmClient := createLLMClient(cfg)
if llmClient == nil {
log.Fatal("No LLM provider configured. Please set TIMEWEB_AGENT_ACCESS_ID + TIMEWEB_API_KEY, or OPENAI_API_KEY, or ANTHROPIC_API_KEY")
log.Fatal("No LLM provider configured")
}
generator := learning.NewLearningGenerator(llmClient)
store := NewLessonStore()
searchClient := search.NewSearXNGClient(cfg)
courseGen := learning.NewCourseAutoGenerator(learning.CourseAutoGenConfig{
LLM: llmClient,
Repo: repo,
SearchClient: searchClient,
})
go courseGen.StartBackground(context.Background())
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024,
@@ -129,259 +85,296 @@ func main() {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Post("/api/v1/learning/lesson", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
Query string `json:"query"`
Difficulty string `json:"difficulty"`
Mode string `json:"mode"`
MaxSteps int `json:"maxSteps"`
Locale string `json:"locale"`
IncludeCode bool `json:"includeCode"`
IncludeQuiz bool `json:"includeQuiz"`
}
api := app.Group("/api/v1/learning", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: true,
}))
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
difficulty := learning.DifficultyBeginner
switch req.Difficulty {
case "intermediate":
difficulty = learning.DifficultyIntermediate
case "advanced":
difficulty = learning.DifficultyAdvanced
case "expert":
difficulty = learning.DifficultyExpert
}
mode := learning.ModeExplain
switch req.Mode {
case "guided":
mode = learning.ModeGuided
case "interactive":
mode = learning.ModeInteractive
case "practice":
mode = learning.ModePractice
case "quiz":
mode = learning.ModeQuiz
}
lesson, err := generator.GenerateLesson(ctx, learning.GenerateLessonOptions{
Topic: req.Topic,
Query: req.Query,
Difficulty: difficulty,
Mode: mode,
MaxSteps: req.MaxSteps,
Locale: req.Locale,
IncludeCode: req.IncludeCode,
IncludeQuiz: req.IncludeQuiz,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(lesson)
return c.JSON(lesson)
})
app.Post("/api/v1/learning/explain", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
Difficulty string `json:"difficulty"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
difficulty := learning.DifficultyBeginner
switch req.Difficulty {
case "intermediate":
difficulty = learning.DifficultyIntermediate
case "advanced":
difficulty = learning.DifficultyAdvanced
case "expert":
difficulty = learning.DifficultyExpert
}
step, err := generator.GenerateExplanation(ctx, req.Topic, difficulty, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(step)
})
app.Post("/api/v1/learning/quiz", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
NumQuestions int `json:"numQuestions"`
Difficulty string `json:"difficulty"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if req.NumQuestions == 0 {
req.NumQuestions = 5
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
difficulty := learning.DifficultyBeginner
switch req.Difficulty {
case "intermediate":
difficulty = learning.DifficultyIntermediate
case "advanced":
difficulty = learning.DifficultyAdvanced
case "expert":
difficulty = learning.DifficultyExpert
}
questions, err := generator.GenerateQuiz(ctx, req.Topic, req.NumQuestions, difficulty, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"questions": questions})
})
app.Post("/api/v1/learning/practice", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
Language string `json:"language"`
Difficulty string `json:"difficulty"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
difficulty := learning.DifficultyBeginner
switch req.Difficulty {
case "intermediate":
difficulty = learning.DifficultyIntermediate
case "advanced":
difficulty = learning.DifficultyAdvanced
case "expert":
difficulty = learning.DifficultyExpert
}
exercise, err := generator.GeneratePracticeExercise(ctx, req.Topic, req.Language, difficulty, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(exercise)
})
app.Get("/api/v1/learning/lessons", func(c *fiber.Ctx) error {
api.Get("/courses", func(c *fiber.Ctx) error {
category := c.Query("category")
difficulty := c.Query("difficulty")
search := c.Query("search")
limit := c.QueryInt("limit", 20)
offset := c.QueryInt("offset", 0)
lessons := store.List(limit, offset)
summaries := make([]map[string]interface{}, 0)
for _, l := range lessons {
summaries = append(summaries, map[string]interface{}{
"id": l.ID,
"title": l.Title,
"topic": l.Topic,
"difficulty": l.Difficulty,
"mode": l.Mode,
"stepsCount": len(l.Steps),
"estimatedTime": l.EstimatedTime,
"progress": l.Progress,
"createdAt": l.CreatedAt,
})
courses, total, err := repo.ListCourses(c.Context(), category, difficulty, search, limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to list courses"})
}
return c.JSON(fiber.Map{"lessons": summaries, "count": len(summaries)})
return c.JSON(fiber.Map{"courses": courses, "total": total})
})
app.Get("/api/v1/learning/lessons/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
lesson := store.Get(id)
if lesson == nil {
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
api.Get("/courses/:slug", func(c *fiber.Ctx) error {
slug := c.Params("slug")
course, err := repo.GetCourseBySlug(c.Context(), slug)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get course"})
}
return c.JSON(lesson)
if course == nil {
return c.Status(404).JSON(fiber.Map{"error": "Course not found"})
}
return c.JSON(course)
})
app.Post("/api/v1/learning/lessons/:id/complete-step", func(c *fiber.Ctx) error {
id := c.Params("id")
lesson := store.Get(id)
if lesson == nil {
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
api.Get("/me/profile", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
profile, err := repo.GetProfile(c.Context(), userID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get profile"})
}
if profile == nil {
return c.JSON(fiber.Map{"profile": nil, "exists": false})
}
return c.JSON(fiber.Map{"profile": profile, "exists": true})
})
api.Post("/me/profile", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
var req struct {
StepIndex int `json:"stepIndex"`
DisplayName string `json:"displayName"`
Profile json.RawMessage `json:"profile"`
OnboardingCompleted bool `json:"onboardingCompleted"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
lesson.CompleteStep(req.StepIndex)
if req.Profile == nil {
req.Profile = json.RawMessage("{}")
}
return c.JSON(fiber.Map{
"success": true,
"progress": lesson.Progress,
})
profile := &db.LearningUserProfile{
UserID: userID,
DisplayName: req.DisplayName,
Profile: req.Profile,
OnboardingCompleted: req.OnboardingCompleted,
}
if err := repo.UpsertProfile(c.Context(), profile); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save profile"})
}
return c.JSON(fiber.Map{"success": true})
})
app.Post("/api/v1/learning/lessons/:id/submit-answer", func(c *fiber.Ctx) error {
id := c.Params("id")
lesson := store.Get(id)
if lesson == nil {
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
api.Post("/me/onboarding", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
var req struct {
StepIndex int `json:"stepIndex"`
SelectedOptions []string `json:"selectedOptions"`
DisplayName string `json:"displayName"`
Answers map[string]string `json:"answers"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
correct, explanation := lesson.SubmitQuizAnswer(req.StepIndex, req.SelectedOptions)
if correct {
lesson.CompleteStep(req.StepIndex)
sanitizedAnswers := make(map[string]string, len(req.Answers))
for k, v := range req.Answers {
key := strings.TrimSpace(k)
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
if len(val) > 600 {
val = val[:600]
}
sanitizedAnswers[key] = val
}
if len(sanitizedAnswers) < 3 {
return c.Status(400).JSON(fiber.Map{"error": "At least 3 onboarding answers are required"})
}
return c.JSON(fiber.Map{
"correct": correct,
"explanation": explanation,
"progress": lesson.Progress,
})
ctx, cancel := context.WithTimeout(c.Context(), 60*time.Second)
defer cancel()
profileJSON, err := learning.BuildProfileFromOnboarding(ctx, llmClient, sanitizedAnswers)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to build onboarding profile"})
}
existingProfile, _ := repo.GetProfile(c.Context(), userID)
profile := &db.LearningUserProfile{
UserID: userID,
DisplayName: strings.TrimSpace(req.DisplayName),
Profile: profileJSON,
OnboardingCompleted: true,
}
if existingProfile != nil {
if profile.DisplayName == "" {
profile.DisplayName = existingProfile.DisplayName
}
profile.ResumeFileID = existingProfile.ResumeFileID
profile.ResumeExtractedText = existingProfile.ResumeExtractedText
}
if err := repo.UpsertProfile(c.Context(), profile); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save onboarding profile"})
}
return c.JSON(fiber.Map{"success": true, "profile": profileJSON})
})
app.Delete("/api/v1/learning/lessons/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
if store.Delete(id) {
return c.JSON(fiber.Map{"success": true})
api.Post("/me/resume", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
var req struct {
FileID string `json:"fileId"`
ExtractedText string `json:"extractedText"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if req.ExtractedText == "" {
return c.Status(400).JSON(fiber.Map{"error": "Extracted text required"})
}
ctx, cancel := context.WithTimeout(c.Context(), 60*time.Second)
defer cancel()
profileJSON, err := learning.BuildProfileFromResume(ctx, llmClient, req.ExtractedText)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to build profile from resume"})
}
profile := &db.LearningUserProfile{
UserID: userID,
Profile: profileJSON,
ResumeFileID: &req.FileID,
ResumeExtractedText: req.ExtractedText,
OnboardingCompleted: true,
}
if err := repo.UpsertProfile(c.Context(), profile); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save profile"})
}
return c.JSON(fiber.Map{"success": true, "profile": profileJSON})
})
api.Post("/enroll", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
var req struct {
CourseID string `json:"courseId"`
Slug string `json:"slug"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
var course *db.LearningCourse
var courseErr error
if req.CourseID != "" {
course, courseErr = repo.GetCourseByID(c.Context(), req.CourseID)
} else if req.Slug != "" {
course, courseErr = repo.GetCourseBySlug(c.Context(), req.Slug)
} else {
return c.Status(400).JSON(fiber.Map{"error": "courseId or slug required"})
}
if courseErr != nil || course == nil {
return c.Status(404).JSON(fiber.Map{"error": "Course not found"})
}
ctx, cancel := context.WithTimeout(c.Context(), 90*time.Second)
defer cancel()
profile, _ := repo.GetProfile(ctx, userID)
var profileText string
if profile != nil {
profileText = string(profile.Profile)
}
plan, err := learning.BuildPersonalPlan(ctx, llmClient, course, profileText)
if err != nil {
plan = course.BaseOutline
}
enrollment := &db.LearningEnrollment{
UserID: userID,
CourseID: course.ID,
Status: "active",
Plan: plan,
Progress: json.RawMessage(`{"completed_modules":[],"current_module":0,"score":0}`),
}
if err := repo.CreateEnrollment(c.Context(), enrollment); err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
return c.Status(409).JSON(fiber.Map{"error": "Already enrolled in this course"})
}
return c.Status(500).JSON(fiber.Map{"error": "Failed to create enrollment"})
}
return c.Status(201).JSON(enrollment)
})
api.Get("/enrollments", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
enrollments, err := repo.ListEnrollments(c.Context(), userID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to list enrollments"})
}
if enrollments == nil {
enrollments = []*db.LearningEnrollment{}
}
return c.JSON(fiber.Map{"enrollments": enrollments})
})
api.Get("/enrollments/:id", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
enrollment, err := repo.GetEnrollment(c.Context(), c.Params("id"))
if err != nil || enrollment == nil {
return c.Status(404).JSON(fiber.Map{"error": "Enrollment not found"})
}
if enrollment.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
course, _ := repo.GetCourseByID(c.Context(), enrollment.CourseID)
enrollment.Course = course
tasks, _ := repo.ListTasksByEnrollment(c.Context(), enrollment.ID)
if tasks == nil {
tasks = []*db.LearningTask{}
}
return c.JSON(fiber.Map{"enrollment": enrollment, "tasks": tasks})
})
api.Get("/enrollments/:id/tasks", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
enrollment, err := repo.GetEnrollment(c.Context(), c.Params("id"))
if err != nil || enrollment == nil || enrollment.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Not found"})
}
tasks, err := repo.ListTasksByEnrollment(c.Context(), enrollment.ID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to list tasks"})
}
if tasks == nil {
tasks = []*db.LearningTask{}
}
return c.JSON(fiber.Map{"tasks": tasks})
})
port := getEnvInt("LEARNING_SVC_PORT", 3034)
@@ -389,6 +382,43 @@ func main() {
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func createLLMClient(cfg *config.Config) llm.Client {
if cfg.TimewebAgentAccessID != "" && cfg.TimewebAPIKey != "" {
client, err := llm.NewTimewebClient(llm.TimewebConfig{
ProviderID: "timeweb",
BaseURL: cfg.TimewebAPIBaseURL,
AgentAccessID: cfg.TimewebAgentAccessID,
APIKey: cfg.TimewebAPIKey,
ModelKey: cfg.DefaultLLMModel,
ProxySource: cfg.TimewebProxySource,
})
if err == nil {
return client
}
}
if cfg.AnthropicAPIKey != "" && !isJWT(cfg.AnthropicAPIKey) {
client, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
APIKey: cfg.AnthropicAPIKey,
ModelKey: "claude-3-5-sonnet-20241022",
})
if err == nil {
return client
}
}
if cfg.OpenAIAPIKey != "" && !isJWT(cfg.OpenAIAPIKey) {
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
APIKey: cfg.OpenAIAPIKey,
ModelKey: "gpt-4o-mini",
})
if err == nil {
return client
}
}
return nil
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int

View File

@@ -0,0 +1,120 @@
package main
import (
"bufio"
"context"
"fmt"
"log"
"os"
"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/medicine"
"github.com/gooseek/backend/pkg/middleware"
)
func main() {
llmClient := createLLMClient()
if llmClient == nil {
log.Println("medicine-svc: no LLM configured, fallback mode enabled")
}
svc := medicine.NewService(medicine.ServiceConfig{
LLM: llmClient,
SearXNGURL: getEnv("SEARXNG_URL", "http://searxng:8080"),
Timeout: 20 * time.Second,
})
app := fiber.New(fiber.Config{
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * 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": "medicine-svc"})
})
jwtOptional := middleware.JWTConfig{
Secret: os.Getenv("JWT_SECRET"),
AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"),
AllowGuest: true,
}
api := app.Group("/api/v1/medicine")
api.Post("/consult", middleware.JWT(jwtOptional), func(c *fiber.Ctx) error {
var req medicine.ConsultRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Symptoms == "" {
return c.Status(400).JSON(fiber.Map{"error": "symptoms is required"})
}
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, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if err := svc.StreamConsult(ctx, req, w); err != nil {
log.Printf("medicine consult error: %v", err)
}
})
return nil
})
port := getEnvInt("PORT", 3037)
log.Printf("medicine-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func createLLMClient() llm.Client {
cfg := 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"),
}
client, err := llm.NewClient(cfg)
if err == nil {
return client
}
if os.Getenv("OPENAI_API_KEY") != "" {
openAIClient, openAIErr := llm.NewClient(llm.ProviderConfig{
ProviderID: "openai",
ModelKey: "gpt-4o-mini",
APIKey: os.Getenv("OPENAI_API_KEY"),
})
if openAIErr == nil {
return openAIClient
}
}
return nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
var out int
if _, err := fmt.Sscanf(v, "%d", &out); err != nil {
return fallback
}
return out
}

View File

@@ -0,0 +1,540 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"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/db"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
)
var (
openSandboxURL string
repo *db.LearningRepository
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
openSandboxURL = getEnv("OPENSANDBOX_URL", "http://opensandbox-server:8080")
var database *db.PostgresDB
if cfg.DatabaseURL != "" {
maxRetries := 30
for i := 0; i < maxRetries; i++ {
database, err = db.NewPostgresDB(cfg.DatabaseURL)
if err == nil {
break
}
log.Printf("Waiting for database (attempt %d/%d): %v", i+1, maxRetries, err)
time.Sleep(2 * time.Second)
}
if err != nil {
log.Fatal("Database required for sandbox-svc:", err)
}
defer database.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := database.RunMigrations(ctx); err != nil {
log.Printf("Base migrations warning: %v", err)
}
repo = db.NewLearningRepository(database)
if err := repo.RunMigrations(ctx); err != nil {
log.Printf("Learning migrations warning: %v", err)
}
log.Println("PostgreSQL connected")
} else {
log.Fatal("DATABASE_URL required for sandbox-svc")
}
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: 60 * time.Second,
WriteTimeout: 5 * time.Minute,
})
app.Use(logger.New())
app.Use(cors.New())
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
api := app.Group("/api/v1/sandbox", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: false,
}))
api.Post("/sessions", handleCreateSession)
api.Get("/sessions/:id", handleGetSession)
api.Get("/sessions/:id/files", handleListFiles)
api.Get("/sessions/:id/file", handleReadFile)
api.Put("/sessions/:id/file", handleWriteFile)
api.Post("/sessions/:id/commands/run", handleRunCommand)
api.Post("/sessions/:id/verify", handleVerify)
port := getEnvInt("SANDBOX_SVC_PORT", 3036)
log.Printf("sandbox-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func handleCreateSession(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
var req struct {
TaskID string `json:"taskId"`
Image string `json:"image"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
image := req.Image
if image == "" {
image = "opensandbox/code-interpreter:v1.0.1"
}
sandboxResp, err := createOpenSandbox(image)
if err != nil {
log.Printf("OpenSandbox create error: %v", err)
return c.Status(503).JSON(fiber.Map{"error": "Sandbox creation failed: " + err.Error()})
}
session := &db.SandboxSession{
UserID: userID,
OpenSandboxID: sandboxResp.ID,
Status: "ready",
Metadata: json.RawMessage(fmt.Sprintf(`{"image":"%s"}`, image)),
}
if req.TaskID != "" {
session.TaskID = &req.TaskID
}
if err := repo.CreateSandboxSession(c.Context(), session); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save session"})
}
logEvent(c.Context(), session.ID, "session_created", map[string]interface{}{"image": image})
return c.Status(201).JSON(session)
}
func handleGetSession(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
if err != nil || session == nil || session.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
}
return c.JSON(session)
}
func handleListFiles(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
if err != nil || session == nil || session.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
}
path := c.Query("path", "/home/user")
result, err := sandboxFilesRequest(session.OpenSandboxID, path)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to list files"})
}
return c.JSON(result)
}
func handleReadFile(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
if err != nil || session == nil || session.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
}
path := c.Query("path")
if path == "" {
return c.Status(400).JSON(fiber.Map{"error": "path query required"})
}
content, err := sandboxReadFile(session.OpenSandboxID, path)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to read file"})
}
logEvent(c.Context(), session.ID, "file_read", map[string]interface{}{"path": path})
return c.JSON(fiber.Map{"path": path, "content": content})
}
func handleWriteFile(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
if err != nil || session == nil || session.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
}
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if err := sandboxWriteFile(session.OpenSandboxID, req.Path, req.Content); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to write file"})
}
logEvent(c.Context(), session.ID, "file_write", map[string]interface{}{
"path": req.Path,
"size": len(req.Content),
})
return c.JSON(fiber.Map{"success": true})
}
func handleRunCommand(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
if err != nil || session == nil || session.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
}
var req struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
result, err := sandboxRunCommand(session.OpenSandboxID, req.Command, req.Cwd)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to run command"})
}
logEvent(c.Context(), session.ID, "command_run", map[string]interface{}{
"command": req.Command,
"exit_code": result["exit_code"],
})
return c.JSON(result)
}
func handleVerify(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
if err != nil || session == nil || session.UserID != userID {
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
}
var req struct {
Command string `json:"command"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if req.Command == "" {
if session.TaskID != nil {
task, _ := repo.GetTask(c.Context(), *session.TaskID)
if task != nil && task.VerificationCmd != "" {
req.Command = task.VerificationCmd
}
}
if req.Command == "" {
req.Command = "echo 'No verification command configured'"
}
}
result, err := sandboxRunCommand(session.OpenSandboxID, req.Command, "")
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Verification failed"})
}
logEvent(c.Context(), session.ID, "verify", map[string]interface{}{
"command": req.Command,
"exit_code": result["exit_code"],
"stdout": result["stdout"],
})
passed := false
if exitCode, ok := result["exit_code"].(float64); ok && exitCode == 0 {
passed = true
}
if session.TaskID != nil {
resultJSON, _ := json.Marshal(result)
submission := &db.LearningSubmission{
TaskID: *session.TaskID,
SandboxSessionID: &session.ID,
Result: resultJSON,
Score: 0,
MaxScore: 100,
}
if passed {
submission.Score = 100
}
repo.CreateSubmission(c.Context(), submission)
repo.UpdateTaskStatus(c.Context(), *session.TaskID, "verified")
}
return c.JSON(fiber.Map{
"passed": passed,
"result": result,
"sessionId": session.ID,
})
}
// --- OpenSandbox HTTP client ---
type sandboxCreateResponse struct {
ID string `json:"id"`
SandboxID string `json:"sandbox_id"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
func createOpenSandbox(image string) (*sandboxCreateResponse, error) {
payload, _ := json.Marshal(map[string]interface{}{
"image": image,
"entrypoint": []string{"/opt/opensandbox/code-interpreter.sh"},
"timeout": "30m",
})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", openSandboxURL+"/api/v1/sandboxes", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("opensandbox unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("opensandbox error %d: %s", resp.StatusCode, string(body))
}
var result sandboxCreateResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if result.ID == "" {
if result.SandboxID != "" {
result.ID = result.SandboxID
} else if result.Data.ID != "" {
result.ID = result.Data.ID
}
}
if result.ID == "" {
return nil, fmt.Errorf("opensandbox response missing sandbox id")
}
return &result, nil
}
func sandboxFilesRequest(sandboxID, path string) (interface{}, error) {
reqURL := fmt.Sprintf("%s/api/v1/sandboxes/%s/files?path=%s", openSandboxURL, sandboxID, url.QueryEscape(path))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("files request failed: status %d: %s", resp.StatusCode, string(body))
}
var result interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func sandboxReadFile(sandboxID, path string) (string, error) {
reqURL := fmt.Sprintf("%s/api/v1/sandboxes/%s/files/read?path=%s", openSandboxURL, sandboxID, url.QueryEscape(path))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("read file failed: status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var structured map[string]interface{}
if err := json.Unmarshal(body, &structured); err == nil {
if content, ok := structured["content"].(string); ok {
return content, nil
}
if data, ok := structured["data"].(map[string]interface{}); ok {
if content, ok := data["content"].(string); ok {
return content, nil
}
}
}
return string(body), nil
}
func sandboxWriteFile(sandboxID, path, content string) error {
payload, _ := json.Marshal(map[string]interface{}{
"entries": []map[string]interface{}{
{"path": path, "data": content, "mode": 644},
},
})
url := fmt.Sprintf("%s/api/v1/sandboxes/%s/files/write", openSandboxURL, sandboxID)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("write file failed: status %d: %s", resp.StatusCode, string(body))
}
return nil
}
func sandboxRunCommand(sandboxID, command, cwd string) (map[string]interface{}, error) {
if cwd == "" {
cwd = "/home/user"
}
payload, _ := json.Marshal(map[string]interface{}{
"cmd": command,
"cwd": cwd,
})
url := fmt.Sprintf("%s/api/v1/sandboxes/%s/commands/run", openSandboxURL, sandboxID)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("run command failed: status %d: %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
normalizeCommandResult(result)
return result, nil
}
func normalizeCommandResult(result map[string]interface{}) {
if result == nil {
return
}
if _, ok := result["exit_code"]; !ok {
if exitCode, exists := result["exitCode"]; exists {
result["exit_code"] = exitCode
}
}
if _, ok := result["stdout"]; !ok {
if output, exists := result["output"]; exists {
if s, ok := output.(string); ok {
result["stdout"] = s
}
}
}
if _, ok := result["stderr"]; !ok {
result["stderr"] = ""
}
}
func logEvent(ctx context.Context, sessionID, eventType string, data map[string]interface{}) {
if repo == nil {
return
}
payload, _ := json.Marshal(data)
repo.CreateSandboxEvent(ctx, sessionID, eventType, payload)
}
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 != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}

View File

@@ -121,6 +121,7 @@ func main() {
api.Get("/poi", middleware.JWT(jwtOptional), handleSearchPOI(svc))
api.Post("/poi", middleware.JWT(jwtOptional), handleSearchPOIPost(svc))
api.Post("/places", middleware.JWT(jwtOptional), handleSearchPlaces(svc))
api.Post("/validate-itinerary", middleware.JWT(jwtOptional), handleValidateItinerary(svc))
port := getEnvInt("PORT", 3035)
log.Printf("travel-svc listening on :%d", port)
@@ -522,6 +523,28 @@ func handleSearchPlaces(svc *travel.Service) fiber.Handler {
}
}
func handleValidateItinerary(svc *travel.Service) fiber.Handler {
return func(c *fiber.Ctx) error {
var req travel.ValidateItineraryRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if len(req.Days) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "days required"})
}
ctx, cancel := context.WithTimeout(c.Context(), 30*time.Second)
defer cancel()
result, err := svc.ValidateItinerary(ctx, req)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
}
func getEnv(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val

View File

@@ -24,7 +24,9 @@ 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/sandbox-svc ./cmd/sandbox-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/medicine-svc ./cmd/medicine-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
@@ -43,7 +45,7 @@ COPY --from=builder /bin/* /app/
ENV SERVICE=api-gateway
ENV PORT=3015
EXPOSE 3015 3018 3005 3001 3020 3021 3002 3025 3026 3027 3035 3040
EXPOSE 3015 3018 3005 3001 3020 3021 3002 3025 3026 3027 3034 3035 3036 3037 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

@@ -5,11 +5,11 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=auth-svc
- PORT=3050
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
- JWT_SECRET=${JWT_SECRET}
- AUTH_SVC_URL=http://auth-svc:3050
ports:
- "3050:3050"
depends_on:
@@ -26,6 +26,7 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=api-gateway
- PORT=3015
@@ -39,9 +40,10 @@ services:
- DISCOVER_SVC_URL=http://discover-svc:3002
- FINANCE_HEATMAP_SVC_URL=http://finance-heatmap-svc:3033
- LEARNING_SVC_URL=http://learning-svc:3034
- SANDBOX_SVC_URL=http://sandbox-svc:3036
- TRAVEL_SVC_URL=http://travel-svc:3035
- MEDICINE_SVC_URL=http://medicine-svc:3037
- ADMIN_SVC_URL=http://admin-svc:3040
- JWT_SECRET=${JWT_SECRET}
- REDIS_URL=redis://redis:6379
ports:
- "3015:3015"
@@ -52,6 +54,7 @@ services:
- thread-svc
- admin-svc
- travel-svc
- medicine-svc
- redis
networks:
- gooseek
@@ -60,10 +63,10 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.chat-svc
env_file: ../../../.env
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
@@ -79,23 +82,15 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.agent-svc
env_file: ../../../.env
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}
- TIMEWEB_API_BASE_URL=${TIMEWEB_API_BASE_URL}
- TIMEWEB_AGENT_ACCESS_ID=${TIMEWEB_AGENT_ACCESS_ID}
- TIMEWEB_API_KEY=${TIMEWEB_API_KEY}
ports:
- "3018:3018"
depends_on:
@@ -111,6 +106,7 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.search-svc
env_file: ../../../.env
environment:
- SERVICE=search-svc
- PORT=3001
@@ -126,12 +122,10 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=llm-svc
- PORT=3020
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GEMINI_API_KEY=${GEMINI_API_KEY}
ports:
- "3020:3020"
networks:
@@ -141,6 +135,7 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=scraper-svc
- PORT=3021
@@ -154,6 +149,7 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.discover-svc
env_file: ../../../.env
environment:
- SERVICE=discover-svc
- PORT=3002
@@ -173,12 +169,12 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=collection-svc
- PORT=3025
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
- JWT_SECRET=${JWT_SECRET}
- AUTH_SVC_URL=${AUTH_SVC_URL}
- AUTH_SVC_URL=http://auth-svc:3050
ports:
- "3025:3025"
depends_on:
@@ -190,13 +186,11 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=file-svc
- PORT=3026
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- JWT_SECRET=${JWT_SECRET}
- FILE_STORAGE_PATH=/data/files
ports:
- "3026:3026"
@@ -211,13 +205,12 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=thread-svc
- PORT=3027
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
- OPENAI_API_KEY=${OPENAI_API_KEY}
- JWT_SECRET=${JWT_SECRET}
- AUTH_SVC_URL=${AUTH_SVC_URL}
- AUTH_SVC_URL=http://auth-svc:3050
ports:
- "3027:3027"
depends_on:
@@ -229,12 +222,11 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=labs-svc
- PORT=3031
- LABS_SVC_PORT=3031
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
ports:
- "3031:3031"
networks:
@@ -244,13 +236,11 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=podcast-svc
- PORT=3032
- PODCAST_SVC_PORT=3032
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
ports:
- "3032:3032"
volumes:
@@ -262,12 +252,11 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- 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:
@@ -279,16 +268,12 @@ services:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=learning-svc
- PORT=3034
- LEARNING_SVC_PORT=3034
- TIMEWEB_API_BASE_URL=${TIMEWEB_API_BASE_URL}
- TIMEWEB_AGENT_ACCESS_ID=${TIMEWEB_AGENT_ACCESS_ID}
- TIMEWEB_API_KEY=${TIMEWEB_API_KEY}
- DEFAULT_LLM_MODEL=${DEFAULT_LLM_MODEL:-gpt-4o-mini}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- AUTH_SVC_URL=http://auth-svc:3050
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
ports:
- "3034:3034"
@@ -297,31 +282,37 @@ services:
networks:
- gooseek
sandbox-svc:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=sandbox-svc
- PORT=3036
- SANDBOX_SVC_PORT=3036
- AUTH_SVC_URL=http://auth-svc:3050
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
- OPENSANDBOX_URL=http://opensandbox-server:8080
ports:
- "3036:3036"
depends_on:
- postgres
networks:
- gooseek
travel-svc:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
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:
@@ -330,17 +321,36 @@ services:
networks:
- gooseek
medicine-svc:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=medicine-svc
- PORT=3037
- AUTH_SVC_URL=http://auth-svc:3050
- SEARXNG_URL=http://searxng:8080
- LLM_PROVIDER=timeweb
ports:
- "3037:3037"
depends_on:
- auth-svc
- searxng
networks:
- gooseek
admin-svc:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.all
env_file: ../../../.env
environment:
- SERVICE=admin-svc
- PORT=3040
- ADMIN_SVC_PORT=3040
- DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable
- JWT_SECRET=${JWT_SECRET}
- AUTH_SVC_URL=${AUTH_SVC_URL}
- AUTH_SVC_URL=http://auth-svc:3050
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
@@ -378,12 +388,12 @@ services:
context: ../../webui
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/medicine}
NEXT_PUBLIC_ENABLED_ROUTES: ${NEXT_PUBLIC_ENABLED_ROUTES:-}
NEXT_PUBLIC_TWOGIS_API_KEY: ${NEXT_PUBLIC_TWOGIS_API_KEY:-}
env_file: ../../../.env
environment:
- NODE_ENV=production
- API_URL=http://api-gateway:3015
- NEXT_PUBLIC_API_URL=
- NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/medicine}
ports:
- "3000:3000"
depends_on:

View File

@@ -16,6 +16,27 @@ data:
COLLECTION_SVC_URL: "http://collection-svc:3025"
FILE_SVC_URL: "http://file-svc:3026"
THREAD_SVC_URL: "http://thread-svc:3027"
LEARNING_SVC_URL: "http://learning-svc:3034"
MEDICINE_SVC_URL: "http://medicine-svc:3037"
SANDBOX_SVC_URL: "http://sandbox-svc:3036"
OPENSANDBOX_URL: "http://opensandbox-server:8080"
AUTH_SVC_URL: "http://auth-svc:3050"
TRAVEL_SVC_URL: "http://travel-svc:3035"
ADMIN_SVC_URL: "http://admin-svc:3040"
DEFAULT_LLM_MODEL: "${DEFAULT_LLM_MODEL}"
DEFAULT_LLM_PROVIDER: "${DEFAULT_LLM_PROVIDER}"
TIMEWEB_API_BASE_URL: "${TIMEWEB_API_BASE_URL}"
TIMEWEB_AGENT_ACCESS_ID: "${TIMEWEB_AGENT_ACCESS_ID}"
TRAVELPAYOUTS_TOKEN: "${TRAVELPAYOUTS_TOKEN}"
NEXT_PUBLIC_ENABLED_ROUTES: "${NEXT_PUBLIC_ENABLED_ROUTES}"
NEXT_PUBLIC_TWOGIS_API_KEY: "${NEXT_PUBLIC_TWOGIS_API_KEY}"
S3_ENDPOINT: "${S3_ENDPOINT}"
S3_ACCESS_KEY: "${S3_ACCESS_KEY}"
S3_SECRET_KEY: "${S3_SECRET_KEY}"
S3_BUCKET: "${S3_BUCKET}"
S3_USE_SSL: "${S3_USE_SSL}"
S3_REGION: "${S3_REGION}"
S3_PUBLIC_URL: "${S3_PUBLIC_URL}"
---
apiVersion: v1
kind: Secret
@@ -28,5 +49,6 @@ stringData:
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}"
GEMINI_API_KEY: "${GEMINI_API_KEY}"
JWT_SECRET: "${JWT_SECRET}"
TIMEWEB_API_KEY: "${TIMEWEB_API_KEY}"
POSTGRES_USER: "gooseek"
POSTGRES_PASSWORD: "gooseek"

View File

@@ -3,9 +3,24 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
ROOT_DIR="$(cd "$BACKEND_DIR/.." && pwd)"
ENV_FILE="$ROOT_DIR/.env"
echo "=== GooSeek Go Backend K8s Deployment ==="
REGISTRY="localhost:5000"
IMAGE_TAG="${IMAGE_TAG:-latest}"
echo "=== GooSeek K8s Deployment ==="
echo "Backend dir: $BACKEND_DIR"
echo "Registry: $REGISTRY"
echo "Tag: $IMAGE_TAG"
# Load .env
if [ -f "$ENV_FILE" ]; then
echo "Loading env from $ENV_FILE"
set -a
source "$ENV_FILE"
set +a
fi
# Check kubectl
if ! command -v kubectl &> /dev/null; then
@@ -13,17 +28,40 @@ if ! command -v kubectl &> /dev/null; then
exit 1
fi
# Build Docker image
# Build and push backend image
echo ""
echo "=== Building Docker image ==="
echo "=== Building Go backend image ==="
cd "$BACKEND_DIR"
docker build -f deploy/docker/Dockerfile.all -t gooseek/backend:latest .
docker build -f deploy/docker/Dockerfile.all \
-t "$REGISTRY/gooseek/backend:$IMAGE_TAG" \
-t "$REGISTRY/gooseek/backend:latest" \
.
# Load to k3s (if using k3s)
if command -v k3s &> /dev/null; then
echo ""
echo "=== Loading image to k3s ==="
docker save gooseek/backend:latest | sudo k3s ctr images import -
echo "=== Pushing backend to registry ==="
docker push "$REGISTRY/gooseek/backend:$IMAGE_TAG"
docker push "$REGISTRY/gooseek/backend:latest"
# Build and push webui image
echo ""
echo "=== Building webui image ==="
docker build \
-f "$BACKEND_DIR/webui/Dockerfile" \
--build-arg "NEXT_PUBLIC_ENABLED_ROUTES=${NEXT_PUBLIC_ENABLED_ROUTES:-}" \
--build-arg "NEXT_PUBLIC_TWOGIS_API_KEY=${NEXT_PUBLIC_TWOGIS_API_KEY:-}" \
-t "$REGISTRY/gooseek/webui:$IMAGE_TAG" \
-t "$REGISTRY/gooseek/webui:latest" \
"$BACKEND_DIR/webui"
echo "=== Pushing webui to registry ==="
docker push "$REGISTRY/gooseek/webui:$IMAGE_TAG"
docker push "$REGISTRY/gooseek/webui:latest"
# Generate configmap/secrets from .env via envsubst
echo ""
echo "=== Generating K8s manifests from .env ==="
if command -v envsubst &> /dev/null && [ -f "$ENV_FILE" ]; then
envsubst < "$SCRIPT_DIR/configmap.yaml" > "$SCRIPT_DIR/_generated_configmap.yaml"
kubectl apply -f "$SCRIPT_DIR/_generated_configmap.yaml" -n gooseek
fi
# Apply kustomization
@@ -32,20 +70,31 @@ echo "=== Applying K8s manifests ==="
cd "$SCRIPT_DIR"
kubectl apply -k .
# Rolling restart to pull new images
echo ""
echo "=== Rolling restart deployments ==="
kubectl -n gooseek rollout restart deployment/api-gateway
kubectl -n gooseek rollout restart deployment/webui
kubectl -n gooseek rollout restart deployment/chat-svc
kubectl -n gooseek rollout restart deployment/agent-svc
kubectl -n gooseek rollout restart deployment/discover-svc
kubectl -n gooseek rollout restart deployment/search-svc
kubectl -n gooseek rollout restart deployment/learning-svc
kubectl -n gooseek rollout restart deployment/medicine-svc
kubectl -n gooseek rollout restart deployment/travel-svc
kubectl -n gooseek rollout restart deployment/sandbox-svc
# Wait for rollout
echo ""
echo "=== Waiting for deployments ==="
kubectl -n gooseek rollout status deployment/api-gateway --timeout=120s || true
echo "=== Waiting for rollouts ==="
kubectl -n gooseek rollout status deployment/api-gateway --timeout=180s || true
kubectl -n gooseek rollout status deployment/chat-svc --timeout=120s || true
kubectl -n gooseek rollout status deployment/agent-svc --timeout=120s || true
kubectl -n gooseek rollout status deployment/discover-svc --timeout=120s || true
kubectl -n gooseek rollout status deployment/search-svc --timeout=120s || true
kubectl -n gooseek rollout status deployment/redis --timeout=60s || true
# Show status
echo ""
echo "=== Deployment Status ==="
kubectl -n gooseek get pods
kubectl -n gooseek get pods -o wide
echo ""
kubectl -n gooseek get svc
echo ""
@@ -53,4 +102,5 @@ kubectl -n gooseek get ingress
echo ""
echo "=== Done ==="
echo "API Gateway: http://localhost:3015 (NodePort) or via Ingress"
echo "API: https://api.gooseek.ru"
echo "Web: https://gooseek.ru"

View File

@@ -0,0 +1,44 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
containers:
- name: gitea
image: gitea/gitea:1.22
ports:
- containerPort: 3000
name: http
- containerPort: 22
name: ssh
volumeMounts:
- name: data
mountPath: /data
env:
- name: GITEA__database__DB_TYPE
value: sqlite3
- name: GITEA__server__DOMAIN
value: git.gooseek.ru
- name: GITEA__server__ROOT_URL
value: https://git.gooseek.ru/
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: data
persistentVolumeClaim:
claimName: gitea-data

View File

@@ -4,7 +4,7 @@ metadata:
name: gooseek-ingress
namespace: gooseek
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
@@ -14,9 +14,20 @@ spec:
ingressClassName: nginx
tls:
- hosts:
- gooseek.ru
- api.gooseek.ru
secretName: gooseek-tls
rules:
- host: gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webui
port:
number: 3000
- host: api.gooseek.ru
http:
paths:
@@ -27,25 +38,3 @@ spec:
name: api-gateway
port:
number: 3015
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gooseek-ingress-local
namespace: gooseek
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
spec:
ingressClassName: nginx
rules:
- host: localhost
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-gateway
port:
number: 3015

View File

@@ -9,6 +9,7 @@ resources:
- postgres.yaml
- redis.yaml
- api-gateway.yaml
- webui.yaml
- chat-svc.yaml
- agent-svc.yaml
- search-svc.yaml
@@ -18,6 +19,11 @@ resources:
- collection-svc.yaml
- file-svc.yaml
- thread-svc.yaml
- learning-svc.yaml
- medicine-svc.yaml
- travel-svc.yaml
- sandbox-svc.yaml
- opensandbox.yaml
- ingress.yaml
commonLabels:
@@ -26,4 +32,8 @@ commonLabels:
images:
- name: gooseek/backend
newName: localhost:5000/gooseek/backend
newTag: latest
- name: gooseek/webui
newName: localhost:5000/gooseek/webui
newTag: latest

View File

@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: learning-svc
namespace: gooseek
labels:
app: learning-svc
app.kubernetes.io/name: learning-svc
app.kubernetes.io/part-of: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: learning-svc
template:
metadata:
labels:
app: learning-svc
spec:
containers:
- name: learning-svc
image: gooseek/backend:latest
env:
- name: SERVICE
value: "learning-svc"
- name: PORT
value: "3034"
envFrom:
- configMapRef:
name: gooseek-config
- secretRef:
name: gooseek-secrets
ports:
- containerPort: 3034
name: http
livenessProbe:
httpGet:
path: /health
port: 3034
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 3034
initialDelaySeconds: 10
periodSeconds: 15
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: learning-svc
namespace: gooseek
spec:
type: ClusterIP
selector:
app: learning-svc
ports:
- port: 3034
targetPort: 3034
name: http

View File

@@ -0,0 +1,70 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: medicine-svc
namespace: gooseek
labels:
app: medicine-svc
app.kubernetes.io/name: medicine-svc
app.kubernetes.io/part-of: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: medicine-svc
template:
metadata:
labels:
app: medicine-svc
spec:
containers:
- name: medicine-svc
image: gooseek/backend:latest
env:
- name: SERVICE
value: "medicine-svc"
- name: PORT
value: "3037"
- name: LLM_PROVIDER
value: "timeweb"
envFrom:
- configMapRef:
name: gooseek-config
- secretRef:
name: gooseek-secrets
ports:
- containerPort: 3037
name: http
livenessProbe:
httpGet:
path: /health
port: 3037
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 3037
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 150m
memory: 192Mi
limits:
cpu: 700m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: medicine-svc
namespace: gooseek
spec:
type: ClusterIP
selector:
app: medicine-svc
ports:
- port: 3037
targetPort: 3037
name: http

View File

@@ -0,0 +1,165 @@
apiVersion: v1
kind: Namespace
metadata:
name: gooseek-sandbox
labels:
app.kubernetes.io/part-of: gooseek
purpose: user-sandboxes
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: opensandbox-sa
namespace: gooseek
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: opensandbox-role
namespace: gooseek-sandbox
rules:
- apiGroups: [""]
resources: ["pods", "pods/exec", "pods/log"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: opensandbox-binding
namespace: gooseek-sandbox
subjects:
- kind: ServiceAccount
name: opensandbox-sa
namespace: gooseek
roleRef:
kind: Role
name: opensandbox-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: sandbox-quota
namespace: gooseek-sandbox
spec:
hard:
requests.cpu: "8"
requests.memory: "16Gi"
limits.cpu: "16"
limits.memory: "32Gi"
pods: "50"
---
apiVersion: v1
kind: LimitRange
metadata:
name: sandbox-limits
namespace: gooseek-sandbox
spec:
limits:
- default:
cpu: "500m"
memory: "512Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
max:
cpu: "2"
memory: "2Gi"
type: Container
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: opensandbox-server
namespace: gooseek
labels:
app: opensandbox-server
app.kubernetes.io/name: opensandbox-server
app.kubernetes.io/part-of: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: opensandbox-server
template:
metadata:
labels:
app: opensandbox-server
spec:
serviceAccountName: opensandbox-sa
containers:
- name: opensandbox
image: registry.cn-hangzhou.aliyuncs.com/open_sandbox/server:v1.0.1
ports:
- containerPort: 8080
name: http
env:
- name: SANDBOX_NAMESPACE
value: "gooseek-sandbox"
- name: SANDBOX_DEFAULT_TIMEOUT
value: "30m"
- name: SANDBOX_MAX_CONCURRENT
value: "20"
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: opensandbox-server
namespace: gooseek
spec:
type: ClusterIP
selector:
app: opensandbox-server
ports:
- port: 8080
targetPort: 8080
name: http
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: sandbox-isolation
namespace: gooseek-sandbox
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
app.kubernetes.io/part-of: gooseek
egress:
- to:
- namespaceSelector:
matchLabels:
app.kubernetes.io/part-of: gooseek
- to: []
ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 80

View File

@@ -0,0 +1,78 @@
apiVersion: v1
kind: Secret
metadata:
name: registry-auth
namespace: gooseek
type: Opaque
stringData:
htpasswd: |
admin:$2y$05$A6oxuQhSjFObdjDsbjiWee.FJ62XQrc6BhLfzCMofY.9A/qQ050v6
---
apiVersion: v1
kind: ConfigMap
metadata:
name: registry-config
namespace: gooseek
data:
config.yml: |
version: 0.1
log:
level: info
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: :5000
headers:
X-Content-Type-Options: [nosniff]
auth:
htpasswd:
realm: GooSeek Registry
path: /auth/htpasswd
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: registry
template:
metadata:
labels:
app: registry
spec:
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
volumeMounts:
- name: registry-data
mountPath: /var/lib/registry
- name: registry-config
mountPath: /etc/docker/registry
- name: registry-auth
mountPath: /auth
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
volumes:
- name: registry-data
persistentVolumeClaim:
claimName: registry-pvc
- name: registry-config
configMap:
name: registry-config
- name: registry-auth
secret:
secretName: registry-auth

View File

@@ -0,0 +1,70 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: sandbox-svc
namespace: gooseek
labels:
app: sandbox-svc
app.kubernetes.io/name: sandbox-svc
app.kubernetes.io/part-of: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: sandbox-svc
template:
metadata:
labels:
app: sandbox-svc
spec:
containers:
- name: sandbox-svc
image: gooseek/backend:latest
env:
- name: SERVICE
value: "sandbox-svc"
- name: PORT
value: "3036"
- name: OPENSANDBOX_URL
value: "http://opensandbox-server:8080"
envFrom:
- configMapRef:
name: gooseek-config
- secretRef:
name: gooseek-secrets
ports:
- containerPort: 3036
name: http
livenessProbe:
httpGet:
path: /health
port: 3036
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 3036
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: sandbox-svc
namespace: gooseek
spec:
type: ClusterIP
selector:
app: sandbox-svc
ports:
- port: 3036
targetPort: 3036
name: http

View File

@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: travel-svc
namespace: gooseek
labels:
app: travel-svc
app.kubernetes.io/name: travel-svc
app.kubernetes.io/part-of: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: travel-svc
template:
metadata:
labels:
app: travel-svc
spec:
containers:
- name: travel-svc
image: gooseek/backend:latest
env:
- name: SERVICE
value: "travel-svc"
- name: PORT
value: "3035"
envFrom:
- configMapRef:
name: gooseek-config
- secretRef:
name: gooseek-secrets
ports:
- containerPort: 3035
name: http
livenessProbe:
httpGet:
path: /health
port: 3035
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 3035
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 150m
memory: 192Mi
limits:
cpu: 700m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: travel-svc
namespace: gooseek
spec:
type: ClusterIP
selector:
app: travel-svc
ports:
- port: 3035
targetPort: 3035
name: http

View File

@@ -0,0 +1,63 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: webui
namespace: gooseek
labels:
app: webui
app.kubernetes.io/name: webui
app.kubernetes.io/part-of: gooseek
spec:
replicas: 2
selector:
matchLabels:
app: webui
template:
metadata:
labels:
app: webui
spec:
containers:
- name: webui
image: gooseek/webui:latest
ports:
- containerPort: 3000
name: http
envFrom:
- configMapRef:
name: gooseek-config
- secretRef:
name: gooseek-secrets
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: webui
namespace: gooseek
spec:
type: ClusterIP
selector:
app: webui
ports:
- port: 3000
targetPort: 3000
name: http

View File

@@ -0,0 +1,49 @@
# Установка NVIDIA + CUDA на сервере (Ubuntu 24.04)
Скрипт `setup-nvidia-cuda-ubuntu24.sh` ставит драйвер NVIDIA и CUDA Toolkit для работы с нейросетями (PyTorch, TensorFlow и т.д.).
## Что уже сделано на 192.168.31.59
- **Драйвер:** nvidia-driver-570-server (Open kernel module)
- **CUDA:** 12.6 в `/usr/local/cuda-12.6`
- **Окружение:** `/etc/profile.d/cuda.sh` — подключать: `source /etc/profile.d/cuda.sh`
## Обязательно после установки
**Перезагрузка** (без неё драйвер не загрузится):
```bash
sudo reboot
```
После перезагрузки проверка:
```bash
nvidia-smi
source /etc/profile.d/cuda.sh && nvcc --version
```
## Проверка для нейросетей (PyTorch)
```bash
source /etc/profile.d/cuda.sh
pip install torch --index-url https://download.pytorch.org/whl/cu124
python3 -c "import torch; print('CUDA:', torch.cuda.is_available()); print('Device:', torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'N/A')"
```
Для CUDA 12.6 подойдёт индекс `cu124` (PyTorch совместим с 12.4+).
## Запуск скрипта вручную
Если нужно переустановить или поставить на другом сервере:
```bash
scp backend/deploy/scripts/setup-nvidia-cuda-ubuntu24.sh user@server:/tmp/
ssh user@server "echo YOUR_SUDO_PASSWORD | sudo -S bash /tmp/setup-nvidia-cuda-ubuntu24.sh"
sudo reboot
```
## Железо на текущем сервере
- **GPU:** NVIDIA GeForce RTX 4060 Ti 16GB
- **ОС:** Ubuntu 24.04.4 LTS, ядро 6.8.0-101-generic

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/gitea-runner
ExecStart=/usr/local/bin/act_runner daemon --config /opt/gitea-runner/config.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,55 @@
#!/bin/bash
set -e
echo "=== Installing Helm ==="
cd /tmp
curl -fsSL https://get.helm.sh/helm-v3.17.0-linux-amd64.tar.gz -o helm.tar.gz
tar -zxf helm.tar.gz
mv linux-amd64/helm /usr/local/bin/helm
rm -rf linux-amd64 helm.tar.gz
helm version
echo "=== Adding Helm repos ==="
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add jetstack https://charts.jetstack.io
helm repo update
echo "=== Installing Nginx Ingress Controller ==="
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.hostNetwork=true \
--set controller.kind=DaemonSet \
--set controller.service.type=ClusterIP \
--wait --timeout 300s
echo "=== Installing Cert-Manager ==="
helm upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true \
--wait --timeout 300s
echo "=== Creating Let's Encrypt ClusterIssuer ==="
cat <<'EOF' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@gooseek.ru
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
EOF
echo "=== Creating namespaces ==="
kubectl create namespace gooseek --dry-run=client -o yaml | kubectl apply -f -
kubectl create namespace gitea --dry-run=client -o yaml | kubectl apply -f -
echo "=== Done! Checking status ==="
kubectl get nodes
kubectl get pods -A

View File

@@ -0,0 +1,248 @@
#!/bin/bash
set -e
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
echo "=== Installing Gitea via manifests ==="
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-data
namespace: gitea
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: gitea-config
namespace: gitea
data:
app.ini: |
APP_NAME = GooSeek Git
RUN_MODE = prod
[server]
DOMAIN = git.gooseek.ru
ROOT_URL = https://git.gooseek.ru/
HTTP_PORT = 3000
SSH_PORT = 22
SSH_DOMAIN = git.gooseek.ru
[database]
DB_TYPE = sqlite3
PATH = /data/gitea/gitea.db
[security]
INSTALL_LOCK = true
SECRET_KEY = $(openssl rand -hex 32)
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
containers:
- name: gitea
image: gitea/gitea:1.22
ports:
- containerPort: 3000
name: http
- containerPort: 22
name: ssh
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /data/gitea/conf
env:
- name: GITEA__database__DB_TYPE
value: sqlite3
- name: GITEA__database__PATH
value: /data/gitea/gitea.db
- name: GITEA__server__DOMAIN
value: git.gooseek.ru
- name: GITEA__server__ROOT_URL
value: https://git.gooseek.ru/
- name: GITEA__security__INSTALL_LOCK
value: "false"
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: data
persistentVolumeClaim:
claimName: gitea-data
- name: config
configMap:
name: gitea-config
---
apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: gitea
spec:
selector:
app: gitea
ports:
- port: 3000
targetPort: 3000
name: http
- port: 22
targetPort: 22
name: ssh
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea-ingress
namespace: gitea
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- git.gooseek.ru
secretName: gitea-tls
rules:
- host: git.gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 3000
EOF
echo "=== Installing Docker Registry ==="
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: registry-pvc
namespace: gooseek
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: registry
template:
metadata:
labels:
app: registry
spec:
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
volumeMounts:
- name: registry-data
mountPath: /var/lib/registry
env:
- name: REGISTRY_STORAGE_DELETE_ENABLED
value: "true"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
volumes:
- name: registry-data
persistentVolumeClaim:
claimName: registry-pvc
---
apiVersion: v1
kind: Service
metadata:
name: registry
namespace: gooseek
spec:
selector:
app: registry
ports:
- port: 5000
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: registry-ingress
namespace: gooseek
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "0"
spec:
ingressClassName: nginx
tls:
- hosts:
- registry.gooseek.ru
secretName: registry-tls
rules:
- host: registry.gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: registry
port:
number: 5000
EOF
echo "=== Waiting for deployments ==="
kubectl -n gitea rollout status deployment/gitea --timeout=180s || true
kubectl -n gooseek rollout status deployment/registry --timeout=120s || true
echo "=== Status ==="
kubectl get pods -A
kubectl get ingress -A
kubectl get certificates -A
echo ""
echo "=== DONE ==="
echo "Gitea: https://git.gooseek.ru (first user to register will be admin)"
echo "Registry: https://registry.gooseek.ru"

View File

@@ -0,0 +1,130 @@
#!/bin/bash
set -e
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
echo "=== Adding Gitea Helm repo ==="
helm repo add gitea-charts https://dl.gitea.com/charts/
helm repo update
echo "=== Installing Gitea ==="
helm upgrade --install gitea gitea-charts/gitea \
--namespace gitea \
--set gitea.admin.username=admin \
--set gitea.admin.password=GooSeek2026! \
--set gitea.admin.email=admin@gooseek.ru \
--set persistence.enabled=true \
--set persistence.size=10Gi \
--set postgresql-ha.enabled=false \
--set postgresql.enabled=false \
--set redis-cluster.enabled=false \
--set redis.enabled=false \
--set gitea.config.database.DB_TYPE=sqlite3 \
--set gitea.config.server.ROOT_URL=https://git.gooseek.ru \
--set gitea.config.server.DOMAIN=git.gooseek.ru \
--set ingress.enabled=true \
--set ingress.className=nginx \
--set ingress.hosts[0].host=git.gooseek.ru \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix \
--set ingress.tls[0].secretName=gitea-tls \
--set ingress.tls[0].hosts[0]=git.gooseek.ru \
--set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod \
--wait --timeout 300s
echo "=== Installing Docker Registry ==="
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: registry-pvc
namespace: gooseek
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: gooseek
spec:
replicas: 1
selector:
matchLabels:
app: registry
template:
metadata:
labels:
app: registry
spec:
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
volumeMounts:
- name: registry-data
mountPath: /var/lib/registry
env:
- name: REGISTRY_STORAGE_DELETE_ENABLED
value: "true"
volumes:
- name: registry-data
persistentVolumeClaim:
claimName: registry-pvc
---
apiVersion: v1
kind: Service
metadata:
name: registry
namespace: gooseek
spec:
selector:
app: registry
ports:
- port: 5000
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: registry-ingress
namespace: gooseek
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "0"
spec:
ingressClassName: nginx
tls:
- hosts:
- registry.gooseek.ru
secretName: registry-tls
rules:
- host: registry.gooseek.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: registry
port:
number: 5000
EOF
echo "=== Waiting for pods ==="
kubectl -n gitea wait --for=condition=Ready pod -l app.kubernetes.io/name=gitea --timeout=300s || true
kubectl -n gooseek wait --for=condition=Ready pod -l app=registry --timeout=120s || true
echo "=== Final status ==="
kubectl get pods -A
kubectl get ingress -A
kubectl get certificates -A
echo ""
echo "=== DONE ==="
echo "Gitea: https://git.gooseek.ru (admin / GooSeek2026!)"
echo "Registry: https://registry.gooseek.ru"

View File

@@ -0,0 +1,65 @@
#!/bin/bash
set -e
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
echo "=== Enabling Gitea Actions ==="
# Update Gitea config to enable actions
kubectl -n gitea exec deploy/gitea -- sh -c '
cat >> /data/gitea/conf/app.ini << EOF
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://github.com
EOF
'
echo "=== Restarting Gitea ==="
kubectl -n gitea rollout restart deploy/gitea
kubectl -n gitea rollout status deploy/gitea --timeout=120s
echo "=== Installing Act Runner ==="
cd /tmp
curl -sL https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -o act_runner
chmod +x act_runner
mv act_runner /usr/local/bin/
echo "=== Creating runner service ==="
mkdir -p /opt/gitea-runner
cd /opt/gitea-runner
# Create config
cat > config.yaml << 'EOF'
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- ubuntu-latest:docker://node:20-bullseye
- ubuntu-22.04:docker://node:20-bullseye
cache:
enabled: true
dir: /opt/gitea-runner/cache
container:
network: host
privileged: true
options:
workdir_parent:
valid_volumes:
- /opt/gitea-runner/cache
host:
workdir_parent: /opt/gitea-runner/workspace
EOF
echo "=== Runner installed ==="
echo ""
echo "NEXT STEPS:"
echo "1. Go to https://git.gooseek.ru and register/login as admin"
echo "2. Go to Site Administration -> Actions -> Runners"
echo "3. Click 'Create new Runner' and copy the registration token"
echo "4. Run: act_runner register --config /opt/gitea-runner/config.yaml --instance https://git.gooseek.ru --token YOUR_TOKEN"
echo "5. Run: act_runner daemon --config /opt/gitea-runner/config.yaml"

View File

@@ -0,0 +1,60 @@
#!/bin/bash
set -e
echo "=== Installing K3s ==="
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik --disable servicelb --tls-san gooseek.ru --tls-san 5.187.77.89" sh -
echo "=== Waiting for K3s to be ready ==="
sleep 10
sudo k3s kubectl wait --for=condition=Ready node --all --timeout=120s
echo "=== Setting up kubectl for user ==="
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
chmod 600 ~/.kube/config
echo "=== Installing Helm ==="
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
echo "=== Installing Nginx Ingress Controller ==="
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=NodePort \
--set controller.service.nodePorts.http=80 \
--set controller.service.nodePorts.https=443 \
--set controller.hostNetwork=true \
--set controller.kind=DaemonSet
echo "=== Installing Cert-Manager ==="
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true
echo "=== Waiting for cert-manager ==="
kubectl -n cert-manager wait --for=condition=Available deployment --all --timeout=120s
echo "=== Creating Let's Encrypt ClusterIssuer ==="
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@gooseek.ru
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
EOF
echo "=== K3s + Ingress + Cert-Manager installed ==="
kubectl get nodes
kubectl get pods -A

View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Установка драйвера NVIDIA и CUDA на Ubuntu 24.04 для работы с нейросетями (RTX 40xx).
# Запуск: sudo bash setup-nvidia-cuda-ubuntu24.sh
# После выполнения нужна перезагрузка: sudo reboot
set -e
if [ "$(id -u)" -ne 0 ]; then
echo "Запустите скрипт с sudo."
exit 1
fi
echo "=== 1/6 Отключение драйвера Nouveau ==="
cat > /etc/modprobe.d/blacklist-nvidia-nouveau.conf << 'EOF'
blacklist nouveau
options nouveau modeset=0
EOF
update-initramfs -u 2>/dev/null || true
echo "=== 2/6 Обновление системы и установка зависимостей ==="
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y build-essential wget
echo "=== 3/6 Установка драйвера NVIDIA (рекомендуемый для железа) ==="
# Для RTX 4060 Ti подойдёт драйвер 550+; ubuntu-drivers выберет подходящий
apt-get install -y ubuntu-drivers-common
DRIVER=$(ubuntu-drivers list 2>/dev/null | grep -m1 "nvidia-driver-" || echo "nvidia-driver-560")
if apt-cache show "$DRIVER" &>/dev/null; then
apt-get install -y "$DRIVER"
else
apt-get install -y nvidia-driver-560 || apt-get install -y nvidia-driver-550 || ubuntu-drivers autoinstall
fi
echo "=== 4/6 Добавление репозитория CUDA и установка CUDA Toolkit 12 ==="
KEYRING_DEB="/tmp/cuda-keyring.deb"
wget -q -O "$KEYRING_DEB" "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb" || {
echo "Ошибка загрузки cuda-keyring. Проверьте сеть."
exit 1
}
dpkg -i "$KEYRING_DEB"
rm -f "$KEYRING_DEB"
apt-get update -qq
apt-get install -y cuda-toolkit-12-6 || apt-get install -y cuda-toolkit-12-5 || apt-get install -y cuda
echo "=== 5/6 Настройка окружения CUDA (PATH и библиотеки) ==="
# Ubuntu/NVIDIA repo ставит в /usr/local/cuda-12.6, симлинк /usr/local/cuda создаётся пакетом cuda
for CUDA_ROOT in /usr/local/cuda /usr/local/cuda-12.6 /usr/local/cuda-12.5; do
if [ -d "$CUDA_ROOT" ]; then
cat > /etc/profile.d/cuda.sh << EOF
# CUDA for neural networks
export PATH=$CUDA_ROOT/bin:\$PATH
export LD_LIBRARY_PATH=$CUDA_ROOT/lib64:\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}
EOF
chmod 644 /etc/profile.d/cuda.sh
echo "Файл /etc/profile.d/cuda.sh создан (CUDA_ROOT=$CUDA_ROOT)."
break
fi
done
if [ ! -f /etc/profile.d/cuda.sh ]; then
echo "Предупреждение: каталог CUDA не найден. После установки cuda-toolkit выполните: sudo bash -c 'echo \"export PATH=/usr/local/cuda/bin:\\\$PATH\" > /etc/profile.d/cuda.sh'"
fi
echo "=== 6/6 Готово ==="
echo ""
echo "Драйвер NVIDIA и CUDA Toolkit установлены."
echo "ОБЯЗАТЕЛЬНО перезагрузите сервер для загрузки драйвера:"
echo " sudo reboot"
echo ""
echo "После перезагрузки проверьте:"
echo " nvidia-smi"
echo " source /etc/profile.d/cuda.sh && nvcc --version"
echo " python3 -c 'import torch; print(torch.cuda.is_available())' # если ставите PyTorch"

View File

@@ -0,0 +1,836 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/session"
"github.com/gooseek/backend/internal/types"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
)
type LearningIntent string
const (
IntentTaskGenerate LearningIntent = "task_generate"
IntentVerify LearningIntent = "verify"
IntentPlan LearningIntent = "plan"
IntentQuiz LearningIntent = "quiz"
IntentExplain LearningIntent = "explain"
IntentQuestion LearningIntent = "question"
IntentOnboarding LearningIntent = "onboarding"
IntentProgress LearningIntent = "progress"
)
type LearningBrief struct {
Intent LearningIntent `json:"intent"`
Topic string `json:"topic"`
Difficulty string `json:"difficulty"`
Language string `json:"language"`
TaskType string `json:"task_type"`
SpecificRequest string `json:"specific_request"`
CodeSubmitted string `json:"code_submitted"`
NeedsContext bool `json:"needs_context"`
}
type LearningDraft struct {
Brief *LearningBrief
ProfileContext string
CourseContext string
PlanContext string
TaskContext string
GeneratedTask *GeneratedTask
GeneratedQuiz *GeneratedQuiz
GeneratedPlan *GeneratedPlan
Evaluation *TaskEvaluation
Explanation string
Phase string
}
type GeneratedTask struct {
Title string `json:"title"`
Difficulty string `json:"difficulty"`
EstimatedMin int `json:"estimated_minutes"`
Description string `json:"description"`
Requirements []string `json:"requirements"`
Acceptance []string `json:"acceptance_criteria"`
Hints []string `json:"hints"`
SandboxSetup string `json:"sandbox_setup"`
VerifyCmd string `json:"verify_command"`
StarterCode string `json:"starter_code"`
TestCode string `json:"test_code"`
SkillsTrained []string `json:"skills_trained"`
}
type GeneratedQuiz struct {
Title string `json:"title"`
Questions []QuizQuestion `json:"questions"`
}
type QuizQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
Correct int `json:"correct_index"`
Explain string `json:"explanation"`
}
type GeneratedPlan struct {
Modules []PlanModule `json:"modules"`
TotalHours int `json:"total_hours"`
DifficultyAdjusted string `json:"difficulty_adjusted"`
PersonalizationNote string `json:"personalization_notes"`
}
type PlanModule struct {
Index int `json:"index"`
Title string `json:"title"`
Description string `json:"description"`
Skills []string `json:"skills"`
EstimatedHrs int `json:"estimated_hours"`
PracticeFocus string `json:"practice_focus"`
TaskCount int `json:"task_count"`
}
type TaskEvaluation struct {
Score int `json:"score"`
MaxScore int `json:"max_score"`
Passed bool `json:"passed"`
Strengths []string `json:"strengths"`
Issues []string `json:"issues"`
Suggestions []string `json:"suggestions"`
CodeQuality string `json:"code_quality"`
}
// RunLearningOrchestrator is the multi-agent pipeline for learning chat.
// Flow: Intent Classifier → Context Collector → Specialized Agent → Widget Emission.
func RunLearningOrchestrator(ctx context.Context, sess *session.Session, input OrchestratorInput) error {
researchBlockID := uuid.New().String()
sess.EmitBlock(types.NewResearchBlock(researchBlockID))
emitPhase(sess, researchBlockID, "reasoning", "Анализирую запрос...")
// --- Phase 1: Intent Classification via LLM ---
brief, err := runLearningPlanner(ctx, input)
if err != nil {
log.Printf("[learning] planner error: %v, falling back to keyword", err)
brief = fallbackClassify(input.FollowUp)
}
log.Printf("[learning] intent=%s topic=%q difficulty=%s", brief.Intent, brief.Topic, brief.Difficulty)
draft := &LearningDraft{
Brief: brief,
Phase: "collecting",
}
// --- Phase 2: Parallel Context Collection ---
emitPhase(sess, researchBlockID, "searching", phaseSearchLabel(brief.Intent))
var mu sync.Mutex
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
profile := extractProfileContext(input)
mu.Lock()
draft.ProfileContext = profile
mu.Unlock()
return nil
})
g.Go(func() error {
course := extractCourseContext(input)
mu.Lock()
draft.CourseContext = course
mu.Unlock()
return nil
})
g.Go(func() error {
plan := extractPlanContext(input)
mu.Lock()
draft.PlanContext = plan
mu.Unlock()
return nil
})
_ = g.Wait()
// --- Phase 3: Specialized Agent based on intent ---
emitPhase(sess, researchBlockID, "reasoning", phaseAgentLabel(brief.Intent))
switch brief.Intent {
case IntentTaskGenerate:
task, err := runTaskGeneratorAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] task generator error: %v", err)
} else {
draft.GeneratedTask = task
}
case IntentVerify:
eval, err := runCodeReviewAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] code review error: %v", err)
} else {
draft.Evaluation = eval
}
case IntentQuiz:
quiz, err := runQuizGeneratorAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] quiz generator error: %v", err)
} else {
draft.GeneratedQuiz = quiz
}
case IntentPlan:
plan, err := runPlanBuilderAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] plan builder error: %v", err)
} else {
draft.GeneratedPlan = plan
}
}
sess.EmitResearchComplete()
// --- Phase 4: Generate response text + emit widgets ---
emitLearningResponse(ctx, sess, input, draft)
sess.EmitEnd()
return nil
}
// --- Phase 1: LLM-based Intent Classification ---
func runLearningPlanner(ctx context.Context, input OrchestratorInput) (*LearningBrief, error) {
plannerCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Ты — классификатор запросов в образовательной платформе. Определи намерение ученика.
Сообщение ученика: "%s"
Контекст (если есть): %s
Определи intent и параметры. Ответь строго JSON:
{
"intent": "task_generate|verify|plan|quiz|explain|question|onboarding|progress",
"topic": "тема если определена",
"difficulty": "beginner|intermediate|advanced|expert",
"language": "язык программирования если есть",
"task_type": "code|test|review|deploy|debug|refactor|design",
"specific_request": "что конкретно просит",
"code_submitted": "код если ученик прислал код для проверки",
"needs_context": true
}
Правила:
- "задание/задачу/практику/упражнение" → task_generate
- "проверь/оцени/ревью/посмотри код" → verify
- "план/программу/roadmap/что учить" → plan
- "тест/квиз/экзамен" → quiz
- "объясни/расскажи/как работает" → explain
- "прогресс/результаты/статистика" → progress
- Если код в сообщении и просьба проверить → verify + code_submitted
- По умолчанию → question`, input.FollowUp, truncate(input.Config.SystemInstructions, 500))
result, err := input.Config.LLM.GenerateText(plannerCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var brief LearningBrief
if err := json.Unmarshal([]byte(jsonStr), &brief); err != nil {
return nil, fmt.Errorf("parse brief: %w", err)
}
if brief.Intent == "" {
brief.Intent = IntentQuestion
}
return &brief, nil
}
func fallbackClassify(query string) *LearningBrief {
q := strings.ToLower(query)
brief := &LearningBrief{Intent: IntentQuestion, NeedsContext: true}
switch {
case containsAny(q, "задание", "задачу", "практик", "упражнение", "тренир", "дай задач"):
brief.Intent = IntentTaskGenerate
case containsAny(q, "провер", "оцени", "ревью", "посмотри код", "правильно ли", "code review"):
brief.Intent = IntentVerify
case containsAny(q, "план", "програм", "roadmap", "чему учить", "маршрут обучения"):
brief.Intent = IntentPlan
case containsAny(q, "тест", "экзамен", "квиз", "проверочн"):
brief.Intent = IntentQuiz
case containsAny(q, "объясни", "расскажи", "как работает", "что такое", "зачем нужн"):
brief.Intent = IntentExplain
case containsAny(q, "прогресс", "результат", "статистик", "сколько сделал"):
brief.Intent = IntentProgress
}
return brief
}
// --- Phase 2: Context Extraction ---
func extractProfileContext(input OrchestratorInput) string {
if input.Config.UserMemory != "" {
return input.Config.UserMemory
}
return ""
}
func extractCourseContext(input OrchestratorInput) string {
si := input.Config.SystemInstructions
if idx := strings.Index(si, "Текущий курс:"); idx >= 0 {
end := strings.Index(si[idx:], "\n")
if end > 0 {
return si[idx : idx+end]
}
return si[idx:]
}
return ""
}
func extractPlanContext(input OrchestratorInput) string {
si := input.Config.SystemInstructions
if idx := strings.Index(si, "План обучения:"); idx >= 0 {
return si[idx:]
}
return ""
}
// --- Phase 3: Specialized Agents ---
func runTaskGeneratorAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*GeneratedTask, error) {
agentCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
contextBlock := buildTaskGenContext(draft)
prompt := fmt.Sprintf(`Ты — ведущий разработчик в крупной IT-компании в РФ. Генерируй боевое практическое задание.
%s
Тема: %s
Сложность: %s
Язык: %s
Тип: %s
ТРЕБОВАНИЯ К ЗАДАНИЮ:
1. Задание должно быть РЕАЛЬНЫМ — как задача из production проекта в российской IT-компании
2. Чёткая постановка: что сделать, какие входные данные, что на выходе
3. Обязательно: тесты, обработка ошибок, edge cases
4. Код-стайл: линтеры, форматирование, документация
5. Если backend: REST API, middleware, валидация, логирование
6. Если frontend: компоненты, стейт-менеджмент, адаптивность
7. Starter code должен компилироваться/запускаться
8. Verify command должен реально проверять решение
Ответь строго JSON:
{
"title": "Название задания",
"difficulty": "beginner|intermediate|advanced",
"estimated_minutes": 30,
"description": "Подробное описание задачи в markdown",
"requirements": ["Требование 1", "Требование 2"],
"acceptance_criteria": ["Критерий приёмки 1", "Критерий приёмки 2"],
"hints": ["Подсказка 1 (скрытая)"],
"sandbox_setup": "команды для подготовки окружения (apt install, npm init, etc.)",
"verify_command": "команда для проверки решения (go test ./... или npm test)",
"starter_code": "начальный код проекта",
"test_code": "код тестов для автопроверки",
"skills_trained": ["навык1", "навык2"]
}`, contextBlock, orDefault(draft.Brief.Topic, "по текущему курсу"),
orDefault(draft.Brief.Difficulty, "intermediate"),
orDefault(draft.Brief.Language, "go"),
orDefault(draft.Brief.TaskType, "code"))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var task GeneratedTask
if err := json.Unmarshal([]byte(jsonStr), &task); err != nil {
return nil, fmt.Errorf("parse task: %w", err)
}
return &task, nil
}
func runCodeReviewAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*TaskEvaluation, error) {
agentCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
codeToReview := draft.Brief.CodeSubmitted
if codeToReview == "" {
codeToReview = input.Config.FileContext
}
if codeToReview == "" {
for _, m := range input.ChatHistory {
if m.Role == "user" && (strings.Contains(m.Content, "```") || strings.Contains(m.Content, "func ") || strings.Contains(m.Content, "function ")) {
codeToReview = m.Content
}
}
}
if codeToReview == "" {
return &TaskEvaluation{
Score: 0,
MaxScore: 100,
Issues: []string{"Код для проверки не найден. Пришлите код в сообщении или загрузите файл."},
}, nil
}
prompt := fmt.Sprintf(`Ты — senior code reviewer в крупной IT-компании. Проведи строгое ревью кода.
Код для проверки:
%s
Контекст задания: %s
Оцени по критериям:
1. Корректность (работает ли код правильно)
2. Код-стайл (форматирование, именование, идиоматичность)
3. Обработка ошибок (edge cases, panic recovery, валидация)
4. Тесты (есть ли, покрытие, качество)
5. Безопасность (SQL injection, XSS, утечки данных)
6. Производительность (O-нотация, утечки памяти, N+1)
Ответь строго JSON:
{
"score": 75,
"max_score": 100,
"passed": true,
"strengths": ["Что хорошо"],
"issues": ["Что исправить"],
"suggestions": ["Рекомендации по улучшению"],
"code_quality": "good|acceptable|needs_work|poor"
}`, truncate(codeToReview, 6000), truncate(draft.TaskContext, 1000))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var eval TaskEvaluation
if err := json.Unmarshal([]byte(jsonStr), &eval); err != nil {
return nil, fmt.Errorf("parse eval: %w", err)
}
if eval.MaxScore == 0 {
eval.MaxScore = 100
}
return &eval, nil
}
func runQuizGeneratorAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*GeneratedQuiz, error) {
agentCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Ты — методолог обучения. Создай тест для проверки знаний.
Тема: %s
Сложность: %s
Контекст курса: %s
Создай 5 вопросов. Вопросы должны проверять ПОНИМАНИЕ, а не зубрёжку.
Включи: практические сценарии, код-сниппеты, архитектурные решения.
Ответь строго JSON:
{
"title": "Название теста",
"questions": [
{
"question": "Текст вопроса (может содержать код в markdown)",
"options": ["Вариант A", "Вариант B", "Вариант C", "Вариант D"],
"correct_index": 0,
"explanation": "Почему этот ответ правильный"
}
]
}`, orDefault(draft.Brief.Topic, "текущий модуль"),
orDefault(draft.Brief.Difficulty, "intermediate"),
truncate(draft.CourseContext, 1000))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var quiz GeneratedQuiz
if err := json.Unmarshal([]byte(jsonStr), &quiz); err != nil {
return nil, fmt.Errorf("parse quiz: %w", err)
}
return &quiz, nil
}
func runPlanBuilderAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*GeneratedPlan, error) {
agentCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT. Построй персональный план обучения.
Профиль ученика: %s
Текущий курс: %s
Текущий прогресс: %s
Требования:
1. Минимум теории, максимум боевой практики
2. Каждый модуль = практическое задание из реального проекта
3. Прогрессия сложности от текущего уровня ученика
4. Учитывай уже пройденные темы
5. Задания как в российских IT-компаниях
Ответь строго JSON:
{
"modules": [
{
"index": 0,
"title": "Название модуля",
"description": "Что изучаем и делаем",
"skills": ["навык1"],
"estimated_hours": 4,
"practice_focus": "Конкретная практическая задача",
"task_count": 3
}
],
"total_hours": 40,
"difficulty_adjusted": "intermediate",
"personalization_notes": "Как план адаптирован под ученика"
}`, truncate(draft.ProfileContext, 1500),
truncate(draft.CourseContext, 1000),
truncate(draft.PlanContext, 1000))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var plan GeneratedPlan
if err := json.Unmarshal([]byte(jsonStr), &plan); err != nil {
return nil, fmt.Errorf("parse plan: %w", err)
}
return &plan, nil
}
// --- Phase 4: Response Generation + Widget Emission ---
func emitLearningResponse(ctx context.Context, sess *session.Session, input OrchestratorInput, draft *LearningDraft) {
// Emit structured widgets first
emitLearningWidgets(sess, draft)
// Then stream the conversational response
textBlockID := uuid.New().String()
sess.EmitBlock(types.NewTextBlock(textBlockID, ""))
systemPrompt := buildLearningResponsePrompt(input, draft)
messages := make([]llm.Message, 0, len(input.ChatHistory)+3)
messages = append(messages, llm.Message{Role: "system", Content: systemPrompt})
for _, m := range input.ChatHistory {
messages = append(messages, m)
}
messages = append(messages, llm.Message{Role: "user", Content: input.FollowUp})
streamCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
ch, err := input.Config.LLM.StreamText(streamCtx, llm.StreamRequest{Messages: messages})
if err != nil {
sess.UpdateBlock(textBlockID, []session.Patch{
{Op: "replace", Path: "/data", Value: fmt.Sprintf("Ошибка: %v", err)},
})
return
}
var fullContent strings.Builder
for chunk := range ch {
if chunk.ContentChunk == "" {
continue
}
fullContent.WriteString(chunk.ContentChunk)
sess.UpdateBlock(textBlockID, []session.Patch{
{Op: "replace", Path: "/data", Value: fullContent.String()},
})
}
}
func emitLearningWidgets(sess *session.Session, draft *LearningDraft) {
if draft.GeneratedTask != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_task", map[string]interface{}{
"status": "ready",
"title": draft.GeneratedTask.Title,
"difficulty": draft.GeneratedTask.Difficulty,
"estimated": draft.GeneratedTask.EstimatedMin,
"requirements": draft.GeneratedTask.Requirements,
"acceptance": draft.GeneratedTask.Acceptance,
"hints": draft.GeneratedTask.Hints,
"verify_cmd": draft.GeneratedTask.VerifyCmd,
"starter_code": draft.GeneratedTask.StarterCode,
"test_code": draft.GeneratedTask.TestCode,
"skills": draft.GeneratedTask.SkillsTrained,
}))
}
if draft.Evaluation != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_evaluation", map[string]interface{}{
"score": draft.Evaluation.Score,
"max_score": draft.Evaluation.MaxScore,
"passed": draft.Evaluation.Passed,
"strengths": draft.Evaluation.Strengths,
"issues": draft.Evaluation.Issues,
"suggestions": draft.Evaluation.Suggestions,
"quality": draft.Evaluation.CodeQuality,
}))
}
if draft.GeneratedQuiz != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_quiz", map[string]interface{}{
"title": draft.GeneratedQuiz.Title,
"questions": draft.GeneratedQuiz.Questions,
"count": len(draft.GeneratedQuiz.Questions),
}))
}
if draft.GeneratedPlan != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_plan", map[string]interface{}{
"modules": draft.GeneratedPlan.Modules,
"total_hours": draft.GeneratedPlan.TotalHours,
"difficulty": draft.GeneratedPlan.DifficultyAdjusted,
"notes": draft.GeneratedPlan.PersonalizationNote,
}))
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_progress", map[string]interface{}{
"phase": "idle",
"intent": string(draft.Brief.Intent),
"timestamp": time.Now().Format(time.RFC3339),
}))
}
func buildLearningResponsePrompt(input OrchestratorInput, draft *LearningDraft) string {
var sb strings.Builder
sb.WriteString(`Ты — AI-наставник на платформе GooSeek Education. Ведёшь обучение через чат.
СТИЛЬ:
- Минимум теории, максимум практики. Объясняй кратко, по делу.
- Задания «боевые» — как на реальных проектах в российских IT-компаниях.
- Конструктивная обратная связь: что хорошо + что улучшить.
- Адаптируй сложность под ученика.
- Русский язык. Markdown для форматирования.
- Будь строгим но справедливым ментором, не «добрым учителем».
`)
switch draft.Brief.Intent {
case IntentTaskGenerate:
if draft.GeneratedTask != nil {
taskJSON, _ := json.Marshal(draft.GeneratedTask)
sb.WriteString("СГЕНЕРИРОВАННОЕ ЗАДАНИЕ (уже отправлено как виджет, не дублируй полностью):\n")
sb.WriteString(string(taskJSON))
sb.WriteString("\n\nПредставь задание ученику: кратко опиши суть, мотивируй, дай контекст зачем это нужно в реальной работе. НЕ копируй JSON — виджет уже показан.\n\n")
} else {
sb.WriteString("Задание не удалось сгенерировать. Предложи ученику уточнить тему или сложность.\n\n")
}
case IntentVerify:
if draft.Evaluation != nil {
evalJSON, _ := json.Marshal(draft.Evaluation)
sb.WriteString("РЕЗУЛЬТАТ РЕВЬЮ (уже отправлен как виджет):\n")
sb.WriteString(string(evalJSON))
sb.WriteString("\n\nДай развёрнутую обратную связь: разбери каждый issue, объясни почему это проблема, покажи как исправить с примерами кода. Похвали за strengths.\n\n")
}
case IntentQuiz:
if draft.GeneratedQuiz != nil {
sb.WriteString("Тест сгенерирован и показан как виджет. Кратко представь тест, объясни что проверяется.\n\n")
}
case IntentPlan:
if draft.GeneratedPlan != nil {
planJSON, _ := json.Marshal(draft.GeneratedPlan)
sb.WriteString("ПЛАН ОБУЧЕНИЯ (уже отправлен как виджет):\n")
sb.WriteString(string(planJSON))
sb.WriteString("\n\nПредставь план: объясни логику прогрессии, почему именно такие модули, что ученик получит в итоге.\n\n")
}
case IntentProgress:
sb.WriteString("Покажи прогресс ученика на основе контекста. Если данных нет — предложи начать с задания или теста.\n\n")
}
if draft.ProfileContext != "" {
sb.WriteString("ПРОФИЛЬ УЧЕНИКА:\n")
sb.WriteString(truncate(draft.ProfileContext, 1500))
sb.WriteString("\n\n")
}
if draft.CourseContext != "" {
sb.WriteString("ТЕКУЩИЙ КУРС:\n")
sb.WriteString(draft.CourseContext)
sb.WriteString("\n\n")
}
if draft.PlanContext != "" {
sb.WriteString("ПЛАН ОБУЧЕНИЯ:\n")
sb.WriteString(truncate(draft.PlanContext, 2000))
sb.WriteString("\n\n")
}
if input.Config.FileContext != "" {
sb.WriteString("КОД/ФАЙЛЫ УЧЕНИКА:\n")
sb.WriteString(truncate(input.Config.FileContext, 4000))
sb.WriteString("\n\n")
}
return sb.String()
}
// --- Helpers ---
func emitPhase(sess *session.Session, blockID, stepType, text string) {
step := types.ResearchSubStep{ID: uuid.New().String(), Type: stepType}
switch stepType {
case "reasoning":
step.Reasoning = text
case "searching":
step.Searching = []string{text}
}
sess.UpdateBlock(blockID, []session.Patch{
{Op: "replace", Path: "/data/subSteps", Value: []types.ResearchSubStep{step}},
})
}
func phaseSearchLabel(intent LearningIntent) string {
switch intent {
case IntentTaskGenerate:
return "Проектирую практическое задание..."
case IntentVerify:
return "Анализирую код..."
case IntentPlan:
return "Строю план обучения..."
case IntentQuiz:
return "Создаю тест..."
case IntentExplain:
return "Готовлю объяснение..."
case IntentProgress:
return "Собираю статистику..."
default:
return "Готовлю ответ..."
}
}
func phaseAgentLabel(intent LearningIntent) string {
switch intent {
case IntentTaskGenerate:
return "Генерирую боевое задание..."
case IntentVerify:
return "Провожу code review..."
case IntentPlan:
return "Адаптирую план под профиль..."
case IntentQuiz:
return "Составляю вопросы..."
default:
return "Формирую ответ..."
}
}
func buildTaskGenContext(draft *LearningDraft) string {
var parts []string
if draft.ProfileContext != "" {
parts = append(parts, "Профиль ученика: "+truncate(draft.ProfileContext, 800))
}
if draft.CourseContext != "" {
parts = append(parts, "Курс: "+draft.CourseContext)
}
if draft.PlanContext != "" {
parts = append(parts, "План: "+truncate(draft.PlanContext, 800))
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n\n")
}
func extractJSONFromLLM(response string) string {
if strings.Contains(response, "```json") {
start := strings.Index(response, "```json") + 7
end := strings.Index(response[start:], "```")
if end > 0 {
return strings.TrimSpace(response[start : start+end])
}
}
if strings.Contains(response, "```") {
start := strings.Index(response, "```") + 3
if nl := strings.Index(response[start:], "\n"); nl >= 0 {
start += nl + 1
}
end := strings.Index(response[start:], "```")
if end > 0 {
candidate := strings.TrimSpace(response[start : start+end])
if len(candidate) > 2 && candidate[0] == '{' {
return candidate
}
}
}
depth := 0
startIdx := -1
for i, ch := range response {
if ch == '{' {
if depth == 0 {
startIdx = i
}
depth++
} else if ch == '}' {
depth--
if depth == 0 && startIdx >= 0 {
candidate := response[startIdx : i+1]
if len(candidate) > 10 {
return candidate
}
}
}
}
return "{}"
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func orDefault(val, def string) string {
if val == "" {
return def
}
return val
}

View File

@@ -54,6 +54,7 @@ type OrchestratorConfig struct {
TravelSvcURL string
TravelPayoutsToken string
TravelPayoutsMarker string
PhotoCache *PhotoCacheService
}
type DigestResponse struct {
@@ -94,6 +95,10 @@ func RunOrchestrator(ctx context.Context, sess *session.Session, input Orchestra
return RunTravelOrchestrator(ctx, sess, input)
}
if input.Config.AnswerMode == "learning" || input.Config.LearningMode {
return RunLearningOrchestrator(ctx, sess, input)
}
detectedLang := detectLanguage(input.FollowUp)
isArticleSummary := strings.HasPrefix(strings.TrimSpace(input.FollowUp), "Summary: ")

View File

@@ -22,13 +22,25 @@ type TravelContext struct {
BestTimeInfo string `json:"bestTimeInfo,omitempty"`
}
type DailyForecast struct {
Date string `json:"date"`
TempMin float64 `json:"tempMin"`
TempMax float64 `json:"tempMax"`
Conditions string `json:"conditions"`
Icon string `json:"icon"`
RainChance string `json:"rainChance"`
Wind string `json:"wind,omitempty"`
Tip string `json:"tip,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"`
Summary string `json:"summary"`
TempMin float64 `json:"tempMin"`
TempMax float64 `json:"tempMax"`
Conditions string `json:"conditions"`
Clothing string `json:"clothing"`
RainChance string `json:"rainChance"`
DailyForecast []DailyForecast `json:"dailyForecast,omitempty"`
}
type SafetyAssessment struct {
@@ -87,7 +99,6 @@ func searchForContext(ctx context.Context, client *search.SearXNGClient, brief *
dest := strings.Join(brief.Destinations, ", ")
currentYear := time.Now().Format("2006")
currentMonth := time.Now().Format("01")
monthNames := map[string]string{
"01": "январь", "02": "февраль", "03": "март",
@@ -95,10 +106,25 @@ func searchForContext(ctx context.Context, client *search.SearXNGClient, brief *
"07": "июль", "08": "август", "09": "сентябрь",
"10": "октябрь", "11": "ноябрь", "12": "декабрь",
}
month := monthNames[currentMonth]
tripMonth := time.Now().Format("01")
if brief.StartDate != "" {
if t, err := time.Parse("2006-01-02", brief.StartDate); err == nil {
tripMonth = t.Format("01")
}
}
month := monthNames[tripMonth]
dateRange := ""
if brief.StartDate != "" && brief.EndDate != "" {
dateRange = fmt.Sprintf("%s — %s", brief.StartDate, brief.EndDate)
} else if brief.StartDate != "" {
dateRange = brief.StartDate
}
queries := []string{
fmt.Sprintf("погода %s %s %s прогноз", dest, month, currentYear),
fmt.Sprintf("погода %s %s %s прогноз по дням", dest, month, currentYear),
fmt.Sprintf("прогноз погоды %s %s на 14 дней", dest, dateRange),
fmt.Sprintf("безопасность туристов %s %s", dest, currentYear),
fmt.Sprintf("ограничения %s туризм %s", dest, currentYear),
fmt.Sprintf("что нужно знать туристу %s %s", dest, currentYear),
@@ -154,20 +180,40 @@ func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *Tri
dest := strings.Join(brief.Destinations, ", ")
currentDate := time.Now().Format("2006-01-02")
prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени текущую обстановку в %s для поездки %s — %s.
Сегодня: %s.
tripDays := computeTripDays(brief.StartDate, brief.EndDate)
dailyForecastNote := ""
if tripDays > 0 {
dailyForecastNote = fmt.Sprintf(`
ВАЖНО: Поездка длится %d дней (%s — %s). Составь прогноз погоды НА КАЖДЫЙ ДЕНЬ поездки.
В "dailyForecast" должно быть ровно %d элементов — по одному на каждый день.`, tripDays, brief.StartDate, brief.EndDate, tripDays)
}
prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени обстановку в %s для поездки %s — %s.
Сегодня: %s.
%s
%s
Верни ТОЛЬКО JSON (без текста):
{
"weather": {
"summary": "Краткое описание погоды на период поездки",
"tempMin": число_градусов_минимум,
"tempMax": число_градусов_максимум,
"conditions": "солнечно/облачно/дождливо/снежно",
"clothing": "Что надеть: конкретные рекомендации",
"rainChance": "низкая/средняя/высокая"
"summary": "Общее описание погоды на весь период поездки",
"tempMin": число_минимумаесь_период,
"tempMax": числоаксимумаесь_период,
"conditions": "преобладающие условия: солнечно/облачно/переменная облачность/дождливо/снежно",
"clothing": "Что надеть: конкретные рекомендации по одежде",
"rainChance": "низкая/средняя/высокая",
"dailyForecast": [
{
"date": "YYYY-MM-DD",
"tempMin": число,
"tempMax": число,
"conditions": "солнечно/облачно/дождь/гроза/снег/туман/переменная облачность",
"icon": "sun/cloud/cloud-sun/rain/storm/snow/fog/wind",
"rainChance": "низкая/средняя/высокая",
"wind": "слабый/умеренный/сильный",
"tip": "Краткий совет на этот день (необязательно, только если есть что сказать)"
}
]
},
"safety": {
"level": "safe/caution/warning/danger",
@@ -190,27 +236,35 @@ func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *Tri
}
Правила:
- Используй ТОЛЬКО актуальные данные %s года
- weather: реальный прогноз на период поездки, не среднегодовые значения
- safety: объективная оценка, не преувеличивай опасности
- Используй актуальные данные %s года и данные из поиска
- dailyForecast: прогноз НА КАЖДЫЙ ДЕНЬ поездки с конкретными температурами и условиями
- Если точный прогноз недоступен — используй климатические данные для этого периода, но старайся варьировать по дням реалистично
- icon: одно из значений sun/cloud/cloud-sun/rain/storm/snow/fog/wind
- weather.summary: общее описание, упомяни если ожидаются дождливые дни
- safety: объективная оценка, не преувеличивай
- restrictions: визовые требования, медицинские ограничения, локальные правила
- tips: 3-5 практичных советов для туриста
- Если данных нет — используй свои знания о регионе, но отмечай это
- tips: 3-5 практичных советов
- Температуры в градусах Цельсия`,
dest,
brief.StartDate,
brief.EndDate,
currentDate,
dailyForecastNote,
contextBuilder.String(),
time.Now().Format("2006"),
)
llmCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
llmCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
defer cancel()
maxTokens := 3000
if tripDays > 5 {
maxTokens = 4000
}
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
Options: llm.StreamOptions{MaxTokens: 2000, Temperature: 0.2},
Options: llm.StreamOptions{MaxTokens: maxTokens, Temperature: 0.3},
})
if err != nil {
log.Printf("[travel-context] LLM extraction failed: %v", err)
@@ -233,9 +287,31 @@ func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *Tri
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))
log.Printf("[travel-context] extracted context: weather=%s (%d daily), safety=%s, restrictions=%d, tips=%d",
travelCtx.Weather.Conditions, len(travelCtx.Weather.DailyForecast),
travelCtx.Safety.Level, len(travelCtx.Restrictions), len(travelCtx.Tips))
return &travelCtx
}
func computeTripDays(startDate, endDate string) int {
if startDate == "" || endDate == "" {
return 0
}
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
return 0
}
end, err := time.Parse("2006-01-02", endDate)
if err != nil {
return 0
}
days := int(end.Sub(start).Hours()/24) + 1
if days < 1 {
return 1
}
if days > 30 {
return 30
}
return days
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -40,6 +41,24 @@ func NewTravelDataClient(baseURL string) *TravelDataClient {
}
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
// http.Request.Clone does NOT recreate the Body. If we retry a request with a Body,
// we must be able to recreate it; otherwise retries will send an empty body and may
// fail with ContentLength/body length mismatch.
var bodyCopy []byte
if req.Body != nil && req.GetBody == nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read request body for retry: %w", err)
}
_ = req.Body.Close()
bodyCopy = b
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyCopy)), nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.ContentLength = int64(len(bodyCopy))
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
@@ -51,7 +70,18 @@ func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (
}
}
resp, err := c.httpClient.Do(req.Clone(ctx))
reqAttempt := req.Clone(ctx)
if req.GetBody != nil {
rc, err := req.GetBody()
if err != nil {
lastErr = err
continue
}
reqAttempt.Body = rc
reqAttempt.ContentLength = req.ContentLength
}
resp, err := c.httpClient.Do(reqAttempt)
if err != nil {
lastErr = err
continue
@@ -306,13 +336,16 @@ func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, c
// 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"`
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 *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {

View File

@@ -39,12 +39,16 @@ func CollectEventsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, br
events := extractEventsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
events = geocodeEvents(ctx, cfg, events)
events = geocodeEvents(ctx, cfg, brief, events)
events = deduplicateEvents(events)
events = filterFreshEvents(events, brief.StartDate)
// Hard filter: drop events that ended up in another city/country due to ambiguous geocoding.
destGeo := geocodeDestinations(ctx, cfg, brief)
events = filterEventsNearDestinations(events, destGeo, 250)
if len(events) > 15 {
events = events[:15]
}
@@ -425,28 +429,74 @@ func tryPartialEventParse(jsonStr string) []EventCard {
return events
}
func geocodeEvents(ctx context.Context, cfg TravelOrchestratorConfig, events []EventCard) []EventCard {
func geocodeEvents(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, events []EventCard) []EventCard {
destSuffix := strings.Join(brief.Destinations, ", ")
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()
queries := []string{events[i].Address}
if destSuffix != "" && !strings.Contains(strings.ToLower(events[i].Address), strings.ToLower(destSuffix)) {
queries = append(queries, fmt.Sprintf("%s, %s", events[i].Address, destSuffix))
}
queries = append(queries, fmt.Sprintf("%s, %s", events[i].Title, destSuffix))
if err != nil {
log.Printf("[travel-events] geocode failed for '%s': %v", events[i].Address, err)
continue
var lastErr error
for _, q := range queries {
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
geo, err := cfg.TravelData.Geocode(geoCtx, q)
cancel()
if err != nil {
lastErr = err
continue
}
events[i].Lat = geo.Lat
events[i].Lng = geo.Lng
break
}
events[i].Lat = geo.Lat
events[i].Lng = geo.Lng
if events[i].Lat == 0 && events[i].Lng == 0 {
if lastErr != nil {
log.Printf("[travel-events] geocode failed for '%s': %v", events[i].Address, lastErr)
} else {
log.Printf("[travel-events] geocode failed for '%s'", events[i].Address)
}
continue
}
}
return events
}
func filterEventsNearDestinations(events []EventCard, destinations []destGeoEntry, maxKm float64) []EventCard {
if len(destinations) == 0 {
return events
}
filtered := make([]EventCard, 0, len(events))
for _, e := range events {
if e.Lat == 0 && e.Lng == 0 {
continue
}
minD := 1e18
for _, d := range destinations {
if d.Lat == 0 && d.Lng == 0 {
continue
}
dd := distanceKm(e.Lat, e.Lng, d.Lat, d.Lng)
if dd < minD {
minD = dd
}
}
if minD <= maxKm {
filtered = append(filtered, e)
} else {
log.Printf("[travel-events] dropped far event '%s' (%.0fkm from destinations)", e.Title, minD)
}
}
return filtered
}
func deduplicateEvents(events []EventCard) []EventCard {
seen := make(map[string]bool)
var unique []EventCard

View File

@@ -36,6 +36,8 @@ func CollectHotelsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, br
hotels = deduplicateHotels(hotels)
hotels = filterHotelsNearDestinations(hotels, destinations, 250)
if len(hotels) > 10 {
hotels = hotels[:10]
}
@@ -400,6 +402,34 @@ func geocodeHotels(ctx context.Context, cfg TravelOrchestratorConfig, hotels []H
return hotels
}
func filterHotelsNearDestinations(hotels []HotelCard, destinations []destGeoEntry, maxKm float64) []HotelCard {
if len(destinations) == 0 {
return hotels
}
filtered := make([]HotelCard, 0, len(hotels))
for _, h := range hotels {
if h.Lat == 0 && h.Lng == 0 {
continue
}
minD := 1e18
for _, d := range destinations {
if d.Lat == 0 && d.Lng == 0 {
continue
}
dd := distanceKm(h.Lat, h.Lng, d.Lat, d.Lng)
if dd < minD {
minD = dd
}
}
if minD <= maxKm {
filtered = append(filtered, h)
} else {
log.Printf("[travel-hotels] dropped far hotel '%s' (%.0fkm from destinations)", h.Name, minD)
}
}
return filtered
}
func deduplicateHotels(hotels []HotelCard) []HotelCard {
seen := make(map[string]bool)
var unique []HotelCard

View File

@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"regexp"
"strings"
"sync"
"time"
"github.com/gooseek/backend/internal/llm"
@@ -22,6 +24,7 @@ type TravelOrchestratorConfig struct {
LLM llm.Client
SearchClient *search.SearXNGClient
TravelData *TravelDataClient
PhotoCache *PhotoCacheService
Crawl4AIURL string
Locale string
TravelPayoutsToken string
@@ -35,6 +38,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
LLM: input.Config.LLM,
SearchClient: input.Config.SearchClient,
TravelData: NewTravelDataClient(input.Config.TravelSvcURL),
PhotoCache: input.Config.PhotoCache,
Crawl4AIURL: input.Config.Crawl4AIURL,
Locale: input.Config.Locale,
TravelPayoutsToken: input.Config.TravelPayoutsToken,
@@ -66,6 +70,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
}
brief.ApplyDefaults()
enforceDefaultSingleDay(brief, input.FollowUp)
// Geocode origin if we have a name but no coordinates
if brief.Origin != "" && brief.OriginLat == 0 && brief.OriginLng == 0 {
@@ -79,6 +84,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
// --- Phase 2: Geocode destinations ---
destGeo := geocodeDestinations(ctx, travelCfg, brief)
destGeo = enforceOneDayFeasibility(ctx, &travelCfg, brief, destGeo)
sess.UpdateBlock(researchBlockID, []session.Patch{{
Op: "replace",
@@ -103,6 +109,62 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
var draftMu sync.Mutex
var emitMu sync.Mutex
emitCandidatesWidget := func(kind string) {
emitMu.Lock()
defer emitMu.Unlock()
draftMu.Lock()
defer draftMu.Unlock()
switch kind {
case "context":
if draft.Context == nil {
return
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelContext), map[string]interface{}{
"weather": draft.Context.Weather,
"safety": draft.Context.Safety,
"restrictions": draft.Context.Restrictions,
"tips": draft.Context.Tips,
"bestTimeInfo": draft.Context.BestTimeInfo,
}))
case "events":
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{
"events": draft.Candidates.Events,
"count": len(draft.Candidates.Events),
}))
case "pois":
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
"pois": draft.Candidates.POIs,
"count": len(draft.Candidates.POIs),
}))
case "hotels":
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
"hotels": draft.Candidates.Hotels,
"count": len(draft.Candidates.Hotels),
}))
case "transport":
flights := make([]TransportOption, 0)
ground := make([]TransportOption, 0)
for _, t := range draft.Candidates.Transport {
if t.Mode == "flight" {
flights = append(flights, t)
} else {
ground = append(ground, t)
}
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{
"flights": flights,
"ground": ground,
"passengers": draft.Brief.Travelers,
}))
default:
return
}
}
collectCtx, collectCancel := context.WithTimeout(ctx, 90*time.Second)
defer collectCancel()
@@ -116,7 +178,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] events collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.Events = events
draftMu.Unlock()
emitCandidatesWidget("events")
return nil
})
@@ -127,7 +192,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] POI collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.POIs = pois
draftMu.Unlock()
emitCandidatesWidget("pois")
return nil
})
@@ -138,7 +206,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] hotels collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.Hotels = hotels
draftMu.Unlock()
emitCandidatesWidget("hotels")
return nil
})
@@ -149,7 +220,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] transport collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.Transport = transport
draftMu.Unlock()
emitCandidatesWidget("transport")
return nil
})
@@ -160,7 +234,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] context collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Context = travelCtx
draftMu.Unlock()
emitCandidatesWidget("context")
return nil
})
@@ -197,6 +274,55 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
return nil
}
func userExplicitlyProvidedDateRange(text string) bool {
t := strings.ToLower(text)
isoDate := regexp.MustCompile(`\b20\d{2}-\d{2}-\d{2}\b`)
if len(isoDate.FindAllString(t, -1)) >= 2 {
return true
}
loose := regexp.MustCompile(`\b\d{1,2}[./-]\d{1,2}([./-]\d{2,4})?\b`)
if strings.Contains(t, "с ") && strings.Contains(t, " по ") && len(loose.FindAllString(t, -1)) >= 2 {
return true
}
return false
}
func enforceDefaultSingleDay(brief *TripBrief, userText string) {
// Product rule: default to ONE day unless user explicitly provided start+end dates.
if !userExplicitlyProvidedDateRange(userText) {
brief.EndDate = brief.StartDate
}
}
func enforceOneDayFeasibility(ctx context.Context, cfg *TravelOrchestratorConfig, brief *TripBrief, destGeo []destGeoEntry) []destGeoEntry {
// If it's a one-day request and origin+destination are far apart,
// plan locally around origin (user is already there).
if brief.StartDate == "" || brief.EndDate == "" || brief.StartDate != brief.EndDate {
return destGeo
}
if brief.Origin == "" {
return destGeo
}
if brief.OriginLat == 0 && brief.OriginLng == 0 {
return destGeo
}
if len(destGeo) == 0 || (destGeo[0].Lat == 0 && destGeo[0].Lng == 0) {
return destGeo
}
d := distanceKm(brief.OriginLat, brief.OriginLng, destGeo[0].Lat, destGeo[0].Lng)
if d <= 250 {
return destGeo
}
log.Printf("[travel] one-day request but destination is far (%.0fkm) — switching destination to origin %q", d, brief.Origin)
brief.Destinations = []string{brief.Origin}
return geocodeDestinations(ctx, *cfg, brief)
}
// --- Phase 1: Planner Agent ---
func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input OrchestratorInput) (*TripBrief, error) {
@@ -219,9 +345,13 @@ func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input Or
}
Правила:
- Если пользователь говорит "сегодня" — startDate = текущая дата (` + time.Now().Format("2006-01-02") + `)
- Для однодневных поездок endDate = startDate
- Если дата не указана, оставь пустую строку ""
- Сегодняшняя дата: ` + time.Now().Format("2006-01-02") + `
- Если пользователь говорит "сегодня" — startDate = сегодняшняя дата
- Если пользователь говорит "завтра" — startDate = завтрашняя дата (` + time.Now().AddDate(0, 0, 1).Format("2006-01-02") + `)
- Если пользователь говорит "послезавтра" — startDate = послезавтрашняя дата (` + time.Now().AddDate(0, 0, 2).Format("2006-01-02") + `)
- ВАЖНО: По умолчанию планируем ОДИН день. Если пользователь не указал конечную дату явно — endDate оставь пустой строкой ""
- endDate заполняй ТОЛЬКО если пользователь явно указал диапазон дат (дата начала И дата конца)
- Если дата не указана вообще, оставь пустую строку ""
- Если бюджет не указан, поставь 0
- Если количество путешественников не указано, поставь 0
- ВАЖНО: Если в сообщении есть координаты "Моё текущее местоположение: lat, lng", используй их:
@@ -257,9 +387,18 @@ func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input Or
var brief TripBrief
if err := json.Unmarshal([]byte(jsonMatch), &brief); err != nil {
return &TripBrief{
Destinations: extractDestinationsFromText(input.FollowUp),
}, nil
repaired := repairJSON(jsonMatch)
if repaired != "" {
if err2 := json.Unmarshal([]byte(repaired), &brief); err2 != nil {
return &TripBrief{
Destinations: extractDestinationsFromText(input.FollowUp),
}, nil
}
} else {
return &TripBrief{
Destinations: extractDestinationsFromText(input.FollowUp),
}, nil
}
}
if len(brief.Destinations) == 0 {
@@ -362,19 +501,21 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
}
type poiCompact struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Duration int `json:"duration"`
Price float64 `json:"price"`
Address string `json:"address"`
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Duration int `json:"duration"`
Price float64 `json:"price"`
Address string `json:"address"`
Schedule map[string]string `json:"schedule,omitempty"`
}
type eventCompact struct {
ID string `json:"id"`
Title string `json:"title"`
DateStart string `json:"dateStart"`
DateEnd string `json:"dateEnd,omitempty"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Price float64 `json:"price"`
@@ -398,6 +539,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
ID: p.ID, Name: p.Name, Category: p.Category,
Lat: p.Lat, Lng: p.Lng, Duration: dur,
Price: p.Price, Address: p.Address,
Schedule: p.Schedule,
})
}
@@ -405,6 +547,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
for _, e := range draft.Candidates.Events {
compactEvents = append(compactEvents, eventCompact{
ID: e.ID, Title: e.Title, DateStart: e.DateStart,
DateEnd: e.DateEnd,
Lat: e.Lat, Lng: e.Lng, Price: e.Price, Address: e.Address,
})
}
@@ -428,10 +571,26 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
"hotels": compactHotels,
}
if draft.Context != nil {
weatherCtx := map[string]interface{}{
"summary": draft.Context.Weather.Summary,
"tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax),
"conditions": draft.Context.Weather.Conditions,
}
if len(draft.Context.Weather.DailyForecast) > 0 {
dailyWeather := make([]map[string]interface{}, 0, len(draft.Context.Weather.DailyForecast))
for _, d := range draft.Context.Weather.DailyForecast {
dailyWeather = append(dailyWeather, map[string]interface{}{
"date": d.Date,
"tempMin": d.TempMin,
"tempMax": d.TempMax,
"conditions": d.Conditions,
"rainChance": d.RainChance,
})
}
weatherCtx["dailyForecast"] = dailyWeather
}
candidateData["context"] = map[string]interface{}{
"weather": draft.Context.Weather.Summary,
"tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax),
"conditions": draft.Context.Weather.Conditions,
"weather": weatherCtx,
"safetyLevel": draft.Context.Safety.Level,
"restrictions": draft.Context.Restrictions,
}
@@ -442,6 +601,8 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
Данные (с координатами для расчёта расстояний): %s
ВАЖНО: Если startDate == endDate — это ОДНОДНЕВНЫЙ план. Верни РОВНО 1 день в массиве "days" и поставь date=startDate.
КРИТИЧЕСКИЕ ПРАВИЛА РАСЧЁТА ВРЕМЕНИ:
1. Используй координаты (lat, lng) для оценки расстояний между точками.
2. Средняя скорость передвижения по городу: 15-20 км/ч (пробки, пешком, общественный транспорт).
@@ -453,6 +614,12 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
8. Максимум 4-5 основных активностей в день (не считая еду и переезды).
9. День начинается в 09:00, заканчивается в 21:00. С детьми — до 19:00.
ПРАВИЛА ПОГОДЫ (если есть dailyForecast в context):
1. В дождливые дни (conditions: "дождь"/"гроза") — ставь крытые активности: музеи, торговые центры, рестораны, театры.
2. В солнечные дни — парки, смотровые площадки, прогулки, набережные.
3. В холодные дни (tempMax < 5°C) — больше крытых мест, меньше прогулок.
4. Если есть tip для дня — учитывай его при планировании.
ПРАВИЛА ЦЕН:
1. cost — цена НА ОДНОГО человека за эту активность.
2. Для бесплатных мест (парки, площади, улицы) — cost = 0.
@@ -488,6 +655,8 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
`+"```"+`
Дополнительные правила:
- Для refType="poi"|"event"|"hotel" ЗАПРЕЩЕНО выдумывать места. Используй ТОЛЬКО объекты из данных и ставь их "refId" из списка.
- Если подходящего POI/события/отеля в данных нет — используй refType="custom" (или "food" для еды) и ставь lat/lng = 0.
- Между точками ОБЯЗАТЕЛЬНО вставляй элемент "transfer" с refType="transfer" если расстояние > 1 км
- В note для transfer указывай расстояние и примерное время
- Начинай день с отеля/завтрака
@@ -506,14 +675,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
summaryText := extractTextBeforeJSON(response)
jsonMatch := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```").FindStringSubmatch(response)
var jsonStr string
if len(jsonMatch) > 1 {
jsonStr = strings.TrimSpace(jsonMatch[1])
} else {
jsonStr = regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response)
}
jsonStr := extractJSONFromResponse(response)
if jsonStr == "" {
return nil, summaryText, nil
}
@@ -522,15 +684,119 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
Days []ItineraryDay `json:"days"`
}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
log.Printf("[travel] itinerary JSON parse error: %v", err)
return nil, summaryText, nil
repaired := repairJSON(jsonStr)
if repaired != "" {
if err2 := json.Unmarshal([]byte(repaired), &result); err2 != nil {
log.Printf("[travel] itinerary JSON parse error (after repair): %v", err2)
return nil, summaryText, nil
}
} else {
log.Printf("[travel] itinerary JSON parse error: %v", err)
return nil, summaryText, nil
}
}
result.Days = validateItineraryTimes(result.Days)
result.Days = postValidateItinerary(result.Days, draft)
if draft.Brief != nil && draft.Brief.StartDate != "" && draft.Brief.EndDate == draft.Brief.StartDate && len(result.Days) > 1 {
// Defensive clamp: for one-day plans keep only the first day.
result.Days = result.Days[:1]
result.Days[0].Date = draft.Brief.StartDate
}
return result.Days, summaryText, nil
}
func postValidateItinerary(days []ItineraryDay, draft *TripDraft) []ItineraryDay {
poiByID := make(map[string]*POICard)
for i := range draft.Candidates.POIs {
poiByID[draft.Candidates.POIs[i].ID] = &draft.Candidates.POIs[i]
}
eventByID := make(map[string]*EventCard)
for i := range draft.Candidates.Events {
eventByID[draft.Candidates.Events[i].ID] = &draft.Candidates.Events[i]
}
hotelByID := make(map[string]*HotelCard)
for i := range draft.Candidates.Hotels {
hotelByID[draft.Candidates.Hotels[i].ID] = &draft.Candidates.Hotels[i]
}
// Build a centroid of "known-good" coordinates to detect out-of-area hallucinations.
var sumLat, sumLng float64
var cnt float64
addPoint := func(lat, lng float64) {
if lat == 0 && lng == 0 {
return
}
sumLat += lat
sumLng += lng
cnt++
}
for _, p := range draft.Candidates.POIs {
addPoint(p.Lat, p.Lng)
}
for _, e := range draft.Candidates.Events {
addPoint(e.Lat, e.Lng)
}
for _, h := range draft.Candidates.Hotels {
addPoint(h.Lat, h.Lng)
}
centLat, centLng := 0.0, 0.0
if cnt > 0 {
centLat = sumLat / cnt
centLng = sumLng / cnt
}
for d := range days {
for i := range days[d].Items {
item := &days[d].Items[i]
// If refId exists, always trust coordinates from candidates (even if LLM provided something else).
if item.RefID != "" {
if poi, ok := poiByID[item.RefID]; ok {
item.Lat, item.Lng = poi.Lat, poi.Lng
} else if ev, ok := eventByID[item.RefID]; ok {
item.Lat, item.Lng = ev.Lat, ev.Lng
} else if h, ok := hotelByID[item.RefID]; ok {
item.Lat, item.Lng = h.Lat, h.Lng
} else if item.RefType == "poi" || item.RefType == "event" || item.RefType == "hotel" {
// Unknown refId for these types → convert to custom to avoid cross-city junk.
item.RefType = "custom"
item.RefID = ""
item.Lat = 0
item.Lng = 0
if item.Note == "" {
item.Note = "Уточнить место: не найдено среди вариантов для города"
}
}
}
// Clamp out-of-area coordinates (e.g., another country) if we have a centroid.
if centLat != 0 || centLng != 0 {
if item.Lat != 0 || item.Lng != 0 {
if distanceKm(item.Lat, item.Lng, centLat, centLng) > 250 {
item.Lat = 0
item.Lng = 0
if item.RefType == "poi" || item.RefType == "event" || item.RefType == "hotel" {
item.RefType = "custom"
item.RefID = ""
}
if item.Note == "" {
item.Note = "Уточнить место: координаты вне города/маршрута"
}
}
}
}
if item.Currency == "" {
item.Currency = draft.Brief.Currency
}
}
}
return days
}
func validateItineraryTimes(days []ItineraryDay) []ItineraryDay {
for d := range days {
items := days[d].Items
@@ -577,6 +843,58 @@ func formatMinutesTime(minutes int) string {
return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)
}
func extractJSONFromResponse(response string) string {
codeBlockRe := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
if m := codeBlockRe.FindStringSubmatch(response); len(m) > 1 {
return strings.TrimSpace(m[1])
}
if idx := strings.Index(response, `"days"`); idx >= 0 {
braceStart := strings.LastIndex(response[:idx], "{")
if braceStart >= 0 {
depth := 0
for i := braceStart; i < len(response); i++ {
switch response[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return response[braceStart : i+1]
}
}
}
}
}
return regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response)
}
func repairJSON(s string) string {
s = strings.TrimSpace(s)
s = regexp.MustCompile(`,\s*}`).ReplaceAllString(s, "}")
s = regexp.MustCompile(`,\s*]`).ReplaceAllString(s, "]")
openBraces := strings.Count(s, "{") - strings.Count(s, "}")
for openBraces > 0 {
s += "}"
openBraces--
}
openBrackets := strings.Count(s, "[") - strings.Count(s, "]")
for openBrackets > 0 {
s += "]"
openBrackets--
}
var test json.RawMessage
if json.Unmarshal([]byte(s), &test) == nil {
return s
}
return ""
}
func extractTextBeforeJSON(response string) string {
idx := strings.Index(response, "```")
if idx > 0 {
@@ -674,44 +992,39 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelMap), widgetParams))
}
// Events widget
if len(draft.Candidates.Events) > 0 {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{
"events": draft.Candidates.Events,
}))
}
// Events widget (always emit — UI shows empty state)
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{
"events": draft.Candidates.Events,
"count": len(draft.Candidates.Events),
}))
// POI widget
if len(draft.Candidates.POIs) > 0 {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
"pois": draft.Candidates.POIs,
}))
}
// POI widget (always emit)
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
"pois": draft.Candidates.POIs,
"count": len(draft.Candidates.POIs),
}))
// Hotels widget
if len(draft.Candidates.Hotels) > 0 {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
"hotels": draft.Candidates.Hotels,
}))
}
// Hotels widget (always emit)
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
"hotels": draft.Candidates.Hotels,
"count": len(draft.Candidates.Hotels),
}))
// Transport widget
if len(draft.Candidates.Transport) > 0 {
flights := make([]TransportOption, 0)
ground := make([]TransportOption, 0)
for _, t := range draft.Candidates.Transport {
if t.Mode == "flight" {
flights = append(flights, t)
} else {
ground = append(ground, t)
}
// Transport widget (always emit)
flights := make([]TransportOption, 0)
ground := make([]TransportOption, 0)
for _, t := range draft.Candidates.Transport {
if t.Mode == "flight" {
flights = append(flights, t)
} else {
ground = append(ground, t)
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{
"flights": flights,
"ground": ground,
"passengers": draft.Brief.Travelers,
}))
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{
"flights": flights,
"ground": ground,
"passengers": draft.Brief.Travelers,
}))
// Itinerary widget
if len(draft.Selected.Itinerary) > 0 {
@@ -723,6 +1036,9 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
if len(segments) > 0 {
itineraryParams["segments"] = segments
}
if draft.Context != nil && len(draft.Context.Weather.DailyForecast) > 0 {
itineraryParams["dailyForecast"] = draft.Context.Weather.DailyForecast
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelItinerary), itineraryParams))
}
@@ -735,15 +1051,6 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
"perPerson": budget.PerPerson,
}))
}
// Actions widget
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelActions), map[string]interface{}{
"actions": []map[string]interface{}{
{"id": "save_trip", "label": "Сохранить поездку", "kind": "save", "payload": map[string]interface{}{}},
{"id": "modify_route", "label": "Изменить маршрут", "kind": "modify", "payload": map[string]interface{}{}},
{"id": "add_more", "label": "Найти ещё варианты", "kind": "search", "payload": map[string]interface{}{}},
},
}))
}
func buildMapPoints(draft *TripDraft, destGeo []destGeoEntry) []MapPoint {
@@ -1020,90 +1327,135 @@ func buildRoadRoute(ctx context.Context, cfg *TravelOrchestratorConfig, points [
return nil, nil
}
routeCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
routeCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
log.Printf("[travel] building road route segment-by-segment for %d points", len(points))
segments := buildSegmentCosts(points)
// 2GIS supports up to 10 waypoints per request; batch accordingly
const maxWaypoints = 10
log.Printf("[travel] building batched multi-point route for %d points (batch size %d)", len(points), maxWaypoints)
var allCoords [][2]float64
var allSteps []RouteStepResult
var totalDistance, totalDuration float64
segments := make([]routeSegmentWithCosts, 0, len(points)-1)
batchOK := true
for i := 0; i < len(points)-1; i++ {
if i > 0 {
for batchStart := 0; batchStart < len(points)-1; batchStart += maxWaypoints - 1 {
batchEnd := batchStart + maxWaypoints
if batchEnd > len(points) {
batchEnd = len(points)
}
batch := points[batchStart:batchEnd]
if len(batch) < 2 {
break
}
if batchStart > 0 {
select {
case <-routeCtx.Done():
batchOK = false
case <-time.After(1500 * time.Millisecond):
}
if !batchOK {
break
case <-time.After(300 * time.Millisecond):
}
}
pair := []MapPoint{points[i], points[i+1]}
var segDir *RouteDirectionResult
var batchRoute *RouteDirectionResult
var err error
for attempt := 0; attempt < 3; attempt++ {
segDir, err = cfg.TravelData.GetRoute(routeCtx, pair, "driving")
if err == nil || !strings.Contains(err.Error(), "429") {
batchRoute, err = cfg.TravelData.GetRoute(routeCtx, batch, "driving")
if err == nil {
break
}
log.Printf("[travel] segment %d->%d rate limited, retry %d", i, i+1, attempt+1)
if !strings.Contains(err.Error(), "429") {
break
}
log.Printf("[travel] batch %d-%d rate limited, retry %d", batchStart, batchEnd-1, attempt+1)
select {
case <-routeCtx.Done():
batchOK = false
case <-time.After(time.Duration(2+attempt*2) * time.Second):
}
if !batchOK {
break
case <-time.After(time.Duration(1+attempt) * time.Second):
}
}
var distanceM, durationS float64
if err != nil {
log.Printf("[travel] segment %d->%d routing failed: %v", i, i+1, err)
} else if segDir != nil {
distanceM = segDir.Distance
durationS = segDir.Duration
totalDistance += distanceM
totalDuration += durationS
if len(segDir.Geometry.Coordinates) > 0 {
if len(allCoords) > 0 && len(segDir.Geometry.Coordinates) > 0 {
allCoords = append(allCoords, segDir.Geometry.Coordinates[1:]...)
} else {
allCoords = append(allCoords, segDir.Geometry.Coordinates...)
}
}
allSteps = append(allSteps, segDir.Steps...)
log.Printf("[travel] batch %d-%d routing failed: %v", batchStart, batchEnd-1, err)
batchOK = false
break
}
if batchRoute == nil || len(batchRoute.Geometry.Coordinates) < 2 {
log.Printf("[travel] batch %d-%d returned empty geometry", batchStart, batchEnd-1)
batchOK = false
break
}
totalDistance += batchRoute.Distance
totalDuration += batchRoute.Duration
if len(allCoords) > 0 {
allCoords = append(allCoords, batchRoute.Geometry.Coordinates[1:]...)
} else {
allCoords = append(allCoords, batchRoute.Geometry.Coordinates...)
}
allSteps = append(allSteps, batchRoute.Steps...)
log.Printf("[travel] batch %d-%d OK: +%.0fm, +%d coords", batchStart, batchEnd-1, batchRoute.Distance, len(batchRoute.Geometry.Coordinates))
}
if batchOK && len(allCoords) > 1 {
fullRoute := &RouteDirectionResult{
Geometry: RouteGeometryResult{
Coordinates: allCoords,
Type: "LineString",
},
Distance: totalDistance,
Duration: totalDuration,
Steps: allSteps,
}
log.Printf("[travel] road route OK: distance=%.0fm, coords=%d, segments=%d", totalDistance, len(allCoords), len(segments))
return fullRoute, segments
}
log.Printf("[travel] batched routing failed, no road coordinates collected")
return nil, segments
}
func buildSegmentCosts(points []MapPoint) []routeSegmentWithCosts {
segments := make([]routeSegmentWithCosts, 0, len(points)-1)
for i := 0; i < len(points)-1; i++ {
distKm := haversineDistance(points[i].Lat, points[i].Lng, points[i+1].Lat, points[i+1].Lng)
distM := distKm * 1000
durationS := distKm / 40.0 * 3600 // ~40 km/h average
seg := routeSegmentWithCosts{
From: points[i].Label,
To: points[i+1].Label,
Distance: distanceM,
Distance: distM,
Duration: durationS,
}
if distanceM > 0 {
seg.TransportOptions = calculateTransportCosts(distanceM, durationS)
if distM > 0 {
seg.TransportOptions = calculateTransportCosts(distM, durationS)
}
segments = append(segments, seg)
}
return segments
}
if len(allCoords) == 0 {
log.Printf("[travel] no road coordinates collected")
return nil, segments
}
func haversineDistance(lat1, lng1, lat2, lng2 float64) float64 {
const R = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLng := (lng2 - lng1) * math.Pi / 180
lat1Rad := lat1 * math.Pi / 180
lat2Rad := lat2 * math.Pi / 180
fullRoute := &RouteDirectionResult{
Geometry: RouteGeometryResult{
Coordinates: allCoords,
Type: "LineString",
},
Distance: totalDistance,
Duration: totalDuration,
Steps: allSteps,
}
log.Printf("[travel] road route OK: distance=%.0fm, coords=%d, segments=%d", totalDistance, len(allCoords), len(segments))
return fullRoute, segments
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*
math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Asin(math.Sqrt(a))
return R * c
}
func calculateTransportCosts(distanceMeters float64, durationSeconds float64) []transportCostOption {

View File

@@ -0,0 +1,194 @@
package agent
import (
"context"
"crypto/sha256"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gooseek/backend/pkg/storage"
)
const (
photoCachePrefix = "poi-photos"
maxPhotoSize = 5 * 1024 * 1024 // 5MB
photoDownloadTimeout = 8 * time.Second
)
type PhotoCacheService struct {
storage *storage.MinioStorage
client *http.Client
mu sync.RWMutex
memCache map[string]string // sourceURL -> publicURL (in-memory for current session)
}
func NewPhotoCacheService(s *storage.MinioStorage) *PhotoCacheService {
return &PhotoCacheService{
storage: s,
client: &http.Client{
Timeout: photoDownloadTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
},
memCache: make(map[string]string, 128),
}
}
func (pc *PhotoCacheService) CachePhoto(ctx context.Context, citySlug, sourceURL string) (string, error) {
pc.mu.RLock()
if cached, ok := pc.memCache[sourceURL]; ok {
pc.mu.RUnlock()
return cached, nil
}
pc.mu.RUnlock()
key := pc.buildKey(citySlug, sourceURL)
exists, err := pc.storage.ObjectExists(ctx, key)
if err == nil && exists {
publicURL := pc.storage.GetPublicURL(key)
if publicURL != "" {
pc.mu.Lock()
pc.memCache[sourceURL] = publicURL
pc.mu.Unlock()
return publicURL, nil
}
}
body, contentType, err := pc.downloadImage(ctx, sourceURL)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer body.Close()
limitedReader := io.LimitReader(body, maxPhotoSize)
result, err := pc.storage.UploadWithKey(ctx, key, limitedReader, -1, contentType)
if err != nil {
return "", fmt.Errorf("upload to minio failed: %w", err)
}
publicURL := pc.storage.GetPublicURL(result.Key)
if publicURL == "" {
return "", fmt.Errorf("no public URL configured for storage")
}
pc.mu.Lock()
pc.memCache[sourceURL] = publicURL
pc.mu.Unlock()
return publicURL, nil
}
func (pc *PhotoCacheService) CachePhotoBatch(ctx context.Context, citySlug string, sourceURLs []string) []string {
results := make([]string, len(sourceURLs))
var wg sync.WaitGroup
for i, url := range sourceURLs {
wg.Add(1)
go func(idx int, srcURL string) {
defer wg.Done()
cacheCtx, cancel := context.WithTimeout(ctx, photoDownloadTimeout+2*time.Second)
defer cancel()
cached, err := pc.CachePhoto(cacheCtx, citySlug, srcURL)
if err != nil {
log.Printf("[photo-cache] failed to cache %s: %v", truncateURL(srcURL), err)
results[idx] = srcURL
return
}
results[idx] = cached
}(i, url)
}
wg.Wait()
return results
}
func (pc *PhotoCacheService) buildKey(citySlug, sourceURL string) string {
hash := sha256.Sum256([]byte(sourceURL))
hashStr := fmt.Sprintf("%x", hash[:12])
ext := ".jpg"
lower := strings.ToLower(sourceURL)
switch {
case strings.Contains(lower, ".png"):
ext = ".png"
case strings.Contains(lower, ".webp"):
ext = ".webp"
case strings.Contains(lower, ".gif"):
ext = ".gif"
}
slug := sanitizeSlug(citySlug)
return fmt.Sprintf("%s/%s/%s%s", photoCachePrefix, slug, hashStr, ext)
}
func (pc *PhotoCacheService) downloadImage(ctx context.Context, url string) (io.ReadCloser, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; GooSeek/1.0)")
req.Header.Set("Accept", "image/*")
resp, err := pc.client.Do(req)
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/jpeg"
}
if !strings.HasPrefix(contentType, "image/") {
resp.Body.Close()
return nil, "", fmt.Errorf("not an image: %s", contentType)
}
return resp.Body, contentType, nil
}
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
if (r >= 0x0400 && r <= 0x04FF) || r == '_' {
return r
}
if r == ' ' {
return '-'
}
return -1
}, s)
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
return strings.Trim(s, "-")
}
func truncateURL(u string) string {
if len(u) > 80 {
return u[:80] + "..."
}
return u
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"regexp"
"strings"
"sync"
@@ -103,7 +104,11 @@ func CollectPOIsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brie
}
// Phase 4: Fallback geocoding for POIs without coordinates
allPOIs = geocodePOIs(ctx, cfg, allPOIs)
allPOIs = geocodePOIs(ctx, cfg, brief, allPOIs)
// Hard filter: drop POIs that are far away from any destination center.
// This prevents ambiguous geocoding from pulling in other cities/countries.
allPOIs = filterPOIsNearDestinations(allPOIs, destinations, 250)
allPOIs = deduplicatePOIs(allPOIs)
@@ -453,6 +458,14 @@ func enrichPOIPhotos(ctx context.Context, cfg TravelOrchestratorConfig, brief *T
}
if len(photos) > 0 {
if cfg.PhotoCache != nil {
citySlug := dest
if citySlug == "" {
citySlug = "unknown"
}
photos = cfg.PhotoCache.CachePhotoBatch(ctx, citySlug, photos)
}
mu.Lock()
pois[idx].Photos = photos
mu.Unlock()
@@ -463,12 +476,18 @@ func enrichPOIPhotos(ctx context.Context, cfg TravelOrchestratorConfig, brief *T
wg.Wait()
photosFound := 0
cachedCount := 0
for _, p := range pois {
if len(p.Photos) > 0 {
photosFound++
for _, ph := range p.Photos {
if strings.Contains(ph, "storage.gooseek") || strings.Contains(ph, "minio") {
cachedCount++
}
}
}
}
log.Printf("[travel-poi] enriched %d/%d POIs with photos", photosFound, len(pois))
log.Printf("[travel-poi] enriched %d/%d POIs with photos (%d cached in MinIO)", photosFound, len(pois), cachedCount)
return pois
}
@@ -636,19 +655,27 @@ func extractPOIsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBr
return pois
}
func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, pois []POICard) []POICard {
func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, pois []POICard) []POICard {
destSuffix := strings.Join(brief.Destinations, ", ")
for i := range pois {
if pois[i].Lat != 0 && pois[i].Lng != 0 {
continue
}
// Try geocoding by address first, then by name + city
// Try geocoding by address first, then by name+destination.
queries := []string{}
if pois[i].Address != "" {
queries = append(queries, pois[i].Address)
if destSuffix != "" && !strings.Contains(strings.ToLower(pois[i].Address), strings.ToLower(destSuffix)) {
queries = append(queries, fmt.Sprintf("%s, %s", pois[i].Address, destSuffix))
}
}
if pois[i].Name != "" {
queries = append(queries, pois[i].Name)
if destSuffix != "" {
queries = append(queries, fmt.Sprintf("%s, %s", pois[i].Name, destSuffix))
} else {
queries = append(queries, pois[i].Name)
}
}
for _, query := range queries {
@@ -674,6 +701,46 @@ func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, pois []POICa
return pois
}
func distanceKm(lat1, lng1, lat2, lng2 float64) float64 {
const earthRadiusKm = 6371.0
toRad := func(d float64) float64 { return d * math.Pi / 180 }
lat1r := toRad(lat1)
lat2r := toRad(lat2)
dLat := toRad(lat2 - lat1)
dLng := toRad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1r)*math.Cos(lat2r)*math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusKm * c
}
func filterPOIsNearDestinations(pois []POICard, destinations []destGeoEntry, maxKm float64) []POICard {
if len(destinations) == 0 {
return pois
}
filtered := make([]POICard, 0, len(pois))
for _, p := range pois {
if p.Lat == 0 && p.Lng == 0 {
continue
}
minD := math.MaxFloat64
for _, d := range destinations {
if d.Lat == 0 && d.Lng == 0 {
continue
}
dd := distanceKm(p.Lat, p.Lng, d.Lat, d.Lng)
if dd < minD {
minD = dd
}
}
if minD <= maxKm {
filtered = append(filtered, p)
} else {
log.Printf("[travel-poi] dropped far POI '%s' (%.0fkm from destinations)", p.Name, minD)
}
}
return filtered
}
func deduplicatePOIs(pois []POICard) []POICard {
seen := make(map[string]bool)
var unique []POICard

View File

@@ -30,12 +30,7 @@ func (b *TripBrief) ApplyDefaults() {
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
}
b.EndDate = b.StartDate
}
if b.Travelers == 0 {
b.Travelers = 2

View File

@@ -0,0 +1,642 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
type LearningRepository struct {
db *PostgresDB
}
func NewLearningRepository(db *PostgresDB) *LearningRepository {
return &LearningRepository{db: db}
}
func (r *LearningRepository) RunMigrations(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS learning_user_profiles (
user_id UUID PRIMARY KEY,
display_name VARCHAR(255),
profile JSONB NOT NULL DEFAULT '{}',
resume_file_id UUID,
resume_extracted_text TEXT,
onboarding_completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS learning_courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(255) NOT NULL UNIQUE,
title VARCHAR(500) NOT NULL,
short_description TEXT,
category VARCHAR(100) NOT NULL DEFAULT 'general',
tags TEXT[] DEFAULT '{}',
difficulty VARCHAR(50) NOT NULL DEFAULT 'beginner',
duration_hours INT DEFAULT 0,
base_outline JSONB NOT NULL DEFAULT '{}',
landing JSONB NOT NULL DEFAULT '{}',
cover_image TEXT,
fingerprint VARCHAR(128) UNIQUE,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
enrolled_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_learning_courses_status ON learning_courses(status)`,
`CREATE INDEX IF NOT EXISTS idx_learning_courses_category ON learning_courses(category)`,
`CREATE TABLE IF NOT EXISTS learning_enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
course_id UUID NOT NULL REFERENCES learning_courses(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'active',
plan JSONB NOT NULL DEFAULT '{}',
progress JSONB NOT NULL DEFAULT '{"completed_modules": [], "current_module": 0, "score": 0}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, course_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_learning_enrollments_user ON learning_enrollments(user_id)`,
`CREATE TABLE IF NOT EXISTS learning_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
enrollment_id UUID NOT NULL REFERENCES learning_enrollments(id) ON DELETE CASCADE,
module_index INT NOT NULL DEFAULT 0,
title VARCHAR(500) NOT NULL,
task_type VARCHAR(50) NOT NULL DEFAULT 'code',
instructions_md TEXT NOT NULL,
rubric JSONB NOT NULL DEFAULT '{}',
sandbox_template JSONB NOT NULL DEFAULT '{}',
verification_cmd TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_learning_tasks_enrollment ON learning_tasks(enrollment_id)`,
`CREATE TABLE IF NOT EXISTS learning_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES learning_tasks(id) ON DELETE CASCADE,
sandbox_session_id UUID,
result JSONB NOT NULL DEFAULT '{}',
score INT DEFAULT 0,
max_score INT DEFAULT 100,
feedback_md TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_learning_submissions_task ON learning_submissions(task_id)`,
`CREATE TABLE IF NOT EXISTS learning_trend_candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic VARCHAR(500) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
signals JSONB NOT NULL DEFAULT '{}',
score FLOAT DEFAULT 0,
fingerprint VARCHAR(128) UNIQUE,
fail_count INT NOT NULL DEFAULT 0,
last_error TEXT,
last_failed_at TIMESTAMPTZ,
picked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_learning_trends_score ON learning_trend_candidates(score DESC)`,
`CREATE INDEX IF NOT EXISTS idx_learning_trends_fail ON learning_trend_candidates(fail_count, last_failed_at)`,
// Backward-compatible schema upgrades (older DBs)
`ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS fail_count INT NOT NULL DEFAULT 0`,
`ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS last_error TEXT`,
`ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS last_failed_at TIMESTAMPTZ`,
`CREATE TABLE IF NOT EXISTS sandbox_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
task_id UUID REFERENCES learning_tasks(id) ON DELETE SET NULL,
opensandbox_id VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'creating',
last_active_at TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_sandbox_sessions_user ON sandbox_sessions(user_id)`,
`CREATE TABLE IF NOT EXISTS sandbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sandbox_sessions(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_sandbox_events_session ON sandbox_events(session_id)`,
}
for _, m := range migrations {
if _, err := r.db.db.ExecContext(ctx, m); err != nil {
return fmt.Errorf("learning migration failed: %w", err)
}
}
return nil
}
// --- Course types ---
type LearningCourse struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ShortDescription string `json:"shortDescription"`
Category string `json:"category"`
Tags []string `json:"tags"`
Difficulty string `json:"difficulty"`
DurationHours int `json:"durationHours"`
BaseOutline json.RawMessage `json:"baseOutline"`
Landing json.RawMessage `json:"landing"`
CoverImage string `json:"coverImage,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Status string `json:"status"`
EnrolledCount int `json:"enrolledCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type LearningUserProfile struct {
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Profile json.RawMessage `json:"profile"`
ResumeFileID *string `json:"resumeFileId,omitempty"`
ResumeExtractedText string `json:"resumeExtractedText,omitempty"`
OnboardingCompleted bool `json:"onboardingCompleted"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type LearningEnrollment struct {
ID string `json:"id"`
UserID string `json:"userId"`
CourseID string `json:"courseId"`
Status string `json:"status"`
Plan json.RawMessage `json:"plan"`
Progress json.RawMessage `json:"progress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Course *LearningCourse `json:"course,omitempty"`
}
type LearningTask struct {
ID string `json:"id"`
EnrollmentID string `json:"enrollmentId"`
ModuleIndex int `json:"moduleIndex"`
Title string `json:"title"`
TaskType string `json:"taskType"`
InstructionsMD string `json:"instructionsMd"`
Rubric json.RawMessage `json:"rubric"`
SandboxTemplate json.RawMessage `json:"sandboxTemplate"`
VerificationCmd string `json:"verificationCmd,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type LearningSubmission struct {
ID string `json:"id"`
TaskID string `json:"taskId"`
SandboxSessionID *string `json:"sandboxSessionId,omitempty"`
Result json.RawMessage `json:"result"`
Score int `json:"score"`
MaxScore int `json:"maxScore"`
FeedbackMD string `json:"feedbackMd,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type LearningTrendCandidate struct {
ID string `json:"id"`
Topic string `json:"topic"`
Category string `json:"category"`
Signals json.RawMessage `json:"signals"`
Score float64 `json:"score"`
Fingerprint string `json:"fingerprint"`
FailCount int `json:"failCount,omitempty"`
LastError *string `json:"lastError,omitempty"`
LastFailedAt *time.Time `json:"lastFailedAt,omitempty"`
PickedAt *time.Time `json:"pickedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type SandboxSession struct {
ID string `json:"id"`
UserID string `json:"userId"`
TaskID *string `json:"taskId,omitempty"`
OpenSandboxID string `json:"opensandboxId,omitempty"`
Status string `json:"status"`
LastActiveAt time.Time `json:"lastActiveAt"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"createdAt"`
}
// --- Courses ---
func (r *LearningRepository) ListCourses(ctx context.Context, category, difficulty, search string, limit, offset int) ([]*LearningCourse, int, error) {
where := "status = 'published'"
args := make([]interface{}, 0)
argIdx := 1
if category != "" {
where += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, category)
argIdx++
}
if difficulty != "" {
where += fmt.Sprintf(" AND difficulty = $%d", argIdx)
args = append(args, difficulty)
argIdx++
}
if search != "" {
where += fmt.Sprintf(" AND (title ILIKE $%d OR short_description ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM learning_courses WHERE %s", where)
var total int
if err := r.db.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
query := fmt.Sprintf(`SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours,
base_outline, landing, cover_image, status, enrolled_count, created_at, updated_at
FROM learning_courses WHERE %s ORDER BY enrolled_count DESC, created_at DESC LIMIT $%d OFFSET $%d`,
where, argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := r.db.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var courses []*LearningCourse
for rows.Next() {
c := &LearningCourse{}
var tags []byte
var coverImg sql.NullString
if err := rows.Scan(&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags,
&c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg,
&c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, 0, err
}
if coverImg.Valid {
c.CoverImage = coverImg.String
}
json.Unmarshal(tags, &c.Tags)
courses = append(courses, c)
}
return courses, total, nil
}
func (r *LearningRepository) GetCourseBySlug(ctx context.Context, slug string) (*LearningCourse, error) {
c := &LearningCourse{}
var coverImg sql.NullString
var tags []byte
err := r.db.db.QueryRowContext(ctx, `SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours,
base_outline, landing, cover_image, fingerprint, status, enrolled_count, created_at, updated_at
FROM learning_courses WHERE slug = $1`, slug).Scan(
&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags,
&c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg,
&c.Fingerprint, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if coverImg.Valid {
c.CoverImage = coverImg.String
}
json.Unmarshal(tags, &c.Tags)
return c, nil
}
func (r *LearningRepository) GetCourseByID(ctx context.Context, id string) (*LearningCourse, error) {
c := &LearningCourse{}
var coverImg sql.NullString
var tags []byte
err := r.db.db.QueryRowContext(ctx, `SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours,
base_outline, landing, cover_image, fingerprint, status, enrolled_count, created_at, updated_at
FROM learning_courses WHERE id = $1`, id).Scan(
&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags,
&c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg,
&c.Fingerprint, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if coverImg.Valid {
c.CoverImage = coverImg.String
}
json.Unmarshal(tags, &c.Tags)
return c, nil
}
func (r *LearningRepository) CreateCourse(ctx context.Context, c *LearningCourse) error {
tagsJSON, _ := json.Marshal(c.Tags)
return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_courses
(slug, title, short_description, category, tags, difficulty, duration_hours, base_outline, landing, cover_image, fingerprint, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id, created_at, updated_at`,
c.Slug, c.Title, c.ShortDescription, c.Category, string(tagsJSON), c.Difficulty, c.DurationHours,
c.BaseOutline, c.Landing, sql.NullString{String: c.CoverImage, Valid: c.CoverImage != ""},
sql.NullString{String: c.Fingerprint, Valid: c.Fingerprint != ""}, c.Status,
).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
}
func (r *LearningRepository) UpdateCourseStatus(ctx context.Context, id, status string) error {
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_courses SET status=$2, updated_at=NOW() WHERE id=$1", id, status)
return err
}
func (r *LearningRepository) FingerprintExists(ctx context.Context, fp string) (bool, error) {
var exists bool
err := r.db.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM learning_courses WHERE fingerprint=$1)", fp).Scan(&exists)
return exists, err
}
// --- User profiles ---
func (r *LearningRepository) GetProfile(ctx context.Context, userID string) (*LearningUserProfile, error) {
p := &LearningUserProfile{}
var resumeFileID sql.NullString
var resumeText sql.NullString
err := r.db.db.QueryRowContext(ctx, `SELECT user_id, display_name, profile, resume_file_id, resume_extracted_text,
onboarding_completed, created_at, updated_at FROM learning_user_profiles WHERE user_id=$1`, userID).Scan(
&p.UserID, &p.DisplayName, &p.Profile, &resumeFileID, &resumeText,
&p.OnboardingCompleted, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if resumeFileID.Valid {
p.ResumeFileID = &resumeFileID.String
}
if resumeText.Valid {
p.ResumeExtractedText = resumeText.String
}
return p, nil
}
func (r *LearningRepository) UpsertProfile(ctx context.Context, p *LearningUserProfile) error {
_, err := r.db.db.ExecContext(ctx, `INSERT INTO learning_user_profiles (user_id, display_name, profile, resume_file_id, resume_extracted_text, onboarding_completed)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (user_id) DO UPDATE SET
display_name=EXCLUDED.display_name, profile=EXCLUDED.profile, resume_file_id=EXCLUDED.resume_file_id,
resume_extracted_text=EXCLUDED.resume_extracted_text, onboarding_completed=EXCLUDED.onboarding_completed, updated_at=NOW()`,
p.UserID, p.DisplayName, p.Profile,
sql.NullString{String: func() string { if p.ResumeFileID != nil { return *p.ResumeFileID }; return "" }(), Valid: p.ResumeFileID != nil},
sql.NullString{String: p.ResumeExtractedText, Valid: p.ResumeExtractedText != ""},
p.OnboardingCompleted)
return err
}
// --- Enrollments ---
func (r *LearningRepository) CreateEnrollment(ctx context.Context, e *LearningEnrollment) error {
err := r.db.db.QueryRowContext(ctx, `INSERT INTO learning_enrollments (user_id, course_id, status, plan, progress)
VALUES ($1,$2,$3,$4,$5) RETURNING id, created_at, updated_at`,
e.UserID, e.CourseID, e.Status, e.Plan, e.Progress).Scan(&e.ID, &e.CreatedAt, &e.UpdatedAt)
if err != nil {
return err
}
r.db.db.ExecContext(ctx, "UPDATE learning_courses SET enrolled_count = enrolled_count + 1 WHERE id=$1", e.CourseID)
return nil
}
func (r *LearningRepository) GetEnrollment(ctx context.Context, id string) (*LearningEnrollment, error) {
e := &LearningEnrollment{}
err := r.db.db.QueryRowContext(ctx, `SELECT id, user_id, course_id, status, plan, progress, created_at, updated_at
FROM learning_enrollments WHERE id=$1`, id).Scan(
&e.ID, &e.UserID, &e.CourseID, &e.Status, &e.Plan, &e.Progress, &e.CreatedAt, &e.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return e, nil
}
func (r *LearningRepository) ListEnrollments(ctx context.Context, userID string) ([]*LearningEnrollment, error) {
rows, err := r.db.db.QueryContext(ctx, `SELECT e.id, e.user_id, e.course_id, e.status, e.plan, e.progress, e.created_at, e.updated_at,
c.id, c.slug, c.title, c.short_description, c.category, c.difficulty, c.duration_hours, c.cover_image, c.status
FROM learning_enrollments e JOIN learning_courses c ON e.course_id=c.id WHERE e.user_id=$1 ORDER BY e.updated_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var enrollments []*LearningEnrollment
for rows.Next() {
e := &LearningEnrollment{}
c := &LearningCourse{}
var coverImg sql.NullString
if err := rows.Scan(&e.ID, &e.UserID, &e.CourseID, &e.Status, &e.Plan, &e.Progress, &e.CreatedAt, &e.UpdatedAt,
&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &c.Difficulty, &c.DurationHours, &coverImg, &c.Status); err != nil {
return nil, err
}
if coverImg.Valid {
c.CoverImage = coverImg.String
}
e.Course = c
enrollments = append(enrollments, e)
}
return enrollments, nil
}
func (r *LearningRepository) UpdateEnrollmentProgress(ctx context.Context, id string, progress json.RawMessage) error {
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_enrollments SET progress=$2, updated_at=NOW() WHERE id=$1", id, progress)
return err
}
func (r *LearningRepository) UpdateEnrollmentPlan(ctx context.Context, id string, plan json.RawMessage) error {
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_enrollments SET plan=$2, updated_at=NOW() WHERE id=$1", id, plan)
return err
}
// --- Tasks ---
func (r *LearningRepository) CreateTask(ctx context.Context, t *LearningTask) error {
return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_tasks
(enrollment_id, module_index, title, task_type, instructions_md, rubric, sandbox_template, verification_cmd, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, created_at, updated_at`,
t.EnrollmentID, t.ModuleIndex, t.Title, t.TaskType, t.InstructionsMD, t.Rubric,
t.SandboxTemplate, t.VerificationCmd, t.Status).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
}
func (r *LearningRepository) GetTask(ctx context.Context, id string) (*LearningTask, error) {
t := &LearningTask{}
err := r.db.db.QueryRowContext(ctx, `SELECT id, enrollment_id, module_index, title, task_type, instructions_md,
rubric, sandbox_template, verification_cmd, status, created_at, updated_at
FROM learning_tasks WHERE id=$1`, id).Scan(
&t.ID, &t.EnrollmentID, &t.ModuleIndex, &t.Title, &t.TaskType, &t.InstructionsMD,
&t.Rubric, &t.SandboxTemplate, &t.VerificationCmd, &t.Status, &t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return t, nil
}
func (r *LearningRepository) ListTasksByEnrollment(ctx context.Context, enrollmentID string) ([]*LearningTask, error) {
rows, err := r.db.db.QueryContext(ctx, `SELECT id, enrollment_id, module_index, title, task_type, instructions_md,
rubric, sandbox_template, verification_cmd, status, created_at, updated_at
FROM learning_tasks WHERE enrollment_id=$1 ORDER BY module_index, created_at`, enrollmentID)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []*LearningTask
for rows.Next() {
t := &LearningTask{}
if err := rows.Scan(&t.ID, &t.EnrollmentID, &t.ModuleIndex, &t.Title, &t.TaskType, &t.InstructionsMD,
&t.Rubric, &t.SandboxTemplate, &t.VerificationCmd, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, nil
}
func (r *LearningRepository) UpdateTaskStatus(ctx context.Context, id, status string) error {
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_tasks SET status=$2, updated_at=NOW() WHERE id=$1", id, status)
return err
}
// --- Submissions ---
func (r *LearningRepository) CreateSubmission(ctx context.Context, s *LearningSubmission) error {
return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_submissions
(task_id, sandbox_session_id, result, score, max_score, feedback_md) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, created_at`,
s.TaskID, sql.NullString{String: func() string { if s.SandboxSessionID != nil { return *s.SandboxSessionID }; return "" }(), Valid: s.SandboxSessionID != nil},
s.Result, s.Score, s.MaxScore, s.FeedbackMD).Scan(&s.ID, &s.CreatedAt)
}
func (r *LearningRepository) GetLatestSubmission(ctx context.Context, taskID string) (*LearningSubmission, error) {
s := &LearningSubmission{}
var sessID sql.NullString
err := r.db.db.QueryRowContext(ctx, `SELECT id, task_id, sandbox_session_id, result, score, max_score, feedback_md, created_at
FROM learning_submissions WHERE task_id=$1 ORDER BY created_at DESC LIMIT 1`, taskID).Scan(
&s.ID, &s.TaskID, &sessID, &s.Result, &s.Score, &s.MaxScore, &s.FeedbackMD, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if sessID.Valid {
s.SandboxSessionID = &sessID.String
}
return s, nil
}
// --- Trends ---
func (r *LearningRepository) CreateTrend(ctx context.Context, t *LearningTrendCandidate) error {
err := r.db.db.QueryRowContext(ctx, `INSERT INTO learning_trend_candidates (topic, category, signals, score, fingerprint)
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (fingerprint) DO NOTHING RETURNING id, created_at`,
t.Topic, t.Category, t.Signals, t.Score, t.Fingerprint).Scan(&t.ID, &t.CreatedAt)
if err == sql.ErrNoRows {
return nil
}
return err
}
func (r *LearningRepository) PickTopTrend(ctx context.Context) (*LearningTrendCandidate, error) {
t := &LearningTrendCandidate{}
var lastErr sql.NullString
var lastFailed sql.NullTime
err := r.db.db.QueryRowContext(ctx, `UPDATE learning_trend_candidates SET picked_at=NOW()
WHERE id = (
SELECT id FROM learning_trend_candidates
WHERE picked_at IS NULL
AND fail_count < 5
AND (last_failed_at IS NULL OR last_failed_at < NOW() - INTERVAL '15 minutes')
ORDER BY score DESC, fail_count ASC, created_at ASC
LIMIT 1
)
RETURNING id, topic, category, signals, score, fingerprint, fail_count, last_error, last_failed_at, created_at`).Scan(
&t.ID, &t.Topic, &t.Category, &t.Signals, &t.Score, &t.Fingerprint, &t.FailCount, &lastErr, &lastFailed, &t.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if lastErr.Valid {
t.LastError = &lastErr.String
}
if lastFailed.Valid {
t.LastFailedAt = &lastFailed.Time
}
return t, nil
}
func (r *LearningRepository) MarkTrendFailed(ctx context.Context, id, errMsg string) error {
_, err := r.db.db.ExecContext(ctx, `UPDATE learning_trend_candidates
SET fail_count = fail_count + 1,
last_error = $2,
last_failed_at = NOW(),
picked_at = NULL
WHERE id = $1`, id, errMsg)
return err
}
func (r *LearningRepository) SlugExists(ctx context.Context, slug string) (bool, error) {
var exists bool
err := r.db.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM learning_courses WHERE slug=$1)", slug).Scan(&exists)
return exists, err
}
// --- Sandbox sessions ---
func (r *LearningRepository) CreateSandboxSession(ctx context.Context, s *SandboxSession) error {
return r.db.db.QueryRowContext(ctx, `INSERT INTO sandbox_sessions (user_id, task_id, opensandbox_id, status, metadata)
VALUES ($1,$2,$3,$4,$5) RETURNING id, created_at`,
s.UserID, sql.NullString{String: func() string { if s.TaskID != nil { return *s.TaskID }; return "" }(), Valid: s.TaskID != nil},
s.OpenSandboxID, s.Status, s.Metadata).Scan(&s.ID, &s.CreatedAt)
}
func (r *LearningRepository) GetSandboxSession(ctx context.Context, id string) (*SandboxSession, error) {
s := &SandboxSession{}
var taskID sql.NullString
err := r.db.db.QueryRowContext(ctx, `SELECT id, user_id, task_id, opensandbox_id, status, last_active_at, metadata, created_at
FROM sandbox_sessions WHERE id=$1`, id).Scan(
&s.ID, &s.UserID, &taskID, &s.OpenSandboxID, &s.Status, &s.LastActiveAt, &s.Metadata, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if taskID.Valid {
s.TaskID = &taskID.String
}
return s, nil
}
func (r *LearningRepository) UpdateSandboxSessionStatus(ctx context.Context, id, status string) error {
_, err := r.db.db.ExecContext(ctx, "UPDATE sandbox_sessions SET status=$2, last_active_at=NOW() WHERE id=$1", id, status)
return err
}
func (r *LearningRepository) CreateSandboxEvent(ctx context.Context, sessionID, eventType string, payload json.RawMessage) error {
_, err := r.db.db.ExecContext(ctx, `INSERT INTO sandbox_events (session_id, event_type, payload) VALUES ($1,$2,$3)`,
sessionID, eventType, payload)
return err
}

View File

@@ -0,0 +1,43 @@
package db
import (
"encoding/json"
"testing"
)
func TestNewLearningRepository(t *testing.T) {
pg := &PostgresDB{}
repo := NewLearningRepository(pg)
if repo == nil {
t.Fatalf("expected repository instance")
}
if repo.db != pg {
t.Fatalf("repository must keep provided db pointer")
}
}
func TestLearningUserProfileJSONContract(t *testing.T) {
profile := LearningUserProfile{
UserID: "u-1",
DisplayName: "Alex",
Profile: json.RawMessage(`{"target_track":"backend"}`),
ResumeExtractedText: "resume text",
OnboardingCompleted: true,
}
raw, err := json.Marshal(profile)
if err != nil {
t.Fatalf("marshal profile: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal profile json: %v", err)
}
if decoded["userId"] != "u-1" {
t.Fatalf("unexpected userId: %v", decoded["userId"])
}
if decoded["onboardingCompleted"] != true {
t.Fatalf("unexpected onboardingCompleted: %v", decoded["onboardingCompleted"])
}
}

View File

@@ -0,0 +1,556 @@
package learning
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"regexp"
"strings"
"time"
"unicode"
"github.com/gooseek/backend/internal/db"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/search"
)
type CourseAutoGenConfig struct {
LLM llm.Client
Repo *db.LearningRepository
SearchClient *search.SearXNGClient
}
type CourseAutoGenerator struct {
cfg CourseAutoGenConfig
}
func NewCourseAutoGenerator(cfg CourseAutoGenConfig) *CourseAutoGenerator {
return &CourseAutoGenerator{cfg: cfg}
}
func (g *CourseAutoGenerator) StartBackground(ctx context.Context) {
log.Println("[course-autogen] starting background course generation")
time.Sleep(30 * time.Second)
ticker := time.NewTicker(2 * time.Hour)
defer ticker.Stop()
g.runCycle(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
g.runCycle(ctx)
}
}
}
func (g *CourseAutoGenerator) runCycle(ctx context.Context) {
log.Println("[course-autogen] running generation cycle")
cycleCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
if err := g.collectTrends(cycleCtx); err != nil {
log.Printf("[course-autogen] trend collection error: %v", err)
}
for i := 0; i < 3; i++ {
trend, err := g.cfg.Repo.PickTopTrend(cycleCtx)
if err != nil || trend == nil {
log.Printf("[course-autogen] no more trends to process")
break
}
if err := g.designAndPublishCourse(cycleCtx, trend); err != nil {
log.Printf("[course-autogen] course design error for '%s': %v", trend.Topic, err)
continue
}
time.Sleep(5 * time.Second)
}
}
func (g *CourseAutoGenerator) collectTrends(ctx context.Context) error {
var webContext string
if g.cfg.SearchClient != nil {
webContext = g.searchTrendData(ctx)
}
prompt := `Ты — аналитик трендов IT-индустрии и образования в России и мире.`
if webContext != "" {
prompt += "\n\nРЕАЛЬНЫЕ ДАННЫЕ ИЗ ИНТЕРНЕТА:\n" + webContext
}
prompt += `
На основе реальных данных выбери 5 уникальных тем для курсов:
КРИТЕРИИ:
1. Актуальны на рынке РФ (вакансии hh.ru, habr, стеки)
2. НЕ банальные ("Основы Python", "HTML для начинающих" — НЕТ)
3. Практическая ценность для карьеры и зарплаты
4. Уникальность — чего нет на Stepik/Coursera/Skillbox
5. Тренды 2025-2026: AI/ML ops, platform engineering, Rust, WebAssembly, edge computing и т.д.
Категории: programming, devops, data, ai_ml, security, product, design, management, fintech, gamedev, mobile, blockchain, iot, other
Ответь строго JSON:
{
"trends": [
{
"topic": "Конкретное название курса",
"category": "категория",
"why_unique": "Почему этот курс уникален и привлечёт пользователей",
"demand_signals": ["сигнал спроса 1", "сигнал спроса 2"],
"target_salary": "ожидаемая зарплата после курса",
"score": 0.85
}
]
}`
result, err := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
}, 2, 2*time.Second)
if err != nil {
return err
}
jsonStr := extractJSONBlock(result)
var parsed struct {
Trends []struct {
Topic string `json:"topic"`
Category string `json:"category"`
WhyUnique string `json:"why_unique"`
DemandSignals []string `json:"demand_signals"`
TargetSalary string `json:"target_salary"`
Score float64 `json:"score"`
} `json:"trends"`
}
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil || len(parsed.Trends) == 0 {
// Try a strict repair prompt once (common provider failure mode: extra prose / malformed JSON)
repairPrompt := "Верни ответ СТРОГО как JSON без текста. " + prompt
repaired, rerr := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: repairPrompt}},
}, 1, 2*time.Second)
if rerr != nil {
return fmt.Errorf("failed to parse trends: %w", err)
}
jsonStr = extractJSONBlock(repaired)
if uerr := json.Unmarshal([]byte(jsonStr), &parsed); uerr != nil || len(parsed.Trends) == 0 {
if uerr != nil {
return fmt.Errorf("failed to parse trends: %w", uerr)
}
return fmt.Errorf("failed to parse trends: empty trends")
}
}
saved := 0
for _, t := range parsed.Trends {
fp := generateFingerprint(t.Topic)
exists, _ := g.cfg.Repo.FingerprintExists(ctx, fp)
if exists {
continue
}
signals, _ := json.Marshal(map[string]interface{}{
"why_unique": t.WhyUnique,
"demand_signals": t.DemandSignals,
"target_salary": t.TargetSalary,
})
trend := &db.LearningTrendCandidate{
Topic: t.Topic,
Category: t.Category,
Signals: signals,
Score: t.Score,
Fingerprint: fp,
}
if err := g.cfg.Repo.CreateTrend(ctx, trend); err == nil {
saved++
}
}
log.Printf("[course-autogen] saved %d new trend candidates", saved)
return nil
}
func (g *CourseAutoGenerator) searchTrendData(ctx context.Context) string {
queries := []string{
"IT тренды обучение 2025 2026 Россия",
"самые востребованные IT навыки вакансии hh.ru",
"новые технологии программирование курсы",
}
var results []string
for _, q := range queries {
searchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
resp, err := g.cfg.SearchClient.Search(searchCtx, q, &search.SearchOptions{
Categories: []string{"general"},
PageNo: 1,
})
cancel()
if err != nil {
continue
}
for _, r := range resp.Results {
snippet := r.Title + ": " + r.Content
if len(snippet) > 300 {
snippet = snippet[:300]
}
results = append(results, snippet)
}
}
if len(results) == 0 {
return ""
}
combined := strings.Join(results, "\n---\n")
if len(combined) > 3000 {
combined = combined[:3000]
}
return combined
}
func (g *CourseAutoGenerator) designAndPublishCourse(ctx context.Context, trend *db.LearningTrendCandidate) error {
log.Printf("[course-autogen] designing course: %s", trend.Topic)
fp := generateFingerprint(trend.Topic)
exists, _ := g.cfg.Repo.FingerprintExists(ctx, fp)
if exists {
return nil
}
var marketResearch string
if g.cfg.SearchClient != nil {
marketResearch = g.researchCourseTopic(ctx, trend.Topic)
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT. Спроектируй профессиональный курс.
Тема: %s
Категория: %s`, trend.Topic, trend.Category)
if marketResearch != "" {
prompt += "\n\nИССЛЕДОВАНИЕ РЫНКА:\n" + marketResearch
}
prompt += `
ТРЕБОВАНИЯ:
1. Минимум теории, максимум боевой практики (как на реальных проектах в РФ)
2. Каждый модуль — практическое задание из реального проекта
3. Уровень: от базового до продвинутого
4. Курс должен быть уникальным — не копия Stepik/Coursera
5. Лендинг должен ПРОДАВАТЬ — конкретные выгоды, зарплаты, результаты
6. Outline должен быть детальным — 8-12 модулей
Ответь строго JSON:
{
"title": "Привлекательное название курса",
"slug": "slug-without-spaces",
"short_description": "Краткое описание 2-3 предложения. Конкретика, не вода.",
"difficulty": "beginner|intermediate|advanced",
"duration_hours": 40,
"tags": ["тег1", "тег2"],
"outline": {
"modules": [
{
"index": 0,
"title": "Название модуля",
"description": "Описание + что делаем на практике",
"skills": ["навык"],
"estimated_hours": 4,
"practice_focus": "Конкретная практическая задача"
}
]
},
"landing": {
"hero_title": "Заголовок лендинга (продающий)",
"hero_subtitle": "Подзаголовок с конкретной выгодой",
"benefits": ["Конкретная выгода 1", "Выгода 2", "Выгода 3", "Выгода 4"],
"target_audience": "Для кого этот курс — конкретно",
"outcomes": ["Результат 1 с цифрами", "Результат 2"],
"salary_range": "Ожидаемая зарплата после курса",
"prerequisites": "Что нужно знать заранее",
"faq": [
{"question": "Вопрос?", "answer": "Ответ"}
]
}
}`
result, err := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
}, 2, 2*time.Second)
if err != nil {
lastErr = err
continue
}
jsonStr := extractJSONBlock(result)
var parsed struct {
Title string `json:"title"`
Slug string `json:"slug"`
ShortDescription string `json:"short_description"`
Difficulty string `json:"difficulty"`
DurationHours int `json:"duration_hours"`
Tags []string `json:"tags"`
Outline json.RawMessage `json:"outline"`
Landing json.RawMessage `json:"landing"`
}
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
lastErr = fmt.Errorf("failed to parse course design: %w", err)
continue
}
outlineJSON := parsed.Outline
if outlineJSON == nil {
outlineJSON = json.RawMessage("{}")
}
landingJSON := parsed.Landing
if landingJSON == nil {
landingJSON = json.RawMessage("{}")
}
if err := validateCourseArtifacts(parsed.Title, parsed.ShortDescription, outlineJSON, landingJSON); err != nil {
lastErr = err
continue
}
slug := sanitizeSlug(parsed.Slug)
if slug == "" {
slug = sanitizeSlug(parsed.Title)
}
slug = g.ensureUniqueSlug(ctx, slug)
if parsed.DurationHours == 0 {
parsed.DurationHours = 40
}
parsed.Difficulty = normalizeDifficulty(parsed.Difficulty)
course := &db.LearningCourse{
Slug: slug,
Title: strings.TrimSpace(parsed.Title),
ShortDescription: strings.TrimSpace(parsed.ShortDescription),
Category: trend.Category,
Tags: parsed.Tags,
Difficulty: parsed.Difficulty,
DurationHours: parsed.DurationHours,
BaseOutline: outlineJSON,
Landing: landingJSON,
Fingerprint: fp,
Status: "published",
}
if err := g.cfg.Repo.CreateCourse(ctx, course); err != nil {
lastErr = fmt.Errorf("failed to save course: %w", err)
continue
}
log.Printf("[course-autogen] published course: %s (%s)", course.Title, course.Slug)
return nil
}
if lastErr == nil {
lastErr = errors.New("unknown course design failure")
}
_ = g.cfg.Repo.MarkTrendFailed(ctx, trend.ID, truncateErr(lastErr.Error(), 800))
return lastErr
}
func (g *CourseAutoGenerator) researchCourseTopic(ctx context.Context, topic string) string {
queries := []string{
topic + " курс программа обучение",
topic + " вакансии зарплата Россия",
}
var results []string
for _, q := range queries {
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
resp, err := g.cfg.SearchClient.Search(searchCtx, q, &search.SearchOptions{
Categories: []string{"general"},
PageNo: 1,
})
cancel()
if err != nil {
continue
}
for _, r := range resp.Results {
snippet := r.Title + ": " + r.Content
if len(snippet) > 250 {
snippet = snippet[:250]
}
results = append(results, snippet)
}
}
if len(results) == 0 {
return ""
}
combined := strings.Join(results, "\n---\n")
if len(combined) > 2000 {
combined = combined[:2000]
}
return combined
}
func generateFingerprint(topic string) string {
normalized := strings.ToLower(strings.TrimSpace(topic))
hash := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(hash[:16])
}
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var result []rune
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
result = append(result, r)
} else if r == ' ' || r == '-' || r == '_' {
result = append(result, '-')
}
}
slug := string(result)
re := regexp.MustCompile(`-+`)
slug = re.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
if len(slug) > 100 {
slug = slug[:100]
}
return slug
}
func (g *CourseAutoGenerator) ensureUniqueSlug(ctx context.Context, base string) string {
if base == "" {
base = "course"
}
slug := base
for i := 0; i < 20; i++ {
exists, err := g.cfg.Repo.SlugExists(ctx, slug)
if err == nil && !exists {
return slug
}
slug = fmt.Sprintf("%s-%d", base, i+2)
}
return fmt.Sprintf("%s-%d", base, time.Now().Unix()%10000)
}
func normalizeDifficulty(d string) string {
switch strings.ToLower(strings.TrimSpace(d)) {
case "beginner", "intermediate", "advanced":
return strings.ToLower(strings.TrimSpace(d))
default:
return "intermediate"
}
}
func validateCourseArtifacts(title, short string, outlineJSON, landingJSON json.RawMessage) error {
if strings.TrimSpace(title) == "" {
return errors.New("course title is empty")
}
if len(strings.TrimSpace(short)) < 40 {
return errors.New("short_description слишком короткое (нужна конкретика)")
}
// Outline validation
var outline struct {
Modules []struct {
Index int `json:"index"`
Title string `json:"title"`
Description string `json:"description"`
Skills []string `json:"skills"`
EstimatedHours int `json:"estimated_hours"`
PracticeFocus string `json:"practice_focus"`
} `json:"modules"`
}
if err := json.Unmarshal(outlineJSON, &outline); err != nil {
return fmt.Errorf("outline JSON invalid: %w", err)
}
if len(outline.Modules) < 8 || len(outline.Modules) > 12 {
return fmt.Errorf("outline modules count must be 8-12, got %d", len(outline.Modules))
}
for i, m := range outline.Modules {
if strings.TrimSpace(m.Title) == "" || strings.TrimSpace(m.PracticeFocus) == "" {
return fmt.Errorf("outline module[%d] missing title/practice_focus", i)
}
}
// Landing validation
var landing struct {
HeroTitle string `json:"hero_title"`
HeroSubtitle string `json:"hero_subtitle"`
Benefits []string `json:"benefits"`
Outcomes []string `json:"outcomes"`
SalaryRange string `json:"salary_range"`
FAQ []struct {
Question string `json:"question"`
Answer string `json:"answer"`
} `json:"faq"`
}
if err := json.Unmarshal(landingJSON, &landing); err != nil {
return fmt.Errorf("landing JSON invalid: %w", err)
}
if strings.TrimSpace(landing.HeroTitle) == "" || strings.TrimSpace(landing.HeroSubtitle) == "" {
return errors.New("landing missing hero_title/hero_subtitle")
}
if len(landing.Benefits) < 3 || len(landing.Outcomes) < 2 {
return errors.New("landing benefits/outcomes недостаточно конкретные")
}
if strings.TrimSpace(landing.SalaryRange) == "" {
return errors.New("landing missing salary_range")
}
if len(landing.FAQ) < 1 || strings.TrimSpace(landing.FAQ[0].Question) == "" {
return errors.New("landing FAQ missing")
}
return nil
}
func generateTextWithRetry(ctx context.Context, client llm.Client, req llm.StreamRequest, retries int, baseDelay time.Duration) (string, error) {
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
if attempt > 0 {
delay := baseDelay * time.Duration(1<<uint(attempt-1))
t := time.NewTimer(delay)
select {
case <-ctx.Done():
t.Stop()
return "", ctx.Err()
case <-t.C:
}
}
res, err := client.GenerateText(ctx, req)
if err == nil && strings.TrimSpace(res) != "" {
return res, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errors.New("empty response")
}
return "", lastErr
}
func truncateErr(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) <= max {
return s
}
return s[:max] + "..."
}

View File

@@ -0,0 +1,79 @@
package learning
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/gooseek/backend/internal/llm"
)
func TestSanitizeSlug(t *testing.T) {
got := sanitizeSlug(" Go Backend: Production _ Course ")
if got != "go-backend-production-course" {
t.Fatalf("sanitizeSlug result mismatch: %q", got)
}
}
func TestNormalizeDifficulty(t *testing.T) {
if normalizeDifficulty("advanced") != "advanced" {
t.Fatalf("expected advanced difficulty")
}
if normalizeDifficulty("unknown") != "intermediate" {
t.Fatalf("expected fallback to intermediate")
}
}
func TestValidateCourseArtifacts(t *testing.T) {
outline := json.RawMessage(`{
"modules": [
{"index":0,"title":"m1","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":1,"title":"m2","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":2,"title":"m3","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":3,"title":"m4","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":4,"title":"m5","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":5,"title":"m6","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":6,"title":"m7","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
{"index":7,"title":"m8","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"}
]
}`)
landing := json.RawMessage(`{
"hero_title":"Hero",
"hero_subtitle":"Subtitle",
"benefits":["b1","b2","b3"],
"outcomes":["o1","o2"],
"salary_range":"200k",
"faq":[{"question":"q","answer":"a"}]
}`)
err := validateCourseArtifacts("Course", "Это достаточно длинное описание для валидации курса в тесте.", outline, landing)
if err != nil {
t.Fatalf("validateCourseArtifacts unexpected error: %v", err)
}
}
func TestGenerateTextWithRetry(t *testing.T) {
attempt := 0
client := &mockLLMClient{
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
attempt++
if attempt < 3 {
return "", errors.New("temporary")
}
return "ok", nil
},
}
got, err := generateTextWithRetry(context.Background(), client, llm.StreamRequest{}, 3, time.Millisecond)
if err != nil {
t.Fatalf("generateTextWithRetry error: %v", err)
}
if got != "ok" {
t.Fatalf("unexpected result: %q", got)
}
if attempt != 3 {
t.Fatalf("expected 3 attempts, got %d", attempt)
}
}

View File

@@ -0,0 +1,36 @@
package learning
import (
"context"
"github.com/gooseek/backend/internal/llm"
)
type mockLLMClient struct {
generateFunc func(ctx context.Context, req llm.StreamRequest) (string, error)
streamFunc func(ctx context.Context, req llm.StreamRequest) (<-chan llm.StreamChunk, error)
}
func (m *mockLLMClient) StreamText(ctx context.Context, req llm.StreamRequest) (<-chan llm.StreamChunk, error) {
if m.streamFunc != nil {
return m.streamFunc(ctx, req)
}
ch := make(chan llm.StreamChunk)
close(ch)
return ch, nil
}
func (m *mockLLMClient) GenerateText(ctx context.Context, req llm.StreamRequest) (string, error) {
if m.generateFunc != nil {
return m.generateFunc(ctx, req)
}
return "", nil
}
func (m *mockLLMClient) GetProviderID() string {
return "mock"
}
func (m *mockLLMClient) GetModelKey() string {
return "mock-model"
}

View File

@@ -0,0 +1,118 @@
package learning
import (
"context"
"encoding/json"
"fmt"
"github.com/gooseek/backend/internal/db"
"github.com/gooseek/backend/internal/llm"
)
type PersonalPlan struct {
Modules []PlanModule `json:"modules"`
TotalHours int `json:"total_hours"`
DifficultyAdjusted string `json:"difficulty_adjusted"`
PersonalizationNote string `json:"personalization_notes"`
MilestoneProject string `json:"milestone_project"`
}
type PlanModule struct {
Index int `json:"index"`
Title string `json:"title"`
Description string `json:"description"`
Skills []string `json:"skills"`
EstimatedHrs int `json:"estimated_hours"`
PracticeFocus string `json:"practice_focus"`
TaskCount int `json:"task_count"`
IsCheckpoint bool `json:"is_checkpoint"`
}
func BuildPersonalPlan(ctx context.Context, llmClient llm.Client, course *db.LearningCourse, profileJSON string) (json.RawMessage, error) {
profileInfo := "Профиль неизвестен — план по умолчанию."
if profileJSON != "" && profileJSON != "{}" {
profileInfo = "Профиль ученика:\n" + truncateStr(profileJSON, 2000)
}
outlineStr := string(course.BaseOutline)
if len(outlineStr) > 4000 {
outlineStr = outlineStr[:4000]
}
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT с 10-летним опытом. Адаптируй базовый план курса под конкретного ученика.
Курс: %s
Описание: %s
Сложность: %s
Длительность: %d часов
Базовый план:
%s
%s
ТРЕБОВАНИЯ:
1. Минимум теории, максимум боевой практики (как на реальных проектах в РФ)
2. Каждый модуль = конкретное практическое задание из реального проекта
3. Прогрессия: от простого к сложному, учитывая текущий уровень ученика
4. Каждый 3-й модуль — checkpoint (мини-проект для проверки навыков)
5. Финальный milestone project — полноценный проект для портфолио
6. Учитывай стек и опыт ученика — не повторяй то, что он уже знает
Ответь строго JSON:
{
"modules": [
{
"index": 0,
"title": "Название модуля",
"description": "Что изучаем и делаем",
"skills": ["навык1"],
"estimated_hours": 4,
"practice_focus": "Конкретная практическая задача из реального проекта",
"task_count": 3,
"is_checkpoint": false
}
],
"total_hours": 40,
"difficulty_adjusted": "intermediate",
"personalization_notes": "Как план адаптирован под ученика",
"milestone_project": "Описание финального проекта для портфолио"
}`, course.Title, course.ShortDescription, course.Difficulty, course.DurationHours, outlineStr, profileInfo)
var plan PersonalPlan
if err := generateAndParse(ctx, llmClient, prompt, &plan, 2); err != nil {
return course.BaseOutline, fmt.Errorf("plan generation failed, using base outline: %w", err)
}
if len(plan.Modules) == 0 {
return course.BaseOutline, nil
}
for i := range plan.Modules {
plan.Modules[i].Index = i
if plan.Modules[i].TaskCount == 0 {
plan.Modules[i].TaskCount = 2
}
}
if plan.TotalHours == 0 {
total := 0
for _, m := range plan.Modules {
total += m.EstimatedHrs
}
plan.TotalHours = total
}
result, err := json.Marshal(plan)
if err != nil {
return course.BaseOutline, err
}
return result, nil
}
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View File

@@ -0,0 +1,66 @@
package learning
import (
"context"
"encoding/json"
"testing"
"github.com/gooseek/backend/internal/db"
"github.com/gooseek/backend/internal/llm"
)
func TestBuildPersonalPlanAppliesDefaults(t *testing.T) {
course := &db.LearningCourse{
Title: "Go Backend",
ShortDescription: "Курс по backend-разработке на Go",
Difficulty: "intermediate",
DurationHours: 24,
BaseOutline: json.RawMessage(`{"modules":[{"index":0,"title":"base","description":"base","skills":["go"],"estimated_hours":4,"practice_focus":"api"}]}`),
}
client := &mockLLMClient{
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
return `{
"modules": [
{"index": 999, "title": "API design", "description": "design REST", "skills": ["http"], "estimated_hours": 6, "practice_focus": "build handlers", "task_count": 0},
{"index": 999, "title": "DB layer", "description": "storage", "skills": ["sql"], "estimated_hours": 8, "practice_focus": "repository pattern", "task_count": 3}
],
"total_hours": 0,
"difficulty_adjusted": "intermediate",
"personalization_notes": "adapted"
}`, nil
},
}
planJSON, err := BuildPersonalPlan(context.Background(), client, course, `{"level":"junior"}`)
if err != nil {
t.Fatalf("BuildPersonalPlan error: %v", err)
}
var plan PersonalPlan
if err := json.Unmarshal(planJSON, &plan); err != nil {
t.Fatalf("unmarshal plan: %v", err)
}
if len(plan.Modules) != 2 {
t.Fatalf("expected 2 modules, got %d", len(plan.Modules))
}
if plan.Modules[0].Index != 0 || plan.Modules[1].Index != 1 {
t.Fatalf("module indexes were not normalized: %+v", plan.Modules)
}
if plan.Modules[0].TaskCount != 2 {
t.Fatalf("expected default task_count=2, got %d", plan.Modules[0].TaskCount)
}
if plan.TotalHours != 14 {
t.Fatalf("expected total_hours=14, got %d", plan.TotalHours)
}
}
func TestTruncateStr(t *testing.T) {
if got := truncateStr("abc", 5); got != "abc" {
t.Fatalf("truncateStr should keep short string, got %q", got)
}
if got := truncateStr("abcdef", 3); got != "abc..." {
t.Fatalf("truncateStr should truncate with ellipsis, got %q", got)
}
}

View File

@@ -0,0 +1,203 @@
package learning
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gooseek/backend/internal/llm"
)
type UserProfile struct {
Name string `json:"name"`
ExperienceYears float64 `json:"experience_years"`
CurrentRole string `json:"current_role"`
Skills []string `json:"skills"`
ProgrammingLangs []string `json:"programming_languages"`
Frameworks []string `json:"frameworks"`
Education string `json:"education"`
Industries []string `json:"industries"`
Strengths []string `json:"strengths"`
GrowthAreas []string `json:"growth_areas"`
CareerGoals string `json:"career_goals"`
RecommendedTracks []string `json:"recommended_tracks"`
Level string `json:"level"`
Summary string `json:"summary"`
}
func BuildProfileFromResume(ctx context.Context, llmClient llm.Client, extractedText string) (json.RawMessage, error) {
if strings.TrimSpace(extractedText) == "" {
return json.RawMessage("{}"), fmt.Errorf("empty resume text")
}
if len(extractedText) > 12000 {
extractedText = extractedText[:12000]
}
prompt := `Ты — senior HR-аналитик с 15-летним опытом в IT-рекрутинге в РФ. Проанализируй резюме и создай детальный профиль.
Резюме:
` + extractedText + `
ЗАДАЧА: Извлеки максимум информации. Определи реальный уровень кандидата (не завышай).
Ответь строго JSON (без markdown, без комментариев):
{
"name": "Имя Фамилия",
"experience_years": 3.5,
"current_role": "текущая должность или последняя",
"skills": ["навык1", "навык2", "навык3"],
"programming_languages": ["Go", "Python"],
"frameworks": ["React", "Fiber"],
"education": "образование кратко",
"industries": ["fintech", "ecommerce"],
"strengths": ["сильная сторона 1", "сильная сторона 2"],
"growth_areas": ["зона роста 1", "зона роста 2"],
"career_goals": "предположительные цели на основе опыта",
"recommended_tracks": ["рекомендуемый трек 1", "трек 2"],
"level": "junior|middle|senior|lead|expert",
"summary": "Краткая характеристика кандидата в 2-3 предложения"
}`
var profile UserProfile
err := generateAndParse(ctx, llmClient, prompt, &profile, 2)
if err != nil {
return json.RawMessage("{}"), fmt.Errorf("profile extraction failed: %w", err)
}
if profile.Level == "" {
profile.Level = inferLevel(profile.ExperienceYears)
}
if profile.Summary == "" {
profile.Summary = fmt.Sprintf("%s, %s, опыт %.0f лет", profile.Name, profile.CurrentRole, profile.ExperienceYears)
}
result, err := json.Marshal(profile)
if err != nil {
return json.RawMessage("{}"), err
}
return result, nil
}
func BuildProfileFromOnboarding(ctx context.Context, llmClient llm.Client, answers map[string]string) (json.RawMessage, error) {
answersJSON, _ := json.Marshal(answers)
prompt := `Ты — методолог обучения. На основе ответов пользователя на онбординг-вопросы, построй профиль.
Ответы пользователя:
` + string(answersJSON) + `
Ответь строго JSON:
{
"name": "",
"experience_years": 0,
"current_role": "",
"skills": [],
"programming_languages": [],
"frameworks": [],
"education": "",
"industries": [],
"strengths": [],
"growth_areas": [],
"career_goals": "",
"recommended_tracks": [],
"level": "beginner|junior|middle|senior",
"summary": "Краткая характеристика"
}`
var profile UserProfile
if err := generateAndParse(ctx, llmClient, prompt, &profile, 2); err != nil {
return json.RawMessage("{}"), err
}
if profile.Level == "" {
profile.Level = "beginner"
}
result, _ := json.Marshal(profile)
return result, nil
}
func inferLevel(years float64) string {
switch {
case years < 1:
return "beginner"
case years < 3:
return "junior"
case years < 5:
return "middle"
case years < 8:
return "senior"
default:
return "lead"
}
}
func generateAndParse(ctx context.Context, llmClient llm.Client, prompt string, target interface{}, maxRetries int) error {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
result, err := llmClient.GenerateText(attemptCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
cancel()
if err != nil {
lastErr = err
continue
}
jsonStr := extractJSONBlock(result)
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
lastErr = fmt.Errorf("attempt %d: JSON parse error: %w", attempt, err)
continue
}
return nil
}
return fmt.Errorf("all %d attempts failed: %w", maxRetries+1, lastErr)
}
func extractJSONBlock(text string) string {
if strings.Contains(text, "```json") {
start := strings.Index(text, "```json") + 7
end := strings.Index(text[start:], "```")
if end > 0 {
return strings.TrimSpace(text[start : start+end])
}
}
if strings.Contains(text, "```") {
start := strings.Index(text, "```") + 3
if nl := strings.Index(text[start:], "\n"); nl >= 0 {
start += nl + 1
}
end := strings.Index(text[start:], "```")
if end > 0 {
candidate := strings.TrimSpace(text[start : start+end])
if len(candidate) > 2 && candidate[0] == '{' {
return candidate
}
}
}
depth := 0
startIdx := -1
for i, ch := range text {
if ch == '{' {
if depth == 0 {
startIdx = i
}
depth++
} else if ch == '}' {
depth--
if depth == 0 && startIdx >= 0 {
return text[startIdx : i+1]
}
}
}
return "{}"
}

View File

@@ -0,0 +1,65 @@
package learning
import (
"context"
"encoding/json"
"testing"
"github.com/gooseek/backend/internal/llm"
)
func TestInferLevel(t *testing.T) {
tests := []struct {
years float64
want string
}{
{0, "beginner"},
{1.5, "junior"},
{3.2, "middle"},
{6.5, "senior"},
{10, "lead"},
}
for _, tc := range tests {
got := inferLevel(tc.years)
if got != tc.want {
t.Fatalf("inferLevel(%v) = %q, want %q", tc.years, got, tc.want)
}
}
}
func TestExtractJSONBlockFromMarkdown(t *testing.T) {
input := "text before\n```json\n{\"name\":\"Alex\",\"level\":\"junior\"}\n```\ntext after"
got := extractJSONBlock(input)
if got != "{\"name\":\"Alex\",\"level\":\"junior\"}" {
t.Fatalf("unexpected json block: %q", got)
}
}
func TestBuildProfileFromOnboarding(t *testing.T) {
llmClient := &mockLLMClient{
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
return `{"name":"Иван","experience_years":1.5,"current_role":"qa","skills":["testing"],"programming_languages":["Go"],"frameworks":[],"education":"BS","industries":["it"],"strengths":["аналитика"],"growth_areas":["backend"],"career_goals":"backend","recommended_tracks":["backend go"],"level":"junior","summary":"Начинающий специалист"}`, nil
},
}
profileJSON, err := BuildProfileFromOnboarding(context.Background(), llmClient, map[string]string{
"experience_level": "junior",
"target_track": "backend go",
"weekly_hours": "10",
})
if err != nil {
t.Fatalf("BuildProfileFromOnboarding error: %v", err)
}
var profile map[string]interface{}
if err := json.Unmarshal(profileJSON, &profile); err != nil {
t.Fatalf("profile json unmarshal: %v", err)
}
if profile["name"] != "Иван" {
t.Fatalf("unexpected name: %v", profile["name"])
}
if profile["level"] != "junior" {
t.Fatalf("unexpected level: %v", profile["level"])
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"regexp"
"time"
"github.com/gooseek/backend/internal/llm"
@@ -680,12 +679,7 @@ func (l *StepByStepLesson) SubmitQuizAnswer(stepIndex int, selectedOptions []str
}
func extractJSON(text string) string {
re := regexp.MustCompile(`(?s)\{.*\}`)
match := re.FindString(text)
if match != "" {
return match
}
return "{}"
return extractJSONBlock(text)
}
func (l *StepByStepLesson) ToJSON() ([]byte, error) {

View File

@@ -0,0 +1,671 @@
package medicine
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/gooseek/backend/internal/llm"
"github.com/google/uuid"
)
type ServiceConfig struct {
LLM llm.Client
SearXNGURL string
Timeout time.Duration
}
type Service struct {
llm llm.Client
searxngURL string
httpClient *http.Client
}
type ConsultRequest struct {
Symptoms string `json:"symptoms"`
City string `json:"city,omitempty"`
History [][2]string `json:"history,omitempty"`
Age int `json:"age,omitempty"`
Gender string `json:"gender,omitempty"`
ChatID string `json:"chatId,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
type ConditionItem struct {
Name string `json:"name"`
Likelihood string `json:"likelihood"`
Why string `json:"why"`
}
type SpecialtyItem struct {
Specialty string `json:"specialty"`
Reason string `json:"reason"`
Priority string `json:"priority"`
}
type MedicationInfo struct {
Name string `json:"name"`
ForWhat string `json:"forWhat"`
Notes string `json:"notes"`
}
type SupplementInfo struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
Evidence string `json:"evidence"`
Notes string `json:"notes"`
}
type ProcedureInfo struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
WhenUseful string `json:"whenUseful"`
}
type Assessment struct {
TriageLevel string `json:"triageLevel"`
UrgentSigns []string `json:"urgentSigns"`
PossibleConditions []ConditionItem `json:"possibleConditions"`
RecommendedSpecialists []SpecialtyItem `json:"recommendedSpecialists"`
QuestionsToClarify []string `json:"questionsToClarify"`
HomeCare []string `json:"homeCare"`
MedicationInfo []MedicationInfo `json:"medicationInfo"`
SupplementInfo []SupplementInfo `json:"supplementInfo"`
ProcedureInfo []ProcedureInfo `json:"procedureInfo"`
Disclaimer string `json:"disclaimer"`
}
type DoctorOption struct {
ID string `json:"id"`
Name string `json:"name"`
Specialty string `json:"specialty"`
Clinic string `json:"clinic"`
City string `json:"city"`
Address string `json:"address,omitempty"`
SourceURL string `json:"sourceUrl"`
SourceName string `json:"sourceName"`
Snippet string `json:"snippet,omitempty"`
}
type AppointmentOption struct {
ID string `json:"id"`
DoctorID string `json:"doctorId"`
Doctor string `json:"doctor"`
Specialty string `json:"specialty"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
Clinic string `json:"clinic"`
BookURL string `json:"bookUrl"`
Remote bool `json:"remote"`
}
type searxResponse struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
} `json:"results"`
}
func NewService(cfg ServiceConfig) *Service {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
return &Service{
llm: cfg.LLM,
searxngURL: strings.TrimSuffix(cfg.SearXNGURL, "/"),
httpClient: &http.Client{Timeout: timeout},
}
}
func (s *Service) StreamConsult(ctx context.Context, req ConsultRequest, writer io.Writer) error {
writeEvent := func(eventType string, data any) {
payload := map[string]any{"type": eventType}
if data != nil {
payload["data"] = data
}
encoded, _ := json.Marshal(payload)
_, _ = writer.Write(encoded)
_, _ = writer.Write([]byte("\n"))
if bw, ok := writer.(*bufio.Writer); ok {
_ = bw.Flush()
}
}
writeBlock := func(blockID, blockType string, data any) {
event := map[string]any{
"type": "block",
"block": map[string]any{
"id": blockID,
"type": blockType,
"data": data,
},
}
encoded, _ := json.Marshal(event)
_, _ = writer.Write(encoded)
_, _ = writer.Write([]byte("\n"))
if bw, ok := writer.(*bufio.Writer); ok {
_ = bw.Flush()
}
}
writeEvent("messageStart", nil)
assessment, err := s.buildAssessment(ctx, req)
if err != nil {
return err
}
city := strings.TrimSpace(req.City)
if city == "" {
city = "Москва"
}
doctors := s.searchDoctors(ctx, assessment.RecommendedSpecialists, city)
bookingLinks := buildBookingLinks(doctors)
summary := buildSummaryText(req.Symptoms, city, assessment, doctors, bookingLinks)
streamText(summary, writeEvent)
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_assessment",
"params": map[string]any{
"triageLevel": assessment.TriageLevel,
"urgentSigns": assessment.UrgentSigns,
"possibleConditions": assessment.PossibleConditions,
"recommendedSpecialists": assessment.RecommendedSpecialists,
"questionsToClarify": assessment.QuestionsToClarify,
"homeCare": assessment.HomeCare,
"disclaimer": assessment.Disclaimer,
},
})
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_doctors",
"params": map[string]any{
"city": city,
"doctors": doctors,
"specialists": assessment.RecommendedSpecialists,
},
})
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_appointments",
"params": map[string]any{
"bookingLinks": bookingLinks,
},
})
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_reference",
"params": map[string]any{
"medicationInfo": assessment.MedicationInfo,
"supplementInfo": assessment.SupplementInfo,
"procedureInfo": assessment.ProcedureInfo,
"note": "Справочная информация. Назначения и схемы лечения определяет только врач после очного осмотра.",
},
})
writeEvent("messageEnd", nil)
return nil
}
func streamText(text string, writeEvent func(string, any)) {
chunks := splitTextByChunks(text, 120)
for _, chunk := range chunks {
writeEvent("textChunk", map[string]any{
"chunk": chunk,
})
}
}
func splitTextByChunks(text string, size int) []string {
if len(text) <= size {
return []string{text}
}
parts := make([]string, 0, len(text)/size+1)
runes := []rune(text)
for i := 0; i < len(runes); i += size {
end := i + size
if end > len(runes) {
end = len(runes)
}
parts = append(parts, string(runes[i:end]))
}
return parts
}
func (s *Service) buildAssessment(ctx context.Context, req ConsultRequest) (*Assessment, error) {
if s.llm == nil {
return buildFallbackAssessment(req.Symptoms), nil
}
historyContext := ""
if len(req.History) > 0 {
var hb strings.Builder
hb.WriteString("\nИстория диалога:\n")
for _, pair := range req.History {
hb.WriteString(fmt.Sprintf("Пациент: %s\nВрач: %s\n", pair[0], pair[1]))
}
historyContext = hb.String()
}
ageInfo := "не указан"
if req.Age > 0 {
ageInfo = fmt.Sprintf("%d", req.Age)
}
genderInfo := "не указан"
if req.Gender != "" {
genderInfo = req.Gender
}
prompt := fmt.Sprintf(`Ты опытный врач-терапевт, работающий в системе GooSeek. Веди себя как настоящий доктор на приёме.
ПРАВИЛА:
1. Дай ДИФФЕРЕНЦИАЛЬНУЮ оценку — перечисли вероятные состояния с обоснованием, от наиболее вероятного к менее.
2. Для каждого состояния укажи likelihood (low/medium/high) и подробное "why" — почему именно эти симптомы указывают на это.
3. Подбери конкретных специалистов с чёткой причиной направления.
4. НЕ назначай таблетки, дозировки, схемы лечения. Только справочная информация: "для чего применяется" и "при каких состояниях назначают".
5. Дай конкретные рекомендации по домашнему уходу до визита к врачу.
6. Укажи красные флаги — при каких симптомах вызывать скорую немедленно.
7. Задай уточняющие вопросы, которые помогут сузить диф-диагноз.
Симптомы пациента:
%s
%s
Возраст: %s
Пол: %s
Верни строго JSON (без markdown-обёрток):
{
"triageLevel": "low|medium|high|emergency",
"urgentSigns": ["конкретный симптом при котором вызывать 103"],
"possibleConditions": [{"name":"Название", "likelihood":"low|medium|high", "why":"Подробное обоснование на основе симптомов"}],
"recommendedSpecialists": [{"specialty":"Название специальности", "reason":"Почему именно этот врач", "priority":"high|normal"}],
"questionsToClarify": ["Конкретный вопрос пациенту"],
"homeCare": ["Конкретная рекомендация что делать дома до визита"],
"medicationInfo": [{"name":"Название", "forWhat":"При каких состояниях применяется", "notes":"Важные особенности"}],
"supplementInfo": [{"name":"Название", "purpose":"Для чего", "evidence":"low|medium|high", "notes":"Примечания"}],
"procedureInfo": [{"name":"Название обследования/процедуры", "purpose":"Что покажет/зачем", "whenUseful":"В каких случаях назначают"}],
"disclaimer": "..."
}`, req.Symptoms, historyContext, ageInfo, genderInfo)
resp, err := s.llm.GenerateText(ctx, llm.StreamRequest{
Messages: []llm.Message{
{Role: llm.RoleSystem, Content: "Ты опытный врач-диагност. Отвечай на русском. Только валидный JSON. Никаких назначений лекарств и дозировок — только справочная информация."},
{Role: llm.RoleUser, Content: prompt},
},
Options: llm.StreamOptions{
Temperature: 0.3,
MaxTokens: 2800,
},
})
if err != nil {
return buildFallbackAssessment(req.Symptoms), nil
}
jsonBlock := extractJSONBlock(resp)
if jsonBlock == "" {
return buildFallbackAssessment(req.Symptoms), nil
}
var result Assessment
if err := json.Unmarshal([]byte(jsonBlock), &result); err != nil {
return buildFallbackAssessment(req.Symptoms), nil
}
normalizeAssessment(&result)
return &result, nil
}
func normalizeAssessment(a *Assessment) {
if a.TriageLevel == "" {
a.TriageLevel = "medium"
}
if a.Disclaimer == "" {
a.Disclaimer = "Информация носит справочный характер и не заменяет очный осмотр врача."
}
if len(a.RecommendedSpecialists) == 0 {
a.RecommendedSpecialists = []SpecialtyItem{
{Specialty: "Терапевт", Reason: "Первичный очный осмотр и маршрутизация", Priority: "high"},
}
}
}
func (s *Service) searchDoctors(ctx context.Context, specialists []SpecialtyItem, city string) []DoctorOption {
if s.searxngURL == "" {
return fallbackDoctors(specialists, city)
}
unique := make(map[string]struct{})
out := make([]DoctorOption, 0, 9)
for _, sp := range specialists {
if strings.TrimSpace(sp.Specialty) == "" {
continue
}
query := fmt.Sprintf("%s %s запись на прием", sp.Specialty, city)
results, err := s.searchWeb(ctx, query)
if err != nil {
continue
}
for _, r := range results {
key := r.URL + "|" + sp.Specialty
if _, ok := unique[key]; ok {
continue
}
unique[key] = struct{}{}
clinic := extractClinicName(r.Title)
out = append(out, DoctorOption{
ID: uuid.NewString(),
Name: fmt.Sprintf("%s (%s)", sp.Specialty, clinic),
Specialty: sp.Specialty,
Clinic: clinic,
City: city,
SourceURL: r.URL,
SourceName: sourceNameFromURL(r.URL),
Snippet: trimText(r.Content, 220),
})
if len(out) >= 12 {
sortDoctors(out)
return out[:12]
}
}
}
if len(out) == 0 {
return fallbackDoctors(specialists, city)
}
sortDoctors(out)
return out
}
func (s *Service) searchWeb(ctx context.Context, query string) ([]struct {
Title string
URL string
Content string
}, error) {
values := url.Values{}
values.Set("q", query)
values.Set("format", "json")
values.Set("language", "ru-RU")
values.Set("safesearch", "1")
reqURL := s.searxngURL + "/search?" + values.Encode()
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 >= 300 {
return nil, fmt.Errorf("search status %d", resp.StatusCode)
}
var parsed searxResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, err
}
items := make([]struct {
Title string
URL string
Content string
}, 0, len(parsed.Results))
for _, r := range parsed.Results {
if r.URL == "" || r.Title == "" {
continue
}
items = append(items, struct {
Title string
URL string
Content string
}{
Title: r.Title,
URL: r.URL,
Content: r.Content,
})
if len(items) >= 5 {
break
}
}
return items, nil
}
func buildFallbackAssessment(symptoms string) *Assessment {
base := &Assessment{
TriageLevel: "medium",
UrgentSigns: []string{
"резкая боль в груди", "затруднение дыхания", "потеря сознания", "кровотечение",
},
PossibleConditions: []ConditionItem{
{Name: "ОРВИ/вирусная инфекция", Likelihood: "medium", Why: "Часто проявляется общими симптомами и слабостью"},
{Name: "Воспалительный процесс", Likelihood: "low", Why: "Требует очной диагностики и анализа"},
},
RecommendedSpecialists: []SpecialtyItem{
{Specialty: "Терапевт", Reason: "Первичный осмотр и назначение базовой диагностики", Priority: "high"},
},
QuestionsToClarify: []string{
"Когда начались симптомы?",
"Есть ли температура и как меняется в течение дня?",
"Есть ли хронические заболевания и аллергии?",
},
HomeCare: []string{
"Контролируйте температуру и самочувствие каждые 6-8 часов",
"Поддерживайте питьевой режим",
"При ухудшении состояния обращайтесь в неотложную помощь",
},
MedicationInfo: []MedicationInfo{
{Name: "Парацетамол", ForWhat: "Снижение температуры и облегчение боли", Notes: "Только общая справка, дозировку определяет врач"},
},
SupplementInfo: []SupplementInfo{
{Name: "Витамин D", Purpose: "Поддержка общего метаболизма", Evidence: "medium", Notes: "Эффективность зависит от дефицита по анализам"},
},
ProcedureInfo: []ProcedureInfo{
{Name: "Общий анализ крови", Purpose: "Оценка воспалительного ответа", WhenUseful: "При сохраняющихся симптомах более 2-3 дней"},
},
Disclaimer: "Информация носит справочный характер и не заменяет консультацию врача.",
}
lowered := strings.ToLower(symptoms)
if strings.Contains(lowered, "груд") || strings.Contains(lowered, "дыш") || strings.Contains(lowered, "онем") {
base.TriageLevel = "high"
base.RecommendedSpecialists = append(base.RecommendedSpecialists,
SpecialtyItem{Specialty: "Кардиолог", Reason: "Исключение кардиологических причин", Priority: "high"},
)
}
if strings.Contains(lowered, "живот") || strings.Contains(lowered, "тошн") {
base.RecommendedSpecialists = append(base.RecommendedSpecialists,
SpecialtyItem{Specialty: "Гастроэнтеролог", Reason: "Оценка ЖКТ-симптомов", Priority: "normal"},
)
}
return base
}
func fallbackDoctors(specialists []SpecialtyItem, city string) []DoctorOption {
if len(specialists) == 0 {
specialists = []SpecialtyItem{{Specialty: "Терапевт"}}
}
out := make([]DoctorOption, 0, len(specialists))
for i, sp := range specialists {
out = append(out, DoctorOption{
ID: uuid.NewString(),
Name: fmt.Sprintf("%s, приём онлайн/очно", sp.Specialty),
Specialty: sp.Specialty,
Clinic: "Проверенные клиники",
City: city,
SourceURL: fmt.Sprintf("https://yandex.ru/search/?text=%s+%s+запись", url.QueryEscape(sp.Specialty), url.QueryEscape(city)),
SourceName: "yandex",
Snippet: "Подбор по агрегаторам клиник и медицинских центров.",
})
if i >= 5 {
break
}
}
return out
}
func buildBookingLinks(doctors []DoctorOption) []AppointmentOption {
out := make([]AppointmentOption, 0, len(doctors))
for _, d := range doctors {
if d.SourceURL == "" {
continue
}
out = append(out, AppointmentOption{
ID: uuid.NewString(),
DoctorID: d.ID,
Doctor: d.Name,
Specialty: d.Specialty,
Clinic: d.Clinic,
BookURL: d.SourceURL,
Remote: strings.Contains(strings.ToLower(d.Snippet), "онлайн"),
})
}
return out
}
func buildSummaryText(symptoms, city string, assessment *Assessment, doctors []DoctorOption, bookings []AppointmentOption) string {
var b strings.Builder
b.WriteString("### Медицинская навигация\n\n")
triageEmoji := map[string]string{"low": "🟢", "medium": "🟡", "high": "🟠", "emergency": "🔴"}
emoji := triageEmoji[assessment.TriageLevel]
if emoji == "" {
emoji = "🟡"
}
b.WriteString(fmt.Sprintf("%s **Приоритет: %s**\n\n", emoji, strings.ToUpper(assessment.TriageLevel)))
if assessment.TriageLevel == "emergency" || assessment.TriageLevel == "high" {
b.WriteString("⚠️ **Рекомендуется срочное обращение к врачу.**\n\n")
}
if len(assessment.PossibleConditions) > 0 {
b.WriteString("**Вероятные состояния:**\n")
for _, c := range assessment.PossibleConditions {
likelihood := map[string]string{"low": "маловероятно", "medium": "возможно", "high": "вероятно"}
lbl := likelihood[c.Likelihood]
if lbl == "" {
lbl = c.Likelihood
}
b.WriteString(fmt.Sprintf("- **%s** (%s) — %s\n", c.Name, lbl, c.Why))
}
b.WriteString("\n")
}
if len(assessment.RecommendedSpecialists) > 0 {
b.WriteString("**К кому обратиться:**\n")
for _, sp := range assessment.RecommendedSpecialists {
prio := ""
if sp.Priority == "high" {
prio = " ⚡"
}
b.WriteString(fmt.Sprintf("- **%s**%s — %s\n", sp.Specialty, prio, sp.Reason))
}
b.WriteString("\n")
}
if len(assessment.QuestionsToClarify) > 0 {
b.WriteString("**Уточните для более точной оценки:**\n")
for _, q := range assessment.QuestionsToClarify {
b.WriteString(fmt.Sprintf("- %s\n", q))
}
b.WriteString("\n")
}
if len(doctors) > 0 {
b.WriteString(fmt.Sprintf("Найдено **%d** вариантов записи в городе **%s**. ", len(doctors), city))
b.WriteString("Подробности — на панели справа.\n\n")
}
if len(assessment.UrgentSigns) > 0 {
b.WriteString("🚨 **При появлении:** ")
b.WriteString(strings.Join(assessment.UrgentSigns[:min(3, len(assessment.UrgentSigns))], ", "))
b.WriteString(" — **немедленно вызывайте 103/112.**\n\n")
}
b.WriteString("---\n")
b.WriteString("*Информация носит справочный характер. Таблетки и схемы лечения не назначаются.*\n")
return b.String()
}
func extractJSONBlock(text string) string {
if text == "" {
return ""
}
if start := strings.Index(text, "```json"); start >= 0 {
start += len("```json")
if end := strings.Index(text[start:], "```"); end >= 0 {
return strings.TrimSpace(text[start : start+end])
}
}
if start := strings.Index(text, "{"); start >= 0 {
if end := strings.LastIndex(text, "}"); end > start {
return strings.TrimSpace(text[start : end+1])
}
}
return ""
}
func extractClinicName(title string) string {
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return "Клиника"
}
for _, sep := range []string{" - ", " | ", " — "} {
if idx := strings.Index(trimmed, sep); idx > 0 {
return strings.TrimSpace(trimmed[:idx])
}
}
return trimText(trimmed, 56)
}
func sourceNameFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "web"
}
host := strings.TrimPrefix(u.Hostname(), "www.")
if host == "" {
return "web"
}
return host
}
func trimText(v string, max int) string {
r := []rune(strings.TrimSpace(v))
if len(r) <= max {
return string(r)
}
return string(r[:max]) + "..."
}
func sortDoctors(items []DoctorOption) {
sort.SliceStable(items, func(i, j int) bool {
a := strings.ToLower(items[i].SourceName)
b := strings.ToLower(items[j].SourceName)
if a == b {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
}
return a < b
})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -628,6 +628,141 @@ func (s *Service) BuildRouteFromPoints(ctx context.Context, trip *Trip) (*RouteD
return s.openRoute.GetDirections(ctx, points, "driving-car")
}
// ValidateItineraryRequest is the input for itinerary validation.
type ValidateItineraryRequest struct {
Days []ValidateDay `json:"days"`
POIs []ValidatePOI `json:"pois,omitempty"`
Events []ValidateEvent `json:"events,omitempty"`
}
type ValidateDay struct {
Date string `json:"date"`
Items []ValidateItem `json:"items"`
}
type ValidateItem 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"`
}
type ValidatePOI struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Schedule map[string]string `json:"schedule,omitempty"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type ValidateEvent struct {
ID string `json:"id"`
Title string `json:"title"`
DateStart string `json:"dateStart,omitempty"`
DateEnd string `json:"dateEnd,omitempty"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type ValidationWarning struct {
DayIdx int `json:"dayIdx"`
ItemIdx int `json:"itemIdx,omitempty"`
Message string `json:"message"`
}
type ValidateItineraryResponse struct {
Valid bool `json:"valid"`
Warnings []ValidationWarning `json:"warnings"`
Suggestions []ValidationWarning `json:"suggestions"`
}
func (s *Service) ValidateItinerary(ctx context.Context, req ValidateItineraryRequest) (*ValidateItineraryResponse, error) {
if s.llmClient == nil {
return &ValidateItineraryResponse{Valid: true, Warnings: []ValidationWarning{}, Suggestions: []ValidationWarning{}}, nil
}
daysJSON, _ := json.Marshal(req.Days)
poisJSON, _ := json.Marshal(req.POIs)
eventsJSON, _ := json.Marshal(req.Events)
prompt := fmt.Sprintf(`Проверь маршрут путешествия на логистические ошибки и предложи улучшения.
Маршрут по дням: %s
Доступные POI (с расписанием): %s
Доступные события (с датами): %s
Проверь:
1. Логистику: нет ли точек в разных концах города подряд без достаточного времени на переезд
2. Расписание: если POI имеет schedule и стоит в день когда закрыт — это ошибка
3. Даты событий: если событие стоит в день вне его dateStart-dateEnd — это ошибка
4. Реалистичность: не слишком ли много активностей в день (>6 основных)
5. Время: нет ли пересечений по времени
Верни ТОЛЬКО JSON:
{
"valid": true/false,
"warnings": [{"dayIdx": 0, "itemIdx": 2, "message": "причина"}],
"suggestions": [{"dayIdx": 0, "message": "рекомендация"}]
}
Если всё хорошо — warnings пустой массив, valid=true. Suggestions — необязательные рекомендации.`, string(daysJSON), string(poisJSON), string(eventsJSON))
var fullResponse strings.Builder
err := s.llmClient.StreamChat(ctx, []ChatMessage{
{Role: "user", Content: prompt},
}, func(chunk string) {
fullResponse.WriteString(chunk)
})
if err != nil {
return nil, fmt.Errorf("LLM validation failed: %w", err)
}
responseText := fullResponse.String()
jsonStart := strings.Index(responseText, "{")
jsonEnd := strings.LastIndex(responseText, "}")
if jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart {
return &ValidateItineraryResponse{
Valid: false,
Warnings: []ValidationWarning{{
DayIdx: 0,
Message: "Не удалось проверить маршрут — повторите попытку",
}},
Suggestions: []ValidationWarning{},
}, nil
}
var result ValidateItineraryResponse
if err := json.Unmarshal([]byte(responseText[jsonStart:jsonEnd+1]), &result); err != nil {
return &ValidateItineraryResponse{
Valid: false,
Warnings: []ValidationWarning{{
DayIdx: 0,
Message: "Ошибка анализа маршрута — попробуйте ещё раз",
}},
Suggestions: []ValidationWarning{},
}, nil
}
if result.Warnings == nil {
result.Warnings = []ValidationWarning{}
}
if result.Suggestions == nil {
result.Suggestions = []ValidationWarning{}
}
return &result, nil
}
func (s *Service) EnrichTripWithAI(ctx context.Context, trip *Trip) error {
if len(trip.Route) == 0 {
return nil

View File

@@ -46,6 +46,8 @@ type Config struct {
FinanceHeatmapURL string
LearningSvcURL string
TravelSvcURL string
SandboxSvcURL string
MedicineSvcURL string
// TravelPayouts
TravelPayoutsToken string
@@ -57,6 +59,7 @@ type Config struct {
MinioSecretKey string
MinioBucket string
MinioUseSSL bool
MinioPublicURL string
// Auth
JWTSecret string
@@ -130,15 +133,18 @@ func Load() (*Config, error) {
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"),
SandboxSvcURL: getEnv("SANDBOX_SVC_URL", "http://localhost:3036"),
MedicineSvcURL: getEnv("MEDICINE_SVC_URL", "http://localhost:3037"),
TravelPayoutsToken: getEnv("TRAVELPAYOUTS_TOKEN", ""),
TravelPayoutsMarker: getEnv("TRAVELPAYOUTS_MARKER", ""),
MinioEndpoint: getEnv("MINIO_ENDPOINT", "minio:9000"),
MinioAccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
MinioSecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"),
MinioBucket: getEnv("MINIO_BUCKET", "gooseek"),
MinioUseSSL: getEnv("MINIO_USE_SSL", "false") == "true",
MinioEndpoint: getEnv("MINIO_ENDPOINT", getEnv("S3_ENDPOINT", "minio:9000")),
MinioAccessKey: getEnv("MINIO_ACCESS_KEY", getEnv("S3_ACCESS_KEY", "minioadmin")),
MinioSecretKey: getEnv("MINIO_SECRET_KEY", getEnv("S3_SECRET_KEY", "minioadmin")),
MinioBucket: getEnv("MINIO_BUCKET", getEnv("S3_BUCKET", "gooseek")),
MinioUseSSL: getEnv("MINIO_USE_SSL", getEnv("S3_USE_SSL", "false")) == "true",
MinioPublicURL: getEnv("MINIO_PUBLIC_URL", getEnv("S3_PUBLIC_URL", "")),
JWTSecret: getEnv("JWT_SECRET", ""),
AuthSvcURL: getEnv("AUTH_SVC_URL", ""),

View File

@@ -20,11 +20,13 @@ type MinioConfig struct {
SecretKey string
Bucket string
UseSSL bool
PublicURL string
}
type MinioStorage struct {
client *minio.Client
bucket string
client *minio.Client
bucket string
publicURL string
}
type UploadResult struct {
@@ -57,11 +59,23 @@ func NewMinioStorage(cfg MinioConfig) (*MinioStorage, error) {
}
return &MinioStorage{
client: client,
bucket: cfg.Bucket,
client: client,
bucket: cfg.Bucket,
publicURL: strings.TrimRight(cfg.PublicURL, "/"),
}, nil
}
func (s *MinioStorage) GetPublicURL(key string) string {
if s.publicURL != "" {
return s.publicURL + "/" + s.bucket + "/" + key
}
return ""
}
func (s *MinioStorage) Bucket() string {
return s.bucket
}
func (s *MinioStorage) Upload(ctx context.Context, reader io.Reader, size int64, filename, contentType string) (*UploadResult, error) {
ext := filepath.Ext(filename)
key := generateStorageKey(ext)
@@ -83,6 +97,25 @@ func (s *MinioStorage) Upload(ctx context.Context, reader io.Reader, size int64,
}, nil
}
func (s *MinioStorage) UploadWithKey(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*UploadResult, error) {
opts := minio.PutObjectOptions{
ContentType: contentType,
CacheControl: "public, max-age=2592000",
}
info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts)
if err != nil {
return nil, fmt.Errorf("failed to upload file: %w", err)
}
return &UploadResult{
Key: key,
Bucket: s.bucket,
Size: info.Size,
ETag: info.ETag,
}, nil
}
func (s *MinioStorage) UploadUserFile(ctx context.Context, userID string, reader io.Reader, size int64, filename, contentType string) (*UploadResult, error) {
ext := filepath.Ext(filename)
key := fmt.Sprintf("users/%s/%s%s", userID, uuid.New().String(), ext)

View File

@@ -1,16 +0,0 @@
# GooSeek WebUI Configuration
# API Gateway URL (internal Docker network)
API_URL=http://api-gateway:3015
# Public API URL (for browser requests)
NEXT_PUBLIC_API_URL=
# ============================================
# === MENU VISIBILITY ===
# ============================================
# Отключённые маршруты (через запятую)
# Страницы в разработке можно скрыть из меню
# Пример: /travel,/medicine,/finance,/learning
NEXT_PUBLIC_DISABLED_ROUTES=

View File

@@ -15,9 +15,11 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build-time переменные для Next.js (NEXT_PUBLIC_* должны быть доступны во время сборки)
ARG NEXT_PUBLIC_DISABLED_ROUTES
ENV NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES}
ARG NEXT_PUBLIC_ENABLED_ROUTES
ENV NEXT_PUBLIC_ENABLED_ROUTES=${NEXT_PUBLIC_ENABLED_ROUTES}
ARG NEXT_PUBLIC_TWOGIS_API_KEY
ENV NEXT_PUBLIC_TWOGIS_API_KEY=${NEXT_PUBLIC_TWOGIS_API_KEY}
RUN npm run build

View File

@@ -5,6 +5,7 @@ const nextConfig = {
env: {
API_URL: process.env.API_URL || 'http://localhost:3015',
NEXT_PUBLIC_TWOGIS_API_KEY: process.env.NEXT_PUBLIC_TWOGIS_API_KEY || process.env.TWOGIS_API_KEY || '',
NEXT_PUBLIC_ENABLED_ROUTES: process.env.NEXT_PUBLIC_ENABLED_ROUTES || '',
},
};

View File

@@ -9,6 +9,10 @@
"version": "1.0.0",
"dependencies": {
"@2gis/mapgl": "^1.70.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -21,7 +25,7 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.454.0",
"next": "^14.2.26",
"next": "^14.2.35",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
@@ -64,6 +68,59 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
@@ -141,16 +198,39 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@next/env": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.26.tgz",
"integrity": "sha512-vO//GJ/YBco+H7xdQhzJxF7ub3SUwft76jwaeOyVVQFHCi5DCnkP16WHB+JBylo4vOKPoZBlR94Z8xBxNBdNJA==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.26.tgz",
"integrity": "sha512-zDJY8gsKEseGAxG+C2hTMT0w9Nk9N1Sk1qV7vXYz9MEiyRoF5ogQX2+vplyUMIfygnjn9/A04I6yrUTRTuRiyQ==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
@@ -164,9 +244,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.26.tgz",
"integrity": "sha512-U0adH5ryLfmTDkahLwG9sUQG2L0a9rYux8crQeC92rPhi3jGQEY47nByQHrVrt3prZigadwj/2HZ1LUUimuSbg==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
@@ -180,9 +260,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.26.tgz",
"integrity": "sha512-SINMl1I7UhfHGM7SoRiw0AbwnLEMUnJ/3XXVmhyptzriHbWvPPbbm0OEVG24uUKhuS1t0nvN/DBvm5kz6ZIqpg==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
@@ -196,9 +276,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.26.tgz",
"integrity": "sha512-s6JaezoyJK2DxrwHWxLWtJKlqKqTdi/zaYigDXUJ/gmx/72CrzdVZfMvUc6VqnZ7YEvRijvYo+0o4Z9DencduA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
@@ -212,9 +292,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.26.tgz",
"integrity": "sha512-FEXeUQi8/pLr/XI0hKbe0tgbLmHFRhgXOUiPScz2hk0hSmbGiU8aUqVslj/6C6KA38RzXnWoJXo4FMo6aBxjzg==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
@@ -228,9 +308,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.26.tgz",
"integrity": "sha512-BUsomaO4d2DuXhXhgQCVt2jjX4B4/Thts8nDoIruEJkhE5ifeQFtvW5c9JkdOtYvE5p2G0hcwQ0UbRaQmQwaVg==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
@@ -244,9 +324,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.26.tgz",
"integrity": "sha512-5auwsMVzT7wbB2CZXQxDctpWbdEnEW/e66DyXO1DcgHxIyhP06awu+rHKshZE+lPLIGiwtjo7bsyeuubewwxMw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
@@ -260,9 +340,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.26.tgz",
"integrity": "sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
@@ -276,9 +356,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.26.tgz",
"integrity": "sha512-2rdB3T1/Gp7bv1eQTTm9d1Y1sv9UuJ2LAwOE0Pe2prHKe32UNscj7YS13fRB37d0GAiGNR+Y7ZcW8YjDI8Ns0w==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
@@ -1774,6 +1854,14 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2201,6 +2289,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -2641,6 +2739,19 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -3260,6 +3371,17 @@
"node": ">=8.6"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
@@ -3312,13 +3434,12 @@
}
},
"node_modules/next": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.26.tgz",
"integrity": "sha512-b81XSLihMwCfwiUVRRja3LphLo4uBBMZEzBBWMaISbKTwOmq3wPknIETy/8000tr7Gq4WmbuFYPS7jOYIf+ZJw==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.26",
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -3333,15 +3454,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.26",
"@next/swc-darwin-x64": "14.2.26",
"@next/swc-linux-arm64-gnu": "14.2.26",
"@next/swc-linux-arm64-musl": "14.2.26",
"@next/swc-linux-x64-gnu": "14.2.26",
"@next/swc-linux-x64-musl": "14.2.26",
"@next/swc-win32-arm64-msvc": "14.2.26",
"@next/swc-win32-ia32-msvc": "14.2.26",
"@next/swc-win32-x64-msvc": "14.2.26"
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -3967,6 +4088,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",

View File

@@ -10,6 +10,10 @@
},
"dependencies": {
"@2gis/mapgl": "^1.70.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -22,7 +26,7 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.454.0",
"next": "^14.2.26",
"next": "^14.2.35",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",

View File

@@ -100,7 +100,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) {
<label className="block text-sm text-secondary mb-1">Статус</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
onChange={(e) => setStatus(e.target.value as 'draft' | 'published' | 'archived')}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="draft">Черновик</option>

View File

@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
import { Sidebar } from '@/components/Sidebar';
import { ThemeToggle } from '@/components/ThemeToggle';
export default function MainLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -41,30 +42,39 @@ export default function MainLayout({ children }: { children: React.ReactNode })
<Menu className="w-5 h-5" />
</button>
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
<div className="w-10" />
<div className="w-10 flex items-center justify-end">
<ThemeToggle variant="icon" />
</div>
</div>
)}
{/* Desktop Sidebar */}
{!isMobile && <Sidebar />}
{!isMobile && (
<div className="relative z-30">
<Sidebar />
</div>
)}
{/* Mobile Sidebar Overlay */}
<AnimatePresence>
<AnimatePresence mode="wait">
{isMobile && sidebarOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={() => setSidebarOpen(false)}
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm"
style={{ pointerEvents: 'auto' }}
/>
<motion.div
initial={{ x: -240 }}
initial={{ x: -260 }}
animate={{ x: 0 }}
exit={{ x: -240 }}
exit={{ x: -260 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 bottom-0 z-50 w-[240px]"
className="fixed left-0 top-0 bottom-0 z-50 w-[260px]"
style={{ pointerEvents: 'auto' }}
>
<Sidebar onClose={() => setSidebarOpen(false)} />
</motion.div>

View File

@@ -0,0 +1,263 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import {
ArrowLeft,
BookOpen,
CheckCircle2,
Clock,
Loader2,
Play,
Target,
Users,
} from 'lucide-react';
import { enrollInCourse, fetchLearningCourse } from '@/lib/api';
import type { CourseModule, LearningCourse } from '@/lib/types';
const difficultyLabels: Record<string, string> = {
beginner: 'Начинающий',
intermediate: 'Средний',
advanced: 'Продвинутый',
expert: 'Эксперт',
};
export default function LearningCourseLandingPage() {
const params = useParams<{ slug: string }>();
const router = useRouter();
const slug = params?.slug || '';
const [course, setCourse] = useState<LearningCourse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEnrolling, setIsEnrolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const modules = useMemo<CourseModule[]>(() => {
if (!course?.baseOutline?.modules || !Array.isArray(course.baseOutline.modules)) {
return [];
}
return course.baseOutline.modules;
}, [course]);
const loadCourse = useCallback(async () => {
if (!slug) return;
setIsLoading(true);
setError(null);
try {
const data = await fetchLearningCourse(slug);
if (!data) {
setError('Курс не найден');
setCourse(null);
return;
}
setCourse(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось загрузить курс');
} finally {
setIsLoading(false);
}
}, [slug]);
useEffect(() => {
void loadCourse();
}, [loadCourse]);
const handleEnroll = useCallback(async () => {
if (!course) return;
setIsEnrolling(true);
setError(null);
try {
await enrollInCourse(course.id);
router.push('/learning');
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось записаться на курс');
} finally {
setIsEnrolling(false);
}
}, [course, router]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-2 text-muted text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Загрузка курса...
</div>
</div>
);
}
if (!course) {
return (
<div className="h-full flex items-center justify-center px-4">
<div className="max-w-md w-full p-4 rounded-2xl border border-border/40 bg-surface/40 text-center">
<p className="text-sm text-primary font-medium">Курс недоступен</p>
{error && <p className="text-xs text-muted mt-1">{error}</p>}
<button
onClick={() => router.push('/learning')}
className="mt-4 inline-flex items-center gap-1.5 px-3 py-2 text-xs btn-gradient rounded-lg"
>
<ArrowLeft className="w-3.5 h-3.5 btn-gradient-text" />
<span className="btn-gradient-text">Назад к обучению</span>
</button>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-gradient-main">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-5">
<button
onClick={() => router.push('/learning')}
className="inline-flex items-center gap-2 text-xs text-secondary hover:text-primary transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Назад к обучению
</button>
<motion.section
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 rounded-2xl border border-border/40 bg-surface/40"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] px-2 py-0.5 rounded border border-border/40 text-muted">
{difficultyLabels[course.difficulty] || course.difficulty}
</span>
<span className="text-[10px] px-2 py-0.5 rounded border border-border/40 text-muted">
{course.category}
</span>
<span className="inline-flex items-center gap-1 text-[10px] text-faint">
<Clock className="w-3 h-3" />
{course.durationHours}ч
</span>
<span className="inline-flex items-center gap-1 text-[10px] text-faint">
<Users className="w-3 h-3" />
{course.enrolledCount} записей
</span>
</div>
<h1 className="mt-3 text-2xl sm:text-3xl font-bold text-primary">
{course.landing?.hero_title || course.title}
</h1>
<p className="mt-2 text-sm text-secondary max-w-3xl">
{course.landing?.hero_subtitle || course.shortDescription}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
onClick={handleEnroll}
disabled={isEnrolling}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs btn-gradient rounded-lg disabled:opacity-70"
>
{isEnrolling ? (
<Loader2 className="w-3.5 h-3.5 animate-spin btn-gradient-text" />
) : (
<Play className="w-3.5 h-3.5 btn-gradient-text" />
)}
<span className="btn-gradient-text">
{isEnrolling ? 'Записываем...' : 'Записаться и начать'}
</span>
</button>
{course.landing?.salary_range && (
<span className="text-xs px-2.5 py-1 rounded-lg bg-success/10 text-success border border-success/25">
Доход после курса: {course.landing.salary_range}
</span>
)}
</div>
</motion.section>
{error && (
<div className="p-3 rounded-xl border border-error/30 bg-error/10 text-sm text-error">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<section className="lg:col-span-2 p-4 rounded-2xl border border-border/40 bg-surface/40">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-accent" />
<h2 className="text-sm font-medium text-primary">Программа курса</h2>
</div>
<div className="mt-3 space-y-2">
{modules.length > 0 ? (
modules.map((moduleItem, index) => (
<div key={`${moduleItem.title}-${index}`} className="p-3 rounded-xl border border-border/40 bg-elevated/30">
<p className="text-xs text-muted">Модуль {index + 1}</p>
<p className="text-sm font-medium text-primary mt-0.5">{moduleItem.title}</p>
<p className="text-xs text-secondary mt-1">{moduleItem.description}</p>
{moduleItem.practice_focus && (
<div className="mt-2 text-[11px] text-faint inline-flex items-center gap-1.5">
<Target className="w-3 h-3" />
Практика: {moduleItem.practice_focus}
</div>
)}
</div>
))
) : (
<div className="p-3 rounded-xl border border-border/40 bg-elevated/20 text-xs text-muted">
Программа появится после публикации модулей.
</div>
)}
</div>
</section>
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40 space-y-3">
<div>
<h3 className="text-sm font-medium text-primary">Для кого курс</h3>
<p className="text-xs text-secondary mt-1">
{course.landing?.target_audience || 'Разработчики и специалисты, которым нужен практический рост.'}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-primary">Что получите</h3>
<ul className="mt-2 space-y-1">
{(course.landing?.outcomes || []).slice(0, 6).map((item, index) => (
<li key={index} className="text-xs text-secondary flex gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-success mt-0.5" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-medium text-primary">Порог входа</h3>
<p className="text-xs text-secondary mt-1">
{course.landing?.prerequisites || 'Базовые знания программирования и готовность к практике.'}
</p>
</div>
</section>
</div>
{(course.landing?.benefits || []).length > 0 && (
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40">
<h2 className="text-sm font-medium text-primary">Преимущества</h2>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{(course.landing?.benefits || []).map((benefit, index) => (
<div key={index} className="p-3 rounded-xl border border-border/40 bg-elevated/30 text-xs text-secondary">
{benefit}
</div>
))}
</div>
</section>
)}
{(course.landing?.faq || []).length > 0 && (
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40">
<h2 className="text-sm font-medium text-primary">FAQ</h2>
<div className="mt-3 space-y-2">
{(course.landing?.faq || []).map((item, index) => (
<div key={index} className="p-3 rounded-xl border border-border/40 bg-elevated/30">
<p className="text-xs font-medium text-primary">{item.question}</p>
<p className="text-xs text-secondary mt-1">{item.answer}</p>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View File

@@ -1,201 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Target, BookOpen, Brain, Sparkles, Code2, CheckCircle2 } from 'lucide-react';
import { createLesson } from '@/lib/api';
const difficulties = [
{ value: 'beginner', label: 'Начинающий', icon: Target },
{ value: 'intermediate', label: 'Средний', icon: BookOpen },
{ value: 'advanced', label: 'Продвинутый', icon: Brain },
{ value: 'expert', label: 'Эксперт', icon: Sparkles },
];
const modes = [
{ value: 'explain', label: 'Объяснение' },
{ value: 'guided', label: 'С наставником' },
{ value: 'interactive', label: 'Интерактив' },
{ value: 'practice', label: 'Практика' },
{ value: 'quiz', label: 'Тест' },
];
export default function NewLessonPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
topic: '',
difficulty: 'beginner',
mode: 'explain',
includeCode: true,
includeQuiz: true,
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.topic.trim() || isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await createLesson({
topic: formData.topic.trim(),
difficulty: formData.difficulty,
mode: formData.mode,
includeCode: formData.includeCode,
includeQuiz: formData.includeQuiz,
locale: 'ru',
});
router.push('/learning');
} catch (err) {
console.error('Failed to create lesson:', err);
setError('Не удалось создать урок. Попробуйте позже.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="h-full overflow-y-auto">
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6 sm:mb-8">
<Link
href="/learning"
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary">Новый урок</h1>
<p className="text-sm text-secondary mt-0.5 hidden sm:block">Создайте интерактивный урок с AI</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleCreate} className="space-y-5">
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Тема урока <span className="text-error">*</span>
</label>
<input
value={formData.topic}
onChange={(e) => setFormData((f) => ({ ...f, topic: e.target.value }))}
placeholder="Например: Основы Python"
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none input-gradient transition-colors"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Уровень сложности
</label>
<div className="grid grid-cols-2 gap-2">
{difficulties.map((d) => (
<button
key={d.value}
type="button"
onClick={() => setFormData((f) => ({ ...f, difficulty: d.value }))}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
formData.difficulty === d.value
? 'active-gradient text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
}`}
>
<d.icon className={`w-4 h-4 ${formData.difficulty === d.value ? 'text-gradient' : ''}`} />
<span className="text-sm">{d.label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Режим обучения
</label>
<select
value={formData.mode}
onChange={(e) => setFormData((f) => ({ ...f, mode: e.target.value }))}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary focus:outline-none input-gradient transition-colors"
>
{modes.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Дополнительные материалы
</label>
<div className="flex flex-col sm:flex-row gap-3">
<label
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
formData.includeCode
? 'active-gradient text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
}`}
>
<input
type="checkbox"
checked={formData.includeCode}
onChange={(e) => setFormData((f) => ({ ...f, includeCode: e.target.checked }))}
className="sr-only"
/>
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-gradient' : ''}`} />
<span className="text-sm">Примеры кода</span>
</label>
<label
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
formData.includeQuiz
? 'active-gradient text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
}`}
>
<input
type="checkbox"
checked={formData.includeQuiz}
onChange={(e) => setFormData((f) => ({ ...f, includeQuiz: e.target.checked }))}
className="sr-only"
/>
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-gradient' : ''}`} />
<span className="text-sm">Тесты</span>
</label>
</div>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
<Link
href="/learning"
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
>
Отмена
</Link>
<button
type="submit"
disabled={!formData.topic.trim() || isSubmitting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
<span className="btn-gradient-text">Создать урок</span>
</button>
</div>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,298 +1,283 @@
'use client';
import { useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
HeartPulse,
Search,
Pill,
Stethoscope,
FileText,
AlertTriangle,
Clock,
Loader2,
Sparkles,
Activity,
Thermometer,
Brain,
Eye,
Bone,
Wind,
Droplet,
Shield,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangle, ArrowUp, HeartPulse, Loader2, MapPin, Plus, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { useMedicineChat, type MedicineChatMessage, type MedicineWidget } from '@/lib/hooks/useMedicineChat';
import { MedicineWidgetTabs } from '@/components/MedicineWidgetTabs';
interface Symptom {
id: string;
name: string;
icon: React.ElementType;
category: string;
}
interface Article {
id: string;
title: string;
category: string;
readTime: string;
icon: string;
}
const commonSymptoms: Symptom[] = [
{ id: '1', name: 'Головная боль', icon: Brain, category: 'Неврология' },
{ id: '2', name: 'Температура', icon: Thermometer, category: 'Общие' },
{ id: '3', name: 'Боль в горле', icon: Wind, category: 'ЛОР' },
{ id: '4', name: 'Боль в спине', icon: Bone, category: 'Ортопедия' },
{ id: '5', name: 'Проблемы со зрением', icon: Eye, category: 'Офтальмология' },
{ id: '6', name: 'Давление', icon: Activity, category: 'Кардиология' },
{ id: '7', name: 'Аллергия', icon: Droplet, category: 'Аллергология' },
{ id: '8', name: 'Усталость', icon: HeartPulse, category: 'Общие' },
const quickPrompts = [
{ icon: '🤒', text: 'Температура и слабость', query: 'Температура 38.2, слабость, ломота в теле второй день' },
{ icon: '🫀', text: 'Боль в груди', query: 'Периодическая давящая боль в груди и одышка при нагрузке' },
{ icon: '🧠', text: 'Головная боль', query: 'Сильные головные боли вечером, иногда тошнота, светобоязнь' },
{ icon: '🤧', text: 'Кашель/горло', query: 'Кашель, боль в горле, насморк, температура 37.5' },
];
const healthArticles: Article[] = [
{
id: '1',
title: 'Как укрепить иммунитет зимой',
category: 'Профилактика',
readTime: '5 мин',
icon: '🛡️',
},
{
id: '2',
title: 'Правильное питание для сердца',
category: 'Кардиология',
readTime: '7 мин',
icon: '❤️',
},
{
id: '3',
title: 'Упражнения для здоровой спины',
category: 'Ортопедия',
readTime: '6 мин',
icon: '🏃',
},
{
id: '4',
title: 'Как справиться со стрессом',
category: 'Психология',
readTime: '8 мин',
icon: '🧘',
},
];
const quickServices = [
{ 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' },
];
function SymptomButton({ symptom, onClick }: { symptom: Symptom; onClick: () => void }) {
function AssistantMessage({ message }: { message: MedicineChatMessage }) {
return (
<button
onClick={onClick}
className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg hover:border-border hover:bg-surface/70 transition-all text-left"
>
<symptom.icon className="w-4 h-4 text-muted flex-shrink-0" />
<span className="text-sm text-secondary truncate">{symptom.name}</span>
</button>
);
}
function ArticleCard({ article, delay }: { article: Article; delay: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay }}
className="flex items-center gap-3 p-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border transition-all cursor-pointer group"
>
<div className="w-10 h-10 rounded-xl bg-surface/60 flex items-center justify-center text-xl flex-shrink-0">
{article.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
{article.title}
</h3>
<div className="flex items-center gap-2 text-xs text-muted">
<span>{article.category}</span>
<span></span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{article.readTime}
</span>
<div className="max-w-full w-full">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none text-ui">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
</div>
</motion.div>
)}
{message.isStreaming && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span className="text-xs text-muted">Анализ симптомов и подбор специалистов...</span>
</div>
)}
</div>
);
}
export default function MedicinePage() {
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [aiResponse, setAiResponse] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [city, setCity] = useState('Москва');
const [showPanel, setShowPanel] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleAIConsult = useCallback(async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
setAiResponse(null);
await new Promise((resolve) => setTimeout(resolve, 2000));
setAiResponse(
'На основе описанных симптомов рекомендую обратиться к терапевту для первичного осмотра. ' +
'Это может быть связано с несколькими причинами, которые требуют диагностики. ' +
'До визита к врачу: пейте больше жидкости, отдыхайте, избегайте переохлаждения.'
);
setIsLoading(false);
}, [searchQuery]);
const { messages, isLoading, sendMessage, stopGeneration, clearChat } = useMedicineChat();
const hasMessages = messages.length > 0;
const handleSymptomClick = (symptomName: string) => {
setSearchQuery(symptomName);
const allWidgets = useMemo((): MedicineWidget[] => {
const dedup = new Map<string, MedicineWidget>();
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
for (const w of msg.widgets) {
if (w.type.startsWith('medicine_')) dedup.set(w.type, w);
}
}
return Array.from(dedup.values());
}, [messages]);
const handleSend = useCallback(() => {
if (!inputValue.trim() || isLoading) return;
sendMessage(inputValue, city);
setInputValue('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}, [city, inputValue, isLoading, sendMessage]);
const handleInput = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Медицина</h1>
<p className="text-sm text-secondary">AI-помощник по здоровью и медицине</p>
</div>
{/* 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-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-600 mb-1">Важно</p>
<p className="text-xs text-muted">
Информация носит справочный характер и не заменяет консультацию врача.
При серьёзных симптомах обратитесь к специалисту.
</p>
</div>
</div>
{/* AI Search */}
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
<div className="relative mb-4">
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAIConsult()}
placeholder="Опишите симптомы или задайте вопрос о здоровье..."
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
/>
</div>
<button
onClick={handleAIConsult}
disabled={isLoading || !searchQuery.trim()}
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
<div className="relative z-0 flex flex-col h-full bg-gradient-main">
<AnimatePresence mode="wait">
{!hasMessages ? (
<motion.div
key="welcome"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -12 }}
className="flex-1 overflow-y-auto"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
AI анализирует...
</>
) : (
<>
<Search className="w-4 h-4" />
Получить консультацию AI
</>
)}
</button>
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8">
<div className="w-full max-w-2xl">
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl active-gradient flex items-center justify-center">
<HeartPulse className="w-8 h-8" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-primary tracking-tight">
Медицинский <span className="text-gradient">консультант</span>
</h1>
<p className="text-sm text-secondary mt-2 max-w-xl mx-auto">
Опишите симптомы я помогу с диф-оценкой, подберу профильного врача, найду варианты записи и время приёма.
</p>
</motion.div>
{/* AI Response */}
{aiResponse && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-4 bg-surface/50 border border-border/40 rounded-xl"
>
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-gradient" />
<span className="text-xs font-medium text-gradient">Рекомендация AI</span>
<div className="mb-4 p-3 bg-amber-500/5 border border-amber-500/20 rounded-xl">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-muted">
Информация носит справочный характер. Таблетки и дозировки не назначаются. При сильной боли, одышке, потере сознания 103/112.
</p>
</div>
</div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
<div className="px-4 pt-4">
<label className="text-ui-sm text-muted flex items-center gap-1.5 mb-2">
<MapPin className="w-3.5 h-3.5" />
Город для записи
</label>
<input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Москва"
className="w-full h-10 px-3 bg-surface/40 border border-border/40 rounded-lg text-sm text-primary"
/>
</div>
<div className="flex items-end gap-3 p-4">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder="Опишите симптомы и жалобы..."
className="flex-1 bg-transparent text-base text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
rows={1}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim() ? 'btn-gradient text-accent' : 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-5 h-5" />
</button>
</div>
</div>
</motion.div>
<div className="mt-6 grid grid-cols-2 gap-2">
{quickPrompts.map((prompt, i) => (
<motion.button
key={prompt.text}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + i * 0.05 }}
onClick={() => sendMessage(prompt.query, city)}
className="flex items-center gap-2 px-4 py-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border hover:bg-elevated/60 transition-all text-left"
>
<span className="text-xl">{prompt.icon}</span>
<span className="text-sm text-secondary">{prompt.text}</span>
</motion.button>
))}
</div>
</div>
<p className="text-sm text-secondary leading-relaxed">{aiResponse}</p>
</motion.div>
)}
</div>
{/* Quick Services */}
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
{quickServices.map((service) => (
<button
key={service.label}
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${service.color}`}
>
<service.icon className="w-5 h-5 sm:w-6 sm:h-6" />
<span className="text-[10px] sm:text-xs font-medium text-center leading-tight">
{service.label}
</span>
</button>
))}
</div>
{/* Common Symptoms */}
<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-600" />
</div>
<h2 className="text-sm font-medium text-primary">Частые симптомы</h2>
</div>
</motion.div>
) : (
<motion.div key="chat" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col h-full">
<div className="flex-1 flex overflow-hidden">
<div className="flex flex-col w-full lg:w-[48%] min-w-0">
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-4">
<div className="space-y-4">
{messages.map((message, i) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'user' ? (
<div className="max-w-[85%] px-3 py-2 bg-accent/20 border border-accent/30 rounded-xl rounded-br-md">
<p className="text-ui text-primary whitespace-pre-wrap">{message.content}</p>
</div>
) : (
<AssistantMessage message={message} />
)}
</motion.div>
))}
<div ref={messagesEndRef} />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{commonSymptoms.map((symptom) => (
<SymptomButton
key={symptom.id}
symptom={symptom}
onClick={() => handleSymptomClick(symptom.name)}
/>
))}
</div>
</div>
{(allWidgets.length > 0 || isLoading) && showPanel && (
<div className="lg:hidden border-t border-border/30 bg-base/80 backdrop-blur-sm h-[46vh] min-h-[260px] max-h-[560px]">
<MedicineWidgetTabs widgets={allWidgets} isLoading={isLoading} />
</div>
)}
{/* Health Articles */}
<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-600" />
<div className="px-3 sm:px-4 pb-3 pt-2 bg-base/95">
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
<button
onClick={() => setShowPanel((v) => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
showPanel ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
}`}
>
Панель врача
</button>
<button
onClick={() => {
clearChat();
setInputValue('');
}}
className="flex items-center gap-1.5 px-2.5 py-1 text-ui-sm bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" />
Новый
</button>
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-xl overflow-hidden">
<div className="flex items-end gap-2 p-3">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Уточните симптомы или попросите запись к врачу..."
className="flex-1 bg-transparent text-sm text-primary resize-none focus:outline-none min-h-[24px] max-h-[100px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={stopGeneration}
className="w-9 h-9 flex items-center justify-center rounded-lg bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
inputValue.trim() ? 'btn-gradient text-accent' : 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
</div>
<div className="hidden lg:block w-[52%] border-l border-border/30">
<div className="h-full p-3">
{allWidgets.length > 0 || isLoading ? (
<MedicineWidgetTabs widgets={allWidgets} isLoading={isLoading} />
) : (
<div className="h-full rounded-xl border border-border/40 bg-surface/20 flex flex-col items-center justify-center gap-3 p-4">
<HeartPulse className="w-8 h-8 text-muted/30" />
<p className="text-sm text-muted text-center">Панель диагностики появится<br />после первого ответа</p>
</div>
)}
</div>
</div>
</div>
<h2 className="text-sm font-medium text-primary">Полезные статьи</h2>
</div>
<div className="space-y-2">
{healthArticles.map((article, i) => (
<ArticleCard key={article.id} article={article} delay={i * 0.05} />
))}
</div>
</div>
{/* 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-600" />
<span className="text-sm font-medium text-red-600">Экстренная помощь</span>
</div>
<p className="text-sm text-muted mb-3">
При угрозе жизни немедленно вызывайте скорую помощь
</p>
<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-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-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
📞 112
</a>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -240,7 +240,7 @@ export default function SpacesPage() {
{member.avatar ? (
<img src={member.avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-[10px] font-medium text-secondary">
<span className="text-2xs font-medium text-secondary">
{(member.name || member.email || '?').charAt(0).toUpperCase()}
</span>
)}
@@ -248,7 +248,7 @@ export default function SpacesPage() {
))}
{(space.memberCount || 0) > 4 && (
<div className="w-7 h-7 rounded-full bg-surface border-2 border-base flex items-center justify-center">
<span className="text-[10px] font-medium text-muted">
<span className="text-2xs font-medium text-muted">
+{(space.memberCount || 0) - 4}
</span>
</div>

View File

@@ -6,19 +6,22 @@ import {
Plane,
MapPin,
Calendar,
Sparkles,
Loader2,
X,
ArrowUp,
Bookmark,
Map as MapIcon,
Plus,
ChevronUp,
Columns2,
PanelBottom,
} from 'lucide-react';
import { useTravelChat, type TravelChatMessage } from '@/lib/hooks/useTravelChat';
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
import { useTravelChat, type TravelChatMessage, type TravelWidget } from '@/lib/hooks/useTravelChat';
import { TravelWidgetTabs } from '@/components/TravelWidgetTabs';
import { TravelMap } from '@/components/TravelMap';
import { fetchTrips, createTrip, deleteTrip } from '@/lib/api';
import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption } from '@/lib/types';
import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption, ItineraryDay } from '@/lib/types';
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
import ReactMarkdown from 'react-markdown';
const quickPrompts = [
@@ -80,86 +83,28 @@ function TripCard({ trip, onClick, onDelete }: TripCardProps) {
);
}
interface AssistantMessageProps {
message: TravelChatMessage;
onAddEventToMap: (event: EventCard) => void;
onAddPOIToMap: (poi: POICard) => void;
onSelectHotel: (hotel: HotelCard) => void;
onSelectTransport: (option: TransportOption) => void;
onClarifyingAnswer: (field: string, value: string) => void;
onAction: (kind: string) => void;
selectedEventIds: Set<string>;
selectedPOIIds: Set<string>;
selectedHotelId?: string;
selectedTransportId?: string;
}
function AssistantMessage({
message,
onAddEventToMap,
onAddPOIToMap,
onSelectHotel,
onSelectTransport,
onClarifyingAnswer,
onAction,
selectedEventIds,
selectedPOIIds,
selectedHotelId,
selectedTransportId,
}: AssistantMessageProps) {
const travelWidgets = useMemo(
() => message.widgets.filter((w) => w.type.startsWith('travel_')),
[message.widgets]
);
function AssistantMessage({ message }: { message: TravelChatMessage }) {
return (
<div className="max-w-full w-full">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg active-gradient flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{message.isStreaming && !message.content && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-muted">Планирую маршрут...</span>
</div>
)}
</div>
<div className="min-w-0">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none text-ui">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{message.isStreaming && !message.content && (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span className="text-xs text-muted">Планирую маршрут...</span>
</div>
)}
{message.isStreaming && message.content && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span className="text-xs text-muted">Собираю данные...</span>
</div>
)}
</div>
{travelWidgets.length > 0 && (
<div className="mt-4 ml-11 space-y-3">
{travelWidgets.map((widget) => (
<TravelWidgetRenderer
key={widget.id}
widget={widget}
onAddEventToMap={onAddEventToMap}
onAddPOIToMap={onAddPOIToMap}
onSelectHotel={onSelectHotel}
onSelectTransport={onSelectTransport}
onClarifyingAnswer={onClarifyingAnswer}
onAction={onAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
/>
))}
</div>
)}
{message.isStreaming && message.content && (
<div className="flex items-center gap-2 mt-3 ml-11">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-muted">Собираю данные...</span>
</div>
)}
</div>
);
}
@@ -175,10 +120,6 @@ export default function TravelPage() {
budget: 0,
});
const [showOptions, setShowOptions] = useState(false);
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
const [selectedHotelId, setSelectedHotelId] = useState<string>();
const [selectedTransportId, setSelectedTransportId] = useState<string>();
const [userLocation, setUserLocation] = useState<GeoLocation | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -202,24 +143,67 @@ export default function TravelPage() {
messages,
isLoading,
isResearching,
loadingPhase,
currentRoute,
routeDirection,
routeSegments,
pois: availablePois,
events: availableEvents,
selectedEventIds,
selectedPOIIds,
selectedHotelId,
selectedTransportId,
sendMessage,
stopGeneration,
clearChat,
addRoutePoint,
answerClarifying,
handleAction,
addEventToRoute,
addPOIToRoute,
selectHotelOnRoute,
toggleEventSelection,
togglePOISelection,
toggleHotelSelection,
toggleTransportSelection,
} = useTravelChat({
onRouteUpdate: (route) => {
if (route.length > 0) setShowMap(true);
},
});
const handleItineraryUpdate = useCallback((days: ItineraryDay[]) => {
sendMessage(
`_clarify:Пользователь отредактировал маршрут. Вот обновлённый маршрут:\n${JSON.stringify(days, null, 2)}\n\ожалуйста, пересчитай бюджет и обнови маршрут.`,
);
}, [sendMessage]);
const handleValidateItineraryWithLLM = useCallback(
async (days: ItineraryDay[]): Promise<LLMValidationResponse | null> => {
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/travel/validate-itinerary`, {
method: 'POST',
headers,
body: JSON.stringify({
days,
pois: availablePois,
events: availableEvents,
}),
});
if (!response.ok) {
return null;
}
return await response.json() as LLMValidationResponse;
} catch {
return null;
}
},
[availablePois, availableEvents],
);
useEffect(() => {
loadTrips();
}, []);
@@ -264,42 +248,20 @@ export default function TravelPage() {
}, [sendMessage, planOptions, userLocation]);
const handleAddEventToMap = useCallback((event: EventCard) => {
setSelectedEventIds((prev) => {
const next = new Set(prev);
if (next.has(event.id)) {
next.delete(event.id);
} else {
next.add(event.id);
addEventToRoute(event);
}
return next;
});
}, [addEventToRoute]);
toggleEventSelection(event);
}, [toggleEventSelection]);
const handleAddPOIToMap = useCallback((poi: POICard) => {
setSelectedPOIIds((prev) => {
const next = new Set(prev);
if (next.has(poi.id)) {
next.delete(poi.id);
} else {
next.add(poi.id);
addPOIToRoute(poi);
}
return next;
});
}, [addPOIToRoute]);
togglePOISelection(poi);
}, [togglePOISelection]);
const handleSelectHotel = useCallback((hotel: HotelCard) => {
setSelectedHotelId((prev) => {
const newId = prev === hotel.id ? undefined : hotel.id;
if (newId) selectHotelOnRoute(hotel);
return newId;
});
}, [selectHotelOnRoute]);
toggleHotelSelection(hotel);
}, [toggleHotelSelection]);
const handleSelectTransport = useCallback((option: TransportOption) => {
setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id));
}, []);
toggleTransportSelection(option);
}, [toggleTransportSelection]);
const handleMapClick = useCallback((location: GeoLocation) => {
const point: RoutePoint = {
@@ -367,8 +329,35 @@ export default function TravelPage() {
const hasMessages = messages.length > 0;
const allWidgets = useMemo((): TravelWidget[] => {
const widgetMap = new Map<string, TravelWidget>();
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
for (const w of msg.widgets) {
if (w.type.startsWith('travel_')) {
widgetMap.set(w.type, w);
}
}
}
return Array.from(widgetMap.values());
}, [messages]);
const hasTravelWidgets = allWidgets.length > 0;
const [showWidgetPanel, setShowWidgetPanel] = useState(true);
const [isDesktop, setIsDesktop] = useState(false);
const [rightPanelMode, setRightPanelMode] = useState<'map' | 'split' | 'panel'>('map');
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(min-width: 1024px)');
const apply = () => setIsDesktop(mq.matches);
apply();
mq.addEventListener?.('change', apply);
return () => mq.removeEventListener?.('change', apply);
}, []);
return (
<div className="flex flex-col h-full bg-gradient-main">
<div className="relative z-0 flex flex-col h-full bg-gradient-main">
<AnimatePresence mode="wait">
{!hasMessages ? (
<motion.div
@@ -411,7 +400,7 @@ export default function TravelPage() {
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Опишите своё идеальное путешествие..."
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
className="flex-1 bg-transparent text-base text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
rows={1}
autoFocus
/>
@@ -563,44 +552,23 @@ export default function TravelPage() {
className="flex flex-col h-full"
>
<div className="flex-1 flex overflow-hidden">
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-1/2' : 'w-full'}`}>
{isResearching && (
<div className="px-4 py-2 bg-accent/5 border-b border-accent/20">
<div className="flex items-center gap-2 max-w-3xl mx-auto">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-accent">Исследую маршруты, мероприятия и отели...</span>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6">
<div className="max-w-3xl mx-auto space-y-6">
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-[45%]' : 'w-full'}`}>
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-4">
<div className="space-y-4">
{messages.map((message, i) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 12 }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'user' ? (
<div className="max-w-[85%] px-4 py-3 bg-accent/20 border border-accent/30 rounded-2xl rounded-br-md">
<p className="text-sm text-primary whitespace-pre-wrap">{message.content}</p>
<div className="max-w-[85%] px-3 py-2 bg-accent/20 border border-accent/30 rounded-xl rounded-br-md">
<p className="text-ui text-primary whitespace-pre-wrap">{message.content}</p>
</div>
) : (
<AssistantMessage
message={message}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
/>
<AssistantMessage message={message} />
)}
</motion.div>
))}
@@ -608,92 +576,139 @@ export default function TravelPage() {
</div>
</div>
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-3">
{currentRoute.length > 0 && (
<button
onClick={handleSaveTrip}
disabled={saveStatus === 'saving'}
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
saveStatus === 'saved'
? 'bg-green-500/20 text-green-600'
: saveStatus === 'error'
? 'bg-error/20 text-error'
: 'bg-accent/20 text-accent hover:bg-accent/30'
}`}
>
{saveStatus === 'saving' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Bookmark className="w-3.5 h-3.5" />
)}
{saveStatus === 'saved' ? 'Сохранено!' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить поездку'}
</button>
)}
{/* Mobile: keep widgets under chat */}
{hasTravelWidgets && showWidgetPanel && (
<div className="lg:hidden border-t border-border/30 bg-base/80 backdrop-blur-sm">
<div className="h-[45vh] min-h-[260px] max-h-[520px]">
<TravelWidgetTabs
widgets={allWidgets}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
availablePois={availablePois}
availableEvents={availableEvents}
onItineraryUpdate={handleItineraryUpdate}
onValidateItineraryWithLLM={handleValidateItineraryWithLLM}
isLoading={isLoading}
loadingPhase={loadingPhase}
isResearching={isResearching}
routePointCount={currentRoute.length}
hasRouteDirection={Boolean(routeDirection)}
/>
</div>
</div>
)}
<div className="px-3 sm:px-4 pb-3 pt-2 bg-base/95">
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
{currentRoute.length > 0 && (
<button
onClick={() => setShowMap(!showMap)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
onClick={handleSaveTrip}
disabled={saveStatus === 'saving'}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
saveStatus === 'saved'
? 'bg-green-500/20 text-green-600'
: saveStatus === 'error'
? 'bg-error/20 text-error'
: 'bg-accent/20 text-accent hover:bg-accent/30'
}`}
>
<MapIcon className="w-3.5 h-3.5" />
{showMap ? 'Скрыть карту' : 'Показать карту'}
{currentRoute.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-accent/30 rounded text-[10px]">
{currentRoute.length}
</span>
{saveStatus === 'saving' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Bookmark className="w-3 h-3" />
)}
{saveStatus === 'saved' ? 'Сохранено' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить'}
</button>
)}
<button
onClick={() => setShowMap(!showMap)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
}`}
>
<MapIcon className="w-3 h-3" />
Карта
{currentRoute.length > 0 && (
<span className="px-1 py-0.5 bg-accent/30 rounded text-3xs">
{currentRoute.length}
</span>
)}
</button>
{hasTravelWidgets && (
<button
onClick={() => {
clearChat();
setInputValue('');
setSaveStatus('idle');
if (isDesktop) {
setRightPanelMode((prev) => (prev === 'map' ? 'split' : prev === 'split' ? 'panel' : 'map'));
} else {
setShowWidgetPanel(!showWidgetPanel);
}
}}
className="flex items-center gap-2 px-3 py-1.5 text-xs bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
(isDesktop ? rightPanelMode !== 'map' : showWidgetPanel)
? 'bg-accent/20 text-accent'
: 'bg-surface/50 text-secondary'
}`}
>
<Plus className="w-3.5 h-3.5" />
Новый план
<Calendar className="w-3 h-3" />
Панель
</button>
</div>
)}
<button
onClick={() => {
clearChat();
setInputValue('');
setSaveStatus('idle');
}}
className="flex items-center gap-1.5 px-2.5 py-1 text-ui-sm bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" />
Новый
</button>
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
<div className="flex items-end gap-3 p-4">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Уточните детали или задайте вопрос..."
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={stopGeneration}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-5 h-5" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim()
? 'btn-gradient text-accent'
: 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-5 h-5" />
</button>
)}
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-xl overflow-hidden">
<div className="flex items-end gap-2 p-3">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Уточните детали или задайте вопрос..."
className="flex-1 bg-transparent text-sm text-primary resize-none focus:outline-none min-h-[24px] max-h-[100px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={stopGeneration}
className="w-9 h-9 flex items-center justify-center rounded-lg bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
inputValue.trim()
? 'btn-gradient text-accent'
: 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
@@ -701,16 +716,109 @@ export default function TravelPage() {
<div
className={`hidden lg:block border-l border-border/30 transition-all duration-300 ${
showMap ? 'w-1/2 opacity-100' : 'w-0 opacity-0 overflow-hidden'
showMap ? 'w-[55%] opacity-100' : 'w-0 opacity-0 overflow-hidden'
}`}
>
<TravelMap
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-full"
userLocation={userLocation}
/>
<div className="relative h-full">
<TravelMap
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-full"
userLocation={userLocation}
/>
{/* Desktop: widgets drawer over the map (3 modes) */}
{hasTravelWidgets && (
<>
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 bg-base border border-border/50 rounded-xl p-1 shadow-sm">
<button
type="button"
onClick={() => setRightPanelMode('map')}
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
rightPanelMode === 'map' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
}`}
title="Карта"
aria-label="Карта"
>
<MapIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setRightPanelMode('split')}
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
rightPanelMode === 'split' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
}`}
title="Карта + панель"
aria-label="Карта + панель"
>
<Columns2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setRightPanelMode('panel')}
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
rightPanelMode === 'panel' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
}`}
title="Панель"
aria-label="Панель"
>
<PanelBottom className="w-3.5 h-3.5" />
</button>
</div>
{rightPanelMode !== 'map' && (
<div
className={`absolute left-3 right-3 bottom-3 z-10 transition-all duration-200 ${
rightPanelMode === 'panel' ? 'top-3' : ''
}`}
>
<div
className={`h-full flex flex-col ${
rightPanelMode === 'split' ? 'h-[42%] min-h-[260px] max-h-[520px]' : ''
}`}
>
<button
type="button"
onClick={() => setRightPanelMode((m) => (m === 'split' ? 'panel' : 'split'))}
className="mb-2 w-full flex items-center justify-between px-3 py-2 text-xs rounded-xl bg-base border border-border/50 hover:border-accent/30 transition-colors shadow-sm"
aria-label="Изменить размер панели"
title="Изменить размер панели"
>
<span className="text-secondary">Панель этапов</span>
<ChevronUp className={`w-4 h-4 text-muted transition-transform ${rightPanelMode === 'panel' ? '' : 'rotate-180'}`} />
</button>
<div className="flex-1 min-h-0">
<TravelWidgetTabs
widgets={allWidgets}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
availablePois={availablePois}
availableEvents={availableEvents}
onItineraryUpdate={handleItineraryUpdate}
onValidateItineraryWithLLM={handleValidateItineraryWithLLM}
isLoading={isLoading}
loadingPhase={loadingPhase}
isResearching={isResearching}
routePointCount={currentRoute.length}
hasRouteDirection={Boolean(routeDirection)}
/>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
@@ -720,7 +828,7 @@ export default function TravelPage() {
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-[300px]"
className="h-[250px]"
userLocation={userLocation}
/>
</div>

View File

@@ -6,42 +6,36 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* 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 — 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 — slate-blue, professional */
--accent: 224 64% 48%;
--accent-hover: 224 64% 42%;
--accent-muted: 224 45% 58%;
--accent-subtle: 224 30% 90%;
/* Secondary accent — teal for variety */
--accent-secondary: 180 50% 38%;
--accent-secondary-muted: 180 35% 52%;
/* Semantic colors */
--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: 220 12% 86%;
--border-hover: 220 12% 78%;
--border-focus: 224 64% 48%;
/* GitHub Light (approx. 1:1 tokens) */
--bg-base: 210 29% 97%; /* #f6f8fa */
--bg-elevated: 0 0% 100%; /* #ffffff */
--bg-surface: 210 29% 97%; /* #f6f8fa */
--bg-overlay: 0 0% 100%; /* #ffffff */
--bg-muted: 210 22% 93%; /* ~#eef1f4 */
--text-primary: 215 19% 17%; /* #24292f */
--text-secondary: 215 14% 33%; /* #57606a */
--text-muted: 215 10% 45%; /* #6e7781 */
--text-faint: 214 10% 60%; /* #8c959f */
--accent: 213 92% 44%; /* #0969da */
--accent-hover: 213 95% 36%; /* ~#0550ae */
--accent-muted: 212 92% 64%; /* ~#54aeff */
--accent-subtle: 199 100% 92%;/* ~#ddf4ff */
--accent-secondary: 210 69% 38%;
--accent-secondary-muted: 210 60% 52%;
--success: 140 64% 30%; /* #1a7f37 */
--success-muted: 140 48% 38%;
--warning: 36 100% 30%; /* ~#9a6700 */
--warning-muted: 36 85% 38%;
--error: 355 74% 47%; /* #cf222e */
--error-muted: 355 60% 55%;
--border: 210 14% 84%; /* #d0d7de */
--border-hover: 213 10% 72%; /* #afb8c1 */
--border-focus: 213 92% 44%;
/* Legacy mappings for compatibility */
--background: var(--bg-base);
@@ -75,16 +69,60 @@ html {
color-scheme: light;
}
html.theme-dim {
color-scheme: dark;
/* GitHub Dark Dimmed (approx. 1:1 tokens) */
--bg-base: 210 13% 15%; /* #22272e */
--bg-elevated: 210 12% 20%; /* #2d333b */
--bg-surface: 210 12% 20%; /* #2d333b */
--bg-overlay: 210 10% 25%; /* #373e47 */
--bg-muted: 210 15% 13%; /* #1c2128 */
--text-primary: 210 17% 73%; /* #adbac7 */
--text-secondary: 211 13% 49%; /* #768390 */
--text-muted: 212 11% 41%; /* ~#636e7b */
--text-faint: 213 11% 33%; /* ~#545d68 */
--accent: 212 89% 64%; /* #539bf5 */
--accent-hover: 212 74% 58%; /* ~#4184e4 */
--accent-muted: 212 82% 70%;
--accent-subtle: 214 53% 20%; /* ~#143d79 */
--accent-secondary: 199 80% 55%;
--accent-secondary-muted: 199 70% 62%;
--success: 135 52% 48%; /* ~#57ab5a */
--success-muted: 135 42% 56%;
--warning: 39 75% 56%; /* ~#c69026 */
--warning-muted: 39 65% 62%;
--error: 0 75% 58%; /* ~#e5534b */
--error-muted: 0 65% 64%;
--border: 212 12% 30%; /* #444c56 */
--border-hover: 213 11% 36%; /* #545d68 */
--border-focus: 212 89% 64%;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: hsl(var(--bg-base));
color: hsl(var(--text-primary));
font-size: 0.9375rem; /* match Tailwind text-base override (15px) */
line-height: 1.5rem;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
button,
input,
textarea,
select {
font: inherit;
}
/* ========================
Gradient backgrounds
======================== */
@@ -92,35 +130,35 @@ body {
.bg-gradient-main {
background: linear-gradient(
160deg,
hsl(220 16% 96%) 0%,
hsl(220 14% 94%) 30%,
hsl(225 14% 92%) 60%,
hsl(220 12% 95%) 100%
hsl(var(--bg-base)) 0%,
hsl(var(--bg-surface)) 30%,
hsl(var(--bg-muted)) 60%,
hsl(var(--bg-base)) 100%
);
}
.bg-gradient-elevated {
background: linear-gradient(
145deg,
hsl(0 0% 100%) 0%,
hsl(220 14% 97%) 100%
hsl(var(--bg-elevated)) 0%,
hsl(var(--bg-base)) 100%
);
}
.bg-gradient-card {
background: linear-gradient(
145deg,
hsl(0 0% 100% / 0.9) 0%,
hsl(220 14% 96% / 0.7) 100%
hsl(var(--bg-elevated) / 0.92) 0%,
hsl(var(--bg-base) / 0.72) 100%
);
}
.bg-gradient-accent {
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(224 64% 48%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 50%,
hsl(var(--accent)) 100%
);
}
@@ -131,9 +169,9 @@ body {
.text-gradient {
background: linear-gradient(
135deg,
hsl(224 64% 42%) 0%,
hsl(240 55% 48%) 50%,
hsl(180 50% 38%) 100%
hsl(var(--accent-hover)) 0%,
hsl(var(--accent)) 55%,
hsl(var(--accent-secondary)) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@@ -152,7 +190,7 @@ body {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(224 64% 42%);
color: hsl(var(--accent-hover));
transition: all 0.15s ease;
z-index: 1;
}
@@ -165,9 +203,9 @@ body {
padding: 1.5px;
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--accent-secondary)) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -180,9 +218,9 @@ body {
.btn-gradient:hover {
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.08) 0%,
hsl(240 55% 52% / 0.06) 50%,
hsl(180 50% 42% / 0.05) 100%
hsl(var(--accent) / 0.08) 0%,
hsl(var(--accent) / 0.06) 55%,
hsl(var(--accent-secondary) / 0.05) 100%
);
}
@@ -198,7 +236,7 @@ body {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(224 64% 42%);
color: hsl(var(--accent-hover));
transition: all 0.15s ease;
z-index: 1;
}
@@ -211,9 +249,9 @@ body {
padding: 2px;
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--accent-secondary)) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -226,18 +264,18 @@ body {
.btn-gradient-lg:hover {
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.08) 0%,
hsl(240 55% 52% / 0.06) 50%,
hsl(180 50% 42% / 0.05) 100%
hsl(var(--accent) / 0.08) 0%,
hsl(var(--accent) / 0.06) 55%,
hsl(var(--accent-secondary) / 0.05) 100%
);
}
.btn-gradient-text {
background: linear-gradient(
135deg,
hsl(224 64% 42%) 0%,
hsl(240 55% 48%) 50%,
hsl(180 50% 38%) 100%
hsl(var(--accent-hover)) 0%,
hsl(var(--accent)) 55%,
hsl(var(--accent-secondary)) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@@ -252,9 +290,9 @@ body {
position: relative;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.08) 0%,
hsl(240 55% 52% / 0.06) 50%,
hsl(180 50% 42% / 0.04) 100%
hsl(var(--accent) / 0.08) 0%,
hsl(var(--accent) / 0.06) 55%,
hsl(var(--accent-secondary) / 0.04) 100%
);
}
@@ -266,9 +304,9 @@ body {
padding: 1px;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.3) 0%,
hsl(240 55% 52% / 0.2) 50%,
hsl(180 50% 42% / 0.15) 100%
hsl(var(--accent) / 0.3) 0%,
hsl(var(--accent) / 0.2) 55%,
hsl(var(--accent-secondary) / 0.15) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -290,9 +328,9 @@ body {
padding: 1px;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.2) 0%,
hsl(240 55% 52% / 0.15) 50%,
hsl(180 50% 42% / 0.1) 100%
hsl(var(--accent) / 0.2) 0%,
hsl(var(--accent) / 0.15) 55%,
hsl(var(--accent-secondary) / 0.1) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -312,9 +350,9 @@ body {
justify-content: center;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.1) 0%,
hsl(240 55% 52% / 0.07) 50%,
hsl(180 50% 42% / 0.05) 100%
hsl(var(--accent) / 0.1) 0%,
hsl(var(--accent) / 0.07) 55%,
hsl(var(--accent-secondary) / 0.05) 100%
);
position: relative;
}
@@ -327,9 +365,9 @@ body {
padding: 1px;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.2) 0%,
hsl(240 55% 52% / 0.15) 50%,
hsl(180 50% 42% / 0.1) 100%
hsl(var(--accent) / 0.2) 0%,
hsl(var(--accent) / 0.15) 55%,
hsl(var(--accent-secondary) / 0.1) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -346,8 +384,8 @@ body {
.input-gradient:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 1px hsl(224 64% 48% / 0.35),
0 0 0 3px hsl(224 64% 48% / 0.08);
box-shadow: 0 0 0 1px hsl(var(--accent) / 0.35),
0 0 0 3px hsl(var(--accent) / 0.08);
}
/* ========================
@@ -355,36 +393,36 @@ body {
======================== */
.loader-gradient {
color: hsl(224 64% 48%);
filter: drop-shadow(0 0 6px hsl(224 64% 48% / 0.2));
color: hsl(var(--accent));
filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.2));
}
.progress-gradient {
background: linear-gradient(
90deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 55%,
hsl(var(--accent-secondary)) 100%
);
}
.stat-gradient {
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.06) 0%,
hsl(240 55% 52% / 0.03) 100%
hsl(var(--accent) / 0.06) 0%,
hsl(var(--accent) / 0.03) 100%
);
border: 1px solid;
border-image: linear-gradient(
135deg,
hsl(224 64% 48% / 0.18) 0%,
hsl(180 50% 42% / 0.1) 100%
hsl(var(--accent) / 0.18) 0%,
hsl(var(--accent-secondary) / 0.1) 100%
) 1;
}
.glow-gradient {
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.1),
0 1px 4px hsl(220 14% 50% / 0.08);
box-shadow: 0 4px 16px hsl(var(--accent) / 0.1),
0 1px 4px hsl(var(--text-muted) / 0.08);
}
/* ========================
@@ -405,9 +443,9 @@ body {
height: 60%;
background: linear-gradient(
180deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent)) 55%,
hsl(var(--accent-secondary)) 100%
);
border-radius: 1px;
}
@@ -426,12 +464,12 @@ body {
}
::-webkit-scrollbar-thumb {
background: hsl(220 10% 78%);
background: hsl(var(--border));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(220 10% 66%);
background: hsl(var(--border-hover));
}
.scrollbar-hide {
@@ -445,7 +483,7 @@ body {
.poi-carousel {
scrollbar-width: thin;
scrollbar-color: hsl(220 10% 78% / 0.4) transparent;
scrollbar-color: hsl(var(--border) / 0.5) transparent;
}
.poi-carousel::-webkit-scrollbar {
@@ -457,17 +495,17 @@ body {
}
.poi-carousel::-webkit-scrollbar-thumb {
background: hsl(220 10% 78% / 0.4);
background: hsl(var(--border) / 0.5);
border-radius: 2px;
}
.poi-carousel::-webkit-scrollbar-thumb:hover {
background: hsl(220 10% 78% / 0.7);
background: hsl(var(--border-hover) / 0.7);
}
::selection {
background: hsl(224 64% 48% / 0.2);
color: hsl(220 15% 16%);
background: hsl(var(--accent) / 0.2);
color: hsl(var(--text-primary));
}
.font-mono {
@@ -483,20 +521,20 @@ body {
@layer components {
.glass-card {
@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);
box-shadow: 0 1px 3px hsl(var(--text-muted) / 0.1),
0 4px 12px hsl(var(--text-muted) / 0.06);
}
.surface-card {
@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);
box-shadow: 0 1px 2px hsl(var(--text-muted) / 0.1);
}
.surface-card:hover {
@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);
box-shadow: 0 2px 8px hsl(var(--text-muted) / 0.14),
0 1px 3px hsl(var(--text-muted) / 0.1);
}
.input-cursor {
@@ -521,16 +559,16 @@ body {
@apply transition-all duration-150;
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(232 58% 52%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 100%
);
}
.btn-primary-solid:hover {
background: linear-gradient(
135deg,
hsl(224 64% 42%) 0%,
hsl(232 58% 46%) 100%
hsl(var(--accent-hover)) 0%,
hsl(var(--accent)) 100%
);
}
@@ -572,12 +610,12 @@ body {
@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);
box-shadow: 0 1px 3px hsl(var(--text-muted) / 0.08);
}
.card:hover {
@apply border-border bg-elevated/90;
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08);
box-shadow: 0 2px 8px hsl(var(--text-muted) / 0.12);
}
.card-interactive {
@@ -586,8 +624,8 @@ body {
.card-interactive:hover {
@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);
box-shadow: 0 4px 16px hsl(var(--accent) / 0.08),
0 1px 4px hsl(var(--text-muted) / 0.1);
}
.section-header {
@@ -626,17 +664,17 @@ body {
}
.glow-accent {
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.1),
0 1px 4px hsl(224 64% 48% / 0.06);
box-shadow: 0 2px 12px hsl(var(--accent) / 0.1),
0 1px 4px hsl(var(--accent) / 0.06);
}
.glow-accent-strong {
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.15),
0 2px 8px hsl(224 64% 48% / 0.1);
box-shadow: 0 4px 20px hsl(var(--accent) / 0.15),
0 2px 8px hsl(var(--accent) / 0.1);
}
.glow-subtle {
box-shadow: 0 2px 12px hsl(220 14% 50% / 0.08);
box-shadow: 0 2px 12px hsl(var(--text-muted) / 0.12);
}
.focus-ring {
@@ -693,10 +731,10 @@ body {
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.08);
box-shadow: 0 2px 12px hsl(var(--accent) / 0.08);
}
50% {
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.14);
box-shadow: 0 4px 20px hsl(var(--accent) / 0.14);
}
}
@@ -727,9 +765,9 @@ body {
.animate-shimmer {
background: linear-gradient(
90deg,
hsl(220 14% 93%) 0%,
hsl(220 14% 88%) 50%,
hsl(220 14% 93%) 100%
hsl(var(--bg-surface)) 0%,
hsl(var(--bg-muted)) 50%,
hsl(var(--bg-surface)) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
@@ -808,10 +846,10 @@ body {
@keyframes thinking-pulse {
0%, 100% {
background: hsl(224 64% 48% / 0.04);
background: hsl(var(--accent) / 0.04);
}
50% {
background: hsl(224 64% 48% / 0.09);
background: hsl(var(--accent) / 0.09);
}
}
@@ -840,7 +878,7 @@ body {
.file-card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px hsl(220 14% 50% / 0.15);
box-shadow: 0 8px 25px -5px hsl(var(--text-muted) / 0.2);
}
.collapsible-content {
@@ -856,11 +894,11 @@ body {
.progress-shimmer {
background: linear-gradient(
90deg,
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%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 25%,
hsl(var(--accent-secondary)) 50%,
hsl(var(--accent-hover)) 75%,
hsl(var(--accent)) 100%
);
background-size: 200% 100%;
animation: progress-shimmer 2s linear infinite;
@@ -886,6 +924,6 @@ body {
.artifact-glow:hover {
box-shadow:
0 4px 16px hsl(152 60% 38% / 0.1),
0 2px 8px hsl(220 14% 50% / 0.08);
0 4px 16px hsl(var(--success) / 0.14),
0 2px 8px hsl(var(--text-muted) / 0.12);
}

View File

@@ -7,13 +7,30 @@ export const metadata: Metadata = {
description: 'AI-поиск нового поколения',
};
const themeInitScript = `
(() => {
try {
const KEY = 'gooseek_theme';
const saved = localStorage.getItem(KEY);
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = (saved === 'light' || saved === 'dim') ? saved : (prefersDark ? 'dim' : 'light');
const root = document.documentElement;
if (theme === 'dim') root.classList.add('theme-dim');
else root.classList.remove('theme-dim');
} catch {}
})();
`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<html lang="ru" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="antialiased">
<Providers>{children}</Providers>
</body>

View File

@@ -3,15 +3,18 @@
import { ReactNode } from 'react';
import { LanguageProvider } from '@/lib/contexts/LanguageContext';
import { AuthProvider } from '@/lib/contexts/AuthContext';
import { ThemeProvider } from '@/lib/contexts/ThemeContext';
import { AuthModal } from '@/components/auth';
export function Providers({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<LanguageProvider>
{children}
<AuthModal />
</LanguageProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<LanguageProvider>
{children}
<AuthModal />
</LanguageProvider>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,40 @@
import type { MetadataRoute } from 'next';
const ALL_APP_ROUTES = [
'/discover',
'/spaces',
'/history',
'/travel',
'/medicine',
'/finance',
'/learning',
];
function getDisallowedRoutes(): string[] {
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!raw.trim()) return [];
const enabled = new Set(
raw
.split(',')
.map((r) => r.trim())
.filter((r) => r.startsWith('/')),
);
return ALL_APP_ROUTES.filter((r) => !enabled.has(r));
}
export default function robots(): MetadataRoute.Robots {
const disallowed = getDisallowedRoutes();
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', ...disallowed.map((r) => r + '/')],
},
],
sitemap: 'https://gooseek.ru/sitemap.xml',
};
}

View File

@@ -0,0 +1,40 @@
import type { MetadataRoute } from 'next';
const BASE_URL = 'https://gooseek.ru';
const ALL_APP_ROUTES = [
'/discover',
'/spaces',
'/history',
'/travel',
'/medicine',
'/finance',
'/learning',
];
const ALWAYS_IN_SITEMAP = ['/', '/login', '/register'];
function getEnabledAppRoutes(): string[] {
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!raw.trim()) return ALL_APP_ROUTES;
const enabled = new Set(
raw
.split(',')
.map((r) => r.trim())
.filter((r) => r.startsWith('/')),
);
return ALL_APP_ROUTES.filter((r) => enabled.has(r));
}
export default function sitemap(): MetadataRoute.Sitemap {
const routes = [...ALWAYS_IN_SITEMAP, ...getEnabledAppRoutes()];
return routes.map((route) => ({
url: `${BASE_URL}${route}`,
lastModified: new Date(),
changeFrequency: route === '/' ? 'daily' : 'weekly',
priority: route === '/' ? 1.0 : 0.7,
}));
}

View File

@@ -0,0 +1,954 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
useDroppable,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
GripVertical,
X,
Plus,
Check,
RotateCcw,
Loader2,
AlertTriangle,
Lightbulb,
Search,
MapPin,
Ticket,
Utensils,
Camera,
ShoppingBag,
Coffee,
Footprints,
ChevronDown,
ChevronUp,
Clock,
Ban,
} from 'lucide-react';
import type {
ItineraryItem,
POICard,
EventCard,
DailyForecast,
} from '@/lib/types';
import {
useEditableItinerary,
validateItemPlacement,
type EditableDay,
type ValidationResult,
type LLMValidationResponse,
type LLMValidationWarning,
type CustomItemType,
} from '@/lib/hooks/useEditableItinerary';
import type { ItineraryDay } from '@/lib/types';
// --- Sortable Item ---
interface SortableItemProps {
item: ItineraryItem;
itemId: string;
dayIdx: number;
itemIdx: number;
onRemove: () => void;
warning?: LLMValidationWarning;
}
function SortableItem({ item, itemId, dayIdx, itemIdx, onRemove, warning }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: itemId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
warning
? 'border-amber-500/40 bg-amber-500/5'
: 'border-border/30 bg-elevated/20 hover:border-border/50'
}`}
>
<button
{...attributes}
{...listeners}
className="flex-shrink-0 p-0.5 text-muted hover:text-secondary cursor-grab active:cursor-grabbing touch-none"
aria-label="Перетащить"
>
<GripVertical className="w-4 h-4" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded flex-shrink-0">
{item.startTime}
</span>
)}
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
<span className="text-[9px] text-muted bg-surface/50 px-1.5 py-px rounded-full flex-shrink-0">
{item.refType}
</span>
</div>
{warning && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0" />
<span className="text-[10px] text-amber-400">{warning.message}</span>
</div>
)}
</div>
<button
onClick={onRemove}
className="flex-shrink-0 p-1 rounded text-muted hover:text-error hover:bg-error/10 opacity-0 group-hover:opacity-100 transition-all"
aria-label="Удалить"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// --- Drag Overlay Item ---
function DragOverlayItem({ item }: { item: ItineraryItem }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-accent/50 bg-elevated shadow-lg shadow-accent/10">
<GripVertical className="w-4 h-4 text-accent" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
{item.startTime}
</span>
)}
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
</div>
</div>
</div>
);
}
// --- Validation Toast ---
function ValidationToast({ result, onDismiss }: { result: ValidationResult; onDismiss: () => void }) {
if (result.valid) return null;
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 max-w-sm"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-red-950/90 border border-red-500/40 shadow-lg backdrop-blur-sm">
<Ban className="w-5 h-5 text-red-400 flex-shrink-0" />
<p className="text-sm text-red-200">{result.reason}</p>
<button onClick={onDismiss} className="p-1 text-red-400 hover:text-red-300 flex-shrink-0">
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
);
}
// --- Add Item Panel ---
interface AddItemPanelProps {
pois: POICard[];
events: EventCard[];
dayIdx: number;
dayDate: string;
dayItems: ItineraryItem[];
poisMap: Map<string, POICard>;
eventsMap: Map<string, EventCard>;
onAdd: (dayIdx: number, item: ItineraryItem) => ValidationResult;
onAddCustom: (dayIdx: number, title: string, refType: CustomItemType, duration: number) => ValidationResult;
onClose: () => void;
}
type AddTab = 'poi' | 'event' | 'custom';
const customTypeOptions: { value: CustomItemType; label: string; icon: typeof Utensils }[] = [
{ value: 'food', label: 'Еда', icon: Utensils },
{ value: 'walk', label: 'Прогулка', icon: Footprints },
{ value: 'rest', label: 'Отдых', icon: Coffee },
{ value: 'shopping', label: 'Шопинг', icon: ShoppingBag },
{ value: 'custom', label: 'Другое', icon: MapPin },
];
function AddItemPanel({
pois,
events,
dayIdx,
dayDate,
dayItems,
poisMap,
eventsMap,
onAdd,
onAddCustom,
onClose,
}: AddItemPanelProps) {
const [tab, setTab] = useState<AddTab>('poi');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<POICategory>('all');
const [customTitle, setCustomTitle] = useState('');
const [customType, setCustomType] = useState<CustomItemType>('food');
const [customDuration, setCustomDuration] = useState(60);
const [lastError, setLastError] = useState<string | null>(null);
const filteredPois = useMemo(() => {
let result = pois;
if (categoryFilter !== 'all') {
result = result.filter((p) => p.category.toLowerCase().includes(categoryFilter));
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) ||
(p.address && p.address.toLowerCase().includes(q)),
);
}
return result;
}, [pois, searchQuery, categoryFilter]);
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) return events;
const q = searchQuery.toLowerCase();
return events.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
(e.address && e.address.toLowerCase().includes(q)),
);
}, [events, searchQuery]);
const handleAddPOI = useCallback(
(poi: POICard) => {
const item: ItineraryItem = {
refType: 'poi',
refId: poi.id,
title: poi.name,
lat: poi.lat,
lng: poi.lng,
note: poi.description || '',
cost: poi.price || 0,
currency: poi.currency || 'RUB',
};
const result = onAdd(dayIdx, item);
if (!result.valid) {
setLastError(result.reason || 'Невозможно добавить');
setTimeout(() => setLastError(null), 3000);
} else {
setLastError(null);
}
},
[dayIdx, onAdd],
);
const handleAddEvent = useCallback(
(event: EventCard) => {
const item: ItineraryItem = {
refType: 'event',
refId: event.id,
title: event.title,
lat: event.lat || 0,
lng: event.lng || 0,
note: event.description || '',
cost: event.price || 0,
currency: event.currency || 'RUB',
};
const result = onAdd(dayIdx, item);
if (!result.valid) {
setLastError(result.reason || 'Невозможно добавить');
setTimeout(() => setLastError(null), 3000);
} else {
setLastError(null);
}
},
[dayIdx, onAdd],
);
const handleAddCustom = useCallback(() => {
if (!customTitle.trim()) return;
const result = onAddCustom(dayIdx, customTitle.trim(), customType, customDuration);
if (!result.valid) {
setLastError(result.reason || 'Невозможно добавить');
setTimeout(() => setLastError(null), 3000);
} else {
setCustomTitle('');
setLastError(null);
}
}, [dayIdx, customTitle, customType, customDuration, onAddCustom]);
const isItemAlreadyInDay = useCallback(
(refId: string) => dayItems.some((i) => i.refId === refId),
[dayItems],
);
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="p-3 border border-border/30 rounded-lg bg-elevated/30 mt-2">
<div className="flex items-center justify-between mb-3">
<div className="flex gap-1">
{[
{ key: 'poi' as AddTab, label: 'Места', icon: Camera },
{ key: 'event' as AddTab, label: 'События', icon: Ticket },
{ key: 'custom' as AddTab, label: 'Своё', icon: Plus },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => { setTab(key); setSearchQuery(''); }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] rounded-lg transition-colors ${
tab === key
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary hover:bg-surface/30'
}`}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
<button onClick={onClose} className="p-1 text-muted hover:text-primary">
<X className="w-4 h-4" />
</button>
</div>
{lastError && (
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-lg bg-red-500/10 border border-red-500/20">
<Ban className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
<span className="text-[11px] text-red-300">{lastError}</span>
</div>
)}
{tab !== 'custom' && (
<div className="space-y-2 mb-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={tab === 'poi' ? 'Поиск мест...' : 'Поиск событий...'}
className="w-full pl-8 pr-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
/>
</div>
{tab === 'poi' && (
<div className="flex gap-1 flex-wrap">
{categoryFilters.map((cf) => (
<button
key={cf.value}
onClick={() => setCategoryFilter(cf.value)}
className={`px-2 py-1 text-[9px] rounded-md transition-colors ${
categoryFilter === cf.value
? 'bg-accent/20 text-accent'
: 'bg-surface/30 text-muted hover:text-secondary'
}`}
>
{cf.label}
</button>
))}
</div>
)}
</div>
)}
{tab === 'poi' && (
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
{filteredPois.length === 0 ? (
<p className="text-[11px] text-muted text-center py-4">Нет доступных мест</p>
) : (
filteredPois.map((poi) => {
const alreadyAdded = isItemAlreadyInDay(poi.id);
const precheck = validateItemPlacement(
{ refType: 'poi', refId: poi.id, title: poi.name, lat: poi.lat, lng: poi.lng },
dayDate,
dayItems,
poisMap,
eventsMap,
);
return (
<button
key={poi.id}
onClick={() => !alreadyAdded && precheck.valid && handleAddPOI(poi)}
disabled={alreadyAdded || !precheck.valid}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
alreadyAdded
? 'opacity-50 cursor-not-allowed bg-surface/20'
: !precheck.valid
? 'opacity-60 cursor-not-allowed bg-red-500/5'
: 'hover:bg-surface/30'
}`}
>
<Camera className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-primary truncate block">{poi.name}</span>
{!precheck.valid && (
<span className="text-[9px] text-red-400">{precheck.reason}</span>
)}
</div>
{alreadyAdded ? (
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
) : (
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
)}
</button>
);
})
)}
</div>
)}
{tab === 'event' && (
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
{filteredEvents.length === 0 ? (
<p className="text-[11px] text-muted text-center py-4">Нет доступных событий</p>
) : (
filteredEvents.map((event) => {
const alreadyAdded = isItemAlreadyInDay(event.id);
const precheck = validateItemPlacement(
{ refType: 'event', refId: event.id, title: event.title, lat: event.lat || 0, lng: event.lng || 0 },
dayDate,
dayItems,
poisMap,
eventsMap,
);
return (
<button
key={event.id}
onClick={() => !alreadyAdded && precheck.valid && handleAddEvent(event)}
disabled={alreadyAdded || !precheck.valid}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
alreadyAdded
? 'opacity-50 cursor-not-allowed bg-surface/20'
: !precheck.valid
? 'opacity-60 cursor-not-allowed bg-red-500/5'
: 'hover:bg-surface/30'
}`}
>
<Ticket className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-primary truncate block">{event.title}</span>
{event.dateStart && (
<span className="text-[9px] text-muted">{event.dateStart}</span>
)}
{!precheck.valid && (
<span className="text-[9px] text-red-400 block">{precheck.reason}</span>
)}
</div>
{alreadyAdded ? (
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
) : (
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
)}
</button>
);
})
)}
</div>
)}
{tab === 'custom' && (
<div className="space-y-3">
<input
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="Название пункта..."
className="w-full px-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
/>
<div className="flex flex-wrap gap-1.5">
{customTypeOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => setCustomType(value)}
className={`flex items-center gap-1 px-2.5 py-1.5 text-[10px] rounded-lg transition-colors ${
customType === value
? 'bg-accent/20 text-accent border border-accent/30'
: 'bg-surface/30 text-muted hover:text-secondary border border-border/20'
}`}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-muted" />
<input
type="number"
value={customDuration}
onChange={(e) => setCustomDuration(Math.max(15, Number(e.target.value)))}
min={15}
max={480}
step={15}
className="w-20 px-2 py-1.5 bg-surface/40 border border-border/30 rounded-lg text-[11px] text-primary focus:outline-none focus:border-accent/40"
/>
<span className="text-[10px] text-muted">мин</span>
</div>
<button
onClick={handleAddCustom}
disabled={!customTitle.trim()}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-accent/20 text-accent rounded-lg text-[11px] font-medium hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
Добавить
</button>
</div>
)}
</div>
</motion.div>
);
}
// --- Droppable Day Container ---
function DroppableDayZone({ dayIdx, children }: { dayIdx: number; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({
id: `droppable-day-${dayIdx}`,
data: { dayIdx },
});
return (
<div
ref={setNodeRef}
className={`transition-colors rounded-lg ${isOver ? 'ring-2 ring-accent/40 bg-accent/5' : ''}`}
>
{children}
</div>
);
}
// --- Category filter for AddItemPanel ---
type POICategory = 'all' | 'attraction' | 'museum' | 'park' | 'restaurant' | 'shopping' | 'entertainment' | 'religious';
const categoryFilters: { value: POICategory; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'attraction', label: 'Достопримечательности' },
{ value: 'museum', label: 'Музеи' },
{ value: 'park', label: 'Парки' },
{ value: 'restaurant', label: 'Рестораны' },
{ value: 'shopping', label: 'Шопинг' },
{ value: 'entertainment', label: 'Развлечения' },
];
// --- Main EditableItinerary Component ---
interface EditableItineraryProps {
days: ItineraryDay[];
pois: POICard[];
events: EventCard[];
dailyForecast?: DailyForecast[];
onApply: (days: EditableDay[]) => void;
onCancel: () => void;
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
}
export function EditableItinerary({
days,
pois,
events,
dailyForecast,
onApply,
onCancel,
onValidateWithLLM,
}: EditableItineraryProps) {
const poisMap = useMemo(() => new Map(pois.map((p) => [p.id, p])), [pois]);
const eventsMap = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
const {
editableDays,
isEditing,
hasChanges,
isValidating,
llmWarnings,
llmSuggestions,
startEditing,
stopEditing,
resetChanges,
moveItem,
addItem,
removeItem,
addCustomItem,
applyChanges,
} = useEditableItinerary({
initialDays: days,
poisMap,
eventsMap,
dailyForecast,
onValidateWithLLM,
});
const [activeItem, setActiveItem] = useState<ItineraryItem | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [validationToast, setValidationToast] = useState<ValidationResult | null>(null);
const [addPanelDayIdx, setAddPanelDayIdx] = useState<number | null>(null);
const [expandedDays, setExpandedDays] = useState<Set<number>>(new Set([0]));
const [showLLMResults, setShowLLMResults] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Start editing on mount
if (!isEditing) {
startEditing();
}
const getItemId = (dayIdx: number, itemIdx: number): string =>
`day-${dayIdx}-item-${itemIdx}`;
const parseItemId = (id: string): { dayIdx: number; itemIdx: number } | null => {
const match = id.match(/^day-(\d+)-item-(\d+)$/);
if (!match) return null;
return { dayIdx: parseInt(match[1], 10), itemIdx: parseInt(match[2], 10) };
};
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const parsed = parseItemId(String(event.active.id));
if (!parsed) return;
const item = editableDays[parsed.dayIdx]?.items[parsed.itemIdx];
if (item) {
setActiveItem(item);
setActiveDragId(String(event.active.id));
}
},
[editableDays],
);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const from = parseItemId(String(active.id));
if (!from) return;
const overId = String(over.id);
let toDayIdx: number | null = null;
if (overId.startsWith('droppable-day-')) {
toDayIdx = parseInt(overId.replace('droppable-day-', ''), 10);
} else {
const to = parseItemId(overId);
if (to) toDayIdx = to.dayIdx;
}
if (toDayIdx !== null && toDayIdx !== from.dayIdx) {
const toItemIdx = editableDays[toDayIdx]?.items.length ?? 0;
const result = moveItem(from.dayIdx, from.itemIdx, toDayIdx, toItemIdx);
if (!result.valid) {
setValidationToast(result);
setTimeout(() => setValidationToast(null), 4000);
}
}
},
[editableDays, moveItem],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveItem(null);
setActiveDragId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = parseItemId(String(active.id));
const to = parseItemId(String(over.id));
if (!from || !to) return;
if (from.dayIdx === to.dayIdx) {
const result = moveItem(from.dayIdx, from.itemIdx, to.dayIdx, to.itemIdx);
if (!result.valid) {
setValidationToast(result);
setTimeout(() => setValidationToast(null), 4000);
}
}
},
[moveItem],
);
const handleApply = useCallback(async () => {
if (onValidateWithLLM) {
const result = await applyChanges();
if (result) {
setShowLLMResults(true);
const hasCritical = result.warnings.length > 0;
if (!hasCritical) {
onApply(editableDays);
}
} else {
onApply(editableDays);
}
} else {
onApply(editableDays);
}
}, [applyChanges, editableDays, onApply, onValidateWithLLM]);
const handleCancel = useCallback(() => {
stopEditing();
onCancel();
}, [stopEditing, onCancel]);
const toggleDay = useCallback((idx: number) => {
setExpandedDays((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
}, []);
const getWarningForItem = useCallback(
(dayIdx: number, itemIdx: number): LLMValidationWarning | undefined =>
llmWarnings.find((w) => w.dayIdx === dayIdx && w.itemIdx === itemIdx),
[llmWarnings],
);
if (!isEditing || editableDays.length === 0) return null;
return (
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 px-1 pb-2 border-b border-border/30">
<div className="flex items-center gap-2">
<button
onClick={handleApply}
disabled={!hasChanges || isValidating}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium bg-accent/20 text-accent rounded-lg hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isValidating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
{isValidating ? 'Проверка...' : 'Применить'}
</button>
<button
onClick={resetChanges}
disabled={!hasChanges}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-secondary rounded-lg hover:bg-surface/30 transition-colors disabled:opacity-40"
>
<RotateCcw className="w-3.5 h-3.5" />
Сбросить
</button>
</div>
<button
onClick={handleCancel}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-primary rounded-lg hover:bg-surface/30 transition-colors"
>
<X className="w-3.5 h-3.5" />
Отмена
</button>
</div>
{/* LLM Validation Results */}
<AnimatePresence>
{showLLMResults && (llmWarnings.length > 0 || llmSuggestions.length > 0) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="space-y-2 pb-2">
{llmWarnings.length > 0 && (
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
<span className="text-[11px] font-medium text-amber-300">Предупреждения</span>
</div>
<ul className="space-y-1">
{llmWarnings.map((w, i) => (
<li key={i} className="text-[10px] text-amber-200/80 flex items-start gap-1.5">
<span className="text-amber-400 flex-shrink-0"></span>
{w.message}
</li>
))}
</ul>
<button
onClick={() => { onApply(editableDays); setShowLLMResults(false); }}
className="mt-2 text-[10px] text-accent hover:underline"
>
Применить всё равно
</button>
</div>
)}
{llmSuggestions.length > 0 && (
<div className="p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="flex items-center gap-2 mb-2">
<Lightbulb className="w-4 h-4 text-accent" />
<span className="text-[11px] font-medium text-accent">Рекомендации</span>
</div>
<ul className="space-y-1">
{llmSuggestions.map((s, i) => (
<li key={i} className="text-[10px] text-secondary flex items-start gap-1.5">
<span className="text-accent flex-shrink-0"></span>
{s.message}
</li>
))}
</ul>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* DnD Context */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{editableDays.map((day, dayIdx) => {
const isExpanded = expandedDays.has(dayIdx);
const itemIds = day.items.map((_, i) => getItemId(dayIdx, i));
return (
<DroppableDayZone key={`edit-day-${dayIdx}`} dayIdx={dayIdx}>
<div
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
>
<button
onClick={() => toggleDay(dayIdx)}
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
>
<div className="flex items-center gap-2.5">
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center">
{dayIdx + 1}
</span>
<span className="text-[13px] font-medium text-primary">{day.date}</span>
<span className="text-[11px] text-muted">{day.items.length} мест</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted" />
) : (
<ChevronDown className="w-4 h-4 text-muted" />
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3.5 pb-3 pt-1 space-y-1.5">
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{day.items.map((item, itemIdx) => (
<SortableItem
key={getItemId(dayIdx, itemIdx)}
item={item}
itemId={getItemId(dayIdx, itemIdx)}
dayIdx={dayIdx}
itemIdx={itemIdx}
onRemove={() => removeItem(dayIdx, itemIdx)}
warning={getWarningForItem(dayIdx, itemIdx)}
/>
))}
</SortableContext>
{day.items.length === 0 && (
<div className="py-4 text-center text-[11px] text-muted">
Пусто добавьте пункты
</div>
)}
<button
onClick={() =>
setAddPanelDayIdx(addPanelDayIdx === dayIdx ? null : dayIdx)
}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 border border-dashed border-border/40 rounded-lg text-[11px] text-muted hover:text-accent hover:border-accent/30 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Добавить пункт
</button>
<AnimatePresence>
{addPanelDayIdx === dayIdx && (
<AddItemPanel
pois={pois}
events={events}
dayIdx={dayIdx}
dayDate={day.date}
dayItems={day.items}
poisMap={poisMap}
eventsMap={eventsMap}
onAdd={addItem}
onAddCustom={addCustomItem}
onClose={() => setAddPanelDayIdx(null)}
/>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</DroppableDayZone>
);
})}
<DragOverlay>
{activeItem ? <DragOverlayItem item={activeItem} /> : null}
</DragOverlay>
</DndContext>
{/* Validation Toast */}
<AnimatePresence>
{validationToast && !validationToast.valid && (
<ValidationToast
result={validationToast}
onDismiss={() => setValidationToast(null)}
/>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,668 @@
'use client';
import { useMemo, useState, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
Activity,
AlertTriangle,
ArrowUpRight,
Beaker,
Calendar,
ChevronDown,
ChevronUp,
CircleAlert,
ExternalLink,
HeartPulse,
HelpCircle,
Home,
Loader2,
MapPin,
Monitor,
Pill,
Shield,
Stethoscope,
Thermometer,
Zap,
} from 'lucide-react';
import type { MedicineWidget } from '@/lib/hooks/useMedicineChat';
type TabId = 'assessment' | 'doctors' | 'booking' | 'reference';
interface TabDef {
id: TabId;
label: string;
icon: typeof HeartPulse;
emptyLabel: string;
}
const TABS: TabDef[] = [
{ id: 'assessment', label: 'Оценка', icon: HeartPulse, emptyLabel: 'Оценка симптомов появится после запроса' },
{ id: 'doctors', label: 'Врачи', icon: Stethoscope, emptyLabel: 'Специалисты подбираются после анализа' },
{ id: 'booking', label: 'Запись', icon: Calendar, emptyLabel: 'Ссылки на запись появятся после подбора врачей' },
{ id: 'reference', label: 'Справка', icon: Pill, emptyLabel: 'Справочная информация появится после ответа' },
];
interface ConditionItem {
name: string;
likelihood: string;
why: string;
}
interface SpecialtyItem {
specialty: string;
reason: string;
priority: string;
}
interface DoctorOption {
id: string;
name: string;
specialty: string;
clinic: string;
city: string;
address?: string;
sourceUrl: string;
sourceName: string;
snippet?: string;
}
interface BookingLink {
id: string;
doctorId: string;
doctor: string;
specialty: string;
clinic: string;
bookUrl: string;
remote: boolean;
}
interface MedicationInfoItem {
name: string;
forWhat: string;
notes: string;
}
interface SupplementInfoItem {
name: string;
purpose: string;
evidence: string;
notes: string;
}
interface ProcedureInfoItem {
name: string;
purpose: string;
whenUseful: string;
}
const TRIAGE_CONFIG: Record<string, { label: string; color: string; bg: string; border: string; icon: typeof Zap }> = {
low: { label: 'Низкий', color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/30', icon: Shield },
medium: { label: 'Средний', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', icon: Activity },
high: { label: 'Высокий', color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', icon: AlertTriangle },
emergency: { label: 'Экстренный', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: Zap },
};
const LIKELIHOOD_CONFIG: Record<string, { label: string; width: string; color: string }> = {
low: { label: 'Маловероятно', width: 'w-1/4', color: 'bg-blue-500/60' },
medium: { label: 'Возможно', width: 'w-2/4', color: 'bg-amber-500/60' },
high: { label: 'Вероятно', width: 'w-3/4', color: 'bg-orange-500/60' },
};
const EVIDENCE_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
low: { label: 'Слабая доказательность', color: 'text-zinc-400', bg: 'bg-zinc-500/15' },
medium: { label: 'Умеренная доказательность', color: 'text-amber-400', bg: 'bg-amber-500/15' },
high: { label: 'Высокая доказательность', color: 'text-green-400', bg: 'bg-green-500/15' },
};
function CollapsibleSection({
title,
icon: Icon,
children,
defaultOpen = true,
count,
}: {
title: string;
icon: typeof HeartPulse;
children: React.ReactNode;
defaultOpen?: boolean;
count?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border/20 rounded-xl overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-surface/30 hover:bg-surface/50 transition-colors"
>
<div className="flex items-center gap-2 text-sm font-medium text-primary">
<Icon className="w-4 h-4 text-accent" />
{title}
{count !== undefined && count > 0 && (
<span className="text-xs text-muted bg-surface/50 px-1.5 py-0.5 rounded-md">{count}</span>
)}
</div>
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="p-3 space-y-2">{children}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function TriageBadge({ level }: { level: string }) {
const cfg = TRIAGE_CONFIG[level] || TRIAGE_CONFIG.medium;
const Icon = cfg.icon;
return (
<div className={`flex items-center gap-3 p-4 rounded-xl border ${cfg.bg} ${cfg.border}`}>
<div className={`w-10 h-10 rounded-xl ${cfg.bg} flex items-center justify-center`}>
<Icon className={`w-5 h-5 ${cfg.color}`} />
</div>
<div>
<div className="text-xs text-muted uppercase tracking-wider">Уровень приоритета</div>
<div className={`text-lg font-bold ${cfg.color}`}>{cfg.label}</div>
</div>
</div>
);
}
function LikelihoodBar({ likelihood }: { likelihood: string }) {
const cfg = LIKELIHOOD_CONFIG[likelihood] || LIKELIHOOD_CONFIG.medium;
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-surface/40 rounded-full overflow-hidden">
<div className={`h-full ${cfg.width} ${cfg.color} rounded-full transition-all`} />
</div>
<span className="text-xs text-muted whitespace-nowrap">{cfg.label}</span>
</div>
);
}
function AssessmentTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const triageLevel = String(p.triageLevel || 'medium');
const urgentSigns = (p.urgentSigns || []) as string[];
const conditions = (p.possibleConditions || []) as ConditionItem[];
const specialists = (p.recommendedSpecialists || []) as SpecialtyItem[];
const questions = (p.questionsToClarify || []) as string[];
const homeCare = (p.homeCare || []) as string[];
const disclaimer = String(p.disclaimer || '');
return (
<div className="space-y-3">
<TriageBadge level={triageLevel} />
{urgentSigns.length > 0 && (
<div className="p-3 rounded-xl bg-red-500/5 border border-red-500/20">
<div className="flex items-center gap-2 mb-2">
<CircleAlert className="w-4 h-4 text-red-400" />
<span className="text-sm font-medium text-red-400">Красные флаги</span>
</div>
<div className="space-y-1">
{urgentSigns.map((sign, i) => (
<div key={`us-${i}`} className="flex items-start gap-2 text-xs text-secondary">
<span className="text-red-400 mt-0.5">!</span>
<span>{sign} <strong className="text-red-400">вызывайте 103/112</strong></span>
</div>
))}
</div>
</div>
)}
{conditions.length > 0 && (
<CollapsibleSection title="Вероятные состояния" icon={Thermometer} count={conditions.length}>
{conditions.map((c, i) => (
<div key={`cond-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-primary">{c.name}</span>
</div>
<LikelihoodBar likelihood={c.likelihood} />
{c.why && <p className="text-xs text-muted leading-relaxed">{c.why}</p>}
</div>
))}
</CollapsibleSection>
)}
{specialists.length > 0 && (
<CollapsibleSection title="Рекомендуемые специалисты" icon={Stethoscope} count={specialists.length}>
{specialists.map((sp, i) => (
<div key={`sp-${i}`} className="flex items-start gap-3 p-2.5 rounded-lg bg-surface/20 border border-border/15">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${sp.priority === 'high' ? 'bg-accent/15' : 'bg-surface/40'}`}>
<Stethoscope className={`w-4 h-4 ${sp.priority === 'high' ? 'text-accent' : 'text-muted'}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-primary">{sp.specialty}</span>
{sp.priority === 'high' && (
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
)}
</div>
<p className="text-xs text-muted mt-0.5">{sp.reason}</p>
</div>
</div>
))}
</CollapsibleSection>
)}
{questions.length > 0 && (
<CollapsibleSection title="Уточняющие вопросы" icon={HelpCircle} count={questions.length} defaultOpen={false}>
<p className="text-xs text-muted mb-2">Ответы на эти вопросы помогут уточнить оценку:</p>
{questions.map((q, i) => (
<div key={`q-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
<HelpCircle className="w-3.5 h-3.5 text-accent mt-0.5 shrink-0" />
<span className="text-xs text-secondary">{q}</span>
</div>
))}
</CollapsibleSection>
)}
{homeCare.length > 0 && (
<CollapsibleSection title="Что делать дома до визита" icon={Home} count={homeCare.length} defaultOpen={false}>
{homeCare.map((tip, i) => (
<div key={`hc-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
<span className="text-green-400 mt-0.5 text-xs">+</span>
<span className="text-xs text-secondary">{tip}</span>
</div>
))}
</CollapsibleSection>
)}
{disclaimer && (
<div className="p-3 rounded-xl bg-surface/20 border border-border/15">
<p className="text-xs text-muted italic leading-relaxed">{disclaimer}</p>
</div>
)}
</div>
);
}
function DoctorsTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const doctors = (p.doctors || []) as DoctorOption[];
const specialists = (p.specialists || []) as SpecialtyItem[];
const city = String(p.city || '');
const grouped = useMemo(() => {
const map = new Map<string, { specialist: SpecialtyItem | null; doctors: DoctorOption[] }>();
for (const d of doctors) {
const key = d.specialty;
if (!map.has(key)) {
const sp = specialists.find((s) => s.specialty === key) || null;
map.set(key, { specialist: sp, doctors: [] });
}
map.get(key)!.doctors.push(d);
}
return Array.from(map.entries());
}, [doctors, specialists]);
if (doctors.length === 0) {
return (
<div className="text-center py-8">
<Stethoscope className="w-8 h-8 text-muted/30 mx-auto mb-2" />
<p className="text-sm text-muted">Специалисты не найдены</p>
<p className="text-xs text-muted/70 mt-1">Попробуйте указать другой город</p>
</div>
);
}
return (
<div className="space-y-4">
{city && (
<div className="flex items-center gap-2 text-xs text-muted">
<MapPin className="w-3.5 h-3.5" />
<span>Поиск в городе: <strong className="text-secondary">{city}</strong></span>
<span className="text-muted/50">|</span>
<span>Найдено: <strong className="text-secondary">{doctors.length}</strong></span>
</div>
)}
{grouped.map(([specialty, group]) => (
<CollapsibleSection
key={specialty}
title={specialty}
icon={Stethoscope}
count={group.doctors.length}
>
{group.specialist && (
<div className="flex items-center gap-2 mb-2 px-1">
<span className="text-xs text-muted">Причина направления:</span>
<span className="text-xs text-secondary">{group.specialist.reason}</span>
{group.specialist.priority === 'high' && (
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
)}
</div>
)}
{group.doctors.map((d) => (
<div key={d.id} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{d.clinic || d.name}</p>
{d.sourceName && (
<p className="text-xs text-muted mt-0.5">Источник: {d.sourceName}</p>
)}
</div>
{d.sourceUrl && (
<a
href={d.sourceUrl}
target="_blank"
rel="noreferrer"
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-accent bg-accent/10 hover:bg-accent/20 border border-accent/20 rounded-lg transition-colors"
>
Записаться
<ArrowUpRight className="w-3 h-3" />
</a>
)}
</div>
{d.snippet && (
<p className="text-xs text-muted leading-relaxed line-clamp-3">{d.snippet}</p>
)}
</div>
))}
</CollapsibleSection>
))}
</div>
);
}
function BookingTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const bookingLinks = (p.bookingLinks || p.slots || []) as BookingLink[];
if (bookingLinks.length === 0) {
return (
<div className="text-center py-8">
<Calendar className="w-8 h-8 text-muted/30 mx-auto mb-2" />
<p className="text-sm text-muted">Ссылки на запись не найдены</p>
<p className="text-xs text-muted/70 mt-1">Попробуйте уточнить город или специальность</p>
</div>
);
}
const grouped = useMemo(() => {
const map = new Map<string, BookingLink[]>();
for (const link of bookingLinks) {
const key = link.specialty || 'Другое';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(link);
}
return Array.from(map.entries());
}, [bookingLinks]);
return (
<div className="space-y-4">
<div className="p-3 rounded-xl bg-accent/5 border border-accent/15">
<div className="flex items-start gap-2">
<Calendar className="w-4 h-4 text-accent mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-primary">Запись к специалистам</p>
<p className="text-xs text-muted mt-0.5">
Нажмите на ссылку, чтобы перейти на сайт клиники и выбрать удобное время.
</p>
</div>
</div>
</div>
{grouped.map(([specialty, links]) => (
<CollapsibleSection key={specialty} title={specialty} icon={Stethoscope} count={links.length}>
{links.map((link) => (
<a
key={link.id}
href={link.bookUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-surface/20 border border-border/15 hover:border-accent/30 hover:bg-accent/5 transition-all group"
>
<div className="w-9 h-9 rounded-lg bg-surface/40 flex items-center justify-center shrink-0 group-hover:bg-accent/15 transition-colors">
{link.remote ? (
<Monitor className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
) : (
<MapPin className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary truncate">{link.doctor}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted truncate">{link.clinic}</span>
{link.remote && (
<span className="text-3xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400 font-medium shrink-0">Онлайн</span>
)}
</div>
</div>
<ExternalLink className="w-4 h-4 text-muted group-hover:text-accent transition-colors shrink-0" />
</a>
))}
</CollapsibleSection>
))}
</div>
);
}
function ReferenceTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const medications = (p.medicationInfo || []) as MedicationInfoItem[];
const supplements = (p.supplementInfo || []) as SupplementInfoItem[];
const procedures = (p.procedureInfo || []) as ProcedureInfoItem[];
const note = String(p.note || '');
const hasContent = medications.length > 0 || supplements.length > 0 || procedures.length > 0;
if (!hasContent) {
return (
<div className="text-center py-8">
<Pill className="w-8 h-8 text-muted/30 mx-auto mb-2" />
<p className="text-sm text-muted">Справочная информация отсутствует</p>
</div>
);
}
return (
<div className="space-y-3">
{note && (
<div className="p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-xs text-muted leading-relaxed">{note}</p>
</div>
</div>
)}
{medications.length > 0 && (
<CollapsibleSection title="Лекарственные препараты" icon={Pill} count={medications.length}>
<p className="text-xs text-muted mb-2">Только справочная информация. Назначения делает врач.</p>
{medications.map((m, i) => (
<div key={`med-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
<div className="flex items-center gap-2">
<Pill className="w-3.5 h-3.5 text-blue-400" />
<span className="text-sm font-medium text-primary">{m.name}</span>
</div>
<p className="text-xs text-secondary"><strong>Применяется:</strong> {m.forWhat}</p>
{m.notes && <p className="text-xs text-muted italic">{m.notes}</p>}
</div>
))}
</CollapsibleSection>
)}
{supplements.length > 0 && (
<CollapsibleSection title="БАДы и добавки" icon={Beaker} count={supplements.length}>
{supplements.map((s, i) => {
const ev = EVIDENCE_CONFIG[s.evidence] || EVIDENCE_CONFIG.low;
return (
<div key={`sup-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Beaker className="w-3.5 h-3.5 text-green-400" />
<span className="text-sm font-medium text-primary">{s.name}</span>
</div>
<span className={`text-3xs px-1.5 py-0.5 rounded font-medium ${ev.bg} ${ev.color}`}>
{ev.label}
</span>
</div>
<p className="text-xs text-secondary"><strong>Назначение:</strong> {s.purpose}</p>
{s.notes && <p className="text-xs text-muted italic">{s.notes}</p>}
</div>
);
})}
</CollapsibleSection>
)}
{procedures.length > 0 && (
<CollapsibleSection title="Обследования и процедуры" icon={Activity} count={procedures.length}>
{procedures.map((pr, i) => (
<div key={`proc-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
<div className="flex items-center gap-2">
<Activity className="w-3.5 h-3.5 text-purple-400" />
<span className="text-sm font-medium text-primary">{pr.name}</span>
</div>
<p className="text-xs text-secondary"><strong>Что покажет:</strong> {pr.purpose}</p>
{pr.whenUseful && (
<p className="text-xs text-muted"><strong>Когда назначают:</strong> {pr.whenUseful}</p>
)}
</div>
))}
</CollapsibleSection>
)}
</div>
);
}
interface MedicineWidgetTabsProps {
widgets: MedicineWidget[];
isLoading?: boolean;
}
export function MedicineWidgetTabs({ widgets, isLoading }: MedicineWidgetTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('assessment');
const byType = useMemo(() => {
const out = new Map<string, MedicineWidget>();
for (const w of widgets) {
if (w.type.startsWith('medicine_')) out.set(w.type, w);
}
return out;
}, [widgets]);
const assessment = byType.get('medicine_assessment');
const doctors = byType.get('medicine_doctors');
const appointments = byType.get('medicine_appointments');
const reference = byType.get('medicine_reference');
const tabHasData = useCallback(
(id: TabId): boolean => {
switch (id) {
case 'assessment': return Boolean(assessment);
case 'doctors': return Boolean(doctors);
case 'booking': return Boolean(appointments);
case 'reference': return Boolean(reference);
default: return false;
}
},
[assessment, doctors, appointments, reference],
);
const tabCounts = useMemo((): Record<TabId, number> => {
const doctorList = (doctors?.params.doctors || []) as DoctorOption[];
const bookingList = (appointments?.params.bookingLinks || appointments?.params.slots || []) as BookingLink[];
const conditions = (assessment?.params.possibleConditions || []) as ConditionItem[];
const meds = (reference?.params.medicationInfo || []) as MedicationInfoItem[];
const sups = (reference?.params.supplementInfo || []) as SupplementInfoItem[];
const procs = (reference?.params.procedureInfo || []) as ProcedureInfoItem[];
return {
assessment: conditions.length,
doctors: doctorList.length,
booking: bookingList.length,
reference: meds.length + sups.length + procs.length,
};
}, [assessment, doctors, appointments, reference]);
return (
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col">
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base border-b border-border/30">
{TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
const count = tabCounts[tab.id];
const hasData = tabHasData(tab.id);
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
isActive
? 'bg-surface/60 text-primary border-accent'
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
}`}
>
<Icon className="w-3.5 h-3.5" />
{tab.label}
{hasData && count > 0 && (
<span
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'
}`}
>
{count}
</span>
)}
{hasData && count === 0 && <span className="w-1.5 h-1.5 rounded-full bg-accent" />}
</button>
);
})}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="p-3"
>
{isLoading && widgets.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs">Анализ симптомов...</span>
</div>
) : !tabHasData(activeTab) ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
<CircleAlert className="w-5 h-5 opacity-40" />
<span className="text-xs">{TABS.find((t) => t.id === activeTab)?.emptyLabel}</span>
</div>
) : (
<>
{activeTab === 'assessment' && <AssessmentTab widget={assessment} />}
{activeTab === 'doctors' && <DoctorsTab widget={doctors} />}
{activeTab === 'booking' && <BookingTab widget={appointments} />}
{activeTab === 'reference' && <ReferenceTab widget={reference} />}
</>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,643 @@
'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import dynamic from 'next/dynamic';
import {
FileCode,
Terminal,
Play,
CheckCircle2,
XCircle,
Loader2,
ChevronRight,
ChevronDown,
File,
Folder,
RefreshCw,
Save,
Plus,
} from 'lucide-react';
import { useTheme } from '@/lib/contexts/ThemeContext';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), { ssr: false });
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
interface FileNode {
name: string;
path: string;
type: 'file' | 'dir';
children?: FileNode[];
}
interface SandboxPanelProps {
sessionId: string;
onVerifyResult?: (passed: boolean, result: Record<string, unknown>) => void;
}
export default function SandboxPanel({ sessionId, onVerifyResult }: SandboxPanelProps) {
const { theme } = useTheme();
const [activeTab, setActiveTab] = useState<'editor' | 'terminal'>('editor');
const [files, setFiles] = useState<FileNode[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set(['/home/user']));
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState('');
const [originalFileContent, setOriginalFileContent] = useState<string>('');
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [terminalOutput, setTerminalOutput] = useState('');
const [terminalCmd, setTerminalCmd] = useState('');
const [isRunningCmd, setIsRunningCmd] = useState(false);
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [isVerifying, setIsVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<{ passed: boolean; stdout: string } | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<unknown>(null);
const isDirty = useMemo(() => selectedFile != null && fileContent !== originalFileContent, [
selectedFile,
fileContent,
originalFileContent,
]);
const parseFileNodes = useCallback((data: unknown): FileNode[] => {
const rawEntries: unknown[] = Array.isArray(data)
? data
: (data as { entries?: unknown[] } | null)?.entries && Array.isArray((data as { entries?: unknown[] }).entries)
? ((data as { entries?: unknown[] }).entries as unknown[])
: [];
return rawEntries
.map((e) => {
const entry = e as { name?: unknown; path?: unknown; type?: unknown };
const name = typeof entry.name === 'string' ? entry.name : '';
const path = typeof entry.path === 'string' ? entry.path : '';
const typeRaw = typeof entry.type === 'string' ? entry.type : 'file';
const type: 'file' | 'dir' = typeRaw === 'dir' || typeRaw === 'directory' ? 'dir' : 'file';
if (!name || !path) return null;
return { name, path, type } satisfies FileNode;
})
.filter((v): v is FileNode => v != null)
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, []);
const updateNodeChildren = useCallback((nodes: FileNode[], dirPath: string, children: FileNode[]): FileNode[] => {
return nodes.map((n) => {
if (n.type === 'dir' && n.path === dirPath) {
return { ...n, children };
}
if (n.type === 'dir' && n.children) {
return { ...n, children: updateNodeChildren(n.children, dirPath, children) };
}
return n;
});
}, []);
const fetchDir = useCallback(
async (path: string): Promise<FileNode[]> => {
const resp = await fetch(
`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/files?path=${encodeURIComponent(path)}`,
{ headers: getAuthHeaders() }
);
if (!resp.ok) {
throw new Error(`list files failed: ${resp.status}`);
}
const data: unknown = await resp.json();
return parseFileNodes(data);
},
[sessionId, parseFileNodes]
);
const loadFiles = useCallback(async (path = '/home/user') => {
setIsLoadingFiles(true);
setErrorMessage(null);
try {
const nodes = await fetchDir(path);
if (path === '/home/user') setFiles(nodes);
return nodes;
} catch (e) {
const msg = e instanceof Error ? e.message : 'failed to load files';
setErrorMessage(msg);
return [];
} finally {
setIsLoadingFiles(false);
}
}, [fetchDir]);
useEffect(() => {
loadFiles();
}, [loadFiles]);
const openFile = useCallback(async (path: string) => {
setSelectedFile(path);
setActiveTab('editor');
setIsLoadingFile(true);
setErrorMessage(null);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file?path=${encodeURIComponent(path)}`, {
headers: getAuthHeaders(),
});
if (!resp.ok) throw new Error(`read file failed: ${resp.status}`);
const data: unknown = await resp.json();
const content = (data as { content?: unknown } | null)?.content;
const text = typeof content === 'string' ? content : '';
setFileContent(text);
setOriginalFileContent(text);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Error loading file';
setErrorMessage(msg);
setFileContent('');
setOriginalFileContent('');
} finally {
setIsLoadingFile(false);
}
}, [sessionId]);
const saveFile = useCallback(async () => {
if (!selectedFile) return;
setIsSaving(true);
setErrorMessage(null);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ path: selectedFile, content: fileContent }),
});
if (!resp.ok) throw new Error(`write file failed: ${resp.status}`);
setOriginalFileContent(fileContent);
} finally {
setIsSaving(false);
}
}, [sessionId, selectedFile, fileContent]);
const toggleDir = useCallback(
async (node: FileNode) => {
if (node.type !== 'dir') return;
const willExpand = !expandedDirs.has(node.path);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(node.path)) next.delete(node.path);
else next.add(node.path);
return next;
});
if (!willExpand) return;
if (node.children && node.children.length > 0) return;
setLoadingDirs((prev) => new Set(prev).add(node.path));
try {
const children = await fetchDir(node.path);
setFiles((prev) => updateNodeChildren(prev, node.path, children));
} catch (e) {
const msg = e instanceof Error ? e.message : 'failed to load directory';
setErrorMessage(msg);
} finally {
setLoadingDirs((prev) => {
const next = new Set(prev);
next.delete(node.path);
return next;
});
}
},
[expandedDirs, fetchDir, updateNodeChildren]
);
const runCommand = useCallback(async () => {
if (!terminalCmd.trim()) return;
const cmd = terminalCmd.trim();
setTerminalCmd('');
setIsRunningCmd(true);
setHistoryIndex(-1);
setCmdHistory((prev) => (prev[prev.length - 1] === cmd ? prev : [...prev, cmd]));
setTerminalOutput((prev) => prev + `$ ${cmd}\n`);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/commands/run`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ command: cmd }),
});
if (!resp.ok) {
setTerminalOutput((prev) => prev + `Error: ${resp.status}\n`);
return;
}
const data = await resp.json();
const stdout = data.stdout || '';
const stderr = data.stderr || '';
const exitCode = data.exit_code ?? 0;
setTerminalOutput((prev) =>
prev + stdout + (stderr ? `\nstderr: ${stderr}` : '') + `\n[exit: ${exitCode}]\n\n`
);
} catch (err) {
setTerminalOutput((prev) => prev + `Error: ${err}\n`);
} finally {
setIsRunningCmd(false);
}
}, [sessionId, terminalCmd]);
const runVerify = useCallback(async () => {
setIsVerifying(true);
setVerifyResult(null);
setErrorMessage(null);
try {
if (isDirty && selectedFile) {
await saveFile();
}
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/verify`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({}),
});
if (!resp.ok) {
setVerifyResult({ passed: false, stdout: `Error: ${resp.status}` });
return;
}
const data = await resp.json();
const result = {
passed: data.passed === true,
stdout: (data.result?.stdout || '') as string,
};
setVerifyResult(result);
onVerifyResult?.(result.passed, data.result || {});
} catch {
setVerifyResult({ passed: false, stdout: 'Verification failed' });
} finally {
setIsVerifying(false);
}
}, [sessionId, onVerifyResult, isDirty, selectedFile, saveFile]);
useEffect(() => {
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight);
}, [terminalOutput]);
const fileName = useMemo(() => {
if (!selectedFile) return '';
return selectedFile.split('/').pop() || '';
}, [selectedFile]);
const editorLanguage = useMemo(() => detectLanguage(selectedFile), [selectedFile]);
const createNewFile = useCallback(async () => {
const p = window.prompt('Путь нового файла (пример: /home/user/main.go)');
if (!p) return;
setErrorMessage(null);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ path: p, content: '' }),
});
if (!resp.ok) throw new Error(`create file failed: ${resp.status}`);
await loadFiles('/home/user');
await openFile(p);
} catch (e) {
const msg = e instanceof Error ? e.message : 'create file failed';
setErrorMessage(msg);
}
}, [sessionId, loadFiles, openFile]);
return (
<div className="flex flex-col h-full bg-background border-l border-border/30">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/30 bg-elevated/40">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded icon-gradient flex items-center justify-center">
<FileCode className="w-3 h-3" />
</div>
<span className="text-xs font-medium text-primary">Песочница</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => loadFiles()}
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
title="Обновить файлы"
>
<RefreshCw className="w-3 h-3" />
</button>
<button
onClick={createNewFile}
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
title="Новый файл"
>
<Plus className="w-3 h-3" />
</button>
<button
onClick={runVerify}
disabled={isVerifying}
className="flex items-center gap-1 px-2 py-1 text-[10px] btn-gradient rounded-md"
>
{isVerifying ? (
<Loader2 className="w-3 h-3 animate-spin btn-gradient-text" />
) : (
<Play className="w-3 h-3 btn-gradient-text" />
)}
<span className="btn-gradient-text">Проверить</span>
</button>
</div>
</div>
{/* Error banner */}
<AnimatePresence>
{errorMessage && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-3 py-2 text-xs bg-error/10 text-error border-b border-error/20 overflow-hidden"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
{/* Verify result banner */}
<AnimatePresence>
{verifyResult && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className={`px-3 py-2 text-xs flex items-center gap-2 overflow-hidden ${
verifyResult.passed
? 'bg-success/10 text-success border-b border-success/20'
: 'bg-error/10 text-error border-b border-error/20'
}`}
>
{verifyResult.passed ? (
<CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 flex-shrink-0" />
)}
{verifyResult.passed ? 'Тест пройден!' : 'Тест не пройден'}
</motion.div>
)}
</AnimatePresence>
{/* Main area */}
<div className="flex flex-1 overflow-hidden">
{/* File tree */}
<div className="w-[180px] border-r border-border/30 overflow-y-auto bg-surface/20">
<div className="px-2 py-1.5 text-[10px] text-muted uppercase tracking-wider">Файлы</div>
{isLoadingFiles ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-3 h-3 animate-spin text-muted" />
</div>
) : (
<div className="text-xs">
{files.map((node) => (
<FileTreeNode
key={node.path || node.name}
node={node}
expanded={expandedDirs}
loading={loadingDirs}
selected={selectedFile}
onToggle={toggleDir}
onSelect={openFile}
depth={0}
/>
))}
</div>
)}
</div>
{/* Editor / Terminal */}
<div className="flex-1 flex flex-col min-w-0">
{/* Tabs */}
<div className="flex items-center gap-0 border-b border-border/30 bg-elevated/20">
<button
onClick={() => setActiveTab('editor')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
activeTab === 'editor'
? 'border-accent text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<FileCode className="w-3 h-3" />
{fileName || 'Редактор'}
{isDirty && <span className="text-[10px] text-warning ml-1"></span>}
</button>
<button
onClick={() => setActiveTab('terminal')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
activeTab === 'terminal'
? 'border-accent text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<Terminal className="w-3 h-3" />
Терминал
</button>
{activeTab === 'editor' && selectedFile && (
<button
onClick={saveFile}
disabled={isSaving}
className="ml-auto mr-2 p-1 text-muted hover:text-primary transition-all"
title="Сохранить"
>
{isSaving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
</button>
)}
</div>
{/* Content */}
{activeTab === 'editor' ? (
<div className="flex-1 overflow-auto p-0">
{selectedFile ? (
isLoadingFile ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-5 h-5 animate-spin text-muted" />
</div>
) : (
<div className="w-full h-full">
<MonacoEditor
value={fileContent}
onChange={(v) => setFileContent(v ?? '')}
language={editorLanguage}
theme={theme === 'dim' ? 'vs-dark' : 'vs'}
options={{
minimap: { enabled: false },
fontSize: 12,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
onMount={(editor, monaco) => {
editorRef.current = editor as unknown;
const editorLike = editor as unknown as { addCommand: (keybinding: number, handler: () => void) => void };
const monacoLike = monaco as unknown as { KeyMod: { CtrlCmd: number }; KeyCode: { KeyS: number } };
editorLike.addCommand(monacoLike.KeyMod.CtrlCmd | monacoLike.KeyCode.KeyS, () => {
void saveFile();
});
}}
/>
</div>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<FileCode className="w-8 h-8 text-muted mb-2" />
<p className="text-xs text-muted">Выберите файл</p>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col">
<div
ref={terminalRef}
className={`flex-1 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap ${
theme === 'dim'
? 'text-emerald-300 bg-black/30'
: 'text-emerald-700 bg-elevated/50 border border-border/30'
}`}
>
{terminalOutput || '$ Введите команду...\n'}
</div>
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/30 bg-surface/20">
<span className={`text-xs font-mono ${theme === 'dim' ? 'text-emerald-300' : 'text-emerald-700'}`}>$</span>
<input
value={terminalCmd}
onChange={(e) => setTerminalCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
runCommand();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (cmdHistory.length === 0) return;
const nextIndex = historyIndex < 0 ? cmdHistory.length - 1 : Math.max(0, historyIndex - 1);
setHistoryIndex(nextIndex);
setTerminalCmd(cmdHistory[nextIndex] || '');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (cmdHistory.length === 0) return;
if (historyIndex < 0) return;
const nextIndex = Math.min(cmdHistory.length - 1, historyIndex + 1);
setHistoryIndex(nextIndex);
setTerminalCmd(cmdHistory[nextIndex] || '');
}
}}
placeholder="Введите команду..."
className="flex-1 bg-transparent text-xs font-mono text-primary focus:outline-none placeholder:text-muted"
disabled={isRunningCmd}
/>
{isRunningCmd && <Loader2 className="w-3 h-3 animate-spin text-muted" />}
<button
onClick={() => setTerminalOutput('')}
className="p-1 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
title="Очистить"
disabled={isRunningCmd}
>
<XCircle className="w-3 h-3" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function FileTreeNode({
node,
expanded,
loading,
selected,
onToggle,
onSelect,
depth,
}: {
node: FileNode;
expanded: Set<string>;
loading: Set<string>;
selected: string | null;
onToggle: (node: FileNode) => void | Promise<void>;
onSelect: (path: string) => void;
depth: number;
}) {
const isDir = node.type === 'dir';
const isExpanded = expanded.has(node.path);
const isSelected = selected === node.path;
const isLoading = loading.has(node.path);
return (
<>
<button
onClick={() => {
if (isDir) {
void onToggle(node);
} else {
onSelect(node.path);
}
}}
className={`w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface/60 transition-colors ${
isSelected ? 'bg-accent/10 text-accent' : 'text-secondary'
}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{isDir ? (
<>
{isExpanded ? (
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
) : (
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
)}
<Folder className="w-3 h-3 text-amber-400 flex-shrink-0" />
</>
) : (
<>
<span className="w-2.5" />
<File className="w-3 h-3 text-muted flex-shrink-0" />
</>
)}
<span className="truncate text-[11px]">{node.name}</span>
{isDir && isExpanded && isLoading && <Loader2 className="w-3 h-3 ml-auto animate-spin text-muted" />}
</button>
{isDir && isExpanded && node.children?.map((child) => (
<FileTreeNode
key={child.path || child.name}
node={child}
expanded={expanded}
loading={loading}
selected={selected}
onToggle={onToggle}
onSelect={onSelect}
depth={depth + 1}
/>
))}
</>
);
}
function detectLanguage(path: string | null): string {
if (!path) return 'plaintext';
const lower = path.toLowerCase();
if (lower.endsWith('.go')) return 'go';
if (lower.endsWith('.ts')) return 'typescript';
if (lower.endsWith('.tsx')) return 'typescript';
if (lower.endsWith('.js')) return 'javascript';
if (lower.endsWith('.jsx')) return 'javascript';
if (lower.endsWith('.py')) return 'python';
if (lower.endsWith('.sql')) return 'sql';
if (lower.endsWith('.json')) return 'json';
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml';
if (lower.endsWith('.md')) return 'markdown';
if (lower.endsWith('.sh')) return 'shell';
return 'plaintext';
}

View File

@@ -23,6 +23,7 @@ import {
} from 'lucide-react';
import { filterMenuItems } from '@/lib/config/menu';
import { useAuth } from '@/lib/contexts/AuthContext';
import { ThemeToggle } from '@/components/ThemeToggle';
interface SidebarProps {
onClose?: () => void;
@@ -65,12 +66,12 @@ export function Sidebar({ onClose }: SidebarProps) {
return (
<motion.aside
initial={false}
animate={{ width: isMobile ? 240 : collapsed ? 56 : 200 }}
animate={{ width: isMobile ? 260 : collapsed ? 60 : 220 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="h-full flex flex-col bg-base border-r border-border/50"
className="relative z-30 h-full flex flex-col bg-base border-r border-border/50"
>
{/* Header */}
<div className="h-12 px-3 flex items-center justify-between">
<div className="h-14 px-3 flex items-center justify-between">
<AnimatePresence mode="wait">
{(isMobile || !collapsed) && (
<motion.div
@@ -79,7 +80,7 @@ export function Sidebar({ onClose }: SidebarProps) {
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="font-black italic text-primary tracking-tight text-xl">GooSeek</span>
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
</motion.div>
)}
</AnimatePresence>
@@ -106,7 +107,7 @@ export function Sidebar({ onClose }: SidebarProps) {
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-2 space-y-0.5 overflow-y-auto">
<nav className="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.href}
@@ -120,9 +121,9 @@ export function Sidebar({ onClose }: SidebarProps) {
))}
{/* Tools Section */}
<div className="pt-4 pb-1">
<div className="pt-5 pb-1.5">
{(isMobile || !collapsed) && (
<span className="px-2 text-[10px] font-semibold text-muted uppercase tracking-wider">
<span className="px-3 text-xs font-semibold text-muted uppercase tracking-wider">
Инструменты
</span>
)}
@@ -144,6 +145,17 @@ export function Sidebar({ onClose }: SidebarProps) {
{/* Footer - Profile Block */}
<div className="p-2 border-t border-border/30">
{/* Theme toggle */}
<div className={`${isAdmin ? '' : ''} mb-2`}>
{collapsed && !isMobile ? (
<div className="flex justify-center">
<ThemeToggle variant="icon" />
</div>
) : (
<ThemeToggle variant="full" />
)}
</div>
{isAdmin && (
<NavLink
href="/admin"
@@ -157,21 +169,21 @@ export function Sidebar({ onClose }: SidebarProps) {
{/* Guest - Login button */}
{!isAuthenticated && (
<div className={`${isAdmin ? 'mt-1' : ''}`}>
<div className={`${isAdmin ? 'mt-1.5' : ''}`}>
{collapsed && !isMobile ? (
<button
onClick={() => showAuthModal('login')}
className="btn-gradient w-full h-9 flex items-center justify-center"
className="btn-gradient w-full h-10 flex items-center justify-center rounded-lg"
>
<LogIn className="w-4 h-4 btn-gradient-text" />
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
</button>
) : (
<button
onClick={() => { showAuthModal('login'); handleNavClick(); }}
className="btn-gradient w-full h-9 flex items-center justify-center gap-2"
className="btn-gradient w-full h-10 flex items-center justify-center gap-2.5 rounded-lg"
>
<LogIn className="w-3.5 h-3.5 btn-gradient-text" />
<span className="btn-gradient-text text-xs font-medium">Войти</span>
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
<span className="btn-gradient-text text-sm font-medium">Войти</span>
</button>
)}
</div>
@@ -197,7 +209,7 @@ export function Sidebar({ onClose }: SidebarProps) {
</div>
<div className="flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-surface/50">
<Wallet className="w-2.5 h-2.5 text-accent" />
<span className="text-[10px] font-medium text-primary">{user.balance ?? 0}</span>
<span className="text-2xs font-medium text-primary">{user.balance ?? 0}</span>
</div>
</Link>
) : (
@@ -216,9 +228,9 @@ export function Sidebar({ onClose }: SidebarProps) {
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-primary truncate">{user.name}</span>
<span className={`text-2xs font-medium px-1.5 py-0.5 rounded ${
user.tier === 'business'
? 'bg-amber-500/20 text-amber-600'
: user.tier === 'pro'
@@ -229,8 +241,8 @@ export function Sidebar({ onClose }: SidebarProps) {
</span>
</div>
<div className="flex items-center gap-1 mt-0.5">
<Wallet className="w-2.5 h-2.5 text-accent" />
<span className="text-[10px] font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} </span>
<Wallet className="w-3 h-3 text-accent" />
<span className="text-xs font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} </span>
</div>
</div>
<ChevronRightIcon className="w-3.5 h-3.5 text-muted group-hover:text-secondary transition-colors" />
@@ -258,17 +270,17 @@ function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLin
href={href}
onClick={onClick}
className={`
flex items-center gap-2 h-9 rounded-lg transition-all duration-150
${collapsed ? 'justify-center px-0' : 'px-2'}
flex items-center gap-3 h-10 rounded-lg transition-all duration-150
${collapsed ? 'justify-center px-0' : 'px-3'}
${active
? 'active-gradient text-primary border-l-gradient ml-0'
: 'text-secondary hover:text-primary hover:bg-surface/50'
}
`}
>
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
{!collapsed && (
<span className={`text-xs font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
<span className={`text-sm font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
)}
</Link>
);

View File

@@ -0,0 +1,37 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '@/lib/contexts/ThemeContext';
export function ThemeToggle({
variant = 'icon',
}: {
variant?: 'icon' | 'full';
}) {
const { theme, toggleTheme } = useTheme();
const isDim = theme === 'dim';
const label = isDim ? 'Тема: GitHub Dimmed' : 'Тема: GitHub Light';
if (variant === 'full') {
return (
<button onClick={toggleTheme} className="btn-secondary w-full h-10 flex items-center justify-center gap-2" aria-label={label}>
{isDim ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
<span className="text-sm font-medium">{isDim ? 'Dimmed' : 'Light'}</span>
</button>
);
}
return (
<button
onClick={toggleTheme}
className="btn-icon"
aria-label={label}
title={label}
type="button"
>
{isDim ? <Moon className="w-[18px] h-[18px]" /> : <Sun className="w-[18px] h-[18px]" />}
</button>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MapPin,
@@ -24,8 +24,11 @@ import {
CalendarDays,
Flag,
User,
Clock,
Layers,
} from 'lucide-react';
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
import { getRoute } from '@/lib/api';
interface MapglAPI {
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
@@ -116,6 +119,30 @@ const pointTypeColors: Record<string, string> = {
origin: '#10B981',
};
const pointTypeLabels: Record<string, string> = {
airport: 'Аэропорт',
hotel: 'Отель',
restaurant: 'Ресторан',
attraction: 'Достопримечательность',
transport: 'Транспорт',
custom: 'Точка',
museum: 'Музей',
park: 'Парк',
theater: 'Театр',
shopping: 'Шопинг',
entertainment: 'Развлечения',
religious: 'Храм',
viewpoint: 'Смотровая',
event: 'Событие',
destination: 'Пункт назначения',
poi: 'Место',
food: 'Еда',
transfer: 'Пересадка',
origin: 'Старт',
};
const ITINERARY_TYPES = new Set(['destination', 'origin', 'transfer', 'airport']);
let mapglPromise: Promise<MapglAPI> | null = null;
function loadMapGL(): Promise<MapglAPI> {
@@ -128,20 +155,46 @@ function loadMapGL(): Promise<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>` +
function createNumberedPinMarkerSVG(index: number, color: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="34" viewBox="0 0 26 34">` +
`<defs>` +
`<filter id="is${index}" x="-15%" y="-8%" width="130%" height="130%">` +
`<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000" flood-opacity="0.2"/>` +
`</filter>` +
`</defs>` +
`<path d="M13 32 C13 32 24 20 24 12 C24 6.5 19.1 2 13 2 C6.9 2 2 6.5 2 12 C2 20 13 32 13 32Z" fill="${color}" filter="url(#is${index})" stroke="white" stroke-width="1.5"/>` +
`<circle cx="13" cy="12" r="7" fill="white" fill-opacity="0.95"/>` +
`<text x="13" y="15.5" text-anchor="middle" fill="${color}" font-size="10" font-weight="700" font-family="Inter,system-ui,sans-serif">${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"/>` +
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
`<circle cx="14" cy="14" r="12" fill="#3B82F6" fill-opacity="0.15" stroke="#3B82F6" stroke-width="1.5"/>` +
`<circle cx="14" cy="14" r="5" fill="#3B82F6" stroke="white" stroke-width="1.5"/>` +
`</svg>`;
}
function getCategoryAbbr(type: string): string {
const map: Record<string, string> = {
hotel: 'H',
restaurant: 'R',
food: 'R',
attraction: 'A',
museum: 'M',
park: 'P',
theater: 'T',
shopping: 'S',
entertainment: 'E',
religious: 'C',
viewpoint: 'V',
event: 'Ev',
poi: 'P',
custom: '?',
};
return map[type] || type.slice(0, 1).toUpperCase();
}
export function TravelMap({
route,
routeDirection,
@@ -158,12 +211,16 @@ export function TravelMap({
const mapglRef = useRef<MapglAPI | null>(null);
const markersRef = useRef<MapglMarkerInstance[]>([]);
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
const polylineRef = useRef<MapglPolylineInstance | null>(null);
const polylinesRef = useRef<MapglPolylineInstance[]>([]);
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 [showLegend, setShowLegend] = useState(false);
const [routeType, setRouteType] = useState<'road' | 'straight' | 'none'>('none');
const [fallbackDirection, setFallbackDirection] = useState<RouteDirection | null>(null);
const fallbackRequestRef = useRef<string>('');
const initDoneRef = useRef(false);
onMapClickRef.current = onMapClick;
@@ -171,6 +228,24 @@ export function TravelMap({
const effectiveUserLocation = userLocation ?? detectedLocation;
const activeTypes = useMemo(() => {
const types = new Set<string>();
route.forEach((p) => types.add(p.type));
return types;
}, [route]);
const pointNumberById = useMemo(() => {
const map = new Map<string, number>();
let idx = 0;
route.forEach((p) => {
if (!p.id) return;
if (!p.lat || !p.lng || p.lat === 0 || p.lng === 0) return;
idx += 1;
map.set(p.id, idx);
});
return map;
}, [route]);
useEffect(() => {
if (!mapRef.current || initDoneRef.current) return;
initDoneRef.current = true;
@@ -190,6 +265,9 @@ export function TravelMap({
zoom,
key: TWOGIS_API_KEY,
lang: 'ru',
// Hide built-in MapGL controls (we render our own UI controls).
zoomControl: false,
geolocationControl: false,
});
map.on('click', (e: MapglClickEvent) => {
@@ -214,10 +292,8 @@ export function TravelMap({
userMarkerRef.current.destroy();
userMarkerRef.current = null;
}
if (polylineRef.current) {
polylineRef.current.destroy();
polylineRef.current = null;
}
polylinesRef.current.forEach((p) => p.destroy());
polylinesRef.current = [];
if (mapInstanceRef.current) {
mapInstanceRef.current.destroy();
mapInstanceRef.current = null;
@@ -244,9 +320,7 @@ export function TravelMap({
mapInstanceRef.current.setZoom(12);
}
},
() => {
// geolocation denied or unavailable
},
() => {},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -264,6 +338,41 @@ export function TravelMap({
return () => observer.disconnect();
}, []);
const effectiveRouteDirection = routeDirection || fallbackDirection;
useEffect(() => {
if (routeDirection) {
setFallbackDirection(null);
return;
}
const validPoints = route.filter((p) => p.lat !== 0 && p.lng !== 0 && p.lat && p.lng);
if (validPoints.length < 2) {
setFallbackDirection(null);
return;
}
const key = validPoints.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|');
if (fallbackRequestRef.current === key) return;
fallbackRequestRef.current = key;
const geoPoints: GeoLocation[] = validPoints.map((p) => ({
lat: p.lat,
lng: p.lng,
name: p.name,
}));
getRoute(geoPoints).then((rd) => {
if (fallbackRequestRef.current === key) {
setFallbackDirection(rd);
}
}).catch(() => {
if (fallbackRequestRef.current === key) {
setFallbackDirection(null);
}
});
}, [route, routeDirection]);
useEffect(() => {
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
@@ -273,10 +382,8 @@ export function TravelMap({
markersRef.current.forEach((m) => m.destroy());
markersRef.current = [];
if (polylineRef.current) {
polylineRef.current.destroy();
polylineRef.current = null;
}
polylinesRef.current.forEach((p) => p.destroy());
polylinesRef.current = [];
if (userMarkerRef.current) {
userMarkerRef.current.destroy();
@@ -289,11 +396,11 @@ export function TravelMap({
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
label: {
text: '',
offset: [0, -48],
offset: [0, -40],
image: {
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
size: [32, 32],
anchor: [16, 16],
size: [28, 28],
anchor: [14, 14],
},
},
});
@@ -311,21 +418,27 @@ export function TravelMap({
return;
}
route.forEach((point, index) => {
let pointIndex = 0;
route.forEach((point) => {
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
pointIndex++;
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
const isItinerary = ITINERARY_TYPES.has(point.type);
const svgUrl = `data:image/svg+xml,${encodeURIComponent(createNumberedPinMarkerSVG(pointIndex, color))}`;
const markerSize: [number, number] = [26, 34];
const markerAnchor: [number, number] = [13, 32];
try {
const marker = new mapgl.Marker(map, {
coordinates: [point.lng, point.lat],
label: {
text: String(index + 1),
offset: [0, -48],
text: '',
offset: [0, isItinerary ? -48 : -48],
image: {
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
size: [28, 28],
anchor: [14, 14],
url: svgUrl,
size: markerSize,
anchor: markerAnchor,
},
},
});
@@ -341,35 +454,61 @@ export function TravelMap({
}
});
const rdCoords = routeDirection?.geometry?.coordinates;
const rdCoords = effectiveRouteDirection?.geometry?.coordinates;
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
setRouteType('road');
const coords = rdCoords.map(
(c: number[]) => [c[0], c[1]] as [number, number]
);
try {
polylineRef.current = new mapgl.Polyline(map, {
const outlinePoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#6366F1',
color: '#ffffff',
width: 7,
});
polylinesRef.current.push(outlinePoly);
} catch {
// outline polyline failed
}
try {
const mainPoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#4F5BD5',
width: 4,
});
} catch (err) {
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
polylinesRef.current.push(mainPoly);
} catch {
// main polyline failed
}
} else if (route.length > 1) {
setRouteType('straight');
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, {
const outlinePoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#6366F1',
color: '#ffffff',
width: 6,
});
polylinesRef.current.push(outlinePoly);
} catch {
// outline polyline failed
}
try {
const mainPoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#94A3B8',
width: 3,
});
} catch (err) {
console.error('[TravelMap] fallback polyline failed:', err);
polylinesRef.current.push(mainPoly);
} catch {
// main polyline failed
}
}
} else {
setRouteType('none');
}
const allPoints: { lat: number; lng: number }[] = route
@@ -398,13 +537,13 @@ export function TravelMap({
} else {
map.fitBounds(
{
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
},
);
}
}
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
}, [isMapReady, route, effectiveRouteDirection, effectiveUserLocation]);
const handleZoomIn = useCallback(() => {
const map = mapInstanceRef.current;
@@ -469,8 +608,8 @@ export function TravelMap({
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],
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
});
}
}, [route, effectiveUserLocation]);
@@ -479,90 +618,187 @@ export function TravelMap({
? pointTypeIcons[selectedPoint.type] || MapPin
: MapPin;
const selectedPointIndex = useMemo(() => {
if (!selectedPoint) return 0;
if (!selectedPoint.id) return 0;
return pointNumberById.get(selectedPoint.id) || 0;
}, [selectedPoint, pointNumberById]);
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]">
<div className="absolute top-3 right-3 flex flex-col gap-1.5 z-30">
<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"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Увеличить"
>
<ZoomIn className="w-5 h-5" />
<ZoomIn className="w-4 h-4" />
</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"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Уменьшить"
>
<ZoomOut className="w-5 h-5" />
<ZoomOut className="w-4 h-4" />
</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"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Моё местоположение"
>
<Locate className="w-5 h-5" />
<Locate className="w-4 h-4" />
</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"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Показать весь маршрут"
>
<Navigation className="w-5 h-5" />
<Navigation className="w-4 h-4" />
</button>
)}
{activeTypes.size > 1 && (
<button
onClick={() => setShowLegend((v) => !v)}
className={`w-8 h-8 backdrop-blur-sm border rounded-lg flex items-center justify-center transition-all ${
showLegend
? 'bg-accent/20 border-accent/40 text-accent'
: 'bg-elevated/90 border-border/40 text-secondary hover:text-primary hover:bg-surface'
}`}
title="Легенда"
>
<Layers className="w-4 h-4" />
</button>
)}
</div>
)}
{route.length > 0 && (
<div className="absolute top-3 left-3 z-30 flex flex-col gap-1">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg">
<MapPin className="w-3 h-3 text-accent" />
<span className="text-[11px] font-medium text-primary">{route.length}</span>
<span className="text-[10px] text-muted">
{route.length === 1 ? 'точка' : route.length < 5 ? 'точки' : 'точек'}
</span>
</div>
{routeType === 'road' && (
<div className="flex items-center gap-1 px-2 py-1 bg-green-500/10 backdrop-blur-sm border border-green-500/30 rounded-lg">
<Navigation className="w-2.5 h-2.5 text-green-500" />
<span className="text-[9px] text-green-500 font-medium">По дорогам</span>
</div>
)}
{routeType === 'straight' && (
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-500/10 backdrop-blur-sm border border-yellow-500/30 rounded-lg">
<Navigation className="w-2.5 h-2.5 text-yellow-500" />
<span className="text-[9px] text-yellow-500 font-medium">Прямые линии</span>
</div>
)}
</div>
)}
{/* Legend */}
<AnimatePresence>
{showLegend && activeTypes.size > 0 && (
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="absolute top-14 right-3 z-30 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-lg p-2 min-w-[120px]"
>
<div className="space-y-1">
{Array.from(activeTypes).map((type) => (
<div key={type} className="flex items-center gap-2 px-1 py-0.5">
<div
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: pointTypeColors[type] || '#94A3B8' }}
/>
<span className="text-[10px] text-secondary leading-none">
{pointTypeLabels[type] || type}
</span>
<span className="text-[9px] text-muted ml-auto">
{route.filter((p) => p.type === type).length}
</span>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Selected point popup */}
<AnimatePresence>
{selectedPoint && (
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 16 }}
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]"
exit={{ opacity: 0, y: 16 }}
className="absolute bottom-3 left-3 right-3 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-xl overflow-hidden z-30"
>
<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' }}
<div className="p-3">
<button
onClick={() => setSelectedPoint(null)}
className="absolute top-2.5 right-2.5 p-1 rounded-md hover:bg-surface/50 text-muted hover:text-primary transition-colors"
>
<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>
<X className="w-3.5 h-3.5" />
</button>
<div className="flex items-start gap-2.5 pr-6">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '18' }}
>
<PointIcon
className="w-4 h-4"
style={{ color: pointTypeColors[selectedPoint.type] }}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{selectedPointIndex > 0 && (
<span
className="w-4.5 h-4.5 rounded text-[9px] font-bold flex items-center justify-center text-white px-1"
style={{ backgroundColor: pointTypeColors[selectedPoint.type] }}
>
{selectedPointIndex}
</span>
)}
<h4 className="text-[13px] font-medium text-primary truncate">
{selectedPoint.name}
</h4>
</div>
)}
{selectedPoint.duration && (
<p className="text-xs text-muted mt-2">
Рекомендуемое время: {selectedPoint.duration} мин
</p>
)}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span
className="text-[9px] font-medium px-1.5 py-px rounded-full"
style={{
backgroundColor: pointTypeColors[selectedPoint.type] + '15',
color: pointTypeColors[selectedPoint.type],
}}
>
{pointTypeLabels[selectedPoint.type] || selectedPoint.type}
</span>
{selectedPoint.address && (
<span className="text-[10px] text-muted truncate max-w-[180px]">
{selectedPoint.address}
</span>
)}
</div>
{selectedPoint.aiComment && (
<div className="flex items-start gap-1.5 mt-2 p-2 bg-accent/5 border border-accent/10 rounded-lg">
<Sparkles className="w-3 h-3 text-accent flex-shrink-0 mt-0.5" />
<p className="text-[10px] text-secondary leading-relaxed">{selectedPoint.aiComment}</p>
</div>
)}
{selectedPoint.duration && selectedPoint.duration > 0 && (
<div className="flex items-center gap-1 mt-1.5">
<Clock className="w-3 h-3 text-muted" />
<span className="text-[10px] text-muted">~{selectedPoint.duration} мин</span>
</div>
)}
</div>
</div>
</div>
</motion.div>

View File

@@ -0,0 +1,649 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar,
Ticket,
Hotel,
Plane,
Bus,
DollarSign,
CloudSun,
Camera,
Search,
Filter,
Sparkles,
Check,
Loader2,
AlertCircle,
} from 'lucide-react';
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
import type { TravelWidget } from '@/lib/hooks/useTravelChat';
import type {
EventCard,
POICard,
HotelCard,
TransportOption,
ItineraryDay,
} from '@/lib/types';
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
type TabId = 'plan' | 'places' | 'events' | 'hotels' | 'tickets' | 'transport' | 'budget' | 'context';
interface TabDef {
id: TabId;
label: string;
icon: typeof Calendar;
widgetTypes: string[];
emptyLabel: string;
}
const TABS: TabDef[] = [
{ id: 'plan', label: 'План', icon: Calendar, widgetTypes: ['travel_itinerary'], emptyLabel: 'Маршрут ещё не построен' },
{ id: 'places', label: 'Места', icon: Camera, widgetTypes: ['travel_poi'], emptyLabel: 'Достопримечательности не найдены' },
{ id: 'events', label: 'События', icon: Ticket, widgetTypes: ['travel_events'], emptyLabel: 'Мероприятия не найдены' },
{ id: 'hotels', label: 'Отели', icon: Hotel, widgetTypes: ['travel_hotels'], emptyLabel: 'Отели не найдены' },
{ id: 'tickets', label: 'Билеты', icon: Plane, widgetTypes: ['travel_transport'], emptyLabel: 'Билеты не найдены' },
{ id: 'transport', label: 'Транспорт', icon: Bus, widgetTypes: ['travel_transport'], emptyLabel: 'Транспорт не найден' },
{ id: 'budget', label: 'Бюджет', icon: DollarSign, widgetTypes: ['travel_budget'], emptyLabel: 'Бюджет не рассчитан' },
{ id: 'context', label: 'Инфо', icon: CloudSun, widgetTypes: ['travel_context'], emptyLabel: 'Информация недоступна' },
];
type LoadingPhase = 'idle' | 'planning' | 'collecting' | 'building' | 'routing';
interface TravelWidgetTabsProps {
widgets: TravelWidget[];
onAddEventToMap?: (event: EventCard) => void;
onAddPOIToMap?: (poi: POICard) => void;
onSelectHotel?: (hotel: HotelCard) => void;
onSelectTransport?: (option: TransportOption) => void;
onClarifyingAnswer?: (field: string, value: string) => void;
onAction?: (kind: string) => void;
selectedEventIds?: Set<string>;
selectedPOIIds?: Set<string>;
selectedHotelId?: string;
selectedTransportId?: string;
availablePois?: POICard[];
availableEvents?: EventCard[];
onItineraryUpdate?: (days: ItineraryDay[]) => void;
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<LLMValidationResponse | null>;
isLoading?: boolean;
loadingPhase?: LoadingPhase;
isResearching?: boolean;
routePointCount?: number;
hasRouteDirection?: boolean;
}
function normalizeText(value: string): string {
return value.trim().toLowerCase();
}
function includesAny(haystack: string, needles: string[]): boolean {
const h = normalizeText(haystack);
if (!h) return false;
return needles.some((n) => h.includes(n));
}
function getEventSearchText(e: EventCard): string {
return [
e.title,
e.description,
e.address,
e.source,
...(e.tags || []),
]
.filter(Boolean)
.join(' ');
}
function getPOISearchText(p: POICard): string {
return [p.name, p.description, p.address, p.category].filter(Boolean).join(' ');
}
function getHotelSearchText(h: HotelCard): string {
return [h.name, h.address, ...(h.amenities || [])].filter(Boolean).join(' ');
}
function getTransportSearchText(t: TransportOption): string {
return [
t.mode,
t.from,
t.to,
t.provider,
t.airline,
t.flightNum,
]
.filter(Boolean)
.join(' ');
}
function TabToolbar({
tab,
query,
setQuery,
onlySelected,
setOnlySelected,
minRating,
setMinRating,
filtersOpen,
setFiltersOpen,
isLoading,
onAskMore,
showAskMore,
}: {
tab: TabId;
query: string;
setQuery: (value: string) => void;
onlySelected: boolean;
setOnlySelected: (value: boolean) => void;
minRating: number;
setMinRating: (value: number) => void;
filtersOpen: boolean;
setFiltersOpen: (value: boolean) => void;
isLoading?: boolean;
onAskMore?: () => void;
showAskMore?: boolean;
}) {
const placeholder =
tab === 'plan'
? 'Поиск по маршруту...'
: tab === 'places'
? 'Поиск по местам...'
: tab === 'events'
? 'Поиск по событиям...'
: tab === 'hotels'
? 'Поиск по отелям...'
: tab === 'tickets'
? 'Поиск по билетам...'
: tab === 'transport'
? 'Поиск по транспорту...'
: tab === 'budget'
? 'Поиск по бюджету...'
: 'Поиск...';
const showRating = tab === 'places' || tab === 'hotels';
const showSelected = tab === 'places' || tab === 'events' || tab === 'hotels' || tab === 'tickets' || tab === 'transport';
const filterCount = (showSelected && onlySelected ? 1 : 0) + (showRating && minRating > 0 ? 1 : 0);
const shouldShowFilterPanel = filtersOpen || filterCount > 0;
return (
<div className="flex flex-col gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full pl-8 pr-3 py-2 text-xs bg-surface/40 border border-border/30 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
{onAskMore && showAskMore && (
<button
onClick={onAskMore}
disabled={isLoading}
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
isLoading
? 'bg-surface/30 text-muted border-border/20'
: 'bg-accent/15 text-accent border-accent/25 hover:bg-accent/20'
}`}
title="Попросить AI найти ещё варианты"
aria-label="Найти ещё"
>
<Sparkles className="w-4 h-4" />
</button>
)}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
shouldShowFilterPanel
? 'bg-surface/50 text-secondary border-border/40'
: 'bg-surface/30 text-muted border-border/25 hover:text-secondary'
}`}
title="Фильтры"
aria-label="Фильтры"
>
<div className="relative">
<Filter className="w-4 h-4" />
{filterCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] px-1 rounded-full bg-accent text-3xs leading-[14px] text-black text-center">
{filterCount}
</span>
)}
</div>
</button>
</div>
{shouldShowFilterPanel && (
<div className="flex items-center gap-2 flex-wrap">
{showSelected && (
<button
onClick={() => setOnlySelected(!onlySelected)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg border transition-colors ${
onlySelected
? 'bg-accent/20 text-accent border-accent/30'
: 'bg-surface/30 text-secondary border-border/30 hover:text-primary'
}`}
title="Показывать только выбранные"
>
{onlySelected && <Check className="w-3.5 h-3.5" />}
Выбранные
</button>
)}
{showRating && (
<div className="flex items-center gap-2">
<span className="text-ui-sm text-muted">Рейтинг</span>
<select
value={minRating}
onChange={(e) => setMinRating(Number(e.target.value))}
className="px-2 py-1 text-ui-sm bg-surface/30 border border-border/30 rounded-lg text-secondary focus:outline-none"
aria-label="Минимальный рейтинг"
>
<option value={0}>Любой</option>
<option value={4}>4.0+</option>
<option value={4.5}>4.5+</option>
</select>
</div>
)}
{!showSelected && !showRating && (
<span className="text-ui-sm text-muted/80">Нет фильтров для этого этапа</span>
)}
</div>
)}
</div>
);
}
export function TravelWidgetTabs({
widgets,
onAddEventToMap,
onAddPOIToMap,
onSelectHotel,
onSelectTransport,
onClarifyingAnswer,
onAction,
selectedEventIds = new Set(),
selectedPOIIds = new Set(),
selectedHotelId,
selectedTransportId,
availablePois,
availableEvents,
onItineraryUpdate,
onValidateItineraryWithLLM,
isLoading,
loadingPhase = 'idle',
isResearching,
routePointCount = 0,
hasRouteDirection,
}: TravelWidgetTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('plan');
const [inlineNotice, setInlineNotice] = useState<string | null>(null);
const [queryByTab, setQueryByTab] = useState<Record<TabId, string>>({
plan: '',
places: '',
events: '',
hotels: '',
tickets: '',
transport: '',
budget: '',
context: '',
});
const [onlySelectedByTab, setOnlySelectedByTab] = useState<Record<TabId, boolean>>({
plan: false,
places: false,
events: false,
hotels: false,
tickets: false,
transport: false,
budget: false,
context: false,
});
const [minRatingByTab, setMinRatingByTab] = useState<Record<TabId, number>>({
plan: 0,
places: 0,
events: 0,
hotels: 0,
tickets: 0,
transport: 0,
budget: 0,
context: 0,
});
const [filtersOpenByTab, setFiltersOpenByTab] = useState<Record<TabId, boolean>>({
plan: false,
places: false,
events: false,
hotels: false,
tickets: false,
transport: false,
budget: false,
context: false,
});
const travelWidgets = useMemo(
() => widgets.filter((w) => w.type.startsWith('travel_')),
[widgets],
);
const widgetsByTab = useMemo(() => {
const map = new Map<TabId, TravelWidget[]>();
for (const tab of TABS) {
map.set(tab.id, []);
}
for (const w of travelWidgets) {
for (const tab of TABS) {
if (tab.widgetTypes.includes(w.type)) {
map.get(tab.id)!.push(w);
break;
}
}
}
return map;
}, [travelWidgets]);
const tabCounts = useMemo(() => {
const counts = new Map<TabId, number>();
for (const tab of TABS) {
const tabWidgets = widgetsByTab.get(tab.id) || [];
let count = 0;
for (const w of tabWidgets) {
const p = w.params;
if (p.days) count += (p.days as ItineraryDay[]).length;
else if (p.pois) count += (p.pois as POICard[]).length;
else if (p.events) count += (p.events as EventCard[]).length;
else if (p.hotels) count += (p.hotels as HotelCard[]).length;
else if (p.flights || p.ground) {
const flightsCount = ((p.flights as TransportOption[]) || []).length;
const groundCount = ((p.ground as TransportOption[]) || []).length;
if (tab.id === 'tickets') count += flightsCount;
else if (tab.id === 'transport') count += groundCount;
else count += flightsCount + groundCount;
}
else if (p.breakdown) count = 1;
else if (p.weather || p.safety) count = 1;
}
counts.set(tab.id, count);
}
return counts;
}, [widgetsByTab]);
const activeWidgets = widgetsByTab.get(activeTab) || [];
const activeTabDef = TABS.find((t) => t.id === activeTab)!;
const clarifyingWidgets = useMemo(
() => travelWidgets.filter((w) => w.type === 'travel_clarifying'),
[travelWidgets],
);
const clarifyingKey = useMemo(() => clarifyingWidgets.map((w) => w.id).join('|'), [clarifyingWidgets]);
useEffect(() => {
// If clarifying widget changes, show it again and clear old notice
setInlineNotice(null);
}, [clarifyingKey]);
useEffect(() => {
// Keep the "submitted" notice only until planning really starts.
if (!inlineNotice) return;
if (!isLoading) return;
if (loadingPhase !== 'planning') {
setInlineNotice(null);
}
}, [inlineNotice, isLoading, loadingPhase]);
const handleTabClick = useCallback((tabId: TabId) => {
setActiveTab(tabId);
}, []);
const query = queryByTab[activeTab] || '';
const queryTokens = useMemo(() => normalizeText(query).split(/\s+/).filter(Boolean), [query]);
const onlySelected = onlySelectedByTab[activeTab] || false;
const minRating = minRatingByTab[activeTab] || 0;
const filteredActiveWidgets = useMemo(() => {
const widgetsForTab = activeWidgets;
if (widgetsForTab.length === 0) return [];
const filtered: TravelWidget[] = [];
for (const w of widgetsForTab) {
if (activeTab === 'places' && w.type === 'travel_poi') {
const pois = (w.params.pois || []) as POICard[];
const list = pois
.filter((p) => (minRating > 0 ? (p.rating || 0) >= minRating : true))
.filter((p) => (onlySelected ? selectedPOIIds.has(p.id) : true))
.filter((p) => (queryTokens.length ? includesAny(getPOISearchText(p), queryTokens) : true));
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, pois: list } });
continue;
}
if (activeTab === 'events' && w.type === 'travel_events') {
const events = (w.params.events || []) as EventCard[];
const list = events
.filter((e) => (onlySelected ? selectedEventIds.has(e.id) : true))
.filter((e) => (queryTokens.length ? includesAny(getEventSearchText(e), queryTokens) : true));
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, events: list } });
continue;
}
if (activeTab === 'hotels' && w.type === 'travel_hotels') {
const hotels = (w.params.hotels || []) as HotelCard[];
const list = hotels
.filter((h) => (minRating > 0 ? (h.rating || 0) >= minRating : true))
.filter((h) => (onlySelected ? selectedHotelId === h.id : true))
.filter((h) => (queryTokens.length ? includesAny(getHotelSearchText(h), queryTokens) : true));
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, hotels: list } });
continue;
}
if ((activeTab === 'tickets' || activeTab === 'transport') && w.type === 'travel_transport') {
const flights = (w.params.flights || []) as TransportOption[];
const ground = (w.params.ground || []) as TransportOption[];
const baseList = activeTab === 'tickets' ? flights : ground;
const list = baseList
.filter((t) => (onlySelected ? selectedTransportId === t.id : true))
.filter((t) => (queryTokens.length ? includesAny(getTransportSearchText(t), queryTokens) : true));
if (list.length > 0) {
filtered.push({
...w,
params: {
...w.params,
flights: activeTab === 'tickets' ? list : [],
ground: activeTab === 'transport' ? list : [],
},
});
}
continue;
}
// plan/budget/context: keep as-is (we still show empty state below if nothing meaningful)
filtered.push(w);
}
return filtered;
}, [
activeWidgets,
activeTab,
onlySelected,
minRating,
queryTokens,
selectedPOIIds,
selectedEventIds,
selectedHotelId,
selectedTransportId,
]);
const hasRenderableContent = useMemo(() => {
if (activeTab === 'plan') {
// treat itinerary as present only when it has days
for (const w of activeWidgets) {
if (w.type === 'travel_itinerary' && Array.isArray(w.params.days) && (w.params.days as ItineraryDay[]).length > 0) {
return true;
}
}
return false;
}
if (activeTab === 'budget') {
return filteredActiveWidgets.some((w) => Boolean(w.params.breakdown));
}
if (activeTab === 'context') {
return filteredActiveWidgets.some((w) => Boolean(w.params.weather || w.params.safety || w.params.tips));
}
return filteredActiveWidgets.length > 0;
}, [activeTab, activeWidgets, filteredActiveWidgets]);
const askMore = useCallback(() => {
if (!onAction) return;
const q = queryByTab[activeTab]?.trim();
const encoded = q ? `search:${activeTab}:${q}` : `search:${activeTab}`;
onAction(encoded);
}, [activeTab, onAction, queryByTab]);
return (
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col shadow-sm">
{!inlineNotice && clarifyingWidgets.map((w) => (
<div key={w.id} className="p-3 border-b border-border/20">
<TravelWidgetRenderer
widget={w}
onClarifyingAnswer={onClarifyingAnswer}
onInlineNotice={(text) => setInlineNotice(text)}
/>
</div>
))}
<div className="px-3 py-2 border-b border-border/30 bg-surface">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-ui-sm text-muted">
{inlineNotice ? (
<>
<Check className="w-3.5 h-3.5 text-green-600" />
<span className="text-green-600">{inlineNotice}</span>
</>
) : (
<>
{isLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
<span className="text-secondary">
{isLoading && loadingPhase === 'planning' && 'Анализ запроса'}
{isLoading && loadingPhase === 'collecting' && 'Сбор данных'}
{isLoading && loadingPhase === 'building' && 'План по дням'}
{isLoading && loadingPhase === 'routing' && 'Маршрут по дорогам'}
{!isLoading && isResearching && 'Подбор вариантов'}
{!isLoading && !isResearching && 'Готово к действиям'}
</span>
</>
)}
</div>
<div className="flex items-center gap-2 text-ui-sm text-muted">
<span>
Точек: <span className="text-secondary">{routePointCount}</span>
</span>
<span className="w-1 h-1 rounded-full bg-border/60" />
<span>
Маршрут:{' '}
<span className="text-secondary">
{hasRouteDirection ? 'дороги' : routePointCount >= 2 ? 'линии' : 'нет'}
</span>
</span>
</div>
</div>
</div>
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base">
{TABS.map((tab) => {
const count = tabCounts.get(tab.id) || 0;
const isActive = activeTab === tab.id;
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
isActive
? 'bg-surface/60 text-primary border-accent'
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
}`}
>
<Icon className="w-3.5 h-3.5" />
{tab.label}
<span
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
count > 0 ? '' : 'opacity-0'
} ${isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'}`}
aria-hidden={count === 0}
>
{count > 0 ? count : '0'}
</span>
</button>
);
})}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="p-3"
>
<TabToolbar
tab={activeTab}
query={queryByTab[activeTab] || ''}
setQuery={(value) => setQueryByTab((prev) => ({ ...prev, [activeTab]: value }))}
onlySelected={onlySelectedByTab[activeTab] || false}
setOnlySelected={(value) => setOnlySelectedByTab((prev) => ({ ...prev, [activeTab]: value }))}
minRating={minRatingByTab[activeTab] || 0}
setMinRating={(value) => setMinRatingByTab((prev) => ({ ...prev, [activeTab]: value }))}
filtersOpen={filtersOpenByTab[activeTab] || false}
setFiltersOpen={(value) => setFiltersOpenByTab((prev) => ({ ...prev, [activeTab]: value }))}
isLoading={isLoading}
onAskMore={activeTab === 'budget' || activeTab === 'context' ? undefined : askMore}
showAskMore={Boolean((queryByTab[activeTab] || '').trim())}
/>
{isLoading && travelWidgets.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs">Собираю данные...</span>
</div>
) : !hasRenderableContent ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
<AlertCircle className="w-5 h-5 opacity-40" />
<span className="text-xs">{activeTabDef.emptyLabel}</span>
{(activeTab === 'tickets' || activeTab === 'transport') && (
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
Билеты и транспорт появляются после планирования (или нажмите «Найти ещё», чтобы попросить AI собрать варианты).
</span>
)}
{activeTab === 'plan' && (
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
План строится в 4 шага: анализ запроса сбор мест/событий/отелей маршрут по дням дороги на карте.
</span>
)}
</div>
) : (
filteredActiveWidgets.map((widget) => (
<TravelWidgetRenderer
key={widget.id}
widget={widget}
onAddEventToMap={onAddEventToMap}
onAddPOIToMap={onAddPOIToMap}
onSelectHotel={onSelectHotel}
onSelectTransport={onSelectTransport}
onClarifyingAnswer={onClarifyingAnswer}
onAction={onAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
availablePois={availablePois}
availableEvents={availableEvents}
onItineraryUpdate={onItineraryUpdate}
onValidateItineraryWithLLM={onValidateItineraryWithLLM}
/>
))
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -26,6 +26,14 @@ import {
Ticket,
HelpCircle,
CloudSun,
Cloud,
Sun,
CloudRain,
CloudLightning,
Snowflake,
CloudFog,
Wind,
Droplets,
Shield,
AlertTriangle,
Info,
@@ -35,7 +43,9 @@ import {
ShieldAlert,
ShieldCheck,
Phone,
Pencil,
} from 'lucide-react';
import { EditableItinerary } from '@/components/EditableItinerary';
import type {
EventCard,
POICard,
@@ -47,6 +57,8 @@ import type {
MapPoint,
RouteSegment,
WeatherAssessment,
DailyForecast,
WeatherIcon,
SafetyAssessment,
RestrictionItem,
TravelTip,
@@ -103,7 +115,7 @@ function EventCardComponent({ event, onAddToMap, isSelected }: EventCardComponen
{event.tags && event.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{event.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 text-[10px] bg-surface/60 text-muted rounded-full">
<span key={tag} className="px-2 py-0.5 text-2xs bg-surface/60 text-muted rounded-full">
{tag}
</span>
))}
@@ -172,27 +184,20 @@ const categoryIconsMap: Record<string, typeof MapPin> = {
function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps) {
const [imgError, setImgError] = useState(false);
const [expanded, setExpanded] = useState(false);
const Icon = categoryIconsMap[poi.category] || MapPin;
const hasPhoto = poi.photos && poi.photos.length > 0 && !imgError;
const scheduleEntries = useMemo(() => {
if (!poi.schedule) return [];
return Object.entries(poi.schedule);
}, [poi.schedule]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
className={`flex-shrink-0 w-[260px] rounded-xl border overflow-hidden transition-all ${
className={`flex-shrink-0 w-[180px] rounded-xl border overflow-hidden transition-all ${
isSelected
? 'border-accent/50 bg-accent/10'
: 'border-border/40 bg-elevated/40 hover:border-border'
}`}
>
{/* Photo */}
<div className="relative w-full h-[140px] bg-surface/60 overflow-hidden">
<div className="relative w-full h-[100px] bg-surface/60 overflow-hidden">
{hasPhoto ? (
<img
src={poi.photos![0]}
@@ -203,109 +208,52 @@ function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icon className="w-10 h-10 text-muted/30" />
<Icon className="w-8 h-8 text-muted/20" />
</div>
)}
{/* Category badge */}
<span className="absolute top-2 left-2 px-2 py-0.5 text-[10px] font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
<span className="absolute top-1.5 left-1.5 px-1.5 py-px text-3xs font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
{categoryLabels[poi.category] || poi.category}
</span>
{/* Add to map button */}
{onAddToMap && (
<button
onClick={(e) => { e.stopPropagation(); onAddToMap(poi); }}
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
title="На карту"
>
{isSelected ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{isSelected ? <Check className="w-3 h-3" /> : <Plus className="w-3 h-3" />}
</button>
)}
{/* Photo count */}
{poi.photos && poi.photos.length > 1 && (
<span className="absolute bottom-2 right-2 px-1.5 py-0.5 text-[10px] bg-black/60 text-white rounded-full backdrop-blur-sm">
{poi.photos.length} фото
</span>
)}
</div>
{/* Content */}
<div className="p-3">
<h4 className="text-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
<div className="p-2">
<h4 className="text-ui-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
{/* Rating + Reviews */}
<div className="flex items-center gap-2 mt-1.5">
<div className="flex items-center gap-1.5 mt-1">
{(poi.rating ?? 0) > 0 && (
<span className="flex items-center gap-1 text-xs font-medium text-amber-500">
<Star className="w-3 h-3 fill-current" />
<span className="flex items-center gap-0.5 text-2xs font-medium text-amber-500">
<Star className="w-2.5 h-2.5 fill-current" />
{poi.rating!.toFixed(1)}
</span>
)}
{(poi.reviewCount ?? 0) > 0 && (
<span className="text-[10px] text-muted">
{poi.reviewCount} отзывов
</span>
<span className="text-3xs text-muted">({poi.reviewCount})</span>
)}
{(poi.duration ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted ml-auto">
<Clock className="w-2.5 h-2.5" />
{poi.duration} мин
{(poi.price ?? 0) > 0 && (
<span className="text-2xs font-medium text-accent ml-auto">
{poi.price?.toLocaleString('ru-RU')}
</span>
)}
</div>
{/* Description */}
{poi.description && (
<p className="text-xs text-secondary mt-1.5 line-clamp-2">{poi.description}</p>
<p className="text-2xs text-secondary mt-1 line-clamp-2 leading-snug">{poi.description}</p>
)}
{/* Address */}
{poi.address && (
<div className="flex items-start gap-1 mt-1.5">
<MapPin className="w-3 h-3 text-muted flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-muted line-clamp-1">{poi.address}</span>
</div>
)}
{/* Price */}
{(poi.price ?? 0) > 0 && (
<div className="mt-1.5">
<span className="text-xs font-medium text-accent">
{poi.price?.toLocaleString('ru-RU')} {poi.currency || '₽'}
</span>
</div>
)}
{/* Expandable schedule */}
{scheduleEntries.length > 0 && (
<div className="mt-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-[10px] text-accent hover:text-accent/80 transition-colors"
>
<Clock className="w-2.5 h-2.5" />
{expanded ? 'Скрыть расписание' : 'Расписание'}
{expanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mt-1.5">
{scheduleEntries.map(([day, hours]) => (
<div key={day} className="flex justify-between text-[10px]">
<span className="text-muted font-medium">{day}</span>
<span className="text-secondary">{hours}</span>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{(poi.duration ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-3xs text-muted mt-1">
<Clock className="w-2.5 h-2.5" />
~{poi.duration} мин
</span>
)}
</div>
</motion.div>
@@ -536,6 +484,50 @@ function TransportCardComponent({ option, onSelect, isSelected }: TransportCardC
);
}
// --- Timeline Step Marker ---
const stepIconMap: Record<string, typeof MapPin> = {
attraction: Camera,
museum: Camera,
park: MapPin,
restaurant: Utensils,
food: Utensils,
theater: Music,
entertainment: Music,
shopping: Tag,
religious: MapPin,
viewpoint: Camera,
hotel: Hotel,
transport: Navigation,
transfer: Navigation,
airport: Plane,
event: Ticket,
};
function TimelineStepMarker({ index, refType, isLast, hasSegment }: {
index: number;
refType: string;
isLast: boolean;
hasSegment: boolean;
}) {
const Icon = stepIconMap[refType] || MapPin;
const showLine = !isLast || hasSegment;
return (
<div className="flex flex-col items-center flex-shrink-0">
<div className="relative w-7 h-7 rounded-full bg-accent/15 border-2 border-accent/40 flex items-center justify-center group-hover:border-accent/60 transition-colors">
<Icon className="w-3 h-3 text-accent" />
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-accent text-white text-[8px] font-bold flex items-center justify-center leading-none">
{index}
</span>
</div>
{showLine && (
<div className="w-px flex-1 min-h-[16px] bg-gradient-to-b from-accent/30 to-border/20 mt-1" />
)}
</div>
);
}
// --- Transport Segment Card ---
function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
@@ -553,33 +545,31 @@ function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
}
return (
<div className="mx-2 my-1 p-2 rounded-lg bg-accent/5 border border-accent/15">
<div className="flex items-center gap-2 mb-1.5">
<Navigation className="w-3 h-3 text-accent" />
<span className="text-[10px] text-muted uppercase tracking-wide">Как добраться</span>
{distKm && (
<span className="text-[10px] text-secondary">{distKm} км</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="text-[10px] text-secondary">~{durationMin} мин</span>
)}
</div>
<div className="flex gap-2">
{segment.transportOptions.map((opt) => {
const Icon = modeIcons[opt.mode] || Navigation;
return (
<div
key={opt.mode}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/50 border border-border/30"
>
<Icon className="w-3 h-3 text-muted" />
<span className="text-[11px] text-secondary">{opt.label}</span>
<span className="text-[11px] font-medium text-primary">
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
<div className="ml-3 pl-5 border-l border-dashed border-accent/20">
<div className="flex items-center gap-3 py-1.5">
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Navigation className="w-2.5 h-2.5 text-accent/60" />
{distKm && <span>{distKm} км</span>}
{distKm && durationMin !== null && durationMin > 0 && <span className="text-border">·</span>}
{durationMin !== null && durationMin > 0 && <span>~{durationMin} мин</span>}
</div>
<div className="flex gap-1.5 flex-wrap">
{segment.transportOptions.map((opt) => {
const Icon = modeIcons[opt.mode] || Navigation;
return (
<span
key={opt.mode}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/8 text-[10px] text-secondary"
>
<Icon className="w-2.5 h-2.5 text-accent/50" />
{opt.label}
<span className="font-medium text-primary">
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
</span>
</span>
</div>
);
})}
);
})}
</div>
</div>
</div>
);
@@ -591,10 +581,16 @@ interface ItineraryWidgetProps {
days: ItineraryDay[];
budget?: BudgetBreakdown;
segments?: RouteSegment[];
dailyForecast?: DailyForecast[];
pois?: POICard[];
events?: EventCard[];
onItineraryUpdate?: (days: ItineraryDay[]) => void;
onValidateWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
}
function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
function ItineraryWidget({ days, segments, dailyForecast, pois, events, onItineraryUpdate, onValidateWithLLM }: ItineraryWidgetProps) {
const [expandedDay, setExpandedDay] = useState<number>(0);
const [isEditMode, setIsEditMode] = useState(false);
const findSegment = useCallback((fromTitle: string, toTitle: string): RouteSegment | undefined => {
if (!segments || segments.length === 0) return undefined;
@@ -603,87 +599,142 @@ function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
);
}, [segments]);
const findWeather = useCallback((date: string): DailyForecast | undefined => {
if (!dailyForecast || dailyForecast.length === 0) return undefined;
return dailyForecast.find((d) => d.date === date);
}, [dailyForecast]);
let globalStep = 0;
if (isEditMode && pois && events) {
return (
<EditableItinerary
days={days}
pois={pois}
events={events}
dailyForecast={dailyForecast}
onApply={(editedDays) => {
onItineraryUpdate?.(editedDays);
setIsEditMode(false);
}}
onCancel={() => setIsEditMode(false)}
onValidateWithLLM={onValidateWithLLM}
/>
);
}
return (
<div className="space-y-2">
{days.map((day, idx) => (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
className="border border-border/40 rounded-xl overflow-hidden"
>
<button
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
className="w-full flex items-center justify-between p-3 bg-elevated/40 hover:bg-elevated/60 transition-colors"
{days.map((day, idx) => {
const dayStartStep = globalStep;
globalStep += day.items.length;
const dayWeather = findWeather(day.date) || (dailyForecast && dailyForecast[idx]);
const WeatherDayIcon = dayWeather ? (weatherIconMap[dayWeather.icon] || CloudSun) : null;
const weatherColor = dayWeather ? (weatherIconColors[dayWeather.icon] || 'text-sky-400') : '';
return (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
<span className="text-xs font-bold text-accent">Д{idx + 1}</span>
</div>
<div className="text-left">
<div className="text-sm font-medium text-primary">{day.date}</div>
<div className="text-xs text-muted">{day.items.length} активностей</div>
</div>
</div>
{expandedDay === idx ? (
<ChevronUp className="w-4 h-4 text-muted" />
) : (
<ChevronDown className="w-4 h-4 text-muted" />
)}
</button>
<AnimatePresence>
{expandedDay === idx && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="p-3 space-y-0">
{day.items.map((item, itemIdx) => {
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
return (
<div key={`${item.refId}-${itemIdx}`}>
<div className="flex items-start gap-3 p-2 rounded-lg bg-surface/30">
<div className="flex flex-col items-center">
<div className="w-2 h-2 rounded-full bg-accent" />
{(itemIdx < day.items.length - 1 || segment) && (
<div className="w-0.5 h-8 bg-border/40 mt-1" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-xs text-accent font-mono">{item.startTime}</span>
)}
<span className="text-sm text-primary">{item.title}</span>
</div>
{item.note && (
<p className="text-xs text-muted mt-0.5">{item.note}</p>
)}
{(item.cost ?? 0) > 0 && (
<span className="text-xs text-secondary">
~{item.cost} {item.currency || '₽'}
</span>
)}
</div>
</div>
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
<TransportSegmentCard segment={segment} />
)}
</div>
);
})}
<button
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
>
<div className="flex items-center gap-2.5">
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center leading-none">
{idx + 1}
</span>
<div className="text-left">
<span className="text-[13px] font-medium text-primary">{day.date}</span>
<span className="text-[11px] text-muted ml-2">{day.items.length} мест</span>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
<div className="flex items-center gap-2">
{idx === 0 && (pois && pois.length > 0 || events && events.length > 0) && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setIsEditMode(true); }}
className="w-7 h-7 inline-flex items-center justify-center text-muted hover:text-accent bg-surface/30 hover:bg-accent/10 rounded-lg transition-colors border border-border/30 hover:border-accent/30"
title="Редактировать маршрут"
aria-label="Редактировать маршрут"
>
<Pencil className="w-3 h-3" />
</button>
)}
{dayWeather && WeatherDayIcon && (
<div className="flex items-center gap-1.5" title={dayWeather.conditions}>
<WeatherDayIcon className={`w-3.5 h-3.5 ${weatherColor}`} />
<span className="text-[10px] text-secondary">
{formatTemp(dayWeather.tempMin)}..{formatTemp(dayWeather.tempMax)}
</span>
</div>
)}
<ChevronDown className={`w-4 h-4 text-muted transition-transform duration-200 ${expandedDay === idx ? 'rotate-180' : ''}`} />
</div>
</button>
<AnimatePresence>
{expandedDay === idx && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3.5 pb-3 pt-1">
{day.items.map((item, itemIdx) => {
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
const stepNum = dayStartStep + itemIdx + 1;
const isLast = itemIdx === day.items.length - 1;
return (
<div key={`${item.refId}-${itemIdx}`} className="group">
<div className="flex items-start gap-2.5 py-1.5">
<TimelineStepMarker
index={stepNum}
refType={item.refType}
isLast={isLast && !segment}
hasSegment={!!segment}
/>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-[11px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
{item.startTime}
</span>
)}
<span className="text-[13px] font-medium text-primary truncate">{item.title}</span>
</div>
{item.note && (
<p className="text-[11px] text-muted mt-0.5 line-clamp-2 leading-relaxed">{item.note}</p>
)}
{(item.cost ?? 0) > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-accent/80 mt-0.5">
<DollarSign className="w-2.5 h-2.5" />
~{item.cost?.toLocaleString('ru-RU')} {item.currency || '₽'}
</span>
)}
</div>
</div>
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
<TransportSegmentCard segment={segment} />
)}
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}
@@ -762,9 +813,10 @@ function BudgetWidget({ breakdown }: BudgetWidgetProps) {
interface ClarifyingQuestionsWidgetProps {
questions: ClarifyingQuestion[];
onAnswer: (field: string, value: string) => void;
onSubmittedNotice?: (text: string) => void;
}
function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsWidgetProps) {
function ClarifyingQuestionsWidget({ questions, onAnswer, onSubmittedNotice }: ClarifyingQuestionsWidgetProps) {
const [answers, setAnswers] = useState<Record<string, string>>({});
const [submitted, setSubmitted] = useState(false);
@@ -779,18 +831,20 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
setSubmitted(true);
const combined = filled.map(([field, value]) => `${field}: ${value}`).join('\n');
onAnswer('_batch', combined);
}, [answers, onAnswer]);
onSubmittedNotice?.('Детали отправлены, планирую маршрут…');
}, [answers, onAnswer, onSubmittedNotice]);
if (submitted) {
if (onSubmittedNotice) return null;
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 rounded-xl border border-green-500/30 bg-green-500/5"
className="px-3 py-2 rounded-lg border border-green-500/25 bg-green-500/5"
>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
<span className="text-sm text-green-600">Детали отправлены, планирую маршрут...</span>
<Check className="w-3.5 h-3.5 text-green-600" />
<span className="text-[12px] leading-4 text-green-600">Детали отправлены, планирую маршрут</span>
</div>
</motion.div>
);
@@ -851,6 +905,119 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
);
}
function TipsCollapsible({ tips, tipIcons }: { tips: TravelTip[]; tipIcons: Record<string, typeof Info> }) {
const [open, setOpen] = useState(false);
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-1 w-full text-left"
>
<Lightbulb className="w-3.5 h-3.5 text-accent" />
<span className="text-xs font-medium text-primary">Советы</span>
<span className="px-1.5 py-0.5 text-[10px] bg-accent/20 text-accent rounded-full">{tips.length}</span>
<span className="ml-auto">
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="space-y-2 mt-2">
{tips.map((tip, i) => {
const TipIcon = tipIcons[tip.category] || Info;
return (
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<p className="text-xs text-secondary">{tip.text}</p>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// --- Weather Icon Mapping ---
const weatherIconMap: Record<WeatherIcon | string, typeof Sun> = {
sun: Sun,
cloud: Cloud,
'cloud-sun': CloudSun,
rain: CloudRain,
storm: CloudLightning,
snow: Snowflake,
fog: CloudFog,
wind: Wind,
};
const weatherIconColors: Record<WeatherIcon | string, string> = {
sun: 'text-amber-400',
cloud: 'text-slate-400',
'cloud-sun': 'text-sky-400',
rain: 'text-blue-400',
storm: 'text-purple-400',
snow: 'text-cyan-300',
fog: 'text-slate-300',
wind: 'text-teal-400',
};
function formatTemp(temp: number): string {
return `${temp > 0 ? '+' : ''}${Math.round(temp)}°`;
}
function formatDateShort(dateStr: string): string {
try {
const d = new Date(dateStr + 'T00:00:00');
const weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const day = d.getDate();
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
return `${weekdays[d.getDay()]}, ${day} ${months[d.getMonth()]}`;
} catch {
return dateStr;
}
}
function DailyForecastRow({ day }: { day: DailyForecast }) {
const Icon = weatherIconMap[day.icon] || CloudSun;
const iconColor = weatherIconColors[day.icon] || 'text-sky-400';
return (
<div className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-surface/30 transition-colors group">
<span className="text-[10px] text-muted w-[70px] flex-shrink-0 leading-tight">
{formatDateShort(day.date)}
</span>
<Icon className={`w-4 h-4 ${iconColor} flex-shrink-0`} />
<span className="text-[11px] font-medium text-primary w-[52px] text-right flex-shrink-0">
{formatTemp(day.tempMin)}..{formatTemp(day.tempMax)}
</span>
<span className="text-[10px] text-secondary truncate flex-1">
{day.conditions}
</span>
{day.rainChance && day.rainChance !== 'низкая' && (
<span className="flex items-center gap-0.5 flex-shrink-0">
<Droplets className="w-2.5 h-2.5 text-blue-400" />
<span className="text-[9px] text-blue-400">{day.rainChance}</span>
</span>
)}
{day.tip && (
<span className="hidden group-hover:block absolute right-0 top-full z-10 p-2 bg-elevated border border-border/40 rounded-lg text-[10px] text-secondary max-w-[200px] shadow-lg">
{day.tip}
</span>
)}
</div>
);
}
// --- Travel Context Widget ---
interface TravelContextWidgetProps {
@@ -862,6 +1029,8 @@ interface TravelContextWidgetProps {
}
function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo }: TravelContextWidgetProps) {
const [showAllDays, setShowAllDays] = useState(false);
const safetyColors: Record<string, { bg: string; text: string; icon: typeof ShieldCheck }> = {
safe: { bg: 'bg-green-500/10', text: 'text-green-400', icon: ShieldCheck },
caution: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', icon: Shield },
@@ -893,29 +1062,68 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
const safetyStyle = safetyColors[safety.level] || safetyColors.safe;
const SafetyIcon = safetyStyle.icon;
const dailyForecast = weather.dailyForecast || [];
const visibleDays = showAllDays ? dailyForecast : dailyForecast.slice(0, 4);
const hasMoreDays = dailyForecast.length > 4;
return (
<div className="space-y-3">
{/* Weather */}
<div className="p-4 rounded-xl border border-border/40 bg-elevated/40">
<div className="flex items-center gap-2 mb-3">
<CloudSun className="w-4 h-4 text-sky-400" />
<h4 className="text-sm font-medium text-primary">Погода</h4>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-secondary">
{weather.tempMin > 0 ? '+' : ''}{weather.tempMin}° ... {weather.tempMax > 0 ? '+' : ''}{weather.tempMax}°C
<CloudSun className="w-4 h-4 text-sky-400" />
<h4 className="text-sm font-medium text-primary">Погода</h4>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-xs text-secondary">
<Thermometer className="w-3 h-3 text-orange-400" />
{formatTemp(weather.tempMin)}..{formatTemp(weather.tempMax)}
</span>
<span className="flex items-center gap-1 text-xs text-secondary">
<Umbrella className="w-3 h-3 text-blue-400" />
{weather.rainChance}
</span>
</div>
<div className="flex items-center gap-2">
<Umbrella className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs text-secondary">Осадки: {weather.rainChance}</span>
</div>
</div>
<p className="text-xs text-secondary mt-2">{weather.summary}</p>
<p className="text-xs text-secondary">{weather.summary}</p>
{weather.clothing && (
<p className="text-xs text-muted mt-1.5 italic">{weather.clothing}</p>
<p className="text-[11px] text-muted mt-1.5 italic">{weather.clothing}</p>
)}
{/* Daily forecast */}
{dailyForecast.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/30">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-muted uppercase tracking-wide">Прогноз по дням</span>
<span className="text-[10px] text-muted">{dailyForecast.length} дн.</span>
</div>
<div className="space-y-0">
{visibleDays.map((day) => (
<DailyForecastRow key={day.date} day={day} />
))}
</div>
{hasMoreDays && (
<button
onClick={() => setShowAllDays(!showAllDays)}
className="flex items-center gap-1 mt-1 px-2 py-1 text-[10px] text-accent hover:text-accent-hover transition-colors"
>
{showAllDays ? (
<>
<ChevronUp className="w-3 h-3" />
Свернуть
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
Ещё {dailyForecast.length - 4} дн.
</>
)}
</button>
)}
</div>
)}
</div>
@@ -965,23 +1173,9 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
</div>
)}
{/* Tips */}
{/* Tips (collapsed by default) */}
{tips.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-1">
<Lightbulb className="w-3.5 h-3.5 text-accent" />
<span className="text-xs font-medium text-primary">Советы</span>
</div>
{tips.map((tip, i) => {
const TipIcon = tipIcons[tip.category] || Info;
return (
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<p className="text-xs text-secondary">{tip.text}</p>
</div>
);
})}
</div>
<TipsCollapsible tips={tips} tipIcons={tipIcons} />
)}
{/* Best time info */}
@@ -1008,10 +1202,15 @@ interface TravelWidgetRendererProps {
onSelectTransport?: (option: TransportOption) => void;
onClarifyingAnswer?: (field: string, value: string) => void;
onAction?: (kind: string) => void;
onInlineNotice?: (text: string) => void;
selectedEventIds?: Set<string>;
selectedPOIIds?: Set<string>;
selectedHotelId?: string;
selectedTransportId?: string;
availablePois?: POICard[];
availableEvents?: EventCard[];
onItineraryUpdate?: (days: ItineraryDay[]) => void;
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
}
export function TravelWidgetRenderer({
@@ -1022,10 +1221,15 @@ export function TravelWidgetRenderer({
onSelectTransport,
onClarifyingAnswer,
onAction,
onInlineNotice,
selectedEventIds = new Set(),
selectedPOIIds = new Set(),
selectedHotelId,
selectedTransportId,
availablePois,
availableEvents,
onItineraryUpdate,
onValidateItineraryWithLLM,
}: TravelWidgetRendererProps) {
switch (widget.type) {
case 'travel_context': {
@@ -1130,10 +1334,20 @@ export function TravelWidgetRenderer({
const days = (widget.params.days || []) as ItineraryDay[];
const budgetData = widget.params.budget as BudgetBreakdown | undefined;
const segmentsData = (widget.params.segments || []) as RouteSegment[];
const forecastData = (widget.params.dailyForecast || []) as DailyForecast[];
if (days.length === 0) return null;
return (
<WidgetSection title="Маршрут по дням" icon={<Calendar className="w-4 h-4" />} count={days.length}>
<ItineraryWidget days={days} budget={budgetData} segments={segmentsData} />
<ItineraryWidget
days={days}
budget={budgetData}
segments={segmentsData}
dailyForecast={forecastData}
pois={availablePois}
events={availableEvents}
onItineraryUpdate={onItineraryUpdate}
onValidateWithLLM={onValidateItineraryWithLLM}
/>
</WidgetSection>
);
}
@@ -1151,6 +1365,7 @@ export function TravelWidgetRenderer({
<ClarifyingQuestionsWidget
questions={questions}
onAnswer={onClarifyingAnswer || (() => {})}
onSubmittedNotice={onInlineNotice}
/>
);
}

View File

@@ -30,9 +30,13 @@ import type {
RoutePoint,
TravelSuggestion,
TravelPlanRequest,
LearningCourse,
LearningUserProfile,
LearningEnrollment,
LearningTask,
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || '';
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
@@ -47,6 +51,17 @@ function getAuthHeaders(): HeadersInit {
return headers;
}
function getAuthHeadersWithoutContentType(): HeadersInit {
const headers: HeadersInit = {};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEvent> {
const response = await fetch(`${API_BASE}/api/chat`, {
method: 'POST',
@@ -497,6 +512,197 @@ export async function deleteLesson(id: string): Promise<void> {
}
}
// --- New Learning Cabinet API ---
export async function fetchLearningCourses(params?: {
category?: string;
difficulty?: string;
search?: string;
limit?: number;
offset?: number;
}): Promise<{ courses: LearningCourse[]; total: number }> {
const searchParams = new URLSearchParams();
if (params?.category) searchParams.set('category', params.category);
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
if (params?.search) searchParams.set('search', params.search);
searchParams.set('limit', String(params?.limit ?? 20));
searchParams.set('offset', String(params?.offset ?? 0));
const response = await fetch(`${API_BASE}/api/v1/learning/courses?${searchParams}`, {
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error(`Courses fetch failed: ${response.status}`);
return response.json();
}
export async function fetchLearningCourse(slug: string): Promise<LearningCourse | null> {
const response = await fetch(`${API_BASE}/api/v1/learning/courses/${slug}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Course fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchLearningProfile(): Promise<{ profile: LearningUserProfile | null; exists: boolean }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/profile`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401) return { profile: null, exists: false };
throw new Error(`Profile fetch failed: ${response.status}`);
}
return response.json();
}
export async function saveLearningProfile(data: {
displayName?: string;
profile?: Record<string, unknown>;
onboardingCompleted?: boolean;
}): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/profile`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Profile save failed: ${response.status}`);
return response.json();
}
export async function submitLearningOnboarding(data: {
displayName?: string;
answers: Record<string, string>;
}): Promise<{ success: boolean; profile: Record<string, unknown> }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/onboarding`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Onboarding submit failed: ${response.status}`);
return response.json();
}
export async function submitResume(fileId: string, extractedText: string): Promise<{ success: boolean; profile: Record<string, unknown> }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/resume`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ fileId, extractedText }),
});
if (!response.ok) throw new Error(`Resume submit failed: ${response.status}`);
return response.json();
}
export interface UploadedUserFile {
id: string;
filename: string;
fileType: string;
fileSize: number;
status?: string;
}
export interface UploadedFileContent {
id: string;
filename: string;
extractedText: string;
}
export async function uploadUserFile(file: File): Promise<UploadedUserFile> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/api/v1/files/upload`, {
method: 'POST',
headers: getAuthHeadersWithoutContentType(),
body: formData,
});
if (!response.ok) {
throw new Error(`File upload failed: ${response.status}`);
}
return response.json();
}
export async function fetchUploadedFileContent(fileId: string): Promise<UploadedFileContent> {
const response = await fetch(`${API_BASE}/api/v1/files/${fileId}/content`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`File content fetch failed: ${response.status}`);
}
return response.json();
}
export async function analyzeUploadedFile(fileId: string): Promise<{ extractedText?: string }> {
const response = await fetch(`${API_BASE}/api/v1/files/${fileId}/analyze`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`File analyze failed: ${response.status}`);
}
return response.json();
}
export async function createSandboxSession(payload?: { taskId?: string; image?: string }): Promise<{ id: string; status: string }> {
const response = await fetch(`${API_BASE}/api/v1/sandbox/sessions`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
taskId: payload?.taskId,
image: payload?.image,
}),
});
if (!response.ok) {
throw new Error(`Sandbox create failed: ${response.status}`);
}
return response.json();
}
export async function enrollInCourse(courseId: string): Promise<LearningEnrollment> {
const response = await fetch(`${API_BASE}/api/v1/learning/enroll`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ courseId }),
});
if (!response.ok) {
if (response.status === 409) throw new Error('Already enrolled');
throw new Error(`Enroll failed: ${response.status}`);
}
return response.json();
}
export async function fetchEnrollments(): Promise<{ enrollments: LearningEnrollment[] }> {
const response = await fetch(`${API_BASE}/api/v1/learning/enrollments`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401) return { enrollments: [] };
throw new Error(`Enrollments fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchEnrollment(id: string): Promise<{ enrollment: LearningEnrollment; tasks: LearningTask[] } | null> {
const response = await fetch(`${API_BASE}/api/v1/learning/enrollments/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Enrollment fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchAdminDashboard(): Promise<DashboardStats> {
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
headers: getAuthHeaders(),
@@ -1091,6 +1297,7 @@ export async function* streamTravelAgent(
userLocation?: { lat: number; lng: number; name?: string };
},
chatId?: string,
signal?: AbortSignal,
): AsyncGenerator<StreamEvent> {
const chatHistory = history.flatMap(([user, assistant]) => [
['human', user],
@@ -1132,6 +1339,7 @@ export async function* streamTravelAgent(
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
signal,
});
if (!response.ok) {
@@ -1146,32 +1354,102 @@ export async function* streamTravelAgent(
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
try {
while (true) {
if (signal?.aborted) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
const { done, value } = await reader.read();
if (done) break;
for (const line of lines) {
if (line.trim()) {
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 {
// skip malformed NDJSON lines
}
}
}
}
if (buffer.trim() && !signal?.aborted) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
// skip malformed final buffer
}
}
} finally {
reader.releaseLock();
}
}
export async function* streamMedicineConsult(
symptoms: string,
city?: string,
history?: [string, string][],
signal?: AbortSignal
): AsyncGenerator<StreamEvent> {
const response = await fetch(`${API_BASE}/api/v1/medicine/consult`, {
method: 'POST',
headers: getAuthHeaders(),
signal,
body: JSON.stringify({
symptoms,
city,
history,
}),
});
if (!response.ok) {
throw new Error(`Medicine consult request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
if (signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as StreamEvent;
yield event;
} catch {
console.warn('Failed to parse travel agent event:', line);
// skip malformed NDJSON lines
}
}
}
}
if (buffer.trim()) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
console.warn('Failed to parse final travel agent buffer:', buffer);
if (buffer.trim() && !signal?.aborted) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
// skip malformed final buffer
}
}
} finally {
reader.releaseLock();
}
}

View File

@@ -1,13 +1,14 @@
/**
* Конфигурация видимости пунктов меню
*
* Управляется через переменные окружения:
* - NEXT_PUBLIC_DISABLED_ROUTES — список отключённых маршрутов через запятую
*
* Пример: NEXT_PUBLIC_DISABLED_ROUTES=/travel,/medicine,/finance,/learning
*
* NEXT_PUBLIC_ENABLED_ROUTES — список включённых маршрутов через запятую.
* Если задан — показываются ТОЛЬКО перечисленные маршруты (+ корень "/" всегда виден).
* Если пуст или не задан — показываются все маршруты.
*
* Пример: NEXT_PUBLIC_ENABLED_ROUTES=/travel,/medicine,/discover
*/
type RouteId =
type RouteId =
| '/'
| '/discover'
| '/spaces'
@@ -18,39 +19,45 @@ type RouteId =
| '/learning'
| '/settings';
const parseDisabledRoutes = (): Set<string> => {
const envValue = process.env.NEXT_PUBLIC_DISABLED_ROUTES || '';
const ALWAYS_VISIBLE: ReadonlySet<string> = new Set(['/', '/settings']);
const parseEnabledRoutes = (): Set<string> | null => {
const envValue = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!envValue.trim()) {
return new Set();
return null;
}
const routes = envValue
.split(',')
.map(route => route.trim())
.filter(route => route.startsWith('/'));
.map((route) => route.trim())
.filter((route) => route.startsWith('/'));
return new Set(routes);
};
const disabledRoutes = parseDisabledRoutes();
const enabledRoutes = parseEnabledRoutes();
/**
* Проверяет, включён ли маршрут для отображения
* Проверяет, включён ли маршрут для отображения.
* Если NEXT_PUBLIC_ENABLED_ROUTES задан — показываем только то, что в списке + ALWAYS_VISIBLE.
* Если не задан — показываем всё.
*/
export function isRouteEnabled(route: string): boolean {
return !disabledRoutes.has(route);
if (ALWAYS_VISIBLE.has(route)) return true;
if (enabledRoutes === null) return true;
return enabledRoutes.has(route);
}
/**
* Фильтрует массив пунктов меню по включённым маршрутам
*/
export function filterMenuItems<T extends { href: string }>(items: T[]): T[] {
return items.filter(item => isRouteEnabled(item.href));
return items.filter((item) => isRouteEnabled(item.href));
}
/**
* Получает список отключённых маршрутов
* Получает список включённых маршрутов (или null если все включены)
*/
export function getDisabledRoutes(): string[] {
return Array.from(disabledRoutes);
export function getEnabledRoutes(): string[] | null {
return enabledRoutes ? Array.from(enabledRoutes) : null;
}

View File

@@ -0,0 +1,94 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
export type ThemeMode = 'light' | 'dim';
const THEME_STORAGE_KEY = 'gooseek_theme';
function readStoredTheme(): ThemeMode | null {
try {
const v = localStorage.getItem(THEME_STORAGE_KEY);
if (v === 'light' || v === 'dim') return v;
return null;
} catch {
return null;
}
}
function prefersDark(): boolean {
if (typeof window === 'undefined') return false;
return !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
}
function applyTheme(theme: ThemeMode): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (theme === 'dim') root.classList.add('theme-dim');
else root.classList.remove('theme-dim');
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// ignore storage failures (private mode, quota, etc.)
}
}
function resolveInitialTheme(): ThemeMode {
if (typeof document !== 'undefined' && document.documentElement.classList.contains('theme-dim')) {
return 'dim';
}
const stored = typeof window !== 'undefined' ? readStoredTheme() : null;
if (stored) return stored;
return prefersDark() ? 'dim' : 'light';
}
type ThemeContextValue = {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>('light');
useEffect(() => {
const initial = resolveInitialTheme();
setThemeState(initial);
applyTheme(initial);
}, []);
const setTheme = useCallback((next: ThemeMode) => {
setThemeState(next);
applyTheme(next);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dim' : 'light');
}, [theme, setTheme]);
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== THEME_STORAGE_KEY) return;
const v = e.newValue;
if (v !== 'light' && v !== 'dim') return;
setThemeState(v);
applyTheme(v);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider');
}
return ctx;
}

View File

@@ -0,0 +1,375 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import type { ItineraryDay, ItineraryItem, POICard, EventCard, DailyForecast } from '../types';
export interface ValidationResult {
valid: boolean;
reason?: string;
severity: 'block' | 'warn';
}
export interface EditableDay {
date: string;
items: ItineraryItem[];
}
export type CustomItemType = 'food' | 'walk' | 'rest' | 'shopping' | 'custom';
interface UseEditableItineraryParams {
initialDays: ItineraryDay[];
poisMap: Map<string, POICard>;
eventsMap: Map<string, EventCard>;
dailyForecast?: DailyForecast[];
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
}
export interface LLMValidationWarning {
dayIdx: number;
itemIdx?: number;
message: string;
}
export interface LLMValidationResponse {
valid: boolean;
warnings: LLMValidationWarning[];
suggestions: LLMValidationWarning[];
}
const WEEKDAY_MAP: Record<string, number> = {
'Пн': 1, 'Вт': 2, 'Ср': 3, 'Чт': 4, 'Пт': 5, 'Сб': 6, 'Вс': 0,
'пн': 1, 'вт': 2, 'ср': 3, 'чт': 4, 'пт': 5, 'сб': 6, 'вс': 0,
};
function getWeekdayName(date: Date): string {
const names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
return names[date.getDay()];
}
function parseTimeToMinutes(time: string): number {
const parts = time.split(':');
if (parts.length !== 2) return -1;
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isNaN(h) || isNaN(m)) return -1;
return h * 60 + m;
}
function isScheduleClosed(scheduleValue: string): boolean {
const lower = scheduleValue.toLowerCase();
return lower === 'выходной' || lower === 'закрыто' || lower === 'closed' || lower === '-';
}
export function validateItemPlacement(
item: ItineraryItem,
targetDate: string,
targetDayItems: ItineraryItem[],
poisMap: Map<string, POICard>,
eventsMap: Map<string, EventCard>,
insertIndex?: number,
): ValidationResult {
const targetDateObj = new Date(targetDate + 'T00:00:00');
if (isNaN(targetDateObj.getTime())) {
return { valid: true, severity: 'warn' };
}
if (item.refType === 'poi' && item.refId) {
const poi = poisMap.get(item.refId);
if (poi?.schedule && Object.keys(poi.schedule).length > 0) {
const weekday = getWeekdayName(targetDateObj);
const scheduleEntry = poi.schedule[weekday];
if (scheduleEntry && isScheduleClosed(scheduleEntry)) {
return {
valid: false,
reason: `${item.title} закрыт в ${weekday} (${targetDate})`,
severity: 'block',
};
}
}
}
if (item.refType === 'event' && item.refId) {
const event = eventsMap.get(item.refId);
if (event) {
if (event.dateStart) {
const eventStart = new Date(event.dateStart + 'T00:00:00');
const eventEnd = event.dateEnd
? new Date(event.dateEnd + 'T00:00:00')
: eventStart;
if (targetDateObj < eventStart || targetDateObj > eventEnd) {
const range = event.dateEnd
? `${event.dateStart}${event.dateEnd}`
: event.dateStart;
return {
valid: false,
reason: `${item.title} проходит ${range}, нельзя поставить на ${targetDate}`,
severity: 'block',
};
}
}
}
}
const isDuplicate = targetDayItems.some(
(existing, idx) =>
existing.refId === item.refId &&
existing.refId !== '' &&
idx !== insertIndex,
);
if (isDuplicate) {
return {
valid: false,
reason: `${item.title} уже есть в этом дне`,
severity: 'block',
};
}
if (item.refType !== 'food' && item.refType !== 'walk' && item.refType !== 'rest' &&
item.refType !== 'shopping' && item.refType !== 'custom') {
if ((!item.lat || item.lat === 0) && (!item.lng || item.lng === 0)) {
return {
valid: false,
reason: `${item.title} не имеет координат — не будет отображён на карте`,
severity: 'warn',
};
}
}
if (item.startTime && item.endTime) {
const itemStart = parseTimeToMinutes(item.startTime);
const itemEnd = parseTimeToMinutes(item.endTime);
if (itemStart >= 0 && itemEnd >= 0) {
for (let i = 0; i < targetDayItems.length; i++) {
if (i === insertIndex) continue;
const other = targetDayItems[i];
if (!other.startTime || !other.endTime) continue;
const otherStart = parseTimeToMinutes(other.startTime);
const otherEnd = parseTimeToMinutes(other.endTime);
if (otherStart < 0 || otherEnd < 0) continue;
if (itemStart < otherEnd && itemEnd > otherStart) {
return {
valid: false,
reason: `Конфликт времени: ${item.title} (${item.startTime}-${item.endTime}) пересекается с ${other.title} (${other.startTime}-${other.endTime})`,
severity: 'block',
};
}
}
}
}
return { valid: true, severity: 'warn' };
}
export function useEditableItinerary({
initialDays,
poisMap,
eventsMap,
onValidateWithLLM,
}: UseEditableItineraryParams) {
const [editableDays, setEditableDays] = useState<EditableDay[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [llmWarnings, setLlmWarnings] = useState<LLMValidationWarning[]>([]);
const [llmSuggestions, setLlmSuggestions] = useState<LLMValidationWarning[]>([]);
const [isValidating, setIsValidating] = useState(false);
const originalDaysRef = useRef<ItineraryDay[]>([]);
const hasChanges = useMemo(() => {
if (!isEditing) return false;
return JSON.stringify(editableDays) !== JSON.stringify(originalDaysRef.current);
}, [editableDays, isEditing]);
const startEditing = useCallback(() => {
const copy: EditableDay[] = initialDays.map((d) => ({
date: d.date,
items: d.items.map((item) => ({ ...item })),
}));
originalDaysRef.current = initialDays;
setEditableDays(copy);
setIsEditing(true);
setLlmWarnings([]);
setLlmSuggestions([]);
}, [initialDays]);
const stopEditing = useCallback(() => {
setIsEditing(false);
setEditableDays([]);
setLlmWarnings([]);
setLlmSuggestions([]);
}, []);
const resetChanges = useCallback(() => {
const copy: EditableDay[] = originalDaysRef.current.map((d) => ({
date: d.date,
items: d.items.map((item) => ({ ...item })),
}));
setEditableDays(copy);
setLlmWarnings([]);
setLlmSuggestions([]);
}, []);
const moveItem = useCallback(
(
fromDayIdx: number,
fromItemIdx: number,
toDayIdx: number,
toItemIdx: number,
): ValidationResult => {
const days = editableDays;
if (
fromDayIdx < 0 || fromDayIdx >= days.length ||
toDayIdx < 0 || toDayIdx >= days.length
) {
return { valid: false, reason: 'Неверный индекс дня', severity: 'block' };
}
const item = days[fromDayIdx].items[fromItemIdx];
if (!item) {
return { valid: false, reason: 'Элемент не найден', severity: 'block' };
}
const targetItems = fromDayIdx === toDayIdx
? days[toDayIdx].items.filter((_, i) => i !== fromItemIdx)
: [...days[toDayIdx].items];
const validation = validateItemPlacement(
item,
days[toDayIdx].date,
targetItems,
poisMap,
eventsMap,
);
if (!validation.valid) {
return validation;
}
setEditableDays((prev) => {
const next = prev.map((d) => ({
date: d.date,
items: [...d.items],
}));
const [movedItem] = next[fromDayIdx].items.splice(fromItemIdx, 1);
const adjustedToIdx =
fromDayIdx === toDayIdx && toItemIdx > fromItemIdx
? toItemIdx - 1
: toItemIdx;
next[toDayIdx].items.splice(adjustedToIdx, 0, movedItem);
return next;
});
return { valid: true, severity: 'warn' };
},
[editableDays, poisMap, eventsMap],
);
const addItem = useCallback(
(dayIdx: number, item: ItineraryItem): ValidationResult => {
if (dayIdx < 0 || dayIdx >= editableDays.length) {
return { valid: false, reason: 'Неверный индекс дня', severity: 'block' };
}
const validation = validateItemPlacement(
item,
editableDays[dayIdx].date,
editableDays[dayIdx].items,
poisMap,
eventsMap,
);
if (!validation.valid) {
return validation;
}
setEditableDays((prev) => {
const next = prev.map((d) => ({
date: d.date,
items: [...d.items],
}));
next[dayIdx].items.push(item);
return next;
});
return { valid: true, severity: 'warn' };
},
[editableDays, poisMap, eventsMap],
);
const removeItem = useCallback((dayIdx: number, itemIdx: number) => {
setEditableDays((prev) => {
const next = prev.map((d) => ({
date: d.date,
items: [...d.items],
}));
if (dayIdx >= 0 && dayIdx < next.length) {
next[dayIdx].items.splice(itemIdx, 1);
}
return next;
});
}, []);
const addCustomItem = useCallback(
(
dayIdx: number,
title: string,
refType: CustomItemType,
durationMin: number,
): ValidationResult => {
const newItem: ItineraryItem = {
refType,
refId: `custom-${Date.now()}`,
title,
lat: 0,
lng: 0,
note: `~${durationMin} мин`,
};
return addItem(dayIdx, newItem);
},
[addItem],
);
const applyChanges = useCallback(async (): Promise<LLMValidationResponse | null> => {
if (!onValidateWithLLM) return null;
setIsValidating(true);
setLlmWarnings([]);
setLlmSuggestions([]);
try {
const result = await onValidateWithLLM(editableDays);
if (result) {
setLlmWarnings(result.warnings);
setLlmSuggestions(result.suggestions);
}
return result;
} finally {
setIsValidating(false);
}
}, [editableDays, onValidateWithLLM]);
return {
editableDays,
isEditing,
hasChanges,
isValidating,
llmWarnings,
llmSuggestions,
startEditing,
stopEditing,
resetChanges,
moveItem,
addItem,
removeItem,
addCustomItem,
applyChanges,
validatePlacement: (item: ItineraryItem, dayIdx: number) =>
validateItemPlacement(
item,
editableDays[dayIdx]?.date ?? '',
editableDays[dayIdx]?.items ?? [],
poisMap,
eventsMap,
),
};
}

View File

@@ -0,0 +1,282 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import type { StreamEvent, LearningPhase } from '../types';
import { generateId } from '../api';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
export interface LearningChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
widgets?: Array<{ type: string; data: Record<string, unknown> }>;
createdAt: Date;
}
interface UseLearningChatOptions {
enrollmentId?: string;
courseTitle?: string;
profileContext?: string;
planContext?: string;
}
export function useLearningChat(options: UseLearningChatOptions = {}) {
const [messages, setMessages] = useState<LearningChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadingPhase, setLoadingPhase] = useState<LearningPhase>('idle');
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return;
const userMessage: LearningChatMessage = {
id: generateId(),
role: 'user',
content: content.trim(),
createdAt: new Date(),
};
const assistantMessage: LearningChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
isStreaming: true,
widgets: [],
createdAt: new Date(),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
setLoadingPhase('taskDesign');
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]);
}
return acc;
}, [] as [string, string][]);
const systemInstructions = [
options.courseTitle ? `Текущий курс: ${options.courseTitle}` : '',
options.enrollmentId ? `ID записи: ${options.enrollmentId}` : '',
options.planContext ? `План обучения: ${options.planContext}` : '',
].filter(Boolean).join('\n');
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/chat`, {
method: 'POST',
headers,
signal: abortController.signal,
body: JSON.stringify({
message: {
messageId: assistantMessage.id,
chatId: userMessage.id,
content: content.trim(),
},
optimizationMode: 'balanced',
history,
locale: 'ru',
answerMode: 'learning',
learningMode: true,
systemInstructions,
chatModel: { providerId: '', key: '' },
}),
});
if (!response.ok) throw new Error(`Chat failed: ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
let fullContent = '';
const collectedWidgets: Array<{ type: string; data: Record<string, unknown> }> = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as StreamEvent;
processLearningEvent(event, assistantMessage.id, {
setMessages,
setLoadingPhase,
fullContent: { get: () => fullContent, set: (v: string) => { fullContent = v; } },
collectedWidgets,
});
} catch {
// skip unparseable lines
}
}
}
if (buffer.trim()) {
try {
const event = JSON.parse(buffer) as StreamEvent;
processLearningEvent(event, assistantMessage.id, {
setMessages,
setLoadingPhase,
fullContent: { get: () => fullContent, set: (v: string) => { fullContent = v; } },
collectedWidgets,
});
} catch {
// skip
}
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, isStreaming: false, widgets: collectedWidgets }
: m
)
);
} catch (error) {
if ((error as Error).name === 'AbortError') {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id ? { ...m, isStreaming: false } : m
)
);
} else {
const errMsg = error instanceof Error ? error.message : 'Ошибка';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: `Ошибка: ${errMsg}`, isStreaming: false }
: m
)
);
}
} finally {
setIsLoading(false);
setLoadingPhase('idle');
abortControllerRef.current = null;
}
}, [isLoading, messages, options]);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setLoadingPhase('idle');
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m))
);
}, []);
const clearChat = useCallback(() => {
setMessages([]);
setLoadingPhase('idle');
}, []);
return {
messages,
isLoading,
loadingPhase,
sendMessage,
stopGeneration,
clearChat,
};
}
interface ProcessContext {
setMessages: React.Dispatch<React.SetStateAction<LearningChatMessage[]>>;
setLoadingPhase: React.Dispatch<React.SetStateAction<LearningPhase>>;
fullContent: { get: () => string; set: (v: string) => void };
collectedWidgets: Array<{ type: string; data: Record<string, unknown> }>;
}
function processLearningEvent(
event: StreamEvent,
assistantId: string,
ctx: ProcessContext
): void {
switch (event.type) {
case 'textChunk': {
if (event.chunk && typeof event.chunk === 'string') {
const newContent = ctx.fullContent.get() + event.chunk;
ctx.fullContent.set(newContent);
ctx.setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: newContent } : m
)
);
}
break;
}
case 'block': {
if (event.block && typeof event.block === 'object') {
const block = event.block as { type: string; data: unknown };
if (block.type === 'text' && typeof block.data === 'string') {
ctx.fullContent.set(block.data);
ctx.setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: block.data as string } : m
)
);
} else if (block.type === 'widget' && block.data) {
const widget = block.data as { type: string; params: Record<string, unknown> };
ctx.collectedWidgets.push({ type: widget.type, data: widget.params || {} });
if (widget.type === 'learning_task') {
ctx.setLoadingPhase('taskDesign');
} else if (widget.type === 'learning_plan') {
ctx.setLoadingPhase('planBuilding');
} else if (widget.type === 'learning_quiz') {
ctx.setLoadingPhase('onboarding');
} else if (widget.type === 'learning_evaluation') {
ctx.setLoadingPhase('verifying');
} else if (widget.type === 'learning_sandbox_status') {
ctx.setLoadingPhase('verifying');
} else if (widget.type === 'learning_progress') {
ctx.setLoadingPhase('idle');
}
}
}
break;
}
case 'updateBlock': {
if (event.patch && Array.isArray(event.patch)) {
for (const p of event.patch) {
if (p.op === 'replace' && p.path === '/data' && typeof p.value === 'string') {
ctx.fullContent.set(p.value);
ctx.setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: p.value as string } : m
)
);
}
}
}
break;
}
case 'researchComplete':
ctx.setLoadingPhase('idle');
break;
case 'messageEnd':
ctx.setLoadingPhase('idle');
break;
}
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { streamMedicineConsult } from '../api';
export interface MedicineWidget {
id: string;
type: string;
params: Record<string, unknown>;
}
export interface MedicineChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
widgets: MedicineWidget[];
createdAt: Date;
}
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export function useMedicineChat() {
const [messages, setMessages] = useState<MedicineChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const historyRef = useRef<[string, string][]>([]);
const sendMessage = useCallback(
async (content: string, city?: string) => {
if (!content.trim() || isLoading) return;
const userMessage: MedicineChatMessage = {
id: generateId(),
role: 'user',
content: content.trim(),
widgets: [],
createdAt: new Date(),
};
const assistantMessage: MedicineChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
isStreaming: true,
widgets: [],
createdAt: new Date(),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
try {
let fullContent = '';
const widgets: MedicineWidget[] = [];
const stream = streamMedicineConsult(
content.trim(),
city?.trim() || undefined,
historyRef.current,
controller.signal
);
for await (const event of stream) {
const chunkText = event.chunk || (event.data as Record<string, unknown>)?.chunk;
if (event.type === 'textChunk' && typeof chunkText === 'string') {
fullContent += chunkText;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMessage.id ? { ...m, content: fullContent } : m))
);
}
if (event.type === 'block' && event.block?.type === 'widget') {
const data = event.block.data as { widgetType?: string; params?: Record<string, unknown> };
if (!data.widgetType) continue;
widgets.push({
id: event.block.id,
type: data.widgetType,
params: data.params || {},
});
setMessages((prev) =>
prev.map((m) => (m.id === assistantMessage.id ? { ...m, widgets: [...widgets] } : m))
);
}
if (event.type === 'messageEnd') {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, isStreaming: false, content: fullContent, widgets: [...widgets] }
: m
)
);
}
}
if (fullContent.trim()) {
historyRef.current.push([userMessage.content, fullContent]);
historyRef.current = historyRef.current.slice(-8);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Ошибка консультации';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id ? { ...m, content: `Ошибка: ${message}`, isStreaming: false } : m
)
);
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
},
[isLoading]
);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)));
}, []);
const clearChat = useCallback(() => {
setMessages([]);
historyRef.current = [];
}, []);
return {
messages,
isLoading,
sendMessage,
stopGeneration,
clearChat,
};
}

View File

@@ -2,7 +2,6 @@
import { useState, useCallback, useRef } from 'react';
import type {
TravelMessage,
RoutePoint,
TravelSuggestion,
TravelPreferences,
@@ -59,6 +58,13 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
const [routeDirection, setRouteDirection] = useState<RouteDirection | null>(null);
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]);
const [isResearching, setIsResearching] = useState(false);
const [loadingPhase, setLoadingPhase] = useState<'idle' | 'planning' | 'collecting' | 'building' | 'routing'>('idle');
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
const [selectedHotelId, setSelectedHotelId] = useState<string | undefined>();
const [selectedTransportId, setSelectedTransportId] = useState<string | undefined>();
const abortControllerRef = useRef<AbortController | null>(null);
const chatIdRef = useRef<string>(generateId());
const lastUserQueryRef = useRef<string>('');
@@ -108,9 +114,14 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
createdAt: new Date(),
};
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
setIsResearching(true);
setLoadingPhase('planning');
setClarifyingQuestions([]);
const history: [string, string][] = messages
@@ -127,7 +138,7 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
const effectiveOptions = planOptions || (isClarify ? lastPlanOptionsRef.current : undefined);
try {
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current);
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current, controller.signal);
let fullContent = '';
const collectedWidgets: TravelWidget[] = [];
@@ -152,9 +163,18 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
break;
}
case 'research':
case 'research': {
setIsResearching(true);
const substep = (block.data as Record<string, unknown>)?.substep as string | undefined;
if (substep?.includes('collect') || substep?.includes('search')) {
setLoadingPhase('collecting');
} else if (substep?.includes('itinerary') || substep?.includes('build')) {
setLoadingPhase('building');
} else if (substep?.includes('route') || substep?.includes('routing')) {
setLoadingPhase('routing');
}
break;
}
case 'widget': {
const widgetData = block.data as { widgetType: string; params: Record<string, unknown> };
@@ -200,18 +220,30 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
}
}
} 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));
if (error instanceof DOMException && error.name === 'AbortError') {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: m.content || 'Генерация остановлена', isStreaming: false }
: m
)
);
} else {
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);
setLoadingPhase('idle');
abortControllerRef.current = null;
}
}, [isLoading, messages, options]);
@@ -225,13 +257,7 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
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);
setRouteDirection(params.routeDirection as RouteDirection);
}
if (params.segments) {
@@ -349,6 +375,12 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
setBudget(null);
setClarifyingQuestions([]);
setSuggestions([]);
setRouteDirection(null);
setRouteSegments([]);
setSelectedEventIds(new Set());
setSelectedPOIIds(new Set());
setSelectedHotelId(undefined);
setSelectedTransportId(undefined);
chatIdRef.current = generateId();
lastUserQueryRef.current = '';
lastPlanOptionsRef.current = {};
@@ -380,7 +412,34 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
sendMessage(`_clarify:${combinedMessage}`);
}, [sendMessage]);
function buildSearchPrompt(scope: string, query: string | undefined): string {
const q = (query || '').trim();
const base = q ? `Запрос пользователя: "${q}".` : 'Запрос пользователя: найти больше вариантов.';
switch (scope) {
case 'places':
return `${base}\n\nНайди ещё места/достопримечательности (POI) по текущему направлению поездки. Верни только релевантные варианты с координатами, рейтингом, адресом и кратким описанием.`;
case 'events':
return `${base}\n\nНайди ещё мероприятия и события по датам поездки. Верни релевантные варианты с датами, адресом, ссылкой и координатами если возможно.`;
case 'hotels':
return `${base}\n\nНайди ещё варианты отелей по датам поездки. Верни несколько категорий (дешевле/средний/комфорт) с ценой, рейтингом, адресом и координатами.`;
case 'tickets':
return `${base}\n\одбери билеты (перелёты/поезда) из города вылета к месту назначения и обратно по датам поездки. Верни варианты с ценой, временем, длительностью, пересадками и ссылкой.`;
case 'transport':
return `${base}\n\одбери транспорт на месте (трансферы/общественный/такси/аренда) для перемещений по маршруту. Верни варианты с примерной ценой/длительностью и рекомендациями.`;
default:
return `${base}\n\nНайди ещё варианты мест, отелей и мероприятий.`;
}
}
const handleAction = useCallback((actionKind: string) => {
if (actionKind.startsWith('search:')) {
// format: search:<scope> or search:<scope>:<query>
const parts = actionKind.split(':');
const scope = parts[1] || 'all';
const query = parts.slice(2).join(':') || undefined;
sendMessage(buildSearchPrompt(scope, query));
return;
}
switch (actionKind) {
case 'save':
break;
@@ -448,10 +507,49 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
}
}, [options]);
const toggleEventSelection = useCallback((event: EventCard) => {
setSelectedEventIds((prev) => {
const next = new Set(prev);
if (next.has(event.id)) {
next.delete(event.id);
} else {
next.add(event.id);
addEventToRoute(event);
}
return next;
});
}, [addEventToRoute]);
const togglePOISelection = useCallback((poi: POICard) => {
setSelectedPOIIds((prev) => {
const next = new Set(prev);
if (next.has(poi.id)) {
next.delete(poi.id);
} else {
next.add(poi.id);
addPOIToRoute(poi);
}
return next;
});
}, [addPOIToRoute]);
const toggleHotelSelection = useCallback((hotel: HotelCard) => {
setSelectedHotelId((prev) => {
const newId = prev === hotel.id ? undefined : hotel.id;
if (newId) selectHotelOnRoute(hotel);
return newId;
});
}, [selectHotelOnRoute]);
const toggleTransportSelection = useCallback((option: TransportOption) => {
setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id));
}, []);
return {
messages,
isLoading,
isResearching,
loadingPhase,
currentRoute,
mapPoints,
events,
@@ -464,6 +562,10 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
suggestions,
routeDirection,
routeSegments,
selectedEventIds,
selectedPOIIds,
selectedHotelId,
selectedTransportId,
sendMessage,
stopGeneration,
clearChat,
@@ -474,5 +576,9 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
addEventToRoute,
addPOIToRoute,
selectHotelOnRoute,
toggleEventSelection,
togglePOISelection,
toggleHotelSelection,
toggleTransportSelection,
};
}

View File

@@ -44,7 +44,18 @@ export type WidgetType =
| 'travel_budget'
| 'travel_clarifying'
| 'travel_actions'
| 'travel_context';
| 'travel_context'
| 'learning_profile_card'
| 'learning_plan'
| 'learning_task'
| 'learning_quiz'
| 'learning_evaluation'
| 'learning_sandbox_status'
| 'learning_progress'
| 'medicine_assessment'
| 'medicine_doctors'
| 'medicine_appointments'
| 'medicine_reference';
export interface Chat {
id: string;
@@ -249,6 +260,7 @@ export interface FinanceMarket {
region: string;
}
// Legacy lesson types (kept for backward compat)
export interface Lesson {
id: string;
title: string;
@@ -301,6 +313,118 @@ export interface PracticeExercise {
language: string;
}
// --- New Learning Cabinet types ---
export interface LearningCourse {
id: string;
slug: string;
title: string;
shortDescription: string;
category: string;
tags: string[];
difficulty: string;
durationHours: number;
baseOutline: CourseOutline;
landing: CourseLanding;
coverImage?: string;
status: string;
enrolledCount: number;
createdAt: string;
updatedAt: string;
}
export interface CourseOutline {
modules?: CourseModule[];
total_hours?: number;
difficulty_adjusted?: string;
personalization_notes?: string;
}
export interface CourseModule {
index: number;
title: string;
description: string;
skills: string[];
estimated_hours: number;
practice_focus: string;
}
export interface CourseLanding {
hero_title?: string;
hero_subtitle?: string;
benefits?: string[];
target_audience?: string;
outcomes?: string[];
salary_range?: string;
prerequisites?: string;
faq?: Array<{ question: string; answer: string }>;
}
export interface LearningUserProfile {
userId: string;
displayName: string;
profile: Record<string, unknown>;
resumeFileId?: string;
resumeExtractedText?: string;
onboardingCompleted: boolean;
createdAt: string;
updatedAt: string;
}
export interface LearningEnrollment {
id: string;
userId: string;
courseId: string;
status: string;
plan: CourseOutline;
progress: EnrollmentProgress;
createdAt: string;
updatedAt: string;
course?: LearningCourse;
}
export interface EnrollmentProgress {
completed_modules: number[];
current_module: number;
score: number;
}
export interface LearningTask {
id: string;
enrollmentId: string;
moduleIndex: number;
title: string;
taskType: string;
instructionsMd: string;
rubric: Record<string, unknown>;
sandboxTemplate: Record<string, unknown>;
verificationCmd?: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface LearningSubmission {
id: string;
taskId: string;
sandboxSessionId?: string;
result: Record<string, unknown>;
score: number;
maxScore: number;
feedbackMd?: string;
createdAt: string;
}
export type LearningPhase =
| 'idle'
| 'onboarding'
| 'resumeParsing'
| 'profiling'
| 'planBuilding'
| 'taskDesign'
| 'sandboxPreparing'
| 'verifying';
export type UserRole = 'user' | 'admin';
export type UserTier = 'free' | 'pro' | 'business';
@@ -371,6 +495,58 @@ export interface AdminPostUpdateRequest {
status?: string;
}
// --- Connectors (Settings) ---
export type ConnectorCategory = 'search' | 'data' | 'finance' | 'notifications';
export type ConnectorId =
| 'web_search'
| 'duckduckgo'
| 'rss'
| 'coincap'
| 'exchangerate'
| 'openmeteo'
| 'github'
| 'email'
| 'telegram'
| 'push'
| 'websocket';
export type ConnectorAction = 'source' | 'notify';
export type ConnectorFieldType = 'text' | 'password' | 'textarea' | 'number' | 'url';
export interface ConnectorField {
key: string;
label: string;
type: ConnectorFieldType;
placeholder?: string;
required?: boolean;
helpText?: string;
}
export interface ConnectorConfig {
id: ConnectorId;
name: string;
description: string;
icon: string;
category: ConnectorCategory;
requiresAuth: boolean;
fields: ConnectorField[];
actions: ConnectorAction[];
}
export type UserConnectorStatus = 'connected' | 'disconnected' | 'error';
export interface UserConnector {
id: ConnectorId;
enabled: boolean;
config: Record<string, string>;
status: UserConnectorStatus;
connectedAt?: string;
errorMessage?: string;
}
export interface PlatformSettings {
id: string;
siteName: string;
@@ -624,6 +800,19 @@ export interface EventCard {
source?: string;
}
export type WeatherIcon = 'sun' | 'cloud' | 'cloud-sun' | 'rain' | 'storm' | 'snow' | 'fog' | 'wind';
export interface DailyForecast {
date: string;
tempMin: number;
tempMax: number;
conditions: string;
icon: WeatherIcon;
rainChance: string;
wind?: string;
tip?: string;
}
export interface WeatherAssessment {
summary: string;
tempMin: number;
@@ -631,6 +820,7 @@ export interface WeatherAssessment {
conditions: string;
clothing: string;
rainChance: string;
dailyForecast?: DailyForecast[];
}
export interface SafetyAssessment {

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const ALWAYS_ALLOWED = new Set(['/', '/settings', '/login', '/register', '/forgot-password', '/reset-password']);
const SYSTEM_PREFIXES = ['/_next', '/api', '/favicon', '/robots', '/sitemap'];
function parseEnabledRoutes(): Set<string> | null {
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!raw.trim()) return null;
return new Set(
raw
.split(',')
.map((r) => r.trim())
.filter((r) => r.startsWith('/')),
);
}
const enabled = parseEnabledRoutes();
function isAllowed(pathname: string): boolean {
if (ALWAYS_ALLOWED.has(pathname)) return true;
if (SYSTEM_PREFIXES.some((p) => pathname.startsWith(p))) return true;
if (enabled === null) return true;
for (const route of Array.from(enabled)) {
if (pathname === route || pathname.startsWith(route + '/')) return true;
}
if (pathname.startsWith('/admin')) return true;
return false;
}
export function middleware(request: NextRequest): NextResponse | undefined {
const { pathname } = request.nextUrl;
if (!isAllowed(pathname)) {
const url = request.nextUrl.clone();
url.pathname = '/';
return NextResponse.redirect(url, 302);
}
return undefined;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -78,8 +78,12 @@ const config: Config = {
mono: ['JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', 'monospace'],
},
fontSize: {
'4xs': ['0.5rem', { lineHeight: '0.625rem' }], // 8px
'3xs': ['0.5625rem', { lineHeight: '0.75rem' }], // 9px
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
'xs': ['0.75rem', { lineHeight: '1rem' }],
ui: ['0.8125rem', { lineHeight: '1.125rem' }], // 13px
'ui-sm': ['0.6875rem', { lineHeight: '1rem' }], // 11px
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['0.9375rem', { lineHeight: '1.5rem' }],
'lg': ['1.0625rem', { lineHeight: '1.625rem' }],