feat: Go backend, enhanced search, new widgets, Docker deploy

Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
This commit is contained in:
home
2026-02-27 04:15:32 +03:00
parent 328d968f3f
commit 06fe57c765
285 changed files with 53132 additions and 1871 deletions

View File

@@ -0,0 +1,209 @@
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/agent"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/search"
"github.com/gooseek/backend/internal/session"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/ndjson"
)
type SearchRequest struct {
Message struct {
MessageID string `json:"messageId"`
ChatID string `json:"chatId"`
Content string `json:"content"`
} `json:"message"`
OptimizationMode string `json:"optimizationMode"`
Sources []string `json:"sources"`
History [][]string `json:"history"`
Files []string `json:"files"`
ChatModel ChatModel `json:"chatModel"`
SystemInstructions string `json:"systemInstructions"`
Locale string `json:"locale"`
AnswerMode string `json:"answerMode"`
ResponsePrefs *struct {
Format string `json:"format"`
Length string `json:"length"`
Tone string `json:"tone"`
} `json:"responsePrefs"`
LearningMode bool `json:"learningMode"`
}
type ChatModel struct {
ProviderID string `json:"providerId"`
Key string `json:"key"`
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
searchClient := search.NewSearXNGClient(cfg)
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 10 * 1024 * 1024,
ReadTimeout: time.Minute,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * 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"})
})
app.Post("/api/v1/agents/search", func(c *fiber.Ctx) error {
var req SearchRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Message.Content == "" {
return c.Status(400).JSON(fiber.Map{"error": "Message content required"})
}
providerID := req.ChatModel.ProviderID
modelKey := req.ChatModel.Key
if providerID == "" && cfg.TimewebAPIKey != "" {
providerID = "timeweb"
modelKey = "gpt-4o"
} else if providerID == "" {
providerID = "openai"
modelKey = "gpt-4o-mini"
}
llmClient, err := llm.NewClient(llm.ProviderConfig{
ProviderID: providerID,
ModelKey: modelKey,
APIKey: getAPIKey(cfg, providerID),
BaseURL: cfg.TimewebAPIBaseURL,
AgentAccessID: cfg.TimewebAgentAccessID,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to create LLM client: " + err.Error()})
}
chatHistory := make([]llm.Message, 0, len(req.History))
for _, h := range req.History {
if len(h) >= 2 {
role := llm.RoleUser
if h[0] == "ai" || h[0] == "assistant" {
role = llm.RoleAssistant
}
chatHistory = append(chatHistory, llm.Message{
Role: role,
Content: h[1],
})
}
}
mode := agent.ModeBalanced
switch req.OptimizationMode {
case "speed":
mode = agent.ModeSpeed
case "quality":
mode = agent.ModeQuality
}
var responsePrefs *agent.ResponsePrefs
if req.ResponsePrefs != nil {
responsePrefs = &agent.ResponsePrefs{
Format: req.ResponsePrefs.Format,
Length: req.ResponsePrefs.Length,
Tone: req.ResponsePrefs.Tone,
}
}
input := agent.OrchestratorInput{
ChatHistory: chatHistory,
FollowUp: req.Message.Content,
Config: agent.OrchestratorConfig{
LLM: llmClient,
SearchClient: searchClient,
Mode: mode,
Sources: req.Sources,
FileIDs: req.Files,
SystemInstructions: req.SystemInstructions,
Locale: req.Locale,
AnswerMode: req.AnswerMode,
ResponsePrefs: responsePrefs,
LearningMode: req.LearningMode,
DiscoverSvcURL: cfg.DiscoverSvcURL,
Crawl4AIURL: cfg.Crawl4AIURL,
},
}
sess := session.NewSession()
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(), 5*time.Minute)
defer cancel()
writer := ndjson.NewWriter(w)
unsubscribe := sess.Subscribe(func(eventType session.EventType, data interface{}) {
if eventType == session.EventData {
if dataMap, ok := data.(map[string]interface{}); ok {
writer.Write(dataMap)
w.Flush()
}
}
})
defer unsubscribe()
err := agent.RunOrchestrator(ctx, sess, input)
if err != nil {
ndjson.WriteError(writer, err)
}
})
return nil
})
port := cfg.AgentSvcPort
log.Printf("agent-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getAPIKey(cfg *config.Config, providerID string) string {
switch providerID {
case "timeweb":
return cfg.TimewebAPIKey
case "openai":
return cfg.OpenAIAPIKey
case "anthropic":
return cfg.AnthropicAPIKey
case "gemini", "google":
return cfg.GeminiAPIKey
default:
return ""
}
}
func init() {
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "3018")
}
}

View File

@@ -0,0 +1,232 @@
package main
import (
"bufio"
"fmt"
"io"
"log"
"net/http"
"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/pkg/config"
)
var svcURLs map[string]string
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
svcURLs = map[string]string{
"chat": cfg.ChatSvcURL,
"agents": cfg.AgentSvcURL,
"search": cfg.SearchSvcURL,
"llm": cfg.LLMSvcURL,
"scraper": cfg.ScraperSvcURL,
"memory": cfg.MemorySvcURL,
"library": cfg.LibrarySvcURL,
"thread": cfg.ThreadSvcURL,
"discover": cfg.DiscoverSvcURL,
"finance": cfg.FinanceHeatmapURL,
"learning": cfg.LearningSvcURL,
"computer": cfg.ComputerSvcURL,
}
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: time.Duration(cfg.HTTPTimeout),
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
})
app.Use(logger.New())
app.Use(cors.New(cors.Config{
AllowOrigins: strings.Join(cfg.AllowedOrigins, ","),
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
}))
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Get("/ready", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ready"})
})
app.Post("/api/chat", handleChat)
app.All("/api/*", handleProxy)
port := cfg.APIGatewayPort
log.Printf("api-gateway listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getTarget(path string) (base, rewrite string) {
switch {
case path == "/api/chat" || strings.HasPrefix(path, "/api/chat?"):
return svcURLs["chat"], "/api/v1/chat"
case strings.HasPrefix(path, "/api/v1/agents"):
return svcURLs["agents"], path
case strings.HasPrefix(path, "/api/v1/search"):
return svcURLs["search"], path
case strings.HasPrefix(path, "/api/v1/llm"), strings.HasPrefix(path, "/api/v1/providers"):
return svcURLs["llm"], path
case strings.HasPrefix(path, "/api/v1/memory"):
return svcURLs["memory"], path
case strings.HasPrefix(path, "/api/v1/library"):
return svcURLs["library"], path
case strings.HasPrefix(path, "/api/v1/threads"):
return svcURLs["thread"], path
case strings.HasPrefix(path, "/api/v1/spaces"):
return svcURLs["thread"], path
case strings.HasPrefix(path, "/api/v1/pages"):
return svcURLs["thread"], path
case strings.HasPrefix(path, "/api/v1/share"):
return svcURLs["thread"], path
case strings.HasPrefix(path, "/api/v1/discover"):
return svcURLs["discover"], path
case strings.HasPrefix(path, "/api/v1/heatmap"):
return svcURLs["finance"], path
case strings.HasPrefix(path, "/api/v1/movers"):
return svcURLs["finance"], path
case strings.HasPrefix(path, "/api/v1/markets"):
return svcURLs["finance"], path
case strings.HasPrefix(path, "/api/v1/learning"):
return svcURLs["learning"], path
case strings.HasPrefix(path, "/api/v1/computer"):
return svcURLs["computer"], path
default:
return "", ""
}
}
func handleChat(c *fiber.Ctx) error {
base := svcURLs["chat"]
if base == "" {
return c.Status(503).JSON(fiber.Map{"error": "Chat service not configured"})
}
targetURL := strings.TrimSuffix(base, "/") + "/api/v1/chat"
req, err := http.NewRequest("POST", targetURL, strings.NewReader(string(c.Body())))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
req.Header.Set("Content-Type", "application/json")
if auth := c.Get("Authorization"); auth != "" {
req.Header.Set("Authorization", auth)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return c.Status(resp.StatusCode).Send(body)
}
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) {
defer resp.Body.Close()
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
w.Write(buf[:n])
w.Flush()
}
if err == io.EOF {
break
}
if err != nil {
break
}
}
})
return nil
}
func handleProxy(c *fiber.Ctx) error {
path := c.Path()
base, rewrite := getTarget(path)
if base == "" {
return c.Status(404).JSON(fiber.Map{"error": "Not found"})
}
targetURL := strings.TrimSuffix(base, "/") + rewrite
if c.Context().QueryArgs().Len() > 0 {
targetURL += "?" + string(c.Context().QueryArgs().QueryString())
}
method := c.Method()
var body io.Reader
if method != "GET" && method != "HEAD" {
body = strings.NewReader(string(c.Body()))
}
req, err := http.NewRequest(method, targetURL, body)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
passHeaders := []string{"Authorization", "Content-Type", "Accept", "User-Agent", "Accept-Language"}
for _, h := range passHeaders {
if v := c.Get(h); v != "" {
req.Header.Set(h, v)
}
}
client := &http.Client{Timeout: time.Minute}
resp, err := client.Do(req)
if err != nil {
return handleFallback(c, path)
}
defer resp.Body.Close()
for _, h := range []string{"Content-Type", "Cache-Control", "Set-Cookie"} {
if v := resp.Header.Get(h); v != "" {
c.Set(h, v)
}
}
data, _ := io.ReadAll(resp.Body)
return c.Status(resp.StatusCode).Send(data)
}
func handleFallback(c *fiber.Ctx, path string) error {
switch {
case strings.HasPrefix(path, "/api/v1/discover"):
return c.JSON(fiber.Map{"items": []interface{}{}})
case strings.HasPrefix(path, "/api/geo-context"):
return c.JSON(fiber.Map{"country": nil, "city": nil})
case strings.HasPrefix(path, "/api/translations"):
return c.JSON(fiber.Map{})
default:
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
}
func init() {
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "3015")
}
}

View File

@@ -0,0 +1,53 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/gooseek/backend/internal/computer/browser"
)
func main() {
port := 3050
if p := os.Getenv("PORT"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
port = parsed
}
}
if p := os.Getenv("BROWSER_SVC_PORT"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
port = parsed
}
}
cfg := browser.ServerConfig{
Port: port,
MaxSessions: 20,
SessionTimeout: 30 * time.Minute,
CleanupInterval: 5 * time.Minute,
}
server := browser.NewBrowserServer(cfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("[browser-svc] Shutting down...")
cancel()
}()
log.Printf("[browser-svc] Starting browser service on port %d", port)
if err := server.Start(ctx); err != nil {
log.Fatalf("[browser-svc] Server error: %v", err)
}
}

View File

