Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

View File

@@ -0,0 +1,21 @@
# See services/iam/Dockerfile for the build-stage pattern. This image
# additionally ships web/templates and web/static, since the gateway is
# the only service that renders HTML.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/gateway/ services/gateway/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/gateway-server ./services/gateway/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/gateway-server ./gateway-server
COPY --from=build /src/services/gateway/web ./web
ENV ENKLY_GATEWAY_HTTP_ADDR=:8080
ENV ENKLY_TEMPLATES_DIR=/app/web/templates
ENV ENKLY_STATIC_DIR=/app/web/static
EXPOSE 8080
ENTRYPOINT ["./gateway-server"]

View File

@@ -0,0 +1,173 @@
// Command server runs the gateway: the BFF that terminates the public
// HTTP/HTMX surface and composes it from every other bounded context
// over gRPC. Replaces the net/http wiring half of the monolith's
// cmd/server/main.go.
package main
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
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/envkit"
"enkly/services/gateway/internal/handlers"
)
func dial(addr string, extraOpts ...grpc.DialOption) *grpc.ClientConn {
opts := append([]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, extraOpts...)
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
panic(err) // unreachable at startup is fatal; see main()'s recover-free startup sequence below
}
return conn
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := envkit.LoadDotEnv(".env"); err != nil {
logger.Error("load .env", "err", err)
os.Exit(1)
}
var (
httpAddr = envkit.String("ENKLY_GATEWAY_HTTP_ADDR", ":8080")
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
templatesDir = envkit.String("ENKLY_TEMPLATES_DIR", "web/templates")
staticDir = envkit.String("ENKLY_STATIC_DIR", "web/static")
iamAddr = envkit.String("ENKLY_IAM_GRPC_ADDR", "iam:9001")
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
agentAddr = envkit.String("ENKLY_AGENT_GRPC_ADDR", "agent:9006")
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
)
templates, err := handlers.LoadTemplates(templatesDir)
if err != nil {
logger.Error("load templates", "err", err)
os.Exit(1)
}
bigMsg := grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20))
h := &handlers.Handlers{
IAM: iamv1.NewIAMServiceClient(dial(iamAddr)),
Billing: billingv1.NewBillingServiceClient(dial(billingAddr)),
Conversation: conversationv1.NewConversationServiceClient(dial(conversationAddr)),
Workspace: workspacev1.NewWorkspaceServiceClient(dial(workspaceAddr, bigMsg)),
Knowledge: knowledgev1.NewKnowledgeServiceClient(dial(knowledgeAddr)),
Agent: agentv1.NewAgentServiceClient(dial(agentAddr, bigMsg)),
Templates: templates,
Logger: logger,
BaseURL: baseURL,
StripePublishKey: stripePublishKey,
}
mux := http.NewServeMux()
// --- Health check (probed by k8s readinessProbe/livenessProbe in
// k8s/27-gateway.yaml at GET /healthz — must stay unauthenticated
// and dependency-free so a downstream gRPC outage doesn't also
// take the gateway pod itself out of rotation). ---
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// --- Public (no auth) ---
mux.HandleFunc("GET /login", h.LoginPage)
mux.HandleFunc("POST /login", h.RequireCSRF(h.Login))
mux.HandleFunc("GET /signup", h.SignupPage)
mux.HandleFunc("POST /signup", h.RequireCSRF(h.Signup))
mux.HandleFunc("GET /verify-email", h.VerifyEmailPage)
mux.HandleFunc("POST /verify-email", h.RequireCSRF(h.VerifyEmail))
mux.HandleFunc("POST /verify-email/resend", h.RequireCSRF(h.ResendVerification))
mux.HandleFunc("GET /forgot-password", h.ForgotPasswordPage)
mux.HandleFunc("POST /forgot-password", h.RequireCSRF(h.ForgotPassword))
mux.HandleFunc("GET /reset-password", h.ResetPasswordPage)
mux.HandleFunc("POST /reset-password", h.RequireCSRF(h.ResetPassword))
mux.HandleFunc("POST /logout", h.Logout)
mux.HandleFunc("POST /webhooks/stripe", h.StripeWebhook)
mux.HandleFunc("POST /theme", h.SetTheme)
mux.HandleFunc("POST /language", h.SetLanguage)
// --- Authenticated ---
mux.HandleFunc("GET /{$}", h.RequireAuth(h.Index))
mux.HandleFunc("GET /c/{id}", h.RequireAuth(h.SwitchConversation))
mux.HandleFunc("POST /conversations/new", h.RequireAuth(h.RequireCSRF(h.NewConversation)))
mux.HandleFunc("POST /conversations/{id}/rename", h.RequireAuth(h.RequireCSRF(h.RenameConversation)))
mux.HandleFunc("POST /conversations/{id}/delete", h.RequireAuth(h.RequireCSRF(h.DeleteConversationHandler)))
mux.HandleFunc("POST /chat", h.RequireAuth(h.RequireCSRF(h.Chat)))
mux.HandleFunc("GET /chat/stream/{id}", h.RequireAuth(h.StreamChat))
mux.HandleFunc("POST /chat/stream/{id}/stop", h.RequireAuth(h.RequireCSRF(h.StopStream)))
mux.HandleFunc("GET /tree", h.RequireAuth(h.FileTree))
mux.HandleFunc("GET /status", h.RequireAuth(h.StatusBar))
mux.HandleFunc("GET /files", h.RequireAuth(h.FilesPanel))
mux.HandleFunc("POST /files/upload", h.RequireAuth(h.RequireCSRFUpload(h.UploadFile)))
mux.HandleFunc("POST /files/attach", h.RequireAuth(h.RequireCSRFUpload(h.AttachFile)))
mux.HandleFunc("GET /files/download/{path...}", h.RequireAuth(h.DownloadFile))
mux.HandleFunc("GET /subscription", h.RequireAuth(h.SubscriptionPage))
mux.HandleFunc("POST /subscription/checkout", h.RequireAuth(h.RequireCSRF(h.CreateCheckout)))
mux.HandleFunc("POST /subscription/portal", h.RequireAuth(h.RequireCSRF(h.BillingPortal)))
mux.HandleFunc("GET /subscription/success", h.RequireAuth(h.SubscriptionSuccess))
mux.HandleFunc("GET /subscription/cancel", h.RequireAuth(h.SubscriptionCancel))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
// Shared edge middleware — security headers, gzip, structured
// request logging, panic recovery — applied around the whole mux,
// identical ordering to the monolith's.
root := handlers.Chain(mux,
handlers.Recover(logger),
handlers.RequestLogger(logger),
handlers.SecurityHeaders,
handlers.Gzip,
)
lis, err := net.Listen("tcp", httpAddr)
if err != nil {
logger.Error("listen", "addr", httpAddr, "err", err)
os.Exit(1)
}
srv := &http.Server{
Handler: root,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("gateway listening", "addr", httpAddr, "base_url", baseURL)
if err := srv.Serve(lis); err != nil && err != http.ErrServerClosed {
logger.Error("serve", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}

View File

@@ -0,0 +1,481 @@
// Package handlers (attachments.go) adds the chat composer's "Add
// files or photos" attachment flow — strongly inspired by Claude
// Code's chat input: drag a file (or a screenshot) onto the message
// box, or click the paperclip, and it shows up as a chip with a
// thumbnail while it uploads in the background; remove it before
// sending if it was a mistake. A .zip is handled specially (see
// "Zip extraction" below): instead of landing as one opaque binary
// blob the agent's tools can't do anything useful with, it's unpacked
// into real workspace files the agent can read_file, grep, and
// list_directory exactly like any other code it's handed — no new
// agent tool needed, "read and review" falls out of tools that
// already exist.
//
// Wire format: web/static/js/app.js uploads each staged file the
// moment it's picked/dropped/pasted, one at a time, to POST
// /files/attach (AttachFile below). That handler is a thin wrapper
// around files.go's writeUploadedFile — the same multipart-part ->
// Workspace.UploadFile path the sidebar's bulk upload form already
// uses — except it (a) namespaces attachments under attachments/ so
// they don't clutter the workspace root the agent writes code into,
// (b) de-dupes the filename against what's already there (see
// uniqueWorkspacePath; UploadFile itself silently overwrites), and
// (c) answers with JSON instead of an HTML fragment, since each
// chip's uploading/done/error state is owned by the browser, not the
// server.
//
// Storage: conversation.Message (api/proto/conversation/v1) has no
// field for "files attached to this message" — adding one would be a
// schema change across a service boundary well beyond this task's
// scope. Instead, StartTurn's prompt argument IS what gets persisted
// as the message's content (see agent's StartTurn and
// internal/application/service.go), so the uploaded paths ride along
// as a trailing, both human- and machine-readable block:
//
// <what the user typed>
//
// [Attached files]
// - attachments/diagram.png
// - attachments/project (12 files)
//
// appendAttachmentsBlock writes it, splitAttachments reads it back.
// The model sees plain, readable text it can act on (e.g. read_file
// one of the paths, or list_directory an extracted folder);
// renderUserMessage parses the same string back into prompt text +
// attachment chips for both the just-sent bubble (chat.go's Chat) and
// every past message on reload (pages.go's prepareMessages) — one
// parser, two call sites, same as how deliverables.go's producedPath
// is shared across a live stream and history. See ARCHITECTURE.md's
// "known gaps" for the same trade-off made elsewhere in this codebase.
package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"mime/multipart"
"net/http"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/renderkit"
)
const (
// attachmentsDir keeps chat attachments out of the workspace root,
// where the agent reads/writes/greps code — see package doc.
attachmentsDir = "attachments"
// attachmentFormField is both the name of the hidden <input> 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 <input> 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
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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" }

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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).

View File

@@ -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 <pre> 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(`<pre class="tool-output">(no output)</pre>`)
}
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(`<pre class="tool-output">` + template.HTMLEscapeString(content) + `</pre>`) // #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"
}
}

View File

@@ -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)
}

View File

