package chat import ( "context" "fmt" "html" "log" "net/http" "os" "regexp" "strings" "time" "github.com/juanatsap/cv-site/internal/cache" "google.golang.org/adk/agent" "google.golang.org/adk/model" "google.golang.org/adk/model/gemini" "google.golang.org/adk/runner" "google.golang.org/adk/session" "google.golang.org/genai" ) // chatRunner bundles a runner with its session service and label. type chatRunner struct { runner *runner.Runner session session.Service label string } // iconInfo maps anchor IDs to icon rendering info. type iconInfo struct { spriteIndex int // -1 means use image file instead category string // "company", "project", "course" imagePath string // fallback: "/static/images/projects/foo.png" } // Handler serves the chat API endpoint with automatic fallback. // Primary runner (Gemini) is tried first; if it fails, fallback (Ollama) is used. type Handler struct { primary *chatRunner fallback *chatRunner enabled bool warming bool // true while warmup is in progress warm bool // true after warmup completes icons map[string]iconInfo // anchor ID → icon info } // NewHandler creates a chat handler with primary + optional fallback provider. // - If GOOGLE_API_KEY is set → Gemini is primary // - If OLLAMA_HOST or Ollama is available → Ollama is fallback // - If only one is available, it becomes the sole provider // - If neither is available, chat is disabled func NewHandler(dataCache *cache.DataCache) *Handler { h := &Handler{icons: buildIconMap(dataCache)} // Try Gemini as primary geminiLLM, geminiLabel, geminiErr := initGeminiProvider() if geminiErr == nil && geminiLLM != nil { r, err := buildRunner(geminiLLM, dataCache, "cv-chat-gemini") if err == nil { h.primary = &chatRunner{runner: r.runner, session: r.session, label: geminiLabel} } } // Try Ollama as fallback (or primary if Gemini unavailable) ollamaLLM, ollamaLabel := initOllamaProvider() if ollamaLLM != nil { r, err := buildRunner(ollamaLLM, dataCache, "cv-chat-ollama") if err == nil { if h.primary != nil { h.fallback = &chatRunner{runner: r.runner, session: r.session, label: ollamaLabel} } else { h.primary = &chatRunner{runner: r.runner, session: r.session, label: ollamaLabel} } } } if h.primary == nil { log.Println("⚠️ No chat provider available — chat disabled") return &Handler{enabled: false} } h.enabled = true if h.fallback != nil { log.Printf("💬 Chat enabled (primary: %s, fallback: %s)", h.primary.label, h.fallback.label) } else { log.Printf("💬 Chat enabled (%s)", h.primary.label) } return h } // buildRunner creates an ADK runner for a given LLM provider. func buildRunner(llm model.LLM, dataCache *cache.DataCache, appName string) (*chatRunner, error) { cvAgent, err := NewAgent(llm, dataCache) if err != nil { return nil, err } sessionSvc := session.InMemoryService() r, err := runner.New(runner.Config{ AppName: appName, Agent: cvAgent, SessionService: sessionSvc, AutoCreateSession: true, }) if err != nil { return nil, err } return &chatRunner{runner: r, session: sessionSvc}, nil } // initGeminiProvider initializes the Gemini LLM provider. func initGeminiProvider() (model.LLM, string, error) { apiKey := os.Getenv("GOOGLE_API_KEY") if apiKey == "" { return nil, "", fmt.Errorf("no API key") } modelName := os.Getenv("MODEL_NAME") if modelName == "" { modelName = "gemini-2.5-flash" } llm, err := gemini.NewModel(context.Background(), modelName, &genai.ClientConfig{ APIKey: apiKey, }) if err != nil { log.Printf("⚠️ Gemini init failed: %v", err) return nil, "", err } return llm, fmt.Sprintf("gemini: %s", modelName), nil } // initOllamaProvider initializes the Ollama LLM provider. func initOllamaProvider() (model.LLM, string) { host := os.Getenv("OLLAMA_HOST") if host == "" { host = "http://localhost:11434" } modelName := os.Getenv("OLLAMA_MODEL") if modelName == "" { modelName = "mistral-small3.2" } llm := NewOllamaModel(host, modelName) return llm, fmt.Sprintf("ollama: %s @ %s", modelName, host) } // Enabled returns whether the chat feature is available. func (h *Handler) Enabled() bool { return h.enabled } // HandleWarmup pre-loads the LLM model so the first real question is fast. func (h *Handler) HandleWarmup(w http.ResponseWriter, r *http.Request) { if !h.enabled || r.Method != http.MethodPost { w.WriteHeader(http.StatusNoContent) return } h.startWarmup() w.WriteHeader(http.StatusNoContent) } // startWarmup triggers model warmup in the background (idempotent). func (h *Handler) startWarmup() { if h.warm || h.warming { return } h.warming = true // Warm up fallback (Ollama) in background — Gemini doesn't need warmup target := h.fallback if target == nil { target = h.primary } go func() { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() sess, err := target.session.Create(ctx, &session.CreateRequest{ AppName: "cv-chat-warmup", UserID: "warmup", }) if err != nil { h.warming = false return } msg := genai.NewContentFromText("hi", genai.RoleUser) for range target.runner.Run(ctx, "warmup", sess.Session.ID(), msg, agent.RunConfig{}) { } h.warm = true h.warming = false log.Printf("💬 Model warmed up (%s)", target.label) }() } // HandleStatus returns the chat readiness state as JSON. // GET /api/chat/status → {"ready": true/false, "warming": true/false} func (h *Handler) HandleStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintf(w, `{"ready":%t,"warming":%t}`, h.warm, h.warming) } // AutoWarmup starts model warmup immediately (call on startup in development). func (h *Handler) AutoWarmup() { if !h.enabled { return } log.Println("💬 Auto-warming up model (development mode)...") h.startWarmup() } // HandleChat processes POST /api/chat requests. // Tries the primary provider first; falls back to the secondary on error. func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { if !h.enabled { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) _, _ = fmt.Fprint(w, `
`) return } if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } message := strings.TrimSpace(r.FormValue("message")) if message == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, ``) return } // Try primary, fall back if it fails response, sessionID, err := h.runAgent(h.primary, message) if err != nil && h.fallback != nil { log.Printf("💬 Primary failed (%s: %v), falling back to %s", h.primary.label, err, h.fallback.label) response, sessionID, err = h.runAgent(h.fallback, message) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err != nil { errMsg := "Something went wrong. Please try again in a moment." if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "RESOURCE_EXHAUSTED") { errMsg = "The AI service is temporarily busy. Please try again in a few seconds." } _, _ = fmt.Fprintf(w, ``, errMsg) return } // Agent response bubble with avatar (user bubble is rendered client-side) if response == "" { response = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education." } _, _ = fmt.Fprintf(w, `"+trimmed+"
") } } } if inList { result = append(result, "") } return strings.Join(result, "") } // buildIconMap creates a mapping from anchor IDs to icon info from CV data. func buildIconMap(dataCache *cache.DataCache) map[string]iconInfo { icons := make(map[string]iconInfo) for _, lang := range []string{"en", "es"} { cv := dataCache.GetCV(lang) if cv == nil { continue } for _, e := range cv.Experience { if e.CompanyID == "" { continue } key := "exp-" + e.CompanyID if e.LogoIndex != nil { icons[key] = iconInfo{spriteIndex: *e.LogoIndex, category: "company"} } else if e.CompanyLogo != "" { icons[key] = iconInfo{spriteIndex: -1, imagePath: "/static/images/companies/" + e.CompanyLogo} } } for _, p := range cv.Projects { if p.ProjectID == "" { continue } key := "proj-" + p.ProjectID if p.LogoIndex != nil { icons[key] = iconInfo{spriteIndex: *p.LogoIndex, category: "project"} } else if p.ProjectLogo != "" { icons[key] = iconInfo{spriteIndex: -1, imagePath: "/static/images/projects/" + p.ProjectLogo} } } for _, c := range cv.Courses { if c.CourseID == "" { continue } key := "course-" + c.CourseID if c.LogoIndex != nil { icons[key] = iconInfo{spriteIndex: *c.LogoIndex, category: "course"} } else if c.CourseLogo != "" { icons[key] = iconInfo{spriteIndex: -1, imagePath: "/static/images/courses/" + c.CourseLogo} } } } return icons }