214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
|
|
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, "")
|
||
|
|
}
|