@@ -0,0 +1,235 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"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/pkg/config"
)
type ChatRequest struct {
Message struct {
MessageID string `json:"messageId"`
ChatID string `json:"chatId"`
Content string `json:"content"`
} `json:"message"`
OptimizationMode string `json:"optimizationMode"`
Sources []string `json:"sources"`
History [][]string `json:"history"`
Files []string `json:"files"`
ChatModel ChatModel `json:"chatModel"`
EmbeddingModel ChatModel `json:"embeddingModel"`
SystemInstructions string `json:"systemInstructions"`
Locale string `json:"locale"`
AnswerMode string `json:"answerMode"`
ResponsePrefs *struct {
Format string `json:"format"`
Length string `json:"length"`
Tone string `json:"tone"`
} `json:"responsePrefs"`
LearningMode bool `json:"learningMode"`
}
type ChatModel struct {
ProviderID string `json:"providerId"`
Key string `json:"key"`
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: time.Minute,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * 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"})
})
app.Get("/ready", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ready"})
})
app.Get("/metrics", func(c *fiber.Ctx) error {
c.Set("Content-Type", "text/plain; charset=utf-8")
return c.SendString(
"# HELP gooseek_up Service is up (1) or down (0)\n" +
"# TYPE gooseek_up gauge\n" +
"gooseek_up 1\n",
)
})
app.Get("/api/v1/config", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"values": fiber.Map{
"version": 1,
"setupComplete": true,
"preferences": fiber.Map{},
},
"fields": fiber.Map{},
"modelProviders": []interface{}{},
"envOnlyMode": true,
})
})
app.Post("/api/v1/chat", func(c *fiber.Ctx) error {
var req ChatRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Message.Content == "" {
return c.Status(400).JSON(fiber.Map{"error": "Message content required"})
}
isDiscoverSummary := strings.HasPrefix(req.Message.Content, "Summary: ") && len(req.Message.Content) > 9
var summaryURL string
if isDiscoverSummary {
summaryURL = strings.TrimSpace(strings.TrimPrefix(req.Message.Content, "Summary: "))
}
agentURL := strings.TrimSuffix(cfg.AgentSvcURL, "/") + "/api/v1/agents/search"
httpReq, err := http.NewRequest("POST", agentURL, bytes.NewReader(c.Body()))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
httpReq.Header.Set("Content-Type", "application/json")
if auth := c.Get("Authorization"); auth != "" {
httpReq.Header.Set("Authorization", auth)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(httpReq)
if err != nil {
log.Printf("Agent service error: %v", err)
return c.Status(503).JSON(fiber.Map{"error": "Agent service unavailable"})
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return c.Status(resp.StatusCode).Send(body)
}
c.Set("Content-Type", "application/x-ndjson")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
if isDiscoverSummary && summaryURL != "" && cfg.DiscoverSvcURL != "" {
collected := make([]string, 0)
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
var result bytes.Buffer
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) != "" {
collected = append(collected, line)
result.WriteString(line + "\n")
}
}
resp.Body.Close()
if len(collected) > 0 {
go saveArticleSummary(cfg.DiscoverSvcURL, summaryURL, collected)
}
return c.SendString(result.String())
}
return c.SendStream(resp.Body)
})
port := cfg.ChatSvcPort
log.Printf("chat-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func init() {
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "3005")
}
}
func saveArticleSummary(discoverSvcURL, articleURL string, events []string) {
if discoverSvcURL == "" || articleURL == "" || len(events) == 0 {
return
}
payload := map[string]interface{}{
"url": articleURL,
"events": events,
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("article-summary marshal error: %v", err)
return
}
url := strings.TrimSuffix(discoverSvcURL, "/") + "/api/v1/discover/article-summary"
maxRetries := 5
retryDelay := 2 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
log.Printf("article-summary request error (attempt %d): %v", attempt, err)
continue
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 2 * time.Minute}
resp, err := client.Do(req)
if err != nil {
log.Printf("article-summary save error (attempt %d): %v", attempt, err)
if attempt < maxRetries {
time.Sleep(retryDelay)
}
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Printf("article-summary saved: %s", articleURL[:min(60, len(articleURL))])
return
}
log.Printf("article-summary save failed (attempt %d): status %d", attempt, resp.StatusCode)
if attempt < maxRetries {
time.Sleep(retryDelay)
}
}
log.Printf("article-summary save failed after %d retries: %s", maxRetries, articleURL[:min(60, len(articleURL))])
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,345 @@
package main
import (
"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/db"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var database *db.PostgresDB
var collectionRepo *db.CollectionRepository
if cfg.DatabaseURL != "" {
database, err = db.NewPostgresDB(cfg.DatabaseURL)
if err != nil {
log.Printf("Database unavailable: %v (some features disabled)", err)
} else {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := database.RunMigrations(ctx); err != nil {
log.Printf("Migration warning: %v", err)
}
cancel()
defer database.Close()
collectionRepo = db.NewCollectionRepository(database)
log.Println("PostgreSQL connected")
}
}
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
if cfg.JWTSecret != "" || cfg.AuthSvcURL != "" {
app.Use(middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: false,
SkipPaths: []string{"/health", "/ready"},
}))
}
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Get("/ready", func(c *fiber.Ctx) error {
if database == nil {
return c.Status(503).JSON(fiber.Map{"status": "database unavailable"})
}
return c.JSON(fiber.Map{"status": "ready"})
})
api := app.Group("/api/v1/collections")
api.Get("/", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
collections, err := collectionRepo.GetByUserID(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get collections"})
}
return c.JSON(fiber.Map{"collections": collections})
})
api.Post("/", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
ContextEnabled bool `json:"contextEnabled"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Name == "" {
return c.Status(400).JSON(fiber.Map{"error": "Name is required"})
}
collection := &db.Collection{
UserID: userID,
Name: req.Name,
Description: req.Description,
IsPublic: req.IsPublic,
ContextEnabled: req.ContextEnabled,
}
if err := collectionRepo.Create(c.Context(), collection); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to create collection"})
}
return c.Status(201).JSON(collection)
})
api.Get("/:id", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
collectionID := c.Params("id")
userID := middleware.GetUserID(c)
collection, err := collectionRepo.GetByID(c.Context(), collectionID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get collection"})
}
if collection == nil {
return c.Status(404).JSON(fiber.Map{"error": "Collection not found"})
}
if collection.UserID != userID && !collection.IsPublic {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
items, err := collectionRepo.GetItems(c.Context(), collectionID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get items"})
}
collection.Items = items
return c.JSON(collection)
})
api.Put("/:id", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
collectionID := c.Params("id")
userID := middleware.GetUserID(c)
collection, err := collectionRepo.GetByID(c.Context(), collectionID)
if err != nil || collection == nil {
return c.Status(404).JSON(fiber.Map{"error": "Collection not found"})
}
if collection.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
ContextEnabled bool `json:"contextEnabled"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
collection.Name = req.Name
collection.Description = req.Description
collection.IsPublic = req.IsPublic
collection.ContextEnabled = req.ContextEnabled
if err := collectionRepo.Update(c.Context(), collection); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to update collection"})
}
return c.JSON(collection)
})
api.Delete("/:id", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
collectionID := c.Params("id")
userID := middleware.GetUserID(c)
collection, err := collectionRepo.GetByID(c.Context(), collectionID)
if err != nil || collection == nil {
return c.Status(404).JSON(fiber.Map{"error": "Collection not found"})
}
if collection.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
if err := collectionRepo.Delete(c.Context(), collectionID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete collection"})
}
return c.Status(204).Send(nil)
})
api.Post("/:id/items", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
collectionID := c.Params("id")
userID := middleware.GetUserID(c)
collection, err := collectionRepo.GetByID(c.Context(), collectionID)
if err != nil || collection == nil {
return c.Status(404).JSON(fiber.Map{"error": "Collection not found"})
}
if collection.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
var req struct {
ItemType string `json:"itemType"`
Title string `json:"title"`
Content string `json:"content"`
URL string `json:"url"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.ItemType == "" {
return c.Status(400).JSON(fiber.Map{"error": "itemType is required"})
}
item := &db.CollectionItem{
CollectionID: collectionID,
ItemType: req.ItemType,
Title: req.Title,
Content: req.Content,
URL: req.URL,
Metadata: req.Metadata,
}
if err := collectionRepo.AddItem(c.Context(), item); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to add item"})
}
return c.Status(201).JSON(item)
})
api.Delete("/:id/items/:itemId", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
collectionID := c.Params("id")
itemID := c.Params("itemId")
userID := middleware.GetUserID(c)
collection, err := collectionRepo.GetByID(c.Context(), collectionID)
if err != nil || collection == nil {
return c.Status(404).JSON(fiber.Map{"error": "Collection not found"})
}
if collection.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
if err := collectionRepo.RemoveItem(c.Context(), itemID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to remove item"})
}
return c.Status(204).Send(nil)
})
api.Get("/:id/context", func(c *fiber.Ctx) error {
if collectionRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Database unavailable"})
}
collectionID := c.Params("id")
userID := middleware.GetUserID(c)
collection, err := collectionRepo.GetByID(c.Context(), collectionID)
if err != nil || collection == nil {
return c.Status(404).JSON(fiber.Map{"error": "Collection not found"})
}
if collection.UserID != userID && !collection.IsPublic {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
if !collection.ContextEnabled {
return c.JSON(fiber.Map{"context": "", "enabled": false})
}
context, err := collectionRepo.GetCollectionContext(c.Context(), collectionID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get context"})
}
return c.JSON(fiber.Map{"context": context, "enabled": true})
})
port := getEnvInt("COLLECTION_SVC_PORT", 3025)
log.Printf("collection-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}

View File

@@ -0,0 +1,552 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gooseek/backend/internal/computer"
"github.com/gooseek/backend/internal/computer/connectors"
"github.com/gooseek/backend/internal/db"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/recover"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
var database *db.PostgresDB
maxRetries := 30
for i := 0; i < maxRetries; i++ {
database, err = db.NewPostgresDB(cfg.DatabaseURL)
if err == nil {
log.Println("PostgreSQL connected successfully")
break
}
log.Printf("Waiting for database (attempt %d/%d): %v", i+1, maxRetries, err)
time.Sleep(2 * time.Second)
}
if err != nil {
log.Fatalf("Failed to connect to database after %d attempts: %v", maxRetries, err)
}
taskRepo := db.NewComputerTaskRepo(database.DB())
memoryRepo := db.NewComputerMemoryRepo(database.DB())
artifactRepo := db.NewComputerArtifactRepo(database.DB())
if err := taskRepo.Migrate(); err != nil {
log.Printf("Task repo migration warning: %v", err)
}
if err := memoryRepo.Migrate(); err != nil {
log.Printf("Memory repo migration warning: %v", err)
}
if err := artifactRepo.Migrate(); err != nil {
log.Printf("Artifact repo migration warning: %v", err)
}
registry := llm.NewModelRegistry()
setupModels(registry, cfg)
connectorHub := connectors.NewConnectorHub()
setupConnectors(connectorHub, cfg)
comp := computer.NewComputer(computer.ComputerConfig{
MaxParallelTasks: 10,
MaxSubTasks: 20,
TaskTimeout: 30 * time.Minute,
SubTaskTimeout: 5 * time.Minute,
TotalBudget: 1.0,
EnableSandbox: true,
EnableScheduling: true,
SandboxImage: getEnv("SANDBOX_IMAGE", "gooseek/sandbox:latest"),
}, computer.Dependencies{
Registry: registry,
TaskRepo: taskRepo,
MemoryRepo: memoryRepo,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
comp.StartScheduler(ctx)
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
},
})
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
}))
app.Use(middleware.Logging(middleware.LoggingConfig{}))
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"service": "computer-svc",
"models": registry.Count(),
})
})
api := app.Group("/api/v1/computer")
api.Post("/execute", func(c *fiber.Ctx) error {
var req struct {
Query string `json:"query"`
UserID string `json:"userId"`
Options computer.ExecuteOptions `json:"options"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if req.Query == "" {
return c.Status(400).JSON(fiber.Map{"error": "query is required"})
}
if req.UserID == "" || req.UserID == "anonymous" {
req.UserID = "00000000-0000-0000-0000-000000000000"
}
task, err := comp.Execute(c.Context(), req.UserID, req.Query, req.Options)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(task)
})
api.Get("/tasks", func(c *fiber.Ctx) error {
userID := c.Query("userId", "")
limit := c.QueryInt("limit", 20)
offset := c.QueryInt("offset", 0)
if userID == "" || userID == "anonymous" {
userID = "00000000-0000-0000-0000-000000000000"
}
tasks, err := comp.GetUserTasks(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"tasks": tasks,
"count": len(tasks),
})
})
api.Get("/tasks/:id", func(c *fiber.Ctx) error {
taskID := c.Params("id")
task, err := comp.GetStatus(c.Context(), taskID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "task not found"})
}
return c.JSON(task)
})
api.Get("/tasks/:id/stream", func(c *fiber.Ctx) error {
taskID := c.Params("id")
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("Transfer-Encoding", "chunked")
eventCh, err := comp.Stream(c.Context(), taskID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
for event := range eventCh {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "data: %s\n\n", data)
w.Flush()
}
})
return nil
})
api.Post("/tasks/:id/resume", func(c *fiber.Ctx) error {
taskID := c.Params("id")
var req struct {
UserInput string `json:"userInput"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if err := comp.Resume(c.Context(), taskID, req.UserInput); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"status": "resumed"})
})
api.Delete("/tasks/:id", func(c *fiber.Ctx) error {
taskID := c.Params("id")
if err := comp.Cancel(c.Context(), taskID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"status": "cancelled"})
})
api.Get("/tasks/:id/artifacts", func(c *fiber.Ctx) error {
taskID := c.Params("id")
artifacts, err := artifactRepo.GetByTaskID(c.Context(), taskID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"artifacts": artifacts,
"count": len(artifacts),
})
})
api.Get("/artifacts/:id", func(c *fiber.Ctx) error {
artifactID := c.Params("id")
artifact, err := artifactRepo.GetByID(c.Context(), artifactID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "artifact not found"})
}
return c.JSON(artifact)
})
api.Get("/artifacts/:id/download", func(c *fiber.Ctx) error {
artifactID := c.Params("id")
artifact, err := artifactRepo.GetByID(c.Context(), artifactID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "artifact not found"})
}
if artifact.MimeType != "" {
c.Set("Content-Type", artifact.MimeType)
} else {
c.Set("Content-Type", "application/octet-stream")
}
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", artifact.Name))
return c.Send(artifact.Content)
})
api.Get("/models", func(c *fiber.Ctx) error {
models := registry.GetAll()
return c.JSON(fiber.Map{
"models": models,
"count": len(models),
})
})
api.Get("/connectors", func(c *fiber.Ctx) error {
info := connectorHub.GetInfo()
return c.JSON(fiber.Map{
"connectors": info,
"count": len(info),
})
})
api.Post("/connectors/:id/execute", func(c *fiber.Ctx) error {
connectorID := c.Params("id")
var req struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
result, err := connectorHub.Execute(c.Context(), connectorID, req.Action, req.Params)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
})
port := getEnv("COMPUTER_SVC_PORT", "3030")
addr := ":" + port
go func() {
log.Printf("Computer service starting on %s", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
comp.StopScheduler()
app.Shutdown()
}
func setupModels(registry *llm.ModelRegistry, cfg *config.Config) {
// Timeweb Cloud AI (приоритетный провайдер для России)
if cfg.TimewebAgentAccessID != "" && cfg.TimewebAPIKey != "" {
timewebClient, err := llm.NewTimewebClient(llm.TimewebConfig{
ProviderID: "timeweb",
ModelKey: "gpt-4o",
BaseURL: cfg.TimewebAPIBaseURL,
AgentAccessID: cfg.TimewebAgentAccessID,
APIKey: cfg.TimewebAPIKey,
ProxySource: cfg.TimewebProxySource,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "timeweb-gpt-4o",
Provider: "timeweb",
Model: "gpt-4o",
Capabilities: []llm.ModelCapability{llm.CapSearch, llm.CapFast, llm.CapVision, llm.CapCoding, llm.CapCreative, llm.CapReasoning},
CostPer1K: 0.005,
MaxContext: 128000,
MaxTokens: 16384,
Priority: 0,
Description: "GPT-4o via Timeweb Cloud AI",
}, timewebClient)
log.Println("Timeweb GPT-4o registered")
} else {
log.Printf("Failed to create Timeweb client: %v", err)
}
timewebMiniClient, err := llm.NewTimewebClient(llm.TimewebConfig{
ProviderID: "timeweb",
ModelKey: "gpt-4o-mini",
BaseURL: cfg.TimewebAPIBaseURL,
AgentAccessID: cfg.TimewebAgentAccessID,
APIKey: cfg.TimewebAPIKey,
ProxySource: cfg.TimewebProxySource,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "timeweb-gpt-4o-mini",
Provider: "timeweb",
Model: "gpt-4o-mini",
Capabilities: []llm.ModelCapability{llm.CapFast, llm.CapCoding},
CostPer1K: 0.00015,
MaxContext: 128000,
MaxTokens: 16384,
Priority: 0,
Description: "GPT-4o-mini via Timeweb Cloud AI",
}, timewebMiniClient)
log.Println("Timeweb GPT-4o-mini registered")
}
}
// OpenAI прямой (fallback если Timeweb недоступен)
if cfg.OpenAIAPIKey != "" {
openaiClient, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
ModelKey: "gpt-4o",
APIKey: cfg.OpenAIAPIKey,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "gpt-4o",
Provider: "openai",
Model: "gpt-4o",
Capabilities: []llm.ModelCapability{llm.CapSearch, llm.CapFast, llm.CapVision, llm.CapCoding, llm.CapCreative},
CostPer1K: 0.005,
MaxContext: 128000,
MaxTokens: 16384,
Priority: 10,
}, openaiClient)
}
miniClient, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
ModelKey: "gpt-4o-mini",
APIKey: cfg.OpenAIAPIKey,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "gpt-4o-mini",
Provider: "openai",
Model: "gpt-4o-mini",
Capabilities: []llm.ModelCapability{llm.CapFast, llm.CapCoding},
CostPer1K: 0.00015,
MaxContext: 128000,
MaxTokens: 16384,
Priority: 10,
}, miniClient)
}
}
if cfg.AnthropicAPIKey != "" {
opusClient, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
ModelKey: "claude-3-opus-20240229",
APIKey: cfg.AnthropicAPIKey,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "claude-3-opus",
Provider: "anthropic",
Model: "claude-3-opus-20240229",
Capabilities: []llm.ModelCapability{llm.CapReasoning, llm.CapCoding, llm.CapCreative, llm.CapLongContext},
CostPer1K: 0.015,
MaxContext: 200000,
MaxTokens: 4096,
Priority: 1,
}, opusClient)
}
sonnetClient, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
ModelKey: "claude-3-5-sonnet-20241022",
APIKey: cfg.AnthropicAPIKey,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "claude-3-sonnet",
Provider: "anthropic",
Model: "claude-3-5-sonnet-20241022",
Capabilities: []llm.ModelCapability{llm.CapCoding, llm.CapCreative, llm.CapFast},
CostPer1K: 0.003,
MaxContext: 200000,
MaxTokens: 8192,
Priority: 1,
}, sonnetClient)
}
}
if cfg.GeminiAPIKey != "" {
geminiClient, err := llm.NewGeminiClient(llm.ProviderConfig{
ProviderID: "gemini",
ModelKey: "gemini-1.5-pro",
APIKey: cfg.GeminiAPIKey,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "gemini-1.5-pro",
Provider: "gemini",
Model: "gemini-1.5-pro",
Capabilities: []llm.ModelCapability{llm.CapLongContext, llm.CapSearch, llm.CapVision, llm.CapMath},
CostPer1K: 0.00125,
MaxContext: 2000000,
MaxTokens: 8192,
Priority: 1,
}, geminiClient)
}
flashClient, err := llm.NewGeminiClient(llm.ProviderConfig{
ProviderID: "gemini",
ModelKey: "gemini-1.5-flash",
APIKey: cfg.GeminiAPIKey,
})
if err == nil {
registry.Register(llm.ModelSpec{
ID: "gemini-1.5-flash",
Provider: "gemini",
Model: "gemini-1.5-flash",
Capabilities: []llm.ModelCapability{llm.CapFast, llm.CapVision},
CostPer1K: 0.000075,
MaxContext: 1000000,
MaxTokens: 8192,
Priority: 2,
}, flashClient)
}
}
log.Printf("Registered %d models", registry.Count())
}
func setupConnectors(hub *connectors.ConnectorHub, cfg *config.Config) {
if smtpHost := getEnv("SMTP_HOST", ""); smtpHost != "" {
emailConn := connectors.NewEmailConnector(connectors.EmailConfig{
SMTPHost: smtpHost,
SMTPPort: getEnvInt("SMTP_PORT", 587),
Username: getEnv("SMTP_USERNAME", ""),
Password: getEnv("SMTP_PASSWORD", ""),
FromAddress: getEnv("SMTP_FROM", ""),
FromName: getEnv("SMTP_FROM_NAME", "GooSeek Computer"),
UseTLS: true,
AllowHTML: true,
})
hub.Register(emailConn)
log.Println("Email connector registered")
}
if botToken := getEnv("TELEGRAM_BOT_TOKEN", ""); botToken != "" {
tgConn := connectors.NewTelegramConnector(connectors.TelegramConfig{
BotToken: botToken,
})
hub.Register(tgConn)
log.Println("Telegram connector registered")
}
webhookConn := connectors.NewWebhookConnector(connectors.WebhookConfig{
Timeout: 30 * time.Second,
MaxRetries: 3,
})
hub.Register(webhookConn)
log.Println("Webhook connector registered")
if s3Endpoint := getEnv("S3_ENDPOINT", ""); s3Endpoint != "" {
storageConn, err := connectors.NewStorageConnector(connectors.StorageConfig{
Endpoint: s3Endpoint,
AccessKeyID: getEnv("S3_ACCESS_KEY", ""),
SecretAccessKey: getEnv("S3_SECRET_KEY", ""),
BucketName: getEnv("S3_BUCKET", "gooseek-artifacts"),
UseSSL: getEnv("S3_USE_SSL", "true") == "true",
Region: getEnv("S3_REGION", "us-east-1"),
PublicURL: getEnv("S3_PUBLIC_URL", ""),
})
if err == nil {
hub.Register(storageConn)
log.Println("Storage connector registered")
}
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
var i int
fmt.Sscanf(value, "%d", &i)
return i
}
return defaultValue
}

View File

@@ -0,0 +1,511 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"os"
"strings"
"sync"
"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/search"
"github.com/gooseek/backend/pkg/cache"
"github.com/gooseek/backend/pkg/config"
)
type DigestCitation struct {
Index int `json:"index"`
URL string `json:"url"`
Title string `json:"title"`
Domain string `json:"domain"`
}
type Digest struct {
Topic string `json:"topic"`
Region string `json:"region"`
ClusterTitle string `json:"clusterTitle"`
SummaryRu string `json:"summaryRu"`
Citations []DigestCitation `json:"citations"`
SourcesCount int `json:"sourcesCount"`
FollowUp []string `json:"followUp"`
Thumbnail string `json:"thumbnail"`
ShortDescription string `json:"shortDescription"`
MainURL string `json:"mainUrl"`
CreatedAt time.Time `json:"createdAt"`
}
type ArticleSummary struct {
URL string `json:"url"`
Events []string `json:"events"`
CreatedAt time.Time `json:"createdAt"`
}
type DiscoverStore struct {
digests map[string]*Digest
articleSummaries map[string]*ArticleSummary
mu sync.RWMutex
}
func NewDiscoverStore() *DiscoverStore {
return &DiscoverStore{
digests: make(map[string]*Digest),
articleSummaries: make(map[string]*ArticleSummary),
}
}
func (s *DiscoverStore) GetDigest(topic, region, title string) *Digest {
s.mu.RLock()
defer s.mu.RUnlock()
key := fmt.Sprintf("%s:%s:%s", topic, region, title)
return s.digests[key]
}
func (s *DiscoverStore) GetDigestByURL(url string) *Digest {
s.mu.RLock()
defer s.mu.RUnlock()
normalizedURL := normalizeURL(url)
for _, d := range s.digests {
if normalizeURL(d.MainURL) == normalizedURL {
return d
}
}
return nil
}
func (s *DiscoverStore) GetDigests(topic, region string) []*Digest {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Digest, 0)
prefix := fmt.Sprintf("%s:%s:", topic, region)
for k, d := range s.digests {
if strings.HasPrefix(k, prefix) {
result = append(result, d)
}
}
return result
}
func (s *DiscoverStore) UpsertDigest(d *Digest) {
s.mu.Lock()
defer s.mu.Unlock()
key := fmt.Sprintf("%s:%s:%s", d.Topic, d.Region, d.ClusterTitle)
d.CreatedAt = time.Now()
s.digests[key] = d
}
func (s *DiscoverStore) DeleteDigests(topic, region string) int {
s.mu.Lock()
defer s.mu.Unlock()
count := 0
prefix := fmt.Sprintf("%s:%s:", topic, region)
for k := range s.digests {
if strings.HasPrefix(k, prefix) {
delete(s.digests, k)
count++
}
}
return count
}
func (s *DiscoverStore) GetArticleSummary(url string) *ArticleSummary {
s.mu.RLock()
defer s.mu.RUnlock()
key := articleSummaryKey(url)
return s.articleSummaries[key]
}
func (s *DiscoverStore) SaveArticleSummary(url string, events []string) {
s.mu.Lock()
defer s.mu.Unlock()
key := articleSummaryKey(url)
s.articleSummaries[key] = &ArticleSummary{
URL: url,
Events: events,
CreatedAt: time.Now(),
}
}
func (s *DiscoverStore) DeleteArticleSummary(url string) bool {
s.mu.Lock()
defer s.mu.Unlock()
key := articleSummaryKey(url)
if _, ok := s.articleSummaries[key]; ok {
delete(s.articleSummaries, key)
return true
}
return false
}
func articleSummaryKey(url string) string {
hash := sha256.Sum256([]byte(normalizeURL(url)))
return hex.EncodeToString(hash[:16])
}
func normalizeURL(url string) string {
url = strings.TrimSpace(url)
url = strings.TrimSuffix(url, "/")
url = strings.TrimPrefix(url, "https://")
url = strings.TrimPrefix(url, "http://")
url = strings.TrimPrefix(url, "www.")
return url
}
func extractDomain(url string) string {
normalized := normalizeURL(url)
if idx := strings.Index(normalized, "/"); idx > 0 {
return normalized[:idx]
}
return normalized
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
store := NewDiscoverStore()
searchClient := search.NewSearXNGClient(cfg)
var database *db.PostgresDB
var digestRepo *db.DigestRepository
var summaryRepo *db.ArticleSummaryRepository
if cfg.DatabaseURL != "" {
database, err = db.NewPostgresDB(cfg.DatabaseURL)
if err != nil {
log.Printf("PostgreSQL unavailable: %v (falling back to in-memory)", err)
} else {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := database.RunMigrations(ctx); err != nil {
log.Printf("Migration warning: %v", err)
}
cancel()
defer database.Close()
digestRepo = db.NewDigestRepository(database)
summaryRepo = db.NewArticleSummaryRepository(database)
log.Println("PostgreSQL connected")
}
}
var redisCache *cache.RedisCache
if cfg.RedisURL != "" {
redisCache, err = cache.NewRedisCache(cfg.RedisURL, "gooseek:discover")
if err != nil {
log.Printf("Redis cache unavailable: %v (falling back to in-memory)", err)
} else {
log.Printf("Redis cache connected")
defer redisCache.Close()
}
}
_ = digestRepo
_ = summaryRepo
app := fiber.New(fiber.Config{
BodyLimit: 100 * 1024 * 1024,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Get("/ready", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ready"})
})
app.Get("/metrics", func(c *fiber.Ctx) error {
c.Set("Content-Type", "text/plain; charset=utf-8")
return c.SendString(
"# HELP gooseek_up Service is up (1) or down (0)\n" +
"# TYPE gooseek_up gauge\n" +
"gooseek_up 1\n",
)
})
app.Get("/api/v1/discover/digest", func(c *fiber.Ctx) error {
url := c.Query("url")
if url != "" {
digest := store.GetDigestByURL(url)
if digest == nil {
return c.Status(404).JSON(fiber.Map{"message": "digest not found"})
}
return c.JSON(digest)
}
topic := c.Query("topic")
region := c.Query("region")
title := c.Query("title")
if topic == "" || region == "" || title == "" {
return c.Status(400).JSON(fiber.Map{"message": "topic, region, title (or url) required"})
}
digest := store.GetDigest(topic, region, title)
if digest == nil {
return c.Status(404).JSON(fiber.Map{"message": "digest not found"})
}
return c.JSON(digest)
})
app.Post("/api/v1/discover/digest", func(c *fiber.Ctx) error {
var d Digest
if err := c.BodyParser(&d); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if d.Topic == "" || d.Region == "" || d.ClusterTitle == "" || d.SummaryRu == "" {
return c.Status(400).JSON(fiber.Map{"message": "topic, region, clusterTitle, summaryRu required"})
}
store.UpsertDigest(&d)
return c.Status(204).Send(nil)
})
app.Delete("/api/v1/discover/digest", func(c *fiber.Ctx) error {
topic := c.Query("topic")
region := c.Query("region")
if topic == "" || region == "" {
return c.Status(400).JSON(fiber.Map{"message": "topic, region required"})
}
deleted := store.DeleteDigests(topic, region)
return c.JSON(fiber.Map{"deleted": deleted})
})
app.Get("/api/v1/discover/article-summary", func(c *fiber.Ctx) error {
url := c.Query("url")
if url == "" {
return c.Status(400).JSON(fiber.Map{"message": "url required"})
}
if redisCache != nil {
events, err := redisCache.GetCachedArticleSummary(c.Context(), url)
if err == nil && len(events) > 0 {
return c.JSON(fiber.Map{"events": events})
}
}
if summaryRepo != nil {
summary, err := summaryRepo.GetByURL(c.Context(), url)
if err == nil && summary != nil {
if redisCache != nil {
redisCache.CacheArticleSummary(c.Context(), url, summary.Events, 24*time.Hour)
}
return c.JSON(fiber.Map{"events": summary.Events})
}
}
summary := store.GetArticleSummary(url)
if summary == nil {
return c.Status(404).JSON(fiber.Map{"message": "not found"})
}
return c.JSON(fiber.Map{"events": summary.Events})
})
app.Post("/api/v1/discover/article-summary", func(c *fiber.Ctx) error {
var body struct {
URL string `json:"url"`
Events []string `json:"events"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.URL == "" || len(body.Events) == 0 {
return c.Status(400).JSON(fiber.Map{"message": "url and events[] required"})
}
store.SaveArticleSummary(body.URL, body.Events)
if summaryRepo != nil {
ttl := 7 * 24 * time.Hour
if err := summaryRepo.Save(c.Context(), body.URL, body.Events, ttl); err != nil {
log.Printf("postgres save article-summary error: %v", err)
}
}
if redisCache != nil {
ttl := 24 * time.Hour
if err := redisCache.CacheArticleSummary(c.Context(), body.URL, body.Events, ttl); err != nil {
log.Printf("redis cache article-summary error: %v", err)
}
}
log.Printf("article-summary saved: %s (%d events)", body.URL[:min(60, len(body.URL))], len(body.Events))
return c.Status(204).Send(nil)
})
app.Delete("/api/v1/discover/article-summary", func(c *fiber.Ctx) error {
url := c.Query("url")
if url == "" {
return c.Status(400).JSON(fiber.Map{"message": "url required"})
}
deleted := store.DeleteArticleSummary(url)
log.Printf("article-summary deleted: %s (deleted=%v)", url[:min(60, len(url))], deleted)
return c.Status(204).Send(nil)
})
app.Get("/api/v1/discover/search", func(c *fiber.Ctx) error {
q := c.Query("q")
if q == "" {
return c.Status(400).JSON(fiber.Map{"message": "Query q is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.SearchTimeout)
defer cancel()
result, err := searchClient.Search(ctx, q, &search.SearchOptions{PageNo: 1})
if err != nil {
return c.Status(503).JSON(fiber.Map{"message": "Search failed"})
}
if len(result.Results) > 10 {
result.Results = result.Results[:10]
}
return c.JSON(fiber.Map{"results": result.Results})
})
app.Get("/api/v1/discover", func(c *fiber.Ctx) error {
topic := c.Query("topic", "tech")
region := c.Query("region", "world")
digests := store.GetDigests(topic, region)
if len(digests) > 0 {
blogs := make([]fiber.Map, len(digests))
for i, d := range digests {
content := d.ShortDescription
if content == "" && len(d.SummaryRu) > 200 {
content = d.SummaryRu[:200] + "…"
} else if content == "" {
content = d.SummaryRu
}
blogs[i] = fiber.Map{
"title": d.ClusterTitle,
"content": content,
"url": d.MainURL,
"thumbnail": d.Thumbnail,
"sourcesCount": d.SourcesCount,
"digestId": fmt.Sprintf("%s:%s:%s", d.Topic, d.Region, d.ClusterTitle),
}
}
return c.JSON(fiber.Map{"blogs": blogs})
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.SearchTimeout*2)
defer cancel()
queries := getQueriesForTopic(topic, region)
results, err := searchClient.Search(ctx, queries[0], &search.SearchOptions{
Categories: []string{"news"},
PageNo: 1,
})
if err != nil {
return c.Status(503).JSON(fiber.Map{"message": "Search failed"})
}
blogs := make([]fiber.Map, 0, 7)
for i, r := range results.Results {
if i >= 7 {
break
}
thumbnail := r.Thumbnail
if thumbnail == "" {
thumbnail = r.ThumbnailSrc
}
if thumbnail == "" {
thumbnail = r.ImgSrc
}
content := r.Content
if content == "" {
content = r.Title
}
if len(content) > 300 {
content = content[:300] + "…"
}
blogs = append(blogs, fiber.Map{
"title": r.Title,
"content": content,
"url": r.URL,
"thumbnail": thumbnail,
})
}
return c.JSON(fiber.Map{"blogs": blogs})
})
port := getEnvInt("DISCOVER_SVC_PORT", 3002)
log.Printf("discover-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getQueriesForTopic(topic, region string) []string {
queries := map[string]map[string][]string{
"tech": {
"world": {"technology news AI innovation"},
"russia": {"технологии новости IT инновации"},
"eu": {"technology news Europe AI"},
},
"finance": {
"world": {"finance news economy markets"},
"russia": {"финансы новости экономика рынки"},
"eu": {"finance news Europe economy"},
},
"sports": {
"world": {"sports news football Olympics"},
"russia": {"спорт новости футбол хоккей"},
"eu": {"sports news football Champions League"},
},
}
if topicQueries, ok := queries[topic]; ok {
if regionQueries, ok := topicQueries[region]; ok {
return regionQueries
}
if defaultQueries, ok := topicQueries["world"]; ok {
return defaultQueries
}
}
return []string{"news today"}
}
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
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,307 @@
package main
import (
"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/db"
"github.com/gooseek/backend/internal/files"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var database *db.PostgresDB
var fileRepo *db.FileRepository
if cfg.DatabaseURL != "" {
database, err = db.NewPostgresDB(cfg.DatabaseURL)
if err != nil {
log.Printf("Database unavailable: %v", err)
} else {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := database.RunMigrations(ctx); err != nil {
log.Printf("Migration warning: %v", err)
}
cancel()
defer database.Close()
fileRepo = db.NewFileRepository(database)
log.Println("PostgreSQL connected")
}
}
var llmClient llm.Client
if cfg.OpenAIAPIKey != "" {
llmClient, err = llm.NewClient(llm.ProviderConfig{
ProviderID: "openai",
ModelKey: "gpt-4o",
APIKey: cfg.OpenAIAPIKey,
})
if err != nil {
log.Printf("Failed to create OpenAI client: %v", err)
}
} else if cfg.AnthropicAPIKey != "" {
llmClient, err = llm.NewClient(llm.ProviderConfig{
ProviderID: "anthropic",
ModelKey: "claude-3-5-sonnet-20241022",
APIKey: cfg.AnthropicAPIKey,
})
if err != nil {
log.Printf("Failed to create Anthropic client: %v", err)
}
}
storagePath := os.Getenv("FILE_STORAGE_PATH")
if storagePath == "" {
storagePath = "/tmp/gooseek-files"
}
var fileAnalyzer *files.FileAnalyzer
if llmClient != nil {
fileAnalyzer = files.NewFileAnalyzer(llmClient, storagePath)
}
app := fiber.New(fiber.Config{
BodyLimit: 100 * 1024 * 1024,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
if cfg.JWTSecret != "" || cfg.AuthSvcURL != "" {
app.Use(middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: false,
SkipPaths: []string{"/health", "/ready"},
}))
}
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Get("/ready", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ready"})
})
api := app.Group("/api/v1/files")
api.Post("/upload", func(c *fiber.Ctx) error {
if fileRepo == nil || fileAnalyzer == nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "No file uploaded"})
}
if file.Size > 50*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": "File too large (max 50MB)"})
}
f, err := file.Open()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to read file"})
}
defer f.Close()
storagePath, fileSize, err := fileAnalyzer.SaveFile(file.Filename, f)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save file"})
}
buf := make([]byte, 512)
f.Seek(0, 0)
f.Read(buf)
mimeType := files.DetectMimeType(file.Filename, buf)
uploadedFile := &db.UploadedFile{
UserID: userID,
Filename: file.Filename,
FileType: mimeType,
FileSize: fileSize,
StoragePath: storagePath,
Metadata: map[string]interface{}{},
}
if err := fileRepo.Create(c.Context(), uploadedFile); err != nil {
fileAnalyzer.DeleteFile(storagePath)
return c.Status(500).JSON(fiber.Map{"error": "Failed to save file record"})
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := fileAnalyzer.AnalyzeFile(ctx, storagePath, mimeType)
if err != nil {
log.Printf("File analysis failed for %s: %v", uploadedFile.ID, err)
return
}
fileRepo.UpdateExtractedText(ctx, uploadedFile.ID, result.ExtractedText)
}()
return c.Status(201).JSON(fiber.Map{
"id": uploadedFile.ID,
"filename": uploadedFile.Filename,
"fileType": uploadedFile.FileType,
"fileSize": uploadedFile.FileSize,
"status": "processing",
})
})
api.Get("/", func(c *fiber.Ctx) error {
if fileRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
files, err := fileRepo.GetByUserID(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get files"})
}
return c.JSON(fiber.Map{"files": files})
})
api.Get("/:id", func(c *fiber.Ctx) error {
if fileRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
fileID := c.Params("id")
userID := middleware.GetUserID(c)
file, err := fileRepo.GetByID(c.Context(), fileID)
if err != nil || file == nil {
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
}
if file.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
return c.JSON(file)
})
api.Get("/:id/content", func(c *fiber.Ctx) error {
if fileRepo == nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
fileID := c.Params("id")
userID := middleware.GetUserID(c)
file, err := fileRepo.GetByID(c.Context(), fileID)
if err != nil || file == nil {
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
}
if file.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
return c.JSON(fiber.Map{
"id": file.ID,
"filename": file.Filename,
"extractedText": file.ExtractedText,
})
})
api.Post("/:id/analyze", func(c *fiber.Ctx) error {
if fileRepo == nil || fileAnalyzer == nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
fileID := c.Params("id")
userID := middleware.GetUserID(c)
file, err := fileRepo.GetByID(c.Context(), fileID)
if err != nil || file == nil {
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
}
if file.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
result, err := fileAnalyzer.AnalyzeFile(c.Context(), file.StoragePath, file.FileType)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Analysis failed: " + err.Error()})
}
fileRepo.UpdateExtractedText(c.Context(), fileID, result.ExtractedText)
return c.JSON(result)
})
api.Delete("/:id", func(c *fiber.Ctx) error {
if fileRepo == nil || fileAnalyzer == nil {
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
}
fileID := c.Params("id")
userID := middleware.GetUserID(c)
file, err := fileRepo.GetByID(c.Context(), fileID)
if err != nil || file == nil {
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
}
if file.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
fileAnalyzer.DeleteFile(file.StoragePath)
if err := fileRepo.Delete(c.Context(), fileID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete file"})
}
return c.Status(204).Send(nil)
})
port := getEnvInt("FILE_SVC_PORT", 3026)
log.Printf("file-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}

View File

@@ -0,0 +1,139 @@
package main
import (
"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/finance"
)
func main() {
heatmapSvc := finance.NewHeatmapService(finance.HeatmapConfig{
CacheTTL: 5 * time.Minute,
RefreshInterval: time.Minute,
})
app := fiber.New(fiber.Config{
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * 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"})
})
app.Get("/api/v1/heatmap/:market", func(c *fiber.Ctx) error {
market := c.Params("market")
timeRange := c.Query("range", "1d")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
heatmap, err := heatmapSvc.GetMarketHeatmap(ctx, market, timeRange)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(heatmap)
})
app.Get("/api/v1/heatmap/:market/treemap", func(c *fiber.Ctx) error {
market := c.Params("market")
timeRange := c.Query("range", "1d")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
heatmap, err := heatmapSvc.GetMarketHeatmap(ctx, market, timeRange)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
treemapData := heatmapSvc.GenerateTreemapData(heatmap)
return c.JSON(treemapData)
})
app.Get("/api/v1/heatmap/:market/grid", func(c *fiber.Ctx) error {
market := c.Params("market")
timeRange := c.Query("range", "1d")
rows := c.QueryInt("rows", 5)
cols := c.QueryInt("cols", 10)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
heatmap, err := heatmapSvc.GetMarketHeatmap(ctx, market, timeRange)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
gridData := heatmapSvc.GenerateGridData(heatmap, rows, cols)
return c.JSON(fiber.Map{"grid": gridData, "rows": rows, "cols": cols})
})
app.Get("/api/v1/heatmap/:market/sector/:sector", func(c *fiber.Ctx) error {
market := c.Params("market")
sector := c.Params("sector")
timeRange := c.Query("range", "1d")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
heatmap, err := heatmapSvc.GetSectorHeatmap(ctx, market, sector, timeRange)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(heatmap)
})
app.Get("/api/v1/movers/:market", func(c *fiber.Ctx) error {
market := c.Params("market")
count := c.QueryInt("count", 10)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
movers, err := heatmapSvc.GetTopMovers(ctx, market, count)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(movers)
})
app.Get("/api/v1/markets", func(c *fiber.Ctx) error {
markets := []map[string]interface{}{
{"id": "sp500", "name": "S&P 500", "region": "us"},
{"id": "nasdaq", "name": "NASDAQ", "region": "us"},
{"id": "dow", "name": "Dow Jones", "region": "us"},
{"id": "moex", "name": "MOEX", "region": "ru"},
{"id": "crypto", "name": "Cryptocurrency", "region": "global"},
{"id": "forex", "name": "Forex", "region": "global"},
}
return c.JSON(fiber.Map{"markets": markets})
})
port := getEnvInt("PORT", 3033)
log.Printf("finance-heatmap-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}

View File

@@ -0,0 +1,553 @@
package main
import (
"context"
"encoding/json"
"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/labs"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/pkg/config"
"github.com/google/uuid"
)
type ReportStore struct {
reports map[string]*labs.Report
}
func NewReportStore() *ReportStore {
return &ReportStore{
reports: make(map[string]*labs.Report),
}
}
func (s *ReportStore) Save(report *labs.Report) {
s.reports[report.ID] = report
}
func (s *ReportStore) Get(id string) *labs.Report {
return s.reports[id]
}
func (s *ReportStore) List(limit, offset int) []*labs.Report {
result := make([]*labs.Report, 0)
i := 0
for _, r := range s.reports {
if i >= offset && len(result) < limit {
result = append(result, r)
}
i++
}
return result
}
func (s *ReportStore) Delete(id string) bool {
if _, ok := s.reports[id]; ok {
delete(s.reports, 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
if cfg.OpenAIAPIKey != "" {
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
APIKey: cfg.OpenAIAPIKey,
ModelKey: "gpt-4o-mini",
})
if err != nil {
log.Fatal("Failed to create OpenAI client:", err)
}
llmClient = client
} else if cfg.AnthropicAPIKey != "" {
client, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
APIKey: cfg.AnthropicAPIKey,
ModelKey: "claude-3-5-sonnet-20241022",
})
if err != nil {
log.Fatal("Failed to create Anthropic client:", err)
}
llmClient = client
}
generator := labs.NewGenerator(llmClient)
store := NewReportStore()
app := fiber.New(fiber.Config{
BodyLimit: 100 * 1024 * 1024,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Post("/api/v1/labs/generate", func(c *fiber.Ctx) error {
var req struct {
Query string `json:"query"`
Data interface{} `json:"data"`
Theme string `json:"theme,omitempty"`
Locale string `json:"locale,omitempty"`
}
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()
report, err := generator.GenerateReport(ctx, labs.GenerateOptions{
Query: req.Query,
Data: req.Data,
Theme: req.Theme,
Locale: req.Locale,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(report)
return c.JSON(report)
})
app.Post("/api/v1/labs/visualize", func(c *fiber.Ctx) error {
var req struct {
Type string `json:"type"`
Title string `json:"title"`
Data interface{} `json:"data"`
Config interface{} `json:"config,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := createVisualizationFromRequest(generator, req.Type, req.Title, req.Data, req.Config)
return c.JSON(viz)
})
app.Post("/api/v1/labs/chart", func(c *fiber.Ctx) error {
var req struct {
Type string `json:"type"`
Title string `json:"title"`
Labels []string `json:"labels"`
Datasets []labs.ChartDataset `json:"datasets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
var viz labs.Visualization
switch req.Type {
case "bar", "bar_chart":
if len(req.Datasets) > 0 {
viz = generator.CreateBarChart(req.Title, req.Labels, req.Datasets[0].Data)
}
case "line", "line_chart":
viz = generator.CreateLineChart(req.Title, req.Labels, req.Datasets)
case "pie", "pie_chart":
if len(req.Datasets) > 0 {
viz = generator.CreatePieChart(req.Title, req.Labels, req.Datasets[0].Data)
}
default:
viz = generator.CreateBarChart(req.Title, req.Labels, req.Datasets[0].Data)
}
return c.JSON(viz)
})
app.Post("/api/v1/labs/table", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Columns []labs.TableColumn `json:"columns"`
Rows []labs.TableRow `json:"rows"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateTable(req.Title, req.Columns, req.Rows)
return c.JSON(viz)
})
app.Post("/api/v1/labs/stat-cards", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Cards []labs.StatCard `json:"cards"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateStatCards(req.Title, req.Cards)
return c.JSON(viz)
})
app.Post("/api/v1/labs/kpi", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Value interface{} `json:"value"`
Change float64 `json:"change"`
Unit string `json:"unit"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateKPI(req.Title, req.Value, req.Change, req.Unit)
return c.JSON(viz)
})
app.Post("/api/v1/labs/heatmap", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
XLabels []string `json:"xLabels"`
YLabels []string `json:"yLabels"`
Values [][]float64 `json:"values"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateHeatmap(req.Title, req.XLabels, req.YLabels, req.Values)
return c.JSON(viz)
})
app.Post("/api/v1/labs/code", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Code string `json:"code"`
Language string `json:"language"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateCodeBlock(req.Title, req.Code, req.Language)
return c.JSON(viz)
})
app.Post("/api/v1/labs/markdown", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateMarkdown(req.Title, req.Content)
return c.JSON(viz)
})
app.Post("/api/v1/labs/tabs", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Tabs []labs.TabItem `json:"tabs"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateTabs(req.Title, req.Tabs)
return c.JSON(viz)
})
app.Post("/api/v1/labs/accordion", func(c *fiber.Ctx) error {
var req struct {
Title string `json:"title"`
Items []labs.AccordionItem `json:"items"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
viz := generator.CreateAccordion(req.Title, req.Items)
return c.JSON(viz)
})
app.Get("/api/v1/labs/reports", func(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 20)
offset := c.QueryInt("offset", 0)
reports := store.List(limit, offset)
return c.JSON(fiber.Map{"reports": reports, "count": len(reports)})
})
app.Get("/api/v1/labs/reports/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
report := store.Get(id)
if report == nil {
return c.Status(404).JSON(fiber.Map{"error": "Report not found"})
}
return c.JSON(report)
})
app.Delete("/api/v1/labs/reports/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
if store.Delete(id) {
return c.JSON(fiber.Map{"success": true})
}
return c.Status(404).JSON(fiber.Map{"error": "Report not found"})
})
app.Post("/api/v1/labs/reports/:id/export", func(c *fiber.Ctx) error {
id := c.Params("id")
format := c.Query("format", "html")
report := store.Get(id)
if report == nil {
return c.Status(404).JSON(fiber.Map{"error": "Report not found"})
}
switch format {
case "html":
html := exportToHTML(report)
c.Set("Content-Type", "text/html")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.html\"", report.ID))
return c.SendString(html)
case "json":
c.Set("Content-Type", "application/json")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.json\"", report.ID))
return c.JSON(report)
default:
return c.Status(400).JSON(fiber.Map{"error": "Unsupported format"})
}
})
port := getEnvInt("LABS_SVC_PORT", 3031)
log.Printf("labs-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func createVisualizationFromRequest(g *labs.Generator, vizType, title string, data, config interface{}) labs.Visualization {
switch vizType {
case "bar_chart":
return parseChartRequest(g, labs.VizBarChart, title, data)
case "line_chart":
return parseChartRequest(g, labs.VizLineChart, title, data)
case "pie_chart":
return parseChartRequest(g, labs.VizPieChart, title, data)
case "table":
return parseTableRequest(g, title, data)
case "stat_cards":
return parseStatCardsRequest(g, title, data)
case "kpi":
return parseKPIRequest(g, title, data)
case "markdown":
content := ""
if dataMap, ok := data.(map[string]interface{}); ok {
content, _ = dataMap["content"].(string)
}
return g.CreateMarkdown(title, content)
case "code_block":
code, lang := "", ""
if dataMap, ok := data.(map[string]interface{}); ok {
code, _ = dataMap["code"].(string)
lang, _ = dataMap["language"].(string)
}
return g.CreateCodeBlock(title, code, lang)
default:
return g.CreateMarkdown(title, fmt.Sprintf("%v", data))
}
}
func parseChartRequest(g *labs.Generator, vizType labs.VisualizationType, title string, data interface{}) labs.Visualization {
dataMap, ok := data.(map[string]interface{})
if !ok {
return g.CreateMarkdown(title, "Invalid chart data")
}
labels := make([]string, 0)
if labelsRaw, ok := dataMap["labels"].([]interface{}); ok {
for _, l := range labelsRaw {
labels = append(labels, fmt.Sprintf("%v", l))
}
}
values := make([]float64, 0)
if valuesRaw, ok := dataMap["values"].([]interface{}); ok {
for _, v := range valuesRaw {
switch val := v.(type) {
case float64:
values = append(values, val)
case int:
values = append(values, float64(val))
}
}
}
switch vizType {
case labs.VizBarChart:
return g.CreateBarChart(title, labels, values)
case labs.VizPieChart:
return g.CreatePieChart(title, labels, values)
default:
return g.CreateLineChart(title, labels, []labs.ChartDataset{{Label: title, Data: values}})
}
}
func parseTableRequest(g *labs.Generator, title string, data interface{}) labs.Visualization {
dataMap, ok := data.(map[string]interface{})
if !ok {
return g.CreateMarkdown(title, "Invalid table data")
}
columns := make([]labs.TableColumn, 0)
if colsRaw, ok := dataMap["columns"].([]interface{}); ok {
for _, c := range colsRaw {
if colMap, ok := c.(map[string]interface{}); ok {
col := labs.TableColumn{}
if v, ok := colMap["key"].(string); ok {
col.Key = v
}
if v, ok := colMap["label"].(string); ok {
col.Label = v
}
columns = append(columns, col)
}
}
}
rows := make([]labs.TableRow, 0)
if rowsRaw, ok := dataMap["rows"].([]interface{}); ok {
for _, r := range rowsRaw {
if rowMap, ok := r.(map[string]interface{}); ok {
rows = append(rows, labs.TableRow(rowMap))
}
}
}
return g.CreateTable(title, columns, rows)
}
func parseStatCardsRequest(g *labs.Generator, title string, data interface{}) labs.Visualization {
dataMap, ok := data.(map[string]interface{})
if !ok {
return g.CreateMarkdown(title, "Invalid stat cards data")
}
cards := make([]labs.StatCard, 0)
if cardsRaw, ok := dataMap["cards"].([]interface{}); ok {
for _, c := range cardsRaw {
if cardMap, ok := c.(map[string]interface{}); ok {
card := labs.StatCard{ID: uuid.New().String()}
if v, ok := cardMap["title"].(string); ok {
card.Title = v
}
if v, ok := cardMap["value"]; ok {
card.Value = v
}
if v, ok := cardMap["change"].(float64); ok {
card.Change = v
}
if v, ok := cardMap["color"].(string); ok {
card.Color = v
}
cards = append(cards, card)
}
}
}
return g.CreateStatCards(title, cards)
}
func parseKPIRequest(g *labs.Generator, title string, data interface{}) labs.Visualization {
dataMap, ok := data.(map[string]interface{})
if !ok {
return g.CreateKPI(title, data, 0, "")
}
value := dataMap["value"]
change := 0.0
if v, ok := dataMap["change"].(float64); ok {
change = v
}
unit := ""
if v, ok := dataMap["unit"].(string); ok {
unit = v
}
return g.CreateKPI(title, value, change, unit)
}
func exportToHTML(report *labs.Report) string {
sectionsHTML := ""
for _, section := range report.Sections {
vizHTML := ""
for _, viz := range section.Visualizations {
dataJSON, _ := json.Marshal(viz.Data)
vizHTML += fmt.Sprintf(`
<div class="visualization" data-type="%s">
<h3>%s</h3>
<div class="viz-container" data-config='%s'></div>
</div>
`, viz.Type, viz.Title, string(dataJSON))
}
sectionsHTML += fmt.Sprintf(`
<section>
<h2>%s</h2>
%s
</section>
`, section.Title, vizHTML)
}
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { color: #1f2937; }
h2 { color: #374151; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px; }
.visualization { margin: 20px 0; padding: 20px; background: #f9fafb; border-radius: 8px; }
.viz-container { min-height: 200px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<h1>%s</h1>
%s
</body>
</html>`, report.Title, report.Title, sectionsHTML)
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}

View File

@@ -0,0 +1,404 @@
package main
import (
"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/learning"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/pkg/config"
)
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
// 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 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")
}
generator := learning.NewLearningGenerator(llmClient)
store := NewLessonStore()
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: 120 * 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"})
})
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"`
}
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 {
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,
})
}
return c.JSON(fiber.Map{"lessons": summaries, "count": len(summaries)})
})
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"})
}
return c.JSON(lesson)
})
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"})
}
var req struct {
StepIndex int `json:"stepIndex"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
lesson.CompleteStep(req.StepIndex)
return c.JSON(fiber.Map{
"success": true,
"progress": lesson.Progress,
})
})
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"})
}
var req struct {
StepIndex int `json:"stepIndex"`
SelectedOptions []string `json:"selectedOptions"`
}
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)
}
return c.JSON(fiber.Map{
"correct": correct,
"explanation": explanation,
"progress": lesson.Progress,
})
})
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})
}
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
})
port := getEnvInt("LEARNING_SVC_PORT", 3034)
log.Printf("learning-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
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
}
func isJWT(s string) bool {
return len(s) > 10 && s[:3] == "eyJ"
}

191
backend/cmd/llm-svc/main.go Normal file
View File

@@ -0,0 +1,191 @@
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/pkg/config"
"github.com/gooseek/backend/pkg/ndjson"
)
type GenerateRequest struct {
ProviderID string `json:"providerId"`
ModelKey string `json:"key"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
Options struct {
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
} `json:"options"`
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 10 * 1024 * 1024,
ReadTimeout: time.Minute,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * 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"})
})
app.Get("/api/v1/providers", func(c *fiber.Ctx) error {
providers := []fiber.Map{}
if cfg.OpenAIAPIKey != "" {
providers = append(providers, fiber.Map{
"id": "openai",
"name": "OpenAI",
"models": []string{"gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"},
})
}
if cfg.AnthropicAPIKey != "" {
providers = append(providers, fiber.Map{
"id": "anthropic",
"name": "Anthropic",
"models": []string{"claude-3-5-sonnet-20241022", "claude-3-opus-20240229", "claude-3-haiku-20240307"},
})
}
if cfg.GeminiAPIKey != "" {
providers = append(providers, fiber.Map{
"id": "gemini",
"name": "Google Gemini",
"models": []string{"gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-flash-exp"},
})
}
return c.JSON(fiber.Map{
"providers": providers,
"envOnlyMode": true,
})
})
app.Get("/api/v1/providers/ui-config", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"sections": []interface{}{},
})
})
app.Post("/api/v1/generate", func(c *fiber.Ctx) error {
var req GenerateRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if len(req.Messages) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Messages required"})
}
client, err := llm.NewClient(llm.ProviderConfig{
ProviderID: req.ProviderID,
ModelKey: req.ModelKey,
APIKey: getAPIKey(cfg, req.ProviderID),
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
messages := make([]llm.Message, len(req.Messages))
for i, m := range req.Messages {
messages[i] = llm.Message{
Role: llm.Role(m.Role),
Content: m.Content,
}
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.LLMTimeout)
defer cancel()
if req.Options.Stream {
stream, err := client.StreamText(ctx, llm.StreamRequest{
Messages: messages,
Options: llm.StreamOptions{
MaxTokens: req.Options.MaxTokens,
Temperature: req.Options.Temperature,
},
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/x-ndjson")
c.Set("Cache-Control", "no-cache")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
writer := ndjson.NewWriter(w)
for chunk := range stream {
writer.Write(fiber.Map{
"type": "chunk",
"chunk": chunk.ContentChunk,
})
w.Flush()
}
writer.Write(fiber.Map{"type": "done"})
})
return nil
}
response, err := client.GenerateText(ctx, llm.StreamRequest{
Messages: messages,
Options: llm.StreamOptions{
MaxTokens: req.Options.MaxTokens,
Temperature: req.Options.Temperature,
},
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"content": response,
})
})
port := cfg.LLMSvcPort
log.Printf("llm-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func getAPIKey(cfg *config.Config, providerID string) string {
switch providerID {
case "openai", "timeweb":
return cfg.OpenAIAPIKey
case "anthropic":
return cfg.AnthropicAPIKey
case "gemini", "google":
return cfg.GeminiAPIKey
default:
return ""
}
}
func init() {
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "3020")
}
}

