Files
cv-site/internal/chat/handler.go
T

214 lines
5.8 KiB
Go
Raw Normal View History

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, `<div class="chat-message chat-error">Chat is not available at the moment.</div>`)
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, `<div class="chat-message chat-error">Please enter a message.</div>`)
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, `<div class="chat-message chat-error">Failed to start chat session.</div>`)
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, `<div class="chat-message chat-error">Something went wrong. Please try again.</div>`)
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, `<div class="chat-message chat-user">%s</div>`, 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, `<div class="chat-message chat-agent">%s</div>`, formatResponse(agentText))
// Hidden input to preserve session ID for next request
_, _ = fmt.Fprintf(w, `<input type="hidden" name="session_id" value="%s" form="chat-form"/>`, 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** → <strong>text</strong>
for strings.Contains(text, "**") {
text = strings.Replace(text, "**", "<strong>", 1)
text = strings.Replace(text, "**", "</strong>", 1)
}
// Bullet points: lines starting with "- " → <li>
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, "<ul>")
inList = true
}
result = append(result, "<li>"+strings.TrimPrefix(strings.TrimPrefix(trimmed, "- "), "• ")+"</li>")
} else {
if inList {
result = append(result, "</ul>")
inList = false
}
if trimmed != "" {
result = append(result, "<p>"+trimmed+"</p>")
}
}
}
if inList {
result = append(result, "</ul>")
}
return strings.Join(result, "")
}