From 79ce8dc4c0d23d6fa76b7eff8e604c08802dca97 Mon Sep 17 00:00:00 2001 From: enkly Date: Fri, 30 Oct 2026 18:06:55 +0100 Subject: [PATCH] Init Project --- .dockerignore | 60 + .gitea/workflows/ci.yaml | 211 ++ .gitignore | 50 + ARCHITECTURE.md | 332 +++ CHANGES.md | 1337 ++++++++++ Makefile | 74 + README.md | 178 +- UI-REDESIGN.md | 19 + api/proto/agent/v1/agent.proto | 159 ++ api/proto/billing/v1/billing.proto | 153 ++ api/proto/common/v1/common.proto | 28 + api/proto/conversation/v1/conversation.proto | 136 + api/proto/iam/v1/iam.proto | 165 ++ api/proto/knowledge/v1/knowledge.proto | 75 + api/proto/workspace/v1/workspace.proto | 196 ++ docker-compose.yml | 256 ++ gen/agent/v1/agent.pb.go | 999 ++++++++ gen/agent/v1/agent_grpc.pb.go | 240 ++ gen/billing/v1/billing.pb.go | 1455 +++++++++++ gen/billing/v1/billing_grpc.pb.go | 445 ++++ gen/common/v1/common.pb.go | 301 +++ gen/conversation/v1/conversation.pb.go | 1690 ++++++++++++ gen/conversation/v1/conversation_grpc.pb.go | 516 ++++ gen/iam/v1/iam.pb.go | 1632 ++++++++++++ gen/iam/v1/iam_grpc.pb.go | 523 ++++ gen/knowledge/v1/knowledge.pb.go | 765 ++++++ gen/knowledge/v1/knowledge_grpc.pb.go | 261 ++ gen/workspace/v1/workspace.pb.go | 2271 +++++++++++++++++ gen/workspace/v1/workspace_grpc.pb.go | 554 ++++ go.mod | 12 + go.sum | 20 + k8s/00-namespace.yaml | 6 + k8s/01-config.yaml | 103 + k8s/02-secrets.yaml | 67 + k8s/10-rabbitmq.yaml | 174 ++ k8s/11-vllm.yaml | 326 +++ k8s/12-databases.yaml | 222 ++ k8s/20-iam.yaml | 45 + k8s/21-billing.yaml | 49 + k8s/22-conversation.yaml | 37 + k8s/23-workspace.yaml | 45 + k8s/24-knowledge.yaml | 36 + k8s/25-agent.yaml | 42 + k8s/26-notification.yaml | 38 + k8s/27-gateway.yaml | 36 + k8s/30-ingress.yaml | 40 + k8s/README.md | 222 ++ k8s/kustomization.yaml | 19 + pkg/authsupport/csrf.go | 37 + pkg/authsupport/password.go | 110 + pkg/authsupport/ratelimit.go | 87 + pkg/authsupport/token.go | 58 + pkg/authsupport/token_test.go | 44 + pkg/effort/effort.go | 175 ++ pkg/effort/effort_test.go | 102 + pkg/entitlements/entitlements.go | 105 + pkg/envkit/dotenv.go | 79 + pkg/envkit/getenv.go | 60 + pkg/eventbus/bus.go | 159 ++ pkg/eventbus/events/billing_events.go | 14 + pkg/eventbus/events/conversation_events.go | 12 + pkg/eventbus/events/iam_events.go | 52 + pkg/eventbus/topology.go | 63 + pkg/grpcerr/grpcerr.go | 24 + pkg/i18n/embed.go | 26 + pkg/i18n/i18n.go | 166 ++ pkg/i18n/i18n_test.go | 54 + pkg/i18n/locales/de.json | 74 + pkg/i18n/locales/en.json | 104 + pkg/i18n/locales/es.json | 74 + pkg/i18n/locales/fr.json | 104 + pkg/i18n/locales/pt.json | 74 + pkg/i18n/negotiate.go | 64 + pkg/i18n/negotiate_test.go | 20 + pkg/mailkit/mail.go | 173 ++ pkg/mailkit/mail_test.go | 99 + pkg/mathkit/calculus.go | 115 + pkg/mathkit/expr.go | 421 +++ pkg/mathkit/linalg.go | 409 +++ pkg/mathkit/mathkit_test.go | 364 +++ pkg/mathkit/ml.go | 191 ++ pkg/mathkit/optimize.go | 135 + pkg/mathkit/solve.go | 835 ++++++ pkg/mathkit/statistics.go | 224 ++ pkg/officekit/docx.go | 557 ++++ pkg/officekit/markdown.go | 128 + pkg/officekit/markdown_test.go | 74 + pkg/officekit/officekit_test.go | 274 ++ pkg/officekit/pdf.go | 206 ++ pkg/officekit/pdf_test.go | 90 + pkg/officekit/pptx.go | 376 +++ pkg/officekit/redline.go | 302 +++ pkg/officekit/redline_test.go | 114 + pkg/officekit/xlsx.go | 505 ++++ pkg/officekit/zipkit.go | 181 ++ pkg/pgkit/pgkit.go | 107 + pkg/renderkit/highlight.go | 359 +++ pkg/renderkit/highlight_test.go | 81 + pkg/renderkit/markdown.go | 94 + pkg/renderkit/markdown_test.go | 49 + pkg/renderkit/snippets.go | 76 + pkg/renderkit/snippets_test.go | 34 + pkg/skillkit/rag.go | 208 ++ pkg/skillkit/skillkit.go | 113 + pkg/skillkit/skills/docx.md | 127 + pkg/skillkit/skills/overview.md | 62 + pkg/skillkit/skills/pdf.md | 51 + pkg/skillkit/skills/pptx.md | 59 + pkg/skillkit/skills/xlsx.md | 79 + pkg/turnopts/turnopts.go | 196 ++ pkg/turnopts/turnopts_test.go | 107 + pkg/vllmkit/client.go | 845 ++++++ pkg/vllmkit/client_test.go | 210 ++ pkg/vllmkit/types.go | 73 + services/agent/Dockerfile | 16 + services/agent/cmd/server/main.go | 254 ++ .../agent/internal/application/service.go | 457 ++++ .../agent/internal/application/throttle.go | 42 + services/agent/internal/domain/stream.go | 203 ++ .../internal/infrastructure/agentrun/agent.go | 924 +++++++ .../infrastructure/agentrun/agent_test.go | 247 ++ .../infrastructure/agentrun/effort.go | 291 +++ .../infrastructure/grpcclients/clients.go | 82 + .../infrastructure/toolcatalog/catalog.go | 750 ++++++ .../infrastructure/toolexec/executor.go | 316 +++ .../infrastructure/toolexec/mlmath.go | 29 + .../infrastructure/toolexec/office.go | 451 ++++ .../infrastructure/toolexec/office_skill.go | 60 + .../infrastructure/toolexec/subagent.go | 84 + .../infrastructure/toolexec/workflow.go | 429 ++++ .../infrastructure/toolexec/workflow_test.go | 118 + .../infrastructure/toolexec/writetodos.go | 58 + .../agent/internal/interfaces/grpc/server.go | 179 ++ services/billing/Dockerfile | 17 + services/billing/cmd/server/main.go | 109 + .../billing/internal/application/service.go | 320 +++ .../billing/internal/domain/subscription.go | 112 + .../postgres/subscription_repo.go | 154 ++ .../infrastructure/stripeapi/stripe.go | 468 ++++ .../internal/interfaces/grpc/server.go | 141 + services/billing/migrations/0001_init.sql | 47 + services/conversation/Dockerfile | 17 + services/conversation/cmd/server/main.go | 98 + .../internal/application/service.go | 75 + .../internal/domain/conversation.go | 70 + .../postgres/conversation_repo.go | 125 + .../infrastructure/postgres/message_repo.go | 76 + .../internal/interfaces/grpc/server.go | 179 ++ .../conversation/migrations/0001_init.sql | 38 + services/gateway/Dockerfile | 21 + services/gateway/cmd/server/main.go | 173 ++ .../gateway/internal/handlers/attachments.go | 481 ++++ services/gateway/internal/handlers/auth.go | 210 ++ services/gateway/internal/handlers/billing.go | 282 ++ services/gateway/internal/handlers/chat.go | 287 +++ services/gateway/internal/handlers/core.go | 279 ++ .../gateway/internal/handlers/deliverables.go | 312 +++ services/gateway/internal/handlers/files.go | 272 ++ services/gateway/internal/handlers/i18n.go | 70 + .../gateway/internal/handlers/middleware.go | 315 +++ services/gateway/internal/handlers/pages.go | 324 +++ services/gateway/internal/handlers/ui.go | 187 ++ services/gateway/web/static/css/style.css | 950 +++++++ .../gateway/web/static/img/enkly-logo.png | Bin 0 -> 25521 bytes services/gateway/web/static/js/app.js | 673 +++++ .../gateway/web/static/js/password-toggle.js | 54 + .../gateway/web/static/js/vendor/htmx-sse.js | 290 +++ .../gateway/web/static/js/vendor/htmx.min.js | 1 + .../gateway/web/templates/attachments.html | 34 + services/gateway/web/templates/auth.html | 245 ++ .../gateway/web/templates/chat_response.html | 115 + .../gateway/web/templates/deliverables.html | 43 + services/gateway/web/templates/files.html | 81 + services/gateway/web/templates/index.html | 268 ++ services/gateway/web/templates/message.html | 116 + services/gateway/web/templates/partials.html | 31 + .../gateway/web/templates/subscription.html | 114 + services/gateway/web/templates/tree.html | 38 + services/iam/Dockerfile | 28 + services/iam/cmd/server/main.go | 132 + services/iam/internal/application/login.go | 92 + .../internal/application/password_reset.go | 69 + services/iam/internal/application/signup.go | 194 ++ services/iam/internal/application/throttle.go | 48 + services/iam/internal/domain/errors.go | 15 + services/iam/internal/domain/user.go | 92 + .../infrastructure/postgres/user_repo.go | 128 + .../postgres/verification_repo.go | 178 ++ .../iam/internal/interfaces/grpc/server.go | 128 + services/iam/migrations/0001_init.sql | 67 + services/knowledge/Dockerfile | 19 + services/knowledge/cmd/server/main.go | 109 + .../knowledge/internal/application/service.go | 95 + services/knowledge/internal/domain/chunk.go | 120 + .../knowledge/internal/domain/document.go | 45 + .../infrastructure/postgres/document_repo.go | 124 + .../internal/interfaces/grpc/server.go | 73 + services/knowledge/migrations/0001_init.sql | 27 + services/notification/Dockerfile | 17 + services/notification/cmd/server/main.go | 81 + .../internal/application/service.go | 96 + services/workspace/Dockerfile | 23 + services/workspace/cmd/server/main.go | 96 + .../infrastructure/sandboxfs/facade.go | 78 + .../infrastructure/sandboxfs/sandbox.go | 437 ++++ .../infrastructure/sandboxfs/tools.go | 764 ++++++ .../infrastructure/sandboxfs/types.go | 21 + .../internal/interfaces/grpc/files.go | 189 ++ .../internal/interfaces/grpc/server.go | 97 + tools/indexer/Dockerfile | 11 + tools/indexer/cmd/indexer/main.go | 176 ++ 211 files changed, 44434 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGES.md create mode 100644 Makefile create mode 100644 UI-REDESIGN.md create mode 100644 api/proto/agent/v1/agent.proto create mode 100644 api/proto/billing/v1/billing.proto create mode 100644 api/proto/common/v1/common.proto create mode 100644 api/proto/conversation/v1/conversation.proto create mode 100644 api/proto/iam/v1/iam.proto create mode 100644 api/proto/knowledge/v1/knowledge.proto create mode 100644 api/proto/workspace/v1/workspace.proto create mode 100644 docker-compose.yml create mode 100644 gen/agent/v1/agent.pb.go create mode 100644 gen/agent/v1/agent_grpc.pb.go create mode 100644 gen/billing/v1/billing.pb.go create mode 100644 gen/billing/v1/billing_grpc.pb.go create mode 100644 gen/common/v1/common.pb.go create mode 100644 gen/conversation/v1/conversation.pb.go create mode 100644 gen/conversation/v1/conversation_grpc.pb.go create mode 100644 gen/iam/v1/iam.pb.go create mode 100644 gen/iam/v1/iam_grpc.pb.go create mode 100644 gen/knowledge/v1/knowledge.pb.go create mode 100644 gen/knowledge/v1/knowledge_grpc.pb.go create mode 100644 gen/workspace/v1/workspace.pb.go create mode 100644 gen/workspace/v1/workspace_grpc.pb.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 k8s/00-namespace.yaml create mode 100644 k8s/01-config.yaml create mode 100644 k8s/02-secrets.yaml create mode 100644 k8s/10-rabbitmq.yaml create mode 100644 k8s/11-vllm.yaml create mode 100644 k8s/12-databases.yaml create mode 100644 k8s/20-iam.yaml create mode 100644 k8s/21-billing.yaml create mode 100644 k8s/22-conversation.yaml create mode 100644 k8s/23-workspace.yaml create mode 100644 k8s/24-knowledge.yaml create mode 100644 k8s/25-agent.yaml create mode 100644 k8s/26-notification.yaml create mode 100644 k8s/27-gateway.yaml create mode 100644 k8s/30-ingress.yaml create mode 100644 k8s/README.md create mode 100644 k8s/kustomization.yaml create mode 100644 pkg/authsupport/csrf.go create mode 100644 pkg/authsupport/password.go create mode 100644 pkg/authsupport/ratelimit.go create mode 100644 pkg/authsupport/token.go create mode 100644 pkg/authsupport/token_test.go create mode 100644 pkg/effort/effort.go create mode 100644 pkg/effort/effort_test.go create mode 100644 pkg/entitlements/entitlements.go create mode 100644 pkg/envkit/dotenv.go create mode 100644 pkg/envkit/getenv.go create mode 100644 pkg/eventbus/bus.go create mode 100644 pkg/eventbus/events/billing_events.go create mode 100644 pkg/eventbus/events/conversation_events.go create mode 100644 pkg/eventbus/events/iam_events.go create mode 100644 pkg/eventbus/topology.go create mode 100644 pkg/grpcerr/grpcerr.go create mode 100644 pkg/i18n/embed.go create mode 100644 pkg/i18n/i18n.go create mode 100644 pkg/i18n/i18n_test.go create mode 100644 pkg/i18n/locales/de.json create mode 100644 pkg/i18n/locales/en.json create mode 100644 pkg/i18n/locales/es.json create mode 100644 pkg/i18n/locales/fr.json create mode 100644 pkg/i18n/locales/pt.json create mode 100644 pkg/i18n/negotiate.go create mode 100644 pkg/i18n/negotiate_test.go create mode 100644 pkg/mailkit/mail.go create mode 100644 pkg/mailkit/mail_test.go create mode 100644 pkg/mathkit/calculus.go create mode 100644 pkg/mathkit/expr.go create mode 100644 pkg/mathkit/linalg.go create mode 100644 pkg/mathkit/mathkit_test.go create mode 100644 pkg/mathkit/ml.go create mode 100644 pkg/mathkit/optimize.go create mode 100644 pkg/mathkit/solve.go create mode 100644 pkg/mathkit/statistics.go create mode 100644 pkg/officekit/docx.go create mode 100644 pkg/officekit/markdown.go create mode 100644 pkg/officekit/markdown_test.go create mode 100644 pkg/officekit/officekit_test.go create mode 100644 pkg/officekit/pdf.go create mode 100644 pkg/officekit/pdf_test.go create mode 100644 pkg/officekit/pptx.go create mode 100644 pkg/officekit/redline.go create mode 100644 pkg/officekit/redline_test.go create mode 100644 pkg/officekit/xlsx.go create mode 100644 pkg/officekit/zipkit.go create mode 100644 pkg/pgkit/pgkit.go create mode 100644 pkg/renderkit/highlight.go create mode 100644 pkg/renderkit/highlight_test.go create mode 100644 pkg/renderkit/markdown.go create mode 100644 pkg/renderkit/markdown_test.go create mode 100644 pkg/renderkit/snippets.go create mode 100644 pkg/renderkit/snippets_test.go create mode 100644 pkg/skillkit/rag.go create mode 100644 pkg/skillkit/skillkit.go create mode 100644 pkg/skillkit/skills/docx.md create mode 100644 pkg/skillkit/skills/overview.md create mode 100644 pkg/skillkit/skills/pdf.md create mode 100644 pkg/skillkit/skills/pptx.md create mode 100644 pkg/skillkit/skills/xlsx.md create mode 100644 pkg/turnopts/turnopts.go create mode 100644 pkg/turnopts/turnopts_test.go create mode 100644 pkg/vllmkit/client.go create mode 100644 pkg/vllmkit/client_test.go create mode 100644 pkg/vllmkit/types.go create mode 100644 services/agent/Dockerfile create mode 100644 services/agent/cmd/server/main.go create mode 100644 services/agent/internal/application/service.go create mode 100644 services/agent/internal/application/throttle.go create mode 100644 services/agent/internal/domain/stream.go create mode 100644 services/agent/internal/infrastructure/agentrun/agent.go create mode 100644 services/agent/internal/infrastructure/agentrun/agent_test.go create mode 100644 services/agent/internal/infrastructure/agentrun/effort.go create mode 100644 services/agent/internal/infrastructure/grpcclients/clients.go create mode 100644 services/agent/internal/infrastructure/toolcatalog/catalog.go create mode 100644 services/agent/internal/infrastructure/toolexec/executor.go create mode 100644 services/agent/internal/infrastructure/toolexec/mlmath.go create mode 100644 services/agent/internal/infrastructure/toolexec/office.go create mode 100644 services/agent/internal/infrastructure/toolexec/office_skill.go create mode 100644 services/agent/internal/infrastructure/toolexec/subagent.go create mode 100644 services/agent/internal/infrastructure/toolexec/workflow.go create mode 100644 services/agent/internal/infrastructure/toolexec/workflow_test.go create mode 100644 services/agent/internal/infrastructure/toolexec/writetodos.go create mode 100644 services/agent/internal/interfaces/grpc/server.go create mode 100644 services/billing/Dockerfile create mode 100644 services/billing/cmd/server/main.go create mode 100644 services/billing/internal/application/service.go create mode 100644 services/billing/internal/domain/subscription.go create mode 100644 services/billing/internal/infrastructure/postgres/subscription_repo.go create mode 100644 services/billing/internal/infrastructure/stripeapi/stripe.go create mode 100644 services/billing/internal/interfaces/grpc/server.go create mode 100644 services/billing/migrations/0001_init.sql create mode 100644 services/conversation/Dockerfile create mode 100644 services/conversation/cmd/server/main.go create mode 100644 services/conversation/internal/application/service.go create mode 100644 services/conversation/internal/domain/conversation.go create mode 100644 services/conversation/internal/infrastructure/postgres/conversation_repo.go create mode 100644 services/conversation/internal/infrastructure/postgres/message_repo.go create mode 100644 services/conversation/internal/interfaces/grpc/server.go create mode 100644 services/conversation/migrations/0001_init.sql create mode 100644 services/gateway/Dockerfile create mode 100644 services/gateway/cmd/server/main.go create mode 100644 services/gateway/internal/handlers/attachments.go create mode 100644 services/gateway/internal/handlers/auth.go create mode 100644 services/gateway/internal/handlers/billing.go create mode 100644 services/gateway/internal/handlers/chat.go create mode 100644 services/gateway/internal/handlers/core.go create mode 100644 services/gateway/internal/handlers/deliverables.go create mode 100644 services/gateway/internal/handlers/files.go create mode 100644 services/gateway/internal/handlers/i18n.go create mode 100644 services/gateway/internal/handlers/middleware.go create mode 100644 services/gateway/internal/handlers/pages.go create mode 100644 services/gateway/internal/handlers/ui.go create mode 100644 services/gateway/web/static/css/style.css create mode 100644 services/gateway/web/static/img/enkly-logo.png create mode 100644 services/gateway/web/static/js/app.js create mode 100644 services/gateway/web/static/js/password-toggle.js create mode 100644 services/gateway/web/static/js/vendor/htmx-sse.js create mode 100644 services/gateway/web/static/js/vendor/htmx.min.js create mode 100644 services/gateway/web/templates/attachments.html create mode 100644 services/gateway/web/templates/auth.html create mode 100644 services/gateway/web/templates/chat_response.html create mode 100644 services/gateway/web/templates/deliverables.html create mode 100644 services/gateway/web/templates/files.html create mode 100644 services/gateway/web/templates/index.html create mode 100644 services/gateway/web/templates/message.html create mode 100644 services/gateway/web/templates/partials.html create mode 100644 services/gateway/web/templates/subscription.html create mode 100644 services/gateway/web/templates/tree.html create mode 100644 services/iam/Dockerfile create mode 100644 services/iam/cmd/server/main.go create mode 100644 services/iam/internal/application/login.go create mode 100644 services/iam/internal/application/password_reset.go create mode 100644 services/iam/internal/application/signup.go create mode 100644 services/iam/internal/application/throttle.go create mode 100644 services/iam/internal/domain/errors.go create mode 100644 services/iam/internal/domain/user.go create mode 100644 services/iam/internal/infrastructure/postgres/user_repo.go create mode 100644 services/iam/internal/infrastructure/postgres/verification_repo.go create mode 100644 services/iam/internal/interfaces/grpc/server.go create mode 100644 services/iam/migrations/0001_init.sql create mode 100644 services/knowledge/Dockerfile create mode 100644 services/knowledge/cmd/server/main.go create mode 100644 services/knowledge/internal/application/service.go create mode 100644 services/knowledge/internal/domain/chunk.go create mode 100644 services/knowledge/internal/domain/document.go create mode 100644 services/knowledge/internal/infrastructure/postgres/document_repo.go create mode 100644 services/knowledge/internal/interfaces/grpc/server.go create mode 100644 services/knowledge/migrations/0001_init.sql create mode 100644 services/notification/Dockerfile create mode 100644 services/notification/cmd/server/main.go create mode 100644 services/notification/internal/application/service.go create mode 100644 services/workspace/Dockerfile create mode 100644 services/workspace/cmd/server/main.go create mode 100644 services/workspace/internal/infrastructure/sandboxfs/facade.go create mode 100644 services/workspace/internal/infrastructure/sandboxfs/sandbox.go create mode 100644 services/workspace/internal/infrastructure/sandboxfs/tools.go create mode 100644 services/workspace/internal/infrastructure/sandboxfs/types.go create mode 100644 services/workspace/internal/interfaces/grpc/files.go create mode 100644 services/workspace/internal/interfaces/grpc/server.go create mode 100644 tools/indexer/Dockerfile create mode 100644 tools/indexer/cmd/indexer/main.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5f836cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,60 @@ +.git/ +.gitea/ + +# --- Secrets: never let these reach the Docker daemon --- +.env +.env.* +!.env.example + +# --- Other services' sources (each build only needs its own) --- +# Deliberately NOT excluded here because the Dockerfiles already COPY +# only the one services// directory they need — Docker's build +# context still includes the rest unless listed below. Uncomment the +# ones you don't need for a given service's build, or leave as-is for +# simplicity (COPY-level filtering already prevents cross-contamination). +# services/agent/ +# services/billing/ +# services/conversation/ +# services/gateway/ +# services/iam/ +# services/knowledge/ +# services/notification/ +# services/workspace/ + +# --- Docs (not needed inside any image) --- +*.md +ARCHITECTURE.md +CHANGES.md +README.md +UI-REDESIGN.md + +# --- Kubernetes / CI / infra manifests (not part of any Go build) --- +k8s/ +.gitea/workflows/ +docker-compose.yml +docker-compose.*.yml + +# --- Go build artifacts / local tooling --- +/bin/ +/dist/ +*.test +*.out +coverage.out +coverage.html +/tools/bin/ +go.work +go.work.sum +/vendor/ + +# --- Editor / OS cruft --- +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# --- Misc --- +*.log +tmp/ \ No newline at end of file diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..2abb717 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,211 @@ +name: Go CI Pipeline +on: [push, pull_request] + +jobs: + build_and_test: + runs-on: ubuntu-latest + + # Start a Docker-in-Docker service container + services: + docker: + image: docker:dind + # --dns pins the dind container to reliable public resolvers instead + # of whatever it inherits from the runner host. Without this, the + # embedded 127.0.0.11 resolver inside dind intermittently fails to + # forward lookups for the registry host ("server misbehaving"), + # which otherwise kills `docker push` nondeterministically. + options: --privileged --dns 1.1.1.1 --dns 8.8.8.8 + env: + DOCKER_TLS_CERTDIR: "" + + # Point docker CLI to the dind container + env: + DOCKER_HOST: tcp://docker:2375 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - name: Install dependencies + run: go mod tidy && go mod download + + - name: Vet and test + run: | + go vet ./... + go test ./... + + - name: Login to private registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push container images + run: | + set -e + REG="${{ secrets.REGISTRY_URL }}/enkly" + SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}" + + # Retry helper: docker push against the dind service occasionally + # hits a transient "server misbehaving" DNS failure (127.0.0.11 + # couldn't resolve the registry host) rather than a real error. + # Retry a few times with backoff before giving up for real. + push_with_retry() { + local ref="$1" + local attempt=1 + local max_attempts=5 + until docker push "$ref"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::error::docker push $ref failed after $max_attempts attempts" + return 1 + fi + local wait=$((attempt * 5)) + echo "push of $ref failed (attempt $attempt/$max_attempts); retrying in ${wait}s..." + sleep "$wait" + attempt=$((attempt + 1)) + done + } + + for svc in iam billing conversation workspace knowledge agent notification gateway; do + docker build -f services/$svc/Dockerfile -t "$REG/$svc:$SHA" -t "$REG/$svc:latest" . + push_with_retry "$REG/$svc:$SHA" + push_with_retry "$REG/$svc:latest" + done + + - name: Build and push vLLM CPU (no AVX512) image + # This is NOT one of the 8 enkly service images above — it's the + # custom vllm/vllm-openai build that k8s/11-vllm.yaml's vllm-chat + # and vllm-embed Deployments require instead of the official + # public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo image, because + # that pre-built image hard-requires avx512f (missing on the + # target cluster's nodes) and crashes with "Illegal instruction" + # without it. Source and build args match k8s/11-vllm.yaml's and + # k8s/README.md's documented command, plus an explicit max_jobs + # cap explained below. + # + # Root cause of the previous failure ("Cannot connect to the + # Docker daemon at tcp://localhost:2375" mid-build): vLLM's + # docker/Dockerfile.cpu defaults to `ARG max_jobs=32`, i.e. 32 + # parallel C++ translation units (oneDNN/cutlass kernels etc.) + # compiling at once, each easily using >1GB RES. The dind + # service here — like the target cluster's own nodes (see + # k8s/README.md: ~7949Mi/~7.76Gi per node) — only has a few GB + # of RAM and 2 vCPUs, nowhere near enough for 32-way parallel + # compilation. The build log showed exactly this: individual + # objects going from ~1-10s to 317s (heavy swapping) right + # before the dind container's dockerd was OOM-killed, which + # drops the daemon out from under the still-running `docker + # build` and surfaces as a generic "cannot connect" error rather + # than an explicit OOM message. A plain retry doesn't fix this — + # dockerd itself died, so the service container is gone — only + # bounding parallelism does. We size MAX_JOBS off the runner's + # actual CPU count and memory (~2GB headroom per job) instead of + # trusting the Dockerfile's hardcoded default, floor 1. + # + # Caveat: this clones and compiles vLLM from source on every run + # of this job, on a small runner — expect this step alone to + # take a long time and to be the slowest part of the pipeline. + # If that becomes a problem, the usual fixes are: only run this + # step on a path filter scoped to a pinned vLLM version/tag + # instead of vLLM's default branch, add Docker layer caching + # (e.g. actions/cache or a registry-backed BuildKit cache), or + # move it to its own workflow that isn't triggered on every + # push/PR. We already pin the clone to a tagged vLLM release + # below so a build is reproducible and isn't broken by upstream + # `main` changing under us. + run: | + set -e + REG="${{ secrets.REGISTRY_URL }}/enkly" + SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}" + VLLM_REF="v0.8.5" + + push_with_retry() { + local ref="$1" + local attempt=1 + local max_attempts=5 + until docker push "$ref"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::error::docker push $ref failed after $max_attempts attempts" + return 1 + fi + local wait=$((attempt * 5)) + echo "push of $ref failed (attempt $attempt/$max_attempts); retrying in ${wait}s..." + sleep "$wait" + attempt=$((attempt + 1)) + done + } + + # Make sure the dind daemon is actually reachable before we + # kick off a build that can run for tens of minutes. + for i in $(seq 1 30); do + docker info >/dev/null 2>&1 && break + echo "waiting for docker daemon ($i/30)..." + sleep 2 + done + docker info >/dev/null 2>&1 || { echo "::error::docker daemon at $DOCKER_HOST is not reachable"; exit 1; } + + # Cap compile parallelism to what this runner can actually + # hold in memory, instead of the Dockerfile's hardcoded + # max_jobs=32 (see note above for why that OOM-kills dockerd). + # Budget ~2GB per compile job and never exceed the vCPU count. + CPU_COUNT="$(nproc 2>/dev/null || echo 2)" + MEM_KB="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)" + MEM_JOBS=$((MEM_KB / 1024 / 1024 / 2)) + MAX_JOBS="$CPU_COUNT" + if [ "$MEM_JOBS" -gt 0 ] && [ "$MEM_JOBS" -lt "$MAX_JOBS" ]; then + MAX_JOBS="$MEM_JOBS" + fi + [ "$MAX_JOBS" -lt 1 ] && MAX_JOBS=1 + echo "Building vLLM with MAX_JOBS=$MAX_JOBS (cpu=$CPU_COUNT, mem_kb=$MEM_KB)" + + # `git clone` below runs directly on the runner host (unlike + # `docker build`/push, it never touches $DOCKER_HOST), so the + # --dns 1.1.1.1/8.8.8.8 pin on the dind *service* above does + # nothing for it — that only fixes resolution for things the + # dind daemon itself does (image pulls, registry push). A + # host-level resolver hiccup ("Could not resolve host: + # github.com") is the same class of transient DNS flakiness + # called out in the dind `options` comment, just hitting a + # different resolver. Retry with backoff, and nudge the host + # resolver between attempts where we can (best-effort, never + # fatal if the tool/permission isn't there). + clone_with_retry() { + local attempt=1 + local max_attempts=5 + until git clone --depth 1 --branch "$VLLM_REF" https://github.com/vllm-project/vllm.git /tmp/vllm-build; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::error::git clone of vllm-project/vllm failed after $max_attempts attempts" + return 1 + fi + local wait=$((attempt * 5)) + echo "git clone failed (attempt $attempt/$max_attempts, likely DNS); retrying in ${wait}s..." + sleep "$wait" + # Best-effort: if the host resolver cache is stuck, poke it. + # Ignore failures - these commands may not exist/be permitted. + (command -v resolvectl >/dev/null 2>&1 && sudo resolvectl flush-caches) || true + (command -v systemd-resolve >/dev/null 2>&1 && sudo systemd-resolve --flush-caches) || true + getent hosts github.com >/dev/null 2>&1 || true + rm -rf /tmp/vllm-build + attempt=$((attempt + 1)) + done + } + + rm -rf /tmp/vllm-build + clone_with_retry + + docker build -f /tmp/vllm-build/docker/Dockerfile.cpu \ + --build-arg VLLM_CPU_DISABLE_AVX512=true \ + --build-arg max_jobs="$MAX_JOBS" \ + --tag "$REG/vllm-cpu:$SHA" \ + --tag "$REG/vllm-cpu:latest" \ + --target vllm-openai \ + /tmp/vllm-build + + push_with_retry "$REG/vllm-cpu:$SHA" + push_with_retry "$REG/vllm-cpu:latest" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2778cbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +.env +.env.* +!.env.example + +# --- Go build artifacts --- +/bin/ +/dist/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +coverage.out +coverage.html + +# Go workspace files (only relevant if someone adds a multi-module go.work locally) +go.work +go.work.sum + +# Dependency vendoring (this repo doesn't vendor; ignore in case it's added ad hoc) +/vendor/ + +# --- Protobuf tooling --- +# gen/ is intentionally tracked (every Dockerfile COPYs it as-is, +# see services/*/Dockerfile) — do NOT ignore it. Only ignore the +# plugin binaries `make tools` would install locally. +/tools/bin/ +*.pb.go.tmp + +# --- Runtime data (bind-mount targets if compose volumes are ever +# switched from named volumes to host paths) --- +/data/ +/.cache/huggingface/ +/pgdata/ + +# --- Editor / OS cruft --- +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# --- Misc local scratch --- +*.log +.air.toml +tmp/ \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..7524f6a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,332 @@ +# Architecture + +This document records the bounded-context decomposition of the +**enkly-ai** monolith, why each boundary is drawn where it is, the +communication rules enforced throughout, and the handful of places +where splitting a single process into eight forced a small, deliberate +change in behavior. + +## 1. Bounded contexts (the context map) + +The monolith's packages were grouped by **technical layer** +(`internal/handlers`, `internal/db`, `internal/llm`, ...); this port +regroups them by **business capability**, each with its own data and +its own deployable: + +``` + ┌─────────────┐ + │ gateway │ (HTTP/HTMX edge — no DB) + └──────┬──────┘ + ┌──────────┬───────┼────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ │ + ┌───────┐ ┌───────┐┌──────┐┌─────────┐┌─────────┐ │ + │ iam │ │billing││ conv │ │workspace││knowledge│ │ + └───┬───┘ └───┬───┘└──────┘└─────────┘└────┬────┘ │ + │ │ │ │ + │ events │ events │ ▼ + ▼ ▼ │ ┌───────┐ + ┌──────────────────┐ └──▶│ agent │ + │ notification │ └───┬───┘ + └──────────────────┘ │ + (agent also calls billing, + conversation, workspace, + knowledge — see §3) +``` + +| Context | Aggregate(s) | Replaces (monolith) | +|---|---|---| +| **IAM** | User, Session, VerificationCode, PasswordResetToken | `internal/handlers/auth.go`, `internal/db/users.go`, `internal/auth/*` | +| **Billing** | Subscription, StripeEvent, DailyUsage | `internal/handlers/billing.go`, `internal/billing/*`, `internal/db/subscriptions.go` | +| **Conversation** | Conversation, Message, ToolCall | `internal/db/conversations.go` | +| **Workspace** | (the sandboxed filesystem itself) | `internal/llm/tools.go` + `sandbox.go`, `internal/handlers/files.go` | +| **Knowledge** | CodeChunk (pgvector document) | `internal/rag/*` | +| **Agent** | Stream (an in-flight run) — a saga coordinator, owns no table | `internal/llm/agent.go` + `client.go`, the run-orchestration half of `internal/handlers/chat.go` | +| **Notification** | (stateless consumer) | the email-sending half of `internal/handlers/auth.go`, `internal/mail/*` | +| **Gateway (BFF)** | (no domain state) | `internal/handlers/{pages,ui,deliverables,middleware}.go`, `internal/render/*` | + +**Why Agent is its own context, not folded into Workspace or +Conversation:** the agent run loop is a process that *uses* workspace +(tool execution), knowledge (semantic search) and conversation +(persistence) to do its job, but owns none of their data. That's a +textbook application service / saga, not an aggregate of any of the +three — giving it its own deployable keeps "what tools exist and how a +turn is orchestrated" independently scalable and independently +restartable from "where files live" or "what was said." + +**Why Workspace is separate from Agent**, even though today only Agent +calls it: the sandboxed filesystem is also directly reachable by a +human through the gateway's upload/download/explorer panel. Coupling +it to Agent would mean every file-browsing click goes through the LLM +orchestration service for no reason, and would block scaling +"concurrent agent runs" independently of "workspace storage." + +**Why rendering lives only in Gateway:** `pkg/renderkit` (Markdown, +syntax highlighting, code-snippet extraction) and every `html/template` +file are a presentation concern, not a domain concept — no bounded +context's *business rule* depends on HTML. Agent streams plain text and +structured tool-call data; Gateway is the one place that turns that +into HTML. This is also what makes Agent's gRPC contract +(`api/proto/agent/v1/agent.proto`) reusable by a future non-HTML client +(a CLI, a different front end) without change. + +## 2. Database-per-service + +Every context that owns data gets its **own Postgres database** (not +just its own schema/tables in a shared instance): + +- `iam` → `enkly_iam` (users, sessions, email_verification_codes, password_reset_tokens) +- `billing` → `enkly_billing` (subscriptions, stripe_events, usage_daily) +- `conversation` → `enkly_conversation` (conversations, messages, tool_calls) +- `knowledge` → `enkly_knowledge` (documents, pgvector) + +`workspace` has no database — the sandboxed directory tree itself +*is* its data, on its own Docker volume. + +**Consequence worth naming explicitly:** the monolith's `schema.sql` +had `user_id BIGINT REFERENCES users(id) ON DELETE CASCADE` on +`conversations`, `subscriptions`, and `usage_daily`. Postgres cannot +enforce a foreign key across two separate databases, so every one of +those is now a bare `user_id BIGINT` with **no cascade delete**. The +monolith never actually exposed an "delete my account" feature, so no +behavior is lost today — but if one is added later, deleting a user +must become a saga (iam deletes the user, then publishes a +`user.deleted` event for billing/conversation to react to and delete +their own rows), not a single SQL statement. This is the standard, +unavoidable cost of database-per-service and is called out here so it +isn't rediscovered the hard way. + +## 3. Communication rules + +> *"gRPC for synchronous communication, RabbitMQ for asynchronous +> communication"* — applied throughout as: **if the caller needs an +> answer (or needs to know the write succeeded) before it can proceed, +> it's a gRPC call; if the caller doesn't need to wait for the result, +> it's an event.** + +### Synchronous (gRPC) — `api/proto/*/v1/*.proto` + +Every cross-context call that's part of a request's critical path: + +- Gateway → IAM: session validation on (almost) every request, login, signup, ... +- Gateway → every other service: composing a page from their data +- Agent → Billing: check-and-increment the daily quota before a run starts +- Agent → Conversation: persist each message/tool-call as a turn proceeds +- Agent → Workspace: every filesystem/bash tool call +- Agent → Knowledge: the `search_codebase` tool +- `dispatch_subagent` makes **no new outbound call of its own kind**: it runs a second, + nested `agentrun.Agent` in-process, sharing the Agent service's own vLLM-backed client and + making the *same* Workspace/Knowledge gRPC calls an ordinary tool call would, just via a + `toolexec.restrictedExecutor` that only forwards the read-only subset + (`read_file`/`list_directory`/`glob_search`/`grep`/`search_codebase`). No new service, no + new proto — see `services/agent/internal/infrastructure/toolexec/subagent.go`. +- `solve_ml_math` (like `write_todos`) makes **no** outbound call at all: it's pure + computation over `pkg/mathkit`, a dependency-free numerical engine (linear algebra, + calculus/gradients, statistics, optimization, ML primitives) that runs entirely inside + the agent process — see `pkg/mathkit`'s package doc and + `services/agent/internal/infrastructure/toolexec/mlmath.go`. +- Agent `StreamTurn`: a **server-streaming** RPC — the gRPC replacement + for the monolith's Server-Sent Events, carrying the same + token/thinking/tool-call/done event sequence; the gateway re-emits + each message as an actual SSE frame to the browser (see + `services/gateway/internal/handlers/chat.go`). + +### Asynchronous (RabbitMQ) — `pkg/eventbus` + +One topic exchange (`enkly.events`); routing keys of the form +`..`. Today's only real consumer is +`notification`, bound to: + +- `iam.user.verification_code_issued` → send the signup/resend email +- `iam.user.password_reset_requested` → send the reset-link email + +Also published, with no consumer yet (kept so the *pattern* — every +aggregate commit gets an event — is consistent across every context, +and so a future service can bind without a producer-side change): +`iam.user.registered`, `iam.user.email_verified`, +`billing.subscription.changed`, `conversation.message.created`. + +**Trust boundary of the event bus:** `VerificationCodeIssued` and +`PasswordResetRequested` carry the raw code/token on the bus, not just +an ID. This is a deliberate trade-off (documented in +`pkg/eventbus/events/iam_events.go`) rather than an oversight: it lets +`notification` send the exact email the monolith sent without needing +read access to `iam`'s token tables (which would violate +database-per-service far more seriously than this does). It's safe +*only* because RabbitMQ is never exposed outside the private service +network — the same trust level the monolith implicitly gave its own +in-process function call. A production hardening pass should put this +exchange behind TLS + per-service credentials (RabbitMQ supports both) +even on a private network, as defense in depth. + +## 4. One Go module, many deployables + +Every service shares a single `module enkly` (one `go.mod` at the repo +root) rather than one `go.mod` per service. This is a deliberate, +pragmatic choice: true microservice *independence* comes from separate +processes, separate databases, separate containers, and a versioned +API contract between them (all of which this repo has) — not from +separate Go dependency graphs, which mainly adds `replace`-directive +and version-skew overhead in a monorepo without buying any of the +actual deployability benefits. Each service still has its own +`cmd/server/main.go`, its own `Dockerfile` that builds only that one +binary, and starts, scales, and fails independently of the others. + +## 5. Layering within a service + +``` +internal/domain/ entities + repository interfaces (port). No sql, no net/http, no generated gRPC code. +internal/application/ use cases. Depends on domain only. Transport-agnostic — unit-testable without a server. +internal/infrastructure/ adapters: Postgres repos, the Stripe/vLLM HTTP clients, the sandboxed filesystem. +internal/interfaces/grpc/ the ONLY files that import this service's generated `gen//v1` package. +``` + +Dependency direction is inward: `interfaces` and `infrastructure` both +depend on `domain` (through its interfaces); `domain` depends on +nothing. This is what lets, e.g., `services/iam/internal/application` +be tested with a fake `UserRepository` and no database at all. + +## 6. What's verified, what isn't + +Every `.proto` file in `api/proto/` was compiled for real with +`protoc` + the actual `protoc-gen-go`/`protoc-gen-go-grpc` plugins — +`gen/` is genuine generated code, not hand-written stand-ins. Every +hand-written `.go` file in this repo passes `gofmt -l` (verified file +by file as it was written). + +What this port could **not** do, for lack of outbound network access +to `proxy.golang.org`/`google.golang.org` in the authoring sandbox: run +`go build`/`go mod tidy` end-to-end across the whole dependency graph +(`google.golang.org/grpc`, `github.com/lib/pq`, +`github.com/rabbitmq/amqp091-go`, `github.com/pgvector/pgvector-go`, +`github.com/gomarkdown/markdown`), and so no `go.sum` is committed. +Every service's Dockerfile therefore runs `go mod tidy` at build time +(after copying in `gen/`, `pkg/`, and that service's own directory) to +resolve and lock the graph on first build — this needs ordinary +internet access, same as any other Go project, and is expected to +succeed on any normal developer machine or CI runner. `make build` +(plain `go build`, no Docker) needs a `go.sum` to exist first, so run +`go mod tidy` at the repo root once before using it locally. Once a +`go.sum` is committed, each Dockerfile's `go mod tidy` line should be +swapped back to `go mod download` (noted inline in +`services/iam/Dockerfile`) for faster, better-cached rebuilds. + +The same constraint applied to the later Ollama→vLLM migration +(`pkg/vllmkit`, replacing `pkg/ollamakit`): `pkg/vllmkit` itself has no +dependency beyond the Go standard library, so it was verified for real +— `go build`/`go test` both pass in an isolated module — but every +file that imports it still depends on the same ungenerated `go.sum`, +so those files could only be checked with `gofmt -l` (a full syntax +parse; it does not resolve imports or type-check across packages), not +a full `go build ./...`. Run `go build ./...` (or `make build`) as the +first gate on that change in an environment with normal network +access, before trusting it beyond what `gofmt` already confirms. See +`CHANGES.md`'s most recent entry for the full migration writeup. + +## 7. Known gaps / simplifications + +Called out here rather than silently shipped, each with why it's safe +to defer: + +- **Session validation latency.** `gateway`'s `RequireAuth` now makes + one `IAM.ValidateSession` gRPC call per request, where the monolith + did one local Postgres query in the same process. For most + deployments this is low-single-digit-millisecond overhead on an + internal network and is the correct place to start; if it becomes a + bottleneck, the standard fix is a short-TTL cache in the gateway (or + switching sessions to signed, self-contained tokens) — not + re-merging the services. +- **Reattaching to a run after a page reload.** The monolith could show + a still-in-flight reply immediately on reload + (`StreamManager.activeFor`). The gateway doesn't re-wire this today + (it would need a new "is there an active run for this conversation" + Agent RPC with no other caller) — a reload mid-reply shows the + completed answer on next load instead of resuming the live view. The + reply itself is unaffected; this is UI polish, not correctness. +- **Deleting a conversation mid-run.** The monolith proactively + cancelled any in-flight stream for a conversation being deleted. + Agent's API has no "stop by conversation" RPC (only by `stream_id`), + so a straggling run's writes will fail (logged) against the deleted + conversation rather than being pre-empted. Low-impact and easy to + close later by adding that RPC. +- **Verification/reset email confirmation.** Because sending email is + now asynchronous (RabbitMQ), `iam`'s `SignUp`/`ForgotPassword` RPCs + can no longer report back "the email failed to send" synchronously + the way the monolith occasionally could — they report success once + the code/token is persisted and the event is published. This is the + expected, intentional cost of decoupling a side effect onto a + message bus, not a bug. +- **Per-call bash timeout.** `WorkspaceService.RunBashCommand` accepts + a `timeout_seconds` field for forward-compatibility, but (matching + the monolith, which only ever had one process-wide timeout) does not + yet apply it per call. +- **Chat attachments aren't a schema field.** The composer's "Add + files or photos" (`services/gateway/internal/handlers/attachments.go`) + uploads into the workspace under `attachments/` and folds the + resulting paths into the prompt string itself, as a trailing + `[Attached files]` block — `conversation.Message` has no attachments + field of its own, and adding one would be a schema change across the + conversation/gateway boundary. The trade-off: attachments only ever + round-trip as a specific text shape inside `content`, parsed back out + by `renderUserMessage`, instead of being structured data the + conversation service understands natively. Also: removing a staged + chip before sending, or never sending at all, leaves the uploaded + file behind in the workspace — nothing currently reaps attachments + that were uploaded but never attached to a sent message. +- **Zip attachments extract one level deep.** A `.zip` attached to the + chat is unpacked into `attachments//` (gateway-side, with its + own zip-slip guard on top of workspace's existing path jail, plus + caps on entry count and total uncompressed size) so the agent's + existing `read_file`/`list_directory`/`grep`/`glob_search` tools can + read what's inside it — no new agent tool needed. A `.zip` nested + inside that `.zip` is written out as-is rather than recursively + extracted; `unzip` isn't in `DefaultAllowedCommands` + (`services/workspace/internal/infrastructure/sandboxfs/sandbox.go`), + so the agent can't shell out to unpack it further unless an operator + adds it to `ENKLY_BASH_ALLOWLIST`. +- **Language preference storage.** The language switcher (`pkg/i18n`, + `services/gateway/internal/handlers/i18n.go`) persists a visitor's + chosen UI locale in a year-long `enkly_lang` cookie, not on the IAM + `User` row. That was a deliberate choice, not an oversight: adding a + `preferred_language` field means extending `iam.proto`'s `User` + message and an RPC, which means regenerating every language's + generated client/server code under `gen/` — this repo's `gen/` + directory is checked in and hand-maintained, and doing that + regeneration safely needs `protoc` and the project's `buf`/codegen + toolchain, which isn't always available in every environment this + code is edited in. A cookie needs neither: it survives logins and + browser restarts, just not a switch to a second device. The same + reasoning is why the visitor's locale reaches the agent service as + gRPC metadata (`x-enkly-lang`, set in `chat.go`'s `withLangMetadata`, + read in `services/agent/internal/interfaces/grpc/server.go`'s + `langFromIncomingContext`) rather than as a new `StartTurnRequest` + field. Moving either onto the User row/proto later is a clean, + backward-compatible addition — `localeFrom`/`langFromIncomingContext` + are the only two places that would need a new "check the account + first" branch — just not one to do without `protoc` on hand to + verify the regenerated code. +- **UI translation coverage.** `pkg/i18n` ships English and French + complete; Spanish, German and Portuguese cover the nav/auth/composer + chrome and fall back to English for the rest (quota/error copy, the + subscription and files panels) — see `pkg/i18n/locales/*.json` and + `T`'s fallback behavior in `pkg/i18n/i18n.go`. `subscription.html` + and `files.html` aren't wired to the switcher yet (no `Lang` field on + their page-data structs). None of this affects how the agent itself + talks to a user — see `agentrun.SystemPrompt`'s "## Language" section + and `agentrun.WithPreferredLanguage`, which work from any locale + regardless of how much of the surrounding chrome has a catalog entry + for it. + +## 8. Operational hardening before a real rollout + +Deliberately out of scope for this restructuring (it's infrastructure +polish orthogonal to the DDD boundary work), but worth a checklist: + +- mTLS between services (today: plaintext gRPC on a private Docker + network — fine inside a single trusted VPC, not fine across one) +- RabbitMQ publisher confirms + a dead-letter exchange for poison + messages (today: at-least-once with a bounded retry via `Nack`) +- Replacing the in-memory `authsupport.Limiter`/`domain.Manager` + (agent's stream registry) with something shared across replicas + (Redis) once any service runs more than one instance + — both are currently per-process, same as the monolith's own + in-memory limiter was diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..b2380d9 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,1337 @@ +# What changed + +## 1. Root cause of the bug + +`agent: round 1: llm: ollama error: XML syntax error on line 5: element closed by` +is Ollama's own Go-side `encoding/xml` parser failing while rendering Qwen's +tool-calling chat template (which wraps tool calls/results in XML-ish +delimiters). Two things made this worse and are now fixed: + +- `compactToolHistory` in `services/agent/internal/infrastructure/agentrun/agent.go` + truncated old tool results with a blind byte slice, which could cut a tag in + half. Fixed with `safeHeadCut`/`safeTailCut`, which snap to the nearest + `<`/`>` (and a valid UTF-8 rune boundary) so a truncated tool result can + never hand back a dangling, malformed tag. +- There was no bounded retry for this specific, usually-transient error class. + Added `ollamakit.IsTemplateParseError` + a bounded 2-attempt retry of the + same round in `Agent.Run`, instead of failing the whole turn on the first + occurrence. + +## 2. Word / Excel / PowerPoint document management + +New package **`pkg/officekit`** (stdlib only — `archive/zip` + `encoding/xml`, +no new dependencies) that creates and edits **real** `.docx`/`.xlsx`/`.pptx` +files. Nine new agent tools, modeled on Claude Code's document-editing +skills: + +- `create_document` / `read_document` / `edit_document` (Word) +- `create_spreadsheet` / `read_spreadsheet` / `edit_spreadsheet` (Excel) +- `create_presentation` / `read_presentation` / `edit_presentation` (PowerPoint) + +**Design choice that structurally prevents this tool family from +reintroducing the Ollama bug:** the model only ever passes plain structured +fields (a paragraph's text and style, a grid of cell values, a slide's title +and bullets) and gets back a short plain-text summary. It never authors or +receives OOXML/XML — `pkg/officekit` does 100% of the actual XML generation +and parsing, server-side, using Go's typed XML encoder (always well-formed, +always correctly escaped). + +Files persist through the workspace service's **existing** +`UploadFile`/`DownloadFile` gRPC RPCs (binary-safe `[]byte` fields, already +present on `WorkspaceServiceClient`) — so this required **zero protobuf/gRPC +changes**. + +New/changed files: +- `pkg/officekit/zipkit.go`, `docx.go`, `xlsx.go`, `pptx.go` (new) +- `services/agent/internal/infrastructure/toolexec/office.go` (new) +- `services/agent/internal/infrastructure/toolcatalog/catalog.go` (+9 tool defs) +- `services/agent/internal/infrastructure/toolexec/executor.go` (+9 dispatch cases) +- `services/agent/internal/infrastructure/agentrun/agent.go` (system prompt + safe-cut + retry) +- `pkg/ollamakit/client.go` (`IsTemplateParseError`) + +## Verification performed + +No Go toolchain ships in the delivery environment by default, so I installed +one (`apt-get install golang-1.23-go`, from the already-allowed +`archive.ubuntu.com`) to actually verify this, rather than just asserting it +compiles: + +- `pkg/officekit` builds and `go vet`s cleanly in isolation. +- A smoke test builds a `.docx`, `.xlsx`, and `.pptx` from scratch, edits each + (append paragraph + find/replace, sparse cell writes, add slide + update + slide), and reads every one back — including round-tripping text containing + literal `<`, `>`, and `&` correctly escaped. +- Every generated XML part in all three files was checked for well-formedness + (`xml.dom.minidom.parseString`). +- The **entire module** (`go build ./...` and `go vet ./...`, all services, + not just the touched ones) compiles cleanly against the real `go.mod`/`go.sum` + — I cloned the project's own pinned dependency versions from their GitHub + mirrors (matching what `go.sum` already recorded) to resolve them under + `GOPROXY=off`, since `proxy.golang.org` isn't reachable here. +- `gofmt -l` is clean on every touched file. + +## 3. Follow-up: "expected element type ... but have ..." (same bug family, different phrasing) + +Go's `encoding/xml` doesn't have one error shape — `IsTemplateParseError` was +only matching its tokenizer-level `SyntaxError`s ("XML syntax error on line +N: ..."), which is what "element closed by" was. "expected element type +`` but have ``" is a *different* error type from the same package +(an `UnmarshalError`, raised when struct-based decoding hits an unexpected +root element) — same root cause, no shared prefix, so the old substring +check missed it. + +Fixed by generalizing the detector (`pkg/ollamakit/client.go`, +`IsTemplateParseError`) from an enumerated list of exact phrasings to the +actual shared signature: an "ollama error" whose message either mentions +"xml" or names an angle-bracketed ``-shaped fragment — the one place in +this error path literal element/type names show up, regardless of which +`encoding/xml` error type produced them. This should now catch future +variants (missing end tag, bad entity, duplicate attribute, etc.) without +needing another patch each time a new phrasing surfaces. + +## 4. Office document skills: silent cell loss in `edit_spreadsheet` + +**Symptom:** `edit_spreadsheet` (`SetXlsxCells` in `pkg/officekit/xlsx.go`) +would, on some calls, silently drop one of several cell writes aimed at +rows that didn't exist in the workbook yet — no error, no panic, the +RPC reported success, but the written `.xlsx` was missing a cell the +model had just asked for. It reproduced intermittently: the same +batch of writes would sometimes round-trip correctly and sometimes +lose a cell, which is what made it hard to pin down — `go test` first +caught it as a flake in `TestSetXlsxCellsFormulaAndOrdering` +(`pkg/officekit/officekit_test.go`), failing on roughly half of runs. + +**Root cause:** `SetXlsxCells` indexed newly-created rows with a +`map[int]*sRow` — a pointer taken as `&ws.SheetData.Rows[i]` at the +moment a row was first appended. Go's `append` reallocates a slice's +backing array once its capacity is exceeded, copying existing elements +into the new array; any pointer taken into the *old* array before that +point keeps pointing at now-orphaned memory. A second write landing in +the same new row, processed after a later `append` triggered that +reallocation, mutated the stale pointer's copy — which nothing else +ever reads — instead of the live row. Since Go map iteration order is +randomized by design, whether a reallocation fell between two writes +to the same row (and so whether the bug fired) varied from run to run +and from request to request: the same tool call could succeed or +silently corrupt the file depending on map iteration order the caller +has no control over. + +**Fix:** `pkg/officekit/xlsx.go`'s `SetXlsxCells` now indexes rows by +their integer position (`map[int]int`) instead of a pointer. An index +has no equivalent failure mode: `ws.SheetData.Rows[idx]` always +re-resolves against whatever backing array the slice header currently +points to, reallocated or not. Cell writes are also now applied in a +deterministic ascending (row, column) order rather than raw map +iteration order, so behavior no longer depends on Go's randomized map +ordering at all (the final on-disk row/cell order was already +restored by the existing `sortSheetData` sort, regardless; this change +makes the *write sequence* itself deterministic too, which matters +for debugging a batch of edits, not just for the final file). + +Added `TestSetXlsxCellsManyNewRowsNoDataLoss` +(`pkg/officekit/officekit_test.go`): writes two cells into each of 200 +brand-new rows — enough to force several slice reallocations — and +reads every one back. Verified against the pre-fix code that this +test fails deterministically (not intermittently) on the old pointer- +map version, confirming it would have caught this the first time. +`go test ./pkg/officekit/... -race -count=20` is clean after the fix. + +This was the one correctness bug found in `pkg/officekit`; the rest of +the office-document path — `docx.go`/`pptx.go`'s append/replace +operations, `zipkit.go`'s zip part copy/replace, the +`toolexec/office.go` tool handlers, their `toolcatalog/catalog.go` +schemas, and the workspace service's binary-safe +`UploadFile`/`DownloadFile` RPCs — was reviewed and is sound: no other +instance of the pointer-into-a-growing-slice pattern exists anywhere +in `pkg/officekit`, every `create_*`/`read_*`/`edit_*` tool name in +the catalog has a matching dispatch case in `executor.go`, and +`AppendPptxSlide`/`AppendDocxParagraph` keep `[Content_Types].xml` and +the relevant `.rels`/`presentation.xml` parts in sync on every write +rather than letting them drift. + +## 5. Office document skills: an unnecessary round-trip on every "create a document" request + +**Symptom:** a simple, one-shot document request — e.g. "Crées un CV +document Word d'Ingénieur en Machine Learning" — took noticeably +longer than it needed to, even though `pkg/officekit` itself (the +actual `.docx`/`.xlsx`/`.pptx` generation) is pure in-memory +`archive/zip`/`encoding/xml` work and runs in low-single-digit +milliseconds — it's never the bottleneck. + +**Root cause:** both the main `SystemPrompt` +(`services/agent/internal/infrastructure/agentrun/agent.go`) and +`read_office_skill`'s own tool description +(`toolcatalog/catalog.go`'s `officeSkillToolDescription`) *mandated* a +`read_office_skill` call "BEFORE your first create_document/ +edit_document, create_spreadsheet/edit_spreadsheet, or +create_presentation/edit_presentation call in a conversation" — with +no exception for a plain, ordinary document. On a local model, a tool +call is a full inference round-trip, not a free lookup. So every +fresh conversation's first document request paid for an extra round +just to read a markdown file, before the model had even started +composing the document's actual content — on top of round 0's +`think=high` reasoning pass (`DefaultThinkPolicy`) and the round that +generates the document's full paragraph list. For a one-page CV, that +extra skill-read round added a full local-model generation pass that +contributed nothing a well-written tool schema couldn't already +convey. + +**Fix (no behavior change for anything nontrivial, same two files):** + +- `create_document`'s own description now inlines the handful of + conventions that actually matter for an ordinary document (lead + with one `heading1`, `heading2`/`heading3` for sections, `bullet` + for list-shaped content, `normal` paragraphs split by idea) plus + its honest limitations (no tables, images, headers/footers, + numbered lists, or mid-paragraph inline formatting) — the same + information `docx.md` already had, just paid for once, in the + schema the model already sees on every call, instead of behind a + mandatory separate round-trip. +- `read_office_skill`'s description and the matching `SystemPrompt` + bullet both changed from "call this before your first document + tool call" to "optional — skip it for a straightforward document + and call create_document/create_spreadsheet/create_presentation + directly; reach for this before something that leans on a format's + sharper edges (Excel formulas, a multi-sheet workbook, a + multi-slide deck, `edit_document`'s `replace_text` run-splitting + caveat), or when unsure a feature someone asked for is supported at + all." + +Net effect: a plain "make me a CV/memo/simple spreadsheet" request now +costs one fewer full LLM round-trip than before — the skill files and +`search_office_skills` are both still there, fully documented, and +still the right call for anything that actually needs them (tables, +formulas, multi-sheet/multi-slide work, or edit-operation nuances); +they're just no longer a toll booth in front of the common case. This +is a prompt/tool-description change only — `pkg/officekit` itself is +untouched here (see §4 above for its one real bug fix) and the actual +per-request cost of generating a CV's worth of paragraph text with a +local model is inherent to doing that work at all, not something this +change (or any prompt change) removes. + +Also added a second line of defense: if the plain retries don't clear it +(the same template error survives every fresh attempt — i.e. it's +deterministic, not transient), `Agent.Run` now blanks the most recent tool +result(s) in that round's local message history — the newest, and so most +likely, thing about the request that differs from the last one that worked +— and tries once more before giving up the whole turn +(`degradeRecentToolMessages` in `agentrun/agent.go`). This never touches +persisted conversation history, only the in-memory copy used for this one +request. + +## Known limitations (documented honestly, not hidden) + +- `edit_document`/`edit_spreadsheet`/`edit_presentation` only support a + deliberately small set of structured operations (append paragraph, + find/replace; sparse cell writes; add/update slide) — by design, so the + model never needs richer markup-shaped arguments. +- PowerPoint slides are two fixed, absolutely-positioned text boxes + (title + bullets) — no custom layouts, images, or manual positioning. +- `read_document`/`read_spreadsheet`/`read_presentation` extract plain text + only — tables, images, and complex formatting in a real user-uploaded + Office file aren't represented. +- No PDF support (there's no `create_pdf`/`read_pdf`; the `overview` skill + below tells the model to say so rather than silently substituting a + `.docx`/`.pptx`). + +## 4. Office document *skills* (Claude Code-style progressive disclosure), and multi-sheet workbooks + +The nine document tools from §2 already told the model *what fields each +tool takes* (the JSON-schema `Parameters`), but that schema is sent to +Ollama on **every single request**, so it has to stay terse — there was +nowhere to put the kind of guidance Claude Code's own `SKILL.md` files +carry: which style to use for a document's title, what `replace_text` +does and doesn't match, what a `bullet` can't do, how to pick between a +single- and multi-sheet workbook, and so on. Two concrete problems this +caused in practice: the model had no documented way to discover that +`create_spreadsheet` only ever wrote one sheet (see the limitation this +removes, below), and nothing told it to say "I can't make a PDF" instead +of quietly handing back a `.docx` when a user specifically asked for one. + +**New package `pkg/skillkit`** (stdlib only — `embed` + `sort` + +`strings`) loads four short, human-editable markdown files +(`pkg/skillkit/skills/{overview,docx,xlsx,pptx}.md`) at build time via +`go:embed` and serves them through `List()` (name + one-line description, +for the tool's own description), `Read(name)` (one skill's full body), +and `Names()` (for the tool schema's `enum`) — the same +progressive-disclosure shape Claude Code uses: a one-line description is +always in context, the full skill is only loaded when the model actually +asks for it. + +**New tool `read_office_skill`** (`toolcatalog.Defs()` + +`toolexec/office_skill.go`, dispatched in `executor.go` like +`write_todos`/`solve_ml_math` — pure in-memory lookup, no gRPC call) +takes one `name` argument constrained to `skillkit.Names()`. Its tool +description is *generated from* `skillkit.List()` at catalog-build time, +so the catalog and the skill files can never drift apart — renaming or +adding a skill file is the only edit needed. `SystemPrompt` in +`agentrun/agent.go` instructs the model to call it before its first +`create_*`/`edit_*` office tool call per conversation. Added to +`toolcatalog.subagentToolNames` too (read-only, harmless for a +sub-agent that already has `read_document`/`read_spreadsheet`/ +`read_presentation`). + +**Multi-sheet `.xlsx` workbooks, wired up** (closing the limitation +`BuildXlsx` already had unused room for): `create_spreadsheet` now +accepts either the original single-sheet `rows`/`sheet_name` pair *or* a +`sheets` array (`{name, rows}` per tab) — `office.go`'s new +`parseSheetsArg` normalizes either shape to `[]officekit.Sheet` before +calling `officekit.BuildXlsx`, which now actually writes every sheet +given (its own worksheet part, workbook `` entry, and +`workbook.xml.rels` relationship each) instead of silently keeping only +`sheets[0]`. `read_spreadsheet`/`edit_spreadsheet` both gained an +optional `sheet` field (1-based index or name); resolving it reads the +target workbook's own `xl/workbook.xml` + `xl/_rels/workbook.xml.rels` +(new `resolveXlsxSheetPart`, with a same-as-before `sheet1.xml` fallback +if either part can't be parsed) rather than assuming sheet order, so it +also works correctly against a user-uploaded workbook whose sheets were +reordered in Excel. `read_spreadsheet`'s result now leads with a +`# sheet "Name" of N: A, B, C` line whenever the workbook has more than +one sheet, so the model (and the person reading the tool output) can see +what else is there without a second call. + +New/changed files: +- `pkg/skillkit/skillkit.go`, `pkg/skillkit/skills/*.md` (new) +- `services/agent/internal/infrastructure/toolexec/office_skill.go` (new) +- `pkg/officekit/xlsx.go` (multi-sheet `BuildXlsx`; sheet-aware + `ReadXlsxCells`/`SetXlsxCells`/new `ReadXlsxSheetNames`) +- `services/agent/internal/infrastructure/toolexec/office.go` + (`parseSheetsArg`; `sheet` plumbed through read/edit) +- `services/agent/internal/infrastructure/toolcatalog/catalog.go` + (`read_office_skill` tool def + `officeSkillToolDescription`; updated + `create_spreadsheet`/`read_spreadsheet`/`edit_spreadsheet` schemas; + `read_office_skill` added to the sub-agent whitelist) +- `services/agent/internal/infrastructure/toolexec/executor.go` + (dispatch case) +- `services/agent/internal/infrastructure/agentrun/agent.go` (system + prompt) + +### Design choices worth calling out + +- **Markdown, not Go, is the unit of change.** Improving the guidance for + how to write a `.docx` is a `pkg/skillkit/skills/docx.md` edit, same as + editing a Claude Code `SKILL.md` — no Go recompile of prose baked into + a string constant, no risk of a typo in a hand-escaped Go string + literal. +- **Generated, not duplicated, tool description.** `read_office_skill`'s + own description lists every available skill by reading `skillkit.List()` + at catalog-build time rather than a second hand-maintained list, so + adding a fifth skill file later (e.g. if PDF support gets built) can't + leave the tool description stale. +- **Backward compatible.** `create_spreadsheet` with the original + `rows`/`sheet_name` fields, and `read_spreadsheet`/`edit_spreadsheet` + with no `sheet` field, behave exactly as before (first/only sheet) — + nothing already relying on the single-sheet shape breaks. + +## 5. RAG over the office skills (search_office_skills) + +`read_office_skill` (§4) is coarse: it returns a whole skill file, which is +fine for "give me the full docx conventions before I start" but wasteful +for one narrow question mid-task ("can a cell hold a formula?"). This adds +a retrieval-augmented complement: semantic search over the same skill +content, returning just the matching section(s). + +**New `pkg/skillkit/rag.go`**, same package as §4's `skillkit.go` (so it +shares the embedded `registry` directly, no second embed): +- `chunkSkill` splits one skill's markdown on its own `## ` headings — + every skill file already follows that convention, so a section *is* + the right retrieval granularity; no char-budget chunker (like + `services/knowledge/internal/domain.ChunkText`, built for arbitrary + source code) is needed. +- `Index`, built with `NewIndex(embedder)`, embeds every chunk (~20-30 + across the four files) exactly once, lazily, on the first `Search` + call — not eagerly at startup, and not re-embedded on every call. A + failed first build isn't cached as permanent failure (deliberately + not a `sync.Once`): a transient Ollama outage on the first call just + means the next call retries the build, rather than `search_office_skills` + staying broken for the rest of the process's life. +- `Search(ctx, query, topK, skillFilter)` embeds the query and ranks + every chunk by cosine similarity, in memory — no ANN index, no + database; brute-force over a few dozen vectors is sub-millisecond and + outgrowing that would mean the skill corpus itself has gotten too big, + which is a reason to add a fifth skill file, not a reason to reach for + pgvector. + +**Why in-process, not through the existing `services/knowledge` +Postgres/pgvector pipeline** that already backs `search_codebase`: that +pipeline is the right tool for what it indexes — a workspace's actual +source files, which are large, per-user, and change constantly, so they +need persistence and incremental re-indexing. The office skills are the +opposite on every axis: a few dozen short, `go:embed`-fixed sections that +are identical for every workspace and never change between deploys. +Routing this through the knowledge service would mean a new gRPC +message, a schema migration to keep skill chunks from polluting a +workspace's own `search_codebase` results (the `documents` table has no +scoping column today), and a network round trip — for content that's +already sitting in the binary. Embedding it once in memory is simpler, +faster, and has nothing to keep in sync. + +**New tool `search_office_skills`** (`query`, optional `skill` filter), +alongside `read_office_skill` in the catalog, dispatch, system prompt, +and sub-agent whitelist. `toolexec.Executor` gained a `SkillIndex +*skillkit.Index` field, built in `New` from a new `embedder +skillkit.Embedder` parameter — `main.go` passes the same `*ollamakit.Client` +already used for chat (it satisfies the interface as-is; no adapter). +Nil-embedder is handled the same way `Subagent` already is: the field +stays nil and the tool reports itself unavailable instead of panicking. + +New/changed files: +- `pkg/skillkit/rag.go` (new) +- `services/agent/internal/infrastructure/toolexec/office_skill.go` + (`searchOfficeSkills`) +- `services/agent/internal/infrastructure/toolexec/executor.go` + (`SkillIndex` field, `New`'s new `embedder` param, dispatch case) +- `services/agent/internal/infrastructure/toolcatalog/catalog.go` + (`search_office_skills` tool def; sub-agent whitelist) +- `services/agent/internal/infrastructure/agentrun/agent.go` (system + prompt) +- `services/agent/cmd/server/main.go` (`toolexec.New`'s new argument) + +### Verification not performed in this environment + +Unlike §2/§3 above, I could not install a Go toolchain here (no network +egress in this environment at all — the earlier `apt-get install +golang-1.23-go` route isn't available this time) and so could not run +`go build ./...`, `go vet ./...`, or `gofmt -l` against these changes. +I've kept every edit close to the existing, already-verified code's +patterns (the same `zipPart`/`readZipPart`/`replaceZipPart` helpers, the +same `argStr`/`errResult` tool-arg conventions, the same `xml:"id,attr"` +style already used elsewhere in `officekit` for namespaced OOXML +attributes) specifically to minimize the risk of a compile error, but +this should be verified with `go build ./... && go vet ./...` and +`gofmt -l .` before this is treated as merged, and the three new skill +files are worth a skim for tone/accuracy even though they're not code. + +## 6. Fixing real correctness bugs in the office document skills + +§5 above shipped without a Go toolchain to verify it, and said so. This +pass went back over all of `pkg/officekit`, `pkg/skillkit`, and the +office tool wiring (`toolexec/office.go`, `office_skill.go`, +`toolcatalog/catalog.go`) specifically looking for correctness bugs — +not style — and fixed four real ones, plus two small, low-risk feature +additions that Claude Code's own document-editing skills already offer +and that close gaps these fixes exposed. No Go toolchain or network +access was available in this environment either (see "Verification +performed" below for exactly what that means here). + +### Bug 1 — `edit_document` silently destroyed tables, images, and +anything else it didn't model, on a real (not self-authored) `.docx` + +`AppendDocxParagraph` and `ReplaceDocxText` both worked by unmarshaling +`word/document.xml` into `wDocument` — a struct that only models direct +`` children (paragraphs and the trailing `sectPr`) — mutating +it, and re-marshaling the whole document back. Re-marshaling a struct +that doesn't model something *drops that something*: a table, an +image, a header reference, a second run in a paragraph, tracked +changes — anything beyond "a flat list of paragraphs" in a real, +Word-authored or previously-edited document would silently disappear +the moment `edit_document` touched that file. `read_document` had the +same blind spot one layer up: `wBody.Paragraphs` only captures `` +elements that are *direct* children of ``, so a paragraph +nested inside a table cell (`///`) was never +unmarshaled into it at all — a table's content wasn't just +unreconstructed as a grid (the already-documented limitation), it was +completely invisible to `read_document`, `replace_text`, and anything +built on top of them. + +Fixed by moving both operations off the unmarshal/modify/marshal +pattern entirely: + +- **`read_document`** now walks `document.xml`'s raw token stream + (`encoding/xml`'s `Decoder.Token()`, not `Unmarshal`) collecting + every `` wherever it occurs, nested in a table or not, in + document order — so a table's text is visible (each cell's + paragraphs as their own lines; the grid shape itself still isn't + reconstructed, which is now the accurately-scoped limitation). +- **`edit_document`'s `append_paragraph`** now marshals *only* the one + new paragraph and splices its bytes directly into the existing + `document.xml`, immediately before the body's trailing `` + (OOXML requires section properties to stay the body's last child) or + before `` if there's none — so every byte of the original + document this package doesn't model survives untouched. The spliced + fragment carries its own explicit WordprocessingML namespace + declaration (`wParagraphFragment`, distinct from the `wParagraph` + type `BuildDocx` still uses internally) so it resolves correctly + whether the target document uses Word's own `w:`-prefixed convention + or this package's unprefixed default-namespace one — without paying + for a redundant `xmlns` on every paragraph of a freshly-built + document, which sharing one type between both call sites would have + meant. +- **`edit_document`'s `replace_text`** now matches and rewrites text + directly inside `...` elements in the raw XML (a small, + locally-scoped regex selecting the exact local element name `t`, + with or without a namespace prefix — not `tab`/`tbl`/`tc`, which + start with the same letter), decoding/re-encoding only the matched + inner text through the same entity (un)escaping `xml.Marshal`/ + `EscapeText` already use. Everything else in the document — tags, + attributes, tables, images — passes through byte-for-byte unchanged. + One real, newly-documented trade-off: a phrase Word has split across + more than one run (common after spell-check or revision tracking) + may not be found as a single match, since each `` is matched + independently — the docx skill now says so. + +### Bug 2 — `edit_spreadsheet` could write cells in an order Excel +flags as needing "repair" + +`SetXlsxCells` appended a newly-written row/cell to the end of the +in-memory slice regardless of its row/column number, so a sparse, +arbitrarily-ordered set of writes (e.g. writing `"A1"` in the same call +as an already-present `"A5"`) could marshal `xl/worksheets/sheetN.xml` +with rows or cells out of ascending order — which Excel's own parser +(unlike most lenient readers) can flag as needing repair on open. +Fixed with `sortSheetData`, called right before marshaling in +`SetXlsxCells`: rows sorted ascending by row number, and cells within +each row sorted ascending by column, so a schema-valid order comes out +regardless of what order the (inherently unordered, since it's a Go +map) set of writes was applied in. Covered by +`TestSetXlsxCellsFormulaAndOrdering`, which asserts on the raw XML +byte offsets, not just this package's own reader, since this bug would +never have shown up through this package's own (order-independent) +`ReadXlsxCells`. + +### Bug 3 — a latent schema-order bug in `wRPr` + +While adding italic/underline support (feature addition below), found +that `wRPr`'s one existing formatting field (`Bold`) was never ordered +against anything else, so there was nothing to get wrong yet — but the +straightforward way to add `Italic`/`Underline`/keep `Sz` would have +produced a `` child order (`b`, `u`, `sz`, `i`, declaration +order) that doesn't match OOXML's actual `CT_RPr` sequence (`b`, `i`, +`sz`, `u`, ...). Real-world readers are typically lenient about this, +but there's no reason to ship something schema-invalid when getting +the field order right costs nothing; `wRPr`'s fields are now declared +in actual schema sequence. + +### Feature addition — italic/underline paragraph formatting (docx) + +`Paragraph` gained `Italic`/`Underline` bool fields alongside the +existing `Bold`, wired through `create_document`'s paragraph schema and +`edit_document`'s `append_paragraph` operation (which — separately — +was also missing `bold` from its own JSON schema even though the +executor already read it off `call.Arguments`; a real, if minor, gap +of its own, now fixed by adding `bold` there too). `pkg/skillkit/skills/docx.md` +documents both fields. + +### Feature addition — live formulas (xlsx) + +A cell value starting with `"="` (e.g. `"=SUM(A1:A4)"`) is now stored +as a real Excel formula (``) rather than literal text — previously +`isNumeric` rejected it and it silently became an inline-string cell +showing the formula text, never actually computing anything in Excel. +`create_spreadsheet` and `edit_spreadsheet` classify values through one +shared `setCellValue` now (previously `BuildXlsx`'s create path and +`SetXlsxCells`'s edit path each had their own, slightly different, +hand-rolled numeric/text branch — now unified, so both paths behave +identically). `read_spreadsheet` shows a formula cell's cached computed +value when one exists (always true for a real Excel-authored or +user-uploaded file) or the formula text itself, prefixed `=`, when +nothing has evaluated it yet (always true immediately after this +package writes one, since nothing here evaluates formulas). +`pkg/skillkit/skills/xlsx.md`'s former "No formulas" limitation is +replaced with an accurate description of this behavior, including the +no-computed-value-yet caveat. + +### Also + +- `toolcatalog.subagentToolNames`'s map literal had drifted out of + `gofmt` alignment (two entries added by §4/§5 weren't gofmt-aligned + with the rest) — recomputed and realigned; this is purely + cosmetic but `gofmt -l` is supposed to be clean per §2's own + verification bar. +- Added `pkg/officekit/officekit_test.go` — there were no tests for + this package at all before this pass. It covers the two + content-preservation fixes directly (append/replace against a + hand-built table-containing document, asserting both on + `read_document`'s output and on raw-XML byte offsets), the + xlsx ordering fix (same: both the package's own reader and raw XML + offsets), and round-trips for all three formats including + formatting flags and an `&`-containing bullet (exercises + escaping both directions through the pptx hand-written-template + path). + +### Verification performed + +Static only — no Go toolchain and no network access in this +environment (same constraint §5 ran into). What that means concretely: +every edit above was re-read multiple times against Go's actual +`encoding/xml`/`regexp`/`archive/zip` semantics rather than assumed; +every touched file was run through a hand-rolled brace/paren/bracket +balance checker (tracking Go string/rune/raw-string/comment lexing) to +catch the class of error a missing `go vet` would otherwise have +caught instantly; the new regex (`wTextElementRe`) and the new +`lastTagIndex`/`xmlUnescapeText` helpers were traced by hand against +concrete example inputs (worked through in detail, not just eyeballed) +before being written. None of this is a substitute for actually +running `go build ./... && go vet ./... && go test ./... && gofmt -l .` +— that should happen before this is treated as merged, same as every +prior section's caveat. + +## Multilingual support (Claude Code-style) + +enkly's agent already detected and mirrored whatever language a user typed in +conversation (`agentrun.SystemPrompt`'s "## Language" section). What was +missing was everything around that: a localized UI chrome, an explicit way +for a visitor to set a language, and a way for that choice to reach the +model as a standing preference rather than relying purely on per-message +detection — the same two-layer design Claude Code itself uses (automatic +detection, plus an explicit `/language`-style override). + +**New shared package `pkg/i18n`** — the single source of truth, for every +service, of which locales exist and what their strings are: a `Locale` +type, `Supported` (en, fr, es, de, pt — en/fr ship complete, the rest cover +nav/auth/composer and fall back to English for the rest), JSON catalogs +loaded via `go:embed`, `T()` with fallback-to-English-then-to-key, and +`NegotiateAcceptLanguage` for parsing a browser's `Accept-Language` header. +Dependency-free (stdlib only) so both `gateway` and `agent` can import it +without depending on each other. + +**Gateway (`services/gateway/internal/handlers/i18n.go`)** — `localeFrom` +resolves a request's locale (`?lang=` query → `enkly_lang` cookie → +Accept-Language → `i18n.Default`); `SetLanguage` (`POST /language`) persists +an explicit choice in a year-long cookie, mirroring `SetTheme`'s existing +pattern byte-for-byte on purpose. A new `lang_switcher` partial +(`web/templates/partials.html`) — a plain ` the + // composer form adds per uploaded attachment (app.js) and the + // multipart field AttachFile reads a single file from. + attachmentFormField = "attachments" + + // maxChatAttachments mirrors maxUploadFilesPerRequest: a sanity + // cap on how many files (or zips) one message can carry, enforced + // server-side regardless of what the client already limits to. + // A single extracted zip still counts as exactly one of these, + // however many files end up inside it. + maxChatAttachments = 8 +) + +var imageExts = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, + ".webp": true, ".svg": true, ".bmp": true, ".avif": true, +} + +func isImagePath(p string) bool { return imageExts[strings.ToLower(path.Ext(p))] } + +// --------------------------------------------------------------------- +// POST /files/attach — one file per request, called by app.js the +// moment a file is staged (picked, dropped, or pasted). +// --------------------------------------------------------------------- + +type attachFileResult struct { + Path string `json:"path"` + Name string `json:"name"` + IsImage bool `json:"isImage"` + IsArchive bool `json:"isArchive"` // true: Path is a folder of extracted files, not one file + EntryCount int `json:"entryCount,omitempty"` // set when IsArchive + DownloadURL string `json:"downloadUrl,omitempty"` // empty for an archive — there's no single file to download + SizeHuman string `json:"sizeHuman"` +} + +func (h *Handlers) AttachFile(w http.ResponseWriter, r *http.Request) { + var headers []*multipart.FileHeader + if r.MultipartForm != nil { + headers = r.MultipartForm.File[uploadFormField] + } + if len(headers) != 1 { + writeJSONError(w, http.StatusBadRequest, "attach one file per request") + return + } + fh := headers[0] + + if isZipPath(fh.Filename) { + result, err := h.extractZipAttachment(r, fh) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) + return + } + + rel, err := h.saveChatAttachment(r, fh) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + + var size int64 + if stat, err := h.Workspace.StatFile(r.Context(), &workspacev1.StatFileRequest{Path: rel}); err == nil { + size = stat.GetSize() + } + writeJSON(w, http.StatusOK, attachFileResult{ + Path: rel, Name: path.Base(rel), IsImage: isImagePath(rel), + DownloadURL: downloadURLFor(rel), SizeHuman: humanBytes(size), + }) +} + +func (h *Handlers) saveChatAttachment(r *http.Request, fh *multipart.FileHeader) (string, error) { + base, err := uploadDestPath("", fh.Filename) + if err != nil { + return "", err + } + rel, err := h.uniqueWorkspacePath(r.Context(), path.Join(attachmentsDir, base)) + if err != nil { + return "", err + } + return h.writeUploadedFile(r, rel, fh) +} + +// uniqueWorkspacePath appends " (2)", " (3)", … before the extension +// until StatFile reports nothing at that path — the way a desktop +// file manager avoids clobbering an existing file. Needed because +// workspace's UploadFile writes with os.WriteFile, which truncates in +// place: two attachments named the same thing in one conversation +// would otherwise silently overwrite each other. Works equally for a +// plain file's path and an extraction folder's path — StatFile +// reports IsDir either way, and all this cares about is Exists. +func (h *Handlers) uniqueWorkspacePath(ctx context.Context, rel string) (string, error) { + ext := path.Ext(rel) + base := strings.TrimSuffix(rel, ext) + for n := 1; n <= 1000; n++ { + candidate := rel + if n > 1 { + candidate = fmt.Sprintf("%s (%d)%s", base, n, ext) + } + stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: candidate}) + if err != nil { + // Can't check — don't block the upload over it, just take + // the first candidate; UploadFile will still succeed. + return candidate, nil + } + if !stat.GetExists() { + return candidate, nil + } + } + return rel, nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// --------------------------------------------------------------------- +// Zip extraction. A .zip attached to the chat is unpacked into its +// own folder under attachments/ — e.g. attachments/project/ — rather +// than stored as a single binary blob, so the agent's existing +// read_file / list_directory / glob_search / grep tools (all already +// scoped to the whole workspace) can actually read and review what's +// inside it. Nothing about those tools changes; extraction is the +// entire feature. +// --------------------------------------------------------------------- + +const ( + // maxZipEntries mirrors workspace's own maxWorkspaceFilesListed — + // the same "a listing/extraction this big is almost certainly not + // something a person meant to attach to a chat message" cap. + maxZipEntries = 500 + + // maxZipUncompressedBytes bounds the classic zip-bomb case: the + // compressed upload itself is already capped at maxUploadFileBytes + // (20 MiB) by the ordinary multipart size limit, but a small zip + // can still decompress to something enormous. 200 MiB total is + // generous for a real codebase or document set while still being + // a hard, enforced ceiling. + maxZipUncompressedBytes = 200 << 20 +) + +// isZipPath checks the filename's extension only — deliberately not +// the multipart part's declared Content-Type, which the browser sets +// from that same extension anyway and is trivial to get wrong or omit. +func isZipPath(name string) bool { + return strings.EqualFold(path.Ext(filepath.Base(filepath.ToSlash(name))), ".zip") +} + +func (h *Handlers) extractZipAttachment(r *http.Request, fh *multipart.FileHeader) (attachFileResult, error) { + if fh.Size > maxUploadFileBytes { + return attachFileResult{}, fmt.Errorf("%q is %s, larger than the %s zip-upload limit", fh.Filename, humanBytes(fh.Size), humanBytes(maxUploadFileBytes)) + } + src, err := fh.Open() + if err != nil { + return attachFileResult{}, fmt.Errorf("%q: %w", fh.Filename, err) + } + defer src.Close() + raw, err := io.ReadAll(io.LimitReader(src, maxUploadFileBytes+1)) + if err != nil { + return attachFileResult{}, fmt.Errorf("%q: read: %w", fh.Filename, err) + } + if len(raw) > maxUploadFileBytes { + return attachFileResult{}, fmt.Errorf("%q exceeds the %s zip-upload limit", fh.Filename, humanBytes(maxUploadFileBytes)) + } + + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return attachFileResult{}, fmt.Errorf("%q doesn't look like a valid zip file", fh.Filename) + } + if len(zr.File) > maxZipEntries { + return attachFileResult{}, fmt.Errorf("%q has too many files to attach (limit %d)", fh.Filename, maxZipEntries) + } + + name := strings.TrimSuffix(path.Base(filepath.ToSlash(fh.Filename)), ".zip") + if name == "" { + name = "archive" + } + destDir, err := h.uniqueWorkspacePath(r.Context(), path.Join(attachmentsDir, name)) + if err != nil { + return attachFileResult{}, err + } + + var totalUncompressed int64 + written := 0 + for _, zf := range zr.File { + if zf.FileInfo().IsDir() { + continue + } + entry := sanitizeZipEntryName(zf.Name) + if entry == "" { + continue // path-traversal / absolute / empty entry — skip rather than fail the whole zip + } + if zf.UncompressedSize64 > uint64(maxUploadFileBytes) { + return attachFileResult{}, fmt.Errorf("%q inside %q is larger than the %s per-file limit", zf.Name, fh.Filename, humanBytes(maxUploadFileBytes)) + } + totalUncompressed += int64(zf.UncompressedSize64) + if totalUncompressed > maxZipUncompressedBytes { + return attachFileResult{}, fmt.Errorf("%q unpacks to more than %s — too large to attach", fh.Filename, humanBytes(maxZipUncompressedBytes)) + } + + rc, err := zf.Open() + if err != nil { + return attachFileResult{}, fmt.Errorf("%q: %w", zf.Name, err) + } + content, err := io.ReadAll(io.LimitReader(rc, maxUploadFileBytes+1)) + rc.Close() + if err != nil { + return attachFileResult{}, fmt.Errorf("%q: read: %w", zf.Name, err) + } + if int64(len(content)) > maxUploadFileBytes { + return attachFileResult{}, fmt.Errorf("%q inside %q is larger than the %s per-file limit", zf.Name, fh.Filename, humanBytes(maxUploadFileBytes)) + } + if _, err := h.Workspace.UploadFile(r.Context(), &workspacev1.UploadFileRequest{ + Path: path.Join(destDir, entry), Content: content, + }); err != nil { + return attachFileResult{}, fmt.Errorf("%q: %w", entry, err) + } + written++ + } + if written == 0 { + return attachFileResult{}, fmt.Errorf("%q doesn't have anything extractable in it", fh.Filename) + } + + return attachFileResult{ + Path: destDir, Name: path.Base(fh.Filename), IsArchive: true, + EntryCount: written, SizeHuman: humanBytes(totalUncompressed), + }, nil +} + +// sanitizeZipEntryName is the standard "zip slip" guard: a malicious +// entry name like "../../somewhere/else" or "/etc/passwd" must never +// be allowed to land outside the extraction folder. Workspace's own +// Toolbox.ResolvePath (services/workspace/internal/infrastructure/ +// sandboxfs) independently refuses to write outside the workspace +// root at all — a real second layer, not redundant with this one — +// but that check alone wouldn't stop a crafted entry from escaping +// just the destDir subfolder and overwriting an unrelated file +// elsewhere in the workspace (the agent's own code, say). Returns "" +// for anything that isn't a clean, relative, forward-only path. +func sanitizeZipEntryName(name string) string { + clean := path.Clean(strings.TrimPrefix(filepath.ToSlash(name), "/")) + if clean == "" || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "" + } + return clean +} + +// --------------------------------------------------------------------- +// Encoding attachments into (and back out of) a message's plain-text +// content — see package doc. +// --------------------------------------------------------------------- + +// attachmentRef is one "[Attached files]" bullet: a plain file +// (EntryCount 0) or an extracted zip's folder (EntryCount = how many +// files it unpacked into). +type attachmentRef struct { + Path string + EntryCount int +} + +const attachmentsBlockHeader = "\n\n[Attached files]\n" + +// attachmentsBlockRe matches appendAttachmentsBlock's own output: a +// "[Attached files]" heading followed by one "- path" (or +// "- path (N files)") bullet per line, anchored to the end of the +// string so it only ever strips a block this package wrote, never +// something a user happened to type. +var attachmentsBlockRe = regexp.MustCompile(`(?s)\n\n\[Attached files\]\n((?:- .+\n?)+)$`) + +// zipBulletRe recognizes appendAttachmentsBlock's "N files" suffix on +// an extracted-archive bullet; anything that doesn't match it is a +// plain file path. +var zipBulletRe = regexp.MustCompile(`^(.+) \((\d+) files?\)$`) + +// appendAttachmentsBlock replaces chat.go's old bare +// `renderkit.PlainStream(prompt)` call site's input: the exact string +// passed to Agent.StartTurn (and therefore persisted verbatim as the +// message's content). +func appendAttachmentsBlock(prompt string, refs []attachmentRef) string { + if len(refs) == 0 { + return prompt + } + var b strings.Builder + b.WriteString(prompt) + b.WriteString(attachmentsBlockHeader) + for _, ref := range refs { + b.WriteString("- ") + b.WriteString(ref.Path) + if ref.EntryCount > 0 { + b.WriteString(fmt.Sprintf(" (%d file", ref.EntryCount)) + if ref.EntryCount != 1 { + b.WriteString("s") + } + b.WriteString(")") + } + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// splitAttachments is appendAttachmentsBlock's inverse. +func splitAttachments(content string) (text string, refs []attachmentRef) { + loc := attachmentsBlockRe.FindStringSubmatchIndex(content) + if loc == nil { + return content, nil + } + text = content[:loc[0]] + for _, line := range strings.Split(strings.TrimRight(content[loc[2]:loc[3]], "\n"), "\n") { + p := strings.TrimPrefix(line, "- ") + if p == "" { + continue + } + if m := zipBulletRe.FindStringSubmatch(p); m != nil { + n, _ := strconv.Atoi(m[2]) + refs = append(refs, attachmentRef{Path: m[1], EntryCount: n}) + continue + } + refs = append(refs, attachmentRef{Path: p}) + } + return text, refs +} + +// cleanAttachmentPaths sanitizes the composer form's "attachments" +// values (one hidden per uploaded file or extracted archive — +// see app.js's addHiddenInput, which encodes an archive as +// "path|entryCount"): resolve away any "." / ".." games in the path, +// drop duplicates, cap the count. The workspace service's own jail +// (sandboxfs.Toolbox.ResolvePath) is still the real security boundary +// for anything that later reads these paths; this is just keeping the +// stored/displayed list sane. +func cleanAttachmentPaths(raw []string) []attachmentRef { + seen := make(map[string]bool, len(raw)) + out := make([]attachmentRef, 0, len(raw)) + for _, v := range raw { + p, count := v, 0 + if i := strings.LastIndex(v, "|"); i != -1 { + if n, err := strconv.Atoi(v[i+1:]); err == nil && n > 0 { + p, count = v[:i], n + } + } + clean := strings.TrimPrefix(path.Clean("/"+p), "/") + if clean == "" || clean == "." || seen[clean] { + continue + } + seen[clean] = true + out = append(out, attachmentRef{Path: clean, EntryCount: count}) + if len(out) == maxChatAttachments { + break + } + } + return out +} + +// defaultAttachmentPrompt replaces chat.go's Chat validating an empty +// prompt the way the agent service always has (service.go's StartTurn +// rejects "" with "Type a message first.") — if files were attached +// with no text at all, that empty-prompt rule shouldn't block the +// send; give it a minimal prompt instead, same spirit as app.js's +// existing "I uploaded X." auto-fill for the sidebar upload flow. +func defaultAttachmentPrompt(refs []attachmentRef) string { + if len(refs) == 1 { + return "Take a look at the attached file." + } + return "Take a look at the attached files." +} + +// --------------------------------------------------------------------- +// Rendering a user message's content (prompt text + attachment +// chips) — shared by chat.go (the just-sent bubble) and pages.go +// (every past message), replacing their old direct +// renderkit.PlainStream(content) calls. +// --------------------------------------------------------------------- + +type attachmentChipView struct { + Name string + IsImage bool + IsArchive bool + EntryCount int + DownloadURL string // empty for an archive chip — nothing to download as one file +} + +func (h *Handlers) renderUserMessage(content string) template.HTML { + text, refs := splitAttachments(content) + html := renderkit.PlainStream(text) + if len(refs) > 0 { + views := make([]attachmentChipView, len(refs)) + for i, ref := range refs { + if ref.EntryCount > 0 { + views[i] = attachmentChipView{Name: path.Base(ref.Path), IsArchive: true, EntryCount: ref.EntryCount} + continue + } + views[i] = attachmentChipView{Name: path.Base(ref.Path), IsImage: isImagePath(ref.Path), DownloadURL: downloadURLFor(ref.Path)} + } + html += h.renderString("message_attachments", views) + } + return template.HTML(html) // #nosec G203 -- renderkit.PlainStream escapes the text; message_attachments is a trusted template +} diff --git a/services/gateway/internal/handlers/auth.go b/services/gateway/internal/handlers/auth.go new file mode 100644 index 0000000..8cf7114 --- /dev/null +++ b/services/gateway/internal/handlers/auth.go @@ -0,0 +1,210 @@ +// Package handlers (auth.go) replaces internal/handlers/auth.go. Every +// handler here does the same HTTP-shape work as before (parse the +// form, render the auth.html templates) but delegates every password +// check, rate limit, token mint, and email dispatch to one IAM RPC — +// the gateway itself no longer touches a password hash, a session +// table, or SMTP. +package handlers + +import ( + "errors" + "net/http" + + iamv1 "enkly/gen/iam/v1" + "enkly/pkg/i18n" +) + +type authPageData struct { + CSRFToken string + Email string + Token string // reset_password only + Error string + Info string + Lang string +} + +// authData is a small constructor so every call site below gets Lang +// filled in automatically instead of relying on each of the dozen +// authPageData{...} literals to remember it. +func (h *Handlers) authData(r *http.Request, w http.ResponseWriter) authPageData { + return authPageData{CSRFToken: h.csrfToken(w, r), Lang: string(localeFrom(r))} +} + +func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) { + h.render(w, http.StatusOK, "login", h.authData(r, w)) +} + +func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { + lang := localeFrom(r) + email, _ := normalizeEmail(r.PostFormValue("email")) + resp, err := h.IAM.Login(r.Context(), &iamv1.LoginRequest{ + Email: email, Password: r.PostFormValue("password"), ClientIp: clientIP(r), + }) + if err != nil { + h.Logger.Error("login", "err", err) + d := h.authData(r, w) + d.Email, d.Error = email, i18n.T(lang, "errors.generic") + h.render(w, http.StatusInternalServerError, "login", d) + return + } + switch { + case resp.GetOk(): + h.startSessionCookie(w, resp.GetSessionToken(), resp.GetSessionExpiresAt().AsTime()) + http.Redirect(w, r, "/", http.StatusSeeOther) + case resp.GetNeedsVerification(): + http.Redirect(w, r, "/verify-email?email="+email, http.StatusSeeOther) + default: + d := h.authData(r, w) + d.Email, d.Error = email, i18n.T(lang, "errors.incorrect_credentials") + h.render(w, http.StatusUnauthorized, "login", d) + } +} + +func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { + if raw := sessionToken(r); raw != "" { + _, _ = h.IAM.Logout(r.Context(), &iamv1.LogoutRequest{SessionToken: raw}) + } + h.setCookie(w, sessionCookieName, "", -1) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (h *Handlers) SignupPage(w http.ResponseWriter, r *http.Request) { + h.render(w, http.StatusOK, "signup", h.authData(r, w)) +} + +func (h *Handlers) Signup(w http.ResponseWriter, r *http.Request) { + lang := localeFrom(r) + email, err := normalizeEmail(r.PostFormValue("email")) + if err != nil { + d := h.authData(r, w) + d.Error = i18n.T(lang, "errors.invalid_email") + h.render(w, http.StatusBadRequest, "signup", d) + return + } + if err := validatePassword(r.PostFormValue("password"), r.PostFormValue("password_confirm")); err != nil { + d := h.authData(r, w) + d.Email, d.Error = email, translatePasswordError(lang, err) + h.render(w, http.StatusBadRequest, "signup", d) + return + } + resp, err := h.IAM.SignUp(r.Context(), &iamv1.SignUpRequest{ + Email: email, Password: r.PostFormValue("password"), PasswordConfirm: r.PostFormValue("password_confirm"), ClientIp: clientIP(r), + }) + if err != nil { + h.Logger.Error("signup", "err", err) + d := h.authData(r, w) + d.Email, d.Error = email, i18n.T(lang, "errors.generic") + h.render(w, http.StatusInternalServerError, "signup", d) + return + } + info := i18n.T(lang, "info.check_inbox") + if !resp.GetMailConfigured() { + info = i18n.T(lang, "info.mail_not_configured") + } + http.Redirect(w, r, "/verify-email?email="+email+"&info="+info, http.StatusSeeOther) +} + +func (h *Handlers) VerifyEmailPage(w http.ResponseWriter, r *http.Request) { + d := h.authData(r, w) + d.Email, d.Info = r.URL.Query().Get("email"), r.URL.Query().Get("info") + h.render(w, http.StatusOK, "verify_email", d) +} + +func (h *Handlers) VerifyEmail(w http.ResponseWriter, r *http.Request) { + lang := localeFrom(r) + email, _ := normalizeEmail(r.PostFormValue("email")) + code := r.PostFormValue("code") + if !isSixDigitCode(code) { + d := h.authData(r, w) + d.Email, d.Error = email, i18n.T(lang, "errors.invalid_code") + h.render(w, http.StatusBadRequest, "verify_email", d) + return + } + resp, err := h.IAM.VerifyEmail(r.Context(), &iamv1.VerifyEmailRequest{Email: email, Code: code, ClientIp: clientIP(r)}) + if err != nil { + h.Logger.Error("verify email", "err", err) + d := h.authData(r, w) + d.Email, d.Error = email, i18n.T(lang, "errors.generic") + h.render(w, http.StatusInternalServerError, "verify_email", d) + return + } + if !resp.GetVerified() { + d := h.authData(r, w) + d.Email, d.Error = email, i18n.T(lang, "errors.code_incorrect") + h.render(w, http.StatusBadRequest, "verify_email", d) + return + } + http.Redirect(w, r, "/login?info="+i18n.T(lang, "info.email_verified"), http.StatusSeeOther) +} + +func (h *Handlers) ResendVerification(w http.ResponseWriter, r *http.Request) { + email, _ := normalizeEmail(r.PostFormValue("email")) + _, _ = h.IAM.ResendVerification(r.Context(), &iamv1.ResendVerificationRequest{Email: email, ClientIp: clientIP(r)}) + http.Redirect(w, r, "/verify-email?email="+email+"&info="+i18n.T(localeFrom(r), "info.resend_sent"), http.StatusSeeOther) +} + +func (h *Handlers) ForgotPasswordPage(w http.ResponseWriter, r *http.Request) { + h.render(w, http.StatusOK, "forgot_password", h.authData(r, w)) +} + +func (h *Handlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { + email, _ := normalizeEmail(r.PostFormValue("email")) + _, _ = h.IAM.ForgotPassword(r.Context(), &iamv1.ForgotPasswordRequest{Email: email, ClientIp: clientIP(r)}) + d := h.authData(r, w) + d.Info = i18n.T(localeFrom(r), "info.forgot_sent") + h.render(w, http.StatusOK, "forgot_password", d) +} + +func (h *Handlers) ResetPasswordPage(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + resp, err := h.IAM.CheckPasswordResetToken(r.Context(), &iamv1.CheckPasswordResetTokenRequest{Token: token}) + if err != nil || !resp.GetValid() { + d := h.authData(r, w) + d.Error = i18n.T(localeFrom(r), "errors.reset_link_invalid") + h.render(w, http.StatusBadRequest, "reset_password", d) + return + } + d := h.authData(r, w) + d.Token = token + h.render(w, http.StatusOK, "reset_password", d) +} + +func (h *Handlers) ResetPassword(w http.ResponseWriter, r *http.Request) { + lang := localeFrom(r) + token := r.PostFormValue("token") + if err := validatePassword(r.PostFormValue("password"), r.PostFormValue("password_confirm")); err != nil { + d := h.authData(r, w) + d.Token, d.Error = token, translatePasswordError(lang, err) + h.render(w, http.StatusBadRequest, "reset_password", d) + return + } + resp, err := h.IAM.ResetPassword(r.Context(), &iamv1.ResetPasswordRequest{ + Token: token, Password: r.PostFormValue("password"), PasswordConfirm: r.PostFormValue("password_confirm"), + }) + if err != nil || !resp.GetOk() { + d := h.authData(r, w) + d.Token, d.Error = token, i18n.T(lang, "errors.reset_link_invalid") + h.render(w, http.StatusBadRequest, "reset_password", d) + return + } + http.Redirect(w, r, "/login?info="+i18n.T(lang, "info.password_reset_done"), http.StatusSeeOther) +} + +// translatePasswordError maps one of validatePassword's sentinel +// errors (see core.go) onto the matching catalog key, via errors.Is +// rather than matching the error's English text — so this still works +// correctly no matter what validatePassword's messages say, or if +// minPasswordLen/maxPasswordLen ever change. Any error that isn't one +// of the three known sentinels falls back to errors.generic. +func translatePasswordError(lang i18n.Locale, err error) string { + switch { + case errors.Is(err, errPasswordTooShort): + return i18n.T(lang, "errors.password_min", minPasswordLen) + case errors.Is(err, errPasswordTooLong): + return i18n.T(lang, "errors.password_max", maxPasswordLen) + case errors.Is(err, errPasswordMismatch): + return i18n.T(lang, "errors.password_mismatch") + default: + return i18n.T(lang, "errors.generic") + } +} diff --git a/services/gateway/internal/handlers/billing.go b/services/gateway/internal/handlers/billing.go new file mode 100644 index 0000000..d91ba83 --- /dev/null +++ b/services/gateway/internal/handlers/billing.go @@ -0,0 +1,282 @@ +// Package handlers (billing.go) replaces internal/handlers/billing.go. +// The Stripe webhook keeps its exact public URL (POST /webhooks/stripe) +// so nothing needs to change in the Stripe dashboard — but this handler +// now just reads the raw body and forwards it untouched to billing's +// IngestStripeWebhook RPC, which holds STRIPE_WEBHOOK_SECRET and does +// the actual signature verification; the gateway itself never sees +// Stripe's signing secret. +package handlers + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + billingv1 "enkly/gen/billing/v1" +) + +const maxWebhookBodyBytes = 1 << 20 + +type planView struct { + Plan string + Name string + PriceEUR int + DailyMessageLimit int + IsCurrent bool + Action string + Popular bool + Multiplier string + Checkoutable bool + UnavailableReason string +} + +type subscriptionData struct { + CSRFToken string + UserEmail string + + Plans []planView + CurrentName string + Status string + CancelAtPeriodEnd bool + CurrentPeriodEnd time.Time + + UsedToday int + DailyLimit int + Unlimited bool + UsagePercent int + NearLimit bool + AtLimit bool + + HasBillingPortal bool + + Message string + Error string +} + +func planSlug(p billingv1.Plan) string { + switch p { + case billingv1.Plan_PLAN_FREE: + return "free" + case billingv1.Plan_PLAN_BASIC: + return "basic" + case billingv1.Plan_PLAN_PRO: + return "pro" + case billingv1.Plan_PLAN_PREMIUM: + return "premium" + default: + return "free" + } +} + +func planRank(p billingv1.Plan) int { + switch p { + case billingv1.Plan_PLAN_FREE: + return 0 + case billingv1.Plan_PLAN_BASIC: + return 1 + case billingv1.Plan_PLAN_PRO: + return 2 + case billingv1.Plan_PLAN_PREMIUM: + return 3 + default: + return 0 + } +} + +func (h *Handlers) buildSubscriptionData(ctx context.Context, w http.ResponseWriter, r *http.Request) (subscriptionData, error) { + user := userFrom(r) + + sub, err := h.Billing.GetOrCreateSubscription(ctx, &billingv1.GetOrCreateSubscriptionRequest{UserId: user.GetId()}) + if err != nil { + return subscriptionData{}, err + } + catalog, err := h.Billing.GetPlanCatalog(ctx, &billingv1.GetPlanCatalogRequest{}) + if err != nil { + return subscriptionData{}, err + } + + curRank := planRank(sub.GetPlan()) + var freeLimit int32 + for _, p := range catalog.GetPlans() { + if p.GetPlan() == billingv1.Plan_PLAN_FREE { + freeLimit = p.GetDailyMessageLimit() + } + } + + plans := make([]planView, 0, len(catalog.GetPlans())) + for _, p := range catalog.GetPlans() { + var reason string + if !p.GetAvailable() { + reason = "Stripe isn't configured on this server yet (missing STRIPE_SECRET_KEY)." + } + + action := "Choose " + p.GetName() + switch rk := planRank(p.GetPlan()); { + case p.GetPlan() == billingv1.Plan_PLAN_FREE: + action = "Downgrade to Free" + case rk > curRank: + action = "Upgrade to " + p.GetName() + case rk < curRank: + action = "Switch to " + p.GetName() + } + + mult := "" + switch { + case p.GetPlan() == billingv1.Plan_PLAN_FREE: + case p.GetDailyMessageLimit() == 0: + mult = "Unlimited messages — no daily cap" + case freeLimit > 0: + mult = fmt.Sprintf("%d× more messages than Free", p.GetDailyMessageLimit()/freeLimit) + } + + plans = append(plans, planView{ + Plan: planSlug(p.GetPlan()), Name: p.GetName(), PriceEUR: int(p.GetPriceEur()), + DailyMessageLimit: int(p.GetDailyMessageLimit()), IsCurrent: p.GetPlan() == sub.GetPlan(), + Action: action, Popular: p.GetPlan() == billingv1.Plan_PLAN_PRO, Multiplier: mult, + Checkoutable: p.GetAvailable(), UnavailableReason: reason, + }) + } + + data := subscriptionData{ + CSRFToken: h.csrfToken(w, r), UserEmail: user.GetEmail(), Plans: plans, + CurrentName: planDisplayName(sub.GetPlan()), Status: sub.GetStatus(), + CancelAtPeriodEnd: sub.GetCancelAtPeriodEnd(), HasBillingPortal: sub.GetHasBillingAccount(), + } + if sub.GetCurrentPeriodEnd() != nil { + data.CurrentPeriodEnd = sub.GetCurrentPeriodEnd().AsTime() + } + + var limit int + for _, p := range catalog.GetPlans() { + if p.GetPlan() == sub.GetPlan() { + limit = int(p.GetDailyMessageLimit()) + } + } + data.DailyLimit = limit + data.Unlimited = limit == 0 + if usage, err := h.Billing.GetDailyUsage(ctx, &billingv1.GetDailyUsageRequest{UserId: user.GetId()}); err != nil { + h.Logger.Warn("load daily usage for subscription page", "err", err, "user_id", user.GetId()) + } else { + data.UsedToday = int(usage.GetUsed()) + if limit > 0 { + data.UsagePercent = min(100, data.UsedToday*100/limit) + data.NearLimit = data.UsedToday*100 >= limit*80 + data.AtLimit = data.UsedToday >= limit + } + } + return data, nil +} + +func (h *Handlers) SubscriptionPage(w http.ResponseWriter, r *http.Request) { + d, err := h.buildSubscriptionData(r.Context(), w, r) + if err != nil { + h.serverError(w, "load subscription", err) + return + } + switch r.URL.Query().Get("msg") { + case "success": + d.Message = "Payment received — thank you! Your plan is now active." + case "cancelled": + d.Message = "Checkout was cancelled — you weren't charged." + } + h.render(w, http.StatusOK, "subscription", d) +} + +func (h *Handlers) subscriptionError(w http.ResponseWriter, r *http.Request, status int, msg string) { + d, err := h.buildSubscriptionData(r.Context(), w, r) + if err != nil { + h.serverError(w, "load subscription", err) + return + } + d.Error = msg + h.render(w, status, "subscription", d) +} + +func (h *Handlers) SubscriptionSuccess(w http.ResponseWriter, r *http.Request) { + if id := r.URL.Query().Get("session_id"); id != "" { + if _, err := h.Billing.SyncCheckoutSession(r.Context(), &billingv1.SyncCheckoutSessionRequest{ + UserId: userFrom(r).GetId(), StripeSessionId: id, + }); err != nil { + h.Logger.Warn("sync checkout session", "err", err, "session_id", id) + } + } + http.Redirect(w, r, "/subscription?msg=success", http.StatusSeeOther) +} + +func (h *Handlers) SubscriptionCancel(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/subscription?msg=cancelled", http.StatusSeeOther) +} + +func (h *Handlers) CreateCheckout(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + ctx := r.Context() + + planStr := r.PostFormValue("plan") + if planStr == "free" { + h.BillingPortal(w, r) + return + } + + resp, err := h.Billing.CreateCheckoutSession(ctx, &billingv1.CreateCheckoutSessionRequest{ + UserId: user.GetId(), Email: user.GetEmail(), Plan: parsePlanSlug(planStr), + SuccessUrl: h.subscriptionURL("/subscription/success") + "?session_id={CHECKOUT_SESSION_ID}", + CancelUrl: h.subscriptionURL("/subscription/cancel"), + }) + if err != nil { + h.Logger.Error("create checkout session", "err", err, "user_id", user.GetId(), "plan", planStr) + h.subscriptionError(w, r, http.StatusBadGateway, fmt.Sprintf("Couldn't start checkout: %s", err)) + return + } + http.Redirect(w, r, resp.GetCheckoutUrl(), http.StatusSeeOther) +} + +func (h *Handlers) BillingPortal(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + resp, err := h.Billing.CreateBillingPortalSession(r.Context(), &billingv1.CreateBillingPortalSessionRequest{ + UserId: user.GetId(), ReturnUrl: h.subscriptionURL("/subscription"), + }) + if err != nil { + h.Logger.Error("create billing portal session", "err", err, "user_id", user.GetId()) + h.subscriptionError(w, r, http.StatusBadGateway, fmt.Sprintf("Couldn't open the billing portal: %s", err)) + return + } + http.Redirect(w, r, resp.GetPortalUrl(), http.StatusSeeOther) +} + +func (h *Handlers) subscriptionURL(path string) string { + return strings.TrimRight(h.BaseURL, "/") + path +} + +func parsePlanSlug(s string) billingv1.Plan { + switch s { + case "basic": + return billingv1.Plan_PLAN_BASIC + case "pro": + return billingv1.Plan_PLAN_PRO + case "premium": + return billingv1.Plan_PLAN_PREMIUM + default: + return billingv1.Plan_PLAN_FREE + } +} + +// StripeWebhook (POST /webhooks/stripe) — see package doc above. +func (h *Handlers) StripeWebhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + _, err = h.Billing.IngestStripeWebhook(r.Context(), &billingv1.IngestStripeWebhookRequest{ + Payload: body, SignatureHeader: r.Header.Get("Stripe-Signature"), + }) + if err != nil { + h.Logger.Warn("stripe webhook rejected", "err", err) + http.Error(w, "invalid signature", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/services/gateway/internal/handlers/chat.go b/services/gateway/internal/handlers/chat.go new file mode 100644 index 0000000..72c25bc --- /dev/null +++ b/services/gateway/internal/handlers/chat.go @@ -0,0 +1,287 @@ +// Package handlers (chat.go) replaces internal/handlers/chat.go's HTTP +// surface. All of the orchestration that file used to do directly +// (quota check, persisting messages, running the agent loop) now +// happens inside the agent service; this file's job is just to call +// StartTurn, translate Agent's gRPC server-stream into the exact same +// SSE wire format the front end (web/static/js/app.js, chat_response/ +// assistant_stream in the templates) already expects, and do the one +// thing that must stay here: render HTML (Markdown, tool-call cards, +// deliverable cards) from the plain text/structured data the agent +// service streams back. See core.go's package doc for why rendering +// lives only in this service. +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "io" + "net/http" + "strconv" + "strings" + + "google.golang.org/grpc/metadata" + + agentv1 "enkly/gen/agent/v1" + "enkly/pkg/i18n" + "enkly/pkg/renderkit" +) + +// langMetadataKey is the gRPC metadata key the gateway uses to tell +// the agent service which locale this visitor has chosen, so the +// model can be told that as a standing preference (see +// agentrun.WithPreferredLanguage) rather than only inferring it +// message-by-message. A metadata header, not a StartTurnRequest +// field: adding a proto field means regenerating every language's +// generated client/server code, and this repo's gen/ directory is +// hand-maintained without network access to protoc in some +// environments — a header costs nothing to add or remove later and +// needs no regeneration on either side. See ARCHITECTURE.md's +// "Language preference storage" note. +const langMetadataKey = "x-enkly-lang" + +// withLangMetadata attaches the request's resolved locale (see +// localeFrom) to r's context as outgoing gRPC metadata, for a call to +// the agent service that should know the visitor's chosen UI +// language — currently just StartTurn, which is the one call that +// kicks off a run whose system prompt gets built. +func withLangMetadata(r *http.Request) context.Context { + return metadata.AppendToOutgoingContext(r.Context(), langMetadataKey, string(localeFrom(r))) +} + +type chatErrorData struct{ Message string } + +type chatResponseData struct { + UserPromptHTML template.HTML + StreamID string + CSRFToken string +} + +// Chat replaces Handlers.Chat. Attachments (see attachments.go) ride +// along as repeated "attachments" form values — one per file the +// composer already uploaded in the background before this submit — +// and get folded into the exact prompt string sent to StartTurn (and +// therefore persisted as the message's content), so the agent, the +// just-sent bubble, and every future reload of this conversation all +// see the same thing. +func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + convID, _ := strconv.ParseInt(r.PostFormValue("conversation_id"), 10, 64) + prompt := strings.TrimSpace(r.PostFormValue("prompt")) + attachments := cleanAttachmentPaths(r.PostForm[attachmentFormField]) + if prompt == "" && len(attachments) > 0 { + prompt = defaultAttachmentPrompt(attachments) + } + fullPrompt := appendAttachmentsBlock(prompt, attachments) + + resp, err := h.Agent.StartTurn(withLangMetadata(r), &agentv1.StartTurnRequest{ + UserId: user.GetId(), ConversationId: convID, Prompt: fullPrompt, + }) + if err != nil { + h.Logger.Error("start turn", "err", err) + h.render(w, http.StatusInternalServerError, "chat_error", chatErrorData{Message: i18n.T(localeFrom(r), "chat.send_failed")}) + return + } + if resp.GetOutcome() != agentv1.StartTurnOutcome_START_TURN_OUTCOME_OK { + status := http.StatusBadRequest + if resp.GetOutcome() == agentv1.StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED { + status = http.StatusPaymentRequired + } + h.render(w, status, "chat_error", chatErrorData{Message: resp.GetMessage()}) + return + } + + h.render(w, http.StatusOK, "chat_response", chatResponseData{ + UserPromptHTML: h.renderUserMessage(fullPrompt), + StreamID: resp.GetStreamId(), + CSRFToken: h.csrfToken(w, r), + }) +} + +// StreamChat replaces Handlers.StreamChat: opens Agent's server- +// streaming StreamTurn RPC and re-emits every event as an SSE frame. +func (h *Handlers) StreamChat(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + streamID := r.PathValue("id") + + resumeFrom := 0 + if id := r.Header.Get("Last-Event-ID"); id != "" { + resumeFrom, _ = strconv.Atoi(id) + } + + grpcStream, err := h.Agent.StreamTurn(r.Context(), &agentv1.StreamTurnRequest{ + UserId: user.GetId(), StreamId: streamID, ResumeFromToolIndex: int32(resumeFrom), + }) + if err != nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + toolIndex := resumeFrom + var finalToolCalls []*agentv1.ToolCall + // shownDeliverables tracks (by cleanWorkspacePath) every file + // already streamed live via a "deliverable" event below, so the + // "done" event's deliverablesHTML doesn't render a duplicate card + // for the same file — see liveDeliverable/remainingToolCalls. + shownDeliverables := map[string]bool{} + + for { + ev, err := grpcStream.Recv() + if err == io.EOF || err != nil { + return + } + switch e := ev.GetEvent().(type) { + case *agentv1.TurnEvent_ThinkingDelta: + writeSSE(w, "thinking", renderkit.PlainStream(e.ThinkingDelta)) + case *agentv1.TurnEvent_TokenDelta: + writeSSE(w, "token", renderkit.PlainStream(e.TokenDelta)) + case *agentv1.TurnEvent_ToolCall: + toolIndex++ + writeSSEID(w, "tool", toolIndex, h.renderToolCallOOB(streamID, e.ToolCall)) + // As soon as this tool call has created or changed a + // document (create_document, edit_spreadsheet, ...), + // show its Download button right away — don't make the + // user wait for the whole turn to finish, which can come + // much later if the agent keeps working after this. + if frag, key, ok := h.liveDeliverable(r.Context(), streamID, e.ToolCall, shownDeliverables); ok { + shownDeliverables[key] = true + writeSSE(w, "deliverable", frag) + } + case *agentv1.TurnEvent_ThinkingRound: + writeSSE(w, "thinking_round", h.renderThinkingRoundOOB(streamID, e.ThinkingRound)) + case *agentv1.TurnEvent_Done: + finalToolCalls = e.Done.GetToolCalls() + html := renderkit.Markdown(e.Done.GetFinalText()) + html += h.deliverablesHTML(r.Context(), remainingToolCalls(finalToolCalls, shownDeliverables)) + writeSSE(w, "done", html) + if flusher != nil { + flusher.Flush() + } + return + } + if flusher != nil { + flusher.Flush() + } + } +} + +// StopStream replaces Handlers.StopStream. +func (h *Handlers) StopStream(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + _, err := h.Agent.StopTurn(r.Context(), &agentv1.StopTurnRequest{UserId: user.GetId(), StreamId: r.PathValue("id")}) + if err != nil { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------- +// SSE framing + tool-card rendering +// --------------------------------------------------------------------- + +func writeSSE(w http.ResponseWriter, event, data string) { + fmt.Fprintf(w, "event: %s\n", event) + for _, line := range splitLines(data) { + fmt.Fprintf(w, "data: %s\n", line) + } + fmt.Fprint(w, "\n") +} + +func writeSSEID(w http.ResponseWriter, event string, id int, data string) { + fmt.Fprintf(w, "id: %d\n", id) + writeSSE(w, event, data) +} + +func splitLines(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + out = append(out, s[start:]) + return out +} + +type toolCallView struct { + StreamID string + ToolName string + Summary string + Body template.HTML + IsError bool +} + +// renderToolCallOOB replaces h.renderToolOOB: builds the +// hx-swap-oob-wrapped tool card for one completed tool call. +func (h *Handlers) renderToolCallOOB(streamID string, tc *agentv1.ToolCall) string { + return h.renderString("tool_call_oob", toolCallView{ + StreamID: streamID, + ToolName: tc.GetName(), + Summary: summarizeToolCall(tc.GetName(), tc.GetArgumentsJson()), + Body: toolBodyHTML(tc.GetResult()), + IsError: tc.GetIsError(), + }) +} + +type thinkingRoundView struct { + StreamID string + Round int32 + Thinking string +} + +// renderThinkingRoundOOB builds the hx-swap-oob-wrapped card for one +// round's standalone reasoning trace — the TurnEvent_ThinkingRound +// counterpart to renderToolCallOOB. Unlike tool cards, there's no +// resume cursor for these (see server.go's StreamTurn comment on +// sentThinkRounds): a client that reconnects mid-turn will see +// already-shown round cards inserted again. Acceptable for now since +// a run has at most MaxRounds of these, each a short card, and the +// existing cumulative "thinking" delta already re-sends in full on +// every reconnect anyway. +func (h *Handlers) renderThinkingRoundOOB(streamID string, tr *agentv1.ThinkingRound) string { + return h.renderString("thinking_round_oob", thinkingRoundView{ + StreamID: streamID, + Round: tr.GetRound(), + Thinking: tr.GetThinking(), + }) +} + +// summarizeToolCall replaces handlers.summarizeCall — a short, +// human-readable one-liner shown in the card's collapsed summary row +// (e.g. "internal/db/users.go" for a read_file call). Tries the +// arguments' most identifying field first (path, pattern, command, +// query); falls back to the raw (truncated) arguments JSON. +func summarizeToolCall(name, argsJSON string) string { + if argsJSON == "" || argsJSON == "{}" { + return "" + } + var args map[string]any + if err := json.Unmarshal([]byte(argsJSON), &args); err == nil { + for _, key := range []string{"path", "pattern", "command", "query", "task"} { + if v, ok := args[key].(string); ok && v != "" { + return truncateSummary(v) + } + } + } + return truncateSummary(argsJSON) +} + +const maxSummaryChars = 80 + +func truncateSummary(s string) string { + if len(s) > maxSummaryChars { + return s[:maxSummaryChars] + "…" + } + return s +} diff --git a/services/gateway/internal/handlers/core.go b/services/gateway/internal/handlers/core.go new file mode 100644 index 0000000..401aba0 --- /dev/null +++ b/services/gateway/internal/handlers/core.go @@ -0,0 +1,279 @@ +// Package handlers is the gateway's net/http layer — the BFF (backend +// for frontend) that terminates the public HTTP/HTMX surface and +// composes it from gRPC calls to every other bounded context. It is +// the direct transplant of the monolith's internal/handlers package, +// with one systematic change: everywhere the original called h.DB, +// h.Agent, or h.Billing directly, this package calls the matching +// service's generated gRPC client instead. Rendering (html/template, +// pkg/renderkit's Markdown/syntax-highlighting) stays here — this is +// now the ONLY service that imports pkg/renderkit or parses *.html +// templates; see ARCHITECTURE.md's "Where rendering lives". +package handlers + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "log/slog" + "net" + "net/http" + "net/mail" + "path/filepath" + "strings" + "time" + + agentv1 "enkly/gen/agent/v1" + billingv1 "enkly/gen/billing/v1" + conversationv1 "enkly/gen/conversation/v1" + iamv1 "enkly/gen/iam/v1" + knowledgev1 "enkly/gen/knowledge/v1" + workspacev1 "enkly/gen/workspace/v1" + "enkly/pkg/authsupport" + "enkly/pkg/i18n" +) + +const ( + sessionCookieName = "enkly_session" + csrfCookieName = "enkly_csrf" + csrfFormField = "csrf_token" + + sessionTTL = 30 * 24 * time.Hour + maxFormBytes = 1 << 20 // 1 MiB + maxTitleChars = 120 + minPasswordLen = 8 + maxPasswordLen = 256 + maxEmailLen = 254 +) + +// Handlers bundles every dependency an HTTP handler needs: the +// generated gRPC clients for each peer bounded context, the parsed +// templates, and the gateway's own edge-only state (CSRF — there is no +// server-side CSRF table, it's a pure double-submit cookie, so nothing +// about it belongs in any other service). +type Handlers struct { + IAM iamv1.IAMServiceClient + Billing billingv1.BillingServiceClient + Conversation conversationv1.ConversationServiceClient + Workspace workspacev1.WorkspaceServiceClient + Knowledge knowledgev1.KnowledgeServiceClient + Agent agentv1.AgentServiceClient + + Templates *template.Template + Logger *slog.Logger + BaseURL string + + StripePublishKey string +} + +// templateFuncs are the template functions every *.html file can call +// regardless of which handler rendered it. Kept to pure functions of +// their arguments (never a closure over per-request state) because +// Templates is parsed once at startup and shared across every +// request — see LoadTemplates. +var templateFuncs = template.FuncMap{ + // t looks up a translation key in the given locale, falling back + // to English and then to the key itself — see pkg/i18n.T's doc + // comment. Usage in a template: {{t .Lang "nav.new_chat"}}, or + // with Sprintf-style args: {{t .Lang "errors.password_min" 8}}. + "t": func(locale, key string, args ...any) string { + return i18n.T(i18n.Locale(locale), key, args...) + }, + // supportedLanguages lists every locale enkly ships a catalog for, + // for the language-switcher partial to range over. A pure function + // of no arguments (the list is the same for every request), so — + // unlike the current locale — it needs no per-page field. + "supportedLanguages": func() []i18n.Info { return i18n.Supported }, + // dict builds a map[string]any from alternating key/value + // arguments, so a page can pass its own CSRFToken/Lang/Next into a + // shared partial (e.g. "lang_switcher") that isn't handed the + // whole page's data value. Standard html/template idiom — the + // stdlib has no built-in equivalent. + "dict": func(pairs ...any) (map[string]any, error) { + if len(pairs)%2 != 0 { + return nil, fmt.Errorf("dict: odd number of arguments") + } + m := make(map[string]any, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + key, ok := pairs[i].(string) + if !ok { + return nil, fmt.Errorf("dict: argument %d is not a string key", i) + } + m[key] = pairs[i+1] + } + return m, nil + }, +} + +// LoadTemplates parses every *.html file in dir, registering +// templateFuncs so every template can call {{t .Lang "..."}} and +// {{supportedLanguages}} — otherwise unchanged from the original +// handlers.LoadTemplates. +func LoadTemplates(dir string) (*template.Template, error) { + files, err := filepath.Glob(filepath.Join(dir, "*.html")) + if err != nil { + return nil, fmt.Errorf("gateway: glob templates: %w", err) + } + if len(files) == 0 { + return nil, fmt.Errorf("gateway: no *.html templates found in %s", dir) + } + t, err := template.New("enkly").Funcs(templateFuncs).ParseFiles(files...) + if err != nil { + return nil, fmt.Errorf("gateway: parse templates: %w", err) + } + return t, nil +} + +func (h *Handlers) render(w http.ResponseWriter, status int, name string, data any) { + var buf bytes.Buffer + if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil { + h.Logger.Error("render template", "template", name, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _, _ = w.Write(buf.Bytes()) +} + +func (h *Handlers) renderString(name string, data any) string { + var buf bytes.Buffer + if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil { + h.Logger.Error("render fragment", "template", name, "err", err) + return "" + } + return buf.String() +} + +// --------------------------------------------------------------------- +// Request context: the authenticated user (now the IAM-service User, +// via the generated client, instead of models.User) +// --------------------------------------------------------------------- + +type ctxKey int + +const userCtxKey ctxKey = iota + +func withUser(ctx context.Context, u *iamv1.User) context.Context { + return context.WithValue(ctx, userCtxKey, u) +} + +func userFrom(r *http.Request) *iamv1.User { + u, _ := r.Context().Value(userCtxKey).(*iamv1.User) + return u +} + +// --------------------------------------------------------------------- +// Cookies +// --------------------------------------------------------------------- + +func (h *Handlers) secureCookies() bool { + return strings.HasPrefix(strings.ToLower(h.BaseURL), "https://") +} + +func (h *Handlers) setCookie(w http.ResponseWriter, name, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: value, Path: "/", MaxAge: maxAge, + HttpOnly: true, Secure: h.secureCookies(), SameSite: http.SameSiteLaxMode, + }) +} + +// csrfToken replaces Handlers.csrfToken. +func (h *Handlers) csrfToken(w http.ResponseWriter, r *http.Request) string { + if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" { + return c.Value + } + tok, err := authsupport.NewCSRFToken() + if err != nil { + h.Logger.Error("csrf token", "err", err) + return "" + } + h.setCookie(w, csrfCookieName, tok, int((365 * 24 * time.Hour).Seconds())) + return tok +} + +func sessionToken(r *http.Request) string { + if c, err := r.Cookie(sessionCookieName); err == nil { + return c.Value + } + return "" +} + +// startSessionCookie sets the browser's session cookie from a token IAM +// already minted (via Login/SignUp) — replaces Handlers.startSession, +// which used to create the session row itself; now IAM owns that. +func (h *Handlers) startSessionCookie(w http.ResponseWriter, rawToken string, expiresAt time.Time) { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge <= 0 { + maxAge = int(sessionTTL.Seconds()) + } + h.setCookie(w, sessionCookieName, rawToken, maxAge) +} + +// --------------------------------------------------------------------- +// Small shared helpers — unchanged from the monolith +// --------------------------------------------------------------------- + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func normalizeEmail(raw string) (string, error) { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" || len(s) > maxEmailLen { + return "", errors.New("invalid email") + } + addr, err := mail.ParseAddress(s) + if err != nil || addr.Address != s { + return "", errors.New("invalid email") + } + return s, nil +} + +func isSixDigitCode(raw string) bool { + if len(raw) != 6 { + return false + } + for _, c := range raw { + if c < '0' || c > '9' { + return false + } + } + return true +} + +// Sentinel errors for validatePassword, so a caller (auth.go's +// translatePasswordError) can switch on *which* rule failed with +// errors.Is instead of matching against the English wording of an +// error string — the old approach, which silently breaks the moment +// either message or the min/max constants change. +var ( + errPasswordTooShort = errors.New("password too short") + errPasswordTooLong = errors.New("password too long") + errPasswordMismatch = errors.New("passwords don't match") +) + +func validatePassword(password, confirm string) error { + switch { + case len(password) < minPasswordLen: + return errPasswordTooShort + case len(password) > maxPasswordLen: + return errPasswordTooLong + case password != confirm: + return errPasswordMismatch + } + return nil +} + +func parseForm(w http.ResponseWriter, r *http.Request) error { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + return r.ParseForm() +} + +func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } diff --git a/services/gateway/internal/handlers/deliverables.go b/services/gateway/internal/handlers/deliverables.go new file mode 100644 index 0000000..a1d6d2c --- /dev/null +++ b/services/gateway/internal/handlers/deliverables.go @@ -0,0 +1,312 @@ +// Package handlers (deliverables.go) replaces +// internal/handlers/deliverables.go. Beyond the original change +// (fileDeliverable's os.Stat call becoming a Workspace.StatFile gRPC +// call), producedPath now also recognizes the nine office-document +// tools (create_document/read_document/edit_document, +// create_spreadsheet/read_spreadsheet/edit_spreadsheet, +// create_presentation/read_presentation/edit_presentation — +// toolexec/office.go) in addition to the original +// write_file/edit_file/multi_edit_file. Those tools were added +// without a matching update here: their success messages ("created +// report.docx (4 paragraph(s), 512 bytes)", "wrote 3 cell(s) to +// budget.xlsx", "added 1 slide to deck.pptx", ...) never matched +// writeResultRe/editResultRe, so officekit would build and upload a +// perfectly valid .docx/.xlsx/.pptx (confirmed by officekit's own +// tests) and the chat UI would still never show a download card for +// it — the file existed in the workspace but was never surfaced as a +// deliverable. officeResultPathRe below matches on the one thing +// every one of those success messages reliably contains — the +// workspace path itself, recognizable by its .docx/.xlsx/.pptx +// extension — rather than each operation's own differently-worded +// phrasing, so it doesn't need one regex per operation and won't +// break again the next time an office tool's wording changes. +package handlers + +import ( + "context" + "html/template" + "path" + "regexp" + "strings" + + agentv1 "enkly/gen/agent/v1" + workspacev1 "enkly/gen/workspace/v1" + "enkly/pkg/renderkit" +) + +const ( + maxFileCards = 8 + maxSnippetCards = 4 + minSnippetLines = 3 +) + +type deliverableView struct { + Name string + TypeLabel string + Detail string + DownloadURL string + SnippetIndex int +} + +var ( + writeResultRe = regexp.MustCompile(`^wrote \d+ bytes to (.+)$`) + editResultRe = regexp.MustCompile(`^edited (.+?): (?:replaced|applied) `) + + // officeResultPathRe pulls the workspace path out of any + // create_document/edit_document/create_spreadsheet/ + // edit_spreadsheet/create_presentation/edit_presentation success + // message by matching the path itself (anything non-whitespace + // ending in .docx, .xlsx or .pptx) rather than the surrounding + // sentence, since office.go's nine tool handlers each phrase + // their result differently ("created %s (...)", "appended 1 + // paragraph to %s", "wrote %d cell(s) to %s", "added 1 slide to + // %s", "updated slide %d of %s", ...) and every one of those + // phrasings always includes exactly that path. + officeResultPathRe = regexp.MustCompile(`\S+\.(?:docx|xlsx|pptx)\b`) + + // officeDocTools is every office-document tool whose success + // message should be checked against officeResultPathRe. + // read_document/read_spreadsheet/read_presentation are excluded + // deliberately: those tools don't produce or change a file, so + // offering a download card for one would be misleading (and + // read_document's/read_spreadsheet's own result text can + // legitimately quote a .docx/.xlsx path from inside the document + // being read, which officeResultPathRe would otherwise mismatch + // as something just produced). + officeDocTools = map[string]bool{ + "create_document": true, + "edit_document": true, + "create_spreadsheet": true, + "edit_spreadsheet": true, + "create_presentation": true, + "edit_presentation": true, + } +) + +// producedPath replaces handlers.producedPath. +func producedPath(toolName, content string) string { + first, _, _ := strings.Cut(content, "\n") + if officeDocTools[toolName] { + return officeResultPathRe.FindString(first) + } + var m []string + switch toolName { + case "write_file": + m = writeResultRe.FindStringSubmatch(first) + case "edit_file", "multi_edit_file": + m = editResultRe.FindStringSubmatch(first) + } + if m == nil { + return "" + } + return strings.TrimSpace(m[1]) +} + +// cleanWorkspacePath normalizes a tool-reported path the way +// fileDeliverable and deliverablesHTMLFull already did inline — +// factored out so liveDeliverable and remainingToolCalls (chat.go's +// per-tool-call live delivery) can dedupe against the exact same key. +func cleanWorkspacePath(rel string) string { + return strings.TrimPrefix(path.Clean("/"+rel), "/") +} + +func (h *Handlers) fileDeliverable(ctx context.Context, rel string) (deliverableView, bool) { + clean := cleanWorkspacePath(rel) + if clean == "" { + return deliverableView{}, false + } + stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: clean}) + if err != nil || !stat.GetExists() || stat.GetIsDir() { + return deliverableView{}, false + } + name := path.Base(clean) + label := strings.ToUpper(strings.TrimPrefix(path.Ext(name), ".")) + if label == "" { + label = "FILE" + } + return deliverableView{ + Name: name, TypeLabel: label, Detail: humanBytes(stat.GetSize()), DownloadURL: downloadURLFor(clean), + }, true +} + +// deliverablesHTML renders the card strip for the given produced paths +// and snippets, or "" when there's nothing worth showing. +func (h *Handlers) deliverablesHTMLFull(ctx context.Context, paths []string, snippets []renderkit.Snippet) string { + var views []deliverableView + + seen := map[string]bool{} + for _, p := range paths { + key := cleanWorkspacePath(p) + if seen[key] { + continue + } + seen[key] = true + if v, ok := h.fileDeliverable(ctx, p); ok { + views = append(views, v) + if len(views) == maxFileCards { + break + } + } + } + + var picked []renderkit.Snippet + for _, sn := range snippets { + if sn.Lines >= minSnippetLines && len(picked) < maxSnippetCards { + picked = append(picked, sn) + } + } + for n, sn := range picked { + name := "snippet" + sn.Ext + if len(picked) > 1 { + name = "snippet-" + itoa(n+1) + sn.Ext + } + if sn.Filename != "" { + name = sn.Filename + } + views = append(views, deliverableView{ + Name: name, TypeLabel: sn.Label, Detail: itoa(sn.Lines) + " lines", SnippetIndex: sn.Index, + }) + } + + if len(views) == 0 { + return "" + } + return h.renderString("deliverables", views) +} + +// deliverablesHTML is the entry point chat.go's StreamChat calls on the +// "done" event: every tool call made during the turn becomes a set of +// produced-file candidates (producedPath filters down to the +// successful write_file/edit_file/multi_edit_file ones). +func (h *Handlers) deliverablesHTML(ctx context.Context, toolCalls []*agentv1.ToolCall) string { + var paths []string + for _, tc := range toolCalls { + if tc.GetIsError() { + continue + } + if p := producedPath(tc.GetName(), tc.GetResult()); p != "" { + paths = append(paths, p) + } + } + return h.deliverablesHTMLFull(ctx, paths, nil) +} + +// liveDeliverable checks whether tc — a tool call that just finished, +// mid-turn — produced or changed a file worth a download card, and +// one that hasn't already been shown live earlier in this same turn +// (shown, keyed by cleanWorkspacePath, is the running set chat.go's +// StreamChat keeps across the whole stream). This is what makes a +// document's download button appear as soon as the tool call that +// created it completes — see chat.go's TurnEvent_ToolCall case — +// rather than only once the whole turn finishes and the "done" event +// fires, which could be much later if the agent keeps working after +// creating the file. ok is false when there's nothing new to show: no +// path, an error result, a path already shown, or (StatFile coming +// back empty) a path that doesn't actually resolve to a file yet. +func (h *Handlers) liveDeliverable(ctx context.Context, streamID string, tc *agentv1.ToolCall, shown map[string]bool) (frag, key string, ok bool) { + if tc.GetIsError() { + return "", "", false + } + p := producedPath(tc.GetName(), tc.GetResult()) + if p == "" { + return "", "", false + } + key = cleanWorkspacePath(p) + if shown[key] { + return "", "", false + } + v, exists := h.fileDeliverable(ctx, p) + if !exists { + return "", "", false + } + return h.renderString("deliverable_oob", struct { + StreamID string + Views []deliverableView + }{StreamID: streamID, Views: []deliverableView{v}}), key, true +} + +// remainingToolCalls filters toolCalls down to the ones not already +// covered by shown, so chat.go's "done" event — which otherwise +// re-derives deliverable cards from every tool call of the whole turn +// — doesn't render a second, duplicate card for a file whose card +// already streamed live via liveDeliverable. On a resumed stream +// (StreamTurn's ResumeFromToolIndex), shown starts empty for the new +// connection, so a file created before the client reconnected still +// gets its card here as a fallback — nothing is ever silently lost, +// only de-duplicated on the common, unbroken-connection path. +func remainingToolCalls(toolCalls []*agentv1.ToolCall, shown map[string]bool) []*agentv1.ToolCall { + if len(shown) == 0 { + return toolCalls + } + out := make([]*agentv1.ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + if !tc.GetIsError() { + if p := producedPath(tc.GetName(), tc.GetResult()); p != "" && shown[cleanWorkspacePath(p)] { + continue + } + } + out = append(out, tc) + } + return out +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +// withDeliverables replaces Handlers.withDeliverables + +// Handlers.attachDeliverables: appends download cards to already- +// rendered messages when a conversation page is loaded. +func (h *Handlers) withDeliverables(ctx context.Context, msgs []messageView) []messageView { + h.attachDeliverables(ctx, msgs) + return msgs +} + +func (h *Handlers) attachDeliverables(ctx context.Context, msgs []messageView) { + var ( + pending []string + last = -1 + ) + flush := func() { + if last >= 0 && len(pending) > 0 { + if cards := h.deliverablesHTMLFull(ctx, pending, nil); cards != "" { + msgs[last].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template + } + } + pending, last = nil, -1 + } + for i := range msgs { + switch msgs[i].Role { + case "user": + flush() + case "tool": + if p := producedPath(msgs[i].ToolName, msgs[i].Content); p != "" { + pending = append(pending, p) + } + case "assistant": + if cards := h.deliverablesHTMLFull(ctx, nil, renderkit.Snippets(msgs[i].Content)); cards != "" { + msgs[i].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template + } + last = i + } + } + flush() +} diff --git a/services/gateway/internal/handlers/files.go b/services/gateway/internal/handlers/files.go new file mode 100644 index 0000000..0777d77 --- /dev/null +++ b/services/gateway/internal/handlers/files.go @@ -0,0 +1,272 @@ +// Package handlers (files.go) replaces internal/handlers/files.go. +// Every path that used to go through llm.Toolbox.ResolvePath on the +// local filesystem now goes through the workspace service's gRPC API +// instead (UploadFile/DownloadFile/Tree) — that service applies the +// exact same jail (see services/workspace/internal/infrastructure/ +// sandboxfs/sandbox.go), just on the other end of the wire now. +package handlers + +import ( + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + workspacev1 "enkly/gen/workspace/v1" + "enkly/pkg/authsupport" +) + +// officeContentTypes covers the handful of extensions whose correct +// MIME type Go's own mime.TypeByExtension can't always be trusted to +// know (it falls back to the host OS's registered MIME database, +// which doesn't universally list the OOXML types) — most importantly +// pkg/officekit's own .docx/.xlsx/.pptx output, which would otherwise +// sniff as "application/zip" (see downloadContentType). +var officeContentTypes = map[string]string{ + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".pdf": "application/pdf", +} + +// downloadContentType returns the Content-Type DownloadFile should +// advertise for name, by extension: officeContentTypes' explicit +// entries first, then mime.TypeByExtension, then a generic binary +// fallback — never server-side content sniffing (see the comment at +// DownloadFile's w.Header().Set("Content-Type", ...) call for why). +func downloadContentType(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + if ct, ok := officeContentTypes[ext]; ok { + return ct + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + return "application/octet-stream" +} + +const ( + maxUploadFileBytes = 20 << 20 + maxUploadFilesPerRequest = 8 + maxUploadRequestBytes = maxUploadFilesPerRequest*maxUploadFileBytes + (1 << 20) + maxUploadMemory = 10 << 20 + uploadFormField = "files" +) + +type workspaceFileView struct { + Path string + DownloadURL string + Size int64 + SizeHuman string + ModTime time.Time +} + +type filesPanelData struct { + WorkspaceFiles []workspaceFileView + ConversationID int64 + CSRFToken string + UploadMessage string + UploadError string +} + +// --------------------------------------------------------------------- +// GET /files, POST /files/upload +// --------------------------------------------------------------------- + +func (h *Handlers) FilesPanel(w http.ResponseWriter, r *http.Request) { + convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64) + h.renderFilesPanel(w, r, filesPanelData{ConversationID: convID}) +} + +func (h *Handlers) UploadFile(w http.ResponseWriter, r *http.Request) { + convID, _ := strconv.ParseInt(r.PostFormValue("conversation_id"), 10, 64) + data := filesPanelData{ConversationID: convID} + + destDir := strings.TrimSpace(r.PostFormValue("dir")) + + var headers []*multipart.FileHeader + if r.MultipartForm != nil { + headers = r.MultipartForm.File[uploadFormField] + } + switch { + case len(headers) == 0: + data.UploadError = "Choose at least one file first." + case len(headers) > maxUploadFilesPerRequest: + data.UploadError = fmt.Sprintf("Too many files in one upload (limit %d).", maxUploadFilesPerRequest) + default: + saved, err := h.saveUploadedFiles(r, destDir, headers) + if err != nil { + data.UploadError = err.Error() + } else if len(saved) == 1 { + data.UploadMessage = fmt.Sprintf("Uploaded %s.", saved[0]) + } else { + data.UploadMessage = fmt.Sprintf("Uploaded %d files.", len(saved)) + } + } + + h.renderFilesPanel(w, r, data) +} + +func (h *Handlers) saveUploadedFiles(r *http.Request, destDir string, headers []*multipart.FileHeader) ([]string, error) { + saved := make([]string, 0, len(headers)) + for _, fh := range headers { + name, err := h.saveUploadedFile(r, destDir, fh) + if err != nil { + return saved, err + } + saved = append(saved, name) + } + return saved, nil +} + +func (h *Handlers) saveUploadedFile(r *http.Request, destDir string, fh *multipart.FileHeader) (string, error) { + rel, err := uploadDestPath(destDir, fh.Filename) + if err != nil { + return "", err + } + return h.writeUploadedFile(r, rel, fh) +} + +// uploadDestPath replaces the browser-supplied filename with just its +// base name (stripping any directory component, which browsers +// sometimes still send for webkitdirectory-style selections) and +// joins it onto destDir. Split out of saveUploadedFile so +// attachments.go's saveChatAttachment can compute a destination, +// de-duplicate it against what's already in the workspace, and only +// then write — see uniqueWorkspacePath. +func uploadDestPath(destDir, filename string) (string, error) { + base := filepath.Base(filepath.ToSlash(filename)) + if base == "" || base == "." || base == "/" { + return "", fmt.Errorf("a file has no usable name") + } + if destDir == "" { + return base, nil + } + return path.Join(destDir, base), nil +} + +// writeUploadedFile replaces the second half of the old +// saveUploadedFile: given an already-computed destination path, reads +// and size-checks the multipart part and writes it into the workspace. +func (h *Handlers) writeUploadedFile(r *http.Request, rel string, fh *multipart.FileHeader) (string, error) { + if fh.Size > maxUploadFileBytes { + return "", fmt.Errorf("%q is %s, larger than the %s-per-file limit", fh.Filename, humanBytes(fh.Size), humanBytes(maxUploadFileBytes)) + } + + src, err := fh.Open() + if err != nil { + return "", fmt.Errorf("%q: %w", fh.Filename, err) + } + defer src.Close() + + content, err := io.ReadAll(io.LimitReader(src, maxUploadFileBytes+1)) + if err != nil { + return "", fmt.Errorf("%q: read: %w", rel, err) + } + if len(content) > maxUploadFileBytes { + return "", fmt.Errorf("%q exceeds the %s-per-file limit", rel, humanBytes(maxUploadFileBytes)) + } + + if _, err := h.Workspace.UploadFile(r.Context(), &workspacev1.UploadFileRequest{Path: rel, Content: content}); err != nil { + return "", fmt.Errorf("%q: %w", rel, err) + } + return filepath.ToSlash(rel), nil +} + +func (h *Handlers) renderFilesPanel(w http.ResponseWriter, r *http.Request, data filesPanelData) { + files, err := h.listWorkspaceFilesCtx(r) + if err != nil { + h.Logger.Warn("list workspace files", "err", err) + } + data.WorkspaceFiles = files + data.CSRFToken = h.csrfToken(w, r) + h.render(w, http.StatusOK, "workspace_files_panel", data) +} + +func (h *Handlers) listWorkspaceFilesCtx(r *http.Request) ([]workspaceFileView, error) { + resp, err := h.Workspace.Tree(r.Context(), &workspacev1.TreeRequest{OneLevel: false}) + if err != nil { + return nil, err + } + out := make([]workspaceFileView, 0, len(resp.GetNodes())) + for _, n := range resp.GetNodes() { + out = append(out, workspaceFileView{ + Path: n.GetRelPath(), DownloadURL: downloadURLFor(n.GetRelPath()), + Size: n.GetSize(), SizeHuman: humanBytes(n.GetSize()), ModTime: n.GetModifiedAt().AsTime(), + }) + } + return out, nil +} + +// --------------------------------------------------------------------- +// GET /files/download/{path...} +// --------------------------------------------------------------------- + +func (h *Handlers) DownloadFile(w http.ResponseWriter, r *http.Request) { + rel := r.PathValue("path") + if strings.TrimSpace(rel) == "" { + http.NotFound(w, r) + return + } + resp, err := h.Workspace.DownloadFile(r.Context(), &workspacev1.DownloadFileRequest{Path: rel}) + if err != nil { + http.NotFound(w, r) + return + } + name := resp.GetFilename() + if name == "" { + name = filepath.Base(rel) + } + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + w.Header().Set("X-Content-Type-Options", "nosniff") + // Content-Type is set explicitly — left unset, Go's server auto- + // sniffs it from the first 512 bytes the moment we Write() (see + // net/http's chunkWriter.writeHeader), independent of the + // X-Content-Type-Options header above (that only tells the + // browser not to second-guess whatever type the server already + // sent — it doesn't stop the server's own sniffing from running + // in the first place). A .docx/.xlsx/.pptx is, at the byte level, + // a zip archive (PK\x03\x04), so unsniffed it comes back as + // "application/zip" — technically not wrong, but not what Office + // expects either, and actively misleading for anything that + // pays attention to the advertised type rather than just the + // filename extension. + w.Header().Set("Content-Type", downloadContentType(name)) + w.Header().Set("Content-Length", strconv.Itoa(len(resp.GetContent()))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(resp.GetContent()) +} + +// --------------------------------------------------------------------- +// CSRF for multipart uploads — replaces Handlers.RequireCSRFUpload. +// --------------------------------------------------------------------- + +func (h *Handlers) RequireCSRFUpload(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestBytes) + if err := r.ParseMultipartForm(maxUploadMemory); err != nil { + http.Error(w, fmt.Sprintf("upload too large or malformed (limit %s total): %v", humanBytes(maxUploadRequestBytes), err), http.StatusBadRequest) + return + } + cookie, _ := r.Cookie(csrfCookieName) + var cookieVal string + if cookie != nil { + cookieVal = cookie.Value + } + submitted := r.FormValue(csrfFormField) + if submitted == "" { + submitted = r.Header.Get("X-CSRF-Token") + } + if !authsupport.VerifyCSRF(cookieVal, submitted) { + http.Error(w, "invalid or missing CSRF token — reload the page and try again", http.StatusForbidden) + return + } + next(w, r) + } +} diff --git a/services/gateway/internal/handlers/i18n.go b/services/gateway/internal/handlers/i18n.go new file mode 100644 index 0000000..30aac57 --- /dev/null +++ b/services/gateway/internal/handlers/i18n.go @@ -0,0 +1,70 @@ +// Package handlers (i18n.go) is the gateway's half of multilingual +// support: resolving which locale a given request should render in, +// and the /language switcher that lets a visitor override it. The +// catalog itself — supported locales, their strings, the T() lookup — +// lives in pkg/i18n so the agent service can share the same locale +// codes and display names (see agentrun.WithPreferredLanguage) +// without the two services depending on each other. +// +// This mirrors ui.go's theme toggle almost exactly on purpose: same +// cookie-based persistence, same safeNext redirect, same form-POST +// shape — a visitor who already knows how the theme switcher works +// recognizes the language switcher instantly, and there's only one +// pattern for a future settings toggle to copy. +package handlers + +import ( + "net/http" + "time" + + "enkly/pkg/i18n" +) + +const langCookieName = "enkly_lang" + +// localeFrom resolves the locale a request should render in: +// 1. an explicit ?lang= query override (lets a shared/bookmarked link +// force a language, and is how SetLanguage's own redirect target +// can take effect without a second round trip), +// 2. the enkly_lang cookie, set the first time a visitor picks one +// (explicitly here, or implicitly — see SetLanguage), +// 3. the browser's Accept-Language header, for a first-time visitor +// who hasn't chosen anything yet, +// 4. i18n.Default ("en") if none of the above yield a supported +// locale. +// +// There is deliberately no account-level preference here: enkly has +// no per-user settings table in any service's schema today, and +// adding `preferred_language` to the IAM User message/row is a schema +// + generated-gRPC-code change of its own — see ARCHITECTURE.md's +// "Language preference storage" note for the follow-up. The cookie +// already survives logins, browser restarts and (for a year) long +// gaps between visits; it just doesn't follow a visitor to a second +// device the way an account field would. +func localeFrom(r *http.Request) i18n.Locale { + if q := r.URL.Query().Get("lang"); q != "" && i18n.IsSupported(q) { + return i18n.Locale(q) + } + if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) { + return i18n.Locale(c.Value) + } + return i18n.NegotiateAcceptLanguage(r.Header.Get("Accept-Language")) +} + +// SetLanguage (POST /language) replaces Handlers.SetTheme's role for +// language: persists the chosen locale in a year-long cookie and +// sends the browser back to wherever it came from. Rejects an +// unsupported code rather than silently falling back, the same way +// SetTheme rejects an unknown theme value — a bad value here is +// either a stale client (an old switcher built against a locale list +// that has since changed) or a tampered request, and either way the +// right response is a 400, not a cookie nobody asked for. +func (h *Handlers) SetLanguage(w http.ResponseWriter, r *http.Request) { + lang := r.PostFormValue("lang") + if !i18n.IsSupported(lang) { + http.Error(w, "unknown language", http.StatusBadRequest) + return + } + h.setCookie(w, langCookieName, lang, int((365 * 24 * time.Hour).Seconds())) + http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther) +} diff --git a/services/gateway/internal/handlers/middleware.go b/services/gateway/internal/handlers/middleware.go new file mode 100644 index 0000000..68bd345 --- /dev/null +++ b/services/gateway/internal/handlers/middleware.go @@ -0,0 +1,315 @@ +package handlers + +import ( + "compress/gzip" + "log/slog" + "net/http" + "runtime/debug" + "strings" + "sync" + "time" + + iamv1 "enkly/gen/iam/v1" + "enkly/pkg/authsupport" +) + +// Middleware wraps an http.Handler. +type Middleware func(http.Handler) http.Handler + +// Chain applies middlewares to h so that the first one listed is the +// outermost (runs first on the way in, last on the way out). +func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler { + for i := len(mws) - 1; i >= 0; i-- { + h = mws[i](h) + } + return h +} + +// --------------------------------------------------------------------- +// Response-writer wrappers (status capture, Flush/Unwrap passthrough) +// --------------------------------------------------------------------- + +type statusWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *statusWriter) WriteHeader(code int) { + if w.status == 0 { + w.status = code + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} + +// Unwrap lets http.NewResponseController reach the real writer's +// Flush / SetWriteDeadline — essential for SSE, which flushes after every event. +func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +// --------------------------------------------------------------------- +// Recover +// --------------------------------------------------------------------- + +// Recover turns a handler panic into a logged 500 instead of a dropped +// connection. +func Recover(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + if rec == http.ErrAbortHandler { + panic(rec) // net/http's own "abort this response" signal + } + logger.Error("panic in handler", + "method", r.Method, "path", r.URL.Path, "panic", rec, "stack", string(debug.Stack())) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) + } +} + +// --------------------------------------------------------------------- +// Request logging +// --------------------------------------------------------------------- + +// RequestLogger logs one structured line per request. Static assets and +// health checks are logged at debug level so they don't drown real traffic. +func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w} + next.ServeHTTP(sw, r) + + status := sw.status + if status == 0 { + status = http.StatusOK + } + level := slog.LevelInfo + if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/static/") { + level = slog.LevelDebug + } + logger.Log(r.Context(), level, "request", + "method", r.Method, + "path", r.URL.Path, + "status", status, + "bytes", sw.bytes, + "dur", time.Since(start).Round(time.Millisecond), + "ip", clientIP(r), + ) + }) + } +} + +// --------------------------------------------------------------------- +// Security headers +// --------------------------------------------------------------------- + +// contentSecurityPolicy: everything same-origin. style-src allows inline +// styles (a couple of templates use style="…" and htmx may inject one); +// script-src does NOT allow inline or eval — the front end is vendored +// htmx + one same-origin script file, and index.html configures htmx with +// allowEval:false so nothing in it needs 'unsafe-eval'. +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'none'" + +// SecurityHeaders adds baseline browser hardening headers to every response. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hd := w.Header() + hd.Set("Content-Security-Policy", contentSecurityPolicy) + hd.Set("X-Content-Type-Options", "nosniff") + hd.Set("X-Frame-Options", "DENY") + hd.Set("Referrer-Policy", "same-origin") + hd.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + hd.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +// --------------------------------------------------------------------- +// Gzip +// --------------------------------------------------------------------- + +var gzipPool = sync.Pool{New: func() any { return gzip.NewWriter(nil) }} + +// gzipWriter compresses text-like responses. The decision is made +// lazily on the first write, when the Content-Type is known. +type gzipWriter struct { + http.ResponseWriter + gz *gzip.Writer + decided bool + wroteHeader bool + status int +} + +func compressible(ct string) bool { + ct = strings.ToLower(ct) + for _, p := range []string{ + "text/", "application/json", "application/javascript", "application/xml", "image/svg+xml", + } { + if strings.HasPrefix(ct, p) { + return true + } + } + return false +} + +func (w *gzipWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.status = code + w.wroteHeader = true + // Decide now, so Content-Length can be dropped before the header goes out. + hd := w.Header() + if !w.decided { + w.decided = true + bodyless := code == http.StatusNoContent || code == http.StatusNotModified || code < 200 + if !bodyless && hd.Get("Content-Encoding") == "" && compressible(hd.Get("Content-Type")) { + hd.Del("Content-Length") + hd.Set("Content-Encoding", "gzip") + w.gz = gzipPool.Get().(*gzip.Writer) + w.gz.Reset(w.ResponseWriter) + } + } + hd.Add("Vary", "Accept-Encoding") + w.ResponseWriter.WriteHeader(code) +} + +func (w *gzipWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", http.DetectContentType(b)) + } + w.WriteHeader(http.StatusOK) + } + if w.gz != nil { + return w.gz.Write(b) + } + return w.ResponseWriter.Write(b) +} + +func (w *gzipWriter) Flush() { + if w.gz != nil { + _ = w.gz.Flush() + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (w *gzipWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func (w *gzipWriter) close() { + if w.gz != nil { + _ = w.gz.Close() + gzipPool.Put(w.gz) + w.gz = nil + } +} + +// Gzip compresses responses for clients that accept it. It never touches +// the SSE stream endpoint (compression buffering would delay events), the +// file-download endpoint (arbitrary — often already-compressed — file +// bytes streamed via http.ServeContent, which also needs to serve +// uncompressed byte ranges), and skips Range requests generally (static +// file partial content). +func Gzip(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || + strings.HasPrefix(r.URL.Path, "/chat/stream/") || + strings.HasPrefix(r.URL.Path, "/files/download/") || + strings.Contains(r.Header.Get("Accept"), "text/event-stream") || + r.Header.Get("Range") != "" { + next.ServeHTTP(w, r) + return + } + gw := &gzipWriter{ResponseWriter: w} + defer gw.close() + next.ServeHTTP(gw, r) + }) +} + +// --------------------------------------------------------------------- +// Auth & CSRF guards (handler-level, used per route in main.go) +// --------------------------------------------------------------------- + +// RequireAuth replaces Handlers.RequireAuth: resolves the session +// cookie to a user via one IAM.ValidateSession gRPC call and puts it on +// the request context; otherwise it sends the browser to /login (htmx +// requests get an HX-Redirect so the whole page navigates, rather than +// the login form being swapped into a chat fragment). This is now the +// one RPC on the hot path of nearly every request — see +// ARCHITECTURE.md's "Session validation latency" note. +func (h *Handlers) RequireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if raw := sessionToken(r); raw != "" { + resp, err := h.IAM.ValidateSession(r.Context(), &iamv1.ValidateSessionRequest{SessionToken: raw}) + switch { + case err == nil && resp.GetValid() && resp.GetUser().GetEmailVerified(): + next(w, r.WithContext(withUser(r.Context(), resp.GetUser()))) + return + case err != nil: + h.Logger.Error("validate session", "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } + if isHTMX(r) { + w.Header().Set("HX-Redirect", "/login") + w.WriteHeader(http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} + +// RequireCSRF rejects a state-changing request unless its csrf_token +// form field (or X-CSRF-Token header) matches the csrf cookie. Purely +// an edge concern — unchanged from the monolith. +func (h *Handlers) RequireCSRF(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := parseForm(w, r); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + cookie, _ := r.Cookie(csrfCookieName) + var cookieVal string + if cookie != nil { + cookieVal = cookie.Value + } + submitted := r.PostFormValue(csrfFormField) + if submitted == "" { + submitted = r.Header.Get("X-CSRF-Token") + } + if !authsupport.VerifyCSRF(cookieVal, submitted) { + http.Error(w, "invalid or missing CSRF token — reload the page and try again", http.StatusForbidden) + return + } + next(w, r) + } +} + +// RequireCSRFUpload lives in files.go (it needs the upload size +// constants defined there). diff --git a/services/gateway/internal/handlers/pages.go b/services/gateway/internal/handlers/pages.go new file mode 100644 index 0000000..121108c --- /dev/null +++ b/services/gateway/internal/handlers/pages.go @@ -0,0 +1,324 @@ +// Package handlers (pages.go) replaces internal/handlers/pages.go: the +// main chat page and conversation management. Every h.DB call becomes +// one gRPC call to the conversation service (plus billing for the plan +// badge, knowledge for the indexed-files list, and workspace for the +// files panel). +package handlers + +import ( + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + billingv1 "enkly/gen/billing/v1" + conversationv1 "enkly/gen/conversation/v1" + knowledgev1 "enkly/gen/knowledge/v1" + "enkly/pkg/renderkit" +) + +// StreamView is what the "assistant_stream" template needs to attach a +// live assistant bubble to an in-flight run. +type StreamView struct { + StreamID string + CSRFToken string +} + +// messageView is a conversation message plus its rendered HTML — the +// gateway's local equivalent of the monolith's models.Message (which +// carried a RenderedHTML field filled in by prepareMessages). +type messageView struct { + Role string + ToolName string + Content string // raw text, kept alongside RenderedHTML for deliverables.go's producedPath + RenderedHTML template.HTML +} + +// conversationView is the gateway's local equivalent of +// models.Conversation, just the fields index.html actually uses. +type conversationView struct { + ID int64 + Title string +} + +// indexData is the template data for index.html — identical field set +// to the monolith's indexData. +type indexData struct { + ConversationTitle string + ConversationID int64 + Conversations []conversationView + Messages []messageView + Files []string + CSRFToken string + UserEmail string + Theme string + Lang string + PlanName string + ActiveStream *StreamView + + WorkspaceFiles []workspaceFileView + UploadMessage string + UploadError string +} + +// Index (GET /) opens the user's most recently active conversation, +// creating the first one if they have none. +func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + ctx := r.Context() + conv, err := h.Conversation.MostRecentConversation(ctx, &conversationv1.MostRecentConversationRequest{UserId: user.GetId()}) + if isNotFound(err) { + conv, err = h.Conversation.CreateConversation(ctx, &conversationv1.CreateConversationRequest{UserId: user.GetId()}) + } + if err != nil { + h.serverError(w, "open conversation", err) + return + } + h.renderConversation(w, r, conv) +} + +// SwitchConversation (GET /c/{id}) shows a specific conversation. +func (h *Handlers) SwitchConversation(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + conv, err := h.Conversation.GetConversation(r.Context(), &conversationv1.GetConversationRequest{UserId: user.GetId(), ConversationId: id}) + if isNotFound(err) { + http.NotFound(w, r) + return + } + if err != nil { + h.serverError(w, "get conversation", err) + return + } + h.renderConversation(w, r, conv) +} + +func isNotFound(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + return ok && st.Code() == codes.NotFound +} + +func (h *Handlers) renderConversation(w http.ResponseWriter, r *http.Request, conv *conversationv1.Conversation) { + ctx := r.Context() + user := userFrom(r) + + convsResp, err := h.Conversation.ListConversations(ctx, &conversationv1.ListConversationsRequest{UserId: user.GetId()}) + if err != nil { + h.serverError(w, "list conversations", err) + return + } + msgsResp, err := h.Conversation.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: conv.GetId()}) + if err != nil { + h.serverError(w, "list messages", err) + return + } + var files []string + if resp, err := h.Knowledge.ListIndexedFiles(ctx, &knowledgev1.ListIndexedFilesRequest{Limit: 200}); err != nil { + h.Logger.Warn("list indexed files", "err", err) + } else { + files = resp.GetFilePaths() + } + workspaceFiles, err := h.listWorkspaceFilesCtx(r) + if err != nil { + h.Logger.Warn("list workspace files", "err", err) + } + + planName := "Free" + if sub, err := h.Billing.GetOrCreateSubscription(ctx, &billingv1.GetOrCreateSubscriptionRequest{UserId: user.GetId()}); err != nil { + h.Logger.Warn("load subscription", "err", err) + } else { + planName = planDisplayName(sub.GetPlan()) + } + + convs := make([]conversationView, 0, len(convsResp.GetConversations())) + for _, c := range convsResp.GetConversations() { + convs = append(convs, conversationView{ID: c.GetId(), Title: c.GetTitle()}) + } + + csrf := h.csrfToken(w, r) + data := indexData{ + ConversationTitle: conv.GetTitle(), + ConversationID: conv.GetId(), + Conversations: convs, + Messages: h.withDeliverables(ctx, h.prepareMessages(msgsResp.GetMessages())), + Files: files, + CSRFToken: csrf, + UserEmail: user.GetEmail(), + Theme: themeFrom(r), + Lang: string(localeFrom(r)), + PlanName: planName, + WorkspaceFiles: workspaceFiles, + } + // NOTE: reattaching to a still-in-flight run after a page reload + // (the monolith's h.Streams.activeFor lookup) is intentionally not + // wired here — it would need a new "is there an active stream for + // this conversation" agent RPC with no use case elsewhere in the + // API. The reply still completes and is persisted normally; a + // reload mid-reply just shows it on the next load instead of + // resuming the live bubble. See ARCHITECTURE.md's known gaps. + h.render(w, http.StatusOK, "index", data) +} + +// NewConversation (POST /conversations/new). +func (h *Handlers) NewConversation(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + ctx := r.Context() + if latest, err := h.Conversation.MostRecentConversation(ctx, &conversationv1.MostRecentConversationRequest{UserId: user.GetId()}); err == nil { + if n, err := h.Conversation.CountMessages(ctx, &conversationv1.CountMessagesRequest{ConversationId: latest.GetId()}); err == nil && n.GetCount() == 0 { + http.Redirect(w, r, fmt.Sprintf("/c/%d", latest.GetId()), http.StatusSeeOther) + return + } + } + conv, err := h.Conversation.CreateConversation(ctx, &conversationv1.CreateConversationRequest{UserId: user.GetId()}) + if err != nil { + h.serverError(w, "create conversation", err) + return + } + http.Redirect(w, r, fmt.Sprintf("/c/%d", conv.GetId()), http.StatusSeeOther) +} + +// RenameConversation (POST /conversations/{id}/rename). +func (h *Handlers) RenameConversation(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + title := cleanTitle(r.PostFormValue("title"), maxTitleChars) + if title == "" { + http.Redirect(w, r, fmt.Sprintf("/c/%d", id), http.StatusSeeOther) + return + } + _, err = h.Conversation.RenameConversation(r.Context(), &conversationv1.RenameConversationRequest{UserId: user.GetId(), ConversationId: id, Title: title}) + switch { + case err == nil: + http.Redirect(w, r, fmt.Sprintf("/c/%d", id), http.StatusSeeOther) + case isNotFound(err): + http.NotFound(w, r) + default: + h.serverError(w, "rename conversation", err) + } +} + +// DeleteConversationHandler (POST /conversations/{id}/delete). +func (h *Handlers) DeleteConversationHandler(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + _, err = h.Conversation.DeleteConversation(r.Context(), &conversationv1.DeleteConversationRequest{UserId: user.GetId(), ConversationId: id}) + switch { + case err == nil: + // NOTE: the monolith also cancelled any in-flight run on this + // conversation here (h.Streams.cancelConversation), so a + // stream still writing wouldn't hit AddMessage failures on a + // deleted row. Agent's API has no "stop by conversation" RPC + // (only by stream_id — see agent.proto), so that cleanup is + // not wired here; a straggling run's writes will simply fail + // and be logged by the agent service instead of being + // pre-empted. See ARCHITECTURE.md's known gaps. + http.Redirect(w, r, "/", http.StatusSeeOther) + case isNotFound(err): + http.NotFound(w, r) + default: + h.serverError(w, "delete conversation", err) + } +} + +func (h *Handlers) serverError(w http.ResponseWriter, what string, err error) { + h.Logger.Error(what, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) +} + +// --------------------------------------------------------------------- +// View helpers +// --------------------------------------------------------------------- + +const maxToolBodyChars = 12_000 + +// prepareMessages fills RenderedHTML for template rendering: Markdown +// for assistant replies, escaped text (plus any attachment chips — +// see attachments.go's renderUserMessage) for user prompts, and an +// escaped
 for tool output. Identical logic to the monolith's,
