package chat import ( "context" "fmt" "html" "log" "net/http" "os" "strings" "github.com/juanatsap/cv-site/internal/cache" "google.golang.org/adk/agent" "google.golang.org/adk/model/gemini" "google.golang.org/adk/runner" "google.golang.org/adk/session" "google.golang.org/genai" ) // Handler serves the chat API endpoint. type Handler struct { runner *runner.Runner sessionService session.Service enabled bool } // NewHandler creates a chat handler. Returns a disabled handler if GOOGLE_API_KEY is not set. func NewHandler(dataCache *cache.DataCache) *Handler { apiKey := os.Getenv("GOOGLE_API_KEY") if apiKey == "" { log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled") return &Handler{enabled: false} } ctx := context.Background() modelName := os.Getenv("MODEL_NAME") if modelName == "" { modelName = "gemini-2.5-flash" } llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ APIKey: apiKey, }) if err != nil { log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err) return &Handler{enabled: false} } cvAgent, err := NewAgent(llm, dataCache) if err != nil { log.Printf("⚠️ Failed to create CV agent: %v — chat disabled", err) return &Handler{enabled: false} } sessionSvc := session.InMemoryService() r, err := runner.New(runner.Config{ AppName: "cv-chat", Agent: cvAgent, SessionService: sessionSvc, AutoCreateSession: true, }) if err != nil { log.Printf("⚠️ Failed to create runner: %v — chat disabled", err) return &Handler{enabled: false} } log.Printf("💬 Chat agent enabled (model: %s)", modelName) return &Handler{ runner: r, sessionService: sessionSvc, enabled: true, } } // Enabled returns whether the chat feature is available. func (h *Handler) Enabled() bool { return h.enabled } // HandleChat processes POST /api/chat requests. // Expects form field "message" and optional "session_id". // Returns an HTML fragment for HTMX to swap into the chat panel. 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 } sessionID := r.FormValue("session_id") if sessionID == "" { sessionID = "default" } // Ensure session exists ctx := r.Context() _, err := h.sessionService.Get(ctx, &session.GetRequest{ AppName: "cv-chat", UserID: "visitor", SessionID: sessionID, }) if err != nil { // Create new session created, createErr := h.sessionService.Create(ctx, &session.CreateRequest{ AppName: "cv-chat", UserID: "visitor", }) if createErr != nil { log.Printf("Chat session create error: %v", createErr) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprint(w, `
Failed to start chat session.
`) return } sessionID = created.Session.ID() } // Run the agent userMsg := genai.NewContentFromText(message, genai.RoleUser) var response strings.Builder for event, err := range h.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) { if err != nil { log.Printf("Chat agent error: %v", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprint(w, `
Something went wrong. Please try again.
`) return } if event.IsFinalResponse() { if event.Content != nil { for _, part := range event.Content.Parts { if part.Text != "" { response.WriteString(part.Text) } } } } } // Render the response as HTML w.Header().Set("Content-Type", "text/html; charset=utf-8") // User message bubble _, _ = fmt.Fprintf(w, `
%s
`, html.EscapeString(message)) // Agent response bubble agentText := response.String() if agentText == "" { agentText = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education." } _, _ = fmt.Fprintf(w, `
%s
`, formatResponse(agentText)) // Hidden input to preserve session ID for next request _, _ = fmt.Fprintf(w, ``, sessionID) } // formatResponse converts basic markdown to HTML for the chat bubble. func formatResponse(text string) string { // Escape HTML first text = html.EscapeString(text) // Bold: **text** → text for strings.Contains(text, "**") { text = strings.Replace(text, "**", "", 1) text = strings.Replace(text, "**", "", 1) } // Bullet points: lines starting with "- " →
  • 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, "") }