merge: chat agent feature — GLM local model, UX overhaul, icons, layout modes

This commit is contained in:
juanatsap
2026-04-09 10:57:10 +01:00
25 changed files with 3644 additions and 32 deletions
+15 -1
View File
@@ -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
+56 -4
View File
@@ -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
+19
View File
@@ -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
+544
View File
@@ -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 `<strong>`, converts `- ` bullet lines to `<ul><li>`, and wraps text in `<p>` tags.
### `templates/partials/widgets/chat-widget.html`
The HTMX + Hyperscript chat UI. Conditionally rendered with `{{if .ChatEnabled}}`. Contains:
- **Toggle button** — Fixed position, Hyperscript toggles `.chat-open` on the panel and `.mascot-active` on itself.
- **Chat header** — Blue bar with robot icon, title (bilingual), and help button that opens the help modal via `command="show-modal"`.
- **Messages area** — Scrollable container (`#chat-messages`) where HTMX appends response fragments.
- **Typing indicator** — Three animated dots, shown/hidden via HTMX's `hx-indicator`.
- **Suggested question chips** — 5 per language, using Hyperscript (`_="on click set #chat-input.value to '...' then trigger submit on #chat-form"`).
- **Input form** — `hx-post="/api/chat"` with `hx-swap="beforeend scroll:#chat-messages:bottom"`. Hyperscript clears the input after each request.
### `templates/partials/modals/chat-help-modal.html`
A native `<dialog>` element styled as a modal. Organized into 6 sections with example questions:
1. **About Experience** — Years of experience, companies, specific employers (Olympic Broadcasting, SAP)
2. **About Technologies** — Programming languages, React, Go, Node.js
3. **About Projects** — Personal projects, Immich Photo Manager, open-source work
4. **Education & Certifications** — Certifications, education, courses
5. **About Skills** — Technical skills, Docker, CI/CD
6. **How it works** — Brief explanation of ADK Go + Gemini powering the assistant
Each section contains 3-4 example questions in both English and Spanish (toggled by `{{if eq .Lang "es"}}`).
### `static/css/04-interactive/_chat.css`
Complete styling for the chat widget. See sections 10 and 11 for design system and dark theme details.
## 4. The `query_cv` Tool
The `query_cv` tool is the agent's only way to access CV data. It accepts three parameters:
| Parameter | Type | Description |
|-----------|------|-------------|
| `section` | string | Which CV section to query (see values below) |
| `query` | string | Keyword filter. Empty returns all items in the section. |
| `language` | string | `"en"` or `"es"`. Defaults to `"en"`. |
### Section Values
| Section | Returns | Filter Behavior |
|---------|---------|-----------------|
| `search` | Cross-section results (map with keys: experience, projects, skills, courses) | Case-insensitive keyword match across ALL four sections simultaneously |
| `experience` | `[]Experience` | Filters by company, position, location, dates, technologies, responsibilities, short description |
| `projects` | `[]Project` | Filters by title, short description, location, technologies, responsibilities |
| `skills` | `[]SkillCategory` | Filters by category name and individual skill items |
| `education` | `[]Education` | Returns all (no filtering) |
| `languages` | `[]Language` | Returns all (no filtering) |
| `certifications` | `[]Certification` | Returns all (no filtering) |
| `courses` | `[]Course` | Filters by title, institution, description |
| `awards` | `[]Award` | Returns all (no filtering) |
| `summary` | `{summary, years_of_experience}` | Returns the professional summary and calculated years |
| `all` | `{experience_count, project_count, skill_categories, ...}` | Returns high-level counts across all sections |
The tool reads from `cache.DataCache` — the same in-memory cache that powers the website rendering. Zero additional I/O, zero data duplication.
## 5. Cross-Section Search
When `section="search"`, the tool performs a simultaneous keyword search across four sections:
1. **Experience** — Matches in company name, position, location, dates, technologies list, responsibilities list, and short description.
2. **Projects** — Matches in title, short description, location, technologies list, and responsibilities list.
3. **Skills** — Matches in skill category name (e.g., "Languages", "DevOps") and individual skill items.
4. **Courses** — Matches in title, institution, and description.
### Why Cross-Section Search Matters
Technology queries are the most common use case, and technologies can appear in multiple sections. For example, asking about "Java":
- **Experience**: Appears in 5+ job entries where Java was used
- **Projects**: May appear in project tech stacks
- **Skills**: Listed under "Programming Languages" with proficiency level
- **Courses**: May appear in training course titles
Without cross-section search, the agent would need to make 4 separate tool calls. With `section="search"`, a single call returns all matches organized by section, giving the agent complete context to synthesize a comprehensive answer.
### Return Format
```json
{
"section": "search",
"query": "go",
"total_found": 5,
"data": {
"experience": [...],
"projects": [...],
"skills": [...],
"courses": [...]
}
}
```
Only sections with matches are included in the result.
## 6. Agent Intelligence
The agent instruction defines query strategies for 8 question types. This is the core of the agent's intelligence — it tells Gemini exactly which section(s) to query for each type of question.
| # | Question Type | Query Strategy | Example |
|---|---------------|----------------|---------|
| 1 | **Technology** (Java, Go, React, Docker) | `section="search"` with technology name | "Has he worked with React?" -> `search`, query=`"react"` |
| 2 | **Company / Employer** | List all: `section="experience"` no query. Specific: `section="search"` with company name. | "What companies?" -> `experience`. "Tell me about SAP" -> `search`, query=`"sap"` |
| 3 | **Years / Career Overview** | `section="summary"` for years. `section="all"` for overview. | "How many years?" -> `summary` |
| 4 | **Projects** | List all: `section="projects"` no query. By tech: `section="search"`. | "Go projects?" -> `search`, query=`"go"` |
| 5 | **Education & Certifications** | `section="certifications"`, `section="education"`, or `section="courses"`. Topic-specific: `section="search"`. | "What certifications?" -> `certifications` |
| 6 | **Skills** | All skills: `section="skills"` no query. Specific: `section="search"`. | "Main skills?" -> `skills`. "Docker?" -> `search`, query=`"docker"` |
| 7 | **Awards** | `section="awards"` | "Any awards?" -> `awards` |
| 8 | **Language Proficiency** | `section="languages"` | "What languages does he speak?" -> `languages` |
### Bonus Context in the Instruction
The agent instruction also tells Gemini:
- The CV website itself is built with Go, HTMX, Hyperscript, and vanilla CSS — a real-world showcase of Juan's skills.
- The chat assistant is powered by Google ADK Go 1.0 and Gemini AI — another demonstration of Go expertise.
- For general questions ("tell me about Juan"), use `summary` first, then `all`.
### Language Behavior
The agent is instructed to respond in the same language the user writes in. If the user asks in Spanish, the response comes back in Spanish. This is handled entirely by Gemini's multilingual capabilities — no language detection code is needed.
## 7. Suggested Questions
The chat panel displays 5 clickable question chips per language. These serve as onboarding — showing visitors what they can ask.
### English Chips
| Chip Label | Full Question Sent |
|------------|-------------------|
| Go projects? | "What Go projects has he built?" |
| Years of experience? | "How many years of experience?" |
| Companies? | "What companies has he worked at?" |
| Knows React? | "Does he know React?" |
| Certifications? | "What certifications?" |
### Spanish Chips
| Chip Label | Full Question Sent |
|------------|-------------------|
| Proyectos en Go? | "Que proyectos en Go ha hecho?" |
| Anos de experiencia? | "Cuantos anos de experiencia tiene?" |
| Empresas? | "En que empresas ha trabajado?" |
| Conoce React? | "Conoce React?" |
| Certificaciones? | "Que certificaciones tiene?" |
### How Chips Work (Hyperscript)
Each chip uses Hyperscript to set the input value and trigger HTMX form submission:
```html
<button type="button" class="chat-chip"
_="on click set #chat-input.value to 'What Go projects has he built?' then trigger submit on #chat-form">
Go projects?
</button>
```
The flow:
1. Hyperscript `on click` handler fires.
2. `set #chat-input.value to '...'` writes the full question into the text input.
3. `trigger submit on #chat-form` dispatches a native `submit` event on the form element.
4. HTMX intercepts the submit event (because the form has `hx-post`) and sends the POST request.
This approach was chosen over inline `onclick` with `htmx.trigger()` because `htmx.trigger()` expects a DOM element reference, not a CSS selector string. Hyperscript's `trigger <event> on <element>` syntax works natively with HTMX's event listening.
## 8. Help Modal
The help modal (`chat-help-modal.html`) is a native `<dialog>` element opened via the `?` button in the chat header using the Invoker Commands API (`commandfor="chat-help-modal" command="show-modal"`).
### Structure
The modal contains 6 sections with 3-4 example questions each:
1. **About Experience** (briefcase icon) — Career duration, companies, specific employers
2. **About Technologies** (code-tags icon) — Programming languages, specific technologies
3. **About Projects** (rocket icon) — Personal projects, open-source, specific projects
4. **Education & Certifications** (school icon) — Certifications, education, courses
5. **About Skills** (star icon) — Technical skills, specific tools
6. **How it works** (info icon) — Brief explanation of the AI powering it
All text is bilingual (English/Spanish) using Go template conditionals. The modal uses the same `info-modal` CSS classes as other site modals (keyboard shortcuts, etc.) for visual consistency.
### Closing Mechanism
The modal closes via:
- The X button (`commandfor="chat-help-modal" command="close"`)
- Clicking the backdrop (Hyperscript: `_="on click call closeOnBackdrop(me, event)"`)
## 9. Graceful Degradation
The chat feature is entirely optional. When `GOOGLE_API_KEY` is not set:
1. `chat.NewHandler()` detects the missing key and returns `&Handler{enabled: false}`.
2. The CV handler receives `chatEnabled: false` from `handler.Enabled()`.
3. Template data includes `ChatEnabled: false`.
4. The chat widget template renders nothing — `{{if .ChatEnabled}}...{{end}}` produces zero HTML.
5. No JavaScript errors, no broken UI, no hidden network requests, no console warnings.
The same graceful fallback applies if:
- The Gemini model fails to initialize (bad API key, network error).
- The ADK agent creation fails.
- The ADK runner creation fails.
In each case, the handler logs a warning and disables itself. The rest of the site is completely unaffected.
**Zero impact on the site when disabled.**
## 10. Design System
The chat widget integrates with the CV site's existing design system, using the same CSS custom properties (design tokens) defined in `_variables.css`.
### Colors
| Element | Token | Default Value |
|---------|-------|---------------|
| Toggle button background | `--black-bar` | `#2b2b2b` |
| Toggle button hover / active | `--accent-blue` | `#0066cc` |
| Panel background | `--paper-bg` | `#ffffff` |
| Panel border | `--border-light` | `#e0e0e0` |
| Header background | `--accent-blue` | `#0066cc` |
| Agent bubble background | `--paper-secondary-bg` | `#f5f5f5` |
| Agent bubble text | `--text-secondary` | `#333333` |
| User bubble background | `--accent-blue` | `#0066cc` |
| User bubble text | (hardcoded) | `#ffffff` |
| Chip text | `--text-muted` | `#666666` |
| Chip border | `--border-light` | `#e0e0e0` |
| Input border focus | `--accent-blue` | `#0066cc` |
| Typing dots | `--text-light` | `#999999` |
### Typography
| Element | Font Family | Size |
|---------|-------------|------|
| Header | Quicksand (matches site headings) | 0.85rem |
| Messages | Source Sans Pro (matches body text) | 0.8rem |
| Chips | Source Sans Pro | 0.68rem |
| Input | Source Sans Pro | 0.8rem |
### Layout
- **Toggle button**: Fixed, `bottom: 6rem`, `right: 2rem`, 50px circle. Positioned just above the back-to-top button.
- **Chat panel**: Fixed, `bottom: 10.5rem`, `right: 2rem`, 360px wide, max 500px tall. Above the toggle button.
- **Shadow**: `var(--shadow-lg)` for the panel, custom shadow for the button.
- **Border radius**: 8px for the panel, 50% for the button, 8px for message bubbles (with 2px on the pointed corner), 14px for chips, 16px for the input.
### Responsive (Mobile)
At `max-width: 480px`:
- Panel goes full-width, bottom-anchored with top rounded corners.
- Button moves to `bottom: 5rem`, `right: 1rem`.
- Messages area reduces to `max-height: 200px`.
## 11. Dark Theme
The site's dark theme class (`.theme-clean`) triggers a complete color override for the chat widget:
| Element | Light | Dark |
|---------|-------|------|
| Panel background | `#ffffff` | `#1a1a1a` |
| Panel border | `#e0e0e0` | `#333333` |
| Header | `#0066cc` | `#003d7a` |
| Agent bubble | `#f5f5f5` | `#2a2a2a` |
| Agent text | `#333333` | `#d0d0d0` |
| User bubble | `#0066cc` | `#004d99` |
| Error bubble bg | `#fef2f2` | `#3a1010` |
| Error text | `#991b1b` | `#fca5a5` |
| Error border | `#fecaca` | `#5a1a1a` |
| Input area bg | `#ffffff` | `#1a1a1a` |
| Input bg | (same) | `#111111` |
| Input border | `#e0e0e0` | `#333333` |
| Chip text | `#666666` | `#999999` |
| Chip border | `#e0e0e0` | `#333333` |
| Chip hover bg | `#0066cc` | `#004d99` |
| Typing dots | `#999999` | `#555555` |
All dark theme rules are scoped under `.theme-clean` to avoid conflicts with the default light theme.
## 12. Session Management
The chat uses ADK Go's built-in `session.InMemoryService()` to maintain conversation context across multiple messages.
### Session Lifecycle
1. **First message**: No `session_id` in the form. Handler sets `sessionID = "default"` then tries to `Get()` it.
2. **Session not found**: Handler calls `session.Create()` which returns a new session with a UUID.
3. **Response includes session ID**: An OOB-swapped hidden input is appended to the response:
```html
<input type="hidden" id="chat-session-id" name="session_id" value="<uuid>"
form="chat-form" hx-swap-oob="true"/>
```
4. **Subsequent messages**: The form now includes the session ID. The handler calls `Get()` which succeeds, and the conversation continues with context.
### Key Properties
- **In-memory only**: Sessions are not persisted to disk. Server restart clears all sessions.
- **Per-visitor isolation**: Each visitor gets an independent session. No session data is shared.
- **OOB swap**: The session ID is injected using HTMX's out-of-band swap mechanism (`hx-swap-oob="true"`), which replaces the hidden input by ID without affecting the chat messages swap.
- **Conversation context**: ADK's session service stores the full message history, allowing Gemini to handle follow-up questions (e.g., "Tell me more about that company").
## 13. Security
### Input Sanitization
- **User messages**: HTML-escaped via `html.EscapeString()` before rendering in the response fragment. Prevents XSS through user input.
- **Agent responses**: Processed through `formatResponse()` which first escapes all HTML, then applies safe markdown-to-HTML conversion (bold, bullet lists, paragraphs). No raw HTML from the LLM reaches the browser.
### Privacy Protection
The agent instruction explicitly states: *"Never reveal personal contact details (email, phone) — point them to the contact form on the website."* This prevents the agent from disclosing contact information even if it exists in the CV data.
### Infrastructure Security
- The `/api/chat` endpoint inherits the site's full middleware chain: recovery, logging, security headers (CSP, HSTS, X-Frame-Options, etc.).
- The agent context uses a 30-second timeout (`context.WithTimeout`) to prevent runaway requests.
- The agent context is detached from the HTTP request context (`context.Background()`) to avoid cancellation if the client disconnects mid-processing.
- Sessions are ephemeral (in-memory only) and not accessible across visitors.
### Rate Limiting
Gemini 2.5 Flash free tier enforces 15 requests/minute at the API level. For additional protection, the endpoint benefits from the site's existing middleware chain.
## 14. Testing
The chat mascot has a comprehensive Playwright test suite at `tests/mjs/83-chat-mascot.test.mjs` with **46 test assertions** across 25 test groups.
### Test Coverage
| Group | Tests | What's Verified |
|-------|-------|-----------------|
| 1. Mascot Button Presence | 3 | Toggle button visible, robot icon shown, close icon hidden initially |
| 2. Initial State | 1 | Chat panel hidden by default |
| 3. Open Chat Panel | 2 | Panel opens on click, button gets `.mascot-active` class |
| 4. Help Card (Onboarding) | 3 | Help card visible on first open, contains description, dismiss button present |
| 5. Dismiss Help Card | 1 | Help card hides after dismiss click |
| 6. Re-toggle Help Card | 1 | Help card re-opens via `?` button |
| 7. Welcome Message | 1 | English welcome message present |
| 8. Suggested Question Chips | 2 | 5 chips exist, first chip has text content |
| 9. Text Input | 2 | Input visible, has correct placeholder |
| 10. Send Button | 1 | Send button visible |
| 11. Chip Click -> Submit | 3 | User message appears, agent responds, response mentions Go |
| 12. Type Custom Question | 2 | Custom message appears, agent responds |
| 13. Input Clear After Submit | 1 | Input value is empty after submission |
| 14. Session Persistence | 1 | Session ID set after first response |
| 15. Close and Reopen | 3 | Panel closes, reopens, messages preserved |
| 16. Header Content | 2 | Shows "CV Assistant", help button present |
| 17. Spanish Language | 4 | Spanish header, chips, welcome message, placeholder |
| 18. Empty Message Handling | 1 | Graceful handling (no crash) |
| 19. Console Errors | 1 | No chat/htmx/hyperscript console errors |
| 20. CSS Positioning | 2 | Button on right side, panel on right side |
| 21. Intelligence: Go (cross-section) | 2 | Finds projects (Immich/Cmux), mentions skills |
| 22. Intelligence: Companies | 3 | Lists Olympic Broadcasting, Insa, SAP/Gigya |
| 23. Intelligence: Years | 1 | Reports 21 years of experience |
| 24. Intelligence: React (cross-section) | 1 | Finds experience entries with React |
| 25. Intelligence: Spanish response | 1 | Responds in Spanish when asked in Spanish |
### Running Tests
```bash
# Run the chat mascot test (requires running server with GOOGLE_API_KEY)
bun tests/mjs/83-chat-mascot.test.mjs
# Run all frontend tests
bun tests/run-all.mjs
```
Tests 21-25 (intelligence tests) require a valid `GOOGLE_API_KEY` and make real API calls to Gemini. They verify that the agent produces accurate, cross-referenced answers from the CV data.
## 15. Configuration
### Required
```bash
# .env
GOOGLE_API_KEY=your-gemini-api-key # From https://aistudio.google.com/apikey
```
Without this key, the chat feature is silently disabled (see section 9).
### Optional
```bash
MODEL_NAME=gemini-2.5-flash # Default model (free tier compatible)
```
### Cost
Gemini 2.5 Flash free tier provides **15 requests/minute** with no credit card required. Each chat message consumes 1 request. For a personal CV site, this is more than sufficient.
If the free tier is exceeded, Gemini returns a rate limit error, which the handler catches and displays as a generic error message to the user.
## 16. Dependencies
| Package | Purpose | Size Impact |
|---------|---------|-------------|
| `google.golang.org/adk` | Agent framework: runner, session, tools, agents | ~2 MB binary increase |
| `google.golang.org/genai` | Gemini API client (included with ADK) | Bundled |
No frontend dependencies are added. The chat widget uses HTMX and Hyperscript which are already loaded by the site.
## 17. ADK Go Concepts Used
| ADK Concept | Go Type / Function | Usage in This Project |
|-------------|-------------------|----------------------|
| LLM Agent | `llmagent.New(llmagent.Config{})` | Creates the `cv_assistant` agent with instruction, model, and tools |
| Function Tool | `functiontool.New(functiontool.Config{}, func)` | Wraps the `query_cv` Go function as an agent-callable tool with JSON schema |
| Runner | `runner.New(runner.Config{})` | Executes the agent within the HTTP handler with app name and session service |
| Session Service | `session.InMemoryService()` | Maintains per-visitor conversation context in memory |
| Content | `genai.NewContentFromText(msg, genai.RoleUser)` | Converts the user's text message to ADK content format for the runner |
| Event Stream | `runner.Run()` range iteration | Iterates over agent events; `event.IsFinalResponse()` extracts the final answer |
| Run Config | `agent.RunConfig{}` | Default (non-streaming) run configuration passed to the runner |
| Auto Session | `runner.Config{AutoCreateSession: true}` | Runner automatically creates sessions when they don't exist |
| Tool Context | `tool.Context` | Passed to the tool function by ADK; provides access to session and agent state |
| JSON Schema | `jsonschema:"..."` struct tags | Describes tool parameters to the LLM for function calling |
## 18. Relation to Other Documentation
- **[01-ARCHITECTURE.md](01-ARCHITECTURE.md)** — Overall system design
- **[03-API.md](03-API.md)** — HTTP API reference (includes `POST /api/chat`)
- **[14-BACKEND-HANDLERS.md](14-BACKEND-HANDLERS.md)** — Handler patterns
- **[23-DATA-CACHE.md](23-DATA-CACHE.md)** — How CV data is cached and accessed
- **[25-GO-TEMPLATE-SYSTEM.md](25-GO-TEMPLATE-SYSTEM.md)** — Template rendering and conditionals
- **[26-GO-ROUTES-API.md](26-GO-ROUTES-API.md)** — Route registration and middleware chain
+243
View File
@@ -0,0 +1,243 @@
# 29. AI-Powered CV Navigation — Technical Showcase
## What This Is
This CV site includes an AI assistant that lets visitors navigate and query the CV through natural language conversation. Instead of scanning a dense document, visitors ask questions like *"What Go projects has he built?"* or *"Has he worked with React?"* and get instant, cross-referenced answers with **clickable links that scroll directly to the relevant section**.
**Live at:** [juan.andres.morenorub.io](https://juan.andres.morenorub.io)
---
## The Problem
A CV is information-dense. Recruiters and hiring managers have specific questions but must scan every section to find answers. Technologies span multiple sections (a language appears in experience, projects, AND skills). Cross-referencing is manual and slow.
## The Solution
An AI agent embedded in the CV page that:
1. **Understands the entire CV** — searches across all sections simultaneously
2. **Answers in natural language** — bilingual (English/Spanish), concise, with bullet points
3. **Navigates the document** — every company, project, and section name in the response is a **clickable link** that closes the chat, scrolls to the target, and highlights it with a green pulse
4. **Degrades gracefully** — no API key? No chat icon. API down? Automatic fallback to local AI.
---
## Technical Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ CV Site (Go) │
│ │
│ Visitor clicks mascot → chat opens → types question │
│ │ │
│ ▼ │
│ HTMX POST /api/chat ──→ Go Handler │
│ │ │
│ ┌─────────┴──────────┐ │
│ ▼ ▼ │
│ Try Gemini Try Ollama │
│ (primary) (auto-fallback) │
│ │ │ │
│ └────────┬───────────┘ │
│ ▼ │
│ ADK Go Agent │
│ "cv_assistant" │
│ │ │
│ ▼ │
│ query_cv tool │
│ (cross-section search) │
│ │ │
│ ▼ │
│ Cached CV JSON │
│ (same data that renders the page) │
│ │ │
│ ▼ │
│ Response with navigation links │
│ [Olympic Broadcasting](#exp-olympic) │
│ │ │
│ ▼ │
│ HTMX swaps into chat panel │
│ Links scroll + highlight on click │
└─────────────────────────────────────────────────────────────┘
```
## Key Technical Decisions
### 1. Google ADK Go 1.0 as the Agent Framework
We chose [ADK Go](https://github.com/google/adk-go) (v1.0, released March 2026) for the agent layer. ADK Go provides:
- **`llmagent.New`** — declarative agent definition with instruction and tools
- **`functiontool.New`** — type-safe Go function → agent tool bridge with auto-generated JSON schema
- **`runner.Runner`** — manages agent execution, sessions, and tool calling loops
- **`session.InMemoryService`** — lightweight session management for conversation context
Why not a simpler approach (raw API calls)? ADK Go handles the tool-calling protocol automatically — the agent decides which tool to call, the framework executes it, feeds results back, and the agent synthesizes. With raw API calls, we'd need to implement this loop ourselves.
### 2. Single Agent, Single Tool
The CV data is bounded and structured. We use one agent (`cv_assistant`) with one tool (`query_cv`). Multi-agent orchestration would be over-engineering here. The intelligence comes from:
- A comprehensive instruction prompt covering 8 question types
- A `search` mode that queries across experience, projects, skills, and courses simultaneously
- Instruction to always include navigation links using CV anchor IDs
### 3. Cross-Section Search
When a visitor asks about a technology (e.g., "Go"), the tool searches **all sections at once**:
```go
case "search":
crossResult := make(map[string]any)
if exp := filterExperience(cv.Experience, q); len(exp) > 0 {
crossResult["experience"] = exp
}
if proj := filterProjects(cv.Projects, q); len(proj) > 0 {
crossResult["projects"] = proj
}
if skills := filterSkills(cv.Skills, q); len(skills) > 0 {
crossResult["skills"] = skills
}
if courses := filterCourses(cv.Courses, q); len(courses) > 0 {
crossResult["courses"] = courses
}
```
This prevents the classic problem of "I searched projects but the answer was in experience."
### 4. CV Navigation Links (GPS for the CV)
The agent includes markdown links in its responses:
```markdown
[Olympic Broadcasting](#exp-olympic-broadcasting) — SAP CDC solutions...
[Immich Photo Manager](#proj-immich-photo-manager) — MCP server for...
See the [Skills section](#skills) for full proficiency details.
```
The `formatResponse` function converts these to clickable HTML links. When clicked, JavaScript:
1. Closes the chat panel
2. Smooth-scrolls to the target element
3. Pulses a green highlight for 2 seconds
This turns the chat into a **navigation tool** — like Google Maps for a document.
### 5. Dual-Provider with Automatic Fallback
```go
// Handler has primary + fallback runners
type Handler struct {
primary *chatRunner // Gemini (fast, cloud)
fallback *chatRunner // Ollama (local, unlimited)
}
// Try primary, fall back on any error
response, sessionID, err := h.runAgent(h.primary, message)
if err != nil && h.fallback != nil {
log.Printf("Primary failed, falling back to %s", h.fallback.label)
response, sessionID, err = h.runAgent(h.fallback, message)
}
```
- **Primary:** Gemini 2.5 Flash — fast (~2s), pay-as-you-go (~$0.0003/question)
- **Fallback:** Ollama with Mistral Small 3.2 on local Mac Mini via Tailscale — free, unlimited
- **Switching:** Automatic and transparent. If Gemini returns 429/503, Ollama handles the request.
- **No manual intervention** — visitors never see the provider switch.
### 6. Model Warmup on Chat Open
Ollama loads models on demand (~10-15s cold start). To hide this latency:
```javascript
function toggleChatPanel() {
// ... open panel ...
if (!chatWarmedUp) {
chatWarmedUp = true;
fetch('/api/chat/warmup', { method: 'POST' }); // background
}
}
```
When the visitor opens the chat, a silent warmup request fires. By the time they type a question, the model is loaded and ready.
### 7. HTMX + Plain JavaScript
The chat widget uses HTMX for server communication and plain JavaScript for interactions:
```html
<!-- Form submits via HTMX -->
<form id="chat-form" hx-post="/api/chat"
hx-target="#chat-messages"
hx-swap="beforeend scroll:#chat-messages:bottom"
hx-indicator="#chat-typing">
<!-- Chips trigger via JavaScript -->
<button onclick="sendChatQuestion('What Go projects has he built?')">
Go projects?
</button>
```
Responses are HTML fragments — the server renders the chat bubbles, HTMX swaps them in. No client-side state management, no JSON parsing, no virtual DOM.
### 8. Rate Limiting
```go
chatRateLimiter := middleware.NewRateLimiter(30, 1*time.Hour)
mux.Handle("/api/chat", chatRateLimiter.Middleware(...))
```
30 requests per hour per IP — generous for genuine visitors, prevents abuse.
### 9. Graceful Degradation
```go
func NewHandler(dataCache *cache.DataCache) *Handler {
// Try Gemini → Try Ollama → Disable chat
// If neither provider works, chat icon doesn't appear at all
}
```
```html
{{if .ChatEnabled}}
<!-- entire chat widget -->
{{end}}
```
No API key? No Ollama? The chat icon simply doesn't render. Zero JavaScript errors, zero broken UI, zero console noise.
---
## Technology Stack
| Component | Technology | Purpose |
|-----------|-----------|---------|
| Agent Framework | [Google ADK Go 1.0](https://github.com/google/adk-go) | Agent definition, tool calling, session management |
| Primary LLM | Gemini 2.5 Flash | Cloud inference, fast responses |
| Fallback LLM | Mistral Small 3.2 via [Ollama](https://ollama.com) | Local inference on Apple Silicon |
| Server Communication | HTMX 2.0 | Form submission, response swapping, indicators |
| Interactions | Plain JavaScript | Panel toggle, chip clicks, navigation scroll |
| Backend | Go 1.25+ stdlib `net/http` | HTTP handler, markdown→HTML, rate limiting |
| Styling | CSS with CV design tokens | Green theme, dark mode, responsive |
## Files
```
internal/chat/
├── agent.go # LLM agent + query_cv tool + cross-section search
├── handler.go # Dual-provider handler, warmup, fallback, response rendering
└── ollama.go # Ollama model.LLM adapter (OpenAI-compatible API)
templates/partials/
├── widgets/chat-widget.html # HTMX chat panel + JS functions
└── modals/chat-help-modal.html # Accordion help modal with clickable questions
static/css/04-interactive/_chat.css # Full styling (tokens, dark theme, responsive, nav links)
tests/mjs/83-chat-mascot.test.mjs # 46 Playwright test assertions
```
## What This Demonstrates
- **AI agent integration in production Go applications** — not a prototype, a deployed feature
- **ADK Go 1.0 in a real-world use case** — function calling, session management, multi-provider
- **Multi-provider LLM architecture** — cloud primary with local fallback, transparent switching
- **Hypermedia-driven AI UI** — HTMX server-rendered responses, no SPA framework needed
- **Document navigation via AI** — chat responses that link to and highlight document sections
- **Graceful engineering** — degrades cleanly, rate-limited, bilingual, theme-aware
+32 -4
View File
@@ -8,32 +8,60 @@ require (
github.com/go-git/go-git/v5 v5.16.4
github.com/joho/godotenv v1.5.1
golang.org/x/image v0.33.0
golang.org/x/text v0.31.0
golang.org/x/text v0.33.0
google.golang.org/adk v1.0.0
google.golang.org/genai v1.52.1
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/safehtml v0.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.34.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/log v0.16.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
rsc.io/omap v1.2.0 // indirect
rsc.io/ordered v1.1.1 // indirect
)
+80 -12
View File
@@ -1,3 +1,9 @@
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -9,6 +15,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
@@ -26,6 +34,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -38,6 +48,11 @@ github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@@ -46,8 +61,24 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8=
github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -83,20 +114,40 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -104,15 +155,28 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/adk v1.0.0 h1:DcJGKH9YweOdsAvE5Hu9UhhLoVYcNEVKzvOPS+B49lQ=
google.golang.org/adk v1.0.0/go.mod h1:wLmpRAp0zXcrdUN2V6mNoh+mj/4O16k0YzGJMNF7Mjk=
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -123,3 +187,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/omap v1.2.0 h1:c1M8jchnHbzmJALzGLclfH3xDWXrPxSUHXzH5C+8Kdw=
rsc.io/omap v1.2.0/go.mod h1:C8pkI0AWexHopQtZX+qiUeJGzvc8HkdgnsWK4/mAa00=
rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
+333
View File
@@ -0,0 +1,333 @@
// Package chat provides an ADK Go agent that answers questions about CV data.
package chat
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/juanatsap/cv-site/internal/cache"
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
"google.golang.org/adk/agent"
"google.golang.org/adk/agent/llmagent"
"google.golang.org/adk/model"
"google.golang.org/adk/tool"
"google.golang.org/adk/tool/functiontool"
)
// NewAgent creates the CV chat agent with a query tool that reads from the data cache.
func NewAgent(llm model.LLM, dataCache *cache.DataCache) (agent.Agent, error) {
queryTool, err := newQueryCVTool(dataCache)
if err != nil {
return nil, fmt.Errorf("query_cv tool: %w", err)
}
return llmagent.New(llmagent.Config{
Name: "cv_assistant",
Model: llm,
Description: "Answers questions about Juan Andrés Moreno Rubio's CV and professional experience.",
Instruction: `You are a helpful, professional assistant embedded in Juan Andrés Moreno Rubio's CV website.
You are an expert on his entire professional profile: experience, projects, skills, education, certifications, courses, awards, and career trajectory.
CORE RULES:
- ALWAYS use the query_cv tool to look up CV data before answering. NEVER make up or assume information.
- Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish.
- Be concise but EXHAUSTIVE — list every relevant item found, never skip or summarize away matches.
- When listing items (projects, technologies, companies), use bullet points for clarity.
- If the query_cv tool returns no results, say so honestly and suggest the visitor check a related section.
- Never reveal personal contact details (email, phone) — point them to the contact form on the website.
- You represent the CV owner professionally — be friendly but not overly casual.
- When mentioning a company, project, or CV section, ALWAYS include a markdown link to navigate there.
Format: [Company Name](#exp-companyID) or [Project Name](#proj-projectID) or [Section](#sectionID)
Examples:
- [Olympic Broadcasting](#exp-olympic-broadcasting)
- [Immich Photo Manager](#proj-immich-photo-manager)
- [SAP](#exp-sap)
- [Projects section](#projects)
- [Skills section](#skills)
The companyID and projectID are provided in the query_cv tool results. Always use them.
QUERY STRATEGY BY QUESTION TYPE:
1. TECHNOLOGY QUESTIONS (e.g. "Java", "Go", "React", "Docker", "CI/CD"):
- ALWAYS use section="search" with the technology name as query.
- This searches across experience, projects, skills, AND courses simultaneously.
- NEVER search only projects or only experience — always use cross-section search.
- Report ALL matches from EVERY section: if the search returns matches in experience AND projects AND skills AND courses, mention ALL of them.
- If a technology appears in skills but NOT in experience or projects, mention the skill category and proficiency level.
- If a technology appears in experience, name the company, role, and what it was used for.
2. COMPANY / EMPLOYER QUESTIONS (e.g. "What companies?", "Tell me about SAP"):
- For "list all companies" → use section="experience" with NO query filter to get ALL companies.
- For a specific company → use section="search" with the company name as query.
- Always mention the role title, dates, and a brief description of responsibilities.
3. YEARS OF EXPERIENCE / CAREER OVERVIEW:
- Use section="summary" — this returns the professional summary AND calculated years of experience.
- You can also use section="all" for a high-level overview of the entire CV.
4. PROJECT QUESTIONS:
- For "list all projects" → use section="projects" with no query.
- For a specific project → use section="search" with the project name.
- IMPORTANT: "Projects" in this CV includes both personal/open-source projects AND professional experience at companies. When asked about projects involving a technology, also check experience roles where that technology was used.
- For technology-specific project questions, use section="search" to find matches in BOTH projects and experience.
5. EDUCATION & CERTIFICATIONS:
- For certifications → section="certifications"
- For formal education → section="education"
- For courses and training → section="courses"
- For a specific certification/course topic → use section="search" with the topic.
- IMPORTANT: When linking to certifications or courses, use [Courses section](#courses) — there is NO #certifications anchor in the CV page. Certifications and courses are both under the #courses section.
6. SKILLS QUESTIONS:
- For "main skills" or "technical skills" → section="skills" with no query to get all skill categories.
- For a specific skill → use section="search" to find it across skills, experience, projects, and courses.
- Always report the skill category (e.g. "Languages", "Frameworks", "DevOps") when available.
7. AWARDS & RECOGNITION:
- Use section="awards" to list all awards.
8. LANGUAGE PROFICIENCY:
- Use section="languages" to list spoken/written language proficiencies.
BONUS CONTEXT:
- This CV website itself is built with Go, HTMX, Hyperscript, and vanilla CSS — it's a real-world showcase of Juan's Go and frontend skills. Mention this when discussing Go or HTMX expertise.
- The chat assistant you ARE is powered by Google ADK Go 1.0 and Gemini AI — another demonstration of Go expertise.
- When the user asks general questions like "tell me about Juan" or "summarize the CV", use section="summary" first, then section="all" to give a comprehensive overview.
EXAMPLES:
- "How many years of experience does Juan have?" → section="summary"
- "What Java experience does he have?" → section="search", query="java"
- "Has he worked with React?" → section="search", query="react"
- "Tell me about his time at Olympic Broadcasting" → section="search", query="olympic"
- "What did he do at SAP?" → section="search", query="sap"
- "What certifications does he have?" → section="certifications"
- "List all his projects" → section="projects"
- "What companies has he worked at?" → section="experience" (no query)
- "Does he know Docker?" → section="search", query="docker"
- "What programming languages does he know?" → section="search", query="language" AND section="skills"
- "Where did he study?" → section="education"
- "What courses has he completed?" → section="courses"`,
Tools: []tool.Tool{queryTool},
})
}
// QueryCVArgs is the input for the CV query tool.
type QueryCVArgs struct {
Section string `json:"section" jsonschema:"CV section to query: 'search' (cross-section keyword search — recommended for technology queries), 'experience', 'projects', 'skills', 'education', 'languages', 'certifications', 'courses', 'awards', 'summary', 'all'"`
Query string `json:"query" jsonschema:"Search term to filter results (e.g. 'Go', 'React', '2019', 'Olympic'). Empty returns all items in the section."`
Language string `json:"language" jsonschema:"Language for CV data: 'en' or 'es'. Default: 'en'."`
}
// QueryCVResult contains the query results.
type QueryCVResult struct {
Section string `json:"section"`
Query string `json:"query,omitempty"`
TotalFound int `json:"total_found"`
Data string `json:"data"` // JSON-encoded results
}
func newQueryCVTool(dataCache *cache.DataCache) (tool.Tool, error) {
return functiontool.New(functiontool.Config{
Name: "query_cv",
Description: `Query the CV data to answer questions about experience, projects, skills, education, certifications, and more.
Use the 'section' parameter to target a specific area, and 'query' to filter by keyword.
For technology or keyword queries (e.g. "Java", "Go", "React", "Olympic"), use section="search" to search across experience, projects, skills, and courses simultaneously. This avoids missing results that appear in multiple sections.
Always call this tool before answering CV-related questions.`,
}, func(ctx tool.Context, args QueryCVArgs) (QueryCVResult, error) {
lang := args.Language
if lang == "" {
lang = "en"
}
cv := dataCache.GetCV(lang)
if cv == nil {
return QueryCVResult{Section: args.Section, TotalFound: 0, Data: "[]"}, nil
}
q := strings.ToLower(args.Query)
result := QueryCVResult{Section: args.Section, Query: args.Query}
switch args.Section {
case "summary":
result.Data = fmt.Sprintf(`{"summary": %q, "years_of_experience": %d}`,
cv.Summary, calculateYears())
result.TotalFound = 1
case "experience":
matches := filterExperience(cv.Experience, q)
result.TotalFound = len(matches)
result.Data = mustJSON(matches)
case "projects":
matches := filterProjects(cv.Projects, q)
result.TotalFound = len(matches)
result.Data = mustJSON(matches)
case "skills":
matches := filterSkills(cv.Skills, q)
result.TotalFound = len(matches)
result.Data = mustJSON(matches)
case "education":
result.TotalFound = len(cv.Education)
result.Data = mustJSON(cv.Education)
case "languages":
result.TotalFound = len(cv.Languages)
result.Data = mustJSON(cv.Languages)
case "certifications":
result.TotalFound = len(cv.Certifications)
result.Data = mustJSON(cv.Certifications)
case "courses":
matches := filterCourses(cv.Courses, q)
result.TotalFound = len(matches)
result.Data = mustJSON(matches)
case "awards":
result.TotalFound = len(cv.Awards)
result.Data = mustJSON(cv.Awards)
case "search":
// Cross-section search: search across experience, projects, skills, and courses simultaneously.
crossResult := make(map[string]any)
total := 0
if expMatches := filterExperience(cv.Experience, q); len(expMatches) > 0 {
crossResult["experience"] = expMatches
total += len(expMatches)
}
if projMatches := filterProjects(cv.Projects, q); len(projMatches) > 0 {
crossResult["projects"] = projMatches
total += len(projMatches)
}
if skillMatches := filterSkills(cv.Skills, q); len(skillMatches) > 0 {
crossResult["skills"] = skillMatches
total += len(skillMatches)
}
if courseMatches := filterCourses(cv.Courses, q); len(courseMatches) > 0 {
crossResult["courses"] = courseMatches
total += len(courseMatches)
}
result.TotalFound = total
result.Data = mustJSON(crossResult)
case "all":
// Return a high-level overview
overview := map[string]int{
"experience_count": len(cv.Experience),
"project_count": len(cv.Projects),
"skill_categories": len(cv.Skills.Technical),
"language_count": len(cv.Languages),
"certification_count": len(cv.Certifications),
"course_count": len(cv.Courses),
"award_count": len(cv.Awards),
}
result.TotalFound = 1
result.Data = mustJSON(overview)
default:
result.Data = `{"error": "unknown section"}`
}
return result, nil
})
}
// Filter helpers — match by keyword across relevant fields
func filterExperience(items []cvmodel.Experience, q string) []cvmodel.Experience {
if q == "" {
return items
}
var out []cvmodel.Experience
for _, e := range items {
if matchesAny(q, e.Company, e.Position, e.Location, e.StartDate, e.EndDate, e.ShortDescription) ||
matchesSlice(q, e.Technologies) || matchesSlice(q, e.Responsibilities) {
out = append(out, e)
}
}
return out
}
func filterProjects(items []cvmodel.Project, q string) []cvmodel.Project {
if q == "" {
return items
}
var out []cvmodel.Project
for _, p := range items {
if matchesAny(q, p.Title, p.ShortDescription, p.Location) ||
matchesSlice(q, p.Technologies) || matchesSlice(q, p.Responsibilities) {
out = append(out, p)
}
}
return out
}
func filterSkills(skills cvmodel.Skills, q string) []cvmodel.SkillCategory {
if q == "" {
return skills.Technical
}
var out []cvmodel.SkillCategory
for _, cat := range skills.Technical {
if matchesAny(q, cat.Category) || matchesSlice(q, cat.Items) {
out = append(out, cat)
}
}
return out
}
func filterCourses(items []cvmodel.Course, q string) []cvmodel.Course {
if q == "" {
return items
}
var out []cvmodel.Course
for _, c := range items {
if matchesAny(q, c.Title, c.Institution, c.Description) {
out = append(out, c)
}
}
return out
}
func matchesAny(q string, fields ...string) bool {
for _, f := range fields {
if strings.Contains(strings.ToLower(f), q) {
return true
}
}
return false
}
func matchesSlice(q string, items []string) bool {
for _, item := range items {
if strings.Contains(strings.ToLower(item), q) {
return true
}
}
return false
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "[]"
}
return string(b)
}
func calculateYears() int {
firstDay := time.Date(2005, time.April, 1, 0, 0, 0, 0, time.UTC)
now := time.Now()
years := now.Year() - firstDay.Year()
if now.Month() < firstDay.Month() ||
(now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) {
years--
}
return years
}
+393
View File
@@ -0,0 +1,393 @@
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
}
// iconMap maps anchor IDs (e.g. "exp-sap", "proj-la-porraclub") to sprite info.
type spriteInfo struct {
index int
category string // "company", "project", "course"
}
// 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]spriteInfo // anchor ID → sprite 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
}
// 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"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></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 like [text](#anchor)
var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((#[a-zA-Z0-9_-]+)\)`)
// formatResponse converts basic markdown to HTML for the chat bubble,
// injecting sprite 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)
}
// Links: [text](#anchor) → sprite icon + clickable navigation link
text = mdLinkRe.ReplaceAllStringFunc(text, func(match string) string {
parts := mdLinkRe.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
linkText, anchor := parts[1], parts[2]
// anchor is like "#exp-sap" or "#proj-la-porraclub"
anchorID := strings.TrimPrefix(anchor, "#")
link := fmt.Sprintf(`<a href="%s" class="chat-nav-link" onclick="return scrollToCV(this)">%s</a>`, anchor, linkText)
if info, ok := h.icons[anchorID]; ok {
sprite := fmt.Sprintf(`<span class="icon-sprite icon-small icon-%s" style="--icon-index:%d" role="img"></span>`, info.category, info.index)
return sprite + " " + 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 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
}
+430
View File
@@ -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
}
+2
View File
@@ -135,6 +135,8 @@ const (
RateLimitGeneralWindow = 1 * time.Minute
RateLimitContactRequests = 5
RateLimitContactWindow = 1 * time.Hour
RateLimitChatRequests = 30
RateLimitChatWindow = 1 * time.Hour
)
// ==============================================================================
+3 -1
View File
@@ -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,
}
}
+1
View File
@@ -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
+1 -1
View File
@@ -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)
}
+9 -2
View File
@@ -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)
+12 -3
View File
@@ -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{
+650
View File
@@ -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;
}
}
@@ -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 {
+1
View File
@@ -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';
+6
View File
@@ -56,10 +56,16 @@
<!-- ============================================ -->
{{template "info-modal" .}}
{{template "shortcuts-modal" .}}
{{template "chat-help-modal" .}}
{{template "pdf-modal" .}}
{{template "contact-modal" .}}
{{template "zoom-control" .}}
<!-- ============================================ -->
<!-- CV ASSISTANT (independent of button stack) -->
<!-- ============================================ -->
{{template "chat-widget" .}}
<!-- ============================================ -->
<!-- SCRIPTS & ANALYTICS -->
<!-- ============================================ -->
+9 -2
View File
@@ -1,7 +1,14 @@
{{define "head-styles"}}
<!-- CSS - Always use bundled CSS (built via 'make css-prod') -->
<!-- Individual CSS files are merged into one bundle to reduce HTTP requests -->
{{if .IsProduction}}
<!-- CSS - Bundled CSS (built via 'make css-prod') -->
<link rel="stylesheet" href="/static/dist/bundle.min.css?v=20251206b">
{{else}}
<!-- CSS - Modular (development) -->
<link rel="stylesheet" href="/static/css/main.css">
{{end}}
{{if .ChatEnabled}}
<link rel="stylesheet" href="/static/css/04-interactive/_chat.css?v=20260408">
{{end}}
<!-- Print styles - loaded separately, only applied when printing -->
<link rel="stylesheet" href="/static/css/print.css" media="print">
{{end}}
@@ -0,0 +1,123 @@
{{define "chat-help-modal"}}
<!-- Chat Help Modal - Native Dialog with Accordion -->
<dialog id="chat-help-modal" class="info-modal chat-help-fullscreen no-print"
_="on click call closeOnBackdrop(me, event)">
<div class="info-modal-content">
<button class="info-modal-close" commandfor="chat-help-modal" command="close" aria-label="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
<div class="info-modal-header">
<h2>{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}</h2>
<div class="info-modal-cv-title">
<span class="keyboard-icon-wrapper">
<iconify-icon icon="mdi:robot-happy-outline" width="32" height="32"></iconify-icon>
</span>
{{if eq .Lang "es"}}Asistente inteligente con IA{{else}}AI-powered intelligent assistant{{end}}
</div>
</div>
<div class="info-modal-body">
<p class="chat-help-intro">
{{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}}
</p>
<div class="chat-help-accordion">
<!-- Experience -->
<details class="chat-help-group" open>
<summary>
<iconify-icon icon="mdi:briefcase-outline"></iconify-icon>
{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}
</summary>
<div class="chat-help-questions">
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuántos años de experiencia tiene Juan?{{else}}How many years of experience does Juan have?{{end}}')">{{if eq .Lang "es"}}¿Cuántos años de experiencia tiene?{{else}}How many years of experience?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿En qué empresas ha trabajado?{{else}}What companies has he worked at?{{end}}')">{{if eq .Lang "es"}}¿En qué empresas ha trabajado?{{else}}What companies has he worked at?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}Cuéntame sobre Olympic Broadcasting{{else}}Tell me about Olympic Broadcasting{{end}}')">{{if eq .Lang "es"}}Cuéntame sobre Olympic Broadcasting{{else}}Tell me about Olympic Broadcasting{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué hacía en SAP?{{else}}What did he do at SAP?{{end}}')">{{if eq .Lang "es"}}¿Qué hacía en SAP?{{else}}What did he do at SAP?{{end}}</button>
</div>
</details>
<!-- Technologies -->
<details class="chat-help-group">
<summary>
<iconify-icon icon="mdi:code-tags"></iconify-icon>
{{if eq .Lang "es"}}Tecnologías{{else}}Technologies{{end}}
</summary>
<div class="chat-help-questions">
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué lenguajes de programación conoce?{{else}}What programming languages does he know?{{end}}')">{{if eq .Lang "es"}}¿Qué lenguajes conoce?{{else}}What languages does he know?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Ha trabajado con React? ¿Dónde?{{else}}Has he worked with React? Where?{{end}}')">{{if eq .Lang "es"}}¿Ha trabajado con React?{{else}}Has he worked with React?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuál es su experiencia con Go?{{else}}What is his Go experience?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con Go?{{else}}Go experience?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Conoce Node.js?{{else}}Does he know Node.js?{{end}}')">{{if eq .Lang "es"}}¿Conoce Node.js?{{else}}Does he know Node.js?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Tiene experiencia con Docker?{{else}}Does he have Docker experience?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con Docker?{{else}}Docker experience?{{end}}</button>
</div>
</details>
<!-- Projects -->
<details class="chat-help-group">
<summary>
<iconify-icon icon="mdi:rocket-launch-outline"></iconify-icon>
{{if eq .Lang "es"}}Proyectos{{else}}Projects{{end}}
</summary>
<div class="chat-help-questions">
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué proyectos personales ha creado?{{else}}What personal projects has he built?{{end}}')">{{if eq .Lang "es"}}¿Qué proyectos ha creado?{{else}}What projects has he built?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}Cuéntame sobre Immich Photo Manager{{else}}Tell me about Immich Photo Manager{{end}}')">{{if eq .Lang "es"}}Sobre Immich Photo Manager{{else}}About Immich Photo Manager{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué proyectos open-source mantiene?{{else}}What open-source projects does he maintain?{{end}}')">{{if eq .Lang "es"}}¿Proyectos open-source?{{else}}Open-source projects?{{end}}</button>
</div>
</details>
<!-- Education -->
<details class="chat-help-group">
<summary>
<iconify-icon icon="mdi:school-outline"></iconify-icon>
{{if eq .Lang "es"}}Formación{{else}}Education{{end}}
</summary>
<div class="chat-help-questions">
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué certificaciones tiene?{{else}}What certifications does he have?{{end}}')">{{if eq .Lang "es"}}¿Certificaciones?{{else}}Certifications?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Dónde estudió?{{else}}Where did he study?{{end}}')">{{if eq .Lang "es"}}¿Dónde estudió?{{else}}Where did he study?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué cursos ha completado?{{else}}What courses has he completed?{{end}}')">{{if eq .Lang "es"}}¿Cursos completados?{{else}}Courses completed?{{end}}</button>
</div>
</details>
<!-- Skills -->
<details class="chat-help-group">
<summary>
<iconify-icon icon="mdi:star-outline"></iconify-icon>
{{if eq .Lang "es"}}Habilidades{{else}}Skills{{end}}
</summary>
<div class="chat-help-questions">
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuáles son sus principales habilidades técnicas?{{else}}What are his main technical skills?{{end}}')">{{if eq .Lang "es"}}¿Habilidades técnicas principales?{{else}}Main technical skills?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué hay de CI/CD?{{else}}What about CI/CD?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con CI/CD?{{else}}CI/CD experience?{{end}}</button>
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué idiomas habla?{{else}}What languages does he speak?{{end}}')">{{if eq .Lang "es"}}¿Qué idiomas habla?{{else}}Languages spoken?{{end}}</button>
</div>
</details>
</div>
<!-- How it works footer -->
<div class="chat-help-footer">
<iconify-icon icon="mdi:information-outline"></iconify-icon>
<span>{{if eq .Lang "es"}}Impulsado por Google ADK Go 1.0 y Gemini AI. Las respuestas provienen de los datos reales del CV.{{else}}Powered by Google ADK Go 1.0 and Gemini AI. Answers come from actual CV data.{{end}}</span>
</div>
</div>
</div>
</dialog>
<!-- Helper function: close modal, open chat, send question -->
<script>
function closeChatHelpAndAsk(question) {
// Close the modal
document.getElementById('chat-help-modal').close();
// Open chat panel if not open
var panel = document.getElementById('chat-panel');
var btn = document.getElementById('chat-toggle-btn');
if (!panel.classList.contains('chat-open')) {
panel.classList.add('chat-open');
btn.classList.add('mascot-active');
}
// Set the question and submit
document.getElementById('chat-input').value = question;
htmx.trigger(document.getElementById('chat-form'), 'submit');
}
</script>
{{end}}
+1
View File
@@ -1,6 +1,7 @@
{{define "section-courses"}}
<!-- Courses Section -->
{{if .CV.Courses}}
<span id="certifications"></span>
<section id="courses" class="cv-section component-wrapper">
<!-- Actual Content -->
<div class="actual-content">
+317
View File
@@ -0,0 +1,317 @@
{{define "chat-widget"}}
{{if .ChatEnabled}}
<!-- AI Chat Widget — CV Assistant Mascot -->
<button
id="chat-toggle-btn"
class="chat-toggle-btn no-print has-tooltip tooltip-left"
aria-label="{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}"
data-tooltip="{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}"
onclick="toggleChatPanel()">
<iconify-icon icon="mdi:robot-happy-outline" class="chat-icon-open"></iconify-icon>
<iconify-icon icon="mdi:close" class="chat-icon-close"></iconify-icon>
</button>
<div id="chat-panel" class="chat-panel no-print">
<div class="chat-header">
<iconify-icon icon="mdi:robot-happy-outline"></iconify-icon>
<span>{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}</span>
<div class="chat-header-actions">
<button class="chat-mode-btn active" data-mode="" title="{{if eq .Lang "es"}}Compacto{{else}}Compact{{end}}" onclick="setChatSize('')">
<iconify-icon icon="mdi:message-outline"></iconify-icon>
</button>
<button class="chat-mode-btn" data-mode="chat-half" title="{{if eq .Lang "es"}}Panel lateral{{else}}Side panel{{end}}" onclick="setChatSize('chat-half')">
<iconify-icon icon="mdi:page-layout-sidebar-right"></iconify-icon>
</button>
<button class="chat-mode-btn" data-mode="chat-float" title="{{if eq .Lang "es"}}Flotante arrastra para mover{{else}}Floating drag to move{{end}}" onclick="setChatSize('chat-float')">
<iconify-icon icon="mdi:cursor-move"></iconify-icon>
</button>
<button class="chat-mode-btn" data-mode="chat-full" title="{{if eq .Lang "es"}}Pantalla completa{{else}}Full screen{{end}}" onclick="setChatSize('chat-full')">
<iconify-icon icon="mdi:arrow-expand-all"></iconify-icon>
</button>
<span class="chat-header-divider"></span>
<button class="chat-mode-btn"
title="{{if eq .Lang "es"}}Ayuda{{else}}Help{{end}}"
commandfor="chat-help-modal"
command="show-modal">
<iconify-icon icon="mdi:help-circle-outline"></iconify-icon>
</button>
</div>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-row chat-row-bot">
<div class="chat-avatar"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></div>
<div class="chat-msg">{{if eq .Lang "es"}}¡Hola! Pregúntame lo que quieras sobre este CV.{{else}}Hi! Ask me anything about this CV.{{end}}</div>
</div>
</div>
<!-- Typing / Status Indicator -->
<div id="chat-typing" class="chat-typing">
<span id="chat-status-text" class="chat-status-text" style="display:none"></span>
<span class="chat-typing-dots">
<span class="chat-typing-dot"></span>
<span class="chat-typing-dot"></span>
<span class="chat-typing-dot"></span>
</span>
</div>
<!-- Suggested Questions -->
<div class="chat-suggestions">
{{if eq .Lang "es"}}
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué proyectos en Go ha hecho?')">¿Proyectos en Go?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Cuántos años de experiencia tiene?')">¿Años de experiencia?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿En qué empresas ha trabajado?')">¿Empresas?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Conoce React?')">¿Conoce React?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué certificaciones tiene?')">¿Certificaciones?</button>
{{else}}
<button type="button" class="chat-chip" onclick="sendChatQuestion('What Go projects has he built?')">Go projects?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('How many years of experience?')">Years of experience?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('What companies has he worked at?')">Companies?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('Does he know React?')">Knows React?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('What certifications?')">Certifications?</button>
{{end}}
</div>
<form id="chat-form" class="chat-input-area"
hx-post="/api/chat"
hx-target="#chat-messages"
hx-swap="beforeend scroll:#chat-messages:bottom"
hx-indicator="#chat-typing"
hx-request='{"timeout":120000}'>
<input type="hidden" id="chat-session-id" name="session_id" value="">
<input type="hidden" name="lang" value="{{.Lang}}">
<input
type="text"
id="chat-input"
name="message"
class="chat-input"
placeholder="{{if eq .Lang "es"}}Pregunta algo sobre el CV...{{else}}Ask something about the CV...{{end}}"
autocomplete="off">
<button type="submit" class="chat-send-btn" aria-label="Send">
<iconify-icon icon="mdi:send"></iconify-icon>
</button>
</form>
</div>
<!-- Chat JavaScript — all interactions in plain JS, no Hyperscript -->
<script>
// Chat model readiness state
var chatModelReady = false;
var chatWarmedUp = false;
// Poll model status until ready
function pollChatStatus() {
fetch('/api/chat/status').then(function(r) { return r.json(); }).then(function(data) {
if (data.ready) {
chatModelReady = true;
var statusEl = document.getElementById('chat-status-text');
if (statusEl) statusEl.style.display = 'none';
} else if (data.warming) {
setTimeout(pollChatStatus, 2000);
}
}).catch(function() {});
}
// Trigger warmup on page load and start polling
(function() {
if (!chatWarmedUp) {
chatWarmedUp = true;
fetch('/api/chat/warmup', { method: 'POST' }).catch(function() {});
pollChatStatus();
}
})();
// Toggle chat panel open/close
function toggleChatPanel() {
var panel = document.getElementById('chat-panel');
var btn = document.getElementById('chat-toggle-btn');
panel.classList.toggle('chat-open');
btn.classList.toggle('mascot-active');
if (panel.classList.contains('chat-open')) {
document.getElementById('chat-input').focus();
}
}
// Show user message bubble immediately in chat
var chatBubblePending = false;
function appendUserBubble(text) {
var messages = document.getElementById('chat-messages');
var row = document.createElement('div');
row.className = 'chat-row chat-row-user';
row.innerHTML = '<div class="chat-msg">' + text.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>'
+ '<div class="chat-avatar chat-avatar-user"><iconify-icon icon="mdi:account"></iconify-icon></div>';
messages.appendChild(row);
messages.scrollTop = messages.scrollHeight;
chatBubblePending = true;
}
// Send a question immediately (from chip click) — show bubble, clear input, send
function sendChatQuestion(question) {
appendUserBubble(question);
var input = document.getElementById('chat-input');
var form = document.getElementById('chat-form');
input.value = question;
htmx.trigger(form, 'submit');
input.value = '';
}
// Prefill input from help modal (user must click Send)
function closeChatHelpAndAsk(question) {
document.getElementById('chat-help-modal').close();
var panel = document.getElementById('chat-panel');
var btn = document.getElementById('chat-toggle-btn');
if (!panel.classList.contains('chat-open')) {
panel.classList.add('chat-open');
btn.classList.add('mascot-active');
}
var input = document.getElementById('chat-input');
input.value = question;
input.focus();
}
// Form submit — show user bubble for manual typing, clear input
document.addEventListener('htmx:beforeRequest', function(event) {
if (event.detail.elt && event.detail.elt.id === 'chat-form') {
var input = document.getElementById('chat-input');
var msg = input.value.trim();
// Chips already added their bubble; manual typing needs one
if (msg && !chatBubblePending) {
appendUserBubble(msg);
}
chatBubblePending = false;
input.value = '';
// Show initializing or typing indicator
var statusEl = document.getElementById('chat-status-text');
var dotsEl = document.querySelector('.chat-typing-dots');
if (!chatModelReady && statusEl && dotsEl) {
statusEl.textContent = '{{if eq .Lang "es"}}Inicializando modelo IA…{{else}}Initializing AI model…{{end}}';
statusEl.style.display = 'inline';
dotsEl.style.display = 'none';
} else if (statusEl && dotsEl) {
statusEl.style.display = 'none';
dotsEl.style.display = 'inline-flex';
}
}
});
// Reset indicator after HTMX request completes
document.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.elt && event.detail.elt.id === 'chat-form') {
document.getElementById('chat-input').value = '';
chatModelReady = true;
var statusEl = document.getElementById('chat-status-text');
if (statusEl) statusEl.style.display = 'none';
var dotsEl = document.querySelector('.chat-typing-dots');
if (dotsEl) dotsEl.style.display = 'inline-flex';
}
});
// Set chat panel size
function setChatSize(size) {
var panel = document.getElementById('chat-panel');
panel.classList.remove('chat-half', 'chat-full', 'chat-float');
panel.style.top = '';
panel.style.left = '';
panel.style.right = '';
panel.style.bottom = '';
if (size) panel.classList.add(size);
// Update active button
document.querySelectorAll('.chat-mode-btn[data-mode]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-mode') === size);
});
// Enable/disable drag
if (size === 'chat-float') {
enableChatDrag(panel);
} else {
disableChatDrag(panel);
}
}
// Drag support for floating mode
var chatDragState = { dragging: false, offsetX: 0, offsetY: 0 };
function enableChatDrag(panel) {
var header = panel.querySelector('.chat-header');
header.style.cursor = 'grab';
header.addEventListener('mousedown', chatDragStart);
header.addEventListener('touchstart', chatDragTouchStart, { passive: false });
}
function disableChatDrag(panel) {
var header = panel.querySelector('.chat-header');
header.style.cursor = '';
header.removeEventListener('mousedown', chatDragStart);
header.removeEventListener('touchstart', chatDragTouchStart);
}
function chatDragStart(e) {
if (e.target.closest('button')) return;
var panel = document.getElementById('chat-panel');
chatDragState.dragging = true;
chatDragState.offsetX = e.clientX - panel.getBoundingClientRect().left;
chatDragState.offsetY = e.clientY - panel.getBoundingClientRect().top;
panel.classList.add('chat-dragging');
document.addEventListener('mousemove', chatDragMove);
document.addEventListener('mouseup', chatDragEnd);
panel.querySelector('.chat-header').style.cursor = 'grabbing';
e.preventDefault();
}
function chatDragTouchStart(e) {
if (e.target.closest('button')) return;
var panel = document.getElementById('chat-panel');
var touch = e.touches[0];
chatDragState.dragging = true;
chatDragState.offsetX = touch.clientX - panel.getBoundingClientRect().left;
chatDragState.offsetY = touch.clientY - panel.getBoundingClientRect().top;
panel.classList.add('chat-dragging');
document.addEventListener('touchmove', chatDragTouchMove, { passive: false });
document.addEventListener('touchend', chatDragEnd);
e.preventDefault();
}
function chatDragMove(e) {
if (!chatDragState.dragging) return;
var panel = document.getElementById('chat-panel');
panel.style.left = (e.clientX - chatDragState.offsetX) + 'px';
panel.style.top = (e.clientY - chatDragState.offsetY) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
function chatDragTouchMove(e) {
if (!chatDragState.dragging) return;
var touch = e.touches[0];
var panel = document.getElementById('chat-panel');
panel.style.left = (touch.clientX - chatDragState.offsetX) + 'px';
panel.style.top = (touch.clientY - chatDragState.offsetY) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
e.preventDefault();
}
function chatDragEnd() {
chatDragState.dragging = false;
var panel = document.getElementById('chat-panel');
panel.classList.remove('chat-dragging');
panel.querySelector('.chat-header').style.cursor = 'grab';
document.removeEventListener('mousemove', chatDragMove);
document.removeEventListener('mouseup', chatDragEnd);
document.removeEventListener('touchmove', chatDragTouchMove);
document.removeEventListener('touchend', chatDragEnd);
}
// Navigate from chat link to CV section, then highlight
function scrollToCV(link) {
var anchor = link.getAttribute('href');
var target = document.querySelector(anchor);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('chat-highlight');
setTimeout(function() { target.classList.remove('chat-highlight'); }, 2000);
}
return false;
}
</script>
{{end}}
{{end}}
+362
View File
@@ -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);
});