merge: chat agent feature — GLM local model, UX overhaul, icons, layout modes
This commit is contained in:
+15
-1
@@ -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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# CV Site - Go + HTMX
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://htmx.org/)
|
||||
[](https://go.dev/)
|
||||
[](https://htmx.org/)
|
||||
[](https://github.com/google/adk-go)
|
||||
[](https://aistudio.google.com/)
|
||||
[](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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -135,6 +135,8 @@ const (
|
||||
RateLimitGeneralWindow = 1 * time.Minute
|
||||
RateLimitContactRequests = 5
|
||||
RateLimitContactWindow = 1 * time.Hour
|
||||
RateLimitChatRequests = 30
|
||||
RateLimitChatWindow = 1 * time.Hour
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 -->
|
||||
<!-- ============================================ -->
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>') + '</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}}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user