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 } // 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 } // 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{} // 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 } // 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(), 60*time.Second) defer cancel() sess, err := target.session.Create(ctx, &session.CreateRequest{ AppName: "cv-chat-warmup", UserID: "warmup", }) if err != nil { return } msg := genai.NewContentFromText("hi", genai.RoleUser) for range target.runner.Run(ctx, "warmup", sess.Session.ID(), msg, agent.RunConfig{}) { } log.Printf("💬 Model warmed up (%s)", target.label) }() w.WriteHeader(http.StatusNoContent) } // 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, `
Chat is not available at the moment.
`) 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, `
Please enter a message.
`) 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, `
%s
`, errMsg) return } // User message bubble (no avatar, right-aligned) _, _ = fmt.Fprintf(w, `
%s
`, html.EscapeString(message)) // Agent response bubble with avatar if response == "" { response = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education." } _, _ = fmt.Fprintf(w, `
%s
`, formatResponse(response)) // Session ID via OOB swap _, _ = fmt.Fprintf(w, ``, sessionID) } // runAgent executes the agent on the given runner and returns the response text. func (h *Handler) runAgent(cr *chatRunner, message string) (string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Create a new session for each request (stateless for fallback compatibility) sess, err := cr.session.Create(ctx, &session.CreateRequest{ AppName: "cv-chat", UserID: "visitor", }) if err != nil { return "", "", fmt.Errorf("session create: %w", err) } sessionID := sess.Session.ID() userMsg := genai.NewContentFromText(message, genai.RoleUser) var response strings.Builder for event, err := range cr.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) { if err != nil { return "", "", err } if event.IsFinalResponse() { if event.Content != nil { for _, part := range event.Content.Parts { if part.Text != "" { response.WriteString(part.Text) } } } } } return response.String(), sessionID, nil } // mdLinkRe matches markdown links like [text](#anchor) var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((#[a-zA-Z0-9_-]+)\)`) // formatResponse converts basic markdown to HTML for the chat bubble. func formatResponse(text string) string { text = html.EscapeString(text) for strings.Contains(text, "**") { text = strings.Replace(text, "**", "", 1) text = strings.Replace(text, "**", "", 1) } // Links: [text](#anchor) → clickable navigation link // After html.EscapeString, the parens and brackets are unchanged but # stays. // The regex matches the escaped form since []()# are not escaped by html.EscapeString. text = mdLinkRe.ReplaceAllString(text, `$1`) lines := strings.Split(text, "\n") var result []string inList := false for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "• ") { if !inList { result = append(result, "") inList = false } if trimmed != "" { result = append(result, "

"+trimmed+"

") } } } if inList { result = append(result, "") } return strings.Join(result, "") }