From f5276431eaff5479e7aff2623e955b7ef8c26c9b Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 00:20:48 +0100 Subject: [PATCH] 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}}