feat: add AI chat widget powered by ADK Go 1.0
Visitors can ask questions about the CV via a floating chat panel. The agent uses Gemini to answer questions about experience, projects, skills, and education by querying the cached CV JSON data. - internal/chat/agent.go: LLM agent with query_cv tool that searches CV data by section (experience, projects, skills, etc.) with keyword filtering - internal/chat/handler.go: POST /api/chat endpoint with session management, graceful degradation when GOOGLE_API_KEY is not set - chat-widget.html: HTMX-powered floating chat panel with Hyperscript toggle - _chat.css: Responsive chat UI with dark theme support - Wired into existing architecture via dependency injection (CVHandler, routes, main.go) — zero breaking changes, all existing tests pass
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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, "")
|
||||
}
|
||||
Reference in New Issue
Block a user