diff --git a/.env.example b/.env.example index 7beea89..dc780ae 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,9 @@ TEMPLATE_HOT_RELOAD=true DATA_DIR=data # Server Timeouts (seconds) +# Write timeout must accommodate local LLM response times (Ollama ~60s for tool-calling queries) READ_TIMEOUT=15 -WRITE_TIMEOUT=15 +WRITE_TIMEOUT=120 # Security Configuration # Allowed origins for API access (comma-separated domains) @@ -80,6 +81,19 @@ SMTP_PASSWORD=your-password SMTP_FROM_EMAIL=your-email@yourdomain.com CONTACT_EMAIL=recipient@example.com +# Chat AI Configuration +# +# MODEL_PROVIDER: "gemini" (default) or "ollama" +# MODEL_PROVIDER=gemini +# +# Gemini settings (when MODEL_PROVIDER=gemini): +# GOOGLE_API_KEY=your-google-api-key +# MODEL_NAME=gemini-2.5-flash +# +# Ollama settings (when MODEL_PROVIDER=ollama): +# OLLAMA_HOST=http://localhost:11434 +# OLLAMA_MODEL=glm-4.7-flash + # Production Settings # Uncomment for production: # GO_ENV=production diff --git a/README.md b/README.md index d05415e..384ea5f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # CV Site - Go + HTMX -[![Go Version](https://img.shields.io/badge/Go-1.21%2B-00ADD8?logo=go)](https://go.dev/) -[![HTMX](https://img.shields.io/badge/HTMX-1.9.10-3366CC)](https://htmx.org/) +[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/) +[![HTMX](https://img.shields.io/badge/HTMX-2.0-3366CC)](https://htmx.org/) +[![ADK Go](https://img.shields.io/badge/ADK_Go-1.0-4285F4?logo=google)](https://github.com/google/adk-go) +[![Gemini](https://img.shields.io/badge/Gemini_2.5_Flash-AI_Chat-8E75B2?logo=google)](https://aistudio.google.com/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) **Modern, minimal curriculum vitae website** for Juan AndrΓ©s Moreno Rubio built with **Go** and **HTMX**. @@ -19,6 +21,7 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact ## πŸ“‘ Table of Contents - [Features](#-features) +- [AI Chat Agent](#-ai-chat-agent) - [Demo](#-demo) - [Security](#-security) - [Quick Start](#-quick-start) @@ -43,6 +46,7 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact - βœ… **Zoom Control** - Adjustable zoom (25%-300%) with persistence across sessions - βœ… **Responsive** - Mobile, tablet, and desktop friendly - βœ… **JSON-Based Content** - Easy to update without touching code +- βœ… **AI Chat Agent** - Ask questions about the CV in natural language (powered by ADK Go + Gemini) - βœ… **AI Development Section** - Showcases modern AI-assisted development skills - βœ… **Fast & Lightweight** - Go backend with chromedp for PDF generation - βœ… **Privacy-Friendly Analytics** - Self-hosted analytics (no third-party data sharing) @@ -50,6 +54,50 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact - βœ… **Production Ready** - Systemd service, CI/CD workflows, deployment guides - βœ… **Developer Friendly** - Hot reload, clear code structure, comprehensive Makefile +## πŸ€– AI Chat Agent + +Visitors can ask questions about the CV through a floating chat widget β€” powered by [Google ADK Go 1.0](https://github.com/google/adk-go) and Gemini 2.5 Flash. + +### How It Works + +``` +Visitor types question β†’ HTMX POST /api/chat β†’ ADK Agent runs query_cv tool +β†’ Tool searches cached CV JSON data β†’ Agent formulates answer β†’ HTML response +``` + +### Example Questions & Answers + +| Question | Answer | +|----------|--------| +| *"How many Go projects has Juan built?"* | Lists 2 Go projects with descriptions | +| *"What companies has he worked at?"* | Lists all 11 companies | +| *"Does he have React experience?"* | Shows companies where React was used | +| *"ΒΏQuΓ© certificaciones tiene?"* | Lists certifications β€” answers in Spanish automatically | + +### Key Design Decisions + +- **Single agent, single tool** β€” the CV data is bounded; multi-agent orchestration would be over-engineering +- **Reads from the same data cache** the site uses β€” zero data duplication, always in sync +- **Graceful degradation** β€” no API key? Chat icon simply doesn't appear. Zero impact on the site +- **HTMX-native** β€” `hx-post` sends messages, responses are HTML fragments, no WebSocket needed +- **Language-aware** β€” the agent responds in whatever language the visitor writes in + +### Setup + +```bash +# Get a free API key from https://aistudio.google.com/apikey +echo "GOOGLE_API_KEY=your-key" >> .env + +# Chat icon appears automatically on next server start +go run . +``` + +**Free tier:** 15 requests/minute β€” more than enough for a personal CV site. + +**Full technical documentation:** [doc/28-AI-CHAT-AGENT.md](doc/28-AI-CHAT-AGENT.md) + +--- + ## πŸ“Έ Demo πŸ”— **Live Demo:** [https://juan.andres.morenorub.io/](https://juan.andres.morenorub.io/) @@ -187,9 +235,10 @@ No code changes needed - just refresh browser! ## 🎯 Key Technologies -- **Backend:** Go 1.21+ (stdlib `net/http`, graceful shutdown) +- **Backend:** Go 1.25+ (stdlib `net/http`, graceful shutdown) +- **AI Agent:** Google ADK Go 1.0 + Gemini 2.5 Flash (conversational CV navigator) - **PDF Generation:** chromedp (headless Chrome automation) -- **Frontend:** HTMX 1.9.10 (hypermedia-driven interactions) +- **Frontend:** HTMX 2.0 + Hyperscript (hypermedia-driven interactions) - **Styling:** Custom CSS with Quicksand font from Google Fonts - **Data:** JSON files for easy content management - **Deployment:** Systemd service, manual binary, GitHub Actions CI/CD @@ -206,6 +255,8 @@ This project includes comprehensive documentation organized by purpose: ### πŸ”§ Technical Reference - **[ARCHITECTURE.md](doc/ARCHITECTURE.md)** - System design, patterns, and technical decisions - **[API.md](doc/API.md)** - Complete HTTP API reference and HTMX integration +- **[AI-CHAT-AGENT.md](doc/28-AI-CHAT-AGENT.md)** - ADK Go agent architecture, tool design, and integration details +- **[AI-CHAT-SHOWCASE.md](doc/29-AI-CHAT-SHOWCASE.md)** - Technical showcase: AI-powered CV navigation with ADK Go, dual-provider architecture, and document GPS ### πŸ“‹ Policies & Standards - **[SECURITY.md](doc/9-SECURITY.md)** - Complete security architecture, implementation, and testing guide @@ -323,6 +374,7 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## πŸ™ Acknowledgments - **HTMX** - For making hypermedia-driven applications enjoyable +- **Google ADK Go** - For the production-grade agent framework - **chromedp** - For reliable headless Chrome automation - **Go Community** - For excellent standard library and tooling - **AI Assistance** - For accelerating development and documentation diff --git a/doc/00-GO-DOCUMENTATION-INDEX.md b/doc/00-GO-DOCUMENTATION-INDEX.md index a9d98fb..8378451 100644 --- a/doc/00-GO-DOCUMENTATION-INDEX.md +++ b/doc/00-GO-DOCUMENTATION-INDEX.md @@ -36,6 +36,25 @@ This documentation covers the core Go systems that power the CV site, with a foc - Coverage gap explanations - Best practices and CI/CD integration +5. **[AI Chat Agent β€” CV Assistant Mascot](28-AI-CHAT-AGENT.md)** (~544 lines) + - Complete mascot feature reference: architecture, components, intelligence + - ADK Go 1.0 integration with Gemini 2.5 Flash + - Agent definition with query_cv tool (11 section types, cross-section search) + - 8 question-type query strategies with instruction engineering + - HTMX + Hyperscript chat widget with suggested question chips + - Help modal with categorized example questions + - Session management (in-memory, OOB swap) + - Design system integration (CSS tokens, dark theme, responsive) + - Graceful degradation, security, and testing (46 Playwright assertions) + +6. **[AI Chat Showcase β€” Technical Writeup](29-AI-CHAT-SHOWCASE.md)** (~250 lines) + - Public-facing technical showcase of the AI chat feature + - Architecture diagram with dual-provider fallback + - 9 key technical decisions explained with code examples + - CV navigation links (GPS for the CV) + - Technology stack and file structure + - What this demonstrates for potential employers/clients + ## Quick Navigation ### By Feature diff --git a/doc/28-AI-CHAT-AGENT.md b/doc/28-AI-CHAT-AGENT.md new file mode 100644 index 0000000..0cef310 --- /dev/null +++ b/doc/28-AI-CHAT-AGENT.md @@ -0,0 +1,544 @@ +# 28. AI Chat Agent β€” CV Assistant Mascot + +## 1. Overview + +The CV site includes an AI-powered conversational assistant (the "mascot") that lets visitors ask natural language questions about the CV content. Built with [Google ADK Go 1.0](https://github.com/google/adk-go) (Agent Development Kit) and Gemini AI, it provides instant, accurate answers by querying the same cached JSON data that renders the site. + +The mascot appears as a floating robot icon in the bottom-right corner of the page. Clicking it opens a chat panel where visitors can type questions or click suggested question chips. All answers are sourced from real CV data β€” no hallucination, no stale data. + +**Why it exists:** A CV is a dense document. Visitors (recruiters, hiring managers) often have specific questions: "Does he know React?", "How many years of experience?", "What certifications?". Instead of making them scan every section, the mascot lets them ask directly and get precise, cross-referenced answers. + +**Live example:** A visitor asks *"What is Juan's experience with Go?"* and gets a response listing Go projects (Immich Photo Manager, Cmux Resurrect), skill categories where Go appears, and experience entries involving Go β€” all pulled from the actual CV data in real time. + +## 2. Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CV Site Server β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Data Cache │─────▢│ ADK Go Agent β”‚ β”‚ +β”‚ β”‚ (cv-en.json) β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ (cv-es.json) β”‚ β”‚ β”‚ cv_assistant (LLM Agent) β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ Tools: β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ └─ query_cv(section, query) β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ search (cross-section) β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ experience β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ projects β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ skills β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ education β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ languages β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ certifications β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ courses β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ awards β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”œβ”€ summary β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ └─ all β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ POST /api/chat β”‚ β”‚ +β”‚ β”‚ (chat.Handler) β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Session management (in-memory) β”‚ β”‚ +β”‚ β”‚ β”œβ”€ ADK Runner execution β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Markdown-to-HTML conversion β”‚ β”‚ +β”‚ β”‚ └─ HTML fragment response (HTMX swap) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–² β”‚ +β”‚ β”‚ hx-post="/api/chat" β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Chat Widget (HTMX + Hyperscript) β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Floating mascot button (robot icon) β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Expandable chat panel β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Suggested question chips (5 per language) β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Message history with auto-scroll β”‚ β”‚ +β”‚ β”‚ β”œβ”€ Typing indicator (animated dots) β”‚ β”‚ +β”‚ β”‚ └─ Session ID persistence (OOB swap) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Gemini 2.5 Flash β”‚ + β”‚ (Google AI) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### End-to-End Flow + +1. **User clicks a chip or types a question** in the chat panel. +2. **Hyperscript** sets the input value (for chips) and triggers `submit` on `#chat-form`. +3. **HTMX** intercepts the form submit and sends `POST /api/chat` with `message`, `session_id`, and `lang` fields. +4. **Go handler** (`chat.HandleChat`) receives the request, ensures a session exists, and creates an ADK `runner.Run()` call. +5. **ADK Runner** sends the message to Gemini along with the agent instruction and available tools. +6. **Gemini calls `query_cv`** with appropriate `section` and `query` parameters (the agent decides which sections to query based on its instruction strategy). +7. **`query_cv` tool** searches the cached CV JSON data (`cache.DataCache`) β€” the same data that renders the HTML pages. For technology queries, it performs cross-section search across experience, projects, skills, and courses simultaneously. +8. **Gemini synthesizes** the tool results into a natural language response. +9. **Handler renders** the response as an HTML fragment: user message bubble + agent message bubble + session ID hidden input. +10. **HTMX swaps** the fragment into `#chat-messages` with `beforeend` swap and auto-scrolls to the bottom. +11. **OOB swap** updates the `#chat-session-id` hidden input so subsequent messages maintain conversation context. + +## 3. Components + +### File Structure + +``` +internal/chat/ +β”œβ”€β”€ agent.go # Agent definition, query_cv tool, filter functions +└── handler.go # HTTP handler, session mgmt, Gemini init, response rendering + +templates/partials/ +β”œβ”€β”€ widgets/chat-widget.html # HTMX chat panel with Hyperscript +└── modals/chat-help-modal.html # Help modal with example questions by category + +static/css/04-interactive/ +└── _chat.css # Styling (CV design tokens, dark theme, responsive) + +tests/mjs/ +└── 83-chat-mascot.test.mjs # 46 Playwright test assertions +``` + +### `internal/chat/agent.go` + +Defines the single LLM agent (`cv_assistant`) with one tool (`query_cv`). Contains: + +- **`NewAgent()`** β€” Creates the agent with a comprehensive instruction prompt covering 8 question types and query strategies. +- **`QueryCVArgs` / `QueryCVResult`** β€” Input/output structs for the tool with JSON schema annotations used by ADK for function calling. +- **`newQueryCVTool()`** β€” Wraps the query function as an agent-callable tool via `functiontool.New`. Supports 11 section values: `search`, `experience`, `projects`, `skills`, `education`, `languages`, `certifications`, `courses`, `awards`, `summary`, `all`. +- **Filter helpers** β€” `filterExperience()`, `filterProjects()`, `filterSkills()`, `filterCourses()` perform case-insensitive keyword matching across all relevant fields (title, company, technologies, descriptions, responsibilities). +- **`matchesAny()` / `matchesSlice()`** β€” Low-level string matching used by all filters. +- **`calculateYears()`** β€” Computes years of experience from career start date (April 2005). + +**Why a single agent?** The CV data is structured and bounded. There is no need for multi-agent orchestration. One agent with one tool is the right abstraction: simple, fast, predictable. + +### `internal/chat/handler.go` + +Handles the HTTP lifecycle: + +- **`NewHandler()`** β€” Initializes Gemini model, creates the agent, sets up in-memory session service and ADK runner. Returns a disabled handler if `GOOGLE_API_KEY` is not set. +- **`Enabled()`** β€” Boolean check used by templates to conditionally render the widget. +- **`HandleChat()`** β€” Processes `POST /api/chat`. Validates input, ensures session exists, runs the agent with a 30-second timeout (using a dedicated context, not the HTTP request context), renders the HTML fragment response. +- **`formatResponse()`** β€” Converts basic markdown to HTML: escapes HTML entities first, then applies `**bold**` to ``, converts `- ` bullet lines to `") + } + + return strings.Join(result, "") +} + +// buildIconMap creates a mapping from anchor IDs to sprite info from CV data. +func buildIconMap(dataCache *cache.DataCache) map[string]spriteInfo { + icons := make(map[string]spriteInfo) + + for _, lang := range []string{"en", "es"} { + cv := dataCache.GetCV(lang) + if cv == nil { + continue + } + for _, e := range cv.Experience { + if e.LogoIndex != nil && e.CompanyID != "" { + icons["exp-"+e.CompanyID] = spriteInfo{index: *e.LogoIndex, category: "company"} + } + } + for _, p := range cv.Projects { + if p.LogoIndex != nil && p.ProjectID != "" { + icons["proj-"+p.ProjectID] = spriteInfo{index: *p.LogoIndex, category: "project"} + } + } + for _, c := range cv.Courses { + if c.LogoIndex != nil && c.CourseID != "" { + icons["course-"+c.CourseID] = spriteInfo{index: *c.LogoIndex, category: "course"} + } + } + } + + return icons +} diff --git a/internal/chat/ollama.go b/internal/chat/ollama.go new file mode 100644 index 0000000..004d206 --- /dev/null +++ b/internal/chat/ollama.go @@ -0,0 +1,430 @@ +// Package chat provides an ADK Go agent that answers questions about CV data. +package chat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "iter" + "net/http" + + "google.golang.org/adk/model" + "google.golang.org/genai" +) + +// OllamaModel implements model.LLM using Ollama's OpenAI-compatible API. +type OllamaModel struct { + host string // e.g. "http://localhost:11434" + modelName string // e.g. "mistral-small3.2" + client *http.Client +} + +// NewOllamaModel creates a new Ollama-backed LLM. +func NewOllamaModel(host, modelName string) *OllamaModel { + return &OllamaModel{ + host: host, + modelName: modelName, + client: &http.Client{}, + } +} + +// Name returns the model name. +func (m *OllamaModel) Name() string { + return m.modelName +} + +// Verify OllamaModel implements model.LLM at compile time. +var _ model.LLM = (*OllamaModel)(nil) + +// GenerateContent sends a request to Ollama and returns ADK-compatible responses. +func (m *OllamaModel) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { + return func(yield func(*model.LLMResponse, error) bool) { + resp, err := m.generate(ctx, req) + yield(resp, err) + } +} + +// --- OpenAI-compatible request/response types --- + +type oaiMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type oaiToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function oaiToolFunction `json:"function"` +} + +type oaiToolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON string +} + +type oaiTool struct { + Type string `json:"type"` + Function oaiToolFuncDecl `json:"function"` +} + +type oaiToolFuncDecl struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} + +type oaiRequest struct { + Model string `json:"model"` + Messages []oaiMessage `json:"messages"` + Tools []oaiTool `json:"tools,omitempty"` + Stream bool `json:"stream"` + Temperature *float32 `json:"temperature,omitempty"` +} + +type oaiResponse struct { + Choices []oaiChoice `json:"choices"` + Usage *oaiUsage `json:"usage,omitempty"` + Model string `json:"model,omitempty"` +} + +type oaiChoice struct { + Message oaiMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type oaiUsage struct { + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + TotalTokens int32 `json:"total_tokens"` +} + +// generate performs a synchronous (non-streaming) call to Ollama. +func (m *OllamaModel) generate(ctx context.Context, req *model.LLMRequest) (*model.LLMResponse, error) { + oaiReq := m.buildRequest(req) + + body, err := json.Marshal(oaiReq) + if err != nil { + return nil, fmt.Errorf("ollama: marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v1/chat/completions", m.host) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ollama: create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := m.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ollama: send request: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("ollama: read response: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama: HTTP %d: %s", httpResp.StatusCode, string(respBody)) + } + + var oaiResp oaiResponse + if err := json.Unmarshal(respBody, &oaiResp); err != nil { + return nil, fmt.Errorf("ollama: unmarshal response: %w", err) + } + + return m.convertResponse(&oaiResp) +} + +// buildRequest converts an ADK LLMRequest into an OpenAI-compatible request. +func (m *OllamaModel) buildRequest(req *model.LLMRequest) *oaiRequest { + oaiReq := &oaiRequest{ + Model: m.modelName, + Stream: false, + } + + // Convert system instruction + if req.Config != nil && req.Config.SystemInstruction != nil { + text := extractText(req.Config.SystemInstruction) + if text != "" { + oaiReq.Messages = append(oaiReq.Messages, oaiMessage{ + Role: "system", + Content: text, + }) + } + } + + // Set temperature if provided + if req.Config != nil && req.Config.Temperature != nil { + oaiReq.Temperature = req.Config.Temperature + } + + // Convert conversation messages + for _, content := range req.Contents { + msgs := convertContent(content) + oaiReq.Messages = append(oaiReq.Messages, msgs...) + } + + // Convert tools (function declarations) + if req.Config != nil && req.Config.Tools != nil { + for _, t := range req.Config.Tools { + if t.FunctionDeclarations != nil { + for _, fd := range t.FunctionDeclarations { + oaiReq.Tools = append(oaiReq.Tools, convertFunctionDecl(fd)) + } + } + } + } + + return oaiReq +} + +// convertContent converts a genai.Content into one or more OpenAI messages. +func convertContent(content *genai.Content) []oaiMessage { + if content == nil { + return nil + } + + role := mapRole(content.Role) + + // Check if this content has function calls (assistant with tool_calls) + var toolCalls []oaiToolCall + var textParts []string + var funcResponses []oaiMessage + + for _, part := range content.Parts { + if part.Text != "" { + textParts = append(textParts, part.Text) + } + if part.FunctionCall != nil { + argsJSON, _ := json.Marshal(part.FunctionCall.Args) + toolCalls = append(toolCalls, oaiToolCall{ + ID: part.FunctionCall.ID, + Type: "function", + Function: oaiToolFunction{ + Name: part.FunctionCall.Name, + Arguments: string(argsJSON), + }, + }) + } + if part.FunctionResponse != nil { + respJSON, _ := json.Marshal(part.FunctionResponse.Response) + funcResponses = append(funcResponses, oaiMessage{ + Role: "tool", + Content: string(respJSON), + ToolCallID: part.FunctionResponse.ID, + }) + } + } + + var msgs []oaiMessage + + // Build the primary message + if len(toolCalls) > 0 { + // Assistant message with tool calls + msg := oaiMessage{ + Role: "assistant", + ToolCalls: toolCalls, + } + if len(textParts) > 0 { + combined := "" + for _, t := range textParts { + combined += t + } + msg.Content = combined + } + msgs = append(msgs, msg) + } else if len(textParts) > 0 { + combined := "" + for _, t := range textParts { + combined += t + } + msgs = append(msgs, oaiMessage{ + Role: role, + Content: combined, + }) + } + + // Append function response messages separately + msgs = append(msgs, funcResponses...) + + return msgs +} + +// convertFunctionDecl converts a genai FunctionDeclaration to an OpenAI tool. +func convertFunctionDecl(fd *genai.FunctionDeclaration) oaiTool { + var params any + if fd.Parameters != nil { + params = convertSchema(fd.Parameters) + } else if fd.ParametersJsonSchema != nil { + params = fd.ParametersJsonSchema + } + + return oaiTool{ + Type: "function", + Function: oaiToolFuncDecl{ + Name: fd.Name, + Description: fd.Description, + Parameters: params, + }, + } +} + +// convertSchema converts a genai.Schema to a JSON-Schema-compatible map. +func convertSchema(s *genai.Schema) map[string]any { + if s == nil { + return nil + } + + m := make(map[string]any) + + if s.Type != "" { + m["type"] = schemaTypeToJSON(s.Type) + } + if s.Description != "" { + m["description"] = s.Description + } + if len(s.Enum) > 0 { + m["enum"] = s.Enum + } + if s.Items != nil { + m["items"] = convertSchema(s.Items) + } + if len(s.Properties) > 0 { + props := make(map[string]any) + for k, v := range s.Properties { + props[k] = convertSchema(v) + } + m["properties"] = props + } + if len(s.Required) > 0 { + m["required"] = s.Required + } + + return m +} + +// schemaTypeToJSON maps genai.Type to JSON Schema type strings. +func schemaTypeToJSON(t genai.Type) string { + switch t { + case genai.TypeString: + return "string" + case genai.TypeNumber: + return "number" + case genai.TypeInteger: + return "integer" + case genai.TypeBoolean: + return "boolean" + case genai.TypeArray: + return "array" + case genai.TypeObject: + return "object" + default: + return "string" + } +} + +// convertResponse converts an OpenAI response back to an ADK LLMResponse. +func (m *OllamaModel) convertResponse(resp *oaiResponse) (*model.LLMResponse, error) { + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("ollama: empty response (no choices)") + } + + choice := resp.Choices[0] + var parts []*genai.Part + + // Handle tool calls + if len(choice.Message.ToolCalls) > 0 { + for _, tc := range choice.Message.ToolCalls { + var args map[string]any + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + // If args aren't valid JSON, wrap them + args = map[string]any{"raw": tc.Function.Arguments} + } + } + parts = append(parts, &genai.Part{ + FunctionCall: &genai.FunctionCall{ + ID: tc.ID, + Name: tc.Function.Name, + Args: args, + }, + }) + } + } + + // Handle text content + if choice.Message.Content != "" { + parts = append(parts, &genai.Part{ + Text: choice.Message.Content, + }) + } + + content := &genai.Content{ + Parts: parts, + Role: genai.RoleModel, + } + + llmResp := &model.LLMResponse{ + Content: content, + FinishReason: mapFinishReason(choice.FinishReason), + TurnComplete: true, + ModelVersion: resp.Model, + } + + // Map usage metadata + if resp.Usage != nil { + llmResp.UsageMetadata = &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: resp.Usage.PromptTokens, + CandidatesTokenCount: resp.Usage.CompletionTokens, + TotalTokenCount: resp.Usage.TotalTokens, + } + } + + return llmResp, nil +} + +// mapRole converts genai roles to OpenAI roles. +func mapRole(role string) string { + switch role { + case "user": + return "user" + case "model": + return "assistant" + default: + return "user" + } +} + +// mapFinishReason converts OpenAI finish reasons to genai finish reasons. +func mapFinishReason(reason string) genai.FinishReason { + switch reason { + case "stop": + return genai.FinishReasonStop + case "length": + return genai.FinishReasonMaxTokens + case "tool_calls": + return genai.FinishReasonStop // Tool calls are a normal stop + default: + return genai.FinishReasonStop + } +} + +// extractText extracts all text from a genai.Content. +func extractText(content *genai.Content) string { + if content == nil { + return "" + } + var result string + for _, part := range content.Parts { + if part.Text != "" { + result += part.Text + } + } + return result +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 1f37802..8bcd94e 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -135,6 +135,8 @@ const ( RateLimitGeneralWindow = 1 * time.Minute RateLimitContactRequests = 5 RateLimitContactWindow = 1 * time.Hour + RateLimitChatRequests = 30 + RateLimitChatWindow = 1 * time.Hour ) // ============================================================================== diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index ffef514..3971bfd 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -21,15 +21,17 @@ type CVHandler struct { emailService *email.Service serverAddr string dataCache *cache.DataCache + chatEnabled bool } // NewCVHandler creates a new CV handler -func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache) *CVHandler { +func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache, chatEnabled bool) *CVHandler { return &CVHandler{ templates: tmpl, pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration), emailService: emailService, serverAddr: serverAddr, dataCache: dataCache, + chatEnabled: chatEnabled, } } diff --git a/internal/handlers/cv_helpers.go b/internal/handlers/cv_helpers.go index f172e49..6c14923 100644 --- a/internal/handlers/cv_helpers.go +++ b/internal/handlers/cv_helpers.go @@ -365,6 +365,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + "ChatEnabled": h.chatEnabled, } return data, nil diff --git a/internal/handlers/test_helpers_test.go b/internal/handlers/test_helpers_test.go index 6d3170c..f10ac29 100644 --- a/internal/handlers/test_helpers_test.go +++ b/internal/handlers/test_helpers_test.go @@ -95,5 +95,5 @@ func newTestCVHandler(t testing.TB, serverAddr string, emailService *email.Servi dataCache := getTestCache(t) - return NewCVHandler(tmplManager, serverAddr, emailService, dataCache) + return NewCVHandler(tmplManager, serverAddr, emailService, dataCache, false) } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index eadbe91..5b8e979 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -3,13 +3,14 @@ package routes import ( "net/http" + "github.com/juanatsap/cv-site/internal/chat" c "github.com/juanatsap/cv-site/internal/constants" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/middleware" ) // Setup configures all application routes and middleware -func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler { +func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, chatHandler *chat.Handler) http.Handler { mux := http.NewServeMux() // Shortcut routes for default CV (year-aware) - MUST be before "/" route @@ -17,7 +18,13 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) mux.HandleFunc("/cv-jamr-", cvHandler.DefaultCVShortcut) // API routes (must be before "/" to avoid catch-all) - mux.HandleFunc("/api/cmd-k", cvHandler.CmdKData) // CMD+K command palette data + mux.HandleFunc("/api/cmd-k", cvHandler.CmdKData) // CMD+K command palette data + + // Chat endpoint with rate limiting (30 requests/hour per IP) + chatRateLimiter := middleware.NewRateLimiter(c.RateLimitChatRequests, c.RateLimitChatWindow) + mux.Handle("/api/chat", chatRateLimiter.Middleware(http.HandlerFunc(chatHandler.HandleChat))) + mux.HandleFunc("/api/chat/warmup", chatHandler.HandleWarmup) // Pre-load model on chat open + mux.HandleFunc("/api/chat/status", chatHandler.HandleStatus) // Model readiness check // Public routes mux.HandleFunc("/", cvHandler.Home) diff --git a/main.go b/main.go index 8b8c543..e2ccb2b 100644 --- a/main.go +++ b/main.go @@ -12,11 +12,12 @@ import ( "github.com/joho/godotenv" "github.com/juanatsap/cv-site/internal/cache" + "github.com/juanatsap/cv-site/internal/chat" "github.com/juanatsap/cv-site/internal/config" c "github.com/juanatsap/cv-site/internal/constants" + "github.com/juanatsap/cv-site/internal/email" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/routes" - "github.com/juanatsap/cv-site/internal/email" "github.com/juanatsap/cv-site/internal/templates" ) @@ -62,12 +63,20 @@ func main() { }) log.Printf("πŸ“§ Email service configured (SMTP: %s:%s)", cfg.Email.SMTPHost, cfg.Email.SMTPPort) + // Initialize chat handler (gracefully disabled if no API key) + chatHandler := chat.NewHandler(dataCache) + + // In development, auto-warmup the local LLM model on startup + if os.Getenv("GO_ENV") != "production" { + chatHandler.AutoWarmup() + } + // Initialize handlers - cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address(), emailService, dataCache) + cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address(), emailService, dataCache, chatHandler.Enabled()) healthHandler := handlers.NewHealthHandler(version) // Setup routes and middleware - handler := routes.Setup(cvHandler, healthHandler) + handler := routes.Setup(cvHandler, healthHandler, chatHandler) // Create server with timeouts server := &http.Server{ diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css new file mode 100644 index 0000000..6d49ecd --- /dev/null +++ b/static/css/04-interactive/_chat.css @@ -0,0 +1,650 @@ +/* ============================================================================ + CHAT WIDGET β€” CV Assistant Mascot + Uses CV design tokens from _variables.css + Position: right side, just above back-to-top button (right: 2rem) + ============================================================================ */ + +/* ========================================================================== + Toggle Button β€” right side, above back-to-top (bottom: 6rem) + Matches existing fixed button style: dark bg, 50px, subtle opacity + ========================================================================== */ + +.chat-toggle-btn { + position: fixed; + bottom: 6rem; + right: 2rem; + z-index: 1000; + width: 50px; + height: 50px; + border-radius: 50%; + background: var(--black-bar, #2b2b2b); + color: white; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + opacity: 0.4; +} + +.chat-toggle-btn:hover { + opacity: 1; + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); + background: var(--accent-green, #27ae60); +} + +/* Icon swap: show mascot by default, close when active */ +.chat-toggle-btn .chat-icon-close { + display: none; +} + +.chat-toggle-btn.mascot-active { + opacity: 1; + background: var(--accent-green, #27ae60); +} + +.chat-toggle-btn.mascot-active .chat-icon-open { + display: none; +} + +.chat-toggle-btn.mascot-active .chat-icon-close { + display: inline-block; +} + +/* ========================================================================== + Panel β€” right side, above the button + ========================================================================== */ + +.chat-panel { + position: fixed; + bottom: 10.5rem; + right: 2rem; + width: 360px; + max-height: 500px; + background: var(--paper-bg, #ffffff); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 8px; + box-shadow: var(--shadow-lg, 2px 2px 9px rgba(0, 0, 0, 0.5)); + display: none; + flex-direction: column; + z-index: 999; + overflow: hidden; + font-family: 'Source Sans Pro', 'Segoe UI', sans-serif; + transition: all 0.25s ease; +} + +.chat-panel.chat-open { + display: flex; +} + +/* Size: Half screen β€” docked right */ +.chat-panel.chat-half { + top: 0; + right: 0; + bottom: 0; + left: auto; + width: 50vw; + max-height: none; + border-radius: 0; + border-left: 2px solid var(--border-light); +} + +.chat-panel.chat-half .chat-messages { + max-height: none; + flex: 1; +} + +/* Size: Floating β€” draggable, resizable feel */ +.chat-panel.chat-float { + top: 10vh; + left: auto; + right: 2rem; + bottom: auto; + width: 420px; + max-height: 70vh; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + resize: both; + overflow: hidden; + transition: none; +} + +/* Disable transition during active drag for instant movement */ +.chat-panel.chat-dragging { + transition: none !important; + user-select: none; +} + +.chat-panel.chat-float .chat-messages { + max-height: none; + flex: 1; +} + +/* Size: Full width */ +.chat-panel.chat-full { + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + max-height: none; + border-radius: 0; + border: none; +} + +.chat-panel.chat-full .chat-messages { + max-height: none; + flex: 1; +} + +/* ========================================================================== + Header β€” accent-green for visual distinction + ========================================================================== */ + +.chat-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--accent-green, #27ae60); + color: #ffffff; + font-family: 'Quicksand', sans-serif; + font-size: 0.85rem; + font-weight: 600; +} + +.chat-header iconify-icon { + font-size: 1.1rem; +} + +/* Header actions β€” icon buttons with tooltips */ +.chat-header-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 1px; +} + +.chat-mode-btn { + background: none; + border: none; + color: rgba(255,255,255,0.5); + cursor: pointer; + font-size: 0.95rem; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.15s; + position: relative; +} + +.chat-mode-btn:hover { + color: #fff; + background: rgba(255,255,255,0.15); +} + +.chat-mode-btn.active { + color: #fff; + background: rgba(255,255,255,0.2); +} + +/* Native tooltip via title attr β€” enhanced with CSS for consistent look */ +.chat-mode-btn[title]:hover::after { + content: attr(title); + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--black-bar, #2b2b2b); + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.65rem; + font-family: 'Source Sans Pro', sans-serif; + font-weight: 400; + white-space: nowrap; + z-index: 1001; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.chat-header-divider { + width: 1px; + height: 16px; + background: rgba(255,255,255,0.25); + margin: 0 3px; +} + +/* ========================================================================== + Messages Area + ========================================================================== */ + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 260px; + min-height: 60px; +} + +/* Teams-style message rows */ +.chat-row { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.chat-row-bot { + align-self: flex-start; +} + +.chat-row-user { + align-self: flex-end; + justify-content: flex-end; +} + +.chat-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent-green, #27ae60); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + flex-shrink: 0; + margin-top: 2px; +} + +.chat-avatar-user { + background: var(--text-light, #999999); +} + +.chat-msg { + padding: 10px 14px; + border-radius: 16px; + font-size: 0.8rem; + line-height: 1.5; + max-width: 85%; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +.chat-row-bot .chat-msg { + background: var(--paper-secondary-bg, #f5f5f5); + color: var(--text-secondary, #333333); + border-top-left-radius: 4px; +} + +.chat-row-user .chat-msg { + background: var(--accent-green, #27ae60); + color: #fff; + border-top-right-radius: 4px; +} + +/* Message content typography */ +.chat-msg p { + margin: 0 0 4px 0; +} + +.chat-msg p:last-child { + margin-bottom: 0; +} + +.chat-msg ul { + margin: 4px 0; + padding-left: 16px; +} + +.chat-msg li { + margin-bottom: 2px; +} + +/* Error message β€” simple styled div (not a chat-row) */ +.chat-error { + padding: 8px 12px; + border-radius: 12px; + background: #fef2f2; + color: #991b1b; + align-self: center; + font-style: italic; + border: 1px solid #fecaca; + font-size: 0.75rem; +} + +[data-color-theme="dark"] .chat-error { + background: #3a1a1a; + color: #fca5a5; + border-color: #5a2a2a; +} + +@media (prefers-color-scheme: dark) { + [data-color-theme="auto"] .chat-error { + background: #3a1a1a; + color: #fca5a5; + border-color: #5a2a2a; + } +} + +/* ========================================================================== + Navigation Links in Chat Messages + ========================================================================== */ + +/* Inline sprite icons in chat messages */ +.chat-msg .icon-sprite { + vertical-align: middle; + margin-right: 2px; + border-radius: 3px; +} + +.chat-nav-link { + color: var(--accent-green, #27ae60); + text-decoration: none; + font-weight: 600; + cursor: pointer; + border-bottom: 1px dotted var(--accent-green, #27ae60); +} + +.chat-nav-link:hover { + color: #1e8c4c; + border-bottom-style: solid; +} + +/* Highlight animation when scrolled to from chat */ +.chat-highlight { + animation: chatHighlight 2s ease; +} + +@keyframes chatHighlight { + 0%, 100% { box-shadow: none; } + 20%, 80% { box-shadow: 0 0 0 3px var(--accent-green, #27ae60); border-radius: 4px; } +} + +/* ========================================================================== + Typing Indicator + ========================================================================== */ + +.chat-typing { + display: none; + align-items: center; + gap: 4px; + padding: 6px 14px; +} + +.chat-typing.htmx-request { + display: flex; +} + +.chat-typing-dots { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.chat-status-text { + font-size: 0.72rem; + color: var(--accent-green, #27ae60); + font-style: italic; + animation: statusPulse 2s ease-in-out infinite; +} + +@keyframes statusPulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +.chat-typing-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--text-light, #999999); + animation: typingBounce 1.4s infinite ease-in-out both; +} + +.chat-typing-dot:nth-child(1) { animation-delay: 0s; } +.chat-typing-dot:nth-child(2) { animation-delay: 0.16s; } +.chat-typing-dot:nth-child(3) { animation-delay: 0.32s; } + +@keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +/* ========================================================================== + Suggested Question Chips + ========================================================================== */ + +.chat-suggestions { + display: flex; + gap: 5px; + padding: 6px 10px; + overflow-x: auto; + flex-wrap: wrap; + border-top: 1px solid var(--border-light, #e0e0e0); +} + +.chat-chip { + flex-shrink: 0; + background: transparent; + color: var(--text-muted, #666666); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 14px; + padding: 3px 10px; + font-size: 0.68rem; + font-family: 'Source Sans Pro', sans-serif; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + line-height: 1.4; +} + +.chat-chip:hover { + background: var(--accent-green, #27ae60); + color: #fff; + border-color: var(--accent-green, #27ae60); +} + +/* ========================================================================== + Input Area + ========================================================================== */ + +.chat-input-area { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border-light, #e0e0e0); + background: var(--paper-bg, #ffffff); +} + +.chat-input { + flex: 1; + padding: 7px 12px; + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 16px; + font-size: 0.8rem; + font-family: 'Source Sans Pro', sans-serif; + outline: none; + background: var(--paper-bg, #ffffff); + color: var(--text-primary, #1a1a1a); + transition: border-color 0.2s; +} + +.chat-input:focus { + border-color: var(--accent-green, #27ae60); +} + +.chat-send-btn { + background: var(--accent-green, #27ae60); + color: #fff; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + transition: background 0.2s; + flex-shrink: 0; +} + +.chat-send-btn:hover { + background: #1e8c4c; +} + +/* ========================================================================== + Help Modal β€” Fullscreen Accordion + ========================================================================== */ + +.chat-help-fullscreen .info-modal-content { + max-width: 560px; + max-height: 90vh; + overflow-y: auto; +} + +.chat-help-intro { + font-size: 0.82rem; + color: var(--text-muted, #666666); + line-height: 1.5; + margin: 0 0 16px 0; + text-align: center; +} + +.chat-help-accordion { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-help-group { + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 6px; + overflow: hidden; +} + +.chat-help-group summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + font-family: 'Quicksand', sans-serif; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + background: var(--paper-secondary-bg, #f5f5f5); + list-style: none; + transition: background 0.2s; +} + +.chat-help-group summary::-webkit-details-marker { + display: none; +} + +.chat-help-group summary::after { + content: ''; + margin-left: auto; + width: 6px; + height: 6px; + border-right: 2px solid var(--text-muted, #666); + border-bottom: 2px solid var(--text-muted, #666); + transform: rotate(-45deg); + transition: transform 0.2s; +} + +.chat-help-group[open] summary::after { + transform: rotate(45deg); +} + +.chat-help-group summary:hover { + background: var(--paper-bg, #ffffff); +} + +.chat-help-group summary iconify-icon { + font-size: 1.1rem; + color: var(--accent-green, #27ae60); +} + +.chat-help-questions { + display: flex; + flex-direction: column; + padding: 4px 8px 8px; + gap: 2px; +} + +.chat-help-q { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 7px 12px; + font-size: 0.78rem; + font-family: 'Source Sans Pro', sans-serif; + color: var(--text-secondary, #333333); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; + line-height: 1.4; +} + +.chat-help-q:hover { + background: var(--accent-green, #27ae60); + color: #fff; +} + +.chat-help-footer { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + padding: 10px 12px; + background: var(--paper-secondary-bg, #f5f5f5); + border-radius: 6px; + font-size: 0.7rem; + color: var(--text-muted, #666666); + line-height: 1.4; +} + +.chat-help-footer iconify-icon { + font-size: 1rem; + flex-shrink: 0; + color: var(--text-light, #999999); +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +@media (max-width: 480px) { + .chat-panel { + bottom: 0; + left: 0; + right: 0; + width: 100%; + max-height: 70vh; + border-radius: 8px 8px 0 0; + } + + .chat-toggle-btn { + bottom: 5rem; + right: 1rem; + } + + .chat-messages { + max-height: 200px; + } +} diff --git a/static/css/04-interactive/_scroll-behavior.css b/static/css/04-interactive/_scroll-behavior.css index 095a169..93d7f36 100644 --- a/static/css/04-interactive/_scroll-behavior.css +++ b/static/css/04-interactive/_scroll-behavior.css @@ -45,12 +45,12 @@ opacity: 1; transform: translateY(-3px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); - background: #27ae60; + background: #555555; } .back-to-top.at-bottom { opacity: 1; - background: #27ae60; + background: #555555; } .back-to-top:active { diff --git a/static/css/main.css b/static/css/main.css index 1eb71c8..0265532 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -36,6 +36,7 @@ @import './04-interactive/_zoom-control.css'; @import './04-interactive/_contact-form.css'; @import './04-interactive/_sprites.css'; +/* Chat CSS loaded separately via head-styles.html (only when ChatEnabled) */ /* 05 - Responsive */ @import './05-responsive/_breakpoints.css'; diff --git a/templates/index.html b/templates/index.html index 53d63cb..748463b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -56,10 +56,16 @@ {{template "info-modal" .}} {{template "shortcuts-modal" .}} + {{template "chat-help-modal" .}} {{template "pdf-modal" .}} {{template "contact-modal" .}} {{template "zoom-control" .}} + + + + {{template "chat-widget" .}} + diff --git a/templates/partials/layout/head-styles.html b/templates/partials/layout/head-styles.html index 8e9a612..391b34c 100644 --- a/templates/partials/layout/head-styles.html +++ b/templates/partials/layout/head-styles.html @@ -1,7 +1,14 @@ {{define "head-styles"}} - - + {{if .IsProduction}} + + {{else}} + + + {{end}} + {{if .ChatEnabled}} + + {{end}} {{end}} diff --git a/templates/partials/modals/chat-help-modal.html b/templates/partials/modals/chat-help-modal.html new file mode 100644 index 0000000..25b8805 --- /dev/null +++ b/templates/partials/modals/chat-help-modal.html @@ -0,0 +1,123 @@ +{{define "chat-help-modal"}} + + +
+ + +
+