@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z'/><path d='M14 3v5h5M9 13h6M9 17h4'/></svg>");
--i-edit: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 20h9'/><path d='M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z'/></svg>");
--i-folder: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/></svg>");
--i-search: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>");
--i-term: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m4 17 6-6-6-6M12 19h8'/></svg>");
--i-todo: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='4' width='18' height='16' rx='3'/><path d='m8 12 3 3 5-6'/></svg>");
--i-spark: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M12 2.5c.35 0 .66.23.76.57l1.4 4.77 4.77 1.4a.79.79 0 0 1 0 1.52l-4.77 1.4-1.4 4.77a.79.79 0 0 1-1.52 0l-1.4-4.77-4.77-1.4a.79.79 0 0 1 0-1.52l4.77-1.4 1.4-4.77c.1-.34.41-.57.76-.57Z'/><path d='M19 15.2c.24 0 .45.16.52.39l.5 1.7 1.7.5a.55.55 0 0 1 0 1.05l-1.7.5-.5 1.7a.55.55 0 0 1-1.05 0l-.5-1.7-1.7-.5a.55.55 0 0 1 0-1.05l1.7-.5.5-1.7a.55.55 0 0 1 .53-.39Z'/></svg>");
--i-cog: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='3'/><path d='M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1'/></svg>");
}
/* 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 + <label>)
--------------------------------------------------------------------- */
@media (max-width: 800px) {
.sidebar { position: fixed; z-index: 30; inset: 0 auto 0 0; transform: translateX(-100%); transition: transform 0.2s ease; box-shadow: 8px 0 30px rgba(0, 0, 0, 0.5); background: var(--glass-strong); }
.sidebar-toggle-input:checked + .app-shell .sidebar { transform: none; }
.sidebar-backdrop { position: fixed; z-index: 20; inset: 0; background: rgba(0, 0, 0, 0.55); opacity: 0; pointer-events: none; transition: opacity 0.2s; display: block; }
.sidebar-toggle-input:checked + .app-shell .sidebar-backdrop { opacity: 1; pointer-events: auto; }
.sidebar-close, .sidebar-open { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; cursor: pointer; font-size: 20px; color: var(--text-dim); }
.sidebar-close:hover, .sidebar-open:hover { background: var(--bg-hover); }
.conversation-actions { display: flex; }
.model-badge { display: none; }
.chat-topbar { padding: 8px 12px; }
.cmd-trigger { flex: 0 0 auto; padding: 8px 10px; }
.cmd-trigger-label, .cmd-trigger kbd { display: none; }
.chat-window, .composer { padding-left: 14px; padding-right: 14px; }
.starters { grid-template-columns: 1fr; }
.send-btn { padding: 0 12px; }
.send-btn-label, .composer-hint .hint-kbd { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
/* ---------------------------------------------------------------------
9. Auth pages
--------------------------------------------------------------------- */
.auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.auth-card { width: 100%; max-width: 400px; padding: 30px; background: var(--glass-strong); border: 1px solid var(--border-glass); border-radius: var(--radius-lg); box-shadow: var(--shadow-glow); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
.auth-card .brand-lockup { margin-bottom: 18px; }
/* The enkly logo artwork (dark-grey linework + teal accents) is drawn
for a light surface; placed directly on this theme's near-black
--bg-elev it would lose most of its contrast. A small light chip
behind it (not a recolor of the artwork itself) fixes that without
touching the logo file. The wordmark is already drawn into the
artwork, so the adjacent <h1> here is set sr-only rather than
shown a second time next to it — still present for accessibility
and for anything that reads the page's heading structure. */
.auth-card .brand-lockup .logo-mark {
height: 96px;
width: auto;
padding: 6px 10px;
/*background: #fbfaf7;
border-radius: 10px;*/
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.auth-card h2 { margin: 0 0 4px; font-size: 20px; }
.auth-subtitle { margin: 0 0 18px; color: var(--text-dim); font-size: 14px; }
.auth-message { margin: 0 0 14px; padding: 9px 12px; border-radius: 8px; font-size: 14px; }
.auth-message.error { color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
.auth-message.info { color: var(--text); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
.auth-form { display: flex; flex-direction: column; gap: 14px; }
.auth-field { display: flex; flex-direction: column; gap: 5px; }
.auth-field label { font-size: 13px; color: var(--text-dim); }
.auth-field input { padding: 9px 11px; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 8px; outline: 0; }
.auth-field input:focus { border-color: var(--accent); }
/* Eye icon for password fields — web/static/js/password-toggle.js
inserts the <button> into every .password-field (see auth.html). */
.password-field { position: relative; }
.password-field input { width: 100%; box-sizing: border-box; padding-right: 38px; }
.password-toggle {
position: absolute; top: 50%; right: 4px; transform: translateY(-50%);
width: 28px; height: 28px; display: grid; place-items: center;
border: 0; border-radius: 6px; background: transparent; color: var(--text-faint); cursor: pointer;
}
.password-toggle:hover { background: var(--bg-elev-2); color: var(--text); }
.password-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.auth-btn { padding: 11px 14px; border: 0; border-radius: 12px; background: var(--accent-ring); color: var(--on-accent); font-weight: 650; cursor: pointer; }
.auth-btn:hover { filter: brightness(1.08); }
.auth-btn-secondary { background: transparent; border: 1px solid var(--border-strong); color: var(--text); }
.auth-btn-secondary:hover { filter: none; border-color: var(--accent); }
.auth-inline-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
#verify-code { letter-spacing: 4px; font-variant-numeric: tabular-nums; text-align: center; }
.auth-footer { margin-top: 14px; text-align: center; font-size: 13px; color: var(--text-dim); }
/* ---------------------------------------------------------------------
Subscription / billing page
--------------------------------------------------------------------- */
.billing-shell { min-height: 100vh; padding: 24px; max-width: 980px; margin: 0 auto; }
.billing-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.billing-back { color: var(--text-dim); font-size: 14px; }
.billing-back:hover { color: var(--accent); text-decoration: none; }
.billing-card { padding: 28px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; }
.billing-card h2 { margin: 0 0 4px; font-size: 22px; }
.plan-status { display: inline-block; margin-left: 4px; color: var(--text-dim); }
.plan-status-warn { color: var(--danger); }
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 22px 0; }
.plan-card {
position: relative; display: flex; flex-direction: column; gap: 10px; padding: 18px 16px;
background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 12px;
}
.plan-card-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.plan-badge {
position: absolute; top: -10px; right: 14px; padding: 2px 8px; border-radius: 999px;
background: var(--accent); color: var(--on-accent); font-size: 11px; font-weight: 700;
}
.plan-card-name { font-size: 15px; font-weight: 600; color: var(--text); }
.plan-card-price { font-size: 26px; font-weight: 700; color: var(--text); }
.plan-card-period { font-size: 13px; font-weight: 400; color: var(--text-faint); }
.plan-card-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.plan-card-features li { font-size: 12.5px; color: var(--text-dim); padding-left: 14px; position: relative; }
.plan-card-features li::before { content: "✓"; position: absolute; left: 0; color: var(--ok); }
.plan-form { margin: 0; }
.plan-btn { width: 100%; }
.plan-btn:disabled { cursor: default; opacity: 0.6; filter: none; }
.plan-unavailable-reason { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.4; }
.billing-portal-form { margin: 0 0 16px; }
.billing-footnote { margin: 0; font-size: 12px; color: var(--text-faint); }
@media (max-width: 640px) {
.plan-grid { grid-template-columns: 1fr 1fr; }
}
/* ---------------------------------------------------------------------
Explorer tree (lazy-expanding; see web/templates/tree.html)
--------------------------------------------------------------------- */
.file-tree { max-height: 260px; overflow: auto; font: 13px/1.5 var(--font-mono); }
.tree-list { list-style: none; margin: 0; padding: 0; }
.tree-list .tree-list { padding-left: 14px; border-left: 1px solid var(--border); margin-left: 6px; }
.tree-dir, .tree-file { display: flex; flex-wrap: wrap; align-items: center; }
.tree-dir > .tree-list { flex: 1 0 100%; }
.tree-node {
display: inline-flex; align-items: center; gap: 6px; padding: 1px 4px;
background: none; border: 0; border-radius: 4px; color: var(--text-dim);
cursor: pointer; text-align: left; font: inherit; text-decoration: none;
}
.tree-node:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
.tree-icon { width: 1em; display: inline-block; text-align: center; color: var(--text-faint); }
.tree-file .file-size { margin-left: auto; color: var(--text-faint); font-size: 11px; }
.tree-empty { color: var(--text-faint); padding: 2px 4px; font-style: italic; }
/* Status bar: a quiet strip under the composer. Context usage is a meter. */
.status-bar { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 18px; align-items: center; padding: 2px 16px 10px; color: var(--text-faint); font: 12px/1.6 var(--font-sans); min-height: 26px; }
.status-item { display: inline-flex; align-items: center; gap: 7px; }
.status-bar .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.status-bar .dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); }
.status-bar .dot.bad { background: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); }
.ctx-meter { width: 64px; height: 4px; border-radius: 4px; background: var(--bg-elev-2); overflow: hidden; }
.ctx-meter > span { display: block; height: 100%; border-radius: inherit; background: var(--accent-ring); }
.ctx-meter.is-high > span { background: var(--warn); }
.ctx-meter.is-full > span { background: var(--danger); }
/* ---------------------------------------------------------------------
Syntax highlighting classes (palette lives in the tokens above)
--------------------------------------------------------------------- */
.tok-kw { color: var(--tok-kw); font-weight: 600; }
.tok-str { color: var(--tok-str); }
.tok-cmt { color: var(--tok-cmt); font-style: italic; }
.tok-num { color: var(--tok-num); }
.tok-fn { color: var(--tok-fn); }
.tok-type { color: var(--tok-type); }
.tok-const { color: var(--tok-const); }
.tok-var { color: var(--tok-var); }
.tok-key { color: var(--tok-fn); }
.tok-add { color: var(--tok-add); }
.tok-del { color: var(--tok-del); }
.tok-hunk { color: var(--tok-hunk); }
/* =====================================================================
Download cards at the end of a reply (web/templates/deliverables.html)
Icon tile + name/type + Download button.
===================================================================== */
.deliverables { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 4px; }
.deliverable-card {
display: flex; align-items: center; gap: 16px;
padding: 12px 14px; background: var(--bg-elev);
border: 1px solid var(--border-strong); border-radius: 14px;
}
.deliverable-tile {
flex: 0 0 auto; width: 52px; height: 56px; display: grid; place-items: center;
background: var(--bg-elev-2); border: 1px solid var(--border-strong);
border-radius: 10px; color: var(--text-dim);
}
.deliverable-meta { flex: 1; min-width: 0; }
.deliverable-name {
font-size: 16px; color: var(--text); line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.deliverable-type { font-size: 13px; color: var(--text-faint); letter-spacing: 0.02em; }
.deliverable-btn {
flex: 0 0 auto; padding: 8px 18px; cursor: pointer;
background: var(--bg-elev-2); color: var(--text);
border: 1px solid var(--border-strong); border-radius: 10px;
font-size: 14px; text-decoration: none;
}
.deliverable-btn:hover { background: var(--bg-hover); text-decoration: none; }
.deliverable-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }
@media (max-width: 480px) {
.deliverable-card { gap: 10px; padding: 10px; }
.deliverable-tile { width: 42px; height: 46px; }
}
/* Drag & drop upload (web/static/js/app.js) */
.drop-overlay {
position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--bg) 72%, transparent); backdrop-filter: blur(2px); pointer-events: none;
}
.drop-overlay.is-visible { display: flex; }
.drop-overlay-box {
display: flex; flex-direction: column; gap: 6px; align-items: center; text-align: center;
padding: 44px 64px; border: 2px dashed var(--accent); border-radius: 18px;
background: var(--accent-soft); color: var(--text);
}
.drop-overlay-box strong { font-size: 18px; color: var(--accent); }
.drop-overlay-box span { font-size: 13px; color: var(--text-dim); }
.drop-toast {
position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%); z-index: 1001;
max-width: min(90vw, 480px); padding: 8px 14px; border-radius: 10px; font-size: 13px;
background: var(--bg-elev-2); border: 1px solid var(--border-strong); color: var(--text);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); transition: opacity 0.4s;
}
.drop-toast.is-error { border-color: var(--danger); color: var(--danger); }
.drop-toast.is-leaving { opacity: 0; }
/* Drop target: the composer textarea itself */
body.drag-composer .drop-overlay { display: none; }
.composer-form { position: relative; transition: box-shadow 0.15s, border-color 0.15s; }
body.drag-composer .composer-form {
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
body.drag-composer #composer-input::placeholder { color: transparent; }
body.drag-composer .composer-form::after {
content: "Drop files here to attach"; position: absolute; left: 16px; top: 50%; transform: translateY(-50%);
color: var(--accent); font-size: 14px; pointer-events: none;
}
/* Subscription page: usage meter + plan polish */
.usage-card { margin: 18px 0 4px; padding: 12px 14px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--bg-elev); }
.usage-head { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); }
.usage-head strong { color: var(--text); }
.usage-bar { height: 8px; margin-top: 8px; border-radius: 99px; background: var(--bg-elev-2); overflow: hidden; }
.usage-fill { height: 100%; border-radius: 99px; background: var(--ok); transition: width 0.4s; }
.usage-near .usage-fill { background: var(--tok-num, #e5c07b); }
.usage-at .usage-fill { background: var(--danger); }
.usage-note { margin: 8px 0 0; font-size: 12.5px; color: var(--text-dim); }
.usage-at .usage-note { color: var(--danger); }
.plan-card { position: relative; }
.plan-card-popular:not(.plan-card-current) { border-color: var(--accent); }
.plan-badge-popular { background: var(--accent-2); color: #fff; }
.plan-perk { color: var(--text) !important; font-weight: 600; }
/* Logo artwork is dark linework: give it a light chip on dark auth surfaces */
:root:not([data-theme="light"]) .auth-card .brand-lockup .logo-mark { /*background: #f4f6fb; border-radius: 14px;*/ padding: 6px 12px; }
@media (prefers-color-scheme: light) { :root:not([data-theme]) .auth-card .brand-lockup .logo-mark { background: none; padding: 6px 10px; } }
@media (max-height: 820px) { .hero p { display: none; } .hero-mark { width: 46px; height: 46px; } }
/* Consecutive agent steps read as one stack, not separate messages */
.role-tool + .role-tool { margin-top: -15px; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,673 @@
/*
* enkly — app.js
*
* A small, dependency-free enhancement layer. It is served same-origin
* (the server's Content-Security-Policy is script-src 'self'), and none of
* it uses eval or inline handlers. Everything here is optional except the
* "clear the composer after sending" behaviour, which replaces what used to
* be an hx-on attribute (htmx evaluates hx-on with eval(), which the CSP
* blocks).
*/
(function () {
"use strict";
var chat = document.getElementById("chat-window");
var input = document.getElementById("composer-input");
/* ------------------------------------------------------------------
* Auto-scroll: follow the stream unless the user scrolled up to read.
* ------------------------------------------------------------------ */
var stickToBottom = true;
function nearBottom() {
return chat.scrollHeight - chat.scrollTop - chat.clientHeight < 120;
}
function scrollToBottom(smooth) {
chat.scrollTo({ top: chat.scrollHeight, behavior: smooth ? "smooth" : "auto" });
}
if (chat) {
scrollToBottom(false);
chat.addEventListener("scroll", function () { stickToBottom = nearBottom(); }, { passive: true });
new MutationObserver(function () {
if (stickToBottom) scrollToBottom(false);
}).observe(chat, { childList: true, subtree: true, characterData: true });
}
/* ------------------------------------------------------------------
* Composer: auto-resize, Enter to send, clear after a successful send.
* ------------------------------------------------------------------ */
function resizeInput() {
if (!input) return;
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 220) + "px";
}
// A send is valid once there's either typed text or at least one
// attachment that isn't still mid-upload (see "Composer attachments"
// below) — an attachment-only message is fine, same as Claude Code.
function canSend() {
if (input && input.value.trim() !== "") return true;
return attachmentsReadyCount() > 0;
}
if (input) {
input.addEventListener("input", resizeInput);
input.addEventListener("keydown", function (e) {
// Enter sends; Shift+Enter inserts a newline; ignore Enter while an
// IME composition (CJK input, etc.) is confirming a candidate.
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
e.preventDefault();
if (canSend() && input.form) input.form.requestSubmit();
}
});
input.focus();
// Covers the case where the browser restores a typed-but-unsent value
// on reload (bfcache / "preserve form on back"), so the box already
// matches its content's height instead of starting at one line.
resizeInput();
}
// Block the actual request (rather than just the Enter shortcut above)
// if an attachment is still uploading when Send is clicked directly, or
// let it through with nothing at all so the server's own "Type a
// message first." validation handles that case exactly as before.
document.body.addEventListener("htmx:beforeRequest", function (e) {
var elt = e.detail && e.detail.elt;
if (!elt || !elt.classList || !elt.classList.contains("composer-form")) return;
if (attachmentsPendingCount() > 0) {
e.preventDefault();
toast("Still uploading \u2014 one moment.", true);
}
});
// htmx fires this after the /chat request completes. Clear the box and
// re-enable following the stream (sending a message means "I'm here").
document.body.addEventListener("htmx:afterRequest", function (e) {
var elt = e.detail && e.detail.elt;
if (elt && elt.classList && elt.classList.contains("composer-form") && e.detail.successful) {
elt.reset();
resizeInput();
clearAttachments();
stickToBottom = true;
if (chat) scrollToBottom(true);
if (input) input.focus();
}
});
/* ------------------------------------------------------------------
* Copy button on code blocks (event delegation: works for blocks that
* arrive later over SSE).
* ------------------------------------------------------------------ */
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
} catch (err) {
reject(err);
} finally {
document.body.removeChild(ta);
}
});
}
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".copy-btn");
if (!btn) return;
var block = btn.closest(".code-block");
var code = block && block.querySelector("pre code");
if (!code) return;
copyText(code.innerText).then(function () {
btn.textContent = "Copied";
}, function () {
btn.textContent = "Press Ctrl+C";
}).then(function () {
setTimeout(function () { btn.textContent = "Copy"; }, 1500);
});
});
/* ------------------------------------------------------------------
* Snippet download cards: build the file in the browser from the code
* block the card refers to (the Nth .code-block in the same reply), so
* no server round-trip or temp file is needed. File cards are plain
* links and need no JS.
* ------------------------------------------------------------------ */
// SNIPPET-DOWNLOAD:BEGIN (scripts/test_snippet_download.mjs extracts this block)
function downloadSnippet(btn) {
var bubble = btn.closest(".bubble");
var blocks = bubble ? bubble.querySelectorAll(".code-block pre code") : [];
var idx = parseInt(btn.getAttribute("data-snippet-index"), 10);
var code = isNaN(idx) ? null : blocks[idx];
if (!code) return false;
// textContent (not innerText) so the file is exactly the source text,
// regardless of the highlight <span>s or CSS.
var blob = new Blob([code.textContent], { type: "text/plain;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = btn.getAttribute("data-filename") || "snippet.txt";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
return true;
}
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".snippet-download");
if (!btn) return;
var ok = downloadSnippet(btn);
var label = btn.getAttribute("data-label") || btn.textContent;
btn.setAttribute("data-label", label);
btn.textContent = ok ? "Downloaded" : "Unavailable";
setTimeout(function () { btn.textContent = label; }, 1500);
});
// SNIPPET-DOWNLOAD:END
/* ------------------------------------------------------------------
* Stop button: cancel via fetch instead of navigating away from the
* live stream. (Without JS the form still works — it POSTs and the
* server redirects back to the conversation.)
* ------------------------------------------------------------------ */
document.addEventListener("submit", function (e) {
var form = e.target;
if (!form.classList || !form.classList.contains("stop-form")) return;
e.preventDefault();
var btn = form.querySelector("button");
if (btn) btn.disabled = true;
fetch(form.action, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(new FormData(form)),
credentials: "same-origin",
}).catch(function () {
if (btn) btn.disabled = false;
});
});
/* ------------------------------------------------------------------
* Thinking indicator: time it, then collapse it once the reply starts.
*
* The card is rendered open by default (see chat_response.html) so
* reasoning is visible live, the same way it streams in before the
* answer on claude.ai — no JS needed for that part. What JS adds is
* purely cosmetic: once the reply actually begins, collapse the card
* into a "Thought for Ns" summary instead of leaving it expanded
* under the finished answer. htmx-ext-sse fires a bubbling
* "htmx:sseBeforeMessage" for every named SSE event it receives, with
* detail.type set to that event's name ("thinking", "token", "done",
* "tool") and evt.target set to whichever sse-swap element matched —
* we don't touch the actual content swap here, htmx-ext-sse already
* does that; this only reads the event to time things.
* ------------------------------------------------------------------ */
var thinkingStartedAt = new WeakMap();
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
var type = e.detail && e.detail.type;
if (type !== "thinking" && type !== "token" && type !== "done") return;
var row = e.target.closest && e.target.closest(".message-row");
var card = row && row.querySelector(".thinking-card");
if (!card || card.dataset.settled) return;
if (type === "thinking") {
if (!thinkingStartedAt.has(card)) thinkingStartedAt.set(card, Date.now());
return;
}
// First "token" or "done" seen for this row: settle the card once,
// whether or not it ever actually thought out loud.
card.dataset.settled = "1";
if (thinkingStartedAt.has(card)) {
var seconds = Math.max(1, Math.round((Date.now() - thinkingStartedAt.get(card)) / 1000));
var label = card.querySelector(".thinking-elapsed");
if (label) label.textContent = "\u00B7 Thought for " + seconds + "s";
card.open = false;
}
});
/* ------------------------------------------------------------------
* Composer attachments — "Add files or photos", strongly inspired by
* Claude Code's chat input: click the paperclip, drop a file anywhere
* on the page (dropping right on the message box highlights it, like
* claude.ai), or paste a screenshot, and it becomes a chip in the tray
* above the textarea. Each chip uploads in the background the moment
* it appears — one fetch per file, to POST /files/attach (see
* internal/handlers/attachments.go) — so sending the message is just
* the composer form's normal htmx POST /chat; the uploaded workspace
* path already rides along as a hidden input (see addHiddenInput
* below). Remove a chip before sending to drop it from the message;
* the file stays in the workspace (nothing currently reaps unattached
* uploads — a known, accepted gap, same spirit as the ones called out
* in ARCHITECTURE.md).
* ------------------------------------------------------------------ */
var tray = document.getElementById("composer-attachments");
var attachFileInput = document.getElementById("composer-file-input");
var attachBtn = document.getElementById("attach-btn");
var composerForm = input && input.closest("form");
var attachments = Object.create(null); // clientId -> { status, path, controller }
var attachSeq = 0;
var maxAttachments = 8; // mirrors maxChatAttachments server-side
function toast(msg, isError) {
var el = document.createElement("div");
el.className = "drop-toast" + (isError ? " is-error" : "");
el.setAttribute("role", "status");
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.classList.add("is-leaving"); }, 3200);
setTimeout(function () { el.remove(); }, 3700);
}
function attachmentsReadyCount() {
var n = 0;
for (var id in attachments) if (attachments[id].status === "done") n++;
return n;
}
function attachmentsPendingCount() {
var n = 0;
for (var id in attachments) if (attachments[id].status === "uploading") n++;
return n;
}
function attachmentsCount() {
return Object.keys(attachments).length;
}
function fileIconSVG() {
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>' +
'<path d="M14 3v5h5"/></svg>';
}
// Shown for a .zip while it's uploading/extracting, and again on the
// sent message's chip (see attachments.html) once the server confirms
// it really was one — see isZipName / markChipAsArchive below.
function archiveIconSVG() {
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
}
function isZipName(name) {
return /\.zip$/i.test(name || "");
}
// Plain "path" for a regular file; "path|N" for an extracted zip's
// folder, so cleanAttachmentPaths (internal/handlers/attachments.go)
// can tell the two apart when the composer form is submitted.
function addHiddenInput(id, path, entryCount) {
var el = document.createElement("input");
el.type = "hidden";
el.name = "attachments";
el.value = entryCount ? path + "|" + entryCount : path;
el.dataset.attachId = id;
composerForm.appendChild(el);
}
// Swaps a settled chip's icon for the folder glyph and appends the
// "N files" count once /files/attach confirms the zip was extracted.
function markChipAsArchive(id, entryCount) {
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (!chip) return;
chip.classList.add("is-archive");
var preview = chip.querySelector(".attach-chip-preview");
if (preview) preview.innerHTML = archiveIconSVG();
var name = chip.querySelector(".attach-chip-name");
if (name) name.textContent = name.textContent + " \u00B7 " + entryCount + (entryCount === 1 ? " file" : " files");
}
function removeAttachment(id) {
var rec = attachments[id];
if (!rec) return;
if (rec.controller) rec.controller.abort();
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (chip) chip.remove();
var hidden = composerForm && composerForm.querySelector('input[data-attach-id="' + id + '"]');
if (hidden) hidden.remove();
delete attachments[id];
}
function clearAttachments() {
for (var id in attachments) removeAttachment(id);
tray.innerHTML = "";
}
function settleChip(id, ok, label) {
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (!chip) return;
chip.classList.remove("is-uploading");
chip.classList.toggle("is-error", !ok);
if (!ok) chip.title = label || "Upload failed \u2014 click to remove";
}
function renderChip(id, file) {
var chip = document.createElement("div");
chip.className = "attach-chip is-uploading";
chip.dataset.attachId = id;
var preview = document.createElement("div");
preview.className = "attach-chip-preview";
if (file.type && file.type.indexOf("image/") === 0 && typeof FileReader === "function") {
// A data: URL, not blob: — the page's img-src CSP (see
// internal/handlers/middleware.go) allows 'self' and data: only, so
// this is the one local-preview mechanism that actually renders.
var reader = new FileReader();
reader.onload = function () {
if (!attachments[id]) return; // chip was removed before this fired
var img = document.createElement("img");
img.src = reader.result;
img.alt = "";
preview.insertBefore(img, preview.firstChild);
};
reader.readAsDataURL(file);
} else if (isZipName(file.name)) {
preview.innerHTML = archiveIconSVG();
} else {
preview.innerHTML = fileIconSVG();
}
var spinner = document.createElement("span");
spinner.className = "attach-chip-spinner";
spinner.setAttribute("aria-hidden", "true");
preview.appendChild(spinner);
var name = document.createElement("span");
name.className = "attach-chip-name";
name.textContent = file.name;
var remove = document.createElement("button");
remove.type = "button";
remove.className = "attach-chip-remove";
remove.setAttribute("aria-label", "Remove " + file.name);
remove.textContent = "\u00D7";
remove.addEventListener("click", function (ev) {
ev.preventDefault();
removeAttachment(id);
});
chip.appendChild(preview);
chip.appendChild(name);
chip.appendChild(remove);
tray.appendChild(chip);
}
function uploadAttachment(id, file) {
var rec = attachments[id];
var controller = typeof AbortController === "function" ? new AbortController() : null;
if (controller) rec.controller = controller;
var body = new FormData();
body.append("csrf_token", composerForm.querySelector('input[name="csrf_token"]').value);
body.append("conversation_id", composerForm.querySelector('input[name="conversation_id"]').value);
body.append("files", file);
fetch("/files/attach", {
method: "POST",
body: body,
credentials: "same-origin",
signal: controller ? controller.signal : undefined,
})
.then(function (res) {
return res.json().then(function (data) { return { ok: res.ok, data: data }; });
})
.then(function (result) {
if (!attachments[id]) return; // removed while in flight
if (!result.ok) {
rec.status = "error";
settleChip(id, false, (result.data && result.data.error) || "Upload failed");
toast((result.data && result.data.error) || ("Couldn\u2019t attach " + file.name + "."), true);
return;
}
rec.status = "done";
rec.path = result.data.path;
addHiddenInput(id, result.data.path, result.data.entryCount);
if (result.data.isArchive) markChipAsArchive(id, result.data.entryCount);
settleChip(id, true);
})
.catch(function (err) {
if (!attachments[id] || (err && err.name === "AbortError")) return;
rec.status = "error";
settleChip(id, false, "Upload failed");
toast("Couldn\u2019t attach " + file.name + ".", true);
});
}
function stageFiles(fileList) {
if (!tray || !composerForm) return;
var files = Array.prototype.slice.call(fileList || []);
if (!files.length) return;
var room = maxAttachments - attachmentsCount();
if (room <= 0) {
toast("Up to " + maxAttachments + " files per message.", true);
return;
}
if (files.length > room) {
toast("Only the first " + room + " file" + (room === 1 ? "" : "s") + " were added (limit " + maxAttachments + ").", true);
files = files.slice(0, room);
}
files.forEach(function (file) {
var id = "a" + (++attachSeq);
attachments[id] = { status: "uploading" };
renderChip(id, file);
uploadAttachment(id, file);
});
}
if (attachBtn && attachFileInput) {
attachBtn.addEventListener("click", function () { attachFileInput.click(); });
attachFileInput.addEventListener("change", function () {
stageFiles(attachFileInput.files);
attachFileInput.value = ""; // allow re-picking the same file later
});
}
/* ---- Drag & drop (anywhere on the page) + paste ---- */
var overlay = null;
var dragDepth = 0;
function overComposer(e) {
return !!(composerForm && e.target && composerForm.contains(e.target));
}
function hasFiles(e) {
var t = e.dataTransfer && e.dataTransfer.types;
if (!t) return false;
for (var i = 0; i < t.length; i++) if (t[i] === "Files") return true;
return false;
}
function showOverlay(on) {
if (!overlay) {
overlay = document.createElement("div");
overlay.className = "drop-overlay";
overlay.setAttribute("aria-hidden", "true");
overlay.innerHTML = '<div class="drop-overlay-box"><strong>Drop files to attach</strong>' +
"<span>They\u2019ll be added to your next message</span></div>";
document.body.appendChild(overlay);
}
overlay.classList.toggle("is-visible", on);
}
document.addEventListener("dragenter", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth++;
showOverlay(true);
});
document.addEventListener("dragover", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
// Hovering the message box: highlight it instead of the page overlay.
document.body.classList.toggle("drag-composer", overComposer(e));
});
document.addEventListener("dragleave", function (e) {
if (!hasFiles(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
showOverlay(false);
document.body.classList.remove("drag-composer");
}
});
document.addEventListener("drop", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth = 0;
showOverlay(false);
document.body.classList.remove("drag-composer");
if (e.dataTransfer.files && e.dataTransfer.files.length) stageFiles(e.dataTransfer.files);
});
// Pasting a file (e.g. a screenshot) into the message box attaches it too.
if (input) {
input.addEventListener("paste", function (e) {
var files = e.clipboardData && e.clipboardData.files;
if (!files || !files.length) return;
e.preventDefault();
stageFiles(files);
});
}
/* ------------------------------------------------------------------
* Send-button state: dim "Send" while there is nothing to send. Purely
* visual (data-empty on the form); the server still validates.
* ------------------------------------------------------------------ */
function syncSendState() {
if (composerForm) composerForm.setAttribute("data-empty", canSend() ? "false" : "true");
}
if (composerForm && input) {
input.addEventListener("input", syncSendState);
new MutationObserver(syncSendState).observe(composerForm, { childList: true });
if (tray) new MutationObserver(syncSendState).observe(tray, { childList: true });
document.body.addEventListener("htmx:afterRequest", function () { setTimeout(syncSendState, 0); });
syncSendState();
}
/* ------------------------------------------------------------------
* Empty-state starters and "More to explore" chips. A chip with
* data-send="true" is self-contained and submits straight away; the
* rest drop their text into the composer for the person to finish.
* ------------------------------------------------------------------ */
document.addEventListener("click", function (e) {
var el = e.target.closest && e.target.closest("[data-prompt]");
if (!el || !input) return;
input.value = el.getAttribute("data-prompt");
resizeInput();
syncSendState();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
if (el.getAttribute("data-send") === "true" && composerForm) composerForm.requestSubmit();
});
/* ------------------------------------------------------------------
* Command palette (Cmd/Ctrl+K or the top-bar search): jump to a
* conversation or run a quick action. Everything it lists is read from
* the sidebar DOM, so there is no new endpoint and it works offline.
* ------------------------------------------------------------------ */
(function palette() {
var trigger = document.getElementById("cmd-trigger");
var overlay, field, list, items = [], selected = 0;
function collect() {
var out = [];
var links = document.querySelectorAll(".conversation-link");
for (var i = 0; i < links.length; i++) {
out.push({ group: "Conversations", title: links[i].textContent.trim(), href: links[i].getAttribute("href"), glyph: "--i-file" });
}
out.push({ group: "Actions", title: "New chat", hint: "Start fresh", glyph: "--i-spark", run: function () {
var f = document.querySelector(".new-chat-form"); if (f) f.submit();
} });
out.push({ group: "Actions", title: "Manage subscription", href: "/subscription", glyph: "--i-cog" });
out.push({ group: "Actions", title: "Attach files", hint: "Add to next message", glyph: "--i-folder", run: function () {
if (attachBtn) attachBtn.click();
} });
return out;
}
function build() {
overlay = document.createElement("div");
overlay.className = "palette-overlay";
overlay.innerHTML =
'<div class="palette" role="dialog" aria-modal="true" aria-label="Search">' +
'<div class="palette-input-row"><span class="glyph" aria-hidden="true"></span>' +
'<input class="palette-input" type="text" placeholder="Search chats and actions\u2026" aria-label="Search" autocomplete="off" spellcheck="false" /></div>' +
'<div class="palette-list" role="listbox"></div>' +
'<div class="palette-foot"><span><kbd>\u2191</kbd> <kbd>\u2193</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Esc</kbd> close</span></div></div>';
document.body.appendChild(overlay);
field = overlay.querySelector(".palette-input");
list = overlay.querySelector(".palette-list");
field.addEventListener("input", function () { selected = 0; render(); });
field.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); choose(selected); }
});
overlay.addEventListener("mousedown", function (e) { if (e.target === overlay) close(); });
list.addEventListener("click", function (e) {
var row = e.target.closest(".palette-item");
if (row) choose(parseInt(row.getAttribute("data-i"), 10));
});
}
var shown = [];
function render() {
var q = field.value.trim().toLowerCase();
shown = collect().filter(function (it) { return !q || it.title.toLowerCase().indexOf(q) !== -1; });
if (!shown.length) { list.innerHTML = '<div class="palette-empty">No matches</div>'; return; }
var html = "", last = "";
shown.forEach(function (it, i) {
if (it.group !== last) { html += '<div class="palette-group"></div>'; last = it.group; }
html += '<div class="palette-item' + (i === selected ? " is-selected" : "") + '" role="option" data-i="' + i + '">' +
'<span class="glyph" style="--glyph: var(' + it.glyph + ')"></span>' +
'<span class="palette-item-title"></span>' + (it.hint ? '<span class="palette-item-hint"></span>' : "") + "</div>";
});
list.innerHTML = html;
// Fill text via textContent so conversation titles can never inject markup.
var groups = list.querySelectorAll(".palette-group"), g = 0; last = "";
var rows = list.querySelectorAll(".palette-item");
shown.forEach(function (it, i) {
if (it.group !== last) { groups[g++].textContent = it.group; last = it.group; }
rows[i].querySelector(".palette-item-title").textContent = it.title;
var h = rows[i].querySelector(".palette-item-hint"); if (h) h.textContent = it.hint;
});
}
function move(d) {
if (!shown.length) return;
selected = (selected + d + shown.length) % shown.length;
render();
var el = list.querySelector(".is-selected"); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
}
function choose(i) {
var it = shown[i]; if (!it) return;
close();
if (it.run) it.run(); else if (it.href) window.location.href = it.href;
}
function open() {
if (!overlay) build();
overlay.classList.add("is-open");
field.value = ""; selected = 0; render(); field.focus();
}
function close() {
if (overlay) overlay.classList.remove("is-open");
if (input) input.focus();
}
if (trigger) trigger.addEventListener("click", open);
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
overlay && overlay.classList.contains("is-open") ? close() : open();
} else if (e.key === "Escape" && overlay && overlay.classList.contains("is-open")) {
close();
}
});
})();
})();

