feat: add AI chat widget powered by ADK Go 1.0
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
This commit is contained in:
@@ -8,32 +8,60 @@ require (
|
||||
github.com/go-git/go-git/v5 v5.16.4
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/text v0.33.0
|
||||
google.golang.org/adk v1.0.0
|
||||
google.golang.org/genai v1.52.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/safehtml v0.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.16.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
rsc.io/omap v1.2.0 // indirect
|
||||
rsc.io/ordered v1.1.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
@@ -9,6 +15,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
@@ -26,6 +34,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
@@ -38,6 +48,11 @@ github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
@@ -46,8 +61,24 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8=
|
||||
github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
@@ -83,20 +114,40 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -104,15 +155,28 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/adk v1.0.0 h1:DcJGKH9YweOdsAvE5Hu9UhhLoVYcNEVKzvOPS+B49lQ=
|
||||
google.golang.org/adk v1.0.0/go.mod h1:wLmpRAp0zXcrdUN2V6mNoh+mj/4O16k0YzGJMNF7Mjk=
|
||||
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
|
||||
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -123,3 +187,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/omap v1.2.0 h1:c1M8jchnHbzmJALzGLclfH3xDWXrPxSUHXzH5C+8Kdw=
|
||||
rsc.io/omap v1.2.0/go.mod h1:C8pkI0AWexHopQtZX+qiUeJGzvc8HkdgnsWK4/mAa00=
|
||||
rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
|
||||
rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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, `<div class="chat-message chat-error">Chat is not available at the moment.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(r.FormValue("message"))
|
||||
if message == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, `<div class="chat-message chat-error">Please enter a message.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
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, `<div class="chat-message chat-error">Failed to start chat session.</div>`)
|
||||
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, `<div class="chat-message chat-error">Something went wrong. Please try again.</div>`)
|
||||
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, `<div class="chat-message chat-user">%s</div>`, 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, `<div class="chat-message chat-agent">%s</div>`, formatResponse(agentText))
|
||||
|
||||
// Hidden input to preserve session ID for next request
|
||||
_, _ = fmt.Fprintf(w, `<input type="hidden" name="session_id" value="%s" form="chat-form"/>`, 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** → <strong>text</strong>
|
||||
for strings.Contains(text, "**") {
|
||||
text = strings.Replace(text, "**", "<strong>", 1)
|
||||
text = strings.Replace(text, "**", "</strong>", 1)
|
||||
}
|
||||
|
||||
// Bullet points: lines starting with "- " → <li>
|
||||
lines := strings.Split(text, "\n")
|
||||
var result []string
|
||||
inList := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "• ") {
|
||||
if !inList {
|
||||
result = append(result, "<ul>")
|
||||
inList = true
|
||||
}
|
||||
result = append(result, "<li>"+strings.TrimPrefix(strings.TrimPrefix(trimmed, "- "), "• ")+"</li>")
|
||||
} else {
|
||||
if inList {
|
||||
result = append(result, "</ul>")
|
||||
inList = false
|
||||
}
|
||||
if trimmed != "" {
|
||||
result = append(result, "<p>"+trimmed+"</p>")
|
||||
}
|
||||
}
|
||||
}
|
||||
if inList {
|
||||
result = append(result, "</ul>")
|
||||
}
|
||||
|
||||
return strings.Join(result, "")
|
||||
}
|
||||
@@ -21,15 +21,17 @@ type CVHandler struct {
|
||||
emailService *email.Service
|
||||
serverAddr string
|
||||
dataCache *cache.DataCache
|
||||
chatEnabled bool
|
||||
}
|
||||
|
||||
// NewCVHandler creates a new CV handler
|
||||
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache) *CVHandler {
|
||||
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache, chatEnabled bool) *CVHandler {
|
||||
return &CVHandler{
|
||||
templates: tmpl,
|
||||
pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration),
|
||||
emailService: emailService,
|
||||
serverAddr: serverAddr,
|
||||
dataCache: dataCache,
|
||||
chatEnabled: chatEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +365,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
"ChatEnabled": h.chatEnabled,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
|
||||
@@ -95,5 +95,5 @@ func newTestCVHandler(t testing.TB, serverAddr string, emailService *email.Servi
|
||||
|
||||
dataCache := getTestCache(t)
|
||||
|
||||
return NewCVHandler(tmplManager, serverAddr, emailService, dataCache)
|
||||
return NewCVHandler(tmplManager, serverAddr, emailService, dataCache, false)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package routes
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/chat"
|
||||
c "github.com/juanatsap/cv-site/internal/constants"
|
||||
"github.com/juanatsap/cv-site/internal/handlers"
|
||||
"github.com/juanatsap/cv-site/internal/middleware"
|
||||
)
|
||||
|
||||
// Setup configures all application routes and middleware
|
||||
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
|
||||
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, chatHandler *chat.Handler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Shortcut routes for default CV (year-aware) - MUST be before "/" route
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
{{template "contact-button" .}}
|
||||
{{template "zoom-toggle-button" .}}
|
||||
{{template "shortcuts-button" .}}
|
||||
{{template "chat-widget" .}}
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- MODALS -->
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{{define "chat-widget"}}
|
||||
{{if .ChatEnabled}}
|
||||
<!-- AI Chat Widget -->
|
||||
<button
|
||||
id="chat-toggle-btn"
|
||||
class="fixed-btn chat-toggle-btn no-print has-tooltip"
|
||||
aria-label="Ask about this CV"
|
||||
data-tooltip="Ask AI about this CV"
|
||||
_="on click toggle .chat-open on #chat-panel
|
||||
then if #chat-panel matches .chat-open
|
||||
then set #chat-input.focus to true
|
||||
then call #chat-input.focus()
|
||||
end">
|
||||
<iconify-icon icon="mdi:chat-outline" id="chat-icon-open"></iconify-icon>
|
||||
<iconify-icon icon="mdi:close" id="chat-icon-close" style="display:none"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<div id="chat-panel" class="chat-panel no-print">
|
||||
<div class="chat-header">
|
||||
<iconify-icon icon="mdi:robot-outline"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Pregunta sobre este CV{{else}}Ask about this CV{{end}}</span>
|
||||
<button class="chat-close-btn"
|
||||
_="on click remove .chat-open from #chat-panel">
|
||||
<iconify-icon icon="mdi:close"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div class="chat-message chat-agent">
|
||||
{{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}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="chat-form" class="chat-input-area"
|
||||
hx-post="/api/chat"
|
||||
hx-target="#chat-messages"
|
||||
hx-swap="beforeend scroll:#chat-messages:bottom"
|
||||
hx-indicator="#chat-spinner"
|
||||
_="on htmx:afterRequest set #chat-input.value to ''">
|
||||
<input type="hidden" name="session_id" value="">
|
||||
<input type="hidden" name="lang" value="{{.Lang}}">
|
||||
<input
|
||||
type="text"
|
||||
id="chat-input"
|
||||
name="message"
|
||||
class="chat-input"
|
||||
placeholder="{{if eq .Lang "es"}}Escribe una pregunta...{{else}}Type a question...{{end}}"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<button type="submit" class="chat-send-btn" aria-label="Send">
|
||||
<iconify-icon icon="mdi:send"></iconify-icon>
|
||||
</button>
|
||||
<div id="chat-spinner" class="chat-spinner htmx-indicator">
|
||||
<iconify-icon icon="mdi:loading" class="spin"></iconify-icon>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user