438 lines
13 KiB
Go
438 lines
13 KiB
Go
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, `<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
|
|
}
|
|
|
|
// Enforce response language based on the CV language the user is viewing
|
|
switch lang := r.FormValue("lang"); lang {
|
|
case "en":
|
|
message = "[RESPOND IN ENGLISH] " + message
|
|
case "es":
|
|
message = "[RESPONDE EN ESPAÑOL] " + message
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, `<div class="chat-row chat-row-bot"><div class="chat-avatar chat-avatar-juan"><img src="/static/images/profile/dni-thumb.jpeg" alt="Juan"></div><div class="chat-msg">%s</div></div>`, h.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(), 90*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: [text](#anchor) and [text](https://...)
|
|
var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(((?:#|https?://)[^\)]+)\)`)
|
|
|
|
// formatResponse converts basic markdown to HTML for the chat bubble,
|
|
// injecting icons next to navigation links when available.
|
|
func (h *Handler) formatResponse(text string) string {
|
|
text = html.EscapeString(text)
|
|
|
|
for strings.Contains(text, "**") {
|
|
text = strings.Replace(text, "**", "<strong>", 1)
|
|
text = strings.Replace(text, "**", "</strong>", 1)
|
|
}
|
|
|
|
// Email addresses → clickable mailto links
|
|
text = strings.ReplaceAll(text, "txeo.msx@gmail.com",
|
|
`<a href="mailto:txeo.msx@gmail.com" class="chat-nav-link">txeo.msx@gmail.com</a>`)
|
|
|
|
// Links: [text](#anchor) → icon + nav link, [text](https://...) → external link
|
|
text = mdLinkRe.ReplaceAllStringFunc(text, func(match string) string {
|
|
parts := mdLinkRe.FindStringSubmatch(match)
|
|
if len(parts) != 3 {
|
|
return match
|
|
}
|
|
linkText, href := parts[1], parts[2]
|
|
|
|
// External link
|
|
if strings.HasPrefix(href, "http") {
|
|
return fmt.Sprintf(`<a href="%s" class="chat-nav-link" target="_blank" rel="noopener">%s</a>`, href, linkText)
|
|
}
|
|
|
|
// Internal CV navigation link
|
|
anchorID := strings.TrimPrefix(href, "#")
|
|
link := fmt.Sprintf(`<a href="%s" class="chat-nav-link" onclick="return scrollToCV(this)">%s</a>`, href, linkText)
|
|
if info, ok := h.icons[anchorID]; ok {
|
|
var icon string
|
|
if info.spriteIndex >= 0 {
|
|
icon = fmt.Sprintf(`<span class="icon-sprite icon-chat icon-%s" style="--icon-index:%d" role="img"></span>`, info.category, info.spriteIndex)
|
|
} else if info.imagePath != "" {
|
|
icon = fmt.Sprintf(`<img src="%s" class="chat-inline-icon" alt="">`, info.imagePath)
|
|
}
|
|
if icon != "" {
|
|
return icon + " " + link
|
|
}
|
|
}
|
|
return link
|
|
})
|
|
|
|
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, "")
|
|
}
|
|
|
|
// 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
|
|
}
|