View File

@@ -0,0 +1,54 @@
// Show/hide ("eye") toggle for every <input type="password">, wrapped
// in a .password-field div in web/templates/auth.html (login, signup,
// reset_password — the only pages with password fields). Framework-free
// and loaded as an external file on purpose: the page's CSP is
// script-src 'self' only, so no inline <script> or onclick="" handlers.
(function () {
function eyeIcon() {
// Open eye — shown while the field is masked (click to reveal).
return (
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M1.5 12S5 5 12 5s10.5 7 10.5 7-3.5 7-10.5 7S1.5 12 1.5 12Z"/>' +
'<circle cx="12" cy="12" r="3"/>' +
"</svg>"
);
}
function eyeOffIcon() {
// Crossed-out eye — shown while the field is revealed (click to mask).
return (
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19.5C5 19.5 1.5 12.5 1.5 12.5a19 19 0 0 1 4.22-5.06M9.9 4.74A10.6 10.6 0 0 1 12 4.5c7 0 10.5 7 10.5 7a18.9 18.9 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/>' +
'<path d="M1 1l22 22"/>' +
"</svg>"
);
}
function addToggle(field) {
var input = field.querySelector('input[type="password"], input[type="text"]');
if (!input) return;
var btn = document.createElement("button");
btn.type = "button";
btn.className = "password-toggle";
btn.setAttribute("aria-label", "Show password");
btn.setAttribute("aria-pressed", "false");
btn.innerHTML = eyeIcon();
btn.addEventListener("click", function () {
var revealed = input.type === "text";
input.type = revealed ? "password" : "text";
btn.setAttribute("aria-pressed", String(!revealed));
btn.setAttribute("aria-label", revealed ? "Show password" : "Hide password");
btn.innerHTML = revealed ? eyeIcon() : eyeOffIcon();
// Keep focus (and the caret position) in the field itself, not the button.
input.focus({ preventScroll: true });
});
field.appendChild(btn);
}
document.querySelectorAll(".password-field").forEach(addToggle);
})();