{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}

+
+ + + + {{if eq .Lang "es"}}Asistente inteligente con IA{{else}}AI-powered intelligent assistant{{end}} +
+
+ +
+

+ {{if eq .Lang "es"}}Pregunta lo que quieras sobre este CV. Haz clic en cualquier pregunta para enviarla directamente al asistente.{{else}}Ask anything about this CV. Click any question to send it directly to the assistant.{{end}} +

+ +
+ + +
+ + + {{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}} + +
+ + + + +
+
+ + +
+ + + {{if eq .Lang "es"}}TecnologΓ­as{{else}}Technologies{{end}} + +
+ + + + + +
+
+ + +
+ + + {{if eq .Lang "es"}}Proyectos{{else}}Projects{{end}} + +
+ + + +
+
+ + +
+ + + {{if eq .Lang "es"}}FormaciΓ³n{{else}}Education{{end}} + +
+ + + +
+
+ + +
+ + + {{if eq .Lang "es"}}Habilidades{{else}}Skills{{end}} + +
+ + + +
+
+ +
+ + + +
+
+
+ + + +{{end}} diff --git a/templates/partials/sections/courses.html b/templates/partials/sections/courses.html index afc2b56..fd7695a 100644 --- a/templates/partials/sections/courses.html +++ b/templates/partials/sections/courses.html @@ -1,6 +1,7 @@ {{define "section-courses"}} {{if .CV.Courses}} +
diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html new file mode 100644 index 0000000..9fcf20c --- /dev/null +++ b/templates/partials/widgets/chat-widget.html @@ -0,0 +1,317 @@ +{{define "chat-widget"}} +{{if .ChatEnabled}} + + + +
+
+ + {{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}} +
+ + + + + + +
+
+ +
+
+
+
{{if eq .Lang "es"}}Β‘Hola! PregΓΊntame lo que quieras sobre este CV.{{else}}Hi! Ask me anything about this CV.{{end}}
+
+
+ + +
+ + + + + + +
+ + +
+ {{if eq .Lang "es"}} + + + + + + {{else}} + + + + + + {{end}} +
+ + + + + + + +
+ + + +{{end}} +{{end}} diff --git a/tests/mjs/83-chat-mascot.test.mjs b/tests/mjs/83-chat-mascot.test.mjs new file mode 100644 index 0000000..b7e5ac9 --- /dev/null +++ b/tests/mjs/83-chat-mascot.test.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env bun +/** + * CV ASSISTANT MASCOT TEST + * ========================= + * Tests the AI chat mascot: UI, chips, navigation links, help modal, + * Gemini responses, cross-section intelligence, and bilingual support. + */ + +import { chromium } from 'playwright'; + +const URL = "http://localhost:1999"; +const CHAT_TIMEOUT = 20000; + +async function testChatMascot() { + console.log('πŸ€– CV ASSISTANT MASCOT TEST\n'); + console.log('='.repeat(70)); + + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + + const errors = []; + let passed = 0; + let failed = 0; + const results = []; + + function record(name, ok, detail = '') { + results.push({ name, ok }); + ok ? passed++ : failed++; + console.log(` ${ok ? 'βœ…' : '❌'} ${name}${detail ? ' β€” ' + detail : ''}`); + } + + page.on('console', msg => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + // ====================================================================== + console.log("\nπŸ“‹ Loading page..."); + await page.goto(`${URL}/?lang=en`); + await page.waitForTimeout(2000); + + // ====================================================================== + // 1. BUTTON + // ====================================================================== + console.log("\n1️⃣ Mascot Button"); + + record('Button visible', await page.locator('#chat-toggle-btn').isVisible()); + record('Robot icon visible', await page.locator('.chat-icon-open').isVisible()); + record('Close icon hidden', await page.locator('.chat-icon-close').isHidden()); + + const btnBox = await page.locator('#chat-toggle-btn').boundingBox(); + record('Button on right side', btnBox && btnBox.x > 1800, `x=${btnBox?.x}`); + + // ====================================================================== + // 2. PANEL TOGGLE + // ====================================================================== + console.log("\n2️⃣ Panel Toggle"); + + record('Panel hidden initially', await page.locator('#chat-panel').isHidden()); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(500); + record('Panel opens on click', await page.locator('#chat-panel.chat-open').isVisible()); + record('Button has mascot-active', (await page.locator('#chat-toggle-btn.mascot-active').count()) > 0); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + record('Panel closes on second click', await page.locator('#chat-panel').isHidden()); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + + // ====================================================================== + // 3. HELP MODAL + // ====================================================================== + console.log("\n3️⃣ Help Modal"); + + record('Help button (?) visible', await page.locator('.chat-help-btn').isVisible()); + + await page.click('.chat-help-btn'); + await page.waitForTimeout(500); + const modalOpen = await page.locator('#chat-help-modal').evaluate(el => el.open); + record('Help modal opens', modalOpen); + + const accordionCount = await page.locator('.chat-help-group').count(); + record('5 accordion sections', accordionCount === 5, `found ${accordionCount}`); + + const firstOpen = await page.locator('.chat-help-group[open]').count(); + record('First section expanded by default', firstOpen >= 1); + + const questionCount = await page.locator('.chat-help-q').count(); + record('18+ clickable questions', questionCount >= 18, `found ${questionCount}`); + + // Close modal + await page.locator('#chat-help-modal .info-modal-close').click(); + await page.waitForTimeout(300); + + // ====================================================================== + // 4. WELCOME MESSAGE + // ====================================================================== + console.log("\n4️⃣ Welcome Message"); + + const welcome = await page.locator('#chat-messages .chat-agent').first().textContent(); + record('Welcome message present', welcome.includes('Ask me anything') || welcome.includes('PregΓΊntame')); + + // ====================================================================== + // 5. CHIPS + // ====================================================================== + console.log("\n5️⃣ Suggested Chips"); + + const chipCount = await page.locator('.chat-chip').count(); + record('5 chips exist', chipCount === 5, `found ${chipCount}`); + + // ====================================================================== + // 6. CHIP CLICK β†’ GEMINI RESPONSE + // ====================================================================== + console.log("\n6️⃣ Chip Click β†’ Response"); + + const msgsBefore = await page.locator('#chat-messages .chat-message').count(); + await page.locator('.chat-chip').first().click(); + + // Wait for response + await page.waitForFunction( + (before) => document.querySelectorAll('#chat-messages .chat-message').length > before + 1, + msgsBefore, + { timeout: CHAT_TIMEOUT } + ); + + const userMsg = await page.locator('#chat-messages .chat-user').last().textContent(); + record('User message appears', userMsg.length > 5, userMsg.substring(0, 40)); + + const agentMsg = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Agent response appears', agentMsg.length > 30, `${agentMsg.substring(0, 50)}...`); + + // ====================================================================== + // 7. NAVIGATION LINKS IN RESPONSE + // ====================================================================== + console.log("\n7️⃣ Navigation Links"); + + const navLinkCount = await page.locator('#chat-messages .chat-nav-link').count(); + record('Response has navigation links', navLinkCount > 0, `found ${navLinkCount}`); + + if (navLinkCount > 0) { + const firstLinkHref = await page.locator('#chat-messages .chat-nav-link').first().getAttribute('href'); + record('Links have anchor hrefs', firstLinkHref && firstLinkHref.startsWith('#'), firstLinkHref); + } + + // ====================================================================== + // 8. TYPED QUESTION + // ====================================================================== + console.log("\n8️⃣ Typed Question"); + + const msgsBeforeType = await page.locator('#chat-messages .chat-user').count(); + await page.fill('#chat-input', 'What certifications does he have?'); + await page.click('.chat-send-btn'); + + await page.waitForFunction( + (count) => document.querySelectorAll('#chat-messages .chat-user').length > count, + msgsBeforeType, + { timeout: CHAT_TIMEOUT } + ); + + const typedMsg = await page.locator('#chat-messages .chat-user').last().textContent(); + record('Typed message appears', typedMsg.includes('certifications')); + + const certResponse = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Certifications response', certResponse.toLowerCase().includes('sap') || certResponse.toLowerCase().includes('certif')); + + // ====================================================================== + // 9. INPUT CLEARS + // ====================================================================== + console.log("\n9️⃣ Input Clear"); + + const inputVal = await page.locator('#chat-input').inputValue(); + record('Input cleared after submit', inputVal === ''); + + // ====================================================================== + // 10. SESSION PERSISTENCE + // ====================================================================== + console.log("\nπŸ”Ÿ Session"); + + const sessionId = await page.locator('#chat-session-id').inputValue(); + record('Session ID set', sessionId.length > 10, sessionId.substring(0, 20)); + + // ====================================================================== + // 11. CROSS-SECTION INTELLIGENCE (Go) + // ====================================================================== + console.log("\n1️⃣1️⃣ Intelligence: Go cross-section"); + + await page.fill('#chat-input', 'What is Juan\'s experience with Go?'); + await page.click('.chat-send-btn'); + + const agentsBefore11 = await page.locator('#chat-messages .chat-agent').count(); + await page.waitForFunction( + (c) => document.querySelectorAll('#chat-messages .chat-agent').length > c, + agentsBefore11, + { timeout: CHAT_TIMEOUT } + ); + + const goResp = (await page.locator('#chat-messages .chat-agent').last().textContent()).toLowerCase(); + record('Go: finds projects', goResp.includes('immich') || goResp.includes('cmux')); + record('Go: finds skills', goResp.includes('skill') || goResp.includes('proficiency') || goResp.includes('programming')); + + // ====================================================================== + // 12. CROSS-SECTION INTELLIGENCE (Java) + // ====================================================================== + console.log("\n1️⃣2️⃣ Intelligence: Java cross-section"); + + await page.fill('#chat-input', 'What Java experience does he have?'); + await page.click('.chat-send-btn'); + + const agentsBefore12 = await page.locator('#chat-messages .chat-agent').count(); + await page.waitForFunction( + (c) => document.querySelectorAll('#chat-messages .chat-agent').length > c, + agentsBefore12, + { timeout: CHAT_TIMEOUT } + ); + + const javaResp = (await page.locator('#chat-messages .chat-agent').last().textContent()).toLowerCase(); + record('Java: finds Insa', javaResp.includes('insa')); + record('Java: finds multiple companies', javaResp.includes('homeria') || javaResp.includes('webratio') || javaResp.includes('penta')); + + // ====================================================================== + // 13. COMPANIES LIST + // ====================================================================== + console.log("\n1️⃣3️⃣ Intelligence: Companies"); + + await page.fill('#chat-input', 'List all companies he worked at'); + await page.click('.chat-send-btn'); + + const agentsBefore13 = await page.locator('#chat-messages .chat-agent').count(); + await page.waitForFunction( + (c) => document.querySelectorAll('#chat-messages .chat-agent').length > c, + agentsBefore13, + { timeout: CHAT_TIMEOUT } + ); + + const compResp = (await page.locator('#chat-messages .chat-agent').last().textContent()).toLowerCase(); + record('Lists Olympic', compResp.includes('olympic')); + record('Lists SAP', compResp.includes('sap')); + record('Lists Insa', compResp.includes('insa')); + + // ====================================================================== + // 14. NAVIGATION LINK CLICK (scroll + highlight) + // ====================================================================== + console.log("\n1️⃣4️⃣ Navigation Link Click"); + + const navLinks = page.locator('#chat-messages .chat-nav-link'); + const navCount = await navLinks.count(); + if (navCount > 0) { + await navLinks.first().click(); + await page.waitForTimeout(1000); + // Panel should close after nav click + const panelAfterNav = await page.locator('#chat-panel.chat-open').count(); + record('Panel closes after nav click', panelAfterNav === 0); + + // Check highlight exists somewhere + const highlighted = await page.locator('.chat-highlight').count(); + record('Target element highlighted', highlighted > 0); + + // Reopen chat for remaining tests + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + } else { + record('Navigation link click (no links)', false, 'no nav links found'); + record('Target highlight (skipped)', false); + } + + // ====================================================================== + // 15. SPANISH LANGUAGE + // ====================================================================== + console.log("\n1️⃣5️⃣ Spanish Language"); + + await page.click('#chat-toggle-btn'); // close + await page.waitForTimeout(200); + await page.goto(`${URL}/?lang=es`); + await page.waitForTimeout(2000); + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(500); + + const esHeader = await page.locator('.chat-header span').textContent(); + record('Spanish header', esHeader.includes('Asistente')); + + const esChip = await page.locator('.chat-chip').first().textContent(); + record('Spanish chips', esChip.includes('Go') || esChip.includes('Proyectos')); + + const esWelcome = await page.locator('#chat-messages .chat-agent').first().textContent(); + record('Spanish welcome', esWelcome.includes('PregΓΊntame')); + + // ====================================================================== + // 16. SPANISH RESPONSE + // ====================================================================== + console.log("\n1️⃣6️⃣ Spanish Intelligence"); + + await page.fill('#chat-input', 'ΒΏCuΓ‘ntos aΓ±os de experiencia tiene?'); + await page.click('.chat-send-btn'); + + const agentsBefore16 = await page.locator('#chat-messages .chat-agent').count(); + await page.waitForFunction( + (c) => document.querySelectorAll('#chat-messages .chat-agent').length > c, + agentsBefore16, + { timeout: CHAT_TIMEOUT } + ); + + const esResp = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Responds in Spanish', esResp.includes('aΓ±os') || esResp.includes('experiencia')); + record('Reports 21 years', esResp.includes('21')); + + // ====================================================================== + // 17. RESPONSE TIME + // ====================================================================== + console.log("\n1️⃣7️⃣ Response Time"); + + // Go back to English for timing test + await page.click('#chat-toggle-btn'); + await page.goto(`${URL}/?lang=en`); + await page.waitForTimeout(2000); + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + + const startTime = Date.now(); + await page.fill('#chat-input', 'How many years of experience?'); + await page.click('.chat-send-btn'); + + const agentsBefore17 = await page.locator('#chat-messages .chat-agent').count(); + await page.waitForFunction( + (c) => document.querySelectorAll('#chat-messages .chat-agent').length > c, + agentsBefore17, + { timeout: CHAT_TIMEOUT } + ); + const responseTime = Date.now() - startTime; + record('Response under 10 seconds', responseTime < 10000, `${(responseTime / 1000).toFixed(1)}s`); + + // ====================================================================== + // 18. ERROR-FREE + // ====================================================================== + console.log("\n1️⃣8️⃣ Console Errors"); + + const chatErrors = errors.filter(e => e.includes('chat') || e.includes('htmx')); + record('No chat console errors', chatErrors.length === 0, + chatErrors.length > 0 ? chatErrors.join('; ') : 'clean'); + + // ====================================================================== + // SUMMARY + // ====================================================================== + console.log('\n' + '='.repeat(70)); + console.log(`\nπŸ“Š RESULTS: ${passed} passed, ${failed} failed (${results.length} total)\n`); + + if (failed > 0) { + console.log('❌ FAILED:'); + results.filter(r => !r.ok).forEach(r => console.log(` β€’ ${r.name}`)); + console.log(''); + } + + await browser.close(); + console.log(failed === 0 ? 'βœ… ALL TESTS PASSED!' : '❌ SOME TESTS FAILED'); + process.exit(failed > 0 ? 1 : 0); +} + +testChatMascot().catch(err => { + console.error('πŸ’₯ Crash:', err.message); + process.exit(1); +});