+// now building local messageView values instead of mutating
+// models.Message.
+func (h *Handlers) prepareMessages(msgs []*conversationv1.Message) []messageView {
+	out := make([]messageView, len(msgs))
+	for i, m := range msgs {
+		mv := messageView{Role: roleString(m.GetRole()), ToolName: m.GetToolName(), Content: m.GetContent()}
+		switch m.GetRole() {
+		case conversationv1.Role_ROLE_ASSISTANT:
+			mv.RenderedHTML = template.HTML(renderkit.Markdown(m.GetContent())) // #nosec G203 -- sanitized by renderkit
+		case conversationv1.Role_ROLE_TOOL:
+			mv.RenderedHTML = toolBodyHTML(m.GetContent())
+		default:
+			mv.RenderedHTML = h.renderUserMessage(m.GetContent())
+		}
+		out[i] = mv
+	}
+	return out
+}
+
+func roleString(r conversationv1.Role) string {
+	switch r {
+	case conversationv1.Role_ROLE_USER:
+		return "user"
+	case conversationv1.Role_ROLE_ASSISTANT:
+		return "assistant"
+	case conversationv1.Role_ROLE_TOOL:
+		return "tool"
+	default:
+		return "system"
+	}
+}
+
+func toolBodyHTML(content string) template.HTML {
+	if strings.TrimSpace(content) == "" {
+		return template.HTML(`
(no output)
`) + } + if len(content) > maxToolBodyChars { + cut := maxToolBodyChars + for cut > 0 && !utf8.RuneStart(content[cut]) { + cut-- + } + content = content[:cut] + fmt.Sprintf("\n… [%d more characters not shown]", len(content)-cut) + } + return template.HTML(`
` + template.HTMLEscapeString(content) + `
`) // #nosec G203 -- escaped +} + +func cleanTitle(s string, max int) string { + s = strings.Join(strings.Fields(s), " ") + if utf8.RuneCountInString(s) <= max { + return s + } + runes := []rune(s) + return strings.TrimSpace(string(runes[:max-1])) + "…" +} + +func planDisplayName(p billingv1.Plan) string { + switch p { + case billingv1.Plan_PLAN_BASIC: + return "Basic" + case billingv1.Plan_PLAN_PRO: + return "Pro" + case billingv1.Plan_PLAN_PREMIUM: + return "Premium" + default: + return "Free" + } +} diff --git a/services/gateway/internal/handlers/ui.go b/services/gateway/internal/handlers/ui.go new file mode 100644 index 0000000..8b34e91 --- /dev/null +++ b/services/gateway/internal/handlers/ui.go @@ -0,0 +1,187 @@ +// Package handlers (ui.go) replaces internal/handlers/ui.go: the theme +// toggle, the lazy-expanding file explorer tree, and the status bar. +// Where the original read the local filesystem directly, this calls +// workspace's Tree RPC (one_level=true for a single folder's children, +// matching the original's per-click round trip exactly). +package handlers + +import ( + "context" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + conversationv1 "enkly/gen/conversation/v1" + workspacev1 "enkly/gen/workspace/v1" +) + +const themeCookieName = "enkly_theme" + +func themeFrom(r *http.Request) string { + c, err := r.Cookie(themeCookieName) + if err != nil { + return "" + } + if c.Value == "light" || c.Value == "dark" { + return c.Value + } + return "" +} + +func safeNext(next string) string { + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") { + return "/" + } + return next +} + +func (h *Handlers) SetTheme(w http.ResponseWriter, r *http.Request) { + switch r.PostFormValue("theme") { + case "light": + h.setCookie(w, themeCookieName, "light", int((365 * 24 * time.Hour).Seconds())) + case "dark": + h.setCookie(w, themeCookieName, "dark", int((365 * 24 * time.Hour).Seconds())) + case "auto": + h.setCookie(w, themeCookieName, "", -1) + default: + http.Error(w, "unknown theme", http.StatusBadRequest) + return + } + http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther) +} + +// --------------------------------------------------------------------- +// File explorer (lazy-expanding tree) — replaces Handlers.FileTree. +// --------------------------------------------------------------------- + +type treeNode struct { + Name string + Path string + IsDir bool + Open bool + Children []treeNode + ExpandURL string + CollapseURL string + DownloadURL string + SizeHuman string +} + +func treeURL(rel string, open bool) string { + o := "1" + if !open { + o = "0" + } + return "/tree?path=" + url.QueryEscape(rel) + "&open=" + o +} + +// downloadURLFor replaces handlers.downloadURLFor: percent-encodes each +// path segment individually (so a filename with spaces, '#', '?', or +// non-ASCII characters still produces a valid, correctly round-tripping +// link) while leaving the '/' separators between segments alone. +func downloadURLFor(rel string) string { + segments := strings.Split(rel, "/") + for i, s := range segments { + segments[i] = url.PathEscape(s) + } + return "/files/download/" + strings.Join(segments, "/") +} + +// humanBytes replaces handlers.humanBytes. +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return strconv.FormatInt(n, 10) + " B" + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + "KMGTPE"[exp:exp+1] + "iB" +} + +func (h *Handlers) FileTree(w http.ResponseWriter, r *http.Request) { + rel := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/") + + resp, err := h.Workspace.Tree(r.Context(), &workspacev1.TreeRequest{Path: rel, OneLevel: true}) + if err != nil { + http.NotFound(w, r) + return + } + + kids := make([]treeNode, 0, len(resp.GetNodes())) + for _, n := range resp.GetNodes() { + tn := treeNode{Name: n.GetName(), Path: n.GetRelPath(), IsDir: n.GetIsDir()} + if n.GetIsDir() { + tn.ExpandURL = treeURL(n.GetRelPath(), true) + } else { + tn.DownloadURL = downloadURLFor(n.GetRelPath()) + tn.SizeHuman = humanBytes(n.GetSize()) + } + kids = append(kids, tn) + } + + if rel == "" { + h.render(w, http.StatusOK, "tree_root", treeNode{Children: kids}) + return + } + node := treeNode{ + Name: path.Base(rel), Path: rel, IsDir: true, + Open: r.URL.Query().Get("open") != "0", + ExpandURL: treeURL(rel, true), + CollapseURL: treeURL(rel, false), + } + if node.Open { + node.Children = kids + } + h.render(w, http.StatusOK, "tree_dir", node) +} + +// --------------------------------------------------------------------- +// Status bar — replaces Handlers.StatusBar. +// --------------------------------------------------------------------- + +type statusData struct { + ConversationID int64 + Model string + PostgresOK bool + VLLMOK bool + ContextTokens int + ContextLimit int + ContextPct int +} + +func estimateTokens(chars int) int { return (chars + 3) / 4 } + +func (h *Handlers) StatusBar(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64) + //data := statusData{ConversationID: convID, Model: "qwen3.5"} + data := statusData{ConversationID: convID, Model: "Baobab 1"} + + // PostgresOK now stands in for "the conversation service (and + // hence its Postgres) is reachable" — the gateway itself no longer + // holds a DB handle to ping directly. + if _, err := h.Conversation.CountMessages(ctx, &conversationv1.CountMessagesRequest{ConversationId: convID}); err == nil { + data.PostgresOK = true + } + + if convID != 0 && user != nil { + if msgs, err := h.Conversation.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: convID}); err == nil { + total := 0 + for _, m := range msgs.GetMessages() { + total += len(m.GetContent()) + } + data.ContextTokens = estimateTokens(total) + } + } + data.VLLMOK = true // see ARCHITECTURE.md: the gateway has no direct vLLM reachability check post-split; agent/knowledge each probe their own chat/embed servers. + + h.render(w, http.StatusOK, "status_bar", data) +} diff --git a/services/gateway/web/static/css/style.css b/services/gateway/web/static/css/style.css new file mode 100644 index 0000000..0d55727 --- /dev/null +++ b/services/gateway/web/static/css/style.css @@ -0,0 +1,950 @@ +/* ===================================================================== + enkly — style.css (v2 design system) + Vanilla CSS, no build step, system fonts only (the gateway's CSP is + same-origin, so nothing here loads from a CDN). + + Direction: a calm ink-blue workspace with ONE luminous element — the + composer. Everything else is quiet glass: hairline borders, soft + surfaces, no decorative gradients. Colour carries meaning (tool kind, + status), never decoration. + + Contents + 1. Tokens (dark default, light override at the bottom of section 1) + 2. Base + ambient background + 3. Sidebar + 4. Top bar + command palette + 5. Chat window + empty state (hero, starters, tools strip) + 6. Messages, thinking, tool steps + 7. Composer (the glow ring) + attachments + 8. Mobile + 9+ Auth, billing, tree, status, syntax, deliverables, drop zone + ===================================================================== */ + +/* --------------------------------------------------------------------- + 1. Tokens + --------------------------------------------------------------------- */ +:root { + /* surfaces: ink blue, three steps */ + --bg: #070b14; + --bg-elev: #0c1220; + --bg-elev-2: #111a2e; + --bg-hover: #17223b; + --glass: rgba(14, 21, 38, 0.72); + --glass-strong: rgba(18, 27, 48, 0.88); + + --border: #1a2540; + --border-strong: #26355a; + --border-glass: rgba(120, 150, 255, 0.14); + + --text: #e9edf7; + --text-dim: #a3adc4; + --text-faint: #6b7794; + + /* brand: the logo's teal, plus a cool blue it fades into */ + --accent: #2dd4bf; + --accent-2: #5b8cff; + --accent-soft: rgba(45, 212, 191, 0.13); + --accent-ring: linear-gradient(120deg, #2dd4bf 0%, #5b8cff 55%, #8b7bff 100%); + --on-accent: #041218; + + --ok: #4ade80; + --warn: #fbbf24; + --danger: #f87171; + + /* tool kinds (used by step cards) */ + --k-read: #60a5fa; + --k-write: #fbbf24; + --k-search: #a78bfa; + --k-run: #4ade80; + --k-plan: #2dd4bf; + --k-agent: #f472b6; + + --radius: 12px; + --radius-lg: 18px; + --sidebar-w: 292px; + --shadow-glow: 0 0 0 1px rgba(91, 140, 255, 0.35), 0 12px 50px -12px rgba(61, 120, 255, 0.55), 0 0 90px -30px rgba(45, 212, 191, 0.5); + --shadow-card: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 10px 30px -18px rgba(0, 0, 0, 0.7); + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; + + /* syntax tokens (also referenced by section 9) */ + --tok-kw: #c4a7ff; + --tok-str: #86efac; + --tok-cmt: #6b7794; + --tok-num: #fcd34d; + --tok-fn: #7cb0ff; + --tok-type: #5eead4; + --tok-const: #fdba74; + --tok-var: #fde68a; + --tok-add: #4ade80; + --tok-del: #f87171; + --tok-hunk: #7cb0ff; + + /* tool glyphs: tiny inline SVGs used as CSS masks (data: is allowed by the CSP) */ + --i-file: url("data:image/svg+xml;utf8,"); + --i-edit: url("data:image/svg+xml;utf8,"); + --i-folder: url("data:image/svg+xml;utf8,"); + --i-search: url("data:image/svg+xml;utf8,"); + --i-term: url("data:image/svg+xml;utf8,"); + --i-todo: url("data:image/svg+xml;utf8,"); + --i-spark: url("data:image/svg+xml;utf8,"); + --i-cog: url("data:image/svg+xml;utf8,"); +} + +/* Light theme: same hierarchy, paper-white surfaces, deeper accents so + text stays above 4.5:1. */ +:root[data-theme="light"] { + --bg: #f5f7fc; + --bg-elev: #ffffff; + --bg-elev-2: #eef2fa; + --bg-hover: #e5ebf7; + --glass: rgba(255, 255, 255, 0.78); + --glass-strong: rgba(255, 255, 255, 0.92); + --border: #e0e6f1; + --border-strong: #c9d3e6; + --border-glass: rgba(40, 70, 160, 0.14); + --text: #0e1526; + --text-dim: #475169; + --text-faint: #76809a; + --accent: #0b7f78; + --accent-2: #2f62e0; + --accent-soft: rgba(11, 127, 120, 0.11); + --on-accent: #ffffff; + --ok: #1f8f4e; + --warn: #b45309; + --danger: #c93c3c; + --k-read: #2563eb; + --k-write: #b45309; + --k-search: #7c3aed; + --k-run: #15803d; + --k-plan: #0f766e; + --k-agent: #be185d; + --shadow-glow: 0 0 0 1px rgba(47, 98, 224, 0.35), 0 14px 40px -14px rgba(47, 98, 224, 0.35), 0 0 70px -34px rgba(11, 127, 120, 0.45); + --shadow-card: 0 1px 2px rgba(20, 30, 60, 0.05), 0 8px 24px -16px rgba(20, 30, 60, 0.25); + --tok-kw: #8a3fb0; --tok-str: #2f7d32; --tok-cmt: #76809a; --tok-num: #b06a00; + --tok-fn: #2456b8; --tok-type: #0d7a7a; --tok-const: #b4531f; --tok-var: #9a6700; + --tok-add: #1f7a3f; --tok-del: #b02a2a; --tok-hunk: #2456b8; +} +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --bg: #f5f7fc; --bg-elev: #ffffff; --bg-elev-2: #eef2fa; --bg-hover: #e5ebf7; + --glass: rgba(255, 255, 255, 0.78); --glass-strong: rgba(255, 255, 255, 0.92); + --border: #e0e6f1; --border-strong: #c9d3e6; --border-glass: rgba(40, 70, 160, 0.14); + --text: #0e1526; --text-dim: #475169; --text-faint: #76809a; + --accent: #0b7f78; --accent-2: #2f62e0; --accent-soft: rgba(11, 127, 120, 0.11); --on-accent: #ffffff; + --ok: #1f8f4e; --warn: #b45309; --danger: #c93c3c; + --k-read: #2563eb; --k-write: #b45309; --k-search: #7c3aed; --k-run: #15803d; --k-plan: #0f766e; --k-agent: #be185d; + --shadow-glow: 0 0 0 1px rgba(47, 98, 224, 0.35), 0 14px 40px -14px rgba(47, 98, 224, 0.35), 0 0 70px -34px rgba(11, 127, 120, 0.45); + --shadow-card: 0 1px 2px rgba(20, 30, 60, 0.05), 0 8px 24px -16px rgba(20, 30, 60, 0.25); + --tok-kw: #8a3fb0; --tok-str: #2f7d32; --tok-cmt: #76809a; --tok-num: #b06a00; + --tok-fn: #2456b8; --tok-type: #0d7a7a; --tok-const: #b4531f; --tok-var: #9a6700; + --tok-add: #1f7a3f; --tok-del: #b02a2a; --tok-hunk: #2456b8; + } +} + +/* --------------------------------------------------------------------- + 2. Base + ambient background + --------------------------------------------------------------------- */ +*, *::before, *::after { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } + +body { + background: var(--bg); + color: var(--text); + font: 15px/1.6 var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +/* Ambient light: two static radial washes behind everything. Static on + purpose — no animation, no GPU cost, nothing competing with the composer. */ +body::before { + content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; + background: + radial-gradient(900px 520px at 62% -8%, rgba(70, 120, 255, 0.16), transparent 70%), + radial-gradient(700px 420px at 18% 108%, rgba(45, 212, 191, 0.09), transparent 70%); +} +:root[data-theme="light"] body::before { + background: + radial-gradient(900px 520px at 62% -8%, rgba(70, 120, 255, 0.09), transparent 70%), + radial-gradient(700px 420px at 18% 108%, rgba(45, 212, 191, 0.07), transparent 70%); +} +@media (prefers-color-scheme: light) { + :root:not([data-theme]) body::before { + background: + radial-gradient(900px 520px at 62% -8%, rgba(70, 120, 255, 0.09), transparent 70%), + radial-gradient(700px 420px at 18% 108%, rgba(45, 212, 191, 0.07), transparent 70%); + } +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +button, input, textarea { font: inherit; color: inherit; } +::selection { background: var(--accent-soft); } +:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; } +kbd { + font: 600 10.5px/1 var(--font-mono); padding: 3px 5px; border-radius: 5px; + background: var(--bg-elev-2); border: 1px solid var(--border-strong); border-bottom-width: 2px; color: var(--text-dim); +} + +/* Shared tool-glyph mask. Any element with .glyph gets a currentColor icon + chosen by a --glyph variable. */ +.glyph, .tool-icon::before, .thinking-icon::before { + content: ""; display: block; width: 14px; height: 14px; background: currentColor; + -webkit-mask: var(--glyph, var(--i-cog)) center / contain no-repeat; + mask: var(--glyph, var(--i-cog)) center / contain no-repeat; +} + +/* --------------------------------------------------------------------- + 3. Sidebar + --------------------------------------------------------------------- */ +.app-shell { display: flex; height: 100vh; height: 100dvh; overflow: hidden; } + +.sidebar { + width: var(--sidebar-w); flex: 0 0 var(--sidebar-w); + display: flex; flex-direction: column; min-height: 0; + background: var(--glass); border-right: 1px solid var(--border-glass); + -webkit-backdrop-filter: blur(18px) saturate(1.3); backdrop-filter: blur(18px) saturate(1.3); +} +.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 6px; } +.brand-lockup { display: flex; align-items: center; justify-content: center; gap: 10px; } +.brand-lockup h1 { margin: 0; font: 650 18px/1 var(--font-sans); letter-spacing: -0.02em; } +.logo-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } +.sidebar-header .brand-lockup img { width: 48px; height: 48px; border-radius: 12px; } +:root:not([data-theme="light"]) .sidebar-header .brand-lockup img { padding: 3px; } +@media (prefers-color-scheme: light) { :root:not([data-theme]) .sidebar-header .brand-lockup img { background: none; padding: 0; } } +.sidebar-close, .sidebar-open, .sidebar-backdrop { display: none; } + +.new-chat-form { padding: 8px 14px 10px; } +.new-chat-btn { + width: 100%; display: flex; align-items: center; gap: 8px; justify-content: flex-start; + padding: 10px 12px; border-radius: var(--radius); cursor: pointer; font-weight: 550; + background: var(--bg-elev-2); border: 1px solid var(--border-strong); + transition: background 0.15s, border-color 0.15s; +} +.new-chat-btn:hover { background: var(--bg-hover); border-color: var(--accent-2); } +.plus-icon { + display: grid; place-items: center; width: 20px; height: 20px; border-radius: 7px; + background: var(--accent); color: var(--on-accent); font-weight: 700; line-height: 1; +} + +.sidebar-section { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px; } +.conversation-section { flex: 1 1 40%; } +.files-section { flex: 0 1 22%; border-top: 1px solid var(--border); padding-top: 8px; } +.workspace-section { flex: 0 1 28%; border-top: 1px solid var(--border); padding-top: 8px; } +.sidebar-section-title { + display: flex; justify-content: space-between; align-items: center; padding: 6px 8px 4px; + font: 600 12px/1.4 var(--font-sans); color: var(--text-faint); letter-spacing: 0.01em; +} +.conversation-list, .file-list { overflow-y: auto; min-height: 0; padding-bottom: 4px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } + +/* Conversations: active row gets a left rail, not a filled slab */ +.conversation-item { position: relative; display: flex; align-items: center; border-radius: 9px; } +.conversation-item:hover, .conversation-item:focus-within { background: var(--bg-hover); } +.conversation-item.active { background: var(--accent-soft); } +.conversation-item.active::before { + content: ""; position: absolute; left: 0; top: 9px; bottom: 9px; width: 3px; border-radius: 3px; background: var(--accent); +} +.conversation-item.active .conversation-link { color: var(--text); font-weight: 550; } +.conversation-link { + flex: 1; min-width: 0; padding: 8px 12px; color: var(--text-dim); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.conversation-link:hover { text-decoration: none; color: var(--text); } +.conversation-actions { display: none; align-items: center; gap: 2px; padding-right: 4px; } +.conversation-item:hover .conversation-actions, .conversation-item:focus-within .conversation-actions { display: flex; } +.rename-form, .delete-form { display: flex; align-items: center; margin: 0; } +.rename-input { width: 0; padding: 0; border: 0; background: transparent; opacity: 0; pointer-events: none; } +.rename-form:focus-within .rename-input, .conversation-item:hover .rename-input { + width: 96px; padding: 3px 6px; border: 1px solid var(--border-strong); border-radius: 6px; + background: var(--bg); opacity: 1; pointer-events: auto; font-size: 12px; +} +.icon-btn { + width: 24px; height: 24px; display: grid; place-items: center; padding: 0; + background: transparent; border: 0; border-radius: 6px; color: var(--text-faint); cursor: pointer; +} +.icon-btn:hover { background: var(--bg-elev-2); color: var(--text); } +.icon-btn-danger:hover { color: var(--danger); } +.conversation-item-empty { padding: 8px 12px; color: var(--text-faint); font-size: 13px; } + +.file-item { display: flex; gap: 6px; padding: 2px 10px; font: 12px/1.6 var(--font-mono); color: var(--text-dim); overflow: hidden; } +.file-item span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.file-icon { color: var(--accent); } +.file-item-empty { color: var(--text-faint); white-space: normal; } + +/* Explorer: a glass card, like the "Ingredients you have" panel in the reference */ +.explorer-section { padding: 6px 10px 10px; } +.explorer-section .file-tree { + padding: 6px; border: 1px solid var(--border-glass); border-radius: var(--radius); + background: var(--glass); box-shadow: var(--shadow-card); +} + +/* Workspace files: upload form + download links (panel is currently commented out in the template) */ +.files-title-right { display: flex; align-items: center; gap: 4px; } +.files-title-right .icon-btn { width: 18px; height: 18px; color: var(--text-faint); } +.upload-form { display: flex; gap: 6px; padding: 6px 8px 8px; align-items: center; } +.upload-input { flex: 1; min-width: 0; font-size: 11px; color: var(--text-dim); } +.upload-input::file-selector-button { + font: 11px var(--font-mono); padding: 4px 8px; margin-right: 6px; border-radius: 6px; + border: 1px solid var(--border-strong); background: var(--bg-elev-2); color: var(--text-dim); cursor: pointer; +} +.upload-input::file-selector-button:hover { border-color: var(--accent); color: var(--text); } +.upload-btn { flex: 0 0 auto; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-strong); background: var(--bg-elev-2); color: var(--text-dim); cursor: pointer; font-size: 12px; } +.upload-btn:hover { border-color: var(--accent); color: var(--text); } +.upload-note { margin: 0 8px 6px; padding: 5px 8px; border-radius: 6px; font-size: 12px; } +.upload-note-ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); } +.upload-note-error { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); } +.workspace-file-item { justify-content: space-between; } +.file-download-link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); font: inherit; } +.file-download-link:hover { color: var(--accent); text-decoration: none; } +.file-size { flex: 0 0 auto; color: var(--text-faint); font-size: 11px; } + +/* Footer: one account row, one plan row, a compact theme switch */ +.sidebar-footer { padding: 12px 14px 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; } +.plan-chip { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px; + background: var(--bg-elev-2); border: 1px solid var(--border); color: var(--text-dim); + font-size: 12.5px; text-decoration: none; +} +.plan-chip:hover { border-color: var(--accent-2); color: var(--text); text-decoration: none; } +.plan-dot { flex: 0 0 7px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.plan-chip span:nth-child(2) { flex: 1; font-weight: 550; color: var(--text); } +.plan-chip-manage { color: var(--text-faint); font-size: 12px; } +.user-chip { display: flex; align-items: center; gap: 10px; min-width: 0; } +.user-avatar { + flex: 0 0 30px; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; + background: var(--accent-ring); color: var(--on-accent); font: 700 13px/1 var(--font-sans); text-transform: uppercase; +} +.status-dot { display: none; } +.user-email { flex: 1; min-width: 0; font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.user-chip form { margin: 0; } +.logout-btn { background: transparent; border: 1px solid var(--border-strong); border-radius: 8px; padding: 4px 10px; font-size: 12px; color: var(--text-dim); cursor: pointer; } +.logout-btn:hover { color: var(--text); border-color: var(--accent-2); } +.model-tag { display: none; } + +.theme-toggle { display: flex; gap: 2px; margin: 0; padding: 3px; background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 10px; } +.theme-btn { flex: 1; padding: 5px 6px; font-size: 12px; cursor: pointer; background: transparent; border: 0; border-radius: 7px; color: var(--text-faint); } +.theme-btn:hover { color: var(--text); } +.theme-btn.is-active { background: var(--bg-hover); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; } + +/* Language switcher — same chrome as .theme-toggle (a select instead of + buttons, since there are too many locales for a button row), so it + reads as a sibling control rather than a one-off widget. Used in the + sidebar footer (index.html) and on every auth.html card. */ +.lang-switcher { display: flex; gap: 4px; align-items: stretch; margin: 0; padding: 3px; background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 10px; } +.lang-select { flex: 1; min-width: 0; padding: 5px 6px; font-size: 12px; background: transparent; border: 0; border-radius: 7px; color: var(--text); } +.lang-select:hover, .lang-select:focus { background: var(--bg-hover); } +.lang-switcher-submit { padding: 5px 9px; font-size: 12px; cursor: pointer; background: transparent; border: 0; border-radius: 7px; color: var(--text-faint); } +.lang-switcher-submit:hover { color: var(--text); background: var(--bg-hover); } +.auth-card .lang-switcher { max-width: 220px; margin: 0 auto 14px; } + +/* --------------------------------------------------------------------- + 4. Top bar + command palette + --------------------------------------------------------------------- */ +.main-col { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.chat-topbar { display: flex; align-items: center; gap: 12px; padding: 10px 20px; } +.chat-topbar h2 { flex: 1; margin: 0; font-size: 14.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); } + +/* The "Ask anything, find anything…" bar from the reference, repurposed as + a search-everything trigger. Opens the palette (app.js). */ +.cmd-trigger { + flex: 0 1 380px; display: flex; align-items: center; gap: 10px; min-width: 0; + padding: 8px 10px 8px 14px; border-radius: 12px; cursor: pointer; text-align: left; + background: var(--glass); border: 1px solid var(--border-glass); color: var(--text-faint); + -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); + transition: border-color 0.15s, color 0.15s; +} +.cmd-trigger:hover { border-color: var(--accent-2); color: var(--text-dim); } +.cmd-trigger .glyph { --glyph: var(--i-search); width: 15px; height: 15px; flex: 0 0 auto; } +.cmd-trigger-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; } +.model-badge { + display: inline-flex; align-items: center; gap: 7px; font: 12px/1 var(--font-sans); color: var(--text-dim); + border: 1px solid var(--border-glass); background: var(--glass); border-radius: 999px; padding: 6px 11px; white-space: nowrap; +} +.model-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); } + +.palette-overlay { + position: fixed; inset: 0; z-index: 900; display: none; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; + background: color-mix(in srgb, var(--bg) 62%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); +} +.palette-overlay.is-open { display: flex; } +.palette { + width: min(620px, 100%); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; + background: var(--glass-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-glow); + -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); +} +.palette-input-row { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border-glass); } +.palette-input-row .glyph { --glyph: var(--i-search); width: 17px; height: 17px; color: var(--accent); flex: 0 0 auto; } +.palette-input { flex: 1; min-width: 0; background: transparent; border: 0; outline: 0; font-size: 16px; } +.palette-input::placeholder { color: var(--text-faint); } +.palette-list { overflow-y: auto; padding: 8px; scrollbar-width: thin; } +.palette-group { padding: 8px 10px 4px; font: 600 12px/1.4 var(--font-sans); color: var(--text-faint); } +.palette-item { + display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 10px; cursor: pointer; color: var(--text-dim); + text-decoration: none; +} +.palette-item .glyph { width: 16px; height: 16px; color: var(--text-faint); flex: 0 0 auto; } +.palette-item-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); } +.palette-item-hint { font-size: 12px; color: var(--text-faint); } +.palette-item.is-selected, .palette-item:hover { background: var(--bg-hover); text-decoration: none; } +.palette-item.is-selected .glyph { color: var(--accent); } +.palette-empty { padding: 22px; text-align: center; color: var(--text-faint); font-size: 14px; } +.palette-foot { display: flex; gap: 16px; padding: 9px 18px; border-top: 1px solid var(--border-glass); font-size: 12px; color: var(--text-faint); } + +/* --------------------------------------------------------------------- + 5. Chat window + empty state + --------------------------------------------------------------------- */ +.chat-window { + flex: 1; min-height: 0; overflow-y: auto; padding: 20px max(16px, calc((100% - 820px) / 2)) 8px; + display: flex; flex-direction: column; gap: 24px; scroll-behavior: smooth; + scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; +} +.chat-window:has(.message-row) .empty-state { display: none; } + +.empty-state { margin: auto; width: 100%; max-width: 760px; text-align: left; color: var(--text-dim); padding: 8px 0 24px; } +.hero { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px; margin-bottom: 22px; } +.hero-mark { + width: 56px; height: 56px; display: grid; place-items: center; border-radius: 18px; color: var(--on-accent); + background: var(--accent-ring); box-shadow: 0 10px 40px -8px rgba(61, 120, 255, 0.6); +} +.hero-mark .glyph { --glyph: var(--i-spark); width: 26px; height: 26px; } +.empty-state h3 { margin: 6px 0 0; font-size: clamp(26px, 4.2vw, 36px); line-height: 1.15; color: var(--text); font-weight: 650; letter-spacing: -0.03em; } +.empty-state .hero p { margin: 0; max-width: 46ch; font-size: 15.5px; color: var(--text-dim); } + +.block-title { margin: 0 0 10px 2px; font: 600 13px/1.4 var(--font-sans); color: var(--text-faint); } + +/* Starters: the "Top recipe ideas" cards — glass tiles, one idea each */ +.starters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px; } +.starter { + display: flex; flex-direction: column; gap: 8px; padding: 12px 15px 13px; text-align: left; cursor: pointer; + background: var(--glass); border: 1px solid var(--border-glass); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); + -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); + transition: border-color 0.15s, transform 0.15s, background 0.15s; +} +.starter:hover { border-color: color-mix(in srgb, var(--k, var(--accent-2)) 60%, transparent); background: var(--glass-strong); transform: translateY(-2px); } +.starter-icon { + width: 32px; height: 32px; display: grid; place-items: center; border-radius: 10px; + color: var(--k, var(--accent)); background: color-mix(in srgb, var(--k, var(--accent)) 14%, transparent); +} +.starter-icon .glyph { width: 17px; height: 17px; } +.starter-title { font-weight: 600; color: var(--text); font-size: 15px; line-height: 1.3; } +.starter-desc { font-size: 13.5px; color: var(--text-dim); line-height: 1.45; } +.starter-meta { margin-top: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-faint); } + +/* "Ingredients you have" → what the agent can use in this workspace */ +.tools-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; } +.tool-chip { + display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 8px; border-radius: 999px; + background: var(--glass); border: 1px solid var(--border-glass); font-size: 13px; color: var(--text-dim); +} +.tool-chip .glyph { width: 15px; height: 15px; color: var(--k, var(--accent)); } + +/* "More to explore" → quick prompt chips (fill, don't send) */ +.explore { display: flex; flex-wrap: wrap; gap: 8px; } +.explore-chip { + padding: 7px 13px; border-radius: 999px; cursor: pointer; font-size: 13.5px; color: var(--text-dim); + background: transparent; border: 1px dashed var(--border-strong); transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.explore-chip:hover { border-style: solid; border-color: var(--accent-2); color: var(--text); background: var(--bg-hover); } + +/* --------------------------------------------------------------------- + 6. Messages, thinking, tool steps + --------------------------------------------------------------------- */ +.message-row { display: flex; flex-direction: column; gap: 6px; max-width: 100%; animation: msg-in 0.28s cubic-bezier(0.2, 0.7, 0.3, 1); } +@keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } + +.role-user, .role-assistant { flex-direction: row; align-items: flex-start; gap: 12px; } +.role-user { flex-direction: row-reverse; justify-content: flex-start; } +.message-content { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; } +.role-user .message-content { flex: 0 1 auto; align-items: flex-end; max-width: 82%; margin-left: auto; } + +.avatar { flex: 0 0 30px; width: 30px; height: 30px; margin-top: 0; border-radius: 10px; display: flex; align-items: center; justify-content: center; user-select: none; } +.avatar-assistant { background: var(--accent-ring); color: var(--on-accent); box-shadow: 0 6px 20px -8px rgba(61, 120, 255, 0.7); } + +.message-meta { display: flex; align-items: center; gap: 10px; height: 30px; font: 650 13px/1 var(--font-sans); color: var(--text); } +.meta-name { letter-spacing: -0.01em; } + +.bubble { max-width: 100%; overflow-wrap: anywhere; } +.role-user .bubble { + padding: 10px 16px; background: var(--glass-strong); border: 1px solid var(--border-glass); + border-radius: 18px 18px 5px 18px; white-space: normal; box-shadow: var(--shadow-card); +} +.role-assistant .bubble { padding: 2px 0; font-size: 15px; line-height: 1.7; } +.bubble-error { padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); } +.role-system { align-items: center; } + +/* Rendered Markdown (calm: only links and inline code take colour) */ +.bubble > :first-child { margin-top: 0; } +.bubble > :last-child { margin-bottom: 0; } +.bubble p { margin: 0 0 0.85em; } +.bubble ul, .bubble ol { margin: 0 0 0.85em; padding-left: 1.4em; } +.bubble li { margin: 0.25em 0; } +.bubble h1, .bubble h2, .bubble h3, .bubble h4 { margin: 1.25em 0 0.5em; line-height: 1.3; font-weight: 650; letter-spacing: -0.015em; } +.bubble h1 { font-size: 1.32em; } .bubble h2 { font-size: 1.16em; } .bubble h3 { font-size: 1.05em; } +.bubble blockquote { margin: 0 0 0.85em; padding: 2px 0 2px 14px; border-left: 3px solid var(--accent-2); color: var(--text-dim); } +.bubble hr { border: 0; border-top: 1px solid var(--border); margin: 1.2em 0; } +.bubble table { border-collapse: separate; border-spacing: 0; margin: 0 0 0.9em; display: block; overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; } +.bubble th, .bubble td { border-bottom: 1px solid var(--border); padding: 7px 13px; } +.bubble tr:last-child td { border-bottom: 0; } +.bubble th { background: var(--bg-elev); font-weight: 600; text-align: left; } +.bubble code { font: 0.88em var(--font-mono); background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; } +.bubble a { text-underline-offset: 2px; } +.bubble pre { margin: 0 0 0.85em; } +.bubble pre code { display: block; padding: 14px 16px; overflow-x: auto; background: var(--bg-elev); border: 0; border-radius: 0 0 12px 12px; line-height: 1.55; } +.role-assistant .bubble a { color: var(--accent-2); text-decoration: underline; } +.role-assistant .bubble :not(pre) > code { color: var(--tok-fn); background: color-mix(in srgb, var(--tok-fn) 11%, transparent); border-color: color-mix(in srgb, var(--tok-fn) 26%, transparent); } +.role-assistant .bubble li::marker { color: var(--accent); } +.role-assistant .bubble mark { background: var(--accent-soft); color: inherit; border-radius: 3px; padding: 0 3px; } + +/* Code blocks: header with language + copy button (see render/markdown.go) */ +.code-block { margin: 0 0 1em; border: 1px solid var(--border-strong); border-radius: 12px; overflow: hidden; background: var(--bg-elev); box-shadow: var(--shadow-card); } +.code-block pre { margin: 0; } +.code-block pre code { border: 0; border-radius: 0; } +.code-block-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 7px 8px 7px 14px; background: var(--bg-elev-2); border-bottom: 1px solid var(--border); font: 12px/1.4 var(--font-mono); color: var(--text-faint); } +.code-block-lang { display: flex; align-items: center; gap: 8px; text-transform: lowercase; } +.code-block-lang::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; } +.copy-btn { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 1px solid transparent; border-radius: 7px; padding: 3px 10px; font-size: 12px; color: var(--text-faint); cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s; } +.copy-btn::before { content: "⧉"; font-size: 12px; } +.copy-btn:hover { color: var(--text); background: var(--bg-hover); border-color: var(--border-strong); } + +/* Streaming affordances */ +.typing-dots { display: inline-flex; gap: 5px; padding: 9px 0; } +.typing-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-2); opacity: 0.4; animation: blink 1.2s infinite ease-in-out; } +.typing-dots span:nth-child(2) { animation-delay: 0.15s; } +.typing-dots span:nth-child(3) { animation-delay: 0.3s; } +@keyframes blink { 0%, 80%, 100% { opacity: 0.28; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-2px); } } + +.stop-form { margin: 0 0 0 auto; } +.stop-btn { + display: inline-flex; align-items: center; gap: 7px; background: var(--glass); border: 1px solid var(--border-strong); + border-radius: 999px; padding: 4px 12px 4px 10px; font: 600 12px/1.6 var(--font-sans); color: var(--text-dim); cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.stop-icon { width: 8px; height: 8px; border-radius: 2px; background: currentColor; display: inline-block; } +.stop-btn:hover { color: var(--danger); border-color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); } +.message-row:has(.stream-done) .stop-form { display: none; } + +/* Thinking: a quiet pill while the model reasons, shimmering label only + until the reply starts. Auto-collapsed by app.js into "Thought for Ns". */ +.thinking-card { + display: none; background: var(--glass); border: 1px solid var(--border-glass); border-radius: 14px; + padding: 8px 14px; margin: 0 0 2px; color: var(--text-faint); font-size: 13px; +} +.thinking-card:has(.thinking-body:not(:empty)) { display: block; } +.thinking-card summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; font: 600 12.5px/1.6 var(--font-sans); color: var(--text-dim); } +.thinking-card summary::-webkit-details-marker { display: none; } +.thinking-card summary::after { content: "▾"; margin-left: auto; color: var(--text-faint); font-size: 10px; transition: transform 0.15s; } +.thinking-card:not([open]) summary::after { transform: rotate(-90deg); } +.thinking-icon { display: inline-flex; color: var(--accent-2); width: 14px; } +.thinking-icon::before { --glyph: var(--i-spark); } +.message-row:not(:has(.stream-done)) .thinking-card:has(.thinking-body:not(:empty)) .thinking-label { + background: linear-gradient(100deg, var(--text-faint) 30%, var(--text) 50%, var(--text-faint) 70%) 0 0 / 220% 100%; + -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; + animation: shimmer 2.2s linear infinite; +} +@keyframes shimmer { to { background-position: -220% 0; } } +.message-row:not(:has(.stream-done)) .thinking-card:has(.thinking-body:not(:empty)) .thinking-icon { animation: thinking-pulse 1.4s ease-in-out infinite; } +@keyframes thinking-pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } +.thinking-elapsed { font-weight: 400; color: var(--text-faint); } +.message-row:has(.stream-done) .thinking-elapsed:empty::before { content: "finished"; } +.thinking-body { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); max-height: 260px; overflow-y: auto; white-space: normal; font-style: italic; line-height: 1.55; } + +/* Tool steps: the "source cards" of an agent run. A coloured icon tile says + what KIND of step it was at a glance; the summary says what it touched. */ +.role-tool { align-items: stretch; } +.tool-card { + --k: var(--accent); + border: 1px solid var(--border-glass); border-radius: 14px; background: var(--glass); overflow: hidden; + -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); + transition: border-color 0.15s, background 0.15s; +} +.tool-card:hover { border-color: color-mix(in srgb, var(--k) 45%, var(--border-glass)); } +.tool-card[open] { background: var(--glass-strong); } +.tool-card summary { display: flex; align-items: center; gap: 10px; padding: 8px 12px 8px 9px; cursor: pointer; list-style: none; font: 12.5px/1.5 var(--font-mono); color: var(--text-dim); } +.tool-card summary::-webkit-details-marker { display: none; } +.tool-card summary:hover { background: var(--bg-hover); } +.tool-icon { + flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 8px; + color: var(--k); background: color-mix(in srgb, var(--k) 15%, transparent); +} +.tool-icon::before { width: 14px; height: 14px; } +.tool-icon-read_file { --glyph: var(--i-file); } +.tool-icon-write_file, .tool-icon-edit_file, .tool-icon-multi_edit_file { --glyph: var(--i-edit); } +.tool-icon-list_directory, .tool-icon-glob_search { --glyph: var(--i-folder); } +.tool-icon-grep, .tool-icon-search_codebase { --glyph: var(--i-search); } +.tool-icon-run_bash_command { --glyph: var(--i-term); } +.tool-icon-write_todos { --glyph: var(--i-todo); } +.tool-icon-dispatch_subagent { --glyph: var(--i-spark); } +/* Kind colour: set on the card via :has so the template stays unchanged */ +.tool-card:has(.tool-icon-read_file), .tool-card:has(.tool-icon-list_directory), .tool-card:has(.tool-icon-glob_search) { --k: var(--k-read); } +.tool-card:has(.tool-icon-write_file), .tool-card:has(.tool-icon-edit_file), .tool-card:has(.tool-icon-multi_edit_file) { --k: var(--k-write); } +.tool-card:has(.tool-icon-grep), .tool-card:has(.tool-icon-search_codebase) { --k: var(--k-search); } +.tool-card:has(.tool-icon-run_bash_command) { --k: var(--k-run); } +.tool-card:has(.tool-icon-write_todos) { --k: var(--k-plan); } +.tool-card:has(.tool-icon-dispatch_subagent) { --k: var(--k-agent); } +.tool-card.is-error { --k: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); } +.tool-card.is-subagent { border-left: 3px solid var(--k-agent); } + +/* One round's reasoning trace, same chrome as a tool step */ +.tool-card.is-thinking-round { --k: var(--accent-2); } +.tool-card.is-thinking-round .tool-body .tool-output { font-style: italic; white-space: pre-wrap; } +.tool-card.is-thinking-round summary .thinking-icon { width: 24px; height: 24px; justify-content: center; align-items: center; border-radius: 8px; color: var(--k); background: color-mix(in srgb, var(--k) 15%, transparent); } + +.tool-name { color: var(--text); font-weight: 600; } +.tool-summary { flex: 1; min-width: 0; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tool-chevron { flex: 0 0 auto; margin-left: auto; color: var(--text-faint); transition: transform 0.15s; } +.tool-chevron::before { content: "▸"; } +.tool-card[open] .tool-chevron { transform: rotate(90deg); } +.tool-body { border-top: 1px solid var(--border); max-height: 360px; overflow: auto; } +.tool-output { margin: 0; padding: 12px 14px; font: 12px/1.55 var(--font-mono); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; } + +/* --------------------------------------------------------------------- + 7. Composer — the one luminous thing on the page + --------------------------------------------------------------------- */ +.composer { padding: 8px max(16px, calc((100% - 820px) / 2)) 6px; } +.composer-form { + position: relative; display: flex; align-items: flex-end; gap: 8px; padding: 10px 10px 10px 10px; + background: var(--glass-strong); border: 1px solid var(--border-strong); border-radius: 22px; + -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); + transition: box-shadow 0.25s, border-color 0.25s; +} +/* Gradient ring, revealed on focus. Drawn as a masked pseudo-element so the + border keeps its radius; browsers without mask-composite just keep the + solid border + glow shadow. */ +.composer-form::before { + content: ""; position: absolute; inset: -1px; border-radius: inherit; padding: 1px; pointer-events: none; + background: var(--accent-ring); opacity: 0; transition: opacity 0.25s; + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; mask-composite: exclude; +} +.composer-form:focus-within { border-color: transparent; box-shadow: var(--shadow-glow); } +.composer-form:focus-within::before { opacity: 1; } +.composer-form textarea { + flex: 1; min-height: 32px; max-height: 220px; resize: none; padding: 6px 0; + background: transparent; border: 0; outline: 0; line-height: 1.5; font-size: 15.5px; + overflow-y: auto; transition: height 0.08s ease-out; +} +.composer-form textarea::placeholder { color: var(--text-faint); } + +.attach-btn { + flex: 0 0 34px; width: 34px; height: 34px; display: grid; place-items: center; + border: 1px solid var(--border-strong); border-radius: 50%; background: transparent; color: var(--text-dim); cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s; +} +.attach-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--accent-2); } +.attach-btn:active { transform: scale(0.94); } + +/* Send: a pill with a label on wide screens (the reference's "Ask") */ +.send-btn { + flex: 0 0 auto; height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 16px 0 13px; + border: 0; border-radius: 999px; background: var(--accent-ring); color: var(--on-accent); font-weight: 650; font-size: 14px; cursor: pointer; + box-shadow: 0 6px 20px -8px rgba(61, 120, 255, 0.8); transition: filter 0.15s, opacity 0.15s, transform 0.15s; +} +.send-btn svg { flex: 0 0 auto; } +.send-btn:hover { filter: brightness(1.08); } +.send-btn:active { transform: scale(0.97); } +.composer-form[data-empty="true"] .send-btn { opacity: 0.45; box-shadow: none; } + +.composer-hint { margin-top: 8px; display: flex; justify-content: center; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--text-faint); } +.composer-hint span { display: inline-flex; align-items: center; gap: 6px; } + +/* Staged-file tray above the box; chips share one shape with sent messages */ +.composer-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 max(16px, calc((100% - 820px) / 2)) 8px; } +.composer-attachments:empty { display: none; margin: 0; } +.attach-chip { + position: relative; display: flex; align-items: center; gap: 8px; max-width: 230px; padding: 5px 10px 5px 5px; + background: var(--glass-strong); border: 1px solid var(--border-strong); border-radius: 12px; + font-size: 12.5px; color: var(--text-dim); transition: border-color 0.15s; +} +.attach-chip.is-error { border-color: var(--danger); color: var(--danger); } +.attach-chip.is-archive { max-width: 270px; } +.attach-chip.is-archive .attach-chip-name { max-width: 210px; } +.attach-chip.is-archive .attach-chip-preview, .msg-attachment.is-archive .msg-attachment-icon { color: var(--accent); } +.attach-chip-preview { position: relative; flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; overflow: hidden; display: grid; place-items: center; background: var(--bg-elev); border: 1px solid var(--border); color: var(--text-faint); } +.attach-chip-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } +.attach-chip-name { min-width: 0; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.attach-chip-remove { flex: 0 0 auto; width: 18px; height: 18px; display: grid; place-items: center; margin-left: 2px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-faint); font-size: 14px; line-height: 1; cursor: pointer; } +.attach-chip-remove:hover { background: var(--bg-hover); color: var(--text); } +.attach-chip-spinner { display: none; } +.attach-chip.is-uploading .attach-chip-spinner { display: block; position: absolute; inset: 0; border-radius: inherit; background: color-mix(in srgb, var(--bg-elev-2) 55%, transparent); } +.attach-chip.is-uploading .attach-chip-spinner::after { content: ""; position: absolute; top: 50%; left: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: attach-spin 0.7s linear infinite; } +@keyframes attach-spin { to { transform: rotate(360deg); } } + +.msg-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.msg-attachment { display: flex; align-items: center; gap: 8px; max-width: 230px; padding: 5px 11px 5px 5px; background: var(--bg-elev-2); border: 1px solid var(--border-strong); border-radius: 12px; font-size: 12.5px; color: var(--text-dim); } +.msg-attachment:hover { border-color: var(--accent-2); text-decoration: none; } +.msg-attachment-thumb { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; object-fit: cover; } +.msg-attachment-icon { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; background: var(--bg-elev); border: 1px solid var(--border); color: var(--text-faint); } +.msg-attachment-name { min-width: 0; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.msg-attachment.is-archive { max-width: 270px; } +.msg-attachment.is-archive .msg-attachment-name { max-width: 210px; } + +/* --------------------------------------------------------------------- + 8. Mobile: sidebar becomes an off-canvas drawer (checkbox +