View File

@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
{{/*
The small gallery of files attached to a user message (see
internal/handlers/attachments.go's renderUserMessage, which appends
this after the escaped prompt text — the same trick
deliverables.go uses to append download cards to an assistant
reply). Data: []attachmentChipView. An archive chip (IsArchive) has
no DownloadURL — it's a folder of extracted files, not one file —
so it renders as a plain <span>, not a link.
*/}}
{{ define "message_attachments" }}
<div class="msg-attachments">
{{ range . }}
{{ if .IsArchive }}
<span class="msg-attachment is-archive" title="{{ .Name }}">
<span class="msg-attachment-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
</span>
<span class="msg-attachment-name">{{ .Name }} &middot; {{ .EntryCount }} file{{ if ne .EntryCount 1 }}s{{ end }}</span>
</span>
{{ else }}
<a class="msg-attachment" href="{{ .DownloadURL }}" download title="{{ .Name }}">
{{ if .IsImage }}
<img class="msg-attachment-thumb" src="{{ .DownloadURL }}" alt="{{ .Name }}" loading="lazy" />
{{ else }}
<span class="msg-attachment-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>
</span>
{{ end }}
<span class="msg-attachment-name">{{ .Name }}</span>
</a>
{{ end }}
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,245 @@
{{ define "login" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.login_title" }} — Enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/login") }}
<h2>{{ t .Lang "auth.login_title" }}</h2>
<p class="auth-subtitle">Welcome back.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="email">{{ t .Lang "auth.email_label" }}</label>
<input id="email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required autofocus />
</div>
<div class="auth-field">
<label for="password">{{ t .Lang "auth.password_label" }}</label>
<div class="password-field">
<input id="password" type="password" name="password" autocomplete="current-password" required />
</div>
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.login_button" }}</button>
</form>
<div class="auth-footer"><a href="/forgot-password">{{ t .Lang "auth.forgot_link" }}</a></div>
<div class="auth-footer">{{ t .Lang "auth.no_account" }} <a href="/signup">{{ t .Lang "auth.signup_link" }}</a></div>
</div>
</div>
<script src="/static/js/password-toggle.js" defer></script>
</body>
</html>
{{ end }}
{{ define "signup" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<title>{{ t .Lang "auth.signup_title" }} — Enkly</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/signup") }}
<h2>{{ t .Lang "auth.signup_title" }}</h2>
<p class="auth-subtitle">We'll email you a 6-digit code to verify it.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/signup">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="email">{{ t .Lang "auth.email_label" }}</label>
<input id="email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required autofocus />
</div>
<div class="auth-field">
<label for="password">{{ t .Lang "auth.password_label" }}</label>
<div class="password-field">
<input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required />
</div>
</div>
<div class="auth-field">
<label for="password_confirm">{{ t .Lang "auth.confirm_password_label" }}</label>
<div class="password-field">
<input id="password_confirm" type="password" name="password_confirm" autocomplete="new-password" minlength="8" required />
</div>
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.signup_button" }}</button>
</form>
<div class="auth-footer">{{ t .Lang "auth.have_account" }} <a href="/login">{{ t .Lang "auth.login_link" }}</a></div>
</div>
</div>
<script src="/static/js/password-toggle.js" defer></script>
</body>
</html>
{{ end }}
{{ define "verify_email" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.verify_title" }} — enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/verify-email") }}
<h2>{{ t .Lang "auth.verify_title" }}</h2>
<p class="auth-subtitle">Enter the 6-digit code we emailed you.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/verify-email">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="verify-email-address">{{ t .Lang "auth.email_label" }}</label>
<input id="verify-email-address" type="email" name="email" value="{{ .Email }}" autocomplete="email" required />
</div>
<div class="auth-field">
<label for="verify-code">{{ t .Lang "auth.verify_code_label" }}</label>
<input id="verify-code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
autocomplete="one-time-code" placeholder="123456" required />
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.verify_button" }}</button>
</form>
<form class="auth-form auth-inline-form" method="post" action="/resend-verification">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="resend-email">Didn't get a code?</label>
<input id="resend-email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required />
</div>
<button class="auth-btn auth-btn-secondary" type="submit">{{ t .Lang "auth.resend_link" }}</button>
</form>
<div class="auth-footer">
Already verified? <a href="/login">{{ t .Lang "auth.login_link" }}</a>
</div>
</div>
</div>
</body>
</html>
{{ end }}
{{ define "forgot_password" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.forgot_title" }} — Enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/forgot-password") }}
<h2>{{ t .Lang "auth.forgot_title" }}</h2>
<p class="auth-subtitle">Enter your email and we'll send you a reset link.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form auth-inline-form" method="post" action="/forgot-password">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="forgot-email">{{ t .Lang "auth.email_label" }}</label>
<input id="forgot-email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required autofocus />
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.forgot_button" }}</button>
</form>
<div class="auth-footer"><a href="/login">{{ t .Lang "auth.back_to_login" }}</a></div>
</div>
</div>
</body>
</html>
{{ end }}
{{ define "reset_password" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.reset_title" }} — enkly</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/reset-password") }}
<h2>{{ t .Lang "auth.reset_title" }}</h2>
<p class="auth-subtitle">Choose a new password for your account.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/reset-password">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="token" value="{{ .Token }}" />
<div class="auth-field">
<label for="password">New password</label>
<div class="password-field">
<input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required autofocus />
</div>
</div>
<div class="auth-field">
<label for="password_confirm">{{ t .Lang "auth.confirm_password_label" }}</label>
<div class="password-field">
<input id="password_confirm" type="password" name="password_confirm" autocomplete="new-password" minlength="8" required />
</div>
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.reset_button" }}</button>
</form>
<div class="auth-footer"><a href="/login">{{ t .Lang "auth.back_to_login" }}</a></div>
</div>
</div>
<script src="/static/js/password-toggle.js" defer></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,115 @@
{{ define "chat_response" }}
<!-- The user's message, appended immediately (no streaming needed). -->
<div class="message-row role-user">
<div class="message-content">
<div class="bubble">{{ .UserPromptHTML }}</div>
</div>
</div>
{{ template "assistant_stream" . }}
{{ end }}
{{/*
The assistant's reply streams in over Server-Sent Events using the htmx
SSE extension (htmx-ext-sse). Shared by "chat_response" (a reply that just
started) and "index" (re-attaching to a run that is still in flight after a
page reload). Data: any value with .StreamID and .CSRFToken.
Event flow (see internal/handlers/chat.go StreamChat):
"tool" -> a fragment wrapped in hx-swap-oob="beforebegin:#assistant-row-<id>"
so each tool-call card is inserted ABOVE this row, in the order
the agent ran them, while the bubble below keeps streaming.
"deliverable" -> a Download-button card for a file a tool call just created
or changed (create_document, edit_spreadsheet, ...), sent right
after that tool call's "tool" event rather than waiting for
"done" — same beforebegin OOB placement, so the button shows up
as soon as the document exists even if the agent keeps working
afterwards. See handlers.liveDeliverable / message.html's
"deliverable_oob".
"thinking_round" -> same OOB-insertion mechanism as "tool", one per
completed round's own reasoning trace (see
agentrun.Callbacks.OnThinkingRound) — gives each round's
thinking its own card, positioned right before the tool
card it led to, instead of only ever being visible as
the single live "thinking" blob below.
"thinking" -> the model's reasoning so far, shown live in an
auto-expanded card (open by default — see .thinking-card
below) so it's visible AS the model thinks, the same way
it streams before the reply on claude.ai. app.js times
how long it stayed open and, on the first "token"/"done"
it sees afterwards, collapses the card to a "Thought for
Ns" summary — a pure enhancement: with JS disabled the
card just stays open and unlabeled, still fully readable.
"token" -> the reply so far (HTML-escaped), swapped into the bubble.
"done" -> the final Markdown-rendered reply. sse-close="done" closes the
EventSource; the trailing .stream-done marker hides Stop and
(via CSS :has()) stops the thinking icon's pulse animation.
*/}}
{{ define "assistant_stream" }}
<div
class="message-row role-assistant"
id="assistant-row-{{ .StreamID }}"
hx-ext="sse"
sse-connect="/chat/stream/{{ .StreamID }}"
sse-close="done"
data-stream-id="{{ .StreamID }}"
>
<span class="avatar avatar-assistant" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 21c1.2-2.5 1.5-5.5 1.3-8.5-.5-1.2-1.2-2.4-2.1-3.5C3.5 9 2 7.8 2 6.2 2 4.6 3.5 3.5 5.2 3.5c.8 0 1.5.3 2.1.8C8.5 3 10.1 2.2 12 2.2c1.9 0 3.5.8 4.7 2.1.6-.5 1.3-.8 2.1-.8 1.7 0 3.2 1.1 3.2 2.7 0 1.6-1.5 2.8-3.2 2.8-.9 1.1-1.6 2.3-2.1 3.5-.2 3 .1 6 1.3 8.5H6Zm4-12c.5 1.8 1.2 3.5 2 3.5s1.5-1.7 2-3.5h-4Z"/>
</svg>
</span>
<div class="message-content">
<div class="message-meta">
<span class="meta-name">enkly</span>
<form method="post" action="/chat/stream/{{ .StreamID }}/stop" class="stop-form" data-role="stop-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button type="submit" class="stop-btn" aria-label="Stop generating">
<span class="stop-icon" aria-hidden="true"></span> Stop
</button>
</form>
</div>
<details class="thinking-card" open>
<summary>
<span class="thinking-icon" aria-hidden="true"></span>
<span class="thinking-label">Thinking</span>
<span class="thinking-elapsed"></span>
</summary>
<div class="thinking-body" sse-swap="thinking" hx-swap="innerHTML"></div>
</details>
<div
class="bubble"
id="assistant-bubble-{{ .StreamID }}"
sse-swap="token,done"
hx-swap="innerHTML"
>
<span class="typing-dots" aria-label="enkly is responding"><span></span><span></span><span></span></span>
</div>
</div>
<!--
"tool" events carry a wrapper with hx-swap-oob (see message.html
"tool_call_oob"). This listener element's own swap is "none" — htmx still
extracts and places the out-of-band content.
-->
<div sse-swap="tool" hx-swap="none" hidden></div>
<!--
"deliverable" events carry a wrapper with hx-swap-oob (see
deliverables.html "deliverable_oob"), same beforebegin placement as
"tool" above. This listener element's own swap is "none" — htmx still
extracts and places the out-of-band content.
-->
<div sse-swap="deliverable" hx-swap="none" hidden></div>
<!--
"thinking_round" events — one per agent round's own standalone
reasoning trace, each its own OOB card (see message.html
"thinking_round_oob") inserted above this row in execution order,
same mechanism as "tool" above. This is in addition to, not a
replacement for, the single live-updating .thinking-card above:
that one shows reasoning AS it streams in for the round currently
in flight; these are the settled, per-step record of every round
once it's done, interleaved with the tool cards the same way
Claude's interleaved thinking blocks sit next to the tool use they
preceded.
-->
<div sse-swap="thinking_round" hx-swap="none" hidden></div>
</div>
{{ end }}