View File

@@ -0,0 +1,424 @@
package main
import (
"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/podcast"
"github.com/gooseek/backend/pkg/config"
)
type PodcastStore struct {
podcasts map[string]*podcast.Podcast
}
func NewPodcastStore() *PodcastStore {
return &PodcastStore{
podcasts: make(map[string]*podcast.Podcast),
}
}
func (s *PodcastStore) Save(p *podcast.Podcast) {
s.podcasts[p.ID] = p
}
func (s *PodcastStore) Get(id string) *podcast.Podcast {
return s.podcasts[id]
}
func (s *PodcastStore) List(limit, offset int) []*podcast.Podcast {
result := make([]*podcast.Podcast, 0)
i := 0
for _, p := range s.podcasts {
if i >= offset && len(result) < limit {
result = append(result, p)
}
i++
}
return result
}
func (s *PodcastStore) GetLatest() *podcast.Podcast {
var latest *podcast.Podcast
for _, p := range s.podcasts {
if latest == nil || p.GeneratedAt.After(latest.GeneratedAt) {
latest = p
}
}
return latest
}
func (s *PodcastStore) GetByDate(date time.Time) *podcast.Podcast {
dateStr := date.Format("2006-01-02")
for _, p := range s.podcasts {
if p.Date.Format("2006-01-02") == dateStr {
return p
}
}
return nil
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var llmClient llm.Client
if cfg.OpenAIAPIKey != "" {
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
APIKey: cfg.OpenAIAPIKey,
ModelKey: "gpt-4o-mini",
})
if err != nil {
log.Fatal("Failed to create OpenAI client:", err)
}
llmClient = client
} else if cfg.AnthropicAPIKey != "" {
client, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
APIKey: cfg.AnthropicAPIKey,
ModelKey: "claude-3-5-sonnet-20241022",
})
if err != nil {
log.Fatal("Failed to create Anthropic client:", err)
}
llmClient = client
}
var ttsClient podcast.TTSClient
elevenLabsKey := os.Getenv("ELEVENLABS_API_KEY")
if elevenLabsKey != "" {
ttsClient = podcast.NewElevenLabsTTS(elevenLabsKey)
} else {
ttsClient = &podcast.DummyTTS{}
}
generator := podcast.NewPodcastGenerator(llmClient, ttsClient, podcast.GeneratorConfig{
DefaultDuration: 300,
MaxDuration: 1800,
OutputDir: "/data/podcasts",
})
store := NewPodcastStore()
app := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: 120 * 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"})
})
app.Post("/api/v1/podcast/generate", func(c *fiber.Ctx) error {
var req struct {
Type string `json:"type"`
Topics []string `json:"topics"`
NewsItems []podcast.NewsItem `json:"newsItems"`
Duration int `json:"duration"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
podcastType := podcast.PodcastDaily
switch req.Type {
case "weekly":
podcastType = podcast.PodcastWeekly
case "topic_deep":
podcastType = podcast.PodcastTopicDeep
case "breaking":
podcastType = podcast.PodcastBreaking
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
p, err := generator.GenerateDailyPodcast(ctx, podcast.GenerateOptions{
Type: podcastType,
Topics: req.Topics,
NewsItems: req.NewsItems,
Duration: req.Duration,
Locale: req.Locale,
IncludeIntro: true,
IncludeOutro: true,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-daily", func(c *fiber.Ctx) error {
var req struct {
NewsItems []podcast.NewsItem `json:"newsItems"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if req.Locale == "" {
req.Locale = "ru"
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
p, err := generator.GenerateDailyPodcast(ctx, podcast.GenerateOptions{
Type: podcast.PodcastDaily,
NewsItems: req.NewsItems,
Date: time.Now(),
Duration: 300,
Locale: req.Locale,
IncludeIntro: true,
IncludeOutro: true,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-weekly", func(c *fiber.Ctx) error {
var req struct {
NewsItems []podcast.NewsItem `json:"newsItems"`
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(), 180*time.Second)
defer cancel()
p, err := generator.GenerateWeeklySummary(ctx, req.NewsItems, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-topic", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
Articles []podcast.NewsItem `json:"articles"`
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(), 180*time.Second)
defer cancel()
p, err := generator.GenerateTopicDeepDive(ctx, req.Topic, req.Articles, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/:id/audio", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
audioData, err := generator.GenerateAudio(ctx, p)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "audio/mpeg")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.mp3\"", p.ID))
return c.Send(audioData)
})
app.Get("/api/v1/podcasts", func(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 20)
offset := c.QueryInt("offset", 0)
podcasts := store.List(limit, offset)
summaries := make([]map[string]interface{}, 0)
for _, p := range podcasts {
summaries = append(summaries, map[string]interface{}{
"id": p.ID,
"title": p.Title,
"description": p.Description,
"type": p.Type,
"date": p.Date,
"duration": p.Duration,
"status": p.Status,
"topics": p.Topics,
"audioUrl": p.AudioURL,
"generatedAt": p.GeneratedAt,
})
}
return c.JSON(fiber.Map{"podcasts": summaries, "count": len(summaries)})
})
app.Get("/api/v1/podcasts/latest", func(c *fiber.Ctx) error {
p := store.GetLatest()
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "No podcasts found"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/today", func(c *fiber.Ctx) error {
p := store.GetByDate(time.Now())
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "No podcast for today"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/:id/transcript", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(fiber.Map{
"id": p.ID,
"title": p.Title,
"transcript": p.Transcript,
"segments": p.Segments,
})
})
app.Get("/api/v1/podcasts/:id/segments", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(fiber.Map{
"segments": p.Segments,
})
})
app.Post("/api/v1/podcast/:id/publish", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
if p.Status != podcast.StatusReady {
return c.Status(400).JSON(fiber.Map{"error": "Podcast audio not ready"})
}
now := time.Now()
p.Status = podcast.StatusPublished
p.PublishedAt = &now
return c.JSON(fiber.Map{"success": true, "publishedAt": now})
})
app.Get("/api/v1/podcast/rss", func(c *fiber.Ctx) error {
podcasts := store.List(50, 0)
rss := generateRSSFeed(podcasts, c.BaseURL())
c.Set("Content-Type", "application/rss+xml")
return c.SendString(rss)
})
port := getEnvInt("PODCAST_SVC_PORT", 3032)
log.Printf("podcast-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func generateRSSFeed(podcasts []*podcast.Podcast, baseURL string) string {
items := ""
for _, p := range podcasts {
if p.Status == podcast.StatusPublished && p.AudioURL != "" {
pubDate := ""
if p.PublishedAt != nil {
pubDate = p.PublishedAt.Format(time.RFC1123Z)
}
items += fmt.Sprintf(`
<item>
<title>%s</title>
<description><![CDATA[%s]]></description>
<pubDate>%s</pubDate>
<enclosure url="%s" type="audio/mpeg" length="0"/>
<guid>%s</guid>
<itunes:duration>%d</itunes:duration>
</item>`, p.Title, p.Description, pubDate, p.AudioURL, p.ID, p.Duration)
}
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>GooSeek Daily</title>
<link>%s</link>
<description>Ежедневный подкаст с главными новостями от GooSeek</description>
<language>ru</language>
<itunes:author>GooSeek</itunes:author>
<itunes:category text="News"/>
<atom:link href="%s/api/v1/podcast/rss" rel="self" type="application/rss+xml"/>
%s
</channel>
</rss>`, baseURL, baseURL, items)
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
var result int
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
return result
}
}
return defaultValue
}

View File

@@ -0,0 +1,284 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"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/pkg/config"
)
type ScrapeRequest struct {
URL string `json:"url"`
}
type ScrapeResponse struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
app := fiber.New(fiber.Config{
BodyLimit: 10 * 1024 * 1024,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Post("/api/v1/scrape", func(c *fiber.Ctx) error {
var req ScrapeRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "URL is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.ScrapeTimeout)
defer cancel()
result := scrapeURL(ctx, req.URL, cfg)
return c.JSON(result)
})
app.Post("/api/v1/scrape/batch", func(c *fiber.Ctx) error {
var req struct {
URLs []string `json:"urls"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if len(req.URLs) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "URLs are required"})
}
if len(req.URLs) > 10 {
req.URLs = req.URLs[:10]
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.ScrapeTimeout*3)
defer cancel()
results := make([]ScrapeResponse, len(req.URLs))
resultCh := make(chan struct {
index int
result ScrapeResponse
}, len(req.URLs))
for i, url := range req.URLs {
go func(idx int, u string) {
resultCh <- struct {
index int
result ScrapeResponse
}{idx, scrapeURL(ctx, u, cfg)}
}(i, url)
}
for range req.URLs {
r := <-resultCh
results[r.index] = r.result
}
return c.JSON(fiber.Map{"results": results})
})
port := cfg.ScraperSvcPort
log.Printf("scraper-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func scrapeURL(ctx context.Context, url string, cfg *config.Config) ScrapeResponse {
if cfg.Crawl4AIURL != "" {
result, err := scrapeWithCrawl4AI(ctx, url, cfg.Crawl4AIURL)
if err == nil && result.Success {
return *result
}
}
return scrapeDirectly(ctx, url)
}
func scrapeWithCrawl4AI(ctx context.Context, url, crawl4aiURL string) (*ScrapeResponse, error) {
reqBody := fmt.Sprintf(`{
"urls": ["%s"],
"crawler_config": {
"type": "CrawlerRunConfig",
"params": {
"cache_mode": "default",
"page_timeout": 20000
}
}
}`, url)
req, err := http.NewRequestWithContext(ctx, "POST", crawl4aiURL+"/crawl", strings.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 25 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Crawl4AI returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
markdown := extractMarkdown(string(body))
title := extractTitle(string(body))
if len(markdown) > 100 {
return &ScrapeResponse{
URL: url,
Title: title,
Content: truncate(markdown, 15000),
Success: true,
}, nil
}
return nil, fmt.Errorf("insufficient content from Crawl4AI")
}
func scrapeDirectly(ctx context.Context, url string) ScrapeResponse {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return ScrapeResponse{URL: url, Success: false, Error: err.Error()}
}
req.Header.Set("User-Agent", "GooSeek-Scraper/1.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ScrapeResponse{URL: url, Success: false, Error: err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ScrapeResponse{URL: url, Success: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return ScrapeResponse{URL: url, Success: false, Error: err.Error()}
}
html := string(body)
title := extractHTMLTitle(html)
content := extractTextContent(html)
if len(content) < 100 {
return ScrapeResponse{URL: url, Success: false, Error: "Insufficient content"}
}
return ScrapeResponse{
URL: url,
Title: title,
Content: truncate(content, 15000),
Success: true,
}
}
var (
titleRegex = regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
scriptRegex = regexp.MustCompile(`(?s)<script[^>]*>.*?</script>`)
styleRegex = regexp.MustCompile(`(?s)<style[^>]*>.*?</style>`)
tagRegex = regexp.MustCompile(`<[^>]+>`)
spaceRegex = regexp.MustCompile(`\s+`)
)
func extractHTMLTitle(html string) string {
matches := titleRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
func extractTextContent(html string) string {
bodyStart := strings.Index(strings.ToLower(html), "<body")
bodyEnd := strings.Index(strings.ToLower(html), "</body>")
if bodyStart != -1 && bodyEnd != -1 && bodyEnd > bodyStart {
html = html[bodyStart:bodyEnd]
}
html = scriptRegex.ReplaceAllString(html, "")
html = styleRegex.ReplaceAllString(html, "")
html = tagRegex.ReplaceAllString(html, " ")
html = spaceRegex.ReplaceAllString(html, " ")
return strings.TrimSpace(html)
}
func extractMarkdown(response string) string {
if idx := strings.Index(response, `"raw_markdown"`); idx != -1 {
start := strings.Index(response[idx:], `"`) + idx + 1
start = strings.Index(response[start:], `"`) + start + 1
end := strings.Index(response[start:], `"`)
if end != -1 {
return response[start : start+end]
}
}
return ""
}
func extractTitle(response string) string {
if idx := strings.Index(response, `"title"`); idx != -1 {
start := strings.Index(response[idx:], `"`) + idx + 1
start = strings.Index(response[start:], `"`) + start + 1
end := strings.Index(response[start:], `"`)
if end != -1 {
return response[start : start+end]
}
}
return ""
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
func init() {
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "3021")
}
}

