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

318 lines
9.2 KiB
Go
Raw Normal View History

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, `<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
}
// 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, `<div class="chat-message chat-error">%s</div>`, errMsg)
return
}
2026-04-08 17:51:14 +01:00
// User message bubble (no avatar, right-aligned)
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-user"><div class="chat-msg">%s</div></div>`, 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."
}
2026-04-08 17:51:14 +01:00
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-bot"><div class="chat-avatar"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></div><div class="chat-msg">%s</div></div>`, formatResponse(response))
// Session ID via OOB swap
_, _ = fmt.Fprintf(w, `<input type="hidden" id="chat-session-id" name="session_id" value="%s" form="chat-form" hx-swap-oob="true"/>`, 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, "**", "<strong>", 1)
text = strings.Replace(text, "**", "</strong>", 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, `<a href="$2" class="chat-nav-link" onclick="return scrollToCV(this)">$1</a>`)
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, "")
}