View File

@@ -0,0 +1,43 @@
{{/*
Download cards shown at the end of an agent reply (see
internal/handlers/deliverables.go). Data: []deliverableView.
A card with DownloadURL is a real link to a workspace file; one without
is a code-snippet card whose button is handled by app.js.
*/}}
{{ define "deliverables" }}
<div class="deliverables">
{{ range . }}
<div class="deliverable-card">
<div class="deliverable-tile" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h4"/></svg>
</div>
<div class="deliverable-meta">
<div class="deliverable-name" title="{{ .Name }}">{{ .Name }}</div>
<div class="deliverable-type">{{ .TypeLabel }}{{ if .Detail }} · {{ .Detail }}{{ end }}</div>
</div>
{{ if .DownloadURL }}
<a class="deliverable-btn" href="{{ .DownloadURL }}" download>Download</a>
{{ else }}
<button type="button" class="deliverable-btn snippet-download" data-snippet-index="{{ .SnippetIndex }}" data-filename="{{ .Name }}">Download</button>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{/*
The SSE "deliverable" payload — one produced file's download card,
sent as soon as the tool call that created or changed it completes
(see internal/handlers/chat.go's TurnEvent_ToolCall case and
liveDeliverable), not only once the whole turn finishes. Same
hx-swap-oob mechanism as message.html's "tool_call_oob": "beforebegin"
on the assistant row puts the card above the still-streaming bubble,
right after the tool card that produced it. Data: StreamID, Views
([]deliverableView — always length 1 here, but "deliverables" above
already ranges over a slice, so it's reused as-is).
*/}}
{{ define "deliverable_oob" }}
<div hx-swap-oob="beforebegin:#assistant-row-{{ .StreamID }}">
{{ template "deliverables" .Views }}
</div>
{{ end }}

