feat: CI/CD pipeline + Learning/Medicine/Travel services
- 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:
@@ -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
|
||||
|
||||
81
.gitea/workflows/deploy.yaml
Normal file
81
.gitea/workflows/deploy.yaml
Normal 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
42
.gitignore
vendored
@@ -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
|
||||
114
CONTINUE.md
114
CONTINUE.md
@@ -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 уже установлены
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
120
backend/cmd/medicine-svc/main.go
Normal file
120
backend/cmd/medicine-svc/main.go
Normal 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
|
||||
}
|
||||
540
backend/cmd/sandbox-svc/main.go
Normal file
540
backend/cmd/sandbox-svc/main.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
44
backend/deploy/k8s/gitea-deployment.yaml
Normal file
44
backend/deploy/k8s/gitea-deployment.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
68
backend/deploy/k8s/learning-svc.yaml
Normal file
68
backend/deploy/k8s/learning-svc.yaml
Normal 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
|
||||
70
backend/deploy/k8s/medicine-svc.yaml
Normal file
70
backend/deploy/k8s/medicine-svc.yaml
Normal 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
|
||||
165
backend/deploy/k8s/opensandbox.yaml
Normal file
165
backend/deploy/k8s/opensandbox.yaml
Normal 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
|
||||
78
backend/deploy/k8s/registry-with-auth.yaml
Normal file
78
backend/deploy/k8s/registry-with-auth.yaml
Normal 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
|
||||
70
backend/deploy/k8s/sandbox-svc.yaml
Normal file
70
backend/deploy/k8s/sandbox-svc.yaml
Normal 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
|
||||
68
backend/deploy/k8s/travel-svc.yaml
Normal file
68
backend/deploy/k8s/travel-svc.yaml
Normal 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
|
||||
63
backend/deploy/k8s/webui.yaml
Normal file
63
backend/deploy/k8s/webui.yaml
Normal 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
|
||||
49
backend/deploy/scripts/README-nvidia-cuda.md
Normal file
49
backend/deploy/scripts/README-nvidia-cuda.md
Normal 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
|
||||
14
backend/deploy/scripts/gitea-runner.service
Normal file
14
backend/deploy/scripts/gitea-runner.service
Normal 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
|
||||
55
backend/deploy/scripts/install-cicd-stack.sh
Normal file
55
backend/deploy/scripts/install-cicd-stack.sh
Normal 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
|
||||
248
backend/deploy/scripts/install-gitea-manifest.sh
Normal file
248
backend/deploy/scripts/install-gitea-manifest.sh
Normal 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"
|
||||
130
backend/deploy/scripts/install-gitea-registry.sh
Normal file
130
backend/deploy/scripts/install-gitea-registry.sh
Normal 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"
|
||||
65
backend/deploy/scripts/setup-gitea-runner.sh
Normal file
65
backend/deploy/scripts/setup-gitea-runner.sh
Normal 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"
|
||||
60
backend/deploy/scripts/setup-k3s-cicd.sh
Normal file
60
backend/deploy/scripts/setup-k3s-cicd.sh
Normal 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
|
||||
73
backend/deploy/scripts/setup-nvidia-cuda-ubuntu24.sh
Normal file
73
backend/deploy/scripts/setup-nvidia-cuda-ubuntu24.sh
Normal 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"
|
||||
836
backend/internal/agent/learning_orchestrator.go
Normal file
836
backend/internal/agent/learning_orchestrator.go
Normal 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
|
||||
}
|
||||
@@ -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: ")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
194
backend/internal/agent/travel_photo_cache.go
Normal file
194
backend/internal/agent/travel_photo_cache.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
642
backend/internal/db/learning_repo.go
Normal file
642
backend/internal/db/learning_repo.go
Normal 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
|
||||
}
|
||||
43
backend/internal/db/learning_repo_test.go
Normal file
43
backend/internal/db/learning_repo_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
556
backend/internal/learning/course_autogen.go
Normal file
556
backend/internal/learning/course_autogen.go
Normal 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] + "..."
|
||||
}
|
||||
79
backend/internal/learning/course_autogen_test.go
Normal file
79
backend/internal/learning/course_autogen_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
backend/internal/learning/mock_llm_test.go
Normal file
36
backend/internal/learning/mock_llm_test.go
Normal 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"
|
||||
}
|
||||
118
backend/internal/learning/plan_builder.go
Normal file
118
backend/internal/learning/plan_builder.go
Normal 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] + "..."
|
||||
}
|
||||
66
backend/internal/learning/plan_builder_test.go
Normal file
66
backend/internal/learning/plan_builder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
203
backend/internal/learning/profile_builder.go
Normal file
203
backend/internal/learning/profile_builder.go
Normal 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 "{}"
|
||||
}
|
||||
65
backend/internal/learning/profile_builder_test.go
Normal file
65
backend/internal/learning/profile_builder_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
671
backend/internal/medicine/service.go
Normal file
671
backend/internal/medicine/service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
217
backend/webui/package-lock.json
generated
217
backend/webui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
263
backend/webui/src/app/(main)/learning/courses/[slug]/page.tsx
Normal file
263
backend/webui/src/app/(main)/learning/courses/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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\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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
40
backend/webui/src/app/robots.ts
Normal file
40
backend/webui/src/app/robots.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
40
backend/webui/src/app/sitemap.ts
Normal file
40
backend/webui/src/app/sitemap.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
954
backend/webui/src/components/EditableItinerary.tsx
Normal file
954
backend/webui/src/components/EditableItinerary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
668
backend/webui/src/components/MedicineWidgetTabs.tsx
Normal file
668
backend/webui/src/components/MedicineWidgetTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
643
backend/webui/src/components/SandboxPanel.tsx
Normal file
643
backend/webui/src/components/SandboxPanel.tsx
Normal 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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
37
backend/webui/src/components/ThemeToggle.tsx
Normal file
37
backend/webui/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
649
backend/webui/src/components/TravelWidgetTabs.tsx
Normal file
649
backend/webui/src/components/TravelWidgetTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
94
backend/webui/src/lib/contexts/ThemeContext.tsx
Normal file
94
backend/webui/src/lib/contexts/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
|
||||
375
backend/webui/src/lib/hooks/useEditableItinerary.ts
Normal file
375
backend/webui/src/lib/hooks/useEditableItinerary.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
282
backend/webui/src/lib/hooks/useLearningChat.ts
Normal file
282
backend/webui/src/lib/hooks/useLearningChat.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
140
backend/webui/src/lib/hooks/useMedicineChat.ts
Normal file
140
backend/webui/src/lib/hooks/useMedicineChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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\nПодбери билеты (перелёты/поезда) из города вылета к месту назначения и обратно по датам поездки. Верни варианты с ценой, временем, длительностью, пересадками и ссылкой.`;
|
||||
case 'transport':
|
||||
return `${base}\n\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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
49
backend/webui/src/middleware.ts
Normal file
49
backend/webui/src/middleware.ts
Normal 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).*)'],
|
||||
};
|
||||
@@ -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' }],
|
||||
|
||||
Reference in New Issue
Block a user