From f5276431eaff5479e7aff2623e955b7ef8c26c9b Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 00:20:48 +0100 Subject: [PATCH 01/24] feat: add AI chat widget powered by ADK Go 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visitors can ask questions about the CV via a floating chat panel. The agent uses Gemini to answer questions about experience, projects, skills, and education by querying the cached CV JSON data. - internal/chat/agent.go: LLM agent with query_cv tool that searches CV data by section (experience, projects, skills, etc.) with keyword filtering - internal/chat/handler.go: POST /api/chat endpoint with session management, graceful degradation when GOOGLE_API_KEY is not set - chat-widget.html: HTMX-powered floating chat panel with Hyperscript toggle - _chat.css: Responsive chat UI with dark theme support - Wired into existing architecture via dependency injection (CVHandler, routes, main.go) — zero breaking changes, all existing tests pass --- go.mod | 36 ++- go.sum | 92 ++++++- internal/chat/agent.go | 244 +++++++++++++++++++ internal/chat/handler.go | 213 ++++++++++++++++ internal/handlers/cv.go | 4 +- internal/handlers/cv_helpers.go | 1 + internal/handlers/test_helpers_test.go | 2 +- internal/routes/routes.go | 4 +- main.go | 10 +- static/css/04-interactive/_chat.css | 255 ++++++++++++++++++++ static/css/main.css | 1 + templates/index.html | 1 + templates/partials/widgets/chat-widget.html | 59 +++++ 13 files changed, 900 insertions(+), 22 deletions(-) create mode 100644 internal/chat/agent.go create mode 100644 internal/chat/handler.go create mode 100644 static/css/04-interactive/_chat.css create mode 100644 templates/partials/widgets/chat-widget.html diff --git a/go.mod b/go.mod index e3842d1..4229caf 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index d45b7c5..bb00e7a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/chat/agent.go b/internal/chat/agent.go new file mode 100644 index 0000000..6bde9f3 --- /dev/null +++ b/internal/chat/agent.go @@ -0,0 +1,244 @@ +// 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 assistant embedded in a professional CV website. +You answer questions about the CV owner's experience, projects, skills, education, and career. + +RULES: +- Use the query_cv tool to look up CV data before answering. Never make up information. +- Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish. +- Be concise and direct — visitors want quick answers, not essays. +- When listing items (projects, technologies, companies), use bullet points. +- If the query_cv tool returns no results for a question, say so honestly. +- You may reference sections of the CV (e.g., "See the Projects section") to guide the visitor. +- Never reveal personal contact details (email, phone) — just point them to the contact form. +- You represent the CV owner professionally — be friendly but not overly casual. + +EXAMPLES of questions you might receive: +- "How many years of experience does Juan have?" +- "What Go projects has he built?" +- "Has he worked with React?" +- "Tell me about his time at Olympic Broadcasting" +- "What certifications does he have?"`, + 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: '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. +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 "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 +} diff --git a/internal/chat/handler.go b/internal/chat/handler.go new file mode 100644 index 0000000..77fa9f2 --- /dev/null +++ b/internal/chat/handler.go @@ -0,0 +1,213 @@ +package chat + +import ( + "context" + "fmt" + "html" + "log" + "net/http" + "os" + "strings" + + "github.com/juanatsap/cv-site/internal/cache" + + "google.golang.org/adk/agent" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +// Handler serves the chat API endpoint. +type Handler struct { + runner *runner.Runner + sessionService session.Service + enabled bool +} + +// NewHandler creates a chat handler. Returns a disabled handler if GOOGLE_API_KEY is not set. +func NewHandler(dataCache *cache.DataCache) *Handler { + apiKey := os.Getenv("GOOGLE_API_KEY") + if apiKey == "" { + log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled") + return &Handler{enabled: false} + } + + ctx := context.Background() + + modelName := os.Getenv("MODEL_NAME") + if modelName == "" { + modelName = "gemini-2.5-flash" + } + + llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ + APIKey: apiKey, + }) + if err != nil { + log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err) + return &Handler{enabled: false} + } + + cvAgent, err := NewAgent(llm, dataCache) + if err != nil { + log.Printf("⚠️ Failed to create CV agent: %v — chat disabled", err) + return &Handler{enabled: false} + } + + sessionSvc := session.InMemoryService() + + r, err := runner.New(runner.Config{ + AppName: "cv-chat", + Agent: cvAgent, + SessionService: sessionSvc, + AutoCreateSession: true, + }) + if err != nil { + log.Printf("⚠️ Failed to create runner: %v — chat disabled", err) + return &Handler{enabled: false} + } + + log.Printf("💬 Chat agent enabled (model: %s)", modelName) + + return &Handler{ + runner: r, + sessionService: sessionSvc, + enabled: true, + } +} + +// Enabled returns whether the chat feature is available. +func (h *Handler) Enabled() bool { + return h.enabled +} + +// HandleChat processes POST /api/chat requests. +// Expects form field "message" and optional "session_id". +// Returns an HTML fragment for HTMX to swap into the chat panel. +func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { + if !h.enabled { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = fmt.Fprint(w, `
Chat is not available at the moment.
`) + 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, `
Please enter a message.
`) + return + } + + sessionID := r.FormValue("session_id") + if sessionID == "" { + sessionID = "default" + } + + // Ensure session exists + ctx := r.Context() + _, err := h.sessionService.Get(ctx, &session.GetRequest{ + AppName: "cv-chat", + UserID: "visitor", + SessionID: sessionID, + }) + if err != nil { + // Create new session + created, createErr := h.sessionService.Create(ctx, &session.CreateRequest{ + AppName: "cv-chat", + UserID: "visitor", + }) + if createErr != nil { + log.Printf("Chat session create error: %v", createErr) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, `
Failed to start chat session.
`) + return + } + sessionID = created.Session.ID() + } + + // Run the agent + userMsg := genai.NewContentFromText(message, genai.RoleUser) + + var response strings.Builder + for event, err := range h.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) { + if err != nil { + log.Printf("Chat agent error: %v", err) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, `
Something went wrong. Please try again.
`) + return + } + if event.IsFinalResponse() { + if event.Content != nil { + for _, part := range event.Content.Parts { + if part.Text != "" { + response.WriteString(part.Text) + } + } + } + } + } + + // Render the response as HTML + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + // User message bubble + _, _ = fmt.Fprintf(w, `
%s
`, html.EscapeString(message)) + + // Agent response bubble + agentText := response.String() + if agentText == "" { + agentText = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education." + } + _, _ = fmt.Fprintf(w, `
%s
`, formatResponse(agentText)) + + // Hidden input to preserve session ID for next request + _, _ = fmt.Fprintf(w, ``, sessionID) +} + +// formatResponse converts basic markdown to HTML for the chat bubble. +func formatResponse(text string) string { + // Escape HTML first + text = html.EscapeString(text) + + // Bold: **text** → text + for strings.Contains(text, "**") { + text = strings.Replace(text, "**", "", 1) + text = strings.Replace(text, "**", "", 1) + } + + // Bullet points: lines starting with "- " →
  • + 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, "") + inList = false + } + if trimmed != "" { + result = append(result, "

    "+trimmed+"

    ") + } + } + } + if inList { + result = append(result, "") + } + + return strings.Join(result, "") +} diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index ffef514..3971bfd 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -21,15 +21,17 @@ type CVHandler struct { emailService *email.Service serverAddr string dataCache *cache.DataCache + chatEnabled bool } // NewCVHandler creates a new CV handler -func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache) *CVHandler { +func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache, chatEnabled bool) *CVHandler { return &CVHandler{ templates: tmpl, pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration), emailService: emailService, serverAddr: serverAddr, dataCache: dataCache, + chatEnabled: chatEnabled, } } diff --git a/internal/handlers/cv_helpers.go b/internal/handlers/cv_helpers.go index f172e49..6c14923 100644 --- a/internal/handlers/cv_helpers.go +++ b/internal/handlers/cv_helpers.go @@ -365,6 +365,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + "ChatEnabled": h.chatEnabled, } return data, nil diff --git a/internal/handlers/test_helpers_test.go b/internal/handlers/test_helpers_test.go index 6d3170c..f10ac29 100644 --- a/internal/handlers/test_helpers_test.go +++ b/internal/handlers/test_helpers_test.go @@ -95,5 +95,5 @@ func newTestCVHandler(t testing.TB, serverAddr string, emailService *email.Servi dataCache := getTestCache(t) - return NewCVHandler(tmplManager, serverAddr, emailService, dataCache) + return NewCVHandler(tmplManager, serverAddr, emailService, dataCache, false) } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index eadbe91..b594adc 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -3,13 +3,14 @@ package routes import ( "net/http" + "github.com/juanatsap/cv-site/internal/chat" c "github.com/juanatsap/cv-site/internal/constants" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/middleware" ) // Setup configures all application routes and middleware -func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler { +func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, chatHandler *chat.Handler) http.Handler { mux := http.NewServeMux() // Shortcut routes for default CV (year-aware) - MUST be before "/" route @@ -18,6 +19,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) // API routes (must be before "/" to avoid catch-all) mux.HandleFunc("/api/cmd-k", cvHandler.CmdKData) // CMD+K command palette data + mux.HandleFunc("/api/chat", chatHandler.HandleChat) // AI chat endpoint // Public routes mux.HandleFunc("/", cvHandler.Home) diff --git a/main.go b/main.go index 8b8c543..5b68b41 100644 --- a/main.go +++ b/main.go @@ -12,11 +12,12 @@ import ( "github.com/joho/godotenv" "github.com/juanatsap/cv-site/internal/cache" + "github.com/juanatsap/cv-site/internal/chat" "github.com/juanatsap/cv-site/internal/config" c "github.com/juanatsap/cv-site/internal/constants" + "github.com/juanatsap/cv-site/internal/email" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/routes" - "github.com/juanatsap/cv-site/internal/email" "github.com/juanatsap/cv-site/internal/templates" ) @@ -62,12 +63,15 @@ 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) + // Initialize handlers - cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address(), emailService, dataCache) + cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address(), emailService, dataCache, chatHandler.Enabled()) healthHandler := handlers.NewHealthHandler(version) // Setup routes and middleware - handler := routes.Setup(cvHandler, healthHandler) + handler := routes.Setup(cvHandler, healthHandler, chatHandler) // Create server with timeouts server := &http.Server{ diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css new file mode 100644 index 0000000..2866bc5 --- /dev/null +++ b/static/css/04-interactive/_chat.css @@ -0,0 +1,255 @@ +/* ============================================================================ + CHAT WIDGET + Floating AI chat panel for CV questions + ============================================================================ */ + +/* Toggle Button */ +.chat-toggle-btn { + position: fixed; + bottom: 100px; + right: 24px; + z-index: 1000; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--accent-color, #2563eb); + color: #fff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); + transition: transform 0.2s, background 0.2s; +} + +.chat-toggle-btn:hover { + transform: scale(1.1); + background: var(--accent-color-hover, #1d4ed8); +} + +/* Panel */ +.chat-panel { + position: fixed; + bottom: 160px; + right: 24px; + width: 380px; + max-height: 500px; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + display: none; + flex-direction: column; + z-index: 999; + overflow: hidden; +} + +.chat-panel.chat-open { + display: flex; +} + +/* Header */ +.chat-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--accent-color, #2563eb); + color: #fff; + font-size: 0.9rem; + font-weight: 600; +} + +.chat-header iconify-icon { + font-size: 1.2rem; +} + +.chat-close-btn { + margin-left: auto; + background: none; + border: none; + color: #fff; + cursor: pointer; + font-size: 1.1rem; + opacity: 0.8; + transition: opacity 0.2s; + display: flex; + align-items: center; +} + +.chat-close-btn:hover { + opacity: 1; +} + +/* Messages Area */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 340px; + min-height: 120px; +} + +/* Message Bubbles */ +.chat-message { + padding: 10px 14px; + border-radius: 12px; + font-size: 0.85rem; + line-height: 1.5; + max-width: 90%; + word-wrap: break-word; +} + +.chat-message p { + margin: 0 0 4px 0; +} + +.chat-message p:last-child { + margin-bottom: 0; +} + +.chat-message ul { + margin: 4px 0; + padding-left: 18px; +} + +.chat-message li { + margin-bottom: 2px; +} + +.chat-agent { + background: var(--bg-secondary, #f1f5f9); + color: var(--text-primary, #1e293b); + align-self: flex-start; + border-bottom-left-radius: 4px; +} + +.chat-user { + background: var(--accent-color, #2563eb); + color: #fff; + align-self: flex-end; + border-bottom-right-radius: 4px; +} + +.chat-error { + background: #fef2f2; + color: #991b1b; + align-self: center; + font-style: italic; + border: 1px solid #fecaca; +} + +/* Input Area */ +.chat-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--border-color, #e2e8f0); + background: var(--bg-primary, #fff); +} + +.chat-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 20px; + font-size: 0.85rem; + outline: none; + background: var(--bg-primary, #fff); + color: var(--text-primary, #1e293b); + transition: border-color 0.2s; +} + +.chat-input:focus { + border-color: var(--accent-color, #2563eb); +} + +.chat-send-btn { + background: var(--accent-color, #2563eb); + color: #fff; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + transition: background 0.2s; + flex-shrink: 0; +} + +.chat-send-btn:hover { + background: var(--accent-color-hover, #1d4ed8); +} + +/* Spinner */ +.chat-spinner { + display: none; +} + +.chat-spinner.htmx-request { + display: flex; + align-items: center; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Responsive */ +@media (max-width: 480px) { + .chat-panel { + bottom: 0; + right: 0; + left: 0; + width: 100%; + max-height: 60vh; + border-radius: 12px 12px 0 0; + } + + .chat-toggle-btn { + bottom: 80px; + right: 16px; + } +} + +/* Theme: Clean (dark) */ +.theme-clean .chat-panel { + background: #1e293b; + border-color: #334155; +} + +.theme-clean .chat-agent { + background: #334155; + color: #e2e8f0; +} + +.theme-clean .chat-error { + background: #450a0a; + color: #fca5a5; + border-color: #7f1d1d; +} + +.theme-clean .chat-input-area { + border-top-color: #334155; + background: #1e293b; +} + +.theme-clean .chat-input { + background: #0f172a; + border-color: #334155; + color: #e2e8f0; +} diff --git a/static/css/main.css b/static/css/main.css index 1eb71c8..f50449a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -36,6 +36,7 @@ @import './04-interactive/_zoom-control.css'; @import './04-interactive/_contact-form.css'; @import './04-interactive/_sprites.css'; +@import './04-interactive/_chat.css'; /* 05 - Responsive */ @import './05-responsive/_breakpoints.css'; diff --git a/templates/index.html b/templates/index.html index 53d63cb..fe18a56 100644 --- a/templates/index.html +++ b/templates/index.html @@ -50,6 +50,7 @@ {{template "contact-button" .}} {{template "zoom-toggle-button" .}} {{template "shortcuts-button" .}} + {{template "chat-widget" .}} diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html new file mode 100644 index 0000000..5299a37 --- /dev/null +++ b/templates/partials/widgets/chat-widget.html @@ -0,0 +1,59 @@ +{{define "chat-widget"}} +{{if .ChatEnabled}} + + + +
    +
    + + {{if eq .Lang "es"}}Pregunta sobre este CV{{else}}Ask about this CV{{end}} + +
    + +
    +
    + {{if eq .Lang "es"}}¡Hola! Puedo responder preguntas sobre este currículum. Prueba a preguntar sobre experiencia, proyectos, tecnologías o formación.{{else}}Hi! I can answer questions about this CV. Try asking about experience, projects, technologies, or education.{{end}} +
    +
    + +
    + + + + +
    + +
    +
    +
    +{{end}} +{{end}} From f67126e8c30e8ed6817db722f11988f8be670136 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 00:30:01 +0100 Subject: [PATCH 02/24] docs: add AI Chat Agent documentation and update README - doc/28-AI-CHAT-AGENT.md: comprehensive technical documentation covering architecture, agent design, query_cv tool, HTMX integration, graceful degradation, security, and example conversations - README.md: add AI Chat Agent section with examples, ADK Go badge, updated tech stack and documentation index - doc/00-GO-DOCUMENTATION-INDEX.md: add chat agent to doc index --- README.md | 59 +++++++- doc/00-GO-DOCUMENTATION-INDEX.md | 7 + doc/28-AI-CHAT-AGENT.md | 230 +++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 doc/28-AI-CHAT-AGENT.md diff --git a/README.md b/README.md index d05415e..5918282 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # CV Site - Go + HTMX -[![Go Version](https://img.shields.io/badge/Go-1.21%2B-00ADD8?logo=go)](https://go.dev/) -[![HTMX](https://img.shields.io/badge/HTMX-1.9.10-3366CC)](https://htmx.org/) +[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/) +[![HTMX](https://img.shields.io/badge/HTMX-2.0-3366CC)](https://htmx.org/) +[![ADK Go](https://img.shields.io/badge/ADK_Go-1.0-4285F4?logo=google)](https://github.com/google/adk-go) +[![Gemini](https://img.shields.io/badge/Gemini_2.5_Flash-AI_Chat-8E75B2?logo=google)](https://aistudio.google.com/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) **Modern, minimal curriculum vitae website** for Juan Andrés Moreno Rubio built with **Go** and **HTMX**. @@ -19,6 +21,7 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact ## 📑 Table of Contents - [Features](#-features) +- [AI Chat Agent](#-ai-chat-agent) - [Demo](#-demo) - [Security](#-security) - [Quick Start](#-quick-start) @@ -43,6 +46,7 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact - ✅ **Zoom Control** - Adjustable zoom (25%-300%) with persistence across sessions - ✅ **Responsive** - Mobile, tablet, and desktop friendly - ✅ **JSON-Based Content** - Easy to update without touching code +- ✅ **AI Chat Agent** - Ask questions about the CV in natural language (powered by ADK Go + Gemini) - ✅ **AI Development Section** - Showcases modern AI-assisted development skills - ✅ **Fast & Lightweight** - Go backend with chromedp for PDF generation - ✅ **Privacy-Friendly Analytics** - Self-hosted analytics (no third-party data sharing) @@ -50,6 +54,50 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact - ✅ **Production Ready** - Systemd service, CI/CD workflows, deployment guides - ✅ **Developer Friendly** - Hot reload, clear code structure, comprehensive Makefile +## 🤖 AI Chat Agent + +Visitors can ask questions about the CV through a floating chat widget — powered by [Google ADK Go 1.0](https://github.com/google/adk-go) and Gemini 2.5 Flash. + +### How It Works + +``` +Visitor types question → HTMX POST /api/chat → ADK Agent runs query_cv tool +→ Tool searches cached CV JSON data → Agent formulates answer → HTML response +``` + +### Example Questions & Answers + +| Question | Answer | +|----------|--------| +| *"How many Go projects has Juan built?"* | Lists 2 Go projects with descriptions | +| *"What companies has he worked at?"* | Lists all 11 companies | +| *"Does he have React experience?"* | Shows companies where React was used | +| *"¿Qué certificaciones tiene?"* | Lists certifications — answers in Spanish automatically | + +### Key Design Decisions + +- **Single agent, single tool** — the CV data is bounded; multi-agent orchestration would be over-engineering +- **Reads from the same data cache** the site uses — zero data duplication, always in sync +- **Graceful degradation** — no API key? Chat icon simply doesn't appear. Zero impact on the site +- **HTMX-native** — `hx-post` sends messages, responses are HTML fragments, no WebSocket needed +- **Language-aware** — the agent responds in whatever language the visitor writes in + +### Setup + +```bash +# Get a free API key from https://aistudio.google.com/apikey +echo "GOOGLE_API_KEY=your-key" >> .env + +# Chat icon appears automatically on next server start +go run . +``` + +**Free tier:** 15 requests/minute — more than enough for a personal CV site. + +**Full technical documentation:** [doc/28-AI-CHAT-AGENT.md](doc/28-AI-CHAT-AGENT.md) + +--- + ## 📸 Demo 🔗 **Live Demo:** [https://juan.andres.morenorub.io/](https://juan.andres.morenorub.io/) @@ -187,9 +235,10 @@ No code changes needed - just refresh browser! ## 🎯 Key Technologies -- **Backend:** Go 1.21+ (stdlib `net/http`, graceful shutdown) +- **Backend:** Go 1.25+ (stdlib `net/http`, graceful shutdown) +- **AI Agent:** Google ADK Go 1.0 + Gemini 2.5 Flash (conversational CV navigator) - **PDF Generation:** chromedp (headless Chrome automation) -- **Frontend:** HTMX 1.9.10 (hypermedia-driven interactions) +- **Frontend:** HTMX 2.0 + Hyperscript (hypermedia-driven interactions) - **Styling:** Custom CSS with Quicksand font from Google Fonts - **Data:** JSON files for easy content management - **Deployment:** Systemd service, manual binary, GitHub Actions CI/CD @@ -206,6 +255,7 @@ 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 ### 📋 Policies & Standards - **[SECURITY.md](doc/9-SECURITY.md)** - Complete security architecture, implementation, and testing guide @@ -323,6 +373,7 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 🙏 Acknowledgments - **HTMX** - For making hypermedia-driven applications enjoyable +- **Google ADK Go** - For the production-grade agent framework - **chromedp** - For reliable headless Chrome automation - **Go Community** - For excellent standard library and tooling - **AI Assistance** - For accelerating development and documentation diff --git a/doc/00-GO-DOCUMENTATION-INDEX.md b/doc/00-GO-DOCUMENTATION-INDEX.md index a9d98fb..0203780 100644 --- a/doc/00-GO-DOCUMENTATION-INDEX.md +++ b/doc/00-GO-DOCUMENTATION-INDEX.md @@ -36,6 +36,13 @@ 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](28-AI-CHAT-AGENT.md)** (~280 lines) + - ADK Go 1.0 integration architecture + - Agent definition with query_cv tool + - HTMX chat widget implementation + - Graceful degradation pattern + - Example conversations and security considerations + ## Quick Navigation ### By Feature diff --git a/doc/28-AI-CHAT-AGENT.md b/doc/28-AI-CHAT-AGENT.md new file mode 100644 index 0000000..98be3eb --- /dev/null +++ b/doc/28-AI-CHAT-AGENT.md @@ -0,0 +1,230 @@ +# 28. AI Chat Agent — ADK Go Integration + +## Overview + +The CV site includes an AI-powered conversational assistant 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), it provides instant answers by querying the same cached JSON data that renders the site. + +**Live example:** A visitor can ask *"How many Go projects has Juan built?"* and get an accurate answer drawn directly from the CV data — no hallucination, no stale data. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ CV Site Server │ +│ │ +│ ┌─────────────┐ ┌────────────────────────┐ │ +│ │ Data Cache │────▶│ ADK Go Agent │ │ +│ │ (cv-en.json) │ │ ┌──────────────────┐ │ │ +│ │ (cv-es.json) │ │ │ cv_assistant │ │ │ +│ └─────────────┘ │ │ (LLM Agent) │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ Tools: │ │ │ +│ │ │ │ ├─ query_cv │ │ │ +│ │ │ │ │ (section+query) │ │ │ +│ │ │ └──────────────────┘ │ │ +│ │ └───────────┬────────────┘ │ +│ │ │ │ +│ ┌──────▼─────────────────────────▼──────────┐ │ +│ │ POST /api/chat │ │ +│ │ (chat.Handler) │ │ +│ │ ├─ Session management │ │ +│ │ ├─ ADK Runner execution │ │ +│ │ └─ HTML fragment response (HTMX) │ │ +│ └────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ hx-post │ +│ ┌──────────────────────┴─────────────────────┐ │ +│ │ Chat Widget (HTMX + Hyperscript) │ │ +│ │ ├─ Floating chat icon │ │ +│ │ ├─ Expandable panel │ │ +│ │ ├─ Message history │ │ +│ │ └─ Session persistence │ │ +│ └────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Gemini 2.5 Flash │ + │ (Google AI) │ + └──────────────────┘ +``` + +## How It Works + +### 1. Agent Definition (`internal/chat/agent.go`) + +A single LLM agent (`cv_assistant`) with one tool (`query_cv`): + +```go +llmagent.New(llmagent.Config{ + Name: "cv_assistant", + Model: llm, + Instruction: `You answer questions about the CV owner's experience, + projects, skills, education, and career. + Use the query_cv tool to look up CV data before answering. + Answer in the SAME LANGUAGE the user writes in.`, + Tools: []tool.Tool{queryTool}, +}) +``` + +**Why a single agent?** The CV data is structured and bounded — there's no need for multi-agent orchestration. One agent with one tool is the right abstraction: simple, fast, predictable. + +### 2. The `query_cv` Tool + +The tool accepts two parameters: +- **`section`** — which CV section to search: `experience`, `projects`, `skills`, `education`, `languages`, `certifications`, `courses`, `awards`, `summary`, or `all` +- **`query`** — keyword filter (e.g., "Go", "React", "2019", "Olympic") + +The tool reads from the same `cache.DataCache` that powers the website rendering — zero additional I/O, zero data duplication. + +**Filtering logic:** Case-insensitive keyword matching across all relevant fields in each section (title, company, technologies, descriptions, responsibilities). + +### 3. HTTP Handler (`internal/chat/handler.go`) + +``` +POST /api/chat +Content-Type: application/x-www-form-urlencoded + +message=How many Go projects has Juan built? +session_id= +``` + +**Response:** HTML fragment for HTMX swap: +```html +
    How many Go projects has Juan built?
    +
    +

    Juan has built 2 projects that use Go:

    +
      +
    • Immich Photo Manager - AI-Powered Photo Library MCP Server
    • +
    • Cmux Resurrect - Terminal Session Persistence Tool
    • +
    +
    + +``` + +**Session management:** ADK Go's in-memory session service maintains conversation context. The session ID is preserved via a hidden form input, enabling follow-up questions. + +### 4. Chat Widget (HTMX + Hyperscript) + +The UI is a floating chat panel that follows the site's existing widget pattern: + +```html + + + + +
    + +
    +``` + +**Key HTMX attributes:** +- `hx-post="/api/chat"` — sends message to the agent +- `hx-target="#chat-messages"` — appends response to chat history +- `hx-swap="beforeend scroll:bottom"` — auto-scrolls to latest message +- `hx-indicator="#chat-spinner"` — shows loading spinner during request + +## Graceful Degradation + +The chat feature is entirely optional. When `GOOGLE_API_KEY` is not set: + +1. `chat.NewHandler()` returns a disabled handler +2. `CVHandler` receives `chatEnabled: false` +3. Template data includes `ChatEnabled: false` +4. The chat widget template renders nothing (`{{if .ChatEnabled}}...{{end}}`) +5. No JavaScript errors, no broken UI, no hidden network requests + +**Zero impact on the site when disabled.** + +## Configuration + +### Required + +```bash +# .env +GOOGLE_API_KEY=your-gemini-api-key # From https://aistudio.google.com/apikey +``` + +### Optional + +```bash +MODEL_NAME=gemini-2.5-flash # Default model (free tier) +``` + +### Cost + +Gemini 2.5 Flash free tier: **15 requests/minute**, no credit card needed. Each chat message = 1 request. For a personal CV site, this is more than sufficient. + +## Example Conversations + +### English + +| Question | Answer | +|----------|--------| +| "How many years of experience?" | "Juan has 21 years of professional experience, starting in April 2005." | +| "What Go projects has he built?" | Lists Immich Photo Manager and Cmux Resurrect with descriptions | +| "Has he worked with React?" | Lists companies where React was used (Olympic Broadcasting, LIV Golf, etc.) | +| "Tell me about his time at SAP" | Pulls SAP experience entry with responsibilities and technologies | +| "What certifications does he have?" | Lists SAP CDC Full Training, SAP Cloud Platform, GDPR Compliance | + +### Spanish + +| Pregunta | Respuesta | +|----------|-----------| +| "¿En cuántas empresas ha trabajado?" | Lista las 11 empresas con nombres | +| "¿Qué tecnologías domina?" | Categorías de skills con proficiency levels | +| "¿Tiene experiencia con autenticación?" | Detalla SAP CDC, Gigya, sistemas de auth | + +## File Structure + +``` +internal/chat/ +├── agent.go # LLM agent + query_cv tool + filter helpers +└── handler.go # HTTP handler + session management + response rendering + +templates/partials/widgets/ +└── chat-widget.html # HTMX chat panel template + +static/css/04-interactive/ +└── _chat.css # Chat UI styles (responsive, dark theme) +``` + +## Dependencies Added + +| Package | Purpose | Size Impact | +|---------|---------|-------------| +| `google.golang.org/adk` | Agent framework (runner, session, tools) | ~2 MB binary increase | +| `google.golang.org/genai` | Gemini API client | Included with ADK | + +## Security Considerations + +- **No personal data exposure:** The agent instruction explicitly prohibits revealing email, phone, or other contact details — it directs visitors to the contact form instead +- **Input sanitization:** User messages are HTML-escaped before rendering +- **Response sanitization:** Agent responses go through `formatResponse()` which escapes HTML then applies safe markdown-to-HTML conversion +- **Rate limiting:** The `/api/chat` endpoint inherits the site's middleware chain (recovery, logging, security headers) +- **Session isolation:** Each visitor gets an independent in-memory session; sessions are ephemeral and not persisted to disk + +## ADK Go Concepts Used + +| Concept | Usage | +|---------|-------| +| `llmagent.New` | Creates the CV assistant agent with instruction and tools | +| `functiontool.New` | Wraps the `query_cv` Go function as an agent-callable tool | +| `runner.Runner` | Executes the agent within the HTTP handler | +| `session.InMemoryService` | Maintains conversation context per visitor | +| `genai.NewContentFromText` | Converts user message to ADK content format | +| `event.IsFinalResponse()` | Extracts the agent's final answer from the event stream | +| `agent.RunConfig{}` | Default run configuration (non-streaming) | + +## Relation to Other Documentation + +- **[01-ARCHITECTURE.md](01-ARCHITECTURE.md)** — Overall system design +- **[03-API.md](03-API.md)** — HTTP API reference (includes `/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 From eddc4249620596caeb48519935e2b1978e18f68b Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 00:44:16 +0100 Subject: [PATCH 03/24] fix: cross-section search and CSS loading for chat widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent.go: add section="search" that queries experience, projects, skills, and courses simultaneously — fixes missing results when a technology spans multiple CV sections (e.g. Java at Insa) - head-styles.html: use modular CSS in development mode and load chat CSS separately — fixes unstyled page when bundle is stale --- internal/chat/agent.go | 40 ++++++++++++++++++---- templates/partials/layout/head-styles.html | 11 ++++-- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/internal/chat/agent.go b/internal/chat/agent.go index 6bde9f3..d60f901 100644 --- a/internal/chat/agent.go +++ b/internal/chat/agent.go @@ -33,6 +33,7 @@ You answer questions about the CV owner's experience, projects, skills, educatio RULES: - Use the query_cv tool to look up CV data before answering. Never make up information. +- For technology questions (e.g. "Java", "Go", "React"), ALWAYS use section="search" — this searches across experience, projects, courses, and skills simultaneously. Do NOT search only projects or only experience. - Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish. - Be concise and direct — visitors want quick answers, not essays. - When listing items (projects, technologies, companies), use bullet points. @@ -42,18 +43,19 @@ RULES: - You represent the CV owner professionally — be friendly but not overly casual. EXAMPLES of questions you might receive: -- "How many years of experience does Juan have?" -- "What Go projects has he built?" -- "Has he worked with React?" -- "Tell me about his time at Olympic Broadcasting" -- "What certifications does he have?"`, +- "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 certifications does he have?" → section="certifications" +- "List all his projects" → section="projects"`, 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: 'experience', 'projects', 'skills', 'education', 'languages', 'certifications', 'courses', 'awards', 'summary', 'all'"` + 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'."` } @@ -71,6 +73,7 @@ func newQueryCVTool(dataCache *cache.DataCache) (tool.Tool, error) { 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 @@ -128,6 +131,31 @@ Always call this tool before answering CV-related questions.`, 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{ diff --git a/templates/partials/layout/head-styles.html b/templates/partials/layout/head-styles.html index 8e9a612..e9de501 100644 --- a/templates/partials/layout/head-styles.html +++ b/templates/partials/layout/head-styles.html @@ -1,7 +1,14 @@ {{define "head-styles"}} - - + {{if .IsProduction}} + + {{else}} + + + {{end}} + {{if .ChatEnabled}} + + {{end}} {{end}} From 55968e022df8bab0bbfcc451d4c5edcdb3603906 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 10:36:52 +0100 Subject: [PATCH 04/24] fix: move chat button to left side matching existing button stack Chat button and panel now anchor from left: 2rem to match the zoom, shortcuts, and other fixed buttons. Panel opens rightward so content is always visible. --- static/css/04-interactive/_chat.css | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 2866bc5..f997480 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -3,14 +3,14 @@ Floating AI chat panel for CV questions ============================================================================ */ -/* Toggle Button */ +/* Toggle Button — left side, above the other fixed buttons */ .chat-toggle-btn { position: fixed; - bottom: 100px; - right: 24px; + bottom: 14rem; + left: 2rem; z-index: 1000; - width: 48px; - height: 48px; + width: 50px; + height: 50px; border-radius: 50%; background: var(--accent-color, #2563eb); color: #fff; @@ -20,20 +20,23 @@ align-items: center; justify-content: center; font-size: 1.4rem; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); - transition: transform 0.2s, background 0.2s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + opacity: 0.6; } .chat-toggle-btn:hover { - transform: scale(1.1); + opacity: 1; + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); background: var(--accent-color-hover, #1d4ed8); } -/* Panel */ +/* Panel — anchored from the left, above the button */ .chat-panel { position: fixed; - bottom: 160px; - right: 24px; + bottom: 18.5rem; + left: 2rem; width: 380px; max-height: 500px; background: var(--bg-primary, #fff); @@ -213,16 +216,16 @@ @media (max-width: 480px) { .chat-panel { bottom: 0; - right: 0; left: 0; + right: 0; width: 100%; max-height: 60vh; border-radius: 12px 12px 0 0; } .chat-toggle-btn { - bottom: 80px; - right: 16px; + bottom: 12rem; + left: 1rem; } } From b0e8e1ced74b270e5488bcd609fff137ea9801a2 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 10:49:19 +0100 Subject: [PATCH 05/24] feat: evolve chat into CV Assistant mascot with help popup and suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mascot identity: robot-happy-outline icon, "CV Assistant" branding - Help popup: onboarding card explaining what the mascot can do (EN/ES) - Suggested questions: 5 clickable chips that auto-submit (bilingual) - Typing indicator: three bouncing dots during agent response - Icon swap: mascot icon ↔ close icon via Hyperscript class toggle - Dark theme support for all new elements - Modular CSS loading in development, chat CSS always loaded separately --- static/css/04-interactive/_chat.css | 267 +++++++++++++++++--- templates/partials/widgets/chat-widget.html | 92 ++++++- 2 files changed, 313 insertions(+), 46 deletions(-) diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index f997480..45cb762 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -1,9 +1,12 @@ /* ============================================================================ - CHAT WIDGET + CHAT WIDGET — CV Assistant Mascot Floating AI chat panel for CV questions ============================================================================ */ -/* Toggle Button — left side, above the other fixed buttons */ +/* ========================================================================== + Toggle Button — left side, in the fixed button column + ========================================================================== */ + .chat-toggle-btn { position: fixed; bottom: 14rem; @@ -32,13 +35,34 @@ background: var(--accent-color-hover, #1d4ed8); } -/* Panel — anchored from the left, above the button */ +/* 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-color, #2563eb); +} + +.chat-toggle-btn.mascot-active .chat-icon-open { + display: none; +} + +.chat-toggle-btn.mascot-active .chat-icon-close { + display: inline-block; +} + +/* ========================================================================== + Panel — anchored from the left, above the button + ========================================================================== */ + .chat-panel { position: fixed; bottom: 18.5rem; left: 2rem; width: 380px; - max-height: 500px; + max-height: 520px; background: var(--bg-primary, #fff); border: 1px solid var(--border-color, #e2e8f0); border-radius: 12px; @@ -53,7 +77,10 @@ display: flex; } -/* Header */ +/* ========================================================================== + Header + ========================================================================== */ + .chat-header { display: flex; align-items: center; @@ -69,8 +96,25 @@ font-size: 1.2rem; } -.chat-close-btn { +.chat-help-btn { margin-left: auto; + background: none; + border: none; + color: #fff; + cursor: pointer; + font-size: 1.1rem; + opacity: 0.7; + transition: opacity 0.2s; + display: flex; + align-items: center; + padding: 0; +} + +.chat-help-btn:hover { + opacity: 1; +} + +.chat-close-btn { background: none; border: none; color: #fff; @@ -80,13 +124,73 @@ transition: opacity 0.2s; display: flex; align-items: center; + padding: 0; } .chat-close-btn:hover { opacity: 1; } -/* Messages Area */ +/* ========================================================================== + Help / Onboarding Card + ========================================================================== */ + +.chat-help-card { + display: none; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 16px 20px; + margin: 12px; + background: var(--bg-secondary, #f1f5f9); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 10px; + text-align: center; + animation: helpFadeIn 0.25s ease; +} + +.chat-help-card.visible { + display: flex; +} + +@keyframes helpFadeIn { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-help-icon { + font-size: 2rem; + color: var(--accent-color, #2563eb); + line-height: 1; +} + +.chat-help-text { + font-size: 0.8rem; + line-height: 1.5; + color: var(--text-secondary, #475569); + margin: 0; +} + +.chat-help-dismiss { + background: var(--accent-color, #2563eb); + color: #fff; + border: none; + border-radius: 6px; + padding: 5px 16px; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.chat-help-dismiss:hover { + background: var(--accent-color-hover, #1d4ed8); +} + +/* ========================================================================== + Messages Area + ========================================================================== */ + .chat-messages { flex: 1; overflow-y: auto; @@ -94,8 +198,8 @@ display: flex; flex-direction: column; gap: 12px; - max-height: 340px; - min-height: 120px; + max-height: 260px; + min-height: 80px; } /* Message Bubbles */ @@ -147,12 +251,91 @@ border: 1px solid #fecaca; } -/* Input Area */ +/* ========================================================================== + Typing Indicator + ========================================================================== */ + +.chat-typing { + display: none; + align-items: center; + gap: 4px; + padding: 8px 16px; +} + +/* Show when HTMX request is in-flight */ +.chat-typing.htmx-request { + display: flex; +} + +.chat-typing-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-secondary, #94a3b8); + 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: 6px; + padding: 8px 12px; + overflow-x: auto; + border-top: 1px solid var(--border-color, #e2e8f0); + scrollbar-width: none; /* Firefox */ +} + +.chat-suggestions::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + +.chat-chip { + flex-shrink: 0; + background: var(--bg-secondary, #f1f5f9); + color: var(--text-primary, #1e293b); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 16px; + padding: 4px 12px; + font-size: 0.72rem; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + line-height: 1.4; +} + +.chat-chip:hover { + background: var(--accent-color, #2563eb); + color: #fff; + border-color: var(--accent-color, #2563eb); +} + +/* ========================================================================== + Input Area + ========================================================================== */ + .chat-input-area { display: flex; align-items: center; gap: 8px; - padding: 12px; + padding: 10px 12px; border-top: 1px solid var(--border-color, #e2e8f0); background: var(--bg-primary, #fff); } @@ -193,33 +376,17 @@ background: var(--accent-color-hover, #1d4ed8); } -/* Spinner */ -.chat-spinner { - display: none; -} +/* ========================================================================== + Responsive + ========================================================================== */ -.chat-spinner.htmx-request { - display: flex; - align-items: center; -} - -.spin { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -/* Responsive */ @media (max-width: 480px) { .chat-panel { bottom: 0; left: 0; right: 0; width: 100%; - max-height: 60vh; + max-height: 70vh; border-radius: 12px 12px 0 0; } @@ -227,9 +394,16 @@ bottom: 12rem; left: 1rem; } + + .chat-messages { + max-height: 200px; + } } -/* Theme: Clean (dark) */ +/* ========================================================================== + Dark Theme Support (theme-clean) + ========================================================================== */ + .theme-clean .chat-panel { background: #1e293b; border-color: #334155; @@ -256,3 +430,32 @@ border-color: #334155; color: #e2e8f0; } + +.theme-clean .chat-help-card { + background: #334155; + border-color: #475569; +} + +.theme-clean .chat-help-text { + color: #cbd5e1; +} + +.theme-clean .chat-suggestions { + border-top-color: #334155; +} + +.theme-clean .chat-chip { + background: #334155; + color: #e2e8f0; + border-color: #475569; +} + +.theme-clean .chat-chip:hover { + background: var(--accent-color, #2563eb); + color: #fff; + border-color: var(--accent-color, #2563eb); +} + +.theme-clean .chat-typing-dot { + background: #64748b; +} diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 5299a37..01d69c2 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -1,41 +1,108 @@ {{define "chat-widget"}} {{if .ChatEnabled}} - +
    - - {{if eq .Lang "es"}}Pregunta sobre este CV{{else}}Ask about this CV{{end}} + + {{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}} +
    + +
    +
    + +
    +

    + {{if eq .Lang "es"}}¡Soy el Asistente del CV! Puedo responder preguntas sobre la experiencia profesional, proyectos, habilidades y formación de Juan. Prueba a hacer clic en una sugerencia o escribe tu propia pregunta.{{else}}I'm the CV Assistant! I can answer questions about Juan's professional experience, projects, skills, and education. Try clicking a suggestion or type your own question.{{end}} +

    + +
    +
    - {{if eq .Lang "es"}}¡Hola! Puedo responder preguntas sobre este currículum. Prueba a preguntar sobre experiencia, proyectos, tecnologías o formación.{{else}}Hi! I can answer questions about this CV. Try asking about experience, projects, technologies, or education.{{end}} + {{if eq .Lang "es"}}¡Hola! Pregúntame lo que quieras sobre este CV.{{else}}Hi! Ask me anything about this CV.{{end}}
    + +
    + + + +
    + + +
    + {{if eq .Lang "es"}} + + + + + + {{else}} + + + + + + {{end}} +
    +
    @@ -44,15 +111,12 @@ id="chat-input" name="message" class="chat-input" - placeholder="{{if eq .Lang "es"}}Escribe una pregunta...{{else}}Type a question...{{end}}" + placeholder="{{if eq .Lang "es"}}Pregunta algo sobre el CV...{{else}}Ask something about the CV...{{end}}" autocomplete="off" required> -
    - -
    {{end}} From 93e33f64968e09f009de83d7257c708921a088d9 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 11:31:09 +0100 Subject: [PATCH 06/24] fix: chat submission and session handling + 37 Playwright tests - Fix chip auto-submit: use htmx.trigger() instead of native submit to bypass browser validation on required field - Remove required from input (server validates already) - Fix session_id duplication: use hx-swap-oob to replace single input - Fix agent context: use background context with 30s timeout instead of HTTP request context (prevents premature cancellation) - Remove redundant close button from header (toggle button handles it) - Add 83-chat-mascot.test.mjs: 37 tests covering button, panel, help card, chips, typed questions, session, Spanish, positioning --- internal/chat/handler.go | 12 +- templates/partials/widgets/chat-widget.html | 50 ++- tests/mjs/83-chat-mascot.test.mjs | 375 ++++++++++++++++++++ 3 files changed, 405 insertions(+), 32 deletions(-) create mode 100644 tests/mjs/83-chat-mascot.test.mjs diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 77fa9f2..1dd296b 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/juanatsap/cv-site/internal/cache" @@ -132,11 +133,14 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { sessionID = created.Session.ID() } - // Run the agent + // Run the agent with a dedicated context (not tied to HTTP request lifecycle) + agentCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + userMsg := genai.NewContentFromText(message, genai.RoleUser) var response strings.Builder - for event, err := range h.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) { + for event, err := range h.runner.Run(agentCtx, "visitor", sessionID, userMsg, agent.RunConfig{}) { if err != nil { log.Printf("Chat agent error: %v", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -168,8 +172,8 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { } _, _ = fmt.Fprintf(w, `
    %s
    `, formatResponse(agentText)) - // Hidden input to preserve session ID for next request - _, _ = fmt.Fprintf(w, ``, sessionID) + // Update session ID via OOB swap (replaces existing input, avoids duplicates) + _, _ = fmt.Fprintf(w, ``, sessionID) } // formatResponse converts basic markdown to HTML for the chat bubble. diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 01d69c2..aba8032 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -27,11 +27,6 @@ _="on click toggle .visible on #chat-help-card"> - @@ -64,37 +59,37 @@
    {{if eq .Lang "es"}} - - + - + - + - + + then call htmx.trigger(#chat-form, 'submit')">¿Certificaciones? {{else}} - - + - + - + - + + then call htmx.trigger(#chat-form, 'submit')">Certifications? {{end}}
    @@ -104,7 +99,7 @@ hx-swap="beforeend scroll:#chat-messages:bottom" hx-indicator="#chat-typing" _="on htmx:afterRequest set #chat-input.value to ''"> - + + autocomplete="off"> diff --git a/tests/mjs/83-chat-mascot.test.mjs b/tests/mjs/83-chat-mascot.test.mjs new file mode 100644 index 0000000..7060c69 --- /dev/null +++ b/tests/mjs/83-chat-mascot.test.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env bun +/** + * CV ASSISTANT MASCOT TEST + * ========================= + * Tests the AI chat widget (mascot) functionality + * - Widget visibility and toggle + * - Help popup display and dismiss + * - Suggested question chips + * - Text input and form submission + * - Chat messages rendering + * - Session persistence + * - Language awareness + * - Responsive behavior + */ + +import { chromium } from 'playwright'; + +const URL = "http://localhost:1999"; +const CHAT_TIMEOUT = 15000; // Agent responses can take a few seconds + +async function testChatMascot() { + console.log('🤖 CV ASSISTANT MASCOT TEST\n'); + console.log('='.repeat(70)); + + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + + const errors = []; + const testResults = []; + let passed = 0; + let failed = 0; + + function record(name, success, detail = '') { + testResults.push({ name, success, detail }); + if (success) { + passed++; + console.log(` ✅ ${name}${detail ? ' — ' + detail : ''}`); + } else { + failed++; + console.log(` ❌ ${name}${detail ? ' — ' + detail : ''}`); + } + } + + page.on('console', msg => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + // ======================================================================== + // SETUP: Load page + // ======================================================================== + console.log("\n📋 Loading page..."); + await page.goto(`${URL}/?lang=en`); + await page.waitForTimeout(2000); + + // ======================================================================== + // TEST 1: Mascot button exists and is visible + // ======================================================================== + console.log("\n1️⃣ Mascot Button Presence"); + + const btnExists = await page.locator('#chat-toggle-btn').isVisible(); + record('Mascot toggle button is visible', btnExists); + + const btnIcon = await page.locator('#chat-toggle-btn .chat-icon-open').isVisible(); + record('Mascot icon (robot) is visible', btnIcon); + + const closeIconHidden = await page.locator('#chat-toggle-btn .chat-icon-close').isHidden(); + record('Close icon is hidden initially', closeIconHidden); + + // ======================================================================== + // TEST 2: Chat panel starts closed + // ======================================================================== + console.log("\n2️⃣ Initial State"); + + const panelHidden = await page.locator('#chat-panel').isHidden(); + record('Chat panel is hidden initially', panelHidden); + + // ======================================================================== + // TEST 3: Click mascot opens chat panel + // ======================================================================== + console.log("\n3️⃣ Open Chat Panel"); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(500); + + const panelOpen = await page.locator('#chat-panel').isVisible(); + record('Chat panel opens on click', panelOpen); + + const hasMascotActive = await page.locator('#chat-toggle-btn.mascot-active').count(); + record('Button has mascot-active class', hasMascotActive > 0); + + // ======================================================================== + // TEST 4: Help card is visible on first open + // ======================================================================== + console.log("\n4️⃣ Help Card (Onboarding)"); + + const helpVisible = await page.locator('#chat-help-card.visible').isVisible(); + record('Help card is visible on first open', helpVisible); + + const helpText = await page.locator('.chat-help-text').textContent(); + record('Help text contains assistant description', + helpText.includes('CV Assistant') || helpText.includes('Asistente')); + + const dismissBtn = await page.locator('.chat-help-dismiss').isVisible(); + record('Dismiss button is visible', dismissBtn); + + // ======================================================================== + // TEST 5: Dismiss help card + // ======================================================================== + console.log("\n5️⃣ Dismiss Help Card"); + + await page.click('.chat-help-dismiss'); + await page.waitForTimeout(300); + + const helpDismissed = await page.locator('#chat-help-card.visible').count(); + record('Help card dismissed after click', helpDismissed === 0); + + // ======================================================================== + // TEST 6: Help button re-toggles help card + // ======================================================================== + console.log("\n6️⃣ Re-toggle Help Card"); + + await page.click('.chat-help-btn'); + await page.waitForTimeout(300); + + const helpReOpened = await page.locator('#chat-help-card.visible').isVisible(); + record('Help card re-opens via ? button', helpReOpened); + + // Dismiss again for clean state + await page.click('.chat-help-dismiss'); + await page.waitForTimeout(200); + + // ======================================================================== + // TEST 7: Initial welcome message exists + // ======================================================================== + console.log("\n7️⃣ Welcome Message"); + + const welcomeMsg = await page.locator('#chat-messages .chat-agent').first().textContent(); + record('Welcome message is present', + welcomeMsg.includes('Ask me anything') || welcomeMsg.includes('Pregúntame')); + + // ======================================================================== + // TEST 8: Suggested question chips exist + // ======================================================================== + console.log("\n8️⃣ Suggested Question Chips"); + + const chipCount = await page.locator('.chat-chip').count(); + record('5 suggested question chips exist', chipCount === 5, `found ${chipCount}`); + + const firstChipText = await page.locator('.chat-chip').first().textContent(); + record('First chip has text', firstChipText.length > 0, firstChipText); + + // ======================================================================== + // TEST 9: Text input exists and is functional + // ======================================================================== + console.log("\n9️⃣ Text Input"); + + const inputExists = await page.locator('#chat-input').isVisible(); + record('Chat input is visible', inputExists); + + const placeholder = await page.locator('#chat-input').getAttribute('placeholder'); + record('Input has placeholder', + placeholder.includes('Ask something') || placeholder.includes('Pregunta')); + + // ======================================================================== + // TEST 10: Send button exists + // ======================================================================== + console.log("\n🔟 Send Button"); + + const sendBtn = await page.locator('.chat-send-btn').isVisible(); + record('Send button is visible', sendBtn); + + // ======================================================================== + // TEST 11: Chip click fills input and submits + // ======================================================================== + console.log("\n1️⃣1️⃣ Chip Click → Submit"); + + const msgCountBefore = await page.locator('#chat-messages .chat-message').count(); + + await page.click('.chat-chip >> text=Go projects'); + // Wait for the response (agent call takes time) + await page.waitForSelector('#chat-messages .chat-user', { timeout: CHAT_TIMEOUT }); + + const userMsg = await page.locator('#chat-messages .chat-user').last().textContent(); + record('User message appears after chip click', + userMsg.includes('Go projects'), userMsg.substring(0, 50)); + + // Wait for agent response + await page.waitForSelector('#chat-messages .chat-agent:nth-child(3)', { timeout: CHAT_TIMEOUT }); + + const agentMsg = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Agent response appears', + agentMsg.length > 20, `${agentMsg.substring(0, 60)}...`); + + record('Agent mentions Go projects', + agentMsg.toLowerCase().includes('go') || agentMsg.toLowerCase().includes('immich') || agentMsg.toLowerCase().includes('cmux')); + + // ======================================================================== + // TEST 12: Type and submit custom question + // ======================================================================== + console.log("\n1️⃣2️⃣ Type Custom Question"); + + const userMsgCountBefore = await page.locator('#chat-messages .chat-user').count(); + await page.fill('#chat-input', 'How many years of experience?'); + await page.click('.chat-send-btn'); + + // Wait for a new user message to appear + await page.waitForFunction( + (count) => document.querySelectorAll('#chat-messages .chat-user').length > count, + userMsgCountBefore, + { timeout: CHAT_TIMEOUT } + ); + const customUserMsg = await page.locator('#chat-messages .chat-user').last().textContent(); + record('Custom typed message appears', + customUserMsg.includes('years of experience')); + + // Wait for agent response + await page.waitForFunction(() => { + const msgs = document.querySelectorAll('#chat-messages .chat-agent'); + return msgs.length >= 3; + }, { timeout: CHAT_TIMEOUT }); + + const customAgentMsg = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Agent responds to custom question', + customAgentMsg.length > 10, `${customAgentMsg.substring(0, 60)}...`); + + // ======================================================================== + // TEST 13: Input clears after submit + // ======================================================================== + console.log("\n1️⃣3️⃣ Input Clear After Submit"); + + const inputValueAfter = await page.locator('#chat-input').inputValue(); + record('Input is cleared after submission', inputValueAfter === ''); + + // ======================================================================== + // TEST 14: Session ID persisted + // ======================================================================== + console.log("\n1️⃣4️⃣ Session Persistence"); + + const sessionId = await page.locator('#chat-session-id').inputValue(); + record('Session ID is set after response', + sessionId.length > 0, sessionId.substring(0, 20) + '...'); + + // ======================================================================== + // TEST 15: Close and reopen panel + // ======================================================================== + console.log("\n1️⃣5️⃣ Close and Reopen"); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + + const panelClosedAgain = await page.locator('#chat-panel').isHidden(); + record('Panel closes on second toggle click', panelClosedAgain); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + + const panelReopened = await page.locator('#chat-panel').isVisible(); + record('Panel reopens on third click', panelReopened); + + // Messages should still be there + const msgCountAfterReopen = await page.locator('#chat-messages .chat-message').count(); + record('Messages preserved after reopen', msgCountAfterReopen >= 3, + `${msgCountAfterReopen} messages`); + + // ======================================================================== + // TEST 16: Chat header content + // ======================================================================== + console.log("\n1️⃣6️⃣ Header Content"); + + const headerText = await page.locator('.chat-header span').textContent(); + record('Header shows "CV Assistant"', + headerText.includes('CV Assistant') || headerText.includes('Asistente')); + + const helpBtnExists = await page.locator('.chat-help-btn').isVisible(); + record('Help button (?) in header', helpBtnExists); + + // ======================================================================== + // TEST 17: Spanish language version + // ======================================================================== + console.log("\n1️⃣7️⃣ Spanish Language"); + + // Close chat first + await page.click('#chat-toggle-btn'); + 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 esHeaderText = await page.locator('.chat-header span').textContent(); + record('Spanish header shows "Asistente del CV"', + esHeaderText.includes('Asistente del CV')); + + const esChipText = await page.locator('.chat-chip').first().textContent(); + record('Spanish chips are in Spanish', + esChipText.includes('Go') || esChipText.includes('Proyectos')); + + const esWelcome = await page.locator('#chat-messages .chat-agent').first().textContent(); + record('Spanish welcome message', + esWelcome.includes('Pregúntame') || esWelcome.includes('Hola')); + + const esPlaceholder = await page.locator('#chat-input').getAttribute('placeholder'); + record('Spanish placeholder', + esPlaceholder.includes('Pregunta')); + + // ======================================================================== + // TEST 18: Empty message handling + // ======================================================================== + console.log("\n1️⃣8️⃣ Empty Message Handling"); + + const msgCountBeforeEmpty = await page.locator('#chat-messages .chat-message').count(); + await page.fill('#chat-input', ''); + await page.click('.chat-send-btn'); + await page.waitForTimeout(1000); + + const msgCountAfterEmpty = await page.locator('#chat-messages .chat-message').count(); + // Should show error or stay the same + record('Empty message handled gracefully', msgCountAfterEmpty >= msgCountBeforeEmpty); + + // ======================================================================== + // TEST 19: No console errors + // ======================================================================== + console.log("\n1️⃣9️⃣ Console Errors"); + + const chatErrors = errors.filter(e => + e.includes('chat') || e.includes('htmx') || e.includes('hyperscript')); + record('No chat-related console errors', chatErrors.length === 0, + chatErrors.length > 0 ? chatErrors.join(', ') : 'clean'); + + // ======================================================================== + // TEST 20: Chat panel CSS positioning + // ======================================================================== + console.log("\n2️⃣0️⃣ CSS Positioning"); + + const btnPos = await page.locator('#chat-toggle-btn').boundingBox(); + record('Button is on the left half of screen', + btnPos && btnPos.x < 200, `x=${btnPos?.x}`); + + const panelPos = await page.locator('#chat-panel').boundingBox(); + record('Panel is on the left side', + panelPos && panelPos.x < 200, `x=${panelPos?.x}`); + + // ======================================================================== + // SUMMARY + // ======================================================================== + console.log('\n' + '='.repeat(70)); + console.log(`\n📊 RESULTS: ${passed} passed, ${failed} failed (${testResults.length} total)\n`); + + if (failed > 0) { + console.log('❌ FAILED TESTS:'); + testResults.filter(t => !t.success).forEach(t => { + console.log(` • ${t.name}${t.detail ? ' — ' + t.detail : ''}`); + }); + console.log(''); + } + + if (errors.length > 0) { + console.log(`⚠️ Console errors: ${errors.length}`); + errors.forEach(e => console.log(` • ${e}`)); + } + + await browser.close(); + + console.log(failed === 0 ? '✅ ALL TESTS PASSED!' : '❌ SOME TESTS FAILED'); + process.exit(failed > 0 ? 1 : 0); +} + +testChatMascot().catch(err => { + console.error('💥 Test crashed:', err.message); + process.exit(1); +}); From 795ba88d6fbd4a46ab3ffb75f5f2c7d611d3d37d Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 13:04:47 +0100 Subject: [PATCH 07/24] fix: match CV design system, right-side positioning, smarter agent CSS: - Button moved to right: 2rem, above back-to-top (bottom: 6rem) - Uses CV design tokens: --black-bar, --accent-blue, --paper-bg - Fonts: Quicksand (header), Source Sans Pro (body) - Tooltip on the left side (tooltip-left class) - Dark theme uses CV-consistent grays Intelligence: - Agent instruction emphasizes exhaustive reporting of ALL matches - Cross-section search results must not be truncated - Mentions CV site itself is built with Go when relevant Tests: - Updated positioning assertions (right side, x > viewport/2) - Added 5 intelligence tests: Go cross-section, company count, years of experience, React cross-section, Spanish response - Resilient to API errors (waits for any message, not just user) - 42 total test assertions --- internal/chat/agent.go | 10 +- static/css/04-interactive/_chat.css | 275 ++++++++++---------- templates/partials/widgets/chat-widget.html | 2 +- tests/mjs/83-chat-mascot.test.mjs | 121 ++++++++- 4 files changed, 253 insertions(+), 155 deletions(-) diff --git a/internal/chat/agent.go b/internal/chat/agent.go index d60f901..58df61c 100644 --- a/internal/chat/agent.go +++ b/internal/chat/agent.go @@ -33,12 +33,14 @@ You answer questions about the CV owner's experience, projects, skills, educatio RULES: - Use the query_cv tool to look up CV data before answering. Never make up information. -- For technology questions (e.g. "Java", "Go", "React"), ALWAYS use section="search" — this searches across experience, projects, courses, and skills simultaneously. Do NOT search only projects or only experience. +- For technology questions (e.g. "Java", "Go", "React"), ALWAYS use section="search" — this searches across experience, projects, courses, and skills simultaneously. Do NOT search only projects or only experience. Always report ALL matches from every section. +- When reporting results, be EXHAUSTIVE. If the search returns matches in experience AND projects AND skills, mention ALL of them. Never truncate or summarize away matches. - Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish. -- Be concise and direct — visitors want quick answers, not essays. +- Be concise but complete — list every relevant item found, don't skip any. - When listing items (projects, technologies, companies), use bullet points. -- If the query_cv tool returns no results for a question, say so honestly. -- You may reference sections of the CV (e.g., "See the Projects section") to guide the visitor. +- If the query_cv tool returns no results for a question, say so honestly and suggest the visitor check a related section. +- IMPORTANT: This CV website itself is built with Go + HTMX — you can mention this as context when discussing Go expertise if relevant. +- You may reference sections of the CV to guide the visitor. - Never reveal personal contact details (email, phone) — just point them to the contact form. - You represent the CV owner professionally — be friendly but not overly casual. diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 45cb762..2bee29c 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -1,22 +1,24 @@ /* ============================================================================ CHAT WIDGET — CV Assistant Mascot - Floating AI chat panel for CV questions + Uses CV design tokens from _variables.css + Position: right side, just above back-to-top button (right: 2rem) ============================================================================ */ /* ========================================================================== - Toggle Button — left side, in the fixed button column + 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: 14rem; - left: 2rem; + bottom: 6rem; + right: 2rem; z-index: 1000; width: 50px; height: 50px; border-radius: 50%; - background: var(--accent-color, #2563eb); - color: #fff; + background: var(--black-bar, #2b2b2b); + color: white; border: none; cursor: pointer; display: flex; @@ -25,14 +27,14 @@ font-size: 1.4rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); transition: all 0.3s ease; - opacity: 0.6; + 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-color-hover, #1d4ed8); + background: var(--accent-blue, #0066cc); } /* Icon swap: show mascot by default, close when active */ @@ -42,7 +44,7 @@ .chat-toggle-btn.mascot-active { opacity: 1; - background: var(--accent-color, #2563eb); + background: var(--accent-blue, #0066cc); } .chat-toggle-btn.mascot-active .chat-icon-open { @@ -54,23 +56,24 @@ } /* ========================================================================== - Panel — anchored from the left, above the button + Panel — right side, above the button ========================================================================== */ .chat-panel { position: fixed; - bottom: 18.5rem; - left: 2rem; - width: 380px; - max-height: 520px; - background: var(--bg-primary, #fff); - border: 1px solid var(--border-color, #e2e8f0); - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + 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; } .chat-panel.chat-open { @@ -78,57 +81,40 @@ } /* ========================================================================== - Header + Header — uses black-bar like action bar ========================================================================== */ .chat-header { display: flex; align-items: center; gap: 8px; - padding: 12px 16px; - background: var(--accent-color, #2563eb); - color: #fff; - font-size: 0.9rem; + padding: 10px 14px; + background: var(--black-bar, #2b2b2b); + color: var(--action-bar-text, #ffffff); + font-family: 'Quicksand', sans-serif; + font-size: 0.85rem; font-weight: 600; } .chat-header iconify-icon { - font-size: 1.2rem; + font-size: 1.1rem; } .chat-help-btn { margin-left: auto; background: none; border: none; - color: #fff; + color: var(--action-bar-text-muted, rgba(255, 255, 255, 0.85)); cursor: pointer; - font-size: 1.1rem; - opacity: 0.7; - transition: opacity 0.2s; + font-size: 1rem; + transition: color 0.2s; display: flex; align-items: center; padding: 0; } .chat-help-btn:hover { - opacity: 1; -} - -.chat-close-btn { - background: none; - border: none; color: #fff; - cursor: pointer; - font-size: 1.1rem; - opacity: 0.8; - transition: opacity 0.2s; - display: flex; - align-items: center; - padding: 0; -} - -.chat-close-btn:hover { - opacity: 1; } /* ========================================================================== @@ -139,14 +125,14 @@ display: none; flex-direction: column; align-items: center; - gap: 10px; - padding: 16px 20px; - margin: 12px; - background: var(--bg-secondary, #f1f5f9); - border: 1px solid var(--border-color, #e2e8f0); - border-radius: 10px; + gap: 8px; + padding: 14px 16px; + margin: 10px; + background: var(--paper-secondary-bg, #f5f5f5); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 6px; text-align: center; - animation: helpFadeIn 0.25s ease; + animation: helpFadeIn 0.2s ease; } .chat-help-card.visible { @@ -154,37 +140,38 @@ } @keyframes helpFadeIn { - from { opacity: 0; transform: translateY(-6px); } + from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } .chat-help-icon { - font-size: 2rem; - color: var(--accent-color, #2563eb); + font-size: 1.6rem; + color: var(--accent-blue, #0066cc); line-height: 1; } .chat-help-text { - font-size: 0.8rem; + font-size: 0.78rem; line-height: 1.5; - color: var(--text-secondary, #475569); + color: var(--text-muted, #666666); margin: 0; } .chat-help-dismiss { - background: var(--accent-color, #2563eb); + background: var(--black-bar, #2b2b2b); color: #fff; border: none; - border-radius: 6px; - padding: 5px 16px; - font-size: 0.75rem; + border-radius: 4px; + padding: 4px 14px; + font-size: 0.72rem; font-weight: 600; cursor: pointer; transition: background 0.2s; + font-family: 'Quicksand', sans-serif; } .chat-help-dismiss:hover { - background: var(--accent-color-hover, #1d4ed8); + background: var(--accent-blue, #0066cc); } /* ========================================================================== @@ -194,19 +181,19 @@ .chat-messages { flex: 1; overflow-y: auto; - padding: 16px; + padding: 12px; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; max-height: 260px; - min-height: 80px; + min-height: 60px; } /* Message Bubbles */ .chat-message { - padding: 10px 14px; - border-radius: 12px; - font-size: 0.85rem; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.8rem; line-height: 1.5; max-width: 90%; word-wrap: break-word; @@ -222,7 +209,7 @@ .chat-message ul { margin: 4px 0; - padding-left: 18px; + padding-left: 16px; } .chat-message li { @@ -230,17 +217,17 @@ } .chat-agent { - background: var(--bg-secondary, #f1f5f9); - color: var(--text-primary, #1e293b); + background: var(--paper-secondary-bg, #f5f5f5); + color: var(--text-secondary, #333333); align-self: flex-start; - border-bottom-left-radius: 4px; + border-bottom-left-radius: 2px; } .chat-user { - background: var(--accent-color, #2563eb); + background: var(--black-bar, #2b2b2b); color: #fff; align-self: flex-end; - border-bottom-right-radius: 4px; + border-bottom-right-radius: 2px; } .chat-error { @@ -249,6 +236,7 @@ align-self: center; font-style: italic; border: 1px solid #fecaca; + font-size: 0.75rem; } /* ========================================================================== @@ -259,19 +247,18 @@ display: none; align-items: center; gap: 4px; - padding: 8px 16px; + padding: 6px 14px; } -/* Show when HTMX request is in-flight */ .chat-typing.htmx-request { display: flex; } .chat-typing-dot { - width: 6px; - height: 6px; + width: 5px; + height: 5px; border-radius: 50%; - background: var(--text-secondary, #94a3b8); + background: var(--text-light, #999999); animation: typingBounce 1.4s infinite ease-in-out both; } @@ -280,14 +267,8 @@ .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; - } + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } } /* ========================================================================== @@ -296,25 +277,22 @@ .chat-suggestions { display: flex; - gap: 6px; - padding: 8px 12px; + gap: 5px; + padding: 6px 10px; overflow-x: auto; - border-top: 1px solid var(--border-color, #e2e8f0); - scrollbar-width: none; /* Firefox */ -} - -.chat-suggestions::-webkit-scrollbar { - display: none; /* Chrome/Safari */ + flex-wrap: wrap; + border-top: 1px solid var(--border-light, #e0e0e0); } .chat-chip { flex-shrink: 0; - background: var(--bg-secondary, #f1f5f9); - color: var(--text-primary, #1e293b); - border: 1px solid var(--border-color, #e2e8f0); - border-radius: 16px; - padding: 4px 12px; - font-size: 0.72rem; + 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; @@ -322,9 +300,9 @@ } .chat-chip:hover { - background: var(--accent-color, #2563eb); + background: var(--black-bar, #2b2b2b); color: #fff; - border-color: var(--accent-color, #2563eb); + border-color: var(--black-bar, #2b2b2b); } /* ========================================================================== @@ -334,46 +312,47 @@ .chat-input-area { display: flex; align-items: center; - gap: 8px; - padding: 10px 12px; - border-top: 1px solid var(--border-color, #e2e8f0); - background: var(--bg-primary, #fff); + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border-light, #e0e0e0); + background: var(--paper-bg, #ffffff); } .chat-input { flex: 1; - padding: 8px 12px; - border: 1px solid var(--border-color, #e2e8f0); - border-radius: 20px; - font-size: 0.85rem; + 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(--bg-primary, #fff); - color: var(--text-primary, #1e293b); + background: var(--paper-bg, #ffffff); + color: var(--text-primary, #1a1a1a); transition: border-color 0.2s; } .chat-input:focus { - border-color: var(--accent-color, #2563eb); + border-color: var(--accent-blue, #0066cc); } .chat-send-btn { - background: var(--accent-color, #2563eb); + background: var(--black-bar, #2b2b2b); color: #fff; border: none; border-radius: 50%; - width: 36px; - height: 36px; + width: 32px; + height: 32px; cursor: pointer; display: flex; align-items: center; justify-content: center; - font-size: 1.1rem; + font-size: 1rem; transition: background 0.2s; flex-shrink: 0; } .chat-send-btn:hover { - background: var(--accent-color-hover, #1d4ed8); + background: var(--accent-blue, #0066cc); } /* ========================================================================== @@ -387,12 +366,12 @@ right: 0; width: 100%; max-height: 70vh; - border-radius: 12px 12px 0 0; + border-radius: 8px 8px 0 0; } .chat-toggle-btn { - bottom: 12rem; - left: 1rem; + bottom: 5rem; + right: 1rem; } .chat-messages { @@ -401,61 +380,69 @@ } /* ========================================================================== - Dark Theme Support (theme-clean) + Dark Theme (theme-clean) ========================================================================== */ .theme-clean .chat-panel { - background: #1e293b; - border-color: #334155; + background: #1a1a1a; + border-color: #333333; +} + +.theme-clean .chat-header { + background: #111111; } .theme-clean .chat-agent { - background: #334155; - color: #e2e8f0; + background: #2a2a2a; + color: #d0d0d0; +} + +.theme-clean .chat-user { + background: #333333; } .theme-clean .chat-error { - background: #450a0a; + background: #3a1010; color: #fca5a5; - border-color: #7f1d1d; + border-color: #5a1a1a; } .theme-clean .chat-input-area { - border-top-color: #334155; - background: #1e293b; + border-top-color: #333333; + background: #1a1a1a; } .theme-clean .chat-input { - background: #0f172a; - border-color: #334155; - color: #e2e8f0; + background: #111111; + border-color: #333333; + color: #d0d0d0; } .theme-clean .chat-help-card { - background: #334155; - border-color: #475569; + background: #2a2a2a; + border-color: #333333; } .theme-clean .chat-help-text { - color: #cbd5e1; + color: #999999; } .theme-clean .chat-suggestions { - border-top-color: #334155; + border-top-color: #333333; } .theme-clean .chat-chip { - background: #334155; - color: #e2e8f0; - border-color: #475569; + background: transparent; + color: #999999; + border-color: #333333; } .theme-clean .chat-chip:hover { - background: var(--accent-color, #2563eb); + background: #333333; color: #fff; - border-color: var(--accent-color, #2563eb); + border-color: #333333; } .theme-clean .chat-typing-dot { - background: #64748b; + background: #555555; } diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index aba8032..3bc8672 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -3,7 +3,7 @@ + +
    +

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

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

    + {{if eq .Lang "es"}}Un asistente con inteligencia artificial que puede responder preguntas sobre este CV. Consulta experiencia profesional, proyectos, habilidades, formación y más — todo basado en datos reales del CV.{{else}}An AI-powered assistant that can answer questions about this CV. Query professional experience, projects, skills, education, and more — all based on actual CV data.{{end}} +

    + + +
    +

    + + {{if eq .Lang "es"}}Sobre Experiencia{{else}}About Experience{{end}} +

    +
    +
    + {{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"}}¿En qué empresas ha trabajado?{{else}}What companies has he worked at?{{end}} +
    +
    + {{if eq .Lang "es"}}Cuéntame sobre su paso por Olympic Broadcasting{{else}}Tell me about his time at Olympic Broadcasting{{end}} +
    +
    + {{if eq .Lang "es"}}¿Qué hacía en SAP?{{else}}What did he do at SAP?{{end}} +
    +
    +
    + + +
    +

    + + {{if eq .Lang "es"}}Sobre Tecnologías{{else}}About Technologies{{end}} +

    +
    +
    + {{if eq .Lang "es"}}¿Qué lenguajes de programación conoce?{{else}}What programming languages does he know?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Ha trabajado con React? ¿Dónde?{{else}}Has he worked with React? Where?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Cuál es su experiencia con Go?{{else}}What's his Go experience?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Conoce Node.js?{{else}}Does he know Node.js?{{end}} +
    +
    +
    + + +
    +

    + + {{if eq .Lang "es"}}Sobre Proyectos{{else}}About Projects{{end}} +

    +
    +
    + {{if eq .Lang "es"}}¿Qué proyectos personales ha creado?{{else}}What personal projects has he built?{{end}} +
    +
    + {{if eq .Lang "es"}}Cuéntame sobre Immich Photo Manager{{else}}Tell me about the Immich Photo Manager{{end}} +
    +
    + {{if eq .Lang "es"}}¿Qué proyectos open-source mantiene?{{else}}What open-source projects does he maintain?{{end}} +
    +
    +
    + + +
    +

    + + {{if eq .Lang "es"}}Formación y Certificaciones{{else}}Education & Certifications{{end}} +

    +
    +
    + {{if eq .Lang "es"}}¿Qué certificaciones tiene?{{else}}What certifications does he have?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Dónde estudió?{{else}}Where did he study?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Qué cursos ha completado?{{else}}What courses has he completed?{{end}} +
    +
    +
    + + +
    +

    + + {{if eq .Lang "es"}}Sobre Habilidades{{else}}About Skills{{end}} +

    +
    +
    + {{if eq .Lang "es"}}¿Cuáles son sus principales habilidades técnicas?{{else}}What are his main technical skills?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Tiene experiencia con Docker?{{else}}Does he have experience with Docker?{{end}} +
    +
    + {{if eq .Lang "es"}}¿Qué hay de CI/CD?{{else}}What about CI/CD?{{end}} +
    +
    +
    + + +
    +

    + + {{if eq .Lang "es"}}Cómo funciona{{else}}How it works{{end}} +

    +
    +
    + + {{if eq .Lang "es"}}Impulsado por Google ADK Go 1.0 y Gemini AI. Las preguntas se responden consultando los datos reales del CV — sin alucinaciones, siempre preciso.{{else}}Powered by Google ADK Go 1.0 and Gemini AI. Questions are answered by querying the actual CV data — no hallucination, always accurate.{{end}} + +
    +
    +
    +
    + + +{{end}} diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 3bc8672..223977a 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -24,25 +24,12 @@ {{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}} - -
    -
    - -
    -

    - {{if eq .Lang "es"}}¡Soy el Asistente del CV! Puedo responder preguntas sobre la experiencia profesional, proyectos, habilidades y formación de Juan. Prueba a hacer clic en una sugerencia o escribe tu propia pregunta.{{else}}I'm the CV Assistant! I can answer questions about Juan's professional experience, projects, skills, and education. Try clicking a suggestion or type your own question.{{end}} -

    - -
    -
    {{if eq .Lang "es"}}¡Hola! Pregúntame lo que quieras sobre este CV.{{else}}Hi! Ask me anything about this CV.{{end}} From 069d6f860ee6adbb04aca5ff3cd096cf183dcf3b Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 13:24:29 +0100 Subject: [PATCH 09/24] fix: isolate chat button from fixed-btn stack to ensure right position - Move chat-widget outside the floating buttons section in index.html - Remove fixed-btn class from toggle button (was inheriting left-side positioning from the button stack's mobile flex layout) - Chat widget now renders independently just before body-scripts --- templates/index.html | 6 +++++- templates/partials/widgets/chat-widget.html | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/templates/index.html b/templates/index.html index ec30d09..748463b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -50,7 +50,6 @@ {{template "contact-button" .}} {{template "zoom-toggle-button" .}} {{template "shortcuts-button" .}} - {{template "chat-widget" .}} @@ -62,6 +61,11 @@ {{template "contact-modal" .}} {{template "zoom-control" .}} + + + + {{template "chat-widget" .}} + diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 223977a..852b4b1 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -3,7 +3,7 @@ + onclick="document.getElementById('chat-input').value='¿Qué proyectos en Go ha hecho?'; htmx.trigger('#chat-form', 'submit');">¿Proyectos en Go? + onclick="document.getElementById('chat-input').value='¿Cuántos años de experiencia tiene?'; htmx.trigger('#chat-form', 'submit');">¿Años de experiencia? + onclick="document.getElementById('chat-input').value='¿En qué empresas ha trabajado?'; htmx.trigger('#chat-form', 'submit');">¿Empresas? + onclick="document.getElementById('chat-input').value='¿Conoce React?'; htmx.trigger('#chat-form', 'submit');">¿Conoce React? + onclick="document.getElementById('chat-input').value='¿Qué certificaciones tiene?'; htmx.trigger('#chat-form', 'submit');">¿Certificaciones? {{else}} + onclick="document.getElementById('chat-input').value='What Go projects has he built?'; htmx.trigger('#chat-form', 'submit');">Go projects? + onclick="document.getElementById('chat-input').value='How many years of experience?'; htmx.trigger('#chat-form', 'submit');">Years of experience? + onclick="document.getElementById('chat-input').value='What companies has he worked at?'; htmx.trigger('#chat-form', 'submit');">Companies? + onclick="document.getElementById('chat-input').value='Does he know React?'; htmx.trigger('#chat-form', 'submit');">Knows React? + onclick="document.getElementById('chat-input').value='What certifications?'; htmx.trigger('#chat-form', 'submit');">Certifications? {{end}}
    From e21418b80e003c9276b3ff0b579744936abd21f3 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 13:49:39 +0100 Subject: [PATCH 12/24] fix: Hyperscript chip submit + comprehensive mascot documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chips: - Replace broken onclick with Hyperscript: `on click set #chat-input.value to '...' then trigger submit on #chat-form` — Hyperscript dispatches a native DOM submit event that HTMX intercepts correctly Documentation (doc/28-AI-CHAT-AGENT.md — complete rewrite, ~544 lines): - 18 sections covering full mascot feature reference - Architecture diagram with end-to-end flow (11 steps) - All 4 component files documented with code patterns - query_cv tool: 11 section types in tables with examples - Cross-section search mechanics explained - Agent intelligence: 8 question-type strategies - Suggested questions: chip-to-question mapping for both languages - Design system: CV color tokens, typography, dark theme comparison - Session management: OOB swap lifecycle - Security: input/output sanitization, privacy rules - Testing: 46 Playwright assertions across 25 test groups - Configuration, dependencies, ADK Go concepts table --- doc/00-GO-DOCUMENTATION-INDEX.md | 16 +- doc/28-AI-CHAT-AGENT.md | 654 +++++++++++++++----- templates/partials/widgets/chat-widget.html | 20 +- 3 files changed, 504 insertions(+), 186 deletions(-) diff --git a/doc/00-GO-DOCUMENTATION-INDEX.md b/doc/00-GO-DOCUMENTATION-INDEX.md index 0203780..f08647d 100644 --- a/doc/00-GO-DOCUMENTATION-INDEX.md +++ b/doc/00-GO-DOCUMENTATION-INDEX.md @@ -36,12 +36,16 @@ 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](28-AI-CHAT-AGENT.md)** (~280 lines) - - ADK Go 1.0 integration architecture - - Agent definition with query_cv tool - - HTMX chat widget implementation - - Graceful degradation pattern - - Example conversations and security considerations +5. **[AI Chat Agent — CV Assistant Mascot](28-AI-CHAT-AGENT.md)** (~500 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) ## Quick Navigation diff --git a/doc/28-AI-CHAT-AGENT.md b/doc/28-AI-CHAT-AGENT.md index 98be3eb..0cef310 100644 --- a/doc/28-AI-CHAT-AGENT.md +++ b/doc/28-AI-CHAT-AGENT.md @@ -1,148 +1,493 @@ -# 28. AI Chat Agent — ADK Go Integration +# 28. AI Chat Agent — CV Assistant Mascot -## Overview +## 1. Overview -The CV site includes an AI-powered conversational assistant 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), it provides instant answers by querying the same cached JSON data that renders the site. +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. -**Live example:** A visitor can ask *"How many Go projects has Juan built?"* and get an accurate answer drawn directly from the CV data — no hallucination, no stale data. +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. -## Architecture +**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) │ │ │ -│ │ │ └──────────────────┘ │ │ -│ │ └───────────┬────────────┘ │ -│ │ │ │ -│ ┌──────▼─────────────────────────▼──────────┐ │ -│ │ POST /api/chat │ │ -│ │ (chat.Handler) │ │ -│ │ ├─ Session management │ │ -│ │ ├─ ADK Runner execution │ │ -│ │ └─ HTML fragment response (HTMX) │ │ -│ └────────────────────────────────────────────┘ │ -│ ▲ │ -│ │ hx-post │ -│ ┌──────────────────────┴─────────────────────┐ │ -│ │ Chat Widget (HTMX + Hyperscript) │ │ -│ │ ├─ Floating chat icon │ │ -│ │ ├─ Expandable panel │ │ -│ │ ├─ Message history │ │ -│ │ └─ Session persistence │ │ -│ └────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ Gemini 2.5 Flash │ - │ (Google AI) │ - └──────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ 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) │ + └──────────────────┘ ``` -## How It Works +### End-to-End Flow -### 1. Agent Definition (`internal/chat/agent.go`) +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. -A single LLM agent (`cv_assistant`) with one tool (`query_cv`): +## 3. Components -```go -llmagent.New(llmagent.Config{ - Name: "cv_assistant", - Model: llm, - Instruction: `You answer questions about the CV owner's experience, - projects, skills, education, and career. - Use the query_cv tool to look up CV data before answering. - Answer in the SAME LANGUAGE the user writes in.`, - Tools: []tool.Tool{queryTool}, -}) -``` - -**Why a single agent?** The CV data is structured and bounded — there's no need for multi-agent orchestration. One agent with one tool is the right abstraction: simple, fast, predictable. - -### 2. The `query_cv` Tool - -The tool accepts two parameters: -- **`section`** — which CV section to search: `experience`, `projects`, `skills`, `education`, `languages`, `certifications`, `courses`, `awards`, `summary`, or `all` -- **`query`** — keyword filter (e.g., "Go", "React", "2019", "Olympic") - -The tool reads from the same `cache.DataCache` that powers the website rendering — zero additional I/O, zero data duplication. - -**Filtering logic:** Case-insensitive keyword matching across all relevant fields in each section (title, company, technologies, descriptions, responsibilities). - -### 3. HTTP Handler (`internal/chat/handler.go`) +### File Structure ``` -POST /api/chat -Content-Type: application/x-www-form-urlencoded +internal/chat/ +├── agent.go # Agent definition, query_cv tool, filter functions +└── handler.go # HTTP handler, session mgmt, Gemini init, response rendering -message=How many Go projects has Juan built? -session_id= +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 ``` -**Response:** HTML fragment for HTMX swap: -```html -
    How many Go projects has Juan built?
    -
    -

    Juan has built 2 projects that use Go:

    -
      -
    • Immich Photo Manager - AI-Powered Photo Library MCP Server
    • -
    • Cmux Resurrect - Terminal Session Persistence Tool
    • -
    -
    - +### `internal/chat/agent.go` + +Defines the single LLM agent (`cv_assistant`) with one tool (`query_cv`). Contains: + +- **`NewAgent()`** — Creates the agent with a comprehensive instruction prompt covering 8 question types and query strategies. +- **`QueryCVArgs` / `QueryCVResult`** — Input/output structs for the tool with JSON schema annotations used by ADK for function calling. +- **`newQueryCVTool()`** — Wraps the query function as an agent-callable tool via `functiontool.New`. Supports 11 section values: `search`, `experience`, `projects`, `skills`, `education`, `languages`, `certifications`, `courses`, `awards`, `summary`, `all`. +- **Filter helpers** — `filterExperience()`, `filterProjects()`, `filterSkills()`, `filterCourses()` perform case-insensitive keyword matching across all relevant fields (title, company, technologies, descriptions, responsibilities). +- **`matchesAny()` / `matchesSlice()`** — Low-level string matching used by all filters. +- **`calculateYears()`** — Computes years of experience from career start date (April 2005). + +**Why a single agent?** The CV data is structured and bounded. There is no need for multi-agent orchestration. One agent with one tool is the right abstraction: simple, fast, predictable. + +### `internal/chat/handler.go` + +Handles the HTTP lifecycle: + +- **`NewHandler()`** — Initializes Gemini model, creates the agent, sets up in-memory session service and ADK runner. Returns a disabled handler if `GOOGLE_API_KEY` is not set. +- **`Enabled()`** — Boolean check used by templates to conditionally render the widget. +- **`HandleChat()`** — Processes `POST /api/chat`. Validates input, ensures session exists, runs the agent with a 30-second timeout (using a dedicated context, not the HTTP request context), renders the HTML fragment response. +- **`formatResponse()`** — Converts basic markdown to HTML: escapes HTML entities first, then applies `**bold**` to ``, converts `- ` bullet lines to `
    • `, and wraps text in `

      ` 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 `

      ` 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": [...] + } +} ``` -**Session management:** ADK Go's in-memory session service maintains conversation context. The session ID is preserved via a hidden form input, enabling follow-up questions. +Only sections with matches are included in the result. -### 4. Chat Widget (HTMX + Hyperscript) +## 6. Agent Intelligence -The UI is a floating chat panel that follows the site's existing widget pattern: +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 - - - - -
      - -
      ``` -**Key HTMX attributes:** -- `hx-post="/api/chat"` — sends message to the agent -- `hx-target="#chat-messages"` — appends response to chat history -- `hx-swap="beforeend scroll:bottom"` — auto-scrolls to latest message -- `hx-indicator="#chat-spinner"` — shows loading spinner during request +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. -## Graceful Degradation +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 on ` syntax works natively with HTMX's event listening. + +## 8. Help Modal + +The help modal (`chat-help-modal.html`) is a native `` 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()` returns a disabled handler -2. `CVHandler` receives `chatEnabled: false` -3. Template data includes `ChatEnabled: false` -4. The chat widget template renders nothing (`{{if .ChatEnabled}}...{{end}}`) -5. No JavaScript errors, no broken UI, no hidden network requests +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.** -## Configuration +## 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 + + ``` +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 @@ -151,80 +496,49 @@ The chat feature is entirely optional. When `GOOGLE_API_KEY` is not set: 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) +MODEL_NAME=gemini-2.5-flash # Default model (free tier compatible) ``` ### Cost -Gemini 2.5 Flash free tier: **15 requests/minute**, no credit card needed. Each chat message = 1 request. For a personal CV site, this is more than sufficient. +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. -## Example Conversations +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. -### English - -| Question | Answer | -|----------|--------| -| "How many years of experience?" | "Juan has 21 years of professional experience, starting in April 2005." | -| "What Go projects has he built?" | Lists Immich Photo Manager and Cmux Resurrect with descriptions | -| "Has he worked with React?" | Lists companies where React was used (Olympic Broadcasting, LIV Golf, etc.) | -| "Tell me about his time at SAP" | Pulls SAP experience entry with responsibilities and technologies | -| "What certifications does he have?" | Lists SAP CDC Full Training, SAP Cloud Platform, GDPR Compliance | - -### Spanish - -| Pregunta | Respuesta | -|----------|-----------| -| "¿En cuántas empresas ha trabajado?" | Lista las 11 empresas con nombres | -| "¿Qué tecnologías domina?" | Categorías de skills con proficiency levels | -| "¿Tiene experiencia con autenticación?" | Detalla SAP CDC, Gigya, sistemas de auth | - -## File Structure - -``` -internal/chat/ -├── agent.go # LLM agent + query_cv tool + filter helpers -└── handler.go # HTTP handler + session management + response rendering - -templates/partials/widgets/ -└── chat-widget.html # HTMX chat panel template - -static/css/04-interactive/ -└── _chat.css # Chat UI styles (responsive, dark theme) -``` - -## Dependencies Added +## 16. Dependencies | Package | Purpose | Size Impact | |---------|---------|-------------| -| `google.golang.org/adk` | Agent framework (runner, session, tools) | ~2 MB binary increase | -| `google.golang.org/genai` | Gemini API client | Included with ADK | +| `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 | -## Security Considerations +No frontend dependencies are added. The chat widget uses HTMX and Hyperscript which are already loaded by the site. -- **No personal data exposure:** The agent instruction explicitly prohibits revealing email, phone, or other contact details — it directs visitors to the contact form instead -- **Input sanitization:** User messages are HTML-escaped before rendering -- **Response sanitization:** Agent responses go through `formatResponse()` which escapes HTML then applies safe markdown-to-HTML conversion -- **Rate limiting:** The `/api/chat` endpoint inherits the site's middleware chain (recovery, logging, security headers) -- **Session isolation:** Each visitor gets an independent in-memory session; sessions are ephemeral and not persisted to disk +## 17. ADK Go Concepts Used -## 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 | -| Concept | Usage | -|---------|-------| -| `llmagent.New` | Creates the CV assistant agent with instruction and tools | -| `functiontool.New` | Wraps the `query_cv` Go function as an agent-callable tool | -| `runner.Runner` | Executes the agent within the HTTP handler | -| `session.InMemoryService` | Maintains conversation context per visitor | -| `genai.NewContentFromText` | Converts user message to ADK content format | -| `event.IsFinalResponse()` | Extracts the agent's final answer from the event stream | -| `agent.RunConfig{}` | Default run configuration (non-streaming) | - -## Relation to Other Documentation +## 18. Relation to Other Documentation - **[01-ARCHITECTURE.md](01-ARCHITECTURE.md)** — Overall system design -- **[03-API.md](03-API.md)** — HTTP API reference (includes `/api/chat`) +- **[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 diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 03bf240..761f0c0 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -47,26 +47,26 @@
      {{if eq .Lang "es"}} + _="on click set #chat-input.value to '¿Qué proyectos en Go ha hecho?' then trigger submit on #chat-form">¿Proyectos en Go? + _="on click set #chat-input.value to '¿Cuántos años de experiencia tiene?' then trigger submit on #chat-form">¿Años de experiencia? + _="on click set #chat-input.value to '¿En qué empresas ha trabajado?' then trigger submit on #chat-form">¿Empresas? + _="on click set #chat-input.value to '¿Conoce React?' then trigger submit on #chat-form">¿Conoce React? + _="on click set #chat-input.value to '¿Qué certificaciones tiene?' then trigger submit on #chat-form">¿Certificaciones? {{else}} + _="on click set #chat-input.value to 'What Go projects has he built?' then trigger submit on #chat-form">Go projects? + _="on click set #chat-input.value to 'How many years of experience?' then trigger submit on #chat-form">Years of experience? + _="on click set #chat-input.value to 'What companies has he worked at?' then trigger submit on #chat-form">Companies? + _="on click set #chat-input.value to 'Does he know React?' then trigger submit on #chat-form">Knows React? + _="on click set #chat-input.value to 'What certifications?' then trigger submit on #chat-form">Certifications? {{end}}
      From 8e93d2b893cd7eff39377df28735333372bd02c1 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 13:58:53 +0100 Subject: [PATCH 13/24] style: green theme for chat mascot, neutral back-to-top arrow - Chat uses --accent-green (#27ae60) matching CV's green theme: header, user bubbles, send button, chip hover, input focus - Dark theme uses deeper greens (#166b3a header, #1e8c4c interactions) - Back-to-top arrow changed from green to neutral gray (#555555) to avoid visual conflict with the mascot button --- static/css/04-interactive/_chat.css | 26 +++++++++---------- .../css/04-interactive/_scroll-behavior.css | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index e8d6892..62a0114 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -34,7 +34,7 @@ opacity: 1; transform: translateY(-3px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); - background: var(--accent-blue, #0066cc); + background: var(--accent-green, #27ae60); } /* Icon swap: show mascot by default, close when active */ @@ -44,7 +44,7 @@ .chat-toggle-btn.mascot-active { opacity: 1; - background: var(--accent-blue, #0066cc); + background: var(--accent-green, #27ae60); } .chat-toggle-btn.mascot-active .chat-icon-open { @@ -89,7 +89,7 @@ align-items: center; gap: 8px; padding: 10px 14px; - background: var(--accent-blue, #0066cc); + background: var(--accent-green, #27ae60); color: #ffffff; font-family: 'Quicksand', sans-serif; font-size: 0.85rem; @@ -167,7 +167,7 @@ } .chat-user { - background: var(--accent-blue, #0066cc); + background: var(--accent-green, #27ae60); color: #fff; align-self: flex-end; border-bottom-right-radius: 2px; @@ -243,9 +243,9 @@ } .chat-chip:hover { - background: var(--accent-blue, #0066cc); + background: var(--accent-green, #27ae60); color: #fff; - border-color: var(--accent-blue, #0066cc); + border-color: var(--accent-green, #27ae60); } /* ========================================================================== @@ -275,11 +275,11 @@ } .chat-input:focus { - border-color: var(--accent-blue, #0066cc); + border-color: var(--accent-green, #27ae60); } .chat-send-btn { - background: var(--accent-blue, #0066cc); + background: var(--accent-green, #27ae60); color: #fff; border: none; border-radius: 50%; @@ -295,7 +295,7 @@ } .chat-send-btn:hover { - background: #004d99; + background: #1e8c4c; } /* ========================================================================== @@ -332,7 +332,7 @@ } .theme-clean .chat-header { - background: #003d7a; + background: #166b3a; } .theme-clean .chat-agent { @@ -341,7 +341,7 @@ } .theme-clean .chat-user { - background: #004d99; + background: #1e8c4c; } .theme-clean .chat-error { @@ -372,9 +372,9 @@ } .theme-clean .chat-chip:hover { - background: #004d99; + background: #1e8c4c; color: #fff; - border-color: #004d99; + border-color: #1e8c4c; } .theme-clean .chat-typing-dot { diff --git a/static/css/04-interactive/_scroll-behavior.css b/static/css/04-interactive/_scroll-behavior.css index 095a169..93d7f36 100644 --- a/static/css/04-interactive/_scroll-behavior.css +++ b/static/css/04-interactive/_scroll-behavior.css @@ -45,12 +45,12 @@ opacity: 1; transform: translateY(-3px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); - background: #27ae60; + background: #555555; } .back-to-top.at-bottom { opacity: 1; - background: #27ae60; + background: #555555; } .back-to-top:active { From 25ddfff0daf245a8ffd73db2306f9ef4aa3bdfad Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 14:01:17 +0100 Subject: [PATCH 14/24] feat: redesign help modal with accordion and clickable questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace flat list with
      / accordion (5 categories) - Questions are clickable: close modal → open chat → send question - closeChatHelpAndAsk() helper bridges modal and chat panel - Green accent on category icons and question hover - Chevron arrows for expand/collapse state - Dark theme support for all accordion elements - Compact layout with no wasted space --- static/css/04-interactive/_chat.css | 152 +++++++++++++ .../partials/modals/chat-help-modal.html | 206 ++++++++---------- 2 files changed, 245 insertions(+), 113 deletions(-) diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 62a0114..8492c82 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -298,6 +298,158 @@ 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); +} + +/* Dark theme for help modal */ +.theme-clean .chat-help-group { + border-color: #333333; +} + +.theme-clean .chat-help-group summary { + background: #2a2a2a; + color: #d0d0d0; +} + +.theme-clean .chat-help-group summary:hover { + background: #333333; +} + +.theme-clean .chat-help-q { + color: #999999; +} + +.theme-clean .chat-help-q:hover { + background: #166b3a; + color: #fff; +} + +.theme-clean .chat-help-footer { + background: #2a2a2a; + color: #777777; +} + +.theme-clean .chat-help-intro { + color: #999999; +} + /* ========================================================================== Responsive ========================================================================== */ diff --git a/templates/partials/modals/chat-help-modal.html b/templates/partials/modals/chat-help-modal.html index 9b3c2a8..52c2ea1 100644 --- a/templates/partials/modals/chat-help-modal.html +++ b/templates/partials/modals/chat-help-modal.html @@ -1,6 +1,6 @@ {{define "chat-help-modal"}} - - +
    -

    - {{if eq .Lang "es"}}Un asistente con inteligencia artificial que puede responder preguntas sobre este CV. Consulta experiencia profesional, proyectos, habilidades, formación y más — todo basado en datos reales del CV.{{else}}An AI-powered assistant that can answer questions about this CV. Query professional experience, projects, skills, education, and more — all based on actual CV data.{{end}} +

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

    - -
    -

    - - {{if eq .Lang "es"}}Sobre Experiencia{{else}}About Experience{{end}} -

    -
    -
    - {{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"}}Experiencia{{else}}Experience{{end}} + +
    + + + +
    -
    - {{if eq .Lang "es"}}¿En qué empresas ha trabajado?{{else}}What companies has he worked at?{{end}} +
    + + +
    + + + {{if eq .Lang "es"}}Tecnologías{{else}}Technologies{{end}} + +
    + + + + +
    -
    - {{if eq .Lang "es"}}Cuéntame sobre su paso por Olympic Broadcasting{{else}}Tell me about his time at Olympic Broadcasting{{end}} +
    + + +
    + + + {{if eq .Lang "es"}}Proyectos{{else}}Projects{{end}} + +
    + + +
    -
    - {{if eq .Lang "es"}}¿Qué hacía en SAP?{{else}}What did he do at SAP?{{end}} +
    + + +
    + + + {{if eq .Lang "es"}}Formación{{else}}Education{{end}} + +
    + + +
    -
    + + + +
    + + + {{if eq .Lang "es"}}Habilidades{{else}}Skills{{end}} + +
    + + + +
    +
    +
    - -
    -

    - - {{if eq .Lang "es"}}Sobre Tecnologías{{else}}About Technologies{{end}} -

    -
    -
    - {{if eq .Lang "es"}}¿Qué lenguajes de programación conoce?{{else}}What programming languages does he know?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Ha trabajado con React? ¿Dónde?{{else}}Has he worked with React? Where?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Cuál es su experiencia con Go?{{else}}What's his Go experience?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Conoce Node.js?{{else}}Does he know Node.js?{{end}} -
    -
    -
    - - -
    -

    - - {{if eq .Lang "es"}}Sobre Proyectos{{else}}About Projects{{end}} -

    -
    -
    - {{if eq .Lang "es"}}¿Qué proyectos personales ha creado?{{else}}What personal projects has he built?{{end}} -
    -
    - {{if eq .Lang "es"}}Cuéntame sobre Immich Photo Manager{{else}}Tell me about the Immich Photo Manager{{end}} -
    -
    - {{if eq .Lang "es"}}¿Qué proyectos open-source mantiene?{{else}}What open-source projects does he maintain?{{end}} -
    -
    -
    - - -
    -

    - - {{if eq .Lang "es"}}Formación y Certificaciones{{else}}Education & Certifications{{end}} -

    -
    -
    - {{if eq .Lang "es"}}¿Qué certificaciones tiene?{{else}}What certifications does he have?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Dónde estudió?{{else}}Where did he study?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Qué cursos ha completado?{{else}}What courses has he completed?{{end}} -
    -
    -
    - - -
    -

    - - {{if eq .Lang "es"}}Sobre Habilidades{{else}}About Skills{{end}} -

    -
    -
    - {{if eq .Lang "es"}}¿Cuáles son sus principales habilidades técnicas?{{else}}What are his main technical skills?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Tiene experiencia con Docker?{{else}}Does he have experience with Docker?{{end}} -
    -
    - {{if eq .Lang "es"}}¿Qué hay de CI/CD?{{else}}What about CI/CD?{{end}} -
    -
    -
    - - -
    -

    - - {{if eq .Lang "es"}}Cómo funciona{{else}}How it works{{end}} -

    -
    -
    - - {{if eq .Lang "es"}}Impulsado por Google ADK Go 1.0 y Gemini AI. Las preguntas se responden consultando los datos reales del CV — sin alucinaciones, siempre preciso.{{else}}Powered by Google ADK Go 1.0 and Gemini AI. Questions are answered by querying the actual CV data — no hallucination, always accurate.{{end}} - -
    -
    + +
    + + + {{end}} From 4f558ac842981a23587a11e3f707855dda4c3acd Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 14:11:11 +0100 Subject: [PATCH 15/24] fix: replace Hyperscript with plain JS for chat interactions The Hyperscript trigger/call commands couldn't reliably trigger HTMX form submissions or call global JS functions. Moved all chat interactions to plain JavaScript: - toggleChatPanel(): open/close panel + icon swap - sendChatQuestion(q): set input + htmx.trigger(form, 'submit') - closeChatHelpAndAsk(q): close modal + open chat + send question - htmx:afterRequest listener clears input after submit Hyperscript kept only for site-wide patterns (closeOnBackdrop) that work reliably. Also: better error message for rate-limited API responses (429). --- internal/chat/handler.go | 7 +- .../partials/modals/chat-help-modal.html | 36 ++++---- templates/partials/widgets/chat-widget.html | 83 ++++++++++++------- 3 files changed, 76 insertions(+), 50 deletions(-) diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 1dd296b..37bb57c 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -144,8 +144,11 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Chat agent error: %v", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprint(w, `
    Something went wrong. Please try again.
    `) + 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, `
    %s
    `, errMsg) return } if event.IsFinalResponse() { diff --git a/templates/partials/modals/chat-help-modal.html b/templates/partials/modals/chat-help-modal.html index 52c2ea1..25b8805 100644 --- a/templates/partials/modals/chat-help-modal.html +++ b/templates/partials/modals/chat-help-modal.html @@ -31,10 +31,10 @@ {{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}
    - - - - + + + +
    @@ -45,11 +45,11 @@ {{if eq .Lang "es"}}Tecnologías{{else}}Technologies{{end}}
    - - - - - + + + + +
    @@ -60,9 +60,9 @@ {{if eq .Lang "es"}}Proyectos{{else}}Projects{{end}}
    - - - + + +
    @@ -73,9 +73,9 @@ {{if eq .Lang "es"}}Formación{{else}}Education{{end}}
    - - - + + +
    @@ -86,9 +86,9 @@ {{if eq .Lang "es"}}Habilidades{{else}}Skills{{end}}
    - - - + + +
    diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 761f0c0..41d3984 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -6,14 +6,7 @@ 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}}" - _="on click toggle .chat-open on #chat-panel - then if #chat-panel matches .chat-open - then add .mascot-active to me - then set #chat-input.focus to true - then call #chat-input.focus() - else - remove .mascot-active from me - end"> + onclick="toggleChatPanel()"> @@ -46,27 +39,17 @@
    {{if eq .Lang "es"}} - - - - - + + + + + {{else}} - - - - - + + + + + {{end}}
    @@ -74,8 +57,7 @@ hx-post="/api/chat" hx-target="#chat-messages" hx-swap="beforeend scroll:#chat-messages:bottom" - hx-indicator="#chat-typing" - _="on htmx:afterRequest set #chat-input.value to ''"> + hx-indicator="#chat-typing">
    + + + {{end}} {{end}} From 8205a2297283f9160c89640a54c4667f43c2d54b Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 14:47:14 +0100 Subject: [PATCH 16/24] feat: Ollama adapter + chat rate limiter (30 req/hour) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama adapter (internal/chat/ollama.go): - Implements model.LLM interface for ADK Go - Talks to Ollama's OpenAI-compatible API (/v1/chat/completions) - Full tool/function calling support (tested with Mistral Small 3.2) - Converts ADK types to OpenAI format (messages, tools, tool_calls) - Configurable via OLLAMA_HOST and OLLAMA_MODEL env vars Multi-provider handler: - MODEL_PROVIDER env: "gemini" (default) or "ollama" - Gemini: requires GOOGLE_API_KEY (pay-as-you-go recommended) - Ollama: connects to local or Tailscale-remote instance Rate limiter: - 30 requests/hour per IP on /api/chat endpoint - Uses existing middleware.NewRateLimiter pattern Tested: Ollama + Mistral Small 3.2 on M4 Pro 64GB — correct answers --- .env.example | 13 + internal/chat/handler.go | 75 ++++-- internal/chat/ollama.go | 430 ++++++++++++++++++++++++++++++++ internal/constants/constants.go | 2 + internal/routes/routes.go | 7 +- 5 files changed, 510 insertions(+), 17 deletions(-) create mode 100644 internal/chat/ollama.go diff --git a/.env.example b/.env.example index 7beea89..11ecf0a 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,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=mistral-small3.2 + # Production Settings # Uncomment for production: # GO_ENV=production diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 37bb57c..6f20197 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -13,6 +13,7 @@ import ( "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" @@ -26,26 +27,28 @@ type Handler struct { enabled bool } -// NewHandler creates a chat handler. Returns a disabled handler if GOOGLE_API_KEY is not set. +// NewHandler creates a chat handler. Returns a disabled handler if no model provider is configured. func NewHandler(dataCache *cache.DataCache) *Handler { - apiKey := os.Getenv("GOOGLE_API_KEY") - if apiKey == "" { - log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled") - return &Handler{enabled: false} + provider := os.Getenv("MODEL_PROVIDER") + if provider == "" { + provider = "gemini" } - ctx := context.Background() + var llm model.LLM + var providerLabel string - modelName := os.Getenv("MODEL_NAME") - if modelName == "" { - modelName = "gemini-2.5-flash" + switch provider { + case "ollama": + llm, providerLabel = initOllamaProvider() + default: + var err error + llm, providerLabel, err = initGeminiProvider() + if err != nil { + return &Handler{enabled: false} + } } - llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ - APIKey: apiKey, - }) - if err != nil { - log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err) + if llm == nil { return &Handler{enabled: false} } @@ -68,7 +71,7 @@ func NewHandler(dataCache *cache.DataCache) *Handler { return &Handler{enabled: false} } - log.Printf("💬 Chat agent enabled (model: %s)", modelName) + log.Printf("💬 Chat agent enabled (%s)", providerLabel) return &Handler{ runner: r, @@ -77,6 +80,48 @@ func NewHandler(dataCache *cache.DataCache) *Handler { } } +// initGeminiProvider initializes the Gemini LLM provider. +func initGeminiProvider() (model.LLM, string, error) { + apiKey := os.Getenv("GOOGLE_API_KEY") + if apiKey == "" { + log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled") + return nil, "", fmt.Errorf("no API key") + } + + ctx := context.Background() + + modelName := os.Getenv("MODEL_NAME") + if modelName == "" { + modelName = "gemini-2.5-flash" + } + + llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ + APIKey: apiKey, + }) + if err != nil { + log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err) + return 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 diff --git a/internal/chat/ollama.go b/internal/chat/ollama.go new file mode 100644 index 0000000..004d206 --- /dev/null +++ b/internal/chat/ollama.go @@ -0,0 +1,430 @@ +// Package chat provides an ADK Go agent that answers questions about CV data. +package chat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "iter" + "net/http" + + "google.golang.org/adk/model" + "google.golang.org/genai" +) + +// OllamaModel implements model.LLM using Ollama's OpenAI-compatible API. +type OllamaModel struct { + host string // e.g. "http://localhost:11434" + modelName string // e.g. "mistral-small3.2" + client *http.Client +} + +// NewOllamaModel creates a new Ollama-backed LLM. +func NewOllamaModel(host, modelName string) *OllamaModel { + return &OllamaModel{ + host: host, + modelName: modelName, + client: &http.Client{}, + } +} + +// Name returns the model name. +func (m *OllamaModel) Name() string { + return m.modelName +} + +// Verify OllamaModel implements model.LLM at compile time. +var _ model.LLM = (*OllamaModel)(nil) + +// GenerateContent sends a request to Ollama and returns ADK-compatible responses. +func (m *OllamaModel) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { + return func(yield func(*model.LLMResponse, error) bool) { + resp, err := m.generate(ctx, req) + yield(resp, err) + } +} + +// --- OpenAI-compatible request/response types --- + +type oaiMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type oaiToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function oaiToolFunction `json:"function"` +} + +type oaiToolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON string +} + +type oaiTool struct { + Type string `json:"type"` + Function oaiToolFuncDecl `json:"function"` +} + +type oaiToolFuncDecl struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} + +type oaiRequest struct { + Model string `json:"model"` + Messages []oaiMessage `json:"messages"` + Tools []oaiTool `json:"tools,omitempty"` + Stream bool `json:"stream"` + Temperature *float32 `json:"temperature,omitempty"` +} + +type oaiResponse struct { + Choices []oaiChoice `json:"choices"` + Usage *oaiUsage `json:"usage,omitempty"` + Model string `json:"model,omitempty"` +} + +type oaiChoice struct { + Message oaiMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type oaiUsage struct { + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + TotalTokens int32 `json:"total_tokens"` +} + +// generate performs a synchronous (non-streaming) call to Ollama. +func (m *OllamaModel) generate(ctx context.Context, req *model.LLMRequest) (*model.LLMResponse, error) { + oaiReq := m.buildRequest(req) + + body, err := json.Marshal(oaiReq) + if err != nil { + return nil, fmt.Errorf("ollama: marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v1/chat/completions", m.host) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ollama: create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := m.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ollama: send request: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("ollama: read response: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama: HTTP %d: %s", httpResp.StatusCode, string(respBody)) + } + + var oaiResp oaiResponse + if err := json.Unmarshal(respBody, &oaiResp); err != nil { + return nil, fmt.Errorf("ollama: unmarshal response: %w", err) + } + + return m.convertResponse(&oaiResp) +} + +// buildRequest converts an ADK LLMRequest into an OpenAI-compatible request. +func (m *OllamaModel) buildRequest(req *model.LLMRequest) *oaiRequest { + oaiReq := &oaiRequest{ + Model: m.modelName, + Stream: false, + } + + // Convert system instruction + if req.Config != nil && req.Config.SystemInstruction != nil { + text := extractText(req.Config.SystemInstruction) + if text != "" { + oaiReq.Messages = append(oaiReq.Messages, oaiMessage{ + Role: "system", + Content: text, + }) + } + } + + // Set temperature if provided + if req.Config != nil && req.Config.Temperature != nil { + oaiReq.Temperature = req.Config.Temperature + } + + // Convert conversation messages + for _, content := range req.Contents { + msgs := convertContent(content) + oaiReq.Messages = append(oaiReq.Messages, msgs...) + } + + // Convert tools (function declarations) + if req.Config != nil && req.Config.Tools != nil { + for _, t := range req.Config.Tools { + if t.FunctionDeclarations != nil { + for _, fd := range t.FunctionDeclarations { + oaiReq.Tools = append(oaiReq.Tools, convertFunctionDecl(fd)) + } + } + } + } + + return oaiReq +} + +// convertContent converts a genai.Content into one or more OpenAI messages. +func convertContent(content *genai.Content) []oaiMessage { + if content == nil { + return nil + } + + role := mapRole(content.Role) + + // Check if this content has function calls (assistant with tool_calls) + var toolCalls []oaiToolCall + var textParts []string + var funcResponses []oaiMessage + + for _, part := range content.Parts { + if part.Text != "" { + textParts = append(textParts, part.Text) + } + if part.FunctionCall != nil { + argsJSON, _ := json.Marshal(part.FunctionCall.Args) + toolCalls = append(toolCalls, oaiToolCall{ + ID: part.FunctionCall.ID, + Type: "function", + Function: oaiToolFunction{ + Name: part.FunctionCall.Name, + Arguments: string(argsJSON), + }, + }) + } + if part.FunctionResponse != nil { + respJSON, _ := json.Marshal(part.FunctionResponse.Response) + funcResponses = append(funcResponses, oaiMessage{ + Role: "tool", + Content: string(respJSON), + ToolCallID: part.FunctionResponse.ID, + }) + } + } + + var msgs []oaiMessage + + // Build the primary message + if len(toolCalls) > 0 { + // Assistant message with tool calls + msg := oaiMessage{ + Role: "assistant", + ToolCalls: toolCalls, + } + if len(textParts) > 0 { + combined := "" + for _, t := range textParts { + combined += t + } + msg.Content = combined + } + msgs = append(msgs, msg) + } else if len(textParts) > 0 { + combined := "" + for _, t := range textParts { + combined += t + } + msgs = append(msgs, oaiMessage{ + Role: role, + Content: combined, + }) + } + + // Append function response messages separately + msgs = append(msgs, funcResponses...) + + return msgs +} + +// convertFunctionDecl converts a genai FunctionDeclaration to an OpenAI tool. +func convertFunctionDecl(fd *genai.FunctionDeclaration) oaiTool { + var params any + if fd.Parameters != nil { + params = convertSchema(fd.Parameters) + } else if fd.ParametersJsonSchema != nil { + params = fd.ParametersJsonSchema + } + + return oaiTool{ + Type: "function", + Function: oaiToolFuncDecl{ + Name: fd.Name, + Description: fd.Description, + Parameters: params, + }, + } +} + +// convertSchema converts a genai.Schema to a JSON-Schema-compatible map. +func convertSchema(s *genai.Schema) map[string]any { + if s == nil { + return nil + } + + m := make(map[string]any) + + if s.Type != "" { + m["type"] = schemaTypeToJSON(s.Type) + } + if s.Description != "" { + m["description"] = s.Description + } + if len(s.Enum) > 0 { + m["enum"] = s.Enum + } + if s.Items != nil { + m["items"] = convertSchema(s.Items) + } + if len(s.Properties) > 0 { + props := make(map[string]any) + for k, v := range s.Properties { + props[k] = convertSchema(v) + } + m["properties"] = props + } + if len(s.Required) > 0 { + m["required"] = s.Required + } + + return m +} + +// schemaTypeToJSON maps genai.Type to JSON Schema type strings. +func schemaTypeToJSON(t genai.Type) string { + switch t { + case genai.TypeString: + return "string" + case genai.TypeNumber: + return "number" + case genai.TypeInteger: + return "integer" + case genai.TypeBoolean: + return "boolean" + case genai.TypeArray: + return "array" + case genai.TypeObject: + return "object" + default: + return "string" + } +} + +// convertResponse converts an OpenAI response back to an ADK LLMResponse. +func (m *OllamaModel) convertResponse(resp *oaiResponse) (*model.LLMResponse, error) { + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("ollama: empty response (no choices)") + } + + choice := resp.Choices[0] + var parts []*genai.Part + + // Handle tool calls + if len(choice.Message.ToolCalls) > 0 { + for _, tc := range choice.Message.ToolCalls { + var args map[string]any + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + // If args aren't valid JSON, wrap them + args = map[string]any{"raw": tc.Function.Arguments} + } + } + parts = append(parts, &genai.Part{ + FunctionCall: &genai.FunctionCall{ + ID: tc.ID, + Name: tc.Function.Name, + Args: args, + }, + }) + } + } + + // Handle text content + if choice.Message.Content != "" { + parts = append(parts, &genai.Part{ + Text: choice.Message.Content, + }) + } + + content := &genai.Content{ + Parts: parts, + Role: genai.RoleModel, + } + + llmResp := &model.LLMResponse{ + Content: content, + FinishReason: mapFinishReason(choice.FinishReason), + TurnComplete: true, + ModelVersion: resp.Model, + } + + // Map usage metadata + if resp.Usage != nil { + llmResp.UsageMetadata = &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: resp.Usage.PromptTokens, + CandidatesTokenCount: resp.Usage.CompletionTokens, + TotalTokenCount: resp.Usage.TotalTokens, + } + } + + return llmResp, nil +} + +// mapRole converts genai roles to OpenAI roles. +func mapRole(role string) string { + switch role { + case "user": + return "user" + case "model": + return "assistant" + default: + return "user" + } +} + +// mapFinishReason converts OpenAI finish reasons to genai finish reasons. +func mapFinishReason(reason string) genai.FinishReason { + switch reason { + case "stop": + return genai.FinishReasonStop + case "length": + return genai.FinishReasonMaxTokens + case "tool_calls": + return genai.FinishReasonStop // Tool calls are a normal stop + default: + return genai.FinishReasonStop + } +} + +// extractText extracts all text from a genai.Content. +func extractText(content *genai.Content) string { + if content == nil { + return "" + } + var result string + for _, part := range content.Parts { + if part.Text != "" { + result += part.Text + } + } + return result +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 1f37802..8bcd94e 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -135,6 +135,8 @@ const ( RateLimitGeneralWindow = 1 * time.Minute RateLimitContactRequests = 5 RateLimitContactWindow = 1 * time.Hour + RateLimitChatRequests = 30 + RateLimitChatWindow = 1 * time.Hour ) // ============================================================================== diff --git a/internal/routes/routes.go b/internal/routes/routes.go index b594adc..4ee977b 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -18,8 +18,11 @@ 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/chat", chatHandler.HandleChat) // AI chat endpoint + 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))) // Public routes mux.HandleFunc("/", cvHandler.Home) From 160be31b317a0405a3303e8f601ba6f08a40610c Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 14:57:38 +0100 Subject: [PATCH 17/24] =?UTF-8?q?feat:=20auto-fallback=20Gemini=E2=86=92Ol?= =?UTF-8?q?lama=20+=20model=20warmup=20on=20chat=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-provider architecture: - Both Gemini and Ollama initialize at startup (if configured) - Primary (Gemini) tried first for every request - On any error (429, 503, timeout), automatically falls back to Ollama - No manual switching needed — completely transparent to the user - Log shows: "Primary failed (gemini: ...), falling back to ollama: ..." Warmup: - POST /api/chat/warmup called silently when chat panel opens - Pre-loads Ollama model in background (10-15s) while user reads welcome - By the time user types, model is ready for instant response - Warms up fallback provider specifically (Gemini doesn't need it) Timeout: - Agent context increased to 60s (Ollama first response can be slow) - Each request creates a fresh session (stateless for fallback compat) --- internal/chat/handler.go | 227 ++++++++++++-------- internal/routes/routes.go | 1 + templates/partials/widgets/chat-widget.html | 6 + 3 files changed, 142 insertions(+), 92 deletions(-) diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 6f20197..67de3f6 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -20,86 +20,106 @@ import ( "google.golang.org/genai" ) -// Handler serves the chat API endpoint. -type Handler struct { - runner *runner.Runner - sessionService session.Service - enabled bool +// chatRunner bundles a runner with its session service and label. +type chatRunner struct { + runner *runner.Runner + session session.Service + label string } -// NewHandler creates a chat handler. Returns a disabled handler if no model provider is configured. +// 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 +} + +// 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 { - provider := os.Getenv("MODEL_PROVIDER") - if provider == "" { - provider = "gemini" - } + h := &Handler{} - var llm model.LLM - var providerLabel string - - switch provider { - case "ollama": - llm, providerLabel = initOllamaProvider() - default: - var err error - llm, providerLabel, err = initGeminiProvider() - if err != nil { - return &Handler{enabled: false} + // 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} } } - if llm == nil { + // 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 { - log.Printf("⚠️ Failed to create CV agent: %v — chat disabled", err) - return &Handler{enabled: false} + return nil, err } sessionSvc := session.InMemoryService() r, err := runner.New(runner.Config{ - AppName: "cv-chat", + AppName: appName, Agent: cvAgent, SessionService: sessionSvc, AutoCreateSession: true, }) if err != nil { - log.Printf("⚠️ Failed to create runner: %v — chat disabled", err) - return &Handler{enabled: false} + return nil, err } - log.Printf("💬 Chat agent enabled (%s)", providerLabel) - - return &Handler{ - runner: r, - sessionService: sessionSvc, - enabled: true, - } + 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 == "" { - log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled") return nil, "", fmt.Errorf("no API key") } - ctx := context.Background() - modelName := os.Getenv("MODEL_NAME") if modelName == "" { modelName = "gemini-2.5-flash" } - llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ + llm, err := gemini.NewModel(context.Background(), modelName, &genai.ClientConfig{ APIKey: apiKey, }) if err != nil { - log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err) + log.Printf("⚠️ Gemini init failed: %v", err) return nil, "", err } @@ -127,9 +147,42 @@ 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 + } + + // 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(), 60*time.Second) + defer cancel() + + sess, err := target.session.Create(ctx, &session.CreateRequest{ + AppName: "cv-chat-warmup", + UserID: "warmup", + }) + if err != nil { + return + } + + msg := genai.NewContentFromText("hi", genai.RoleUser) + for range target.runner.Run(ctx, "warmup", sess.Session.ID(), msg, agent.RunConfig{}) { + } + log.Printf("💬 Model warmed up (%s)", target.label) + }() + + w.WriteHeader(http.StatusNoContent) +} + // HandleChat processes POST /api/chat requests. -// Expects form field "message" and optional "session_id". -// Returns an HTML fragment for HTMX to swap into the chat panel. +// 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") @@ -150,51 +203,58 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { return } - sessionID := r.FormValue("session_id") - if sessionID == "" { - sessionID = "default" + // 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) } - // Ensure session exists - ctx := r.Context() - _, err := h.sessionService.Get(ctx, &session.GetRequest{ - AppName: "cv-chat", - UserID: "visitor", - SessionID: sessionID, - }) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err != nil { - // Create new session - created, createErr := h.sessionService.Create(ctx, &session.CreateRequest{ - AppName: "cv-chat", - UserID: "visitor", - }) - if createErr != nil { - log.Printf("Chat session create error: %v", createErr) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprint(w, `
    Failed to start chat session.
    `) - return + 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." } - sessionID = created.Session.ID() + _, _ = fmt.Fprintf(w, `
    %s
    `, errMsg) + return } - // Run the agent with a dedicated context (not tied to HTTP request lifecycle) - agentCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + // User message bubble + _, _ = fmt.Fprintf(w, `
    %s
    `, html.EscapeString(message)) + + // Agent response bubble + if response == "" { + response = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education." + } + _, _ = fmt.Fprintf(w, `
    %s
    `, formatResponse(response)) + + // Session ID via OOB swap + _, _ = fmt.Fprintf(w, ``, 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(), 60*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 h.runner.Run(agentCtx, "visitor", sessionID, userMsg, agent.RunConfig{}) { + for event, err := range cr.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) { if err != nil { - log.Printf("Chat agent error: %v", err) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - 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, `
    %s
    `, errMsg) - return + return "", "", err } if event.IsFinalResponse() { if event.Content != nil { @@ -207,35 +267,18 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { } } - // Render the response as HTML - w.Header().Set("Content-Type", "text/html; charset=utf-8") - - // User message bubble - _, _ = fmt.Fprintf(w, `
    %s
    `, html.EscapeString(message)) - - // Agent response bubble - agentText := response.String() - if agentText == "" { - agentText = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education." - } - _, _ = fmt.Fprintf(w, `
    %s
    `, formatResponse(agentText)) - - // Update session ID via OOB swap (replaces existing input, avoids duplicates) - _, _ = fmt.Fprintf(w, ``, sessionID) + return response.String(), sessionID, nil } // formatResponse converts basic markdown to HTML for the chat bubble. func formatResponse(text string) string { - // Escape HTML first text = html.EscapeString(text) - // Bold: **text** → text for strings.Contains(text, "**") { text = strings.Replace(text, "**", "", 1) text = strings.Replace(text, "**", "", 1) } - // Bullet points: lines starting with "- " →
  • lines := strings.Split(text, "\n") var result []string inList := false diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 4ee977b..a6c59dc 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -23,6 +23,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, // 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 // Public routes mux.HandleFunc("/", cvHandler.Home) diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 41d3984..5b7771c 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -76,6 +76,7 @@ {{end}} {{end}}