View File

@@ -0,0 +1,81 @@
{{/*
The "Workspace files" sidebar panel: an upload form plus a listing of
every file currently in the agent's ENKLY_WORKSPACE, each linking to
GET /files/download/{path...}. Rendered three ways, all with the same
data shape (WorkspaceFiles, ConversationID, CSRFToken, UploadMessage,
UploadError — see indexData in pages.go and filesPanelData in
files.go):
1. Inline, as part of the full "index" page on first load.
2. Standalone, by GET /files — a manual "refresh the list" htmx
fragment (e.g. after the agent's own write_file tool created a
new file mid-conversation).
3. Standalone, by POST /files/upload — after saving what was
submitted, with UploadMessage/UploadError set.
Cases 2 and 3 both target and swap this element with
hx-swap="outerHTML", so the response IS the whole panel, including its
own wrapping <div id="workspace-files-panel">.
*/}}
{{ define "workspace_files_panel" }}
<!--<div class="sidebar-section workspace-section" id="workspace-files-panel">
<div class="sidebar-section-title">
<span>Workspace files</span>
<span class="files-title-right">
{{ len .WorkspaceFiles }}
<button
type="button"
class="icon-btn"
title="Refresh"
aria-label="Refresh workspace files"
hx-get="/files?conversation_id={{ .ConversationID }}"
hx-target="#workspace-files-panel"
hx-swap="outerHTML"
>⟳</button>
</span>
</div>
{{ if .UploadError }}<div class="upload-note upload-note-error">{{ .UploadError }}</div>{{ end }}
{{ if .UploadMessage }}<div class="upload-note upload-note-ok">{{ .UploadMessage }}</div>{{ end }}
<form
method="post"
action="/files/upload"
enctype="multipart/form-data"
class="upload-form"
hx-post="/files/upload"
hx-encoding="multipart/form-data"
hx-target="#workspace-files-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="conversation_id" value="{{ .ConversationID }}" />
<input
type="file"
name="files"
id="workspace-upload-input"
class="upload-input"
multiple
required
aria-label="Choose files to upload into the workspace"
/>
<button type="submit" class="upload-btn" title="Upload into the workspace">Upload</button>
</form>
<div class="file-list workspace-file-list">
{{ if .WorkspaceFiles }}
{{ range .WorkspaceFiles }}
<div class="file-item workspace-file-item">
<span class="file-icon">›</span>
<a href="{{ .DownloadURL }}" class="file-download-link" title="Download {{ .Path }}" download>{{ .Path }}</a>
<span class="file-size">{{ .SizeHuman }}</span>
</div>
{{ end }}
{{ else }}
<div class="file-item file-item-empty">
No files in the workspace yet. Upload one, or ask enkly to create one.
</div>
{{ end }}
</div>
</div>-->
{{ end }}

View File