View File

@@ -0,0 +1,143 @@
package main
import (
"context"
"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/search"
"github.com/gooseek/backend/pkg/config"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
searchClient := search.NewSearXNGClient(cfg)
app := fiber.New(fiber.Config{
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
})
app.Use(logger.New())
app.Use(cors.New())
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Get("/api/v1/search", func(c *fiber.Ctx) error {
query := c.Query("q")
if query == "" {
return c.Status(400).JSON(fiber.Map{"error": "Query parameter 'q' is required"})
}
opts := &search.SearchOptions{}
if engines := c.Query("engines"); engines != "" {
opts.Engines = strings.Split(engines, ",")
}
if categories := c.Query("categories"); categories != "" {
opts.Categories = strings.Split(categories, ",")
}
if pageno := c.QueryInt("pageno", 1); pageno > 0 {
opts.PageNo = pageno
}
if lang := c.Query("language"); lang != "" {
opts.Language = lang
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.SearchTimeout)
defer cancel()
result, err := searchClient.Search(ctx, query, opts)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
})
app.Get("/api/v1/search/images", func(c *fiber.Ctx) error {
query := c.Query("q")
if query == "" {
return c.Status(400).JSON(fiber.Map{"error": "Query parameter 'q' is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.SearchTimeout)
defer cancel()
result, err := searchClient.Search(ctx, query, &search.SearchOptions{
Categories: []string{"images"},
PageNo: 1,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
})
app.Get("/api/v1/search/videos", func(c *fiber.Ctx) error {
query := c.Query("q")
if query == "" {
return c.Status(400).JSON(fiber.Map{"error": "Query parameter 'q' is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.SearchTimeout)
defer cancel()
result, err := searchClient.Search(ctx, query, &search.SearchOptions{
Categories: []string{"videos"},
PageNo: 1,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
})
app.Get("/api/v1/search/media", func(c *fiber.Ctx) error {
query := c.Query("q")
if query == "" {
return c.Status(400).JSON(fiber.Map{"error": "Query parameter 'q' is required"})
}
maxImages := c.QueryInt("maxImages", 8)
maxVideos := c.QueryInt("maxVideos", 6)
ctx, cancel := context.WithTimeout(context.Background(), cfg.SearchTimeout*2)
defer cancel()
result, err := searchClient.SearchMedia(ctx, query, &search.MediaSearchOptions{
MaxImages: maxImages,
MaxVideos: maxVideos,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
})
port := cfg.SearchSvcPort
log.Printf("search-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func init() {
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "3001")
}
}

View File

@@ -0,0 +1,624 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"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/db"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/pages"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var database *db.PostgresDB
var threadRepo *db.ThreadRepository
var spaceRepo *db.SpaceRepository
var memoryRepo *db.MemoryRepository
var pageRepo *db.PageRepository
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 thread-svc:", err)
}
log.Println("PostgreSQL connected successfully")
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)
}
spaceRepo = db.NewSpaceRepository(database)
if err := spaceRepo.RunMigrations(ctx); err != nil {
log.Printf("Space migrations warning: %v", err)
}
threadRepo = db.NewThreadRepository(database)
if err := threadRepo.RunMigrations(ctx); err != nil {
log.Printf("Thread migrations warning: %v", err)
}
memoryRepo = db.NewMemoryRepository(database)
if err := memoryRepo.RunMigrations(ctx); err != nil {
log.Printf("Memory migrations warning: %v", err)
}
pageRepo = db.NewPageRepository(database)
if err := pageRepo.RunMigrations(ctx); err != nil {
log.Printf("Page migrations warning: %v", err)
}
log.Println("PostgreSQL connected, all migrations complete")
} else {
log.Fatal("DATABASE_URL required for thread-svc")
}
var llmClient llm.Client
if cfg.OpenAIAPIKey != "" {
llmClient, err = llm.NewClient(llm.ProviderConfig{
ProviderID: "openai",
ModelKey: "gpt-4o-mini",
APIKey: cfg.OpenAIAPIKey,
})
if err != nil {
log.Printf("Failed to create LLM client: %v", err)
}
}
app := fiber.New(fiber.Config{
BodyLimit: 10 * 1024 * 1024,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * 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"})
})
app.Get("/ready", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ready"})
})
threads := app.Group("/api/v1/threads", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: true,
}))
threads.Get("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.JSON(fiber.Map{"threads": []interface{}{}})
}
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
threadList, err := threadRepo.GetByUserID(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get threads"})
}
return c.JSON(fiber.Map{"threads": threadList})
})
threads.Post("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
var req struct {
Title string `json:"title"`
FocusMode string `json:"focusMode"`
SpaceID *string `json:"spaceId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
thread := &db.Thread{
UserID: userID,
SpaceID: req.SpaceID,
Title: req.Title,
FocusMode: req.FocusMode,
}
if thread.Title == "" {
thread.Title = "New Thread"
}
if thread.FocusMode == "" {
thread.FocusMode = "all"
}
if err := threadRepo.Create(c.Context(), thread); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to create thread"})
}
return c.Status(201).JSON(thread)
})
threads.Get("/:id", func(c *fiber.Ctx) error {
threadID := c.Params("id")
userID := middleware.GetUserID(c)
thread, err := threadRepo.GetByID(c.Context(), threadID)
if err != nil || thread == nil {
return c.Status(404).JSON(fiber.Map{"error": "Thread not found"})
}
if thread.UserID != userID && !thread.IsPublic {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
messages, _ := threadRepo.GetMessages(c.Context(), threadID, 100, 0)
thread.Messages = messages
return c.JSON(thread)
})
threads.Post("/:id/messages", func(c *fiber.Ctx) error {
threadID := c.Params("id")
userID := middleware.GetUserID(c)
thread, err := threadRepo.GetByID(c.Context(), threadID)
if err != nil || thread == nil {
return c.Status(404).JSON(fiber.Map{"error": "Thread not found"})
}
if thread.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
var req struct {
Role string `json:"role"`
Content string `json:"content"`
Sources []db.ThreadSource `json:"sources"`
Widgets []map[string]interface{} `json:"widgets"`
RelatedQuestions []string `json:"relatedQuestions"`
Model string `json:"model"`
TokensUsed int `json:"tokensUsed"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
msg := &db.ThreadMessage{
ThreadID: threadID,
Role: req.Role,
Content: req.Content,
Sources: req.Sources,
Widgets: req.Widgets,
RelatedQuestions: req.RelatedQuestions,
Model: req.Model,
TokensUsed: req.TokensUsed,
}
if err := threadRepo.AddMessage(c.Context(), msg); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to add message"})
}
if thread.Title == "New Thread" && req.Role == "user" {
threadRepo.GenerateTitle(c.Context(), threadID, req.Content)
}
return c.Status(201).JSON(msg)
})
threads.Post("/:id/share", func(c *fiber.Ctx) error {
threadID := c.Params("id")
userID := middleware.GetUserID(c)
thread, err := threadRepo.GetByID(c.Context(), threadID)
if err != nil || thread == nil {
return c.Status(404).JSON(fiber.Map{"error": "Thread not found"})
}
if thread.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
shareID := generateShareID()
if err := threadRepo.SetShareID(c.Context(), threadID, shareID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to share thread"})
}
return c.JSON(fiber.Map{
"shareId": shareID,
"shareUrl": fmt.Sprintf("/share/%s", shareID),
})
})
threads.Delete("/:id", func(c *fiber.Ctx) error {
threadID := c.Params("id")
userID := middleware.GetUserID(c)
thread, err := threadRepo.GetByID(c.Context(), threadID)
if err != nil || thread == nil {
return c.Status(404).JSON(fiber.Map{"error": "Thread not found"})
}
if thread.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
if err := threadRepo.Delete(c.Context(), threadID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete thread"})
}
return c.Status(204).Send(nil)
})
share := app.Group("/api/v1/share")
share.Get("/:shareId", func(c *fiber.Ctx) error {
shareID := c.Params("shareId")
thread, err := threadRepo.GetByShareID(c.Context(), shareID)
if err != nil || thread == nil {
return c.Status(404).JSON(fiber.Map{"error": "Shared thread not found"})
}
messages, _ := threadRepo.GetMessages(c.Context(), thread.ID, 100, 0)
thread.Messages = messages
return c.JSON(thread)
})
spaces := app.Group("/api/v1/spaces", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: false,
}))
spaces.Get("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
spaceList, err := spaceRepo.GetByUserID(c.Context(), userID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get spaces"})
}
return c.JSON(fiber.Map{"spaces": spaceList})
})
spaces.Post("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
var req db.Space
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
req.UserID = userID
if req.Name == "" {
return c.Status(400).JSON(fiber.Map{"error": "Name required"})
}
if err := spaceRepo.Create(c.Context(), &req); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to create space"})
}
return c.Status(201).JSON(req)
})
spaces.Get("/:id", func(c *fiber.Ctx) error {
spaceID := c.Params("id")
userID := middleware.GetUserID(c)
space, err := spaceRepo.GetByID(c.Context(), spaceID)
if err != nil || space == nil {
return c.Status(404).JSON(fiber.Map{"error": "Space not found"})
}
if space.UserID != userID && !space.IsPublic {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
return c.JSON(space)
})
spaces.Put("/:id", func(c *fiber.Ctx) error {
spaceID := c.Params("id")
userID := middleware.GetUserID(c)
space, err := spaceRepo.GetByID(c.Context(), spaceID)
if err != nil || space == nil {
return c.Status(404).JSON(fiber.Map{"error": "Space not found"})
}
if space.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
var req db.Space
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
req.ID = spaceID
req.UserID = userID
if err := spaceRepo.Update(c.Context(), &req); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to update space"})
}
return c.JSON(req)
})
spaces.Delete("/:id", func(c *fiber.Ctx) error {
spaceID := c.Params("id")
userID := middleware.GetUserID(c)
space, err := spaceRepo.GetByID(c.Context(), spaceID)
if err != nil || space == nil {
return c.Status(404).JSON(fiber.Map{"error": "Space not found"})
}
if space.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
if err := spaceRepo.Delete(c.Context(), spaceID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete space"})
}
return c.Status(204).Send(nil)
})
memory := app.Group("/api/v1/memory", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: false,
}))
memory.Get("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
memType := c.Query("type", "")
memories, err := memoryRepo.GetByUserID(c.Context(), userID, memType, 50)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get memories"})
}
return c.JSON(fiber.Map{"memories": memories})
})
memory.Post("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
var req db.UserMemory
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
req.UserID = userID
if err := memoryRepo.Save(c.Context(), &req); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save memory"})
}
return c.Status(201).JSON(req)
})
memory.Get("/context", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
ctx, err := memoryRepo.GetContextForUser(c.Context(), userID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get context"})
}
return c.JSON(fiber.Map{"context": ctx})
})
memory.Delete("/:id", func(c *fiber.Ctx) error {
memID := c.Params("id")
if err := memoryRepo.Delete(c.Context(), memID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete memory"})
}
return c.Status(204).Send(nil)
})
pagesAPI := app.Group("/api/v1/pages", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: true,
}))
pagesAPI.Get("/", func(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
if userID == "" {
return c.JSON(fiber.Map{"pages": []interface{}{}})
}
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
pageList, err := pageRepo.GetByUserID(c.Context(), userID, limit, offset)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to get pages"})
}
return c.JSON(fiber.Map{"pages": pageList})
})
pagesAPI.Post("/from-thread/:threadId", func(c *fiber.Ctx) error {
threadID := c.Params("threadId")
userID := middleware.GetUserID(c)
if userID == "" {
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
}
thread, err := threadRepo.GetByID(c.Context(), threadID)
if err != nil || thread == nil {
return c.Status(404).JSON(fiber.Map{"error": "Thread not found"})
}
if thread.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
messages, _ := threadRepo.GetMessages(c.Context(), threadID, 100, 0)
var query, answer string
for _, msg := range messages {
if msg.Role == "user" && query == "" {
query = msg.Content
}
if msg.Role == "assistant" {
answer += msg.Content + "\n\n"
}
}
if llmClient == nil {
return c.Status(503).JSON(fiber.Map{"error": "LLM not configured"})
}
generator := pages.NewPageGenerator(pages.PageGeneratorConfig{
LLMClient: llmClient,
Locale: c.Query("locale", "en"),
})
page, err := generator.GenerateFromThread(c.Context(), query, answer, nil)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to generate page"})
}
page.UserID = userID
page.ThreadID = threadID
if err := pageRepo.Create(c.Context(), page); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to save page"})
}
return c.Status(201).JSON(page)
})
pagesAPI.Get("/:id", func(c *fiber.Ctx) error {
pageID := c.Params("id")
userID := middleware.GetUserID(c)
page, err := pageRepo.GetByID(c.Context(), pageID)
if err != nil || page == nil {
return c.Status(404).JSON(fiber.Map{"error": "Page not found"})
}
if page.UserID != userID && !page.IsPublic {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
return c.JSON(page)
})
pagesAPI.Post("/:id/share", func(c *fiber.Ctx) error {
pageID := c.Params("id")
userID := middleware.GetUserID(c)
page, err := pageRepo.GetByID(c.Context(), pageID)
if err != nil || page == nil {
return c.Status(404).JSON(fiber.Map{"error": "Page not found"})
}
if page.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
shareID := generateShareID()
if err := pageRepo.SetShareID(c.Context(), pageID, shareID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to share page"})
}
return c.JSON(fiber.Map{
"shareId": shareID,
"shareUrl": fmt.Sprintf("/page/%s", shareID),
})
})
pagesAPI.Get("/share/:shareId", func(c *fiber.Ctx) error {
shareID := c.Params("shareId")
page, err := pageRepo.GetByShareID(c.Context(), shareID)
if err != nil || page == nil {
return c.Status(404).JSON(fiber.Map{"error": "Page not found"})
}
pageRepo.IncrementViewCount(c.Context(), page.ID)
return c.JSON(page)
})
pagesAPI.Delete("/:id", func(c *fiber.Ctx) error {
pageID := c.Params("id")
userID := middleware.GetUserID(c)
page, err := pageRepo.GetByID(c.Context(), pageID)
if err != nil || page == nil {
return c.Status(404).JSON(fiber.Map{"error": "Page not found"})
}
if page.UserID != userID {
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
}
if err := pageRepo.Delete(c.Context(), pageID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete page"})
}
return c.Status(204).Send(nil)
})
port := getEnvInt("THREAD_SVC_PORT", 3027)
log.Printf("thread-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func generateShareID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
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
}