This commit is contained in:
21
services/gateway/Dockerfile
Normal file
21
services/gateway/Dockerfile
Normal 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"]
|
||||
173
services/gateway/cmd/server/main.go
Normal file
173
services/gateway/cmd/server/main.go
Normal 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)
|
||||
}
|
||||
481
services/gateway/internal/handlers/attachments.go
Normal file
481
services/gateway/internal/handlers/attachments.go
Normal 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
|
||||
}
|
||||
210
services/gateway/internal/handlers/auth.go
Normal file
210
services/gateway/internal/handlers/auth.go
Normal 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")
|
||||
}
|
||||
}
|
||||
282
services/gateway/internal/handlers/billing.go
Normal file
282
services/gateway/internal/handlers/billing.go
Normal 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)
|
||||
}
|
||||
287
services/gateway/internal/handlers/chat.go
Normal file
287
services/gateway/internal/handlers/chat.go
Normal 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
|
||||
}
|
||||
279
services/gateway/internal/handlers/core.go
Normal file
279
services/gateway/internal/handlers/core.go
Normal 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" }
|
||||
312
services/gateway/internal/handlers/deliverables.go
Normal file
312
services/gateway/internal/handlers/deliverables.go
Normal 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()
|
||||
}
|
||||
272
services/gateway/internal/handlers/files.go
Normal file
272
services/gateway/internal/handlers/files.go
Normal 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)
|
||||
}
|
||||
}
|
||||
70
services/gateway/internal/handlers/i18n.go
Normal file
70
services/gateway/internal/handlers/i18n.go
Normal 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)
|
||||
}
|
||||
315
services/gateway/internal/handlers/middleware.go
Normal file
315
services/gateway/internal/handlers/middleware.go
Normal 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).
|
||||
324
services/gateway/internal/handlers/pages.go
Normal file
324
services/gateway/internal/handlers/pages.go
Normal 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"
|
||||
}
|
||||
}
|
||||
187
services/gateway/internal/handlers/ui.go
Normal file
187
services/gateway/internal/handlers/ui.go
Normal 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)
|
||||
}
|
||||
950
services/gateway/web/static/css/style.css
Normal file
950
services/gateway/web/static/css/style.css
Normal 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; }
|
||||
BIN
services/gateway/web/static/img/enkly-logo.png
Normal file
BIN
services/gateway/web/static/img/enkly-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
673
services/gateway/web/static/js/app.js
Normal file
673
services/gateway/web/static/js/app.js
Normal 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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
})();
|
||||
54
services/gateway/web/static/js/password-toggle.js
Normal file
54
services/gateway/web/static/js/password-toggle.js
Normal 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);
|
||||
})();
|
||||
290
services/gateway/web/static/js/vendor/htmx-sse.js
vendored
Normal file
290
services/gateway/web/static/js/vendor/htmx-sse.js
vendored
Normal 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
|
||||
}
|
||||
})()
|
||||
1
services/gateway/web/static/js/vendor/htmx.min.js
vendored
Normal file
1
services/gateway/web/static/js/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
services/gateway/web/templates/attachments.html
Normal file
34
services/gateway/web/templates/attachments.html
Normal 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 }} · {{ .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 }}
|
||||
245
services/gateway/web/templates/auth.html
Normal file
245
services/gateway/web/templates/auth.html
Normal 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 }}
|
||||
115
services/gateway/web/templates/chat_response.html
Normal file
115
services/gateway/web/templates/chat_response.html
Normal 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 }}
|
||||
43
services/gateway/web/templates/deliverables.html
Normal file
43
services/gateway/web/templates/deliverables.html
Normal 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 }}
|
||||
81
services/gateway/web/templates/files.html
Normal file
81
services/gateway/web/templates/files.html
Normal 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 }}
|
||||
268
services/gateway/web/templates/index.html
Normal file
268
services/gateway/web/templates/index.html
Normal 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 }}
|
||||
116
services/gateway/web/templates/message.html
Normal file
116
services/gateway/web/templates/message.html
Normal 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 }}
|
||||
31
services/gateway/web/templates/partials.html
Normal file
31
services/gateway/web/templates/partials.html
Normal 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 }}
|
||||
114
services/gateway/web/templates/subscription.html
Normal file
114
services/gateway/web/templates/subscription.html
Normal 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 & 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 & invoices</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
<p class="billing-footnote">
|
||||
Plans renew monthly and you can cancel any time from “Manage billing & 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 }}
|
||||
38
services/gateway/web/templates/tree.html
Normal file
38
services/gateway/web/templates/tree.html
Normal 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 }}
|
||||
Reference in New Issue
Block a user