@@ -0,0 +1,268 @@
{{ define "index" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}"{{ if .Theme }} data-theme="{{ .Theme }}"{{ end }}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .ConversationTitle }} — Enkly</title>
<!--
allowEval:false — htmx otherwise uses eval()/new Function() for hx-on:*
handlers and trigger filters, which the server's Content-Security-Policy
(script-src 'self', no 'unsafe-eval') would block, silently disabling
them. Nothing on this page needs eval. includeIndicatorStyles:false stops
htmx injecting an inline <style> block.
-->
<meta name="htmx-config" content='{"allowEval":false,"includeIndicatorStyles":false}' />
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
<!--
Vendored locally (web/static/js/vendor/, from npm htmx.org@2.0.4 and
htmx-ext-sse@2.2.2) rather than loaded from a CDN. This is
self-hosted software: a script tag pointed at unpkg.com silently
fails with zero on-page indication whenever the host has no
outbound internet access (common for self-hosted/air-gapped
deployments) or that CDN is blocked/slow/down — and when it fails,
every hx-* attribute in this page becomes an inert, do-nothing
HTML attribute, which is exactly why clicking "Send" could
previously appear to do nothing at all. Vendoring removes the
runtime dependency on any third-party host entirely.
-->
<script src="/static/js/vendor/htmx.min.js"></script>
<script src="/static/js/vendor/htmx-sse.js"></script>
</head>
<body>
<!--
CSS-only mobile sidebar toggle: a hidden checkbox plus a <label>
acting as the hamburger button. No JavaScript is needed for this —
see .sidebar-toggle / .sidebar-backdrop in style.css.
-->
<input type="checkbox" id="sidebar-toggle" class="sidebar-toggle-input" hidden />
<div class="app-shell" hx-ext="sse">
<label for="sidebar-toggle" class="sidebar-backdrop"></label>
<!-- ============================= SIDEBAR ============================= -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" width="40" height="40" />
</div>
<label for="sidebar-toggle" class="sidebar-close" aria-label="{{ t .Lang "nav.close_sidebar" }}">×</label>
</div>
<form method="post" action="/conversations/new" class="new-chat-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button type="submit" class="new-chat-btn">
<span class="plus-icon" aria-hidden="true">+</span> {{ t .Lang "nav.new_chat" }}
</button>
</form>
<div class="sidebar-section conversation-section">
<div class="sidebar-section-title"><span>{{ t .Lang "nav.conversations" }}</span></div>
<div class="conversation-list">
{{ if .Conversations }}
{{ range .Conversations }}
<div class="conversation-item {{ if eq .ID $.ConversationID }}active{{ end }}">
<a href="/c/{{ .ID }}" class="conversation-link">{{ .Title }}</a>
<div class="conversation-actions">
<form method="post" action="/conversations/{{ .ID }}/rename" class="rename-form">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}" />
<input type="text" name="title" value="{{ .Title }}" class="rename-input" aria-label="Rename conversation" />
<button type="submit" class="icon-btn" aria-label="{{ t $.Lang "nav.rename_save" }}" title="{{ t $.Lang "nav.rename_save" }}">✓</button>
</form>
<form method="post" action="/conversations/{{ .ID }}/delete" class="delete-form">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}" />
<button type="submit" class="icon-btn icon-btn-danger" aria-label="{{ t $.Lang "nav.delete" }}" title="{{ t $.Lang "nav.delete" }}">×</button>
</form>
</div>
</div>
{{ end }}
{{ else }}
<div class="conversation-item-empty">{{ t .Lang "nav.no_conversations" }}</div>
{{ end }}
</div>
</div>
<!--<div class="sidebar-section files-section">
<div class="sidebar-section-title">
<span>Indexed files</span>
<span>{{ len .Files }}</span>
</div>
<div class="file-list">
{{ if .Files }}
{{ range .Files }}
<div class="file-item">
<span class="file-icon">›</span>
<span>{{ . }}</span>
</div>
{{ end }}
{{ else }}
<div class="file-item file-item-empty">
No files indexed yet. Run the indexer to populate pgvector.
</div>
{{ end }}
</div>
</div>-->
{{ template "explorer_panel" . }}
{{ template "workspace_files_panel" . }}
<div class="sidebar-footer">
<a href="/subscription" class="plan-chip" title="Manage your subscription">
<span class="plan-dot"></span>
<span>{{ .PlanName }} {{ t .Lang "nav.plan_suffix" }}</span>
<span class="plan-chip-manage">{{ t .Lang "nav.manage" }}</span>
</a>
<div class="user-chip">
<span class="user-avatar" aria-hidden="true">{{ if .UserEmail }}{{ slice .UserEmail 0 1 }}{{ end }}</span>
<span class="user-email" title="{{ .UserEmail }}">{{ .UserEmail }}</span>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button class="logout-btn" type="submit">{{ t .Lang "nav.log_out" }}</button>
</form>
</div>
<form method="post" action="/theme" class="theme-toggle" aria-label="Color theme">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="next" value="/c/{{ .ConversationID }}" />
<button type="submit" name="theme" value="light" class="theme-btn{{ if eq .Theme "light" }} is-active{{ end }}" title="Light theme">{{ t .Lang "nav.theme_light" }}</button>
<button type="submit" name="theme" value="dark" class="theme-btn{{ if eq .Theme "dark" }} is-active{{ end }}" title="Dark theme">{{ t .Lang "nav.theme_dark" }}</button>
<button type="submit" name="theme" value="auto" class="theme-btn{{ if not .Theme }} is-active{{ end }}" title="Follow the system setting">{{ t .Lang "nav.theme_auto" }}</button>
</form>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" (print "/c/" .ConversationID)) }}
<span class="model-tag">local agent · tools enabled</span>
</div>
</aside>
<!-- ============================ MAIN COLUMN =========================== -->
<main class="main-col">
<div class="chat-topbar">
<label for="sidebar-toggle" class="sidebar-open" aria-label="{{ t .Lang "nav.open_sidebar" }}">☰</label>
<h2>{{ .ConversationTitle }}</h2>
<!-- Opens the command palette (app.js): search chats, jump to actions. -->
<button type="button" class="cmd-trigger" id="cmd-trigger" aria-haspopup="dialog" aria-label="Search conversations and actions">
<span class="glyph" aria-hidden="true"></span>
<span class="cmd-trigger-label">{{ t .Lang "nav.search_placeholder" }}</span>
<kbd>⌘K</kbd>
</button>
<span class="model-badge">{{ t .Lang "nav.agent_ready" }}</span>
</div>
<div class="chat-window" id="chat-window">
{{ if .Messages }}
{{ range .Messages }}
{{ template "message" . }}
{{ end }}
{{ with .ActiveStream }}{{ template "assistant_stream" . }}{{ end }}
{{ else }}
<div class="empty-state">
<div class="hero">
<span class="hero-mark" aria-hidden="true"><span class="glyph"></span></span>
<h3>{{ t .Lang "hero.title" }}</h3>
<p>{{ t .Lang "hero.subtitle" }}</p>
</div>
<h4 class="block-title">{{ t .Lang "hero.start_with" }}</h4>
<div class="starters">
<button type="button" class="starter" style="--k: var(--k-read)" data-prompt="Give me a tour of this workspace: the main folders, how they fit together, and where I should start reading." data-send="true">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-folder)"></span></span>
<span class="starter-title">{{ t .Lang "starter.tour_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.tour_desc" }}</span>
</button>
<button type="button" class="starter" style="--k: var(--k-search)" data-prompt="Find where authentication is handled and explain how a request flows through it." data-send="true">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-search)"></span></span>
<span class="starter-title">{{ t .Lang "starter.trace_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.trace_desc" }}</span>
</button>
<button type="button" class="starter" style="--k: var(--k-write)" data-prompt="Fix this bug: ">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-edit)"></span></span>
<span class="starter-title">{{ t .Lang "starter.fix_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.fix_desc" }}</span>
</button>
<button type="button" class="starter" style="--k: var(--k-run)" data-prompt="Run the tests and summarise any failures with a suggested fix for each." data-send="true">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-term)"></span></span>
<span class="starter-title">{{ t .Lang "starter.tests_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.tests_desc" }}</span>
</button>
</div>
<h4 class="block-title">{{ t .Lang "hero.tools_heading" }}</h4>
<div class="tools-strip">
<span class="tool-chip" style="--k: var(--k-read)"><span class="glyph" style="--glyph: var(--i-file)"></span>{{ t .Lang "tool.read" }}</span>
<span class="tool-chip" style="--k: var(--k-write)"><span class="glyph" style="--glyph: var(--i-edit)"></span>{{ t .Lang "tool.write" }}</span>
<span class="tool-chip" style="--k: var(--k-search)"><span class="glyph" style="--glyph: var(--i-search)"></span>{{ t .Lang "tool.search" }}</span>
<span class="tool-chip" style="--k: var(--k-run)"><span class="glyph" style="--glyph: var(--i-term)"></span>{{ t .Lang "tool.run" }}</span>
<span class="tool-chip" style="--k: var(--k-agent)"><span class="glyph" style="--glyph: var(--i-spark)"></span>{{ t .Lang "tool.subagents" }}</span>
</div>
<h4 class="block-title">{{ t .Lang "hero.explore_heading" }}</h4>
<div class="explore">
<button type="button" class="explore-chip" data-prompt="Write unit tests for ">{{ t .Lang "explore.tests" }}</button>
<button type="button" class="explore-chip" data-prompt="Review this code for bugs and style issues: ">{{ t .Lang "explore.review" }}</button>
<button type="button" class="explore-chip" data-prompt="Explain what this file does: ">{{ t .Lang "explore.explain" }}</button>
<button type="button" class="explore-chip" data-prompt="Create a Word document summarising ">{{ t .Lang "explore.document" }}</button>
<button type="button" class="explore-chip" data-prompt="Refactor this to be simpler: ">{{ t .Lang "explore.refactor" }}</button>
</div>
</div>
{{ end }}
</div>
<div class="composer">
<!--
Staged-attachment tray: empty (and hidden via .composer-attachments:empty
in style.css) until app.js adds a chip for a picked/dropped/pasted file.
See the "Composer attachments" section of web/static/js/app.js and
internal/handlers/attachments.go.
-->
<div class="composer-attachments" id="composer-attachments" aria-live="polite"></div>
<form
class="composer-form"
data-empty="true"
hx-post="/chat"
hx-target="#chat-window"
hx-swap="beforeend"
>
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="conversation_id" value="{{ .ConversationID }}" />
<!--
Unnamed on purpose: this input is never submitted as part of the
form itself. app.js uploads each selected file individually (over
fetch, to POST /files/attach) the moment it's chosen, then adds a
separate hidden "attachments" input per successful upload below.
-->
<input type="file" id="composer-file-input" class="composer-file-input" multiple hidden tabindex="-1" aria-hidden="true" />
<button type="button" class="attach-btn" id="attach-btn" aria-label="{{ t .Lang "composer.attach" }}" title="{{ t .Lang "composer.attach" }}">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
<textarea
name="prompt"
id="composer-input"
rows="1"
placeholder="{{ t .Lang "composer.placeholder" }}"
></textarea>
<button class="send-btn" type="submit" aria-label="{{ t .Lang "composer.send" }}">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
<span class="send-btn-label">{{ t .Lang "composer.send" }}</span>
</button>
</form>
<div class="composer-hint">
<span class="hint-kbd"><kbd>Enter</kbd> {{ t .Lang "composer.hint_send" }}</span>
<span class="hint-kbd"><kbd>Shift</kbd><kbd>Enter</kbd> {{ t .Lang "composer.hint_newline" }}</span>
<span>{{ t .Lang "composer.hint_drop" }}</span>
</div>
</div>
<div id="status-bar" class="status-bar" hx-get="/status?conversation_id={{ .ConversationID }}" hx-trigger="load" hx-swap="outerHTML"></div>
</main>
</div>
<!--
Small, same-origin enhancement layer: auto-scroll, auto-resizing
textarea, Enter-to-send, clearing the composer after a send, the copy
button on code blocks, and a fetch-based Stop button. Everything except
sending a chat message (which needs htmx anyway) still works with this
file blocked. See web/static/js/app.js.
-->
<script src="/static/js/app.js"></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,116 @@
{{/*
Renders a single historical message row. Used for full-page loads.
Tool messages get a collapsible <details> card (native HTML — no JS
needed) instead of a plain bubble, since a run_bash_command or
search_codebase result can be long and isn't usually what you want
to read first when scrolling a past conversation.
*/}}
{{ define "message" }}
{{ if eq .Role "tool" }}
<div class="message-row role-tool">
<details class="tool-card{{ if eq .ToolName "dispatch_subagent" }} is-subagent{{ end }}">
<summary>
<span class="tool-icon tool-icon-{{ .ToolName }}"></span>
<span class="tool-name">{{ .ToolName }}</span>
<span class="tool-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-body">{{ .RenderedHTML }}</div>
</details>
</div>
{{ else if eq .Role "user" }}
<div class="message-row role-user">
<div class="message-content">
<div class="bubble">{{ .RenderedHTML }}</div>
</div>
</div>
{{ else }}
<div class="message-row role-assistant">
<span class="avatar avatar-assistant" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 21c1.2-2.5 1.5-5.5 1.3-8.5-.5-1.2-1.2-2.4-2.1-3.5C3.5 9 2 7.8 2 6.2 2 4.6 3.5 3.5 5.2 3.5c.8 0 1.5.3 2.1.8C8.5 3 10.1 2.2 12 2.2c1.9 0 3.5.8 4.7 2.1.6-.5 1.3-.8 2.1-.8 1.7 0 3.2 1.1 3.2 2.7 0 1.6-1.5 2.8-3.2 2.8-.9 1.1-1.6 2.3-2.1 3.5-.2 3 .1 6 1.3 8.5H6Zm4-12c.5 1.8 1.2 3.5 2 3.5s1.5-1.7 2-3.5h-4Z"/>
</svg>
</span>
<div class="message-content">
<div class="message-meta"><span class="meta-name">enkly</span></div>
<div class="bubble">{{ .RenderedHTML }}</div>
</div>
</div>
{{ end }}
{{ end }}
{{/*
Renders a tool-call card (used for live streaming and by tests). Data:
ToolName, Summary, Body (already-escaped HTML), IsError.
*/}}
{{ define "tool_call" }}
<div class="message-row role-tool">
<details class="tool-card{{ if .IsError }} is-error{{ end }}{{ if eq .ToolName "dispatch_subagent" }} is-subagent{{ end }}">
<summary>
<span class="tool-icon tool-icon-{{ .ToolName }}"></span>
<span class="tool-name">{{ .ToolName }}</span>
<span class="tool-summary">{{ .Summary }}</span>
<span class="tool-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-body">{{ .Body }}</div>
</details>
</div>
{{ end }}
{{/*
The SSE "tool" payload. htmx applies an out-of-band swap by moving the
CONTENT of the hx-swap-oob element (for any strategy other than
outerHTML), so the target-carrying wrapper is a bare <div> and the real
.message-row lives inside it. "beforebegin" on the assistant row puts
each card above the still-streaming bubble, in execution order.
Data: StreamID plus the tool_call fields.
*/}}
{{ define "tool_call_oob" }}
<div hx-swap-oob="beforebegin:#assistant-row-{{ .StreamID }}">
{{ template "tool_call" . }}
</div>
{{ end }}
{{/*
A single round's standalone reasoning trace, rendered as its own
collapsible card — the per-step counterpart to the single
auto-collapsing "thinking-card" in chat_response.html's
assistant_stream, which only ever shows one running cumulative blob.
This one instead gives each round its own card, inserted above the
assistant bubble in execution order via the same "beforebegin"
OOB-swap trick tool_call_oob uses — so a card for round 0's reasoning
lands right before the tool card it led to, then round 1's reasoning
card lands before round 1's tool card, and so on: discrete,
positioned reasoning steps rather than one blob for the whole turn,
the same interleaved-thinking shape Claude's own UI gives a
tool-using turn. Reuses .tool-card's chrome (border, summary, chevron)
with a thinking-specific icon and a round number, rather than
introducing a parallel set of card styles for what is structurally
the same kind of row. Data: Round (int), Thinking (plain text).
*/}}
{{ define "thinking_round" }}
<div class="message-row role-tool">
<details class="tool-card is-thinking-round">
<summary>
<span class="thinking-icon" aria-hidden="true"></span>
<span class="tool-name">Thinking</span>
<span class="tool-summary">round {{ .Round }}</span>
<span class="tool-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-body"><p class="tool-output">{{ .Thinking }}</p></div>
</details>
</div>
{{ end }}
{{/* The SSE "thinking_round" payload — same OOB wrapper as tool_call_oob. */}}
{{ define "thinking_round_oob" }}
<div hx-swap-oob="beforebegin:#assistant-row-{{ .StreamID }}">
{{ template "thinking_round" . }}
</div>
{{ end }}
{{/* Inline error row returned by POST /chat for validation problems. */}}
{{ define "chat_error" }}
<div class="message-row role-system">
<div class="bubble bubble-error">{{ .Message }}</div>
</div>
{{ end }}

View File

@@ -0,0 +1,31 @@
{{/*
lang_switcher — the language switcher, shared by index.html (sidebar
footer, next to the theme toggle) and every auth.html page. Expects
a dict with:
CSRFToken — the page's CSRF token
Lang — the page's current locale code ("en", "fr", …)
Next — where SetLanguage should redirect back to after the
POST; each call site passes its own page path since,
unlike ConversationID for the theme toggle, there's no
single field every page that includes this partial has
in common.
Deliberately plain <select> + submit button, no auto-submit-on-change
— the page's Content-Security-Policy has no 'unsafe-inline' in
script-src (see middleware.go's contentSecurityPolicy), and an
inline onchange="…" handler is script-src territory, not
style-src's, so it would be silently blocked by the browser exactly
the way the project's own htmx allowEval:false note warns about.
Same no-new-script-dependency stance as the rest of the front end.
*/}}
{{ define "lang_switcher" }}
<form method="post" action="/language" class="lang-switcher" aria-label="{{ t .Lang "nav.language" }}">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="next" value="{{ .Next }}" />
<select name="lang" class="lang-select" aria-label="{{ t .Lang "nav.language" }}">
{{ range supportedLanguages }}
<option value="{{ .Code }}"{{ if eq (print .Code) $.Lang }} selected{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
<button type="submit" class="lang-switcher-submit" title="{{ t .Lang "nav.language" }}">✓</button>
</form>
{{ end }}

View File

@@ -0,0 +1,114 @@
{{ define "subscription" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Subscription — enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="billing-shell">
<div class="billing-topbar">
<div class="brand-lockup">
<span class="logo-dot"></span>
<h1>enkly</h1>
</div>
<a href="/" class="billing-back">← Back to chat</a>
</div>
<div class="billing-card">
<h2>Subscription</h2>
<p class="auth-subtitle">
You're on the <strong>{{ .CurrentName }}</strong> plan.
{{ if eq .Status "past_due" }}<span class="plan-status plan-status-warn">Payment past due — please update your card.</span>{{ end }}
{{ if eq .Status "canceled" }}<span class="plan-status">Your last paid subscription was cancelled.</span>{{ end }}
{{ if .CancelAtPeriodEnd }}
<span class="plan-status plan-status-warn">
Cancels at the end of the current billing period{{ if not .CurrentPeriodEnd.IsZero }} ({{ .CurrentPeriodEnd.Format "Jan 2, 2006" }}){{ end }}.
</span>
{{ else if not .CurrentPeriodEnd.IsZero }}
<span class="plan-status">Renews {{ .CurrentPeriodEnd.Format "Jan 2, 2006" }}.</span>
{{ end }}
</p>
<div class="usage-card {{ if .AtLimit }}usage-at{{ else if .NearLimit }}usage-near{{ end }}">
<div class="usage-head">
<span>Today's messages</span>
{{ if .Unlimited }}
<span><strong>{{ .UsedToday }}</strong> sent · unlimited</span>
{{ else }}
<span><strong>{{ .UsedToday }}</strong> / {{ .DailyLimit }}</span>
{{ end }}
</div>
{{ if not .Unlimited }}
<div class="usage-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ .UsagePercent }}">
<div class="usage-fill" style="width: {{ .UsagePercent }}%"></div>
</div>
{{ end }}
{{ if .AtLimit }}
<p class="usage-note">You've used all of today's messages. Upgrade below to keep chatting — the count resets daily (UTC).</p>
{{ else if .NearLimit }}
<p class="usage-note">You're close to today's limit. Upgrade below for more room.</p>
{{ end }}
</div>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Message }}<div class="auth-message info">{{ .Message }}</div>{{ end }}
<div class="plan-grid">
{{ $csrf := .CSRFToken }}
{{ range .Plans }}
<div class="plan-card {{ if .IsCurrent }}plan-card-current{{ end }}{{ if .Popular }} plan-card-popular{{ end }}">
{{ if .IsCurrent }}<div class="plan-badge">Current plan</div>{{ else if .Popular }}<div class="plan-badge plan-badge-popular">Most popular</div>{{ end }}
<div class="plan-card-name">{{ .Name }}</div>
<div class="plan-card-price">
{{ if eq .PriceEUR 0 }}€0{{ else }}€{{ .PriceEUR }}{{ end }}
<span class="plan-card-period">/month</span>
</div>
<ul class="plan-card-features">
{{ if eq .DailyMessageLimit 0 }}
<li>Unlimited messages/day</li>
{{ else }}
<li>{{ .DailyMessageLimit }} messages/day</li>
{{ end }}
{{ if .Multiplier }}<li class="plan-perk">{{ .Multiplier }}</li>{{ end }}
<li>Full tool-using agent access</li>
<li>File upload &amp; download</li>
</ul>
{{ if .IsCurrent }}
<button class="auth-btn plan-btn" type="button" disabled>Current plan</button>
{{ else if not .Checkoutable }}
<button class="auth-btn plan-btn auth-btn-secondary" type="button" disabled title="{{ .UnavailableReason }}">Unavailable</button>
{{ if .UnavailableReason }}<p class="plan-unavailable-reason">{{ .UnavailableReason }}</p>{{ end }}
{{ else }}
<form method="post" action="/subscription/checkout" class="plan-form">
<input type="hidden" name="csrf_token" value="{{ $csrf }}" />
<input type="hidden" name="plan" value="{{ .Plan }}" />
<button class="auth-btn plan-btn" type="submit">
{{ .Action }}
</button>
</form>
{{ end }}
</div>
{{ end }}
</div>
{{ if .HasBillingPortal }}
<form method="post" action="/subscription/portal" class="billing-portal-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button class="auth-btn auth-btn-secondary" type="submit">Manage billing &amp; invoices</button>
</form>
{{ end }}
<p class="billing-footnote">
Plans renew monthly and you can cancel any time from “Manage billing &amp; invoices” — you keep your plan until the end of the period you paid for.<br />
Payments are processed by Stripe. enkly never sees or stores your card details.
</p>
</div>
</div>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,38 @@
{{/*
File explorer fragments, served by GET /tree (internal/handlers/ui.go).
Deliberately non-recursive: a directory is fetched one level at a time,
so an open directory's children are always files or COLLAPSED
directories. That matches the lazy-loading behavior (nothing below a
closed folder is ever read) and keeps html/template out of recursive
template definitions.
*/}}
{{ define "tree_root" }}<ul class="tree-list tree-root">{{ range .Children }}{{ template "tree_entry" . }}{{ else }}<li class="tree-empty">The workspace is empty.</li>{{ end }}</ul>{{ end }}
{{ define "tree_entry" }}{{ if .IsDir }}{{ template "tree_dir_collapsed" . }}{{ else }}{{ template "tree_file" . }}{{ end }}{{ end }}
{{ define "tree_file" }}<li class="tree-file"><a href="{{ .DownloadURL }}" class="tree-node tree-file-link" title="Download {{ .Path }}" download><span class="tree-icon">·</span>{{ .Name }}</a><span class="file-size">{{ .SizeHuman }}</span></li>{{ end }}
{{ define "tree_dir_collapsed" }}<li class="tree-dir"><button type="button" class="tree-node" hx-get="{{ .ExpandURL }}" hx-target="closest li" hx-swap="outerHTML" aria-expanded="false"><span class="tree-icon">▸</span>{{ .Name }}</button></li>{{ end }}
{{ define "tree_dir_open" }}<li class="tree-dir is-open"><button type="button" class="tree-node" hx-get="{{ .CollapseURL }}" hx-target="closest li" hx-swap="outerHTML" aria-expanded="true"><span class="tree-icon">▾</span>{{ .Name }}</button><ul class="tree-list">{{ range .Children }}{{ template "tree_entry" . }}{{ else }}<li class="tree-empty">empty</li>{{ end }}</ul></li>{{ end }}
{{ define "tree_dir" }}{{ if .Open }}{{ template "tree_dir_open" . }}{{ else }}{{ template "tree_dir_collapsed" . }}{{ end }}{{ end }}
{{ define "explorer_panel" }}
<div class="sidebar-section explorer-section">
<div class="sidebar-section-title">
<span>Explorer</span>
<button type="button" class="icon-btn" title="Refresh" aria-label="Refresh file tree"
hx-get="/tree" hx-target="#file-tree" hx-swap="innerHTML">⟳</button>
</div>
<div id="file-tree" class="file-tree" hx-get="/tree" hx-trigger="load" hx-swap="innerHTML">
<div class="file-item file-item-empty">Loading…</div>
</div>
</div>
{{ end }}
{{ define "status_bar" }}<div class="status-bar" id="status-bar" hx-get="/status?conversation_id={{ .ConversationID }}" hx-trigger="every 20s" hx-swap="outerHTML">
<span class="status-item" title="Active vLLM chat model"><span class="dot {{ if .VLLMOK }}ok{{ else }}bad{{ end }}"></span>{{ .Model }}</span>
<!--<span class="status-item" title="Postgres connection"><span class="dot {{ if .PostgresOK }}ok{{ else }}bad{{ end }}"></span>postgres</span>-->
<span class="status-item" title="Estimated from message length (chars/4), not the model's tokenizer">Context {{ .ContextTokens }}{{ if .ContextLimit }} / {{ .ContextLimit }} tokens{{ else }} tokens{{ end }}{{ if .ContextLimit }}<span class="ctx-meter{{ if ge .ContextPct 90 }} is-full{{ else if ge .ContextPct 70 }} is-high{{ end }}" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ .ContextPct }}"><span style="width: {{ .ContextPct }}%"></span></span>{{ end }}</span>
</div>{{ end }}