This commit is contained in:
16
services/agent/Dockerfile
Normal file
16
services/agent/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/agent/ services/agent/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/agent-server ./services/agent/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/agent-server ./agent-server
|
||||
ENV ENKLY_AGENT_GRPC_ADDR=:9006
|
||||
EXPOSE 9006
|
||||
ENTRYPOINT ["./agent-server"]
|
||||
254
services/agent/cmd/server/main.go
Normal file
254
services/agent/cmd/server/main.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Command server runs the agent microservice: a gRPC server exposing
|
||||
// api/proto/agent/v1, orchestrating vLLM-served chat + tool calls
|
||||
// across the workspace, knowledge, billing and conversation services.
|
||||
// Replaces internal/llm/agent.go + client.go (chat half) and the
|
||||
// chat-run sections of internal/handlers/chat.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
agentv1 "enkly/gen/agent/v1"
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/effort"
|
||||
"enkly/pkg/entitlements"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/application"
|
||||
"enkly/services/agent/internal/domain"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
"enkly/services/agent/internal/infrastructure/grpcclients"
|
||||
"enkly/services/agent/internal/infrastructure/toolcatalog"
|
||||
"enkly/services/agent/internal/infrastructure/toolexec"
|
||||
grpcsrv "enkly/services/agent/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
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 (
|
||||
grpcAddr = envkit.String("ENKLY_AGENT_GRPC_ADDR", ":9006")
|
||||
|
||||
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
|
||||
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
|
||||
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
|
||||
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
|
||||
|
||||
// vllmChatURL / vllmEmbedURL point at the two vLLM
|
||||
// OpenAI-compatible deployments this service calls — a
|
||||
// chat-serving replica (Qwen, tool-calling + reasoning
|
||||
// enabled) and an embedding-serving replica, each its own vLLM
|
||||
// engine process since one vLLM server hosts exactly one
|
||||
// model. See k8s/11-vllm.yaml for how those are deployed (GPU
|
||||
// requests, --served-model-name, probes) and vllmkit.Client's
|
||||
// doc comment for why the two are separate fields rather than
|
||||
// one shared host the way a single local Ollama daemon used to
|
||||
// be.
|
||||
vllmChatURL = envkit.String("ENKLY_VLLM_CHAT_URL", "http://localhost:8000/v1")
|
||||
vllmEmbedURL = envkit.String("ENKLY_VLLM_EMBED_URL", "http://localhost:8001/v1")
|
||||
chatModel = envkit.String("ENKLY_CHAT_MODEL", vllmkit.DefaultChatModel)
|
||||
embedModel = envkit.String("ENKLY_EMBED_MODEL", vllmkit.DefaultEmbedModel)
|
||||
// vllmAPIKey is sent as a bearer token on every request to both
|
||||
// deployments (vLLM's --api-key flag). Required once either
|
||||
// deployment is reachable from outside the cluster/mesh; leave
|
||||
// unset for a fully cluster-internal deployment with no auth in
|
||||
// front of it. See k8s/02-secrets.yaml.
|
||||
vllmAPIKey = envkit.String("ENKLY_VLLM_API_KEY", "")
|
||||
|
||||
// chatThink controls vLLM/Qwen3's extended-thinking toggle
|
||||
// (vllmkit.Client.Think — "" leaves it to the chat template's
|
||||
// own default, "true"/"false" forces it, and "low"/"medium"/
|
||||
// "high" are also accepted for compatibility with pkg/effort's
|
||||
// vocabulary, though the wire protocol itself has no
|
||||
// graded-effort equivalent — see Think's doc comment).
|
||||
// chatMaxTokens/chatTemperature/chatTopP are the per-request
|
||||
// sampling knobs vLLM actually exposes over HTTP; context
|
||||
// length and GPU/tensor-parallel layout are NOT request-time
|
||||
// options under vLLM the way Ollama's num_ctx/num_gpu/
|
||||
// num_thread were — those are now fixed at server start via
|
||||
// --max-model-len/--tensor-parallel-size/
|
||||
// --gpu-memory-utilization on the vLLM deployment itself (see
|
||||
// k8s/11-vllm.yaml), so there is no client-side equivalent to
|
||||
// wire up here.
|
||||
chatThink = envkit.String("ENKLY_CHAT_THINK", "")
|
||||
chatMaxTokens = envkit.Int("ENKLY_CHAT_MAX_TOKENS", 0)
|
||||
chatTemperature = envkit.Float("ENKLY_CHAT_TEMPERATURE", 0)
|
||||
chatTopP = envkit.Float("ENKLY_CHAT_TOP_P", 0)
|
||||
|
||||
// Effort control / adaptive thinking (see pkg/effort and
|
||||
// agentrun.AdaptiveThinkPolicy). defaultEffort seeds every
|
||||
// Agent's fallback level when a turn carries no /effort
|
||||
// directive (see turnopts) and no WithEffort override;
|
||||
// adaptiveThinking turns on content-driven escalation above
|
||||
// that level's own floor. Both default to this deployment
|
||||
// behaving exactly as it did before effort control existed
|
||||
// (effort.Default == Medium, matching the old hardcoded
|
||||
// MaxRounds=12/DefaultThinkPolicy shape) unless explicitly
|
||||
// overridden.
|
||||
defaultEffort = envkit.String("ENKLY_DEFAULT_EFFORT", string(effort.Default))
|
||||
adaptiveThinking = envkit.Bool("ENKLY_ADAPTIVE_THINKING", true)
|
||||
|
||||
// Dynamic workflows (see dispatch_workflow / toolexec/workflow.go).
|
||||
// workflowMaxParallel is the hard operational ceiling for this
|
||||
// deployment's vLLM chat capacity; dynamicWorkflowPlans is the
|
||||
// comma-separated plan allowlist (see pkg/entitlements) —
|
||||
// "premium" today, see entitlements' package doc comment for why.
|
||||
workflowMaxParallel = envkit.Int("ENKLY_WORKFLOW_MAX_PARALLEL", 0)
|
||||
dynamicWorkflowPlans = envkit.String("ENKLY_DYNAMIC_WORKFLOW_PLANS", "")
|
||||
|
||||
// Fast mode (see /fast, Service.FastAgent). Unset
|
||||
// fastChatModel means "fast mode isn't configured on this
|
||||
// deployment" — StartTurn then just runs a /fast turn on the
|
||||
// normal Agent, exactly as if fast mode had never been
|
||||
// requested (see Service.FastAgent's doc comment). A
|
||||
// deployment that has, say, a smaller/distilled model served
|
||||
// by its own vLLM replica (or by the same replica under a
|
||||
// second --served-model-name via vLLM's multi-LoRA/multi-model
|
||||
// support) points fastChatModel at it to actually unlock the
|
||||
// latency/cost win fast mode promises; fastChatURL lets that
|
||||
// replica live on an entirely different host, which is the
|
||||
// common case (a smaller model usually means a
|
||||
// smaller/cheaper GPU shape, i.e. a different Deployment).
|
||||
fastChatModel = envkit.String("ENKLY_FAST_CHAT_MODEL", "")
|
||||
fastChatURL = envkit.String("ENKLY_FAST_VLLM_CHAT_URL", vllmChatURL)
|
||||
fastChatThink = envkit.String("ENKLY_FAST_CHAT_THINK", "low")
|
||||
fastChatMaxTokens = envkit.Int("ENKLY_FAST_CHAT_MAX_TOKENS", 700)
|
||||
)
|
||||
|
||||
clients, err := grpcclients.Dial(workspaceAddr, knowledgeAddr, billingAddr, conversationAddr)
|
||||
if err != nil {
|
||||
logger.Error("dial peer services", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer clients.Close()
|
||||
|
||||
llmClient := vllmkit.New(vllmChatURL, vllmEmbedURL, chatModel, embedModel)
|
||||
llmClient.APIKey = vllmAPIKey
|
||||
llmClient.Think = chatThink
|
||||
llmClient.ChatOptions = vllmkit.Options{
|
||||
Temperature: chatTemperature, TopP: chatTopP, MaxTokens: chatMaxTokens,
|
||||
}
|
||||
|
||||
// llmClient also backs search_office_skills' semantic index (see
|
||||
// toolexec.Executor.SkillIndex): the same vLLM-backed client
|
||||
// already used for chat satisfies skillkit.Embedder as-is.
|
||||
executor := toolexec.New(clients.Workspace, clients.Knowledge, llmClient)
|
||||
executor.WorkflowMaxParallel = workflowMaxParallel // 0 means "use toolexec's own default"
|
||||
|
||||
// dispatch_subagent (and, built on the same nested Agent,
|
||||
// dispatch_workflow's parallel steps — see toolexec/workflow.go):
|
||||
// a nested Agent sharing the same vLLM-backed client but with a
|
||||
// read-only tool list (toolcatalog.SubagentDefs()) and a
|
||||
// restricted executor (toolexec.NewRestricted) that enforces that
|
||||
// list even if the model asks for anything else. Wired as a second
|
||||
// step, after executor exists, because the restricted executor has
|
||||
// to wrap executor by pointer — see toolexec.Executor.Subagent's
|
||||
// doc comment.
|
||||
executor.Subagent = agentrun.NewSubagent(llmClient, toolexec.NewRestricted(executor), toolcatalog.SubagentDefs())
|
||||
|
||||
agent := agentrun.New(llmClient, executor, toolcatalog.Defs())
|
||||
|
||||
// Effort control / adaptive thinking: an unrecognized
|
||||
// ENKLY_DEFAULT_EFFORT value is logged and ignored, leaving New's
|
||||
// own effort.Default (Medium) in force — a misconfigured env var
|
||||
// degrading to "the agent's long-standing default behavior" is
|
||||
// always a safe failure mode, the same stance effort.Level.Resolve
|
||||
// itself takes for an invalid value reaching it at runtime.
|
||||
if lvl, err := effort.Parse(defaultEffort); err != nil {
|
||||
logger.Warn("invalid ENKLY_DEFAULT_EFFORT, using default", "value", defaultEffort, "err", err)
|
||||
} else {
|
||||
agent.Effort = lvl
|
||||
}
|
||||
if adaptiveThinking {
|
||||
agent.AdaptiveThink = agentrun.AdaptiveThinkPolicy
|
||||
}
|
||||
|
||||
// ENKLY.md project memory: if the workspace root has one, its
|
||||
// content is folded into the system prompt on every turn (see
|
||||
// agentrun.Agent.buildSystemPrompt) — the same idea as Claude Code
|
||||
// reading a CLAUDE.md. A missing file or an unreachable workspace
|
||||
// service both just mean "no project memory today"; this must never
|
||||
// fail a turn over it, so the error is swallowed here, not returned.
|
||||
const projectMemoryPath = "ENKLY.md"
|
||||
agent.MemoryLoader = func(ctx context.Context) string {
|
||||
resp, err := clients.Workspace.ReadFile(ctx, &workspacev1.ReadFileRequest{Path: projectMemoryPath})
|
||||
if err != nil || resp.GetIsError() {
|
||||
return ""
|
||||
}
|
||||
return resp.GetContent()
|
||||
}
|
||||
|
||||
// Fast mode: only wired up when ENKLY_FAST_CHAT_MODEL names a
|
||||
// distinct served model — see fastChatModel's doc comment above and
|
||||
// application.Service.FastAgent's. fastAgent shares executor (and
|
||||
// therefore the same dispatch_subagent/dispatch_workflow nested
|
||||
// Agent) with the normal agent; only the ChatStreamer differs.
|
||||
var fastAgent *agentrun.Agent
|
||||
if fastChatModel != "" {
|
||||
fastClient := vllmkit.New(fastChatURL, vllmEmbedURL, fastChatModel, embedModel)
|
||||
fastClient.APIKey = vllmAPIKey
|
||||
fastClient.Think = fastChatThink
|
||||
fastClient.ChatOptions = vllmkit.Options{MaxTokens: fastChatMaxTokens}
|
||||
fastAgent = agentrun.New(fastClient, executor, toolcatalog.Defs())
|
||||
fastAgent.MemoryLoader = agent.MemoryLoader
|
||||
fastAgent.Effort = effort.Low
|
||||
logger.Info("fast mode configured", "fast_chat_model", fastChatModel, "fast_chat_url", fastChatURL)
|
||||
}
|
||||
|
||||
svc := &application.Service{
|
||||
Streams: domain.NewManager(),
|
||||
Agent: agent,
|
||||
FastAgent: fastAgent,
|
||||
Billing: clients.Billing,
|
||||
Conv: clients.Conversation,
|
||||
Logger: logger,
|
||||
// Same window/backoff as the monolith's ChatLimiter — a gentle
|
||||
// per-account throttle that "only bites a tight scripted loop".
|
||||
ChatLimiter: authsupport.NewLimiter(time.Minute, 200*time.Millisecond, 4*time.Second),
|
||||
Entitlements: entitlements.ParsePlanList(dynamicWorkflowPlans),
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(24<<20), grpc.MaxSendMsgSize(24<<20))
|
||||
agentv1.RegisterAgentServiceServer(grpcServer, grpcsrv.New(svc))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("agent service listening", "addr", grpcAddr, "chat_model", chatModel)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
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")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
457
services/agent/internal/application/service.go
Normal file
457
services/agent/internal/application/service.go
Normal file
@@ -0,0 +1,457 @@
|
||||
// Package application holds the agent service's use cases — the
|
||||
// transplant of internal/handlers/chat.go's Handlers.Chat (minus HTTP
|
||||
// parsing/rendering) plus its runAgent background goroutine. This is a
|
||||
// saga coordinator: every use case here calls out over gRPC to
|
||||
// billing, conversation, workspace (via toolexec) and knowledge rather
|
||||
// than touching a database of its own.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/entitlements"
|
||||
"enkly/pkg/i18n"
|
||||
"enkly/pkg/turnopts"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/domain"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
"enkly/services/agent/internal/infrastructure/toolexec"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPromptChars = 32_000
|
||||
historyMessages = 60 // most recent user/assistant messages replayed to the model
|
||||
|
||||
// defaultConversationTitle mirrors the conversation service's own
|
||||
// DefaultConversationTitle — duplicated as a literal rather than
|
||||
// imported, since depending on another service's internal package
|
||||
// would violate the service boundary; see conversation's
|
||||
// migrations/0001_init.sql column default, which is the actual
|
||||
// source of truth both sides must agree with.
|
||||
defaultConversationTitle = "New conversation"
|
||||
|
||||
maxRunDuration = 30 * time.Minute
|
||||
sseKeepAliveInterval = 15 * time.Second
|
||||
sseCoalesceDelay = 30 * time.Millisecond
|
||||
)
|
||||
|
||||
// Outcome mirrors agentv1.StartTurnOutcome without importing the
|
||||
// generated package into this layer — see internal/interfaces/grpc for
|
||||
// the translation.
|
||||
type Outcome int
|
||||
|
||||
const (
|
||||
OutcomeOK Outcome = iota
|
||||
OutcomeConversationNotFound
|
||||
OutcomeConversationBusy
|
||||
OutcomeTooManyStreams
|
||||
OutcomeRateLimited
|
||||
OutcomeQuotaExceeded
|
||||
OutcomeInternalError
|
||||
)
|
||||
|
||||
type StartTurnResult struct {
|
||||
Outcome Outcome
|
||||
Message string
|
||||
StreamID string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Streams *domain.Manager
|
||||
Agent *agentrun.Agent
|
||||
Billing billingv1.BillingServiceClient
|
||||
Conv conversationv1.ConversationServiceClient
|
||||
Logger *slog.Logger
|
||||
|
||||
// ChatLimiter replaces Handlers.ChatLimiter: a gentle per-account
|
||||
// throttle that "only bites a tight scripted loop".
|
||||
ChatLimiter *authsupport.Limiter
|
||||
|
||||
// FastAgent, when non-nil, is used instead of Agent for a turn that
|
||||
// requests fast mode (see turnopts' /fast directive) — typically
|
||||
// wired in main.go to an *agentrun.Agent sharing Executor but built
|
||||
// around a second, smaller/quantized *vllmkit.Client configured
|
||||
// for lower latency and cost per the "2.5x faster... 3x cheaper"
|
||||
// fast-mode brief. A deployment that hasn't configured a distinct
|
||||
// fast model leaves this nil, and a /fast request just runs on
|
||||
// Agent like any other turn — fast mode degrading to "no change"
|
||||
// rather than erroring is deliberate, since it's meant to be
|
||||
// available on every plan with zero configuration required.
|
||||
FastAgent *agentrun.Agent
|
||||
|
||||
// Entitlements resolves which plans unlock dynamic workflows (see
|
||||
// pkg/entitlements) — built once in main.go from
|
||||
// ENKLY_DYNAMIC_WORKFLOW_PLANS and consulted on every StartTurn
|
||||
// from the plan name billing's own CheckAndIncrementUsage call
|
||||
// already returns, so this needs no gRPC call of its own. The zero
|
||||
// Catalog (an unconfigured Service, e.g. in a test) resolves every
|
||||
// plan as not entitled — see entitlements.Catalog.Resolve.
|
||||
Entitlements entitlements.Catalog
|
||||
}
|
||||
|
||||
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])) + "…"
|
||||
}
|
||||
|
||||
// titleFromPrompt replaces handlers.titleFromPrompt.
|
||||
func titleFromPrompt(prompt string) string {
|
||||
first := prompt
|
||||
if i := strings.IndexByte(prompt, '\n'); i >= 0 {
|
||||
first = prompt[:i]
|
||||
}
|
||||
if t := cleanTitle(first, 60); t != "" {
|
||||
return t
|
||||
}
|
||||
return defaultConversationTitle
|
||||
}
|
||||
|
||||
// buildHistory replaces handlers.buildHistory: earlier turns' raw tool
|
||||
// results are deliberately left out — their assistant tool-call
|
||||
// messages aren't persisted, so replaying the results would present
|
||||
// the model with orphaned tool output; the assistant's own summary of
|
||||
// what it found is already in the text.
|
||||
func buildHistory(msgs []*conversationv1.Message) []vllmkit.Message {
|
||||
out := make([]vllmkit.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
role := fromProtoRole(m.GetRole())
|
||||
if (role == vllmkit.RoleUser || role == vllmkit.RoleAssistant) && strings.TrimSpace(m.GetContent()) != "" {
|
||||
out = append(out, vllmkit.Message{Role: role, Content: m.GetContent()})
|
||||
}
|
||||
}
|
||||
if len(out) > historyMessages {
|
||||
out = out[len(out)-historyMessages:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fromProtoRole(r conversationv1.Role) vllmkit.Role {
|
||||
switch r {
|
||||
case conversationv1.Role_ROLE_USER:
|
||||
return vllmkit.RoleUser
|
||||
case conversationv1.Role_ROLE_ASSISTANT:
|
||||
return vllmkit.RoleAssistant
|
||||
case conversationv1.Role_ROLE_TOOL:
|
||||
return vllmkit.RoleTool
|
||||
default:
|
||||
return vllmkit.RoleSystem
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoRole(r vllmkit.Role) conversationv1.Role {
|
||||
switch r {
|
||||
case vllmkit.RoleUser:
|
||||
return conversationv1.Role_ROLE_USER
|
||||
case vllmkit.RoleAssistant:
|
||||
return conversationv1.Role_ROLE_ASSISTANT
|
||||
case vllmkit.RoleTool:
|
||||
return conversationv1.Role_ROLE_TOOL
|
||||
default:
|
||||
return conversationv1.Role_ROLE_SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
// StartTurn replaces Handlers.Chat's synchronous prefix, launching the
|
||||
// same work Handlers.runAgent did in a background goroutine.
|
||||
//
|
||||
// lang is the visitor's resolved UI locale (see the gateway's
|
||||
// localeFrom / langFromIncomingContext in interfaces/grpc/server.go)
|
||||
// — every message StartTurn itself returns is looked up in that
|
||||
// locale's catalog (see pkg/i18n), and it's also threaded onto runCtx
|
||||
// via agentrun.WithPreferredLanguage so the model's system prompt
|
||||
// carries it as an explicit, standing language preference alongside
|
||||
// its own per-message detection (see agentrun's "## Language"
|
||||
// section).
|
||||
func (s *Service) StartTurn(ctx context.Context, userID, convID int64, prompt string, lang i18n.Locale) StartTurnResult {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.type_message_first")}
|
||||
}
|
||||
if len([]rune(prompt)) > maxPromptChars {
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.message_too_long", maxPromptChars)}
|
||||
}
|
||||
|
||||
// Leading /effort, /fast and /system directive lines (see
|
||||
// turnopts' doc comment for exactly why this, rather than new
|
||||
// proto fields, is today's working surface for those three
|
||||
// features) are parsed off and stripped before the prompt is
|
||||
// persisted or sent to the model — a directive is a control signal
|
||||
// for this service, not part of the conversation the model or a
|
||||
// human reviewing history needs to see. prompt itself is left
|
||||
// untouched if there were no directives, so the overwhelming
|
||||
// majority of turns pay no cost here.
|
||||
opts, prompt := turnopts.Parse(prompt)
|
||||
if opts.HadDirectives {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.type_message_first")}
|
||||
}
|
||||
}
|
||||
|
||||
if !throttle(ctx, s.ChatLimiter, "chat:"+strconv.FormatInt(userID, 10)) {
|
||||
return StartTurnResult{Outcome: OutcomeRateLimited, Message: i18n.T(lang, "chat.rate_limited")}
|
||||
}
|
||||
|
||||
// Plan-based daily message cap: a lookup failure fails open (logged,
|
||||
// not blocking), matching the monolith's stance that a transient
|
||||
// billing hiccup shouldn't lock someone out of chat. planName stays
|
||||
// "" on a failed lookup, which entitlements.Catalog.Resolve treats
|
||||
// as "not entitled" for dynamic workflows — the opposite failure
|
||||
// mode from the quota check just above, and deliberately so: see
|
||||
// entitlements.Catalog.Resolve's doc comment on failing closed for
|
||||
// a gated paid feature versus failing open for message quota.
|
||||
var planName string
|
||||
if usage, err := s.Billing.CheckAndIncrementUsage(ctx, &billingv1.CheckAndIncrementUsageRequest{UserId: userID}); err != nil {
|
||||
s.Logger.Warn("check usage", "err", err, "user_id", userID)
|
||||
} else if !usage.GetAllowed() {
|
||||
return StartTurnResult{Outcome: OutcomeQuotaExceeded, Message: i18n.T(lang, "billing.quota_exceeded", usage.GetPlanName(), usage.GetLimit())}
|
||||
} else {
|
||||
planName = usage.GetPlanName()
|
||||
}
|
||||
|
||||
conv, err := s.Conv.GetConversation(ctx, &conversationv1.GetConversationRequest{UserId: userID, ConversationId: convID})
|
||||
if err != nil {
|
||||
return StartTurnResult{Outcome: OutcomeConversationNotFound, Message: i18n.T(lang, "chat.conversation_gone")}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), maxRunDuration)
|
||||
runCtx = agentrun.WithPreferredLanguage(runCtx, lang)
|
||||
if opts.Effort.Valid() {
|
||||
runCtx = agentrun.WithEffort(runCtx, opts.Effort)
|
||||
}
|
||||
if len(opts.SystemEntries) > 0 {
|
||||
runCtx = agentrun.WithSystemEntries(runCtx, toAgentrunSystemEntries(opts.SystemEntries))
|
||||
}
|
||||
runCtx = toolexec.WithEntitlements(runCtx, s.Entitlements.Resolve(planName))
|
||||
|
||||
// Fast mode (see /fast and Service.FastAgent's doc comment) picks
|
||||
// which Agent actually runs this turn — everything else about the
|
||||
// run (context, callbacks, persistence) is identical either way.
|
||||
runAgent := s.Agent
|
||||
if opts.Fast && s.FastAgent != nil {
|
||||
runAgent = s.FastAgent
|
||||
}
|
||||
|
||||
stream, err := s.Streams.Create(userID, conv.GetId(), cancel)
|
||||
if err != nil {
|
||||
cancel()
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrConversationBusy):
|
||||
return StartTurnResult{Outcome: OutcomeConversationBusy, Message: i18n.T(lang, "chat.conversation_busy")}
|
||||
case errors.Is(err, domain.ErrTooManyStreams):
|
||||
return StartTurnResult{Outcome: OutcomeTooManyStreams, Message: i18n.T(lang, "chat.too_many_streams")}
|
||||
default:
|
||||
s.Logger.Error("create stream", "err", err)
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "errors.generic")}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.Conv.AddMessage(ctx, &conversationv1.AddMessageRequest{
|
||||
ConversationId: conv.GetId(), Role: conversationv1.Role_ROLE_USER, Content: prompt,
|
||||
}); err != nil {
|
||||
cancel()
|
||||
stream.Finish("")
|
||||
s.Streams.ScheduleRemoval(stream)
|
||||
s.Logger.Error("save user message", "err", err)
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.save_failed")}
|
||||
}
|
||||
if _, err := s.Conv.AutoTitleConversation(ctx, &conversationv1.AutoTitleConversationRequest{
|
||||
UserId: userID, ConversationId: conv.GetId(), Title: titleFromPrompt(prompt),
|
||||
}); err != nil {
|
||||
s.Logger.Warn("auto-title conversation", "err", err)
|
||||
}
|
||||
|
||||
msgsResp, err := s.Conv.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: conv.GetId()})
|
||||
if err != nil {
|
||||
cancel()
|
||||
stream.Finish("")
|
||||
s.Streams.ScheduleRemoval(stream)
|
||||
s.Logger.Error("load history", "err", err)
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.load_failed")}
|
||||
}
|
||||
|
||||
go s.runTurn(runCtx, cancel, stream, conv.GetId(), buildHistory(msgsResp.GetMessages()), runAgent)
|
||||
|
||||
return StartTurnResult{Outcome: OutcomeOK, StreamID: stream.ID}
|
||||
}
|
||||
|
||||
// toAgentrunSystemEntries translates turnopts.SystemEntry (a
|
||||
// transport-agnostic shape pkg/turnopts owns so it doesn't need to
|
||||
// import the agent service's internal agentrun package) to
|
||||
// agentrun.SystemEntry — the one call site that actually needs both
|
||||
// types in scope.
|
||||
func toAgentrunSystemEntries(in []turnopts.SystemEntry) []agentrun.SystemEntry {
|
||||
out := make([]agentrun.SystemEntry, 0, len(in))
|
||||
for _, e := range in {
|
||||
var kind agentrun.SystemEntryKind
|
||||
switch e.Kind {
|
||||
case turnopts.SystemEntryPermission:
|
||||
kind = agentrun.SystemEntryPermission
|
||||
case turnopts.SystemEntryBudget:
|
||||
kind = agentrun.SystemEntryBudget
|
||||
default:
|
||||
kind = agentrun.SystemEntryInstruction
|
||||
}
|
||||
out = append(out, agentrun.SystemEntry{Kind: kind, Content: e.Content})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordedCall mirrors handlers.recordedCall — used to match a
|
||||
// persisted tool message back to its structured call/result for the
|
||||
// tool_calls audit log, since callbacks can fire out of order when
|
||||
// tools run concurrently.
|
||||
type recordedCall struct {
|
||||
call vllmkit.ToolCallRequest
|
||||
result vllmkit.ToolResult
|
||||
used bool
|
||||
}
|
||||
|
||||
// runTurn replaces Handlers.runAgent. runAgent is the *agentrun.Agent
|
||||
// StartTurn selected for this turn — s.Agent normally, or s.FastAgent
|
||||
// when fast mode was requested and configured (see StartTurn) — rather
|
||||
// than a hardcoded s.Agent, so fast mode can swap in a differently
|
||||
// configured Agent with zero other change to how a turn runs.
|
||||
func (s *Service) runTurn(ctx context.Context, cancel context.CancelFunc, stream *domain.Stream, convID int64, history []vllmkit.Message, runAgent *agentrun.Agent) {
|
||||
defer cancel()
|
||||
defer s.Streams.ScheduleRemoval(stream)
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.Logger.Error("panic in agent run", "panic", rec, "conversation_id", convID)
|
||||
stream.Finish("⚠️ Something went wrong while running the agent.")
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []*recordedCall
|
||||
)
|
||||
cb := agentrun.Callbacks{
|
||||
OnToken: stream.SetToken,
|
||||
OnThinking: stream.SetThinking,
|
||||
OnThinkingRound: stream.AddThinkingRound,
|
||||
OnToolCall: func(call vllmkit.ToolCallRequest, result vllmkit.ToolResult) {
|
||||
mu.Lock()
|
||||
calls = append(calls, &recordedCall{call: call, result: result})
|
||||
mu.Unlock()
|
||||
stream.AddTool(domain.ToolCallResult{
|
||||
Name: result.Name, ArgumentsJSON: argsJSON(call.Arguments), Result: result.Content, IsError: result.IsError,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
final, toPersist, runErr := runAgent.Run(ctx, history, cb)
|
||||
|
||||
pctx, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer pcancel()
|
||||
|
||||
note := ""
|
||||
switch {
|
||||
case runErr == nil:
|
||||
case agentrun.IsCancelled(runErr) && errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
note = fmt.Sprintf("⏱ Stopped: this reply hit the %s time limit.", maxRunDuration)
|
||||
case agentrun.IsCancelled(runErr):
|
||||
note = "⏹ Stopped."
|
||||
case vllmkit.IsUpstreamUnavailable(runErr):
|
||||
// The vLLM chat deployment is unreachable (Pending/restarting/
|
||||
// mid-rollout) — a raw "dial tcp ...: connection refused"
|
||||
// tells the end user nothing actionable, so show a short
|
||||
// status message instead and keep the real error in the logs
|
||||
// for whoever runs k8s/scripts/gpu-preflight.sh.
|
||||
s.Logger.Error("agent run failed: vllm upstream unavailable", "err", runErr, "conversation_id", convID)
|
||||
note = "⚠️ The AI model service is starting up or temporarily unavailable. Please try again in a minute."
|
||||
default:
|
||||
s.Logger.Error("agent run failed", "err", runErr, "conversation_id", convID)
|
||||
note = "⚠️ " + runErr.Error()
|
||||
}
|
||||
if note != "" {
|
||||
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: note})
|
||||
}
|
||||
|
||||
for _, m := range toPersist {
|
||||
resp, err := s.Conv.AddMessage(pctx, &conversationv1.AddMessageRequest{
|
||||
ConversationId: convID, Role: toProtoRole(m.Role), Content: m.Content, ToolName: m.ToolName,
|
||||
})
|
||||
if err != nil {
|
||||
s.Logger.Error("persist message", "err", err, "conversation_id", convID)
|
||||
continue
|
||||
}
|
||||
if m.Role != vllmkit.RoleTool {
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
for _, rc := range calls {
|
||||
if !rc.used && rc.call.Name == m.ToolName && rc.result.Content == m.Content {
|
||||
rc.used = true
|
||||
if _, err := s.Conv.AddToolCall(pctx, &conversationv1.AddToolCallRequest{
|
||||
MessageId: resp.GetId(), ToolName: rc.call.Name, ArgumentsJson: argsJSON(rc.call.Arguments),
|
||||
Result: rc.result.Content, IsError: rc.result.IsError,
|
||||
}); err != nil {
|
||||
s.Logger.Warn("persist tool call", "err", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
display := strings.TrimSpace(final)
|
||||
if note != "" {
|
||||
if display != "" {
|
||||
display += "\n\n"
|
||||
}
|
||||
display += note
|
||||
}
|
||||
if display == "" {
|
||||
display = "*The model returned an empty response.*"
|
||||
}
|
||||
|
||||
var toolCalls []domain.ToolCallResult
|
||||
mu.Lock()
|
||||
for _, rc := range calls {
|
||||
toolCalls = append(toolCalls, domain.ToolCallResult{
|
||||
Name: rc.call.Name, ArgumentsJSON: argsJSON(rc.call.Arguments), Result: rc.result.Content, IsError: rc.result.IsError,
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
stream.FinishWithToolCalls(display, toolCalls)
|
||||
}
|
||||
|
||||
// StopTurn replaces Handlers.StopStream. Returns false if streamID
|
||||
// doesn't belong to userID (or doesn't exist), exactly like the
|
||||
// monolith's http.NotFound check.
|
||||
func (s *Service) StopTurn(userID int64, streamID string) bool {
|
||||
stream := s.Streams.Get(streamID)
|
||||
if stream == nil || stream.UserID != userID {
|
||||
return false
|
||||
}
|
||||
stream.Cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
func argsJSON(args map[string]any) string {
|
||||
// Matches llm.ArgsJSON's fallback-to-"{}" behavior exactly.
|
||||
b, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
42
services/agent/internal/application/throttle.go
Normal file
42
services/agent/internal/application/throttle.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/authsupport"
|
||||
)
|
||||
|
||||
// throttle replaces Handlers.throttle (see the same helper in the iam
|
||||
// service's application package for the full doc comment — identical
|
||||
// behavior, duplicated rather than shared because it's five lines and
|
||||
// not worth a dependency between two bounded contexts).
|
||||
func throttle(ctx context.Context, l *authsupport.Limiter, keys ...string) bool {
|
||||
var wait time.Duration
|
||||
for _, k := range keys {
|
||||
if d := l.Delay(k); d > wait {
|
||||
wait = d
|
||||
}
|
||||
}
|
||||
if !sleepCtx(ctx, wait) {
|
||||
return false
|
||||
}
|
||||
for _, k := range keys {
|
||||
l.RecordFailure(k)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
203
services/agent/internal/domain/stream.go
Normal file
203
services/agent/internal/domain/stream.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Package domain holds the agent service's own aggregate: one in-flight
|
||||
// (or just-finished) agent run. Transplanted from
|
||||
// internal/handlers/chat.go's StreamManager/Stream, with one
|
||||
// deliberate change: where the monolith's Stream stored pre-rendered
|
||||
// HTML (because the same process also owned templates), this one
|
||||
// stores plain text and structured tool calls — rendering now happens
|
||||
// in the gateway, the only service that imports pkg/renderkit. See
|
||||
// ARCHITECTURE.md's "Where rendering lives" note.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxRunDuration = 30 * time.Minute // hard cap on one agent turn
|
||||
MaxStreamsPerUser = 3 // concurrent in-flight replies per account
|
||||
FinishedStreamTTL = 10 * time.Minute // how long a finished stream stays replayable
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConversationBusy = errors.New("this conversation is still answering")
|
||||
ErrTooManyStreams = errors.New("too many replies in progress")
|
||||
)
|
||||
|
||||
// ToolCallResult is one completed tool call, as recorded on a Stream.
|
||||
type ToolCallResult struct {
|
||||
Name string
|
||||
ArgumentsJSON string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ThinkingRound is one agent round's own standalone reasoning trace, as
|
||||
// recorded on a Stream — the round-indexed counterpart to ThinkText's
|
||||
// single running total. See agentrun.Callbacks.OnThinkingRound.
|
||||
type ThinkingRound struct {
|
||||
Round int
|
||||
Thinking string
|
||||
}
|
||||
|
||||
// Stream is the observable state of one agent run. Like the monolith's
|
||||
// version, it keeps the latest snapshot rather than a log of every
|
||||
// token event (token text is cumulative anyway), plus an append-only
|
||||
// list of completed tool calls, keeping memory bounded and letting a
|
||||
// late attacher (StreamTurn reconnecting) cheaply catch up.
|
||||
type Stream struct {
|
||||
ID string
|
||||
UserID int64
|
||||
ConvID int64
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
changed chan struct{} // closed & replaced on every update (broadcast)
|
||||
tokenText string // latest cumulative reply
|
||||
thinkText string // latest cumulative reasoning trace
|
||||
tools []ToolCallResult
|
||||
thinkingRounds []ThinkingRound // append-only, one per round that produced reasoning — see AddThinkingRound
|
||||
finalText string
|
||||
// finalToolCalls is every tool call made during the run (successful
|
||||
// or not), handed to the gateway alongside finalText so it can
|
||||
// build deliverable cards the same way
|
||||
// internal/handlers/deliverables.go did — see TurnDone in
|
||||
// api/proto/agent/v1/agent.proto.
|
||||
finalToolCalls []ToolCallResult
|
||||
done bool
|
||||
}
|
||||
|
||||
type StreamSnapshot struct {
|
||||
TokenText, ThinkText, FinalText string
|
||||
Tools []ToolCallResult
|
||||
ThinkingRounds []ThinkingRound
|
||||
FinalToolCalls []ToolCallResult
|
||||
Done bool
|
||||
}
|
||||
|
||||
func (s *Stream) update(fn func()) {
|
||||
s.mu.Lock()
|
||||
fn()
|
||||
close(s.changed)
|
||||
s.changed = make(chan struct{})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) Snapshot() (StreamSnapshot, <-chan struct{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return StreamSnapshot{
|
||||
TokenText: s.tokenText, ThinkText: s.thinkText, FinalText: s.finalText,
|
||||
Tools: s.tools[:len(s.tools):len(s.tools)], // append-only; safe to share
|
||||
ThinkingRounds: s.thinkingRounds[:len(s.thinkingRounds):len(s.thinkingRounds)], // append-only; safe to share
|
||||
FinalToolCalls: s.finalToolCalls,
|
||||
Done: s.done,
|
||||
}, s.changed
|
||||
}
|
||||
|
||||
func (s *Stream) IsDone() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.done
|
||||
}
|
||||
|
||||
func (s *Stream) SetToken(text string) { s.update(func() { s.tokenText = text }) }
|
||||
func (s *Stream) SetThinking(text string) { s.update(func() { s.thinkText = text }) }
|
||||
func (s *Stream) AddTool(tc ToolCallResult) { s.update(func() { s.tools = append(s.tools, tc) }) }
|
||||
|
||||
// AddThinkingRound records one round's standalone reasoning trace —
|
||||
// the round-indexed counterpart to SetThinking's running total. See
|
||||
// agentrun.Callbacks.OnThinkingRound.
|
||||
func (s *Stream) AddThinkingRound(round int, thinking string) {
|
||||
s.update(func() { s.thinkingRounds = append(s.thinkingRounds, ThinkingRound{Round: round, Thinking: thinking}) })
|
||||
}
|
||||
func (s *Stream) Finish(finalText string) {
|
||||
s.update(func() { s.finalText = finalText; s.done = true })
|
||||
}
|
||||
|
||||
// FinishWithToolCalls is Finish plus the full tool-call list for this
|
||||
// run, used by the normal (non-panic, non-empty-history) completion
|
||||
// path in runTurn.
|
||||
func (s *Stream) FinishWithToolCalls(finalText string, toolCalls []ToolCallResult) {
|
||||
s.update(func() { s.finalText = finalText; s.finalToolCalls = toolCalls; s.done = true })
|
||||
}
|
||||
|
||||
func (s *Stream) Cancel() { s.cancel() }
|
||||
|
||||
// Manager tracks in-flight (and just-finished) agent runs — identical
|
||||
// in behavior to the monolith's StreamManager, including the "second
|
||||
// concurrent run on the same conversation is refused, not queued"
|
||||
// invariant (their messages would interleave) and the per-user
|
||||
// concurrency cap.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
streams map[string]*Stream
|
||||
byConv map[int64]*Stream
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{streams: map[string]*Stream{}, byConv: map[int64]*Stream{}}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(userID, convID int64, cancel context.CancelFunc) (*Stream, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if s, ok := m.byConv[convID]; ok && !s.IsDone() {
|
||||
return nil, ErrConversationBusy
|
||||
}
|
||||
active := 0
|
||||
for _, s := range m.streams {
|
||||
if s.UserID == userID && !s.IsDone() {
|
||||
active++
|
||||
}
|
||||
}
|
||||
if active >= MaxStreamsPerUser {
|
||||
return nil, ErrTooManyStreams
|
||||
}
|
||||
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Stream{ID: hex.EncodeToString(b[:]), UserID: userID, ConvID: convID, cancel: cancel, changed: make(chan struct{})}
|
||||
m.streams[s.ID] = s
|
||||
m.byConv[convID] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(id string) *Stream {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.streams[id]
|
||||
}
|
||||
|
||||
func (m *Manager) ActiveFor(convID int64) *Stream {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.byConv[convID]; ok && !s.IsDone() {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CancelConversation(convID int64) {
|
||||
if s := m.ActiveFor(convID); s != nil {
|
||||
s.Cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) ScheduleRemoval(s *Stream) {
|
||||
time.AfterFunc(FinishedStreamTTL, func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.streams, s.ID)
|
||||
if m.byConv[s.ConvID] == s {
|
||||
delete(m.byConv, s.ConvID)
|
||||
}
|
||||
})
|
||||
}
|
||||
924
services/agent/internal/infrastructure/agentrun/agent.go
Normal file
924
services/agent/internal/infrastructure/agentrun/agent.go
Normal file
@@ -0,0 +1,924 @@
|
||||
// Package agentrun is the agent service's core: the same round-trip
|
||||
// loop as the monolith's internal/llm/agent.go (send history to the
|
||||
// chat model, execute any requested tool calls, feed results back, repeat
|
||||
// until a final answer), with two changes forced by the split into
|
||||
// services: tool execution goes through a ToolExecutor (an interface
|
||||
// satisfied by toolexec.Executor, which calls out to the workspace and
|
||||
// knowledge services over gRPC) instead of an in-process Toolbox, and
|
||||
// the SystemPrompt's tool descriptions now live in
|
||||
// internal/infrastructure/toolcatalog rather than being read directly
|
||||
// off the Toolbox. The round-trip algorithm itself — compactToolHistory,
|
||||
// MaxRounds, the concurrent-tool-call fan-out — is unchanged.
|
||||
package agentrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
"enkly/pkg/i18n"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// SystemPrompt is prepended to every main-agent conversation. It is not
|
||||
// persisted; Agent rebuilds it fresh on every turn (plus whatever
|
||||
// MemoryLoader contributes — see buildSystemPrompt) — identical text
|
||||
// to the monolith's, since the model has been prompted and tested
|
||||
// against this precise wording.
|
||||
const SystemPrompt = `You are enkly, a local, open-source agentic coding assistant modeled after Claude Code.
|
||||
|
||||
## Tools
|
||||
- list_directory, glob_search — orient yourself: see what's there before guessing paths.
|
||||
- grep — exact/lexical search (a symbol, an error string, a TODO). Fast and precise.
|
||||
- search_codebase — fuzzy, conceptual search ("where do we validate a session") over the
|
||||
pgvector-indexed codebase. Prefer grep when you already know the literal text you want.
|
||||
- read_file — always read a file (or the specific lines you need, via offset/limit on a
|
||||
large one) before editing it. Never edit blind.
|
||||
- edit_file — the default way to change an EXISTING file: give an exact old_string (with
|
||||
enough surrounding context to be unique) and its replacement. It fails loudly instead of
|
||||
guessing if that text isn't found or isn't unique, so a failure means "go read the file
|
||||
again," not "try something else and hope." Returns a diff preview — read it to confirm
|
||||
the change landed where you meant.
|
||||
- multi_edit_file — several edit_file-style replacements applied to ONE file as a single
|
||||
atomic operation: if any edit in the list fails, none of them are written. Prefer this
|
||||
over multiple separate edit_file calls on the same file (e.g. renaming a symbol at its
|
||||
definition and every call site) — one round-trip, and it can never leave the file
|
||||
half-edited if a later edit in the batch turns out to be wrong. Edits apply in order, so
|
||||
a later one may rely on text an earlier one just introduced.
|
||||
- write_file — only for brand-new files, or a genuine full rewrite you've deliberately
|
||||
chosen over a series of edits. Never use it to make a small change to an existing file;
|
||||
that risks silently dropping unrelated content.
|
||||
- run_bash_command — builds, tests, formatters, linters, git. After any nontrivial edit,
|
||||
prefer running the relevant build/tests here over asserting the change is correct.
|
||||
- write_todos — for a nontrivial request (roughly: more than ~3 steps, or touching more
|
||||
than one file), write a short plan as todos before touching anything, mark exactly one
|
||||
item in_progress at a time as you work through it, and mark it completed before moving
|
||||
on. Skip this entirely for a small, single-step request — planning overhead a user
|
||||
didn't need to see is worse than no plan.
|
||||
- solve_ml_math — for any concrete, numeric ML-math question (linear algebra, a
|
||||
derivative/gradient/integral, a probability or statistics computation, gradient descent
|
||||
or root finding, an activation or loss function, fitting a least-squares linear
|
||||
regression), compute it with this tool instead of doing the arithmetic yourself in
|
||||
prose. It runs real Go numerical code, so it won't silently slip a digit the way working
|
||||
it out by hand can. Explain the result in plain terms after the call; don't just paste
|
||||
the raw tool output.
|
||||
- read_office_skill — optional background reading for the document tools below, not a
|
||||
required first step: create_document/create_spreadsheet/create_presentation's own
|
||||
descriptions already cover what you need for an ordinary document (a CV, a memo, a
|
||||
one-sheet table), so for one of those, call the create_*/edit_* tool directly — don't
|
||||
spend a round reading a skill file first. Reach for this before something that leans on
|
||||
a format's sharper edges (an Excel formula or multi-sheet workbook, a multi-slide
|
||||
PowerPoint deck, edit_document's replace_text run-splitting caveat) or when you're not
|
||||
sure a feature someone's asking for is even supported. Start with the "overview" skill
|
||||
if you're not sure which format a request needs. No need to re-read a skill you've
|
||||
already read earlier in this same conversation.
|
||||
- search_office_skills — a retrieval-augmented complement to read_office_skill: give it a
|
||||
specific question ("can a cell hold a formula", "how do I bold part of a paragraph")
|
||||
and it returns just the matching section(s) across the office skills, ranked by semantic
|
||||
similarity, instead of a whole skill file. Reach for this when you have one narrow
|
||||
question mid-task rather than re-reading a full skill; reach for read_office_skill when
|
||||
you want a format's complete guidance before starting.
|
||||
- create_document/read_document/edit_document, create_spreadsheet/read_spreadsheet/
|
||||
edit_spreadsheet, create_presentation/read_presentation/edit_presentation — Word, Excel
|
||||
and PowerPoint document management. Give plain structured fields (paragraph text and
|
||||
style, a grid of cell values, a slide's title and bullets) — never write raw OOXML/XML
|
||||
yourself; these tools render a real, valid .docx/.xlsx/.pptx file from what you give
|
||||
them. Read a document/spreadsheet/presentation back before editing it if you're not sure
|
||||
what it currently contains.
|
||||
- dispatch_subagent — hand off a broad or open-ended investigation ("find every place X
|
||||
happens", "map out how Y flows through this service") to a read-only sub-agent that
|
||||
explores on its own and returns one written report, instead of chaining many of your own
|
||||
search/read calls. Its exploration never touches your context — only its final report
|
||||
does — so this is how you keep a long investigation from eating the context budget you
|
||||
need for the actual edit. It cannot write files or run commands; dispatch work to it, not
|
||||
changes. For a single targeted lookup, just call the tool yourself — dispatching has
|
||||
overhead of its own.
|
||||
- dispatch_workflow — the large-scale version of dispatch_subagent: give it a batch of
|
||||
independent (or dependency-ordered) steps and it runs them in parallel, verifies each
|
||||
report against its own step, and hands you back a per-step status so you can see at a
|
||||
glance what needs attention. Reach for this on a job that's really the same investigation
|
||||
repeated across many files, services, or call sites (a migration, a repo-wide rename, "map
|
||||
what every handler in this package does") instead of looping dispatch_subagent calls
|
||||
yourself one at a time — that serializes work this tool runs concurrently. It carries the
|
||||
same read-only boundary dispatch_subagent has (investigate and report, never write or run),
|
||||
so plan the actual edits as a follow-up once you've read every step's findings. Not
|
||||
available on every plan; if it reports itself unavailable, fall back to dispatch_subagent.
|
||||
|
||||
## How to work
|
||||
- If the workspace root has an ENKLY.md file, its contents appear below under "Project
|
||||
memory" — read it like a project README written specifically for you (conventions,
|
||||
gotchas, how to run things here) and weigh it alongside, not instead of, what you find by
|
||||
actually looking at the code; it can go stale.
|
||||
- Investigate before acting: for anything beyond a one-line, obvious fix, look at the
|
||||
relevant code (list_directory/glob_search/grep/search_codebase, then read_file) before
|
||||
proposing or making a change, rather than pattern-matching from the file names alone.
|
||||
- Independent reads (several files, a search plus a directory listing) can be requested in
|
||||
the same round — they'll run concurrently — but don't parallelize a write with anything
|
||||
that depends on reading first.
|
||||
- Prefer several small, verifiable edits over one large speculative rewrite. Verify with
|
||||
run_bash_command (build/tests) when you can, rather than only asserting the change works.
|
||||
- State your plan briefly before calling tools, and summarize what actually happened
|
||||
afterward — including when a tool failed and what you did about it. Don't narrate every
|
||||
intermediate tool call at length; keep the reasoning proportional to the task.
|
||||
- If a tool call fails, read the error, adjust, and retry rather than repeating the same
|
||||
call or silently giving up. If you're genuinely blocked (missing credentials, an
|
||||
ambiguous requirement), say so plainly instead of guessing.
|
||||
- Decide with a tool call, don't describe one: if a request needs a file read, a search run,
|
||||
or a change made, call the matching tool in this same turn rather than writing "I'll read
|
||||
X" or "let's check Y" as plain text and stopping — that line of prose isn't the action, and
|
||||
leaving it unpaired with the actual call wastes a round. Conversely, don't call a tool out
|
||||
of habit when you already have what you need (e.g. re-reading a file you read two rounds
|
||||
ago and haven't touched since) — pick the smallest set of calls that actually moves the
|
||||
task forward, in parallel when they're independent of each other (see above).
|
||||
- Say what you actually found, including the parts that don't look good: if a change you
|
||||
made doesn't fully address the request, if tests fail and you couldn't get them passing, if
|
||||
you noticed a bug or a risk while working that's outside what was asked, or if you're not
|
||||
sure an edit is correct, say so plainly in your summary rather than describing the work as
|
||||
more complete or more certain than it is. A clearly flagged limitation is useful; a clean-
|
||||
sounding summary that quietly omits a known problem is not — the user can only act on what
|
||||
you tell them, so an unreported bug you noticed is strictly worse than a reported one you
|
||||
didn't have time to fix.
|
||||
|
||||
## Language
|
||||
- Detect the language the user is writing in and reply in that same language by default —
|
||||
don't ask them to pick one, and don't switch to English just because the codebase or your
|
||||
tools are in English. This covers everything you say to them: prose, plans, write_todos
|
||||
items, and summaries of what you changed.
|
||||
- If they switch languages mid-conversation, follow the switch on your next reply. If a
|
||||
message mixes languages (e.g. a pasted English stack trace inside an otherwise Spanish
|
||||
question), reply in the language of their own prose and leave quoted code, paths,
|
||||
commands, and error text exactly as given — don't translate those.
|
||||
- Code stays in English regardless of conversation language: identifiers, source comments,
|
||||
commit messages, and code-review feedback written inline in a diff follow the codebase's
|
||||
existing convention. Only your own explanations to the user switch languages.
|
||||
- If the user explicitly asks for replies in a specific language, use it from then on even
|
||||
if their own messages continue in a different one.`
|
||||
|
||||
// preferredLanguageKey is the context.Context key WithPreferredLanguage
|
||||
// stores a locale under. An unexported type (not string) so no other
|
||||
// package's context.WithValue key can collide with it — the standard
|
||||
// Go idiom for a context key.
|
||||
type preferredLanguageKey struct{}
|
||||
|
||||
// WithPreferredLanguage attaches the visitor's resolved UI locale to
|
||||
// ctx, for Run to fold into the system prompt (see buildSystemPrompt's
|
||||
// languageDirective). This is a second, standing signal layered on
|
||||
// top of the model's own per-message language detection (the
|
||||
// "## Language" section of SystemPrompt above) rather than a
|
||||
// replacement for it:
|
||||
//
|
||||
// - The caller (application.Service.StartTurn) always has SOME
|
||||
// locale by this point — an explicit cookie from the language
|
||||
// switcher, or failing that enkly's best guess from the visitor's
|
||||
// browser Accept-Language header, or failing that i18n.Default
|
||||
// ("en") — see the gateway's localeFrom and grpc/server.go's
|
||||
// langFromIncomingContext, which is where that resolution actually
|
||||
// happens; nothing in this package re-derives it. So this directive
|
||||
// is present on effectively every run, not only for a visitor who
|
||||
// has explicitly touched the language switcher — which is exactly
|
||||
// why languageDirective's wording below says "is using", not "has
|
||||
// set": it's honest about covering both an explicit choice and a
|
||||
// negotiated guess.
|
||||
// - What it's FOR is a reply to a short or ambiguous message (a bare
|
||||
// greeting, or any message before the visitor has typed enough for
|
||||
// detection alone to confidently place) — the same role Claude
|
||||
// Code's own `/language` setting plays alongside its automatic
|
||||
// detection, just seeded from a browser header when no explicit
|
||||
// choice exists yet rather than left unset.
|
||||
// - SystemPrompt's own rule still wins for the actual conversation:
|
||||
// if the visitor starts typing in a language that disagrees with
|
||||
// this directive, follow what they're typing (see "If they switch
|
||||
// languages mid-conversation…" above) — this is a default for when
|
||||
// there's nothing stronger to go on, never an override of what's
|
||||
// in front of the model right now.
|
||||
//
|
||||
// context.Value plumbing (rather than a field on Agent or a Run
|
||||
// parameter) was the natural fit here because MemoryLoader already
|
||||
// establishes the same pattern one field up: a per-run signal threaded
|
||||
// through ctx and read back inside buildSystemPrompt, not stored on
|
||||
// the shared, concurrently-used Agent struct. dispatch_subagent passes
|
||||
// its own derived context straight through to the sub-agent's Run
|
||||
// (see toolexec.Executor.dispatchSubagent), so a sub-agent's report
|
||||
// lands in the same language as the conversation that spawned it with
|
||||
// no extra plumbing needed.
|
||||
func WithPreferredLanguage(ctx context.Context, lang i18n.Locale) context.Context {
|
||||
return context.WithValue(ctx, preferredLanguageKey{}, lang)
|
||||
}
|
||||
|
||||
// preferredLanguageFrom reads back what WithPreferredLanguage stored,
|
||||
// or "" if nothing was ever set (no gateway call set it, a direct
|
||||
// internal call to Run, or a test).
|
||||
func preferredLanguageFrom(ctx context.Context) i18n.Locale {
|
||||
lang, _ := ctx.Value(preferredLanguageKey{}).(i18n.Locale)
|
||||
return lang
|
||||
}
|
||||
|
||||
// languageDirective turns a non-empty preferred locale into the extra
|
||||
// system-prompt paragraph buildSystemPrompt appends. Kept separate
|
||||
// from buildSystemPrompt itself mainly so the exact wording — and the
|
||||
// reasoning behind it — has one obvious place to live and to test.
|
||||
//
|
||||
// "is using", deliberately not "has set": per WithPreferredLanguage's
|
||||
// doc comment, lang is almost always present and may be an explicit
|
||||
// choice from enkly's language switcher or just a browser-negotiated
|
||||
// guess — the model shouldn't be told a visitor "set" something they
|
||||
// may never have touched.
|
||||
func languageDirective(lang i18n.Locale) string {
|
||||
if lang == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"\n\n## Interface language\nThe user is using %s as enkly's interface language. "+
|
||||
"Treat that as your default reply language for this conversation — including a first reply to a "+
|
||||
"short greeting or an ambiguous message you can't otherwise place. The \"## Language\" rules above "+
|
||||
"still apply on top of this: if the user is actually typing in a different language, follow what "+
|
||||
"they're typing, not this setting.",
|
||||
i18n.NameOf(lang),
|
||||
)
|
||||
}
|
||||
|
||||
// SubagentSystemPrompt is prepended instead of SystemPrompt for a run
|
||||
// launched by the main agent's dispatch_subagent tool (see
|
||||
// NewSubagent). The sub-agent only ever gets one user message — the
|
||||
// "task" string the main agent wrote — and no ongoing conversation, so
|
||||
// this prompt is written around a single investigate-then-report turn
|
||||
// rather than an open-ended back-and-forth.
|
||||
const SubagentSystemPrompt = `You are a focused, read-only investigation sub-agent spawned by enkly (an agentic coding
|
||||
assistant) to answer exactly one question about this workspace, then report back. You were
|
||||
given a single task description as your only input — there is no further conversation, so
|
||||
make your report self-contained and complete the first time.
|
||||
|
||||
## Tools
|
||||
You have list_directory, glob_search, grep, read_file and search_codebase — nothing that
|
||||
writes a file, edits a file, or runs a command. You cannot change anything in this
|
||||
workspace, and nothing you do here needs to be undone.
|
||||
|
||||
## How to work
|
||||
- Orient first (list_directory/glob_search), narrow with grep (exact/lexical) or
|
||||
search_codebase (fuzzy/conceptual), then read_file the specific files that matter. Don't
|
||||
read files wholesale "just in case" — every call you make costs context that doesn't
|
||||
benefit the agent that dispatched you, only you, so stay targeted.
|
||||
- Investigate until you can answer the task with evidence, not guesses, then stop — more
|
||||
rounds than necessary just delays the report.
|
||||
- Write your final answer as a standalone report: lead with the direct answer, then the
|
||||
specific evidence (file paths, line-level detail, short quoted fragments) that backs it
|
||||
up. The agent reading your report has none of the context you gathered getting there, so
|
||||
name files and locations explicitly rather than saying "the file I found."
|
||||
- If the task is ambiguous or you can't find what it asks for, say so plainly and report
|
||||
what you did find instead of inventing an answer.
|
||||
- Write your report in the language the task description was written in — the agent that
|
||||
dispatched you will relay or translate it for the user as needed.`
|
||||
|
||||
// ToolExecutor runs one tool call and returns its result. Satisfied by
|
||||
// *toolexec.Executor; kept as an interface here purely so agentrun
|
||||
// doesn't import toolexec directly (and so a test can supply a fake).
|
||||
type ToolExecutor interface {
|
||||
Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult
|
||||
}
|
||||
|
||||
// ChatStreamer is the subset of *vllmkit.Client Agent needs. The
|
||||
// think parameter is a per-call effort override — see
|
||||
// vllmkit.Client.ChatStream and Agent.ThinkPolicy.
|
||||
type ChatStreamer interface {
|
||||
ChatStream(ctx context.Context, messages []vllmkit.Message, tools []vllmkit.ToolDef, think string) (<-chan vllmkit.ChatEvent, error)
|
||||
}
|
||||
|
||||
// Callbacks lets the caller (the application-layer StartTurn use case)
|
||||
// observe an in-flight run so it can push updates onto a domain.Stream.
|
||||
type Callbacks struct {
|
||||
OnToken func(cumulativeText string)
|
||||
OnThinking func(cumulativeThinking string)
|
||||
OnToolCall func(call vllmkit.ToolCallRequest, result vllmkit.ToolResult)
|
||||
// OnThinkingRound, if set, fires once per completed round with just
|
||||
// that round's own reasoning trace (round is 0-indexed), rather
|
||||
// than the running cumulative text OnThinking gets — the same
|
||||
// per-step granularity Claude's interleaved thinking blocks give a
|
||||
// UI: one distinct block of reasoning per tool-use step, not one
|
||||
// blob for the whole turn. Optional; nil is a no-op.
|
||||
OnThinkingRound func(round int, thinking string)
|
||||
}
|
||||
|
||||
// ThinkPolicy decides the per-round thinking-effort override (see
|
||||
// vllmkit.Client.ChatStream's think parameter: "", "true", "false",
|
||||
// "low", "medium", "high") passed for one round of an agent run. round
|
||||
// is 0-indexed; retrying is true when this call is a same-round retry
|
||||
// after a template-parse hiccup (see Run's attempt loop). Returning ""
|
||||
// defers entirely to the ChatStreamer's own configured default.
|
||||
type ThinkPolicy func(round int, retrying bool) string
|
||||
|
||||
// DefaultThinkPolicy asks for deeper reasoning on round 0 — the
|
||||
// planning step where the model has only the user's request and no
|
||||
// tool results yet, so investing more thinking effort up front pays
|
||||
// off across the whole turn — and otherwise leaves effort at the
|
||||
// client's configured default. A retry of the same round (after a
|
||||
// template-parse hiccup) asks for "low" instead: the goal of a retry
|
||||
// is a fast, simple re-attempt, not another expensive reasoning pass
|
||||
// over content that's already a known troublemaker.
|
||||
func DefaultThinkPolicy(round int, retrying bool) string {
|
||||
if retrying {
|
||||
return "low"
|
||||
}
|
||||
if round == 0 {
|
||||
return "high"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Agent ties together the LLM client, the tool executor, and bounds how
|
||||
// many tool-call round-trips one turn may take.
|
||||
type Agent struct {
|
||||
Client ChatStreamer
|
||||
Executor ToolExecutor
|
||||
Tools []vllmkit.ToolDef
|
||||
// MaxRounds matches the monolith's NewAgent default.
|
||||
MaxRounds int
|
||||
// SystemPrompt is prepended to every run this Agent executes. Set by
|
||||
// New (SystemPrompt) or NewSubagent (SubagentSystemPrompt); exported
|
||||
// so a caller can override it entirely if it ever needs to (tests,
|
||||
// a future third agent flavor) without touching this package.
|
||||
SystemPrompt string
|
||||
// MemoryLoader, if set, is called once at the start of every Run and
|
||||
// its (trimmed) return value is appended to the system prompt under
|
||||
// a "Project memory" heading — the mechanism behind the main
|
||||
// agent's ENKLY.md support (see main.go's wiring). Returning "" (the
|
||||
// file doesn't exist, or the read failed) adds nothing; a non-nil
|
||||
// error from the read itself is swallowed by the loader, not
|
||||
// reported here, so a transient infra hiccup never blocks a turn.
|
||||
// Left nil for the subagent flavor — a sub-agent gets its task as
|
||||
// its one input, not the project's standing notes.
|
||||
MemoryLoader func(ctx context.Context) string
|
||||
// ThinkPolicy picks the thinking-effort override for each round (see
|
||||
// the ThinkPolicy type). Defaults to DefaultThinkPolicy in New and
|
||||
// NewSubagent; a caller (or a test) can override it, and setting it
|
||||
// to nil falls back to always passing "" (i.e. the ChatStreamer's
|
||||
// own configured default for every round). When Effort or a
|
||||
// per-call WithEffort(ctx, ...) is in force, Run derives the actual
|
||||
// per-round floor from EffortThinkPolicy(profile) instead of using
|
||||
// this field directly — see resolveEffort and effectiveLimits.
|
||||
// ThinkPolicy remains the fallback for a run with no effort level
|
||||
// resolved at all (effort.Default always resolves to one, in
|
||||
// practice, so this mainly matters for tests that call Run
|
||||
// directly without going through the Effort machinery).
|
||||
ThinkPolicy ThinkPolicy
|
||||
|
||||
// Effort is this Agent's default reasoning-depth level (see pkg/
|
||||
// effort) — used whenever a Run call's ctx carries no WithEffort
|
||||
// override. New and NewSubagent set it to effort.Default, so an
|
||||
// Agent that never touches effort control behaves exactly as it
|
||||
// did before this field existed (EffortThinkPolicy(effort.Default.
|
||||
// Resolve()) reproduces DefaultThinkPolicy's own round-0/retry
|
||||
// shape, just also carrying Medium's MaxRounds/ContextBudgetChars).
|
||||
Effort effort.Level
|
||||
// AdaptiveThink, if set, replaces the per-round floor from Effort
|
||||
// with AdaptiveThinkPolicy(EffortThinkPolicy(profile)) — see that
|
||||
// function's doc comment. Left nil by New/NewSubagent; main.go
|
||||
// turns it on by setting this to agentrun.AdaptiveThinkPolicy (the
|
||||
// function value, not a call to it — Run calls it per-run with the
|
||||
// resolved profile's floor). A deployment that wants every turn's
|
||||
// thinking effort fixed exactly at its level, with no
|
||||
// content-driven escalation, leaves this nil.
|
||||
AdaptiveThink func(floor ThinkPolicy) func(round int, retrying bool, sig RoundSignal) string
|
||||
// ContextBudgetChars, when nonzero, overrides both the package
|
||||
// default (contextBudgetChars) and whatever pkg/effort's resolved
|
||||
// profile would otherwise set for a run — see effectiveLimits. Set
|
||||
// this on a deployment that has measured its own model's usable
|
||||
// context and wants a hard ceiling regardless of the effort level
|
||||
// a particular turn happens to run at. Zero (the New/NewSubagent
|
||||
// default) means "defer entirely to the resolved effort profile".
|
||||
ContextBudgetChars int
|
||||
}
|
||||
|
||||
func New(client ChatStreamer, executor ToolExecutor, tools []vllmkit.ToolDef) *Agent {
|
||||
return &Agent{Client: client, Executor: executor, Tools: tools, MaxRounds: 12, SystemPrompt: SystemPrompt, ThinkPolicy: DefaultThinkPolicy, Effort: effort.Default}
|
||||
}
|
||||
|
||||
// NewSubagent builds the restricted Agent a dispatch_subagent call
|
||||
// runs. It shares the ChatStreamer (vLLM chat-completions requests are
|
||||
// independent, concurrency-safe round trips) but gets its own tool list — pass
|
||||
// toolcatalog.SubagentDefs() — its own executor — pass
|
||||
// toolexec.NewRestricted(mainExecutor) so the read-only boundary is
|
||||
// enforced even if a future tool list edit slips up — and a smaller
|
||||
// MaxRounds, since a sub-agent's job is one bounded investigation, not
|
||||
// an open-ended session.
|
||||
func NewSubagent(client ChatStreamer, executor ToolExecutor, tools []vllmkit.ToolDef) *Agent {
|
||||
return &Agent{Client: client, Executor: executor, Tools: tools, MaxRounds: 8, SystemPrompt: SubagentSystemPrompt, ThinkPolicy: DefaultThinkPolicy, Effort: effort.Default}
|
||||
}
|
||||
|
||||
// thinkSelector is a per-round think-effort function that, unlike
|
||||
// ThinkPolicy, also takes the live RoundSignal Run computed from the
|
||||
// round that just finished — the hook AdaptiveThinkPolicy needs and a
|
||||
// plain ThinkPolicy doesn't.
|
||||
type thinkSelector func(round int, retrying bool, sig RoundSignal) string
|
||||
|
||||
// effectiveLimits resolves the MaxRounds/ContextBudgetChars/thinkSelector
|
||||
// this Run call should actually use: the effort level in force (see
|
||||
// resolveEffort) sizes all three, a.ContextBudgetChars overrides the
|
||||
// level's own context budget when explicitly set, and a.MaxRounds is
|
||||
// used as a hard ceiling the resolved level's MaxRounds can only lower,
|
||||
// never raise — an operator-configured MaxRounds (e.g. a smaller
|
||||
// deployment capping every turn at 8 rounds regardless of level) always
|
||||
// wins over a caller asking for Max effort's 32.
|
||||
func (a *Agent) effectiveLimits(ctx context.Context) (maxRounds, contextBudget int, think thinkSelector) {
|
||||
// An Agent that has never been touched by effort control at all —
|
||||
// built by hand with neither ThinkPolicy nor Effort ever set,
|
||||
// rather than via New/NewSubagent (both of which always set both)
|
||||
// — keeps the exact pre-effort-control behavior: a.MaxRounds with
|
||||
// no clamping, the package's own default context budget, and no
|
||||
// per-round think override whatsoever. This is what lets
|
||||
// TestRun_NilThinkPolicyMeansNoOverride-style minimal test/caller
|
||||
// Agents keep working unchanged; any Agent built via New,
|
||||
// NewSubagent, or with Effort/ThinkPolicy set some other way opts
|
||||
// into the full effort-resolved behavior below.
|
||||
_, ctxHasEffort := effortFrom(ctx)
|
||||
if a.ThinkPolicy == nil && !a.Effort.Valid() && !ctxHasEffort {
|
||||
return a.MaxRounds, contextBudgetChars, func(int, bool, RoundSignal) string { return "" }
|
||||
}
|
||||
|
||||
profile := a.resolveEffort(ctx).Resolve()
|
||||
|
||||
maxRounds = profile.MaxRounds
|
||||
if a.MaxRounds > 0 && a.MaxRounds < maxRounds {
|
||||
maxRounds = a.MaxRounds
|
||||
}
|
||||
|
||||
contextBudget = profile.ContextBudgetChars
|
||||
if a.ContextBudgetChars > 0 {
|
||||
contextBudget = a.ContextBudgetChars
|
||||
}
|
||||
if contextBudget <= 0 {
|
||||
contextBudget = contextBudgetChars
|
||||
}
|
||||
|
||||
floor := EffortThinkPolicy(profile)
|
||||
if a.AdaptiveThink != nil && profile.AdaptiveThinking {
|
||||
think = a.AdaptiveThink(floor)
|
||||
} else {
|
||||
think = func(round int, retrying bool, _ RoundSignal) string { return floor(round, retrying) }
|
||||
}
|
||||
return maxRounds, contextBudget, think
|
||||
}
|
||||
|
||||
// signalFromPriorRound builds the RoundSignal for the round about to
|
||||
// run, from the round immediately before it. round0 (nothing has
|
||||
// happened yet) always gets the zero RoundSignal — there is nothing
|
||||
// yet to react to.
|
||||
func signalFromPriorRound(round, toolCallsSoFar int, priorResults []vllmkit.ToolResult) RoundSignal {
|
||||
sig := RoundSignal{Round: round, ToolCallsSoFar: toolCallsSoFar}
|
||||
for _, r := range priorResults {
|
||||
if r.IsError {
|
||||
sig.PriorRoundFailed = true
|
||||
}
|
||||
if len(r.Content) > sig.PriorToolResultBytes {
|
||||
sig.PriorToolResultBytes = len(r.Content)
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// memoryPromptChars caps how much of ENKLY.md gets folded into the
|
||||
// system prompt on every single turn — generous enough for a real
|
||||
// project-notes file, small enough that a mis-sized or accidentally
|
||||
// binary-ish file can't blow out the context budget every round.
|
||||
const memoryPromptChars = 8_000
|
||||
|
||||
// buildSystemPrompt returns a.SystemPrompt, plus a "Project memory"
|
||||
// section from a.MemoryLoader when one is set and returns non-empty
|
||||
// content.
|
||||
func (a *Agent) buildSystemPrompt(ctx context.Context) string {
|
||||
prompt := a.SystemPrompt
|
||||
|
||||
if a.MemoryLoader != nil {
|
||||
if mem := strings.TrimSpace(a.MemoryLoader(ctx)); mem != "" {
|
||||
if len(mem) > memoryPromptChars {
|
||||
omitted := len(mem) - memoryPromptChars
|
||||
mem = fmt.Sprintf("%s\n\n… [%d chars omitted — ENKLY.md is long; ask to read_file it directly if you need the rest]", safeHeadCut(mem, memoryPromptChars), omitted)
|
||||
}
|
||||
prompt += "\n\n## Project memory (ENKLY.md, workspace root)\n" + mem
|
||||
}
|
||||
}
|
||||
|
||||
prompt += languageDirective(preferredLanguageFrom(ctx))
|
||||
prompt += renderSystemEntries(systemEntriesFrom(ctx))
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Context budget for a single agent run — see compactToolHistory.
|
||||
const (
|
||||
contextBudgetChars = 60_000
|
||||
keepRecentToolMsgs = 4
|
||||
compactedHeadChars = 400
|
||||
compactedTailChars = 400
|
||||
)
|
||||
|
||||
// safeHeadCut and safeTailCut truncate a string to (at most) n bytes
|
||||
// without two kinds of damage a blind c[:n]/c[len(c)-n:] byte slice
|
||||
// can do: splitting a multi-byte UTF-8 rune in half (produces invalid
|
||||
// UTF-8), and — the more consequential one here — splitting an
|
||||
// angle-bracket tag in half (e.g. a file's own "<div" with no closing
|
||||
// ">"). This defensive cut point predates the move to vLLM: an
|
||||
// Ollama-served Qwen deployment's tool-calling chat template wrapped
|
||||
// tool calls/results in its own XML-ish delimiters and parsed the
|
||||
// result with Go's encoding/xml on its side, and a dangling,
|
||||
// unbalanced "<...>" left behind by a mid-tag cut was exactly the kind
|
||||
// of malformed markup that tripped its parser ("ollama error: XML
|
||||
// syntax error ... element closed by"). vLLM's OpenAI-compatible
|
||||
// tool-calling wire format carries tool content as a plain JSON string
|
||||
// field rather than XML-ish markup threaded through the chat template,
|
||||
// so that specific parse failure can no longer happen — but snapping
|
||||
// the cut to the nearest preceding '<' (for a head cut) or following
|
||||
// '>' (for a tail cut) is still cheap, harmless content hygiene (a
|
||||
// truncated tool result never hands the model a half-open tag either
|
||||
// way), so it stays rather than being ripped out for a backend that
|
||||
// no longer strictly requires it.
|
||||
func safeHeadCut(s string, n int) string {
|
||||
if n >= len(s) {
|
||||
return s
|
||||
}
|
||||
cut := n
|
||||
for cut > 0 && !isRuneBoundary(s, cut) {
|
||||
cut--
|
||||
}
|
||||
if lt := strings.LastIndexByte(s[:cut], '<'); lt >= 0 && cut-lt < 200 {
|
||||
cut = lt
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
|
||||
func safeTailCut(s string, n int) string {
|
||||
if n >= len(s) {
|
||||
return s
|
||||
}
|
||||
start := len(s) - n
|
||||
for start < len(s) && !isRuneBoundary(s, start) {
|
||||
start++
|
||||
}
|
||||
if gt := strings.IndexByte(s[start:], '>'); gt >= 0 && gt < 200 {
|
||||
start += gt + 1
|
||||
}
|
||||
return s[start:]
|
||||
}
|
||||
|
||||
func isRuneBoundary(s string, i int) bool {
|
||||
return i == 0 || i == len(s) || (s[i]&0xC0) != 0x80
|
||||
}
|
||||
|
||||
// degradeRecentToolMessages is the last resort in the tool-call-parse
|
||||
// self-heal (see the retry loop in Run and vllmkit.IsToolCallParseError):
|
||||
// when a couple of fresh retries of the exact same round still hit the
|
||||
// same tool-call parse failure, the most likely culprit is
|
||||
// specifically the newest tool result(s) in history — whatever just got
|
||||
// appended is, by construction, the one thing about this request that's
|
||||
// different from the last request that worked. This blanks the content
|
||||
// of the last n tool-role messages (in place, in this Run call's local
|
||||
// messages slice only — never what gets persisted to future turns) down
|
||||
// to a short placeholder, the same "it's gone, re-call the tool if you
|
||||
// need it" shape compactToolHistory already uses for ordinary context
|
||||
// compaction, and returns how many it actually touched.
|
||||
func degradeRecentToolMessages(messages []vllmkit.Message, n int) int {
|
||||
degraded := 0
|
||||
for i := len(messages) - 1; i >= 0 && degraded < n; i-- {
|
||||
if messages[i].Role != vllmkit.RoleTool {
|
||||
continue
|
||||
}
|
||||
messages[i].Content = fmt.Sprintf(
|
||||
"[this %s result was removed after it caused a tool-call parsing error; re-call the tool if you need it again]",
|
||||
messages[i].ToolName,
|
||||
)
|
||||
degraded++
|
||||
}
|
||||
return degraded
|
||||
}
|
||||
|
||||
// compactToolHistory mutates messages in place, shrinking older large
|
||||
// tool-result messages once the running total gets large against
|
||||
// budget (pass contextBudgetChars for the package default; Run passes
|
||||
// the per-effort-level budget resolved by effectiveLimits instead — see
|
||||
// Long context management below). Same core algorithm the monolith had
|
||||
// — unchanged by the service split since it only affects what gets
|
||||
// resent to the chat model within one run, never what's persisted — now
|
||||
// returns a one-line description of each message it actually trimmed
|
||||
// this call, for the caller (Run) to fold into a running compaction
|
||||
// ledger (see upsertCompactionLedger): "fewer drifts on long agentic
|
||||
// traces" means a turn that has compacted away its 30th tool result
|
||||
// should still be working from a stable map of what every earlier
|
||||
// round *did*, not just silently missing content with no trace it was
|
||||
// ever there.
|
||||
func compactToolHistory(messages []vllmkit.Message, budget int) []string {
|
||||
total := 0
|
||||
for _, m := range messages {
|
||||
total += len(m.Content)
|
||||
}
|
||||
if total <= budget {
|
||||
return nil
|
||||
}
|
||||
|
||||
var toolIdx []int
|
||||
for i, m := range messages {
|
||||
if m.Role == vllmkit.RoleTool {
|
||||
toolIdx = append(toolIdx, i)
|
||||
}
|
||||
}
|
||||
if len(toolIdx) <= keepRecentToolMsgs {
|
||||
return nil
|
||||
}
|
||||
|
||||
var trimmed []string
|
||||
for _, i := range toolIdx[:len(toolIdx)-keepRecentToolMsgs] {
|
||||
c := messages[i].Content
|
||||
if len(c) <= compactedHeadChars+compactedTailChars+64 {
|
||||
continue
|
||||
}
|
||||
omitted := len(c) - compactedHeadChars - compactedTailChars
|
||||
messages[i].Content = fmt.Sprintf(
|
||||
"%s\n\n… [%d chars omitted to save context — this was an earlier %s result; re-call it if you need the full output again] …\n\n%s",
|
||||
safeHeadCut(c, compactedHeadChars), omitted, messages[i].ToolName, safeTailCut(c, compactedTailChars),
|
||||
)
|
||||
// vllmkit.Message carries no IsError for a persisted tool
|
||||
// message (only ToolResult does, before it's folded into
|
||||
// history), so the ledger records what ran and how much was
|
||||
// trimmed, not success/failure — Run's own RoundSignal
|
||||
// tracking (see signalFromPriorRound) is the accurate source
|
||||
// for failure-driven adaptive thinking; this is purely a
|
||||
// "what happened, in order" map for the model to re-orient by.
|
||||
trimmed = append(trimmed, fmt.Sprintf("%s (%d chars omitted)", messages[i].ToolName, omitted))
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// compactionLedgerMarker prefixes the single, stable system message
|
||||
// upsertCompactionLedger maintains, so Run can find and update it
|
||||
// again on a later round instead of inserting a new one each time.
|
||||
const compactionLedgerMarker = "## Compaction ledger (auto-maintained)"
|
||||
|
||||
// upsertCompactionLedger folds newly-trimmed descriptions into
|
||||
// messages' running compaction ledger: a single system-role message,
|
||||
// inserted immediately after the main system prompt (index 0) the
|
||||
// first time anything is compacted, and simply re-rendered with a
|
||||
// larger list on every later round that compacts more — never
|
||||
// duplicated, never reordered, so a long turn's prefix up through the
|
||||
// ledger entry is still only ever growing by appended lines, not being
|
||||
// restructured. entries accumulates across the whole Run call (the
|
||||
// caller owns and grows the slice); this function only ever rewrites
|
||||
// the one ledger message's Content from the current full list.
|
||||
func upsertCompactionLedger(messages []vllmkit.Message, entries []string) []vllmkit.Message {
|
||||
if len(entries) == 0 {
|
||||
return messages
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(compactionLedgerMarker)
|
||||
b.WriteString("\nEarlier tool results below were shortened to save context. This is the full list of what ran, in " +
|
||||
"order, so you don't lose track of earlier steps even once their output is gone — re-call a tool if you need " +
|
||||
"its full output again, don't guess at it from this list.\n")
|
||||
for i, e := range entries {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, e)
|
||||
}
|
||||
content := strings.TrimRight(b.String(), "\n")
|
||||
|
||||
for i := range messages {
|
||||
if messages[i].Role == vllmkit.RoleSystem && strings.HasPrefix(messages[i].Content, compactionLedgerMarker) {
|
||||
messages[i].Content = content
|
||||
return messages
|
||||
}
|
||||
}
|
||||
ledgerMsg := vllmkit.Message{Role: vllmkit.RoleSystem, Content: content}
|
||||
if len(messages) == 0 {
|
||||
return append(messages, ledgerMsg)
|
||||
}
|
||||
out := make([]vllmkit.Message, 0, len(messages)+1)
|
||||
out = append(out, messages[0], ledgerMsg)
|
||||
out = append(out, messages[1:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// compactThinkingHistory mutates messages in place, blanking the
|
||||
// Thinking field on every assistant message except the most recent one
|
||||
// that still carries both thinking and tool calls. A round's reasoning
|
||||
// trace has already done its job once its tool call was issued and the
|
||||
// result appended to history — the model doesn't need round 3's
|
||||
// thinking resent to it while it's reasoning about round 7, any more
|
||||
// than Claude's interleaved thinking requires thinking blocks from
|
||||
// completed turns to be resent (the API only needs the block
|
||||
// immediately preceding the in-flight tool use). Re-sending every
|
||||
// round's full reasoning trace on every subsequent round would also
|
||||
// grow strictly with MaxRounds for no benefit, competing with
|
||||
// compactToolHistory's tool-result budget for the same context window.
|
||||
// Unlike compactToolHistory this isn't gated on a size threshold: it
|
||||
// runs every round, since the only cost of keeping old thinking around
|
||||
// is wasted context, never lost information the model still needs.
|
||||
func compactThinkingHistory(messages []vllmkit.Message) {
|
||||
kept := false
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
m := &messages[i]
|
||||
if m.Role != vllmkit.RoleAssistant || m.Thinking == "" {
|
||||
continue
|
||||
}
|
||||
if !kept {
|
||||
kept = true
|
||||
continue
|
||||
}
|
||||
m.Thinking = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes one full agent turn — same control-flow shape the
|
||||
// monolith's Agent.Run always had (send history, execute any requested
|
||||
// tools, feed results back, repeat), now also sizing itself to the
|
||||
// effort level in force (see effectiveLimits), escalating a round's
|
||||
// thinking effort on a concrete difficulty signal when adaptive
|
||||
// thinking is enabled (see signalFromPriorRound/AdaptiveThinkPolicy),
|
||||
// and maintaining a compaction ledger across however many rounds a
|
||||
// long run ends up compacting (see upsertCompactionLedger) instead of
|
||||
// just silently shrinking older tool output with no trace it existed.
|
||||
func (a *Agent) Run(ctx context.Context, history []vllmkit.Message, cb Callbacks) (finalText string, toPersist []vllmkit.Message, err error) {
|
||||
messages := make([]vllmkit.Message, len(history))
|
||||
copy(messages, history)
|
||||
messages = append([]vllmkit.Message{{Role: vllmkit.RoleSystem, Content: a.buildSystemPrompt(ctx)}}, messages...)
|
||||
|
||||
maxRounds, contextBudget, think := a.effectiveLimits(ctx)
|
||||
|
||||
var cumulative, cumulativeThinking string
|
||||
var ledger []string // running "what ran, in order" entries — see upsertCompactionLedger
|
||||
var priorResults []vllmkit.ToolResult // the just-finished round's tool outcomes, for this round's RoundSignal
|
||||
toolCallsSoFar := 0
|
||||
|
||||
for round := 0; round < maxRounds; round++ {
|
||||
if trimmed := compactToolHistory(messages, contextBudget); len(trimmed) > 0 {
|
||||
ledger = append(ledger, trimmed...)
|
||||
messages = upsertCompactionLedger(messages, ledger)
|
||||
}
|
||||
compactThinkingHistory(messages)
|
||||
|
||||
sig := signalFromPriorRound(round, toolCallsSoFar, priorResults)
|
||||
|
||||
var roundContent, roundThinking string
|
||||
var roundToolCalls []vllmkit.ToolCallRequest
|
||||
var roundErr error
|
||||
|
||||
// attemptChat runs one ChatStream call against the current
|
||||
// messages and streams it into roundContent/roundThinking/
|
||||
// roundToolCalls (resetting them first) and cumulative/
|
||||
// cumulativeThinking via cb, exactly as a single round always
|
||||
// has — factored out so the retry and degrade steps below can
|
||||
// each just call it again against a modified messages, instead
|
||||
// of duplicating this loop. retrying selects this round's
|
||||
// thinking-effort override via think (see effectiveLimits,
|
||||
// EffortThinkPolicy, AdaptiveThinkPolicy) — false for the first
|
||||
// attempt at this round, true for the template-parse self-heal
|
||||
// retries below.
|
||||
attemptChat := func(retrying bool) error {
|
||||
reqThink := ""
|
||||
if think != nil {
|
||||
reqThink = think(round, retrying, sig)
|
||||
}
|
||||
events, err := a.Client.ChatStream(ctx, messages, a.Tools, reqThink)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent: round %d: %w", round, err)
|
||||
}
|
||||
roundContent, roundThinking, roundToolCalls = "", "", nil
|
||||
for ev := range events {
|
||||
if ev.Err != nil {
|
||||
return fmt.Errorf("agent: round %d: %w", round, ev.Err)
|
||||
}
|
||||
if ev.Thinking != "" {
|
||||
roundThinking += ev.Thinking
|
||||
if cumulativeThinking != "" && roundThinking == ev.Thinking {
|
||||
cumulativeThinking += "\n\n"
|
||||
}
|
||||
cumulativeThinking += ev.Thinking
|
||||
if cb.OnThinking != nil {
|
||||
cb.OnThinking(cumulativeThinking)
|
||||
}
|
||||
}
|
||||
if ev.Token != "" {
|
||||
roundContent += ev.Token
|
||||
cumulative += ev.Token
|
||||
if cb.OnToken != nil {
|
||||
cb.OnToken(cumulative)
|
||||
}
|
||||
}
|
||||
if len(ev.ToolCalls) > 0 {
|
||||
roundToolCalls = append(roundToolCalls, ev.ToolCalls...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// templateParseRetries bounds the self-heal retry below (see
|
||||
// vllmkit.IsToolCallParseError's doc comment): a transient
|
||||
// tool-call parsing hiccup gets a couple of fresh
|
||||
// attempts at the exact same round before the turn gives up,
|
||||
// instead of failing on the first occurrence. Any tokens a
|
||||
// failed attempt already streamed to cb.OnToken/cumulative stay
|
||||
// visible (there's no clean way to un-stream them from the UI),
|
||||
// but roundContent/roundToolCalls are reset at the top of each
|
||||
// attemptChat call, so nothing from a broken attempt leaks into
|
||||
// messages/toPersist below.
|
||||
const templateParseRetries = 2
|
||||
for attempt := 0; ; attempt++ {
|
||||
roundErr = attemptChat(attempt > 0)
|
||||
if roundErr == nil || !vllmkit.IsToolCallParseError(roundErr) || attempt >= templateParseRetries || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The plain retries above resend the exact same request, on the
|
||||
// theory that the failure was a one-off. If it wasn't — the same
|
||||
// template error survives every attempt — the newest tool
|
||||
// result(s) in history are the most likely deterministic cause
|
||||
// (see degradeRecentToolMessages), so blank those and try the
|
||||
// round fresh exactly once more before giving up the whole turn.
|
||||
if roundErr != nil && vllmkit.IsToolCallParseError(roundErr) && ctx.Err() == nil {
|
||||
if degradeRecentToolMessages(messages, keepRecentToolMsgs) > 0 {
|
||||
roundErr = attemptChat(true)
|
||||
}
|
||||
}
|
||||
if roundErr != nil {
|
||||
return cumulative, toPersist, roundErr
|
||||
}
|
||||
|
||||
if roundThinking != "" && cb.OnThinkingRound != nil {
|
||||
cb.OnThinkingRound(round, roundThinking)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
if roundContent != "" {
|
||||
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent})
|
||||
}
|
||||
return cumulative, toPersist, err
|
||||
}
|
||||
|
||||
if len(roundToolCalls) == 0 {
|
||||
if roundContent != "" {
|
||||
assistantMsg := vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent}
|
||||
toPersist = append(toPersist, assistantMsg)
|
||||
messages = append(messages, assistantMsg)
|
||||
}
|
||||
return cumulative, toPersist, nil
|
||||
}
|
||||
|
||||
if roundContent != "" {
|
||||
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent})
|
||||
}
|
||||
messages = append(messages, vllmkit.Message{
|
||||
Role: vllmkit.RoleAssistant, Content: roundContent, Thinking: roundThinking, ToolCalls: roundToolCalls,
|
||||
})
|
||||
|
||||
results := a.executeToolCalls(ctx, roundToolCalls, cb)
|
||||
for i := range roundToolCalls {
|
||||
result := results[i]
|
||||
toolMsg := vllmkit.Message{Role: vllmkit.RoleTool, Content: result.Content, ToolName: result.Name}
|
||||
toPersist = append(toPersist, toolMsg)
|
||||
messages = append(messages, toolMsg)
|
||||
}
|
||||
priorResults = results
|
||||
toolCallsSoFar += len(roundToolCalls)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return cumulative, toPersist, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return cumulative, toPersist, fmt.Errorf("agent: exceeded max rounds (%d) without a final answer", maxRounds)
|
||||
}
|
||||
|
||||
// executeToolCalls runs every call concurrently via the Executor,
|
||||
// identical fan-out to the monolith's.
|
||||
func (a *Agent) executeToolCalls(ctx context.Context, calls []vllmkit.ToolCallRequest, cb Callbacks) []vllmkit.ToolResult {
|
||||
results := make([]vllmkit.ToolResult, len(calls))
|
||||
if len(calls) == 1 {
|
||||
results[0] = a.Executor.Execute(ctx, calls[0])
|
||||
if cb.OnToolCall != nil {
|
||||
cb.OnToolCall(calls[0], results[0])
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
for i := range calls {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
call := calls[i]
|
||||
result := a.Executor.Execute(ctx, call)
|
||||
results[i] = result
|
||||
if cb.OnToolCall != nil {
|
||||
mu.Lock()
|
||||
cb.OnToolCall(call, result)
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// IsCancelled reports whether err represents the run being deliberately
|
||||
// stopped rather than a real failure.
|
||||
func IsCancelled(err error) bool {
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
247
services/agent/internal/infrastructure/agentrun/agent_test.go
Normal file
247
services/agent/internal/infrastructure/agentrun/agent_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package agentrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
func TestDefaultThinkPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
round int
|
||||
retrying bool
|
||||
want string
|
||||
}{
|
||||
{"round 0, fresh attempt: deep thinking", 0, false, "high"},
|
||||
{"round 0, retry: fast and simple, not deep", 0, true, "low"},
|
||||
{"later round, fresh attempt: defer to client default", 3, false, ""},
|
||||
{"later round, retry: still fast and simple", 5, true, "low"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DefaultThinkPolicy(tc.round, tc.retrying); got != tc.want {
|
||||
t.Errorf("DefaultThinkPolicy(%d, %v) = %q, want %q", tc.round, tc.retrying, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactThinkingHistory(t *testing.T) {
|
||||
msgs := []vllmkit.Message{
|
||||
{Role: vllmkit.RoleSystem, Content: "sys"},
|
||||
{Role: vllmkit.RoleUser, Content: "do the thing"},
|
||||
{Role: vllmkit.RoleAssistant, Content: "", Thinking: "round0 reasoning", ToolCalls: []vllmkit.ToolCallRequest{{Name: "grep"}}},
|
||||
{Role: vllmkit.RoleTool, Content: "grep result", ToolName: "grep"},
|
||||
{Role: vllmkit.RoleAssistant, Content: "", Thinking: "round1 reasoning", ToolCalls: []vllmkit.ToolCallRequest{{Name: "read_file"}}},
|
||||
{Role: vllmkit.RoleTool, Content: "file contents", ToolName: "read_file"},
|
||||
}
|
||||
|
||||
compactThinkingHistory(msgs)
|
||||
|
||||
if msgs[2].Thinking != "" {
|
||||
t.Errorf("round0's thinking should have been stripped once round1's exists, got %q", msgs[2].Thinking)
|
||||
}
|
||||
if msgs[4].Thinking != "round1 reasoning" {
|
||||
t.Errorf("most recent round's thinking should survive, got %q", msgs[4].Thinking)
|
||||
}
|
||||
// Non-thinking fields must be untouched.
|
||||
if len(msgs[2].ToolCalls) != 1 || msgs[2].ToolCalls[0].Name != "grep" {
|
||||
t.Errorf("compaction must not touch ToolCalls, got %+v", msgs[2].ToolCalls)
|
||||
}
|
||||
if msgs[3].Content != "grep result" {
|
||||
t.Errorf("compaction must not touch tool messages, got %q", msgs[3].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactThinkingHistory_NoThinkingIsNoop(t *testing.T) {
|
||||
msgs := []vllmkit.Message{
|
||||
{Role: vllmkit.RoleUser, Content: "hi"},
|
||||
{Role: vllmkit.RoleAssistant, Content: "hello"},
|
||||
}
|
||||
compactThinkingHistory(msgs)
|
||||
if msgs[1].Content != "hello" {
|
||||
t.Errorf("content should be untouched, got %q", msgs[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeChatStreamer scripts one vllmkit.ChatEvent sequence per call,
|
||||
// in order, and records the think override and a deep-ish snapshot of
|
||||
// the messages it was called with so tests can assert on both the
|
||||
// adaptive-thinking policy and the thinking-history compaction working
|
||||
// together across a multi-round Run.
|
||||
type fakeChatStreamer struct {
|
||||
t *testing.T
|
||||
rounds [][]vllmkit.ChatEvent
|
||||
call int
|
||||
gotThink []string
|
||||
gotMsgs [][]vllmkit.Message
|
||||
}
|
||||
|
||||
func (f *fakeChatStreamer) ChatStream(ctx context.Context, messages []vllmkit.Message, tools []vllmkit.ToolDef, think string) (<-chan vllmkit.ChatEvent, error) {
|
||||
if f.call >= len(f.rounds) {
|
||||
f.t.Fatalf("unexpected extra ChatStream call #%d", f.call)
|
||||
}
|
||||
f.gotThink = append(f.gotThink, think)
|
||||
snapshot := make([]vllmkit.Message, len(messages))
|
||||
copy(snapshot, messages)
|
||||
f.gotMsgs = append(f.gotMsgs, snapshot)
|
||||
|
||||
ch := make(chan vllmkit.ChatEvent, len(f.rounds[f.call])+1)
|
||||
for _, ev := range f.rounds[f.call] {
|
||||
ch <- ev
|
||||
}
|
||||
close(ch)
|
||||
f.call++
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type fakeExecutor struct{}
|
||||
|
||||
func (fakeExecutor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "ok: " + call.Name}
|
||||
}
|
||||
|
||||
// TestRun_AdaptiveThinkingAndCompaction drives a 3-round turn (two
|
||||
// tool-call rounds, then a final answer) and checks that: (1)
|
||||
// ThinkPolicy's per-round effort actually reaches each ChatStream call,
|
||||
// and (2) by the time round 2's request is built, round 0's thinking
|
||||
// has been compacted away while round 1's (the most recent) survives —
|
||||
// i.e. the adaptive-effort and history-compaction pieces work together
|
||||
// exactly as agent.go wires them.
|
||||
func TestRun_AdaptiveThinkingAndCompaction(t *testing.T) {
|
||||
fake := &fakeChatStreamer{
|
||||
t: t,
|
||||
rounds: [][]vllmkit.ChatEvent{
|
||||
{ // round 0: thinks, then calls a tool
|
||||
{Thinking: "round0 reasoning"},
|
||||
{ToolCalls: []vllmkit.ToolCallRequest{{Name: "grep", Arguments: map[string]any{}}}},
|
||||
},
|
||||
{ // round 1: thinks, then calls another tool
|
||||
{Thinking: "round1 reasoning"},
|
||||
{ToolCalls: []vllmkit.ToolCallRequest{{Name: "read_file", Arguments: map[string]any{}}}},
|
||||
},
|
||||
{ // round 2: final answer, no tool calls, no thinking
|
||||
{Token: "done"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agent := New(fake, fakeExecutor{}, nil)
|
||||
agent.MemoryLoader = nil
|
||||
|
||||
var thinkingRounds []int
|
||||
var thinkingTexts []string
|
||||
cb := Callbacks{
|
||||
OnThinkingRound: func(round int, thinking string) {
|
||||
thinkingRounds = append(thinkingRounds, round)
|
||||
thinkingTexts = append(thinkingTexts, thinking)
|
||||
},
|
||||
}
|
||||
|
||||
final, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "investigate X"}}, cb)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if final != "done" {
|
||||
t.Errorf("final = %q, want %q", final, "done")
|
||||
}
|
||||
|
||||
// ThinkPolicy effort: round 0 fresh attempt -> "high"; round 1 and 2
|
||||
// (not retries) -> "" (defer to client default), per
|
||||
// DefaultThinkPolicy.
|
||||
wantThink := []string{"high", "", ""}
|
||||
if len(fake.gotThink) != len(wantThink) {
|
||||
t.Fatalf("got %d ChatStream calls, want %d (think=%v)", len(fake.gotThink), len(wantThink), fake.gotThink)
|
||||
}
|
||||
for i, want := range wantThink {
|
||||
if fake.gotThink[i] != want {
|
||||
t.Errorf("round %d: think override = %q, want %q", i, fake.gotThink[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// OnThinkingRound fired once per round that actually thought.
|
||||
if len(thinkingRounds) != 2 || thinkingRounds[0] != 0 || thinkingRounds[1] != 1 {
|
||||
t.Errorf("thinkingRounds = %v, want [0 1]", thinkingRounds)
|
||||
}
|
||||
if thinkingTexts[0] != "round0 reasoning" || thinkingTexts[1] != "round1 reasoning" {
|
||||
t.Errorf("thinkingTexts = %v", thinkingTexts)
|
||||
}
|
||||
|
||||
// History compaction: by round 2's request, round 0's assistant
|
||||
// message (sent on calls 0 and 1, before round1 even existed) is
|
||||
// long gone from the picture — what matters is round 2's own
|
||||
// snapshot, which must carry round1's thinking (still the most
|
||||
// recent) but not round0's (superseded).
|
||||
round2Msgs := fake.gotMsgs[2]
|
||||
var sawRound0Thinking, sawRound1Thinking bool
|
||||
for _, m := range round2Msgs {
|
||||
if m.Thinking == "round0 reasoning" {
|
||||
sawRound0Thinking = true
|
||||
}
|
||||
if m.Thinking == "round1 reasoning" {
|
||||
sawRound1Thinking = true
|
||||
}
|
||||
}
|
||||
if sawRound0Thinking {
|
||||
t.Errorf("round0's thinking should have been compacted away by round 2's request, got messages: %+v", round2Msgs)
|
||||
}
|
||||
if !sawRound1Thinking {
|
||||
t.Errorf("round1's thinking (most recent) should still be present in round 2's request, got messages: %+v", round2Msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NilThinkPolicyMeansNoOverride confirms a nil ThinkPolicy (an
|
||||
// Agent built by hand rather than via New/NewSubagent) falls back to
|
||||
// always passing "" rather than panicking.
|
||||
func TestRun_NilThinkPolicyMeansNoOverride(t *testing.T) {
|
||||
fake := &fakeChatStreamer{
|
||||
t: t,
|
||||
rounds: [][]vllmkit.ChatEvent{
|
||||
{{Token: "hi"}},
|
||||
},
|
||||
}
|
||||
agent := &Agent{Client: fake, Executor: fakeExecutor{}, Tools: nil, MaxRounds: 4, SystemPrompt: SystemPrompt}
|
||||
|
||||
_, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "hi"}}, Callbacks{})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(fake.gotThink) != 1 || fake.gotThink[0] != "" {
|
||||
t.Errorf("gotThink = %v, want [\"\"]", fake.gotThink)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSystemPrompt_PreferredLanguage confirms a locale attached
|
||||
// via WithPreferredLanguage reaches the model as an appended
|
||||
// "## Interface language" section naming that locale's own display
|
||||
// name, and that a run with no preferred language set (the common
|
||||
// case — see WithPreferredLanguage's doc comment) sends the base
|
||||
// SystemPrompt completely unchanged, byte for byte.
|
||||
func TestBuildSystemPrompt_PreferredLanguage(t *testing.T) {
|
||||
fake := &fakeChatStreamer{t: t, rounds: [][]vllmkit.ChatEvent{{{Token: "hi"}}}}
|
||||
agent := &Agent{Client: fake, Executor: fakeExecutor{}, MaxRounds: 4, SystemPrompt: SystemPrompt}
|
||||
|
||||
ctx := WithPreferredLanguage(context.Background(), "fr")
|
||||
if _, _, err := agent.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "bonjour"}}, Callbacks{}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
got := fake.gotMsgs[0][0].Content
|
||||
if !strings.Contains(got, "## Interface language") || !strings.Contains(got, "Français") {
|
||||
t.Errorf("system prompt missing French interface-language directive, got suffix: %q", got[len(SystemPrompt):])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_NoPreferredLanguageLeavesPromptUnchanged(t *testing.T) {
|
||||
fake := &fakeChatStreamer{t: t, rounds: [][]vllmkit.ChatEvent{{{Token: "hi"}}}}
|
||||
agent := &Agent{Client: fake, Executor: fakeExecutor{}, MaxRounds: 4, SystemPrompt: SystemPrompt}
|
||||
|
||||
if _, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "hi"}}, Callbacks{}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := fake.gotMsgs[0][0].Content; got != SystemPrompt {
|
||||
t.Errorf("system prompt changed with no preferred language set")
|
||||
}
|
||||
}
|
||||
291
services/agent/internal/infrastructure/agentrun/effort.go
Normal file
291
services/agent/internal/infrastructure/agentrun/effort.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// effort.go adds three related capabilities to the round-trip loop in
|
||||
// agent.go, all modeled on Claude's agentic-coding feature set:
|
||||
//
|
||||
// - Effort control: a caller picks one of pkg/effort's five levels
|
||||
// for a turn (EffortThinkPolicy, and Run reading a.Effort to size
|
||||
// MaxRounds/ContextBudgetChars — see Agent.effectiveLimits).
|
||||
// - Adaptive thinking: instead of (or layered on top of) a fixed
|
||||
// per-level floor, the model's *own* per-round signal — did the
|
||||
// last tool call fail? is this a late round in a long turn? — can
|
||||
// push a round's thinking effort up, so token spend tracks actual
|
||||
// difficulty rather than one static setting for the whole turn
|
||||
// (AdaptiveThinkPolicy, RoundSignal).
|
||||
// - System entries: a caller can hand Run() additional instruction/
|
||||
// permission/budget text to fold into the system prompt for this
|
||||
// run, without touching SystemPrompt itself — see WithSystemEntries
|
||||
// and its doc comment for exactly what "without breaking cache"
|
||||
// can and can't mean against a vLLM-served backend.
|
||||
package agentrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Effort control
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// effortKey is the context.Context key WithEffort stores a level under
|
||||
// — same unexported-struct-key idiom as preferredLanguageKey.
|
||||
type effortKey struct{}
|
||||
|
||||
// WithEffort attaches the caller's chosen reasoning-depth level to ctx
|
||||
// for Run to read (see Agent.effectiveLimits and EffortThinkPolicy). An
|
||||
// unset context (effortFrom returns "", false) means "use the Agent's
|
||||
// own configured Effort field" — see New's default of effort.Medium —
|
||||
// so a caller that never touches this gets exactly the behavior the
|
||||
// agent had before effort control existed.
|
||||
func WithEffort(ctx context.Context, level effort.Level) context.Context {
|
||||
return context.WithValue(ctx, effortKey{}, level)
|
||||
}
|
||||
|
||||
func effortFrom(ctx context.Context) (effort.Level, bool) {
|
||||
l, ok := ctx.Value(effortKey{}).(effort.Level)
|
||||
return l, ok
|
||||
}
|
||||
|
||||
// EffortFromContext returns the effort level in force on ctx (see
|
||||
// WithEffort), or effort.Default when none was set. Exported — unlike
|
||||
// effortFrom — for callers outside this package that need to read the
|
||||
// resolved level without going through an *Agent (e.g. toolexec's
|
||||
// dispatch_workflow orchestrator sizing its own parallelism from
|
||||
// effort.Level.Resolve().MaxParallelSubagents).
|
||||
func EffortFromContext(ctx context.Context) effort.Level {
|
||||
if l, ok := effortFrom(ctx); ok && l.Valid() {
|
||||
return l
|
||||
}
|
||||
return effort.Default
|
||||
}
|
||||
|
||||
// resolveEffort picks the level actually in force for one Run call:
|
||||
// an explicit WithEffort on ctx wins, otherwise the Agent's own
|
||||
// configured default (a.Effort), otherwise effort.Default.
|
||||
func (a *Agent) resolveEffort(ctx context.Context) effort.Level {
|
||||
if l, ok := effortFrom(ctx); ok && l.Valid() {
|
||||
return l
|
||||
}
|
||||
if a.Effort.Valid() {
|
||||
return a.Effort
|
||||
}
|
||||
return effort.Default
|
||||
}
|
||||
|
||||
// EffortThinkPolicy returns a ThinkPolicy that applies profile.Think as
|
||||
// a floor on every round — never asking for *less* thinking than the
|
||||
// level calls for — while still preserving DefaultThinkPolicy's two
|
||||
// situational overrides: round 0 still gets "high" (planning benefits
|
||||
// from deep thinking regardless of level, unless the level's own floor
|
||||
// is already "high" or above) and a same-round retry still asks for
|
||||
// "low" (a fast, simple re-attempt is always what a retry wants, even
|
||||
// at Max effort — spending more reasoning on a request that just broke
|
||||
// the chat template isn't going to fix it).
|
||||
func EffortThinkPolicy(profile effort.Profile) ThinkPolicy {
|
||||
return func(round int, retrying bool) string {
|
||||
if retrying {
|
||||
return "low"
|
||||
}
|
||||
if round == 0 && !thinkAtLeast(profile.Think, "high") {
|
||||
return "high"
|
||||
}
|
||||
return profile.Think
|
||||
}
|
||||
}
|
||||
|
||||
// thinkRank orders the agent's think settings from least to most
|
||||
// effort, for thinkAtLeast's comparison. Bare "true" (thinking on, no
|
||||
// explicit level) is treated as equivalent to "medium" — vLLM/Qwen3
|
||||
// has no server-side notion of graded reasoning depth (see
|
||||
// vllmkit.Client.Think's doc comment: every non-empty, non-"false"
|
||||
// setting just toggles chat_template_kwargs.enable_thinking on), so
|
||||
// this ranking exists purely for agentrun's own floor/escalation
|
||||
// bookkeeping, not because the wire protocol distinguishes the levels.
|
||||
var thinkRank = map[string]int{"": 0, "false": 0, "low": 1, "true": 2, "medium": 2, "high": 3}
|
||||
|
||||
func thinkAtLeast(setting, floor string) bool {
|
||||
return thinkRank[strings.ToLower(strings.TrimSpace(setting))] >= thinkRank[floor]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Adaptive thinking
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// RoundSignal summarizes what just happened in the previous round of
|
||||
// the same turn, for AdaptiveThinkPolicy to read when deciding this
|
||||
// round's effort. Computed by Run from the round it just finished —
|
||||
// see adaptiveSignalFrom.
|
||||
type RoundSignal struct {
|
||||
// Round is the round about to run (0-indexed) — i.e. len(previous
|
||||
// rounds), not the round the signal describes.
|
||||
Round int
|
||||
// PriorRoundFailed is true when the immediately preceding round's
|
||||
// tool call(s) included at least one IsError result — a concrete
|
||||
// sign the model is dealing with something that didn't go as
|
||||
// planned and may benefit from more deliberate reasoning about
|
||||
// what to try next, rather than a quick pattern-matched retry.
|
||||
PriorRoundFailed bool
|
||||
// PriorToolResultBytes is the size of the largest tool result the
|
||||
// previous round produced. A very large result (a big file, a wide
|
||||
// grep) is a signal the model now has a lot to digest before its
|
||||
// next move is obvious.
|
||||
PriorToolResultBytes int
|
||||
// ToolCallsSoFar is the running count of tool calls issued earlier
|
||||
// in this same turn.
|
||||
ToolCallsSoFar int
|
||||
}
|
||||
|
||||
// adaptiveDeepRoundBytes is the PriorToolResultBytes threshold above
|
||||
// which AdaptiveThinkPolicy treats "a lot just happened" as a reason to
|
||||
// think harder about it — calibrated against compactedHeadChars +
|
||||
// compactedTailChars (800): a result bigger than what compaction would
|
||||
// eventually trim is, by definition, a substantial one.
|
||||
const adaptiveDeepRoundBytes = compactedHeadChars + compactedTailChars
|
||||
|
||||
// adaptiveLateRoundThreshold is how many tool calls into a turn
|
||||
// "this is getting long, don't lose the thread" starts to apply.
|
||||
const adaptiveLateRoundThreshold = 8
|
||||
|
||||
// AdaptiveThinkPolicy wraps a floor policy (normally
|
||||
// EffortThinkPolicy(profile)) so a round whose RoundSignal looks
|
||||
// difficult gets escalated to "high" even if the floor would have
|
||||
// allowed less — "the model automatically determines whether to use
|
||||
// turn-based deep thinking" from a concrete, legible signal rather than
|
||||
// the model guessing about its own difficulty, since vLLM's
|
||||
// OpenAI-compatible chat API gives no hook for the model to request
|
||||
// more effort mid-turn on its own. A round that looks ordinary just gets whatever floor already
|
||||
// says, so this never spends *less* than the configured level, only
|
||||
// sometimes more — "optimizing token usage" by reserving the escalation
|
||||
// for rounds that actually show a difficulty signal, instead of every
|
||||
// round paying a high-effort tax regardless of how routine it is.
|
||||
func AdaptiveThinkPolicy(floor ThinkPolicy) func(round int, retrying bool, sig RoundSignal) string {
|
||||
return func(round int, retrying bool, sig RoundSignal) string {
|
||||
base := ""
|
||||
if floor != nil {
|
||||
base = floor(round, retrying)
|
||||
}
|
||||
if retrying {
|
||||
return base // never escalate a retry — see EffortThinkPolicy's own reasoning
|
||||
}
|
||||
if sig.PriorRoundFailed || sig.PriorToolResultBytes > adaptiveDeepRoundBytes || sig.ToolCallsSoFar > adaptiveLateRoundThreshold {
|
||||
if !thinkAtLeast(base, "high") {
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// System entries: mid-task instruction/permission/budget updates
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SystemEntryKind labels what a SystemEntry is updating, purely for the
|
||||
// heading it renders under in the system prompt — the model doesn't
|
||||
// enforce any of these as hard constraints beyond what the prose itself
|
||||
// says (there's no separate permissions engine here — see toolexec for
|
||||
// where real enforcement, e.g. the dispatch_subagent read-only
|
||||
// whitelist, actually lives).
|
||||
type SystemEntryKind string
|
||||
|
||||
const (
|
||||
// SystemEntryInstruction is a standing instruction added mid-turn
|
||||
// — e.g. "also update the changelog for every file you touch from
|
||||
// now on."
|
||||
SystemEntryInstruction SystemEntryKind = "instruction"
|
||||
// SystemEntryPermission narrows or widens what the model should
|
||||
// consider itself allowed to do — e.g. "do not run_bash_command
|
||||
// against anything under /prod" or "you may now run destructive
|
||||
// migrations in this workspace, it's a throwaway clone."
|
||||
SystemEntryPermission SystemEntryKind = "permission"
|
||||
// SystemEntryBudget communicates a resource constraint — e.g. "you
|
||||
// have 4 tool calls left before this session's budget runs out,
|
||||
// wrap up."
|
||||
SystemEntryBudget SystemEntryKind = "budget"
|
||||
)
|
||||
|
||||
func (k SystemEntryKind) heading() string {
|
||||
switch k {
|
||||
case SystemEntryPermission:
|
||||
return "Permission update"
|
||||
case SystemEntryBudget:
|
||||
return "Budget update"
|
||||
default:
|
||||
return "Instruction update"
|
||||
}
|
||||
}
|
||||
|
||||
// SystemEntry is one mid-conversation update a caller can inject into a
|
||||
// run via WithSystemEntries.
|
||||
type SystemEntry struct {
|
||||
Kind SystemEntryKind
|
||||
Content string
|
||||
}
|
||||
|
||||
// systemEntriesKey is WithSystemEntries' context key.
|
||||
type systemEntriesKey struct{}
|
||||
|
||||
// WithSystemEntries attaches additional system-level entries to ctx for
|
||||
// Run to fold into the system prompt, appended after everything else
|
||||
// buildSystemPrompt already assembles (base SystemPrompt, project
|
||||
// memory, language directive) — see buildSystemPrompt. This is enkly's
|
||||
// answer to "update instructions, permissions or token budgets in the
|
||||
// middle of a conversation... without breaking cache": be honest about
|
||||
// what that can mean here. vLLM has no equivalent of the Anthropic
|
||||
// API's explicit prompt-cache breakpoints; what it actually does is
|
||||
// automatic prefix caching (--enable-prefix-caching on the chat
|
||||
// deployment) — reusing cached KV-cache blocks for however much of the
|
||||
// new request's token prefix is byte-identical, block-for-block, to a
|
||||
// previously served request's, across any request that lands on the
|
||||
// same vLLM replica. The guarantee this function and
|
||||
// buildSystemPrompt together provide is the one piece of that which is
|
||||
// actually in enkly's control: every entry is appended, never inserted
|
||||
// or edited in place, and always after the fixed SystemPrompt + memory
|
||||
// + language block — so a conversation's prefix up to "## Live session
|
||||
// updates" stays byte-identical turn over turn whether or not new
|
||||
// entries have been added, maximizing how much of it a KV-cache
|
||||
// implementation *can* reuse, rather than scattering updates throughout
|
||||
// the prompt in a way that would invalidate everything after the first
|
||||
// change on every single turn.
|
||||
func WithSystemEntries(ctx context.Context, entries []SystemEntry) context.Context {
|
||||
if len(entries) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, systemEntriesKey{}, entries)
|
||||
}
|
||||
|
||||
func systemEntriesFrom(ctx context.Context) []SystemEntry {
|
||||
entries, _ := ctx.Value(systemEntriesKey{}).([]SystemEntry)
|
||||
return entries
|
||||
}
|
||||
|
||||
// maxSystemEntryChars caps one entry's rendered length, for the same
|
||||
// reason memoryPromptChars caps ENKLY.md: a caller-supplied string
|
||||
// shouldn't be able to blow out the context budget of every single
|
||||
// round for the rest of the turn.
|
||||
const maxSystemEntryChars = 2_000
|
||||
|
||||
// renderSystemEntries turns entries into the "## Live session updates"
|
||||
// section buildSystemPrompt appends. Returns "" for no entries, so
|
||||
// callers can unconditionally concatenate it.
|
||||
func renderSystemEntries(entries []SystemEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## Live session updates\nThe following were added to this session after it started; they take " +
|
||||
"precedence over anything above that they conflict with, for the remainder of this conversation.\n")
|
||||
for _, e := range entries {
|
||||
content := strings.TrimSpace(e.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if len(content) > maxSystemEntryChars {
|
||||
content = safeHeadCut(content, maxSystemEntryChars) + " […truncated]"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n### %s\n%s\n", e.Kind.heading(), content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package grpcclients dials every other bounded context the agent
|
||||
// service's saga needs and exposes each as its generated client
|
||||
// interface. One file, one place to see the agent service's entire
|
||||
// synchronous fan-out — see ARCHITECTURE.md's context map diagram.
|
||||
package grpcclients
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
knowledgev1 "enkly/gen/knowledge/v1"
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
)
|
||||
|
||||
// Clients bundles every gRPC client connection the agent service holds
|
||||
// open for the lifetime of the process (one connection each, reused
|
||||
// across requests — grpc.ClientConn is safe for concurrent use and
|
||||
// multiplexes calls over HTTP/2 itself, so there's no pool to manage).
|
||||
type Clients struct {
|
||||
Workspace workspacev1.WorkspaceServiceClient
|
||||
Knowledge knowledgev1.KnowledgeServiceClient
|
||||
Billing billingv1.BillingServiceClient
|
||||
Conversation conversationv1.ConversationServiceClient
|
||||
|
||||
conns []*grpc.ClientConn
|
||||
}
|
||||
|
||||
// Dial connects to every peer service at startup. Addrs are host:port
|
||||
// (e.g. "workspace:9004"); see cmd/server/main.go for the env vars that
|
||||
// supply them. Fails fast — the agent service cannot do anything
|
||||
// useful without all four peers, so there's no point starting the gRPC
|
||||
// server if one is unreachable (docker compose's restart policy
|
||||
// handles the retry loop).
|
||||
func Dial(workspaceAddr, knowledgeAddr, billingAddr, conversationAddr string) (*Clients, error) {
|
||||
c := &Clients{}
|
||||
dial := func(addr string) (*grpc.ClientConn, error) {
|
||||
// Workspace responses can carry a full file's bytes/content
|
||||
// (uploads, downloads, large reads) — match the server-side
|
||||
// limit raised in services/workspace/cmd/server/main.go.
|
||||
conn, err := grpc.NewClient(addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
c.conns = append(c.conns, conn)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
wsConn, err := dial(workspaceAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kConn, err := dial(knowledgeAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bConn, err := dial(billingAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cConn, err := dial(conversationAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Workspace = workspacev1.NewWorkspaceServiceClient(wsConn)
|
||||
c.Knowledge = knowledgev1.NewKnowledgeServiceClient(kConn)
|
||||
c.Billing = billingv1.NewBillingServiceClient(bConn)
|
||||
c.Conversation = conversationv1.NewConversationServiceClient(cConn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Clients) Close() {
|
||||
for _, conn := range c.conns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
750
services/agent/internal/infrastructure/toolcatalog/catalog.go
Normal file
750
services/agent/internal/infrastructure/toolcatalog/catalog.go
Normal file
@@ -0,0 +1,750 @@
|
||||
// Package toolcatalog holds the JSON-schema tool definitions sent to
|
||||
// the vLLM-hosted chat model on every chat request — the exact content of the monolith's
|
||||
// Toolbox.Defs(), unchanged (the model has been prompted and tested
|
||||
// against this precise wording; see agent.go's SystemPrompt for the
|
||||
// companion prose). Presenting tool schemas is the agent service's job
|
||||
// now, not the workspace service's — the workspace service only
|
||||
// executes tools once the model has picked one (see
|
||||
// internal/infrastructure/toolexec).
|
||||
package toolcatalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/mathkit"
|
||||
"enkly/pkg/skillkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// Defs returns every tool the agent may call, in the fixed order the
|
||||
// monolith declared them.
|
||||
func Defs() []vllmkit.ToolDef {
|
||||
return []vllmkit.ToolDef{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read a text file in the project workspace, with 1-based line numbers prefixed (like `cat -n`). Large files are paginated: use offset/limit to page through them instead of guessing what's relevant.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"offset": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "1-based line number to start reading from. Omit to start at line 1.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to return. Omit for the default (2000).",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "Create a new file, or fully overwrite an existing one, with new content. For changes to an EXISTING file, prefer edit_file — it's cheaper and far less likely to accidentally drop unrelated content than rewriting the whole file.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The full contents to write to the file.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_file",
|
||||
Description: "Make a precise, surgical edit to an existing text file by replacing one exact occurrence of old_string with new_string. old_string must match the file's current content exactly (read the file with read_file first, and include enough surrounding context to make it uniquely identify one location) and must not equal new_string. Fails loudly instead of guessing if old_string is missing or ambiguous, which is the whole point: it can't silently clobber the rest of the file the way write_file can. Returns a unified-diff preview of exactly what changed — check it before assuming the edit did what you intended.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"old_string": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The exact text to find. Must appear exactly once in the file unless replace_all is true.",
|
||||
},
|
||||
"new_string": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The text to replace it with.",
|
||||
},
|
||||
"replace_all": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "If true, replace every occurrence of old_string instead of requiring exactly one. Defaults to false.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "old_string", "new_string"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi_edit_file",
|
||||
Description: "Apply several edit_file-style replacements to ONE file as a single atomic operation: every edit is validated (found, and unique unless its own replace_all is set) against the file as it would look after the preceding edits in the list, and if ANY edit fails, nothing is written — the file is left untouched, exactly as if none of the edits had been attempted. Prefer this over several separate edit_file calls when you already know multiple changes a single file needs (e.g. renaming a symbol at its definition and at each call site), since it's one round-trip instead of several and never leaves a file half-edited if a later edit in the batch turns out to be wrong. Returns a diff preview per edit, in order.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"edits": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The edits to apply, in order. Each is the same shape as edit_file's arguments (minus path).",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"old_string": map[string]any{"type": "string", "description": "The exact text to find."},
|
||||
"new_string": map[string]any{"type": "string", "description": "The text to replace it with."},
|
||||
"replace_all": map[string]any{"type": "boolean", "description": "If true, replace every occurrence of this edit's old_string. Defaults to false."},
|
||||
},
|
||||
"required": []string{"old_string", "new_string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "edits"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_directory",
|
||||
Description: "List files and subdirectories under a path in the project workspace, as a sorted tree (directories first). Use this to orient yourself in an unfamiliar project before reading or searching files.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Directory to list, relative to the workspace root. Omit or use \".\" for the workspace root.",
|
||||
},
|
||||
"max_depth": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "How many directory levels deep to recurse. Defaults to 3.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "glob_search",
|
||||
Description: "Find files by name pattern (e.g. \"**/*.go\", \"internal/**/handlers/*.go\", \"*.md\"). `*` matches within one path segment, `**` matches across segments. Faster and more precise than list_directory when you know roughly what filename or extension you're looking for.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Glob pattern, relative to the workspace root.",
|
||||
},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grep",
|
||||
Description: "Search file contents across the workspace for a regular expression, returning matching file:line:text results. Use this for exact/lexical lookups (a symbol name, an error string, a TODO) — use search_codebase instead for fuzzy, conceptual ('where do we handle X') queries.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
"description": "RE2 regular expression to search for.",
|
||||
},
|
||||
"path_glob": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional glob to restrict which files are searched, e.g. \"*.go\".",
|
||||
},
|
||||
"case_insensitive": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Defaults to false.",
|
||||
},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_todos",
|
||||
Description: "Write or replace your working plan as a structured todo list, shown to the user as a checklist. Use it to break a nontrivial, multi-step request (more than ~3 steps, or anything touching several files) into tracked steps *before* starting work, and call it again to flip items to in_progress/completed as you go. Skip it for a single small, obvious change — it adds overhead, not clarity, on a one-step task.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"todos": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The full current plan (this replaces any previous list, so include every item, not just the ones that changed).",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string"},
|
||||
"status": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"pending", "in_progress", "completed"},
|
||||
},
|
||||
},
|
||||
"required": []string{"content", "status"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"todos"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run_bash_command",
|
||||
Description: "Run one command in the project workspace and return its combined stdout/stderr. Commands run directly WITHOUT a shell: no pipes, redirects, globbing, chaining or substitution (quote arguments containing special characters with single quotes). Only allowlisted programs run (go build/test/vet/fmt/list, git read-only subcommands, ls, cat, grep, find, gofmt); all paths must stay inside the workspace. After editing code, prefer running the relevant build/tests here over asserting the change is correct.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A single command line, e.g. `go test ./...` or `grep -rn TODO .`.",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_codebase",
|
||||
Description: "Semantic search over the indexed codebase using pgvector. Returns the most relevant code chunks for a natural-language, conceptual query.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Natural-language description of the code you're looking for.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "solve_ml_math",
|
||||
Description: "Solve a concrete mathematics problem from the four pillars that come up when building, " +
|
||||
"debugging or explaining ML code: linear algebra, calculus (derivatives/gradients/integrals), " +
|
||||
"probability & statistics, numerical optimization, plus ML-specific primitives (activations, loss " +
|
||||
"functions, least-squares linear regression). Computes an exact or numerically-verified answer " +
|
||||
"locally — pure Go arithmetic, not the model's own mental math — so prefer this over working " +
|
||||
"something out by hand whenever a request has an actual numeric or symbolic-in-form-but-numeric " +
|
||||
"answer (\"what's the gradient of this loss at this point\", \"invert this matrix\", \"is this " +
|
||||
"activation numerically stable\", \"fit a line through these points\"). Not for code generation or " +
|
||||
"explaining a concept in prose — just for computing an answer. Supported \"operation\" values: " +
|
||||
strings.Join(mathkit.Operations(), ", ") + ". Each operation's required parameters:\n" +
|
||||
"- evaluate{expression, variables?}: arithmetic/function expression, e.g. \"sigmoid(w*x+b)\".\n" +
|
||||
"- matrix_add/matrix_sub/matrix_mul{a, b}, matrix_transpose/matrix_determinant/matrix_inverse/" +
|
||||
"matrix_rank/matrix_trace{a}: 2D arrays, e.g. a=[[1,2],[3,4]].\n" +
|
||||
"- dot_product{a, b}, vector_norm{a, p?}: 1D arrays; p defaults to 2 (Euclidean).\n" +
|
||||
"- dominant_eigen{a, iterations?}: largest-eigenvalue/eigenvector via power iteration.\n" +
|
||||
"- derivative{expression, variable, at|value, h?}, gradient{expression, variables, at}, " +
|
||||
"integral{expression, variable, from, to, steps?}: at is {\"name\": number, ...}.\n" +
|
||||
"- gradient_descent{expression, variables, start, learning_rate, iterations?, tolerance?}: " +
|
||||
"runs plain gradient descent and returns the trajectory.\n" +
|
||||
"- newton_root{expression, variable, start, iterations?, tolerance?}: finds a root via Newton's method.\n" +
|
||||
"- mean/variance/std_dev/median{data, population?}, covariance/correlation{x, y, population?}, " +
|
||||
"z_score_normalize/min_max_normalize{data}, normal_pdf/normal_cdf{x, mean?, std_dev?}, " +
|
||||
"binomial_pmf{k, n, p}.\n" +
|
||||
"- sigmoid/relu/tanh{x}: x is a number or an array. softmax{x}: array. " +
|
||||
"mse_loss/cross_entropy_loss{predictions, targets}. " +
|
||||
"linear_regression{x, y}: x is a 2D feature matrix (rows=observations), y is the target vector; " +
|
||||
"fits by ordinary least squares and reports coefficients, R², and residuals.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"operation": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Which calculation to run. See the tool description for the full list and each one's required fields.",
|
||||
},
|
||||
"expression": map[string]any{
|
||||
"type": "string",
|
||||
"description": "An infix math expression for operations that take one (evaluate, derivative, gradient, integral, gradient_descent, newton_root). Supports + - * / ^, parentheses, variables, the constants pi/e, and functions sin/cos/tan/asin/acos/atan/sinh/cosh/tanh/exp/sqrt/abs/ln/log/log2/log10/floor/ceil/round/sigmoid/relu/sign/max/min/pow/mod/atan2.",
|
||||
},
|
||||
"variable": map[string]any{"type": "string", "description": "The single variable of differentiation/integration/root-finding (derivative, integral, newton_root)."},
|
||||
"variables": map[string]any{
|
||||
"description": "Either the ordered list of variable names for a multivariate operation (gradient, gradient_descent — an array of strings), OR a name->value object of extra bound variables an expression needs (evaluate, integral).",
|
||||
},
|
||||
"at": map[string]any{"type": "object", "description": "name->number bindings at which to evaluate a derivative/gradient, e.g. {\"x\": 1, \"y\": 2}."},
|
||||
"value": map[string]any{"type": "number", "description": "Shorthand for \"at\" when derivative has exactly one variable."},
|
||||
"h": map[string]any{"type": "number", "description": "Finite-difference step size for derivative/gradient. Omit for a sensible default."},
|
||||
"from": map[string]any{"type": "number", "description": "Lower bound of a definite integral."},
|
||||
"to": map[string]any{"type": "number", "description": "Upper bound of a definite integral."},
|
||||
"steps": map[string]any{"type": "integer", "description": "Subintervals for integral (Simpson's rule). Omit for a sensible default."},
|
||||
"start": map[string]any{"description": "Starting point: an array of numbers (gradient_descent, one per variable) or a single number (newton_root)."},
|
||||
"learning_rate": map[string]any{"type": "number", "description": "Step size for gradient_descent. Required, must be positive."},
|
||||
"iterations": map[string]any{"type": "integer", "description": "Max steps for gradient_descent/newton_root/dominant_eigen. Omit for a sensible default."},
|
||||
"tolerance": map[string]any{"type": "number", "description": "Early-stop threshold for gradient_descent (gradient norm) or newton_root (|f(x)|)."},
|
||||
"a": map[string]any{"description": "A matrix (2D array, e.g. [[1,2],[3,4]]) or vector (1D array), depending on the operation."},
|
||||
"b": map[string]any{"description": "A second matrix or vector, same shape rules as \"a\"."},
|
||||
"p": map[string]any{"type": "number", "description": "Which norm for vector_norm: 1, 2 (default), or a large number to approximate infinity."},
|
||||
"data": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "A 1D array of numbers for a statistics operation."},
|
||||
"x": map[string]any{
|
||||
"description": "Context-dependent: a number or array for sigmoid/relu/tanh/softmax/normal_pdf/normal_cdf, a data array for covariance/correlation, or a 2D feature matrix for linear_regression.",
|
||||
},
|
||||
"y": map[string]any{"description": "A data array for covariance/correlation, or the target vector for linear_regression."},
|
||||
"population": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "variance/std_dev/covariance: true for the population formula (divide by n), false (default) for the unbiased sample formula (divide by n-1).",
|
||||
},
|
||||
"mean": map[string]any{"type": "number", "description": "Distribution mean for normal_pdf/normal_cdf. Defaults to 0."},
|
||||
"std_dev": map[string]any{"type": "number", "description": "Distribution standard deviation for normal_pdf/normal_cdf. Defaults to 1."},
|
||||
"k": map[string]any{"type": "integer", "description": "Number of successes for binomial_pmf."},
|
||||
"n": map[string]any{"type": "integer", "description": "Number of trials for binomial_pmf."},
|
||||
"predictions": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Model outputs for mse_loss/cross_entropy_loss."},
|
||||
"targets": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Ground-truth values for mse_loss/cross_entropy_loss."},
|
||||
},
|
||||
"required": []string{"operation"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_office_skill",
|
||||
Description: officeSkillToolDescription(),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"enum": skillkit.Names(),
|
||||
"description": "Which skill to read. Start with \"overview\" if you're not sure which document format applies.",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_office_skills",
|
||||
Description: "Semantically search the office-document skills (docx/xlsx/pptx/overview) for the specific " +
|
||||
"sections most relevant to a question — e.g. \"can a cell hold a formula\" or \"how do I bold part " +
|
||||
"of a paragraph\" — instead of reading a whole skill file to find one answer. Returns the top " +
|
||||
"matching sections, each labeled with the skill and heading it came from, ranked by relevance. " +
|
||||
"Prefer this over read_office_skill for a narrow, specific question; prefer read_office_skill when " +
|
||||
"you want a skill's complete guidance before a multi-step document task.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "A specific question or short description of what you need to know."},
|
||||
"skill": map[string]any{
|
||||
"type": "string",
|
||||
"enum": skillkit.Names(),
|
||||
"description": "Optional: restrict the search to one skill. Omit to search across all of them.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
// --- Document management (Word/Excel/PowerPoint), modeled on
|
||||
// Claude Code's document-editing skills: the model only ever
|
||||
// supplies plain structured fields (titles, paragraph text,
|
||||
// cell values, bullet lines) — never raw OOXML/XML markup. All
|
||||
// nine tools below are implemented by pkg/officekit, which does
|
||||
// 100% of the actual XML generation/parsing server-side using
|
||||
// Go's typed XML encoder (always well-formed, always correctly
|
||||
// escaped). This is a deliberate safety property, not just a
|
||||
// convenience: an earlier, Ollama-backed version of this service
|
||||
// saw XML text round-tripped through a tool-call argument or tool
|
||||
// result trigger "ollama error: XML syntax error ... element
|
||||
// closed by" — Qwen's tool-calling chat template wrapped tool
|
||||
// calls/results in its own XML-ish delimiters that Ollama parsed
|
||||
// on its side, and a stray or truncated angle bracket in echoed
|
||||
// content could break that parse. The vLLM OpenAI-compatible
|
||||
// tool-calling wire format this service now speaks (pkg/vllmkit)
|
||||
// carries tool calls/results as JSON instead, so that exact
|
||||
// failure mode is gone, but keeping documents structured-in/
|
||||
// structured-out remains the right call regardless of transport:
|
||||
// the model never needs to emit markup at all. See
|
||||
// agentrun/agent.go's compactToolHistory and vllmkit/client.go's
|
||||
// IsToolCallParseError for the matching defensive fixes on
|
||||
// whatever transport-level parse failures remain.
|
||||
{
|
||||
Name: "create_document",
|
||||
Description: "Create a new Word document (.docx) in the workspace, either from a list of structured " +
|
||||
"paragraphs (\"paragraphs\") or by converting an existing Markdown document (\"markdown\") straight " +
|
||||
"into one — headings, body text, bullets. Fails if the path already exists; use edit_document to " +
|
||||
"change one. The model never writes OOXML by hand: give plain text and a style per paragraph (or " +
|
||||
"just Markdown source) and this tool renders a real, valid .docx file. For an ordinary document (a " +
|
||||
"CV, cover letter, memo, report, README-style page), this description is everything you need — go " +
|
||||
"straight to calling it, no read_office_skill round-trip required: with \"paragraphs\", lead with " +
|
||||
"one heading1 for the title, use heading2/heading3 for section headers, bullet for genuinely " +
|
||||
"list-shaped content (not as a stand-in for paragraph breaks), and normal for body text — split body " +
|
||||
"text into real paragraphs by idea, not one giant block; with \"markdown\", just pass the source " +
|
||||
"text and #/##/### headings, paragraphs and -/* lists map onto the same styles automatically. This " +
|
||||
"tool can't yet do tables, images, headers/footers, or bold/italic applied to only part of a " +
|
||||
"paragraph (bold/italic/underline apply to the whole paragraph; Markdown's inline **bold**/*italic* " +
|
||||
"spans are flattened to plain text for the same reason); say so plainly if someone asks for one of " +
|
||||
"those rather than approximating it. Reach for read_office_skill only if a request needs one of " +
|
||||
"those unsupported features confirmed, edit_document's replace_text nuances, or Track Changes " +
|
||||
"redlining (edit_document's *_tracked operations).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .docx."},
|
||||
"paragraphs": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The document's content, in order. Omit if using \"markdown\" instead.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "The paragraph's plain text."},
|
||||
"style": map[string]any{"type": "string", "enum": []string{"normal", "heading1", "heading2", "heading3", "bullet"}, "description": "Defaults to \"normal\"."},
|
||||
"bold": map[string]any{"type": "boolean", "description": "Bold the whole paragraph. Defaults to false; implied true for headings."},
|
||||
"italic": map[string]any{"type": "boolean", "description": "Italicize the whole paragraph. Defaults to false."},
|
||||
"underline": map[string]any{"type": "boolean", "description": "Underline the whole paragraph. Defaults to false."},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
"markdown": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Alternative to \"paragraphs\": a complete Markdown document to convert directly into the .docx (headings, paragraphs, bullet/numbered lists). Use this instead of hand-building a paragraphs array when you already have — or would naturally write — Markdown source.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_document",
|
||||
Description: "Read a Word document (.docx) back as plain text, one line per paragraph (bullets prefixed \"- \"). Works on any standard .docx, including ones a user uploaded, though complex formatting, tables and images aren't represented — just the text.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .docx file, relative to the workspace root."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_document",
|
||||
Description: "Make a structured change to an existing Word document. Plain edits: append_paragraph (add " +
|
||||
"one new paragraph at the end, same fields as create_document's paragraphs) or replace_text (replace " +
|
||||
"every literal occurrence of \"find\" with \"replace\" across the document's text, including text " +
|
||||
"inside tables — the structured, markup-free analogue of edit_file's old_string/new_string; unlike " +
|
||||
"edit_file every occurrence is replaced, and a phrase split across more than one formatting run by " +
|
||||
"the document's own edit history may not be found as a single match). Redlining (Track Changes): " +
|
||||
"append_paragraph_tracked and replace_text_tracked do the same edits but record them as a " +
|
||||
"reviewable, Word-native tracked change (shown struck-through/underlined under Review > Track " +
|
||||
"Changes) instead of rewriting the text in place — use these whenever someone asks for \"tracked " +
|
||||
"changes\", \"redlining\", \"suggest an edit\", or a change that should go through review before it's " +
|
||||
"final; accept_all_changes and reject_all_changes then resolve every pending tracked change in the " +
|
||||
"document at once (there's no per-change accept/reject yet — say so if someone asks for that). " +
|
||||
"Read the document first if you're not sure what's in it or whether it already has pending tracked " +
|
||||
"changes (read_document reports them).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .docx file, relative to the workspace root."},
|
||||
"operation": map[string]any{"type": "string", "enum": []string{
|
||||
"append_paragraph", "replace_text",
|
||||
"append_paragraph_tracked", "replace_text_tracked",
|
||||
"accept_all_changes", "reject_all_changes",
|
||||
}},
|
||||
"text": map[string]any{"type": "string", "description": "append_paragraph/append_paragraph_tracked: the new paragraph's text."},
|
||||
"style": map[string]any{"type": "string", "enum": []string{"normal", "heading1", "heading2", "heading3", "bullet"}, "description": "append_paragraph/append_paragraph_tracked only. Defaults to \"normal\"."},
|
||||
"bold": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false; implied true for headings."},
|
||||
"italic": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false."},
|
||||
"underline": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false."},
|
||||
"find": map[string]any{"type": "string", "description": "replace_text/replace_text_tracked: exact text to find."},
|
||||
"replace": map[string]any{"type": "string", "description": "replace_text/replace_text_tracked: its replacement."},
|
||||
"author": map[string]any{"type": "string", "description": "append_paragraph_tracked/replace_text_tracked only: the reviewer name recorded on the tracked change. Defaults to \"enkly agent\"."},
|
||||
},
|
||||
"required": []string{"path", "operation"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_spreadsheet",
|
||||
Description: "Create a new Excel workbook (.xlsx) in the workspace, either as one sheet (\"rows\") or " +
|
||||
"several (\"sheets\"). Each row is a list of cell values, top to bottom, left to right (row 1 = A1, " +
|
||||
"B1, ...); a value starting with \"=\" is stored as a live Excel formula (e.g. \"=SUM(A1:A4)\"), " +
|
||||
"everything else that parses as a plain number is stored as a numeric cell, and anything left over " +
|
||||
"as text. Fails if the path already exists; use edit_spreadsheet to change individual cells of an " +
|
||||
"existing one, or read_spreadsheet first to see what's there.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .xlsx."},
|
||||
"sheet_name": map[string]any{"type": "string", "description": "Single-sheet form only (with \"rows\"). Defaults to \"Sheet1\"."},
|
||||
"rows": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Single-sheet form: row-major grid of cell values, e.g. [[\"Name\",\"Score\"],[\"Alice\",\"92\"]]. Omit if using \"sheets\" instead.",
|
||||
"items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
"sheets": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Multi-sheet form: a workbook with one tab per entry, in order. Use this instead of \"rows\"/\"sheet_name\" when the workbook needs more than one sheet.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "This sheet's tab name."},
|
||||
"rows": map[string]any{
|
||||
"type": "array",
|
||||
"description": "This sheet's row-major grid of cell values.",
|
||||
"items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
"required": []string{"rows"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_spreadsheet",
|
||||
Description: "Read one sheet of an Excel workbook (.xlsx) back as a row-major text table. Defaults to " +
|
||||
"the first sheet; when the workbook has more than one, the result's first line names the sheet " +
|
||||
"read and lists every sheet's name so you can ask for a different one. Works on any standard " +
|
||||
".xlsx, including ones a user uploaded.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .xlsx file, relative to the workspace root."},
|
||||
"sheet": map[string]any{"type": "string", "description": "Which sheet to read: a 1-based index (\"2\") or a sheet name. Omit for the first sheet."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_spreadsheet",
|
||||
Description: "Write a sparse set of individual cells into one sheet of an existing Excel workbook, " +
|
||||
"by A1-style reference (e.g. \"B12\"). Leaves every other cell untouched; a reference past the " +
|
||||
"current grid extends it. Read the spreadsheet first if you need to know what's already there or " +
|
||||
"what the other sheets are called.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .xlsx file, relative to the workspace root."},
|
||||
"sheet": map[string]any{"type": "string", "description": "Which sheet to write to: a 1-based index (\"2\") or a sheet name. Omit for the first sheet."},
|
||||
"cells": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The cells to write.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"cell": map[string]any{"type": "string", "description": "A1-style reference, e.g. \"B12\"."},
|
||||
"value": map[string]any{"type": "string", "description": "New value. A leading \"=\" stores a live formula (e.g. \"=SUM(B2:B11)\"); otherwise stored as a number if it parses as one, else as text."},
|
||||
},
|
||||
"required": []string{"cell", "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "cells"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_presentation",
|
||||
Description: "Create a new PowerPoint deck (.pptx) in the workspace from a list of slides, each a title " +
|
||||
"plus flat bullet lines — no layouts or manual positioning to specify. Fails if the path already " +
|
||||
"exists; use edit_presentation to add or change a slide afterward.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .pptx."},
|
||||
"slides": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The deck's slides, in order.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{"type": "string"},
|
||||
"bullets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "slides"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_presentation",
|
||||
Description: "Read a PowerPoint deck (.pptx) back as a list of slides (title + bullets), in order. Works on any standard .pptx, including ones a user uploaded, though complex layouts/images aren't represented.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .pptx file, relative to the workspace root."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_presentation",
|
||||
Description: "Either add_slide (append one new slide at the end — same title/bullets shape as " +
|
||||
"create_presentation's slides) or update_slide (replace one existing slide's title/bullets by its " +
|
||||
"1-based index) in an existing PowerPoint deck. Read the presentation first if you're not sure how " +
|
||||
"many slides it has.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .pptx file, relative to the workspace root."},
|
||||
"operation": map[string]any{"type": "string", "enum": []string{"add_slide", "update_slide"}},
|
||||
"index": map[string]any{"type": "integer", "description": "update_slide only: 1-based slide number to replace."},
|
||||
"title": map[string]any{"type": "string"},
|
||||
"bullets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
"required": []string{"path", "operation"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_pdf",
|
||||
Description: "Extract the plain text content of a PDF file, best-effort — data extraction from PDFs, " +
|
||||
"for reading an uploaded report/contract/paper rather than producing one. Works well on PDFs with a " +
|
||||
"real embedded text layer (almost anything produced by Word, LibreOffice, a browser's \"Print to " +
|
||||
"PDF\", Pandoc, or similar tools). It cannot do OCR: a scanned or image-only PDF will come back with " +
|
||||
"an error saying so, not fabricated text. Layout, tables, columns, and images are not represented — " +
|
||||
"only the words, roughly in reading order, with best-effort line breaks; don't rely on this for a " +
|
||||
"document's exact formatting or table structure.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .pdf file, relative to the workspace root."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dispatch_subagent",
|
||||
Description: "Launch an autonomous, read-only sub-agent (modeled after Claude Code's Task tool) to investigate " +
|
||||
"the workspace for you and report back. Give it one self-contained, specific task description — it has its " +
|
||||
"own fresh context and only list_directory, glob_search, grep, read_file and search_codebase (no write_file, " +
|
||||
"edit_file, multi_edit_file or run_bash_command: it can never change anything). It explores on its own for up " +
|
||||
"to a few rounds and returns a single written report; everything it reads along the way stays in ITS context, " +
|
||||
"not yours. Reach for this instead of a long manual chain of your own search/read calls when a question is " +
|
||||
"broad or open-ended (\"find every place X happens and summarize the pattern\", \"map out how auth flows " +
|
||||
"through this service\", \"is there already code that does Y?\") — one dispatch_subagent call often replaces a " +
|
||||
"dozen of your own tool calls and keeps your own context focused on deciding what to do with the answer. " +
|
||||
"Don't use it for a single targeted lookup you could do yourself in one or two calls, and never for anything " +
|
||||
"that needs to write or run something — it can't.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A precise, self-contained description of what to investigate and what the report should " +
|
||||
"cover. The sub-agent has no memory of this conversation beyond this one string, so include every " +
|
||||
"detail it needs (which directory or feature, what you already know, what you want answered).",
|
||||
},
|
||||
},
|
||||
"required": []string{"task"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dispatch_workflow",
|
||||
Description: "Fan a large task out into many independent sub-agents running IN PARALLEL (modeled after " +
|
||||
"Claude's dynamic multi-agent orchestration), then collect, verify and summarize what came back — the " +
|
||||
"tool to reach for on a large-scale, mechanically-repeated job like a codebase migration (\"rename this " +
|
||||
"API across every service\", \"add a context.Context first param to every exported func in these 40 " +
|
||||
"files\", \"port every handler in internal/old to the new router\"), not a single investigation " +
|
||||
"(use dispatch_subagent for that). Give it a short list of self-contained steps; each one runs as its " +
|
||||
"own read-only sub-agent, with the same tool access as dispatch_subagent (it investigates and reports — " +
|
||||
"it does not write or run anything, same as dispatch_subagent, so plan the actual edits as a follow-up " +
|
||||
"once you've seen every step's findings, exactly the way you'd use any other investigation's report). " +
|
||||
"Steps with no listed dependencies start immediately and run concurrently, bounded by this deployment's " +
|
||||
"configured parallelism cap; a step that lists depends_on waits for those steps to finish first, so you " +
|
||||
"can express \"investigate every call site\" as 40 fully-parallel steps while still sequencing a final " +
|
||||
"\"cross-check the findings against each other\" step after them. Every step's report is automatically " +
|
||||
"checked against its own task description (did it actually answer what was asked, or come back empty/" +
|
||||
"off-topic) before you see the results, so a weak report is flagged for you to re-dispatch or handle " +
|
||||
"yourself instead of silently passing as done — this is the real-time verification and plan adjustment " +
|
||||
"loop: look at which steps verified cleanly and which didn't, and adjust what you do next accordingly " +
|
||||
"rather than assuming every step succeeded. Not available on every plan — if it's not enabled for this " +
|
||||
"account, the tool call itself will say so; fall back to dispatch_subagent (sequential, same " +
|
||||
"investigative power, just one step at a time) or to doing the work directly.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"goal": map[string]any{
|
||||
"type": "string",
|
||||
"description": "One or two sentences on the overall objective these steps serve — included in each " +
|
||||
"step's own task so no sub-agent loses the forest for the trees.",
|
||||
},
|
||||
"steps": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The independent (or dependency-ordered) units of work to run, 2 to several hundred.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A short, unique identifier for this step (e.g. \"svc-billing\", \"file-12\"), referenced by other steps' depends_on and by the final summary.",
|
||||
},
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A precise, self-contained description of this one step's work and what its report should cover — same guidance as dispatch_subagent's task argument.",
|
||||
},
|
||||
"depends_on": map[string]any{
|
||||
"type": "array",
|
||||
"description": "IDs of steps that must finish before this one starts. Omit for a step with no dependencies (the common case — most migration steps are independent of each other).",
|
||||
"items": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": []string{"id", "task"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"goal", "steps"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// officeSkillToolDescription builds read_office_skill's description
|
||||
// from the currently-embedded skill files, so the catalog and the
|
||||
// skills (pkg/skillkit/skills/*.md) can never drift apart — adding or
|
||||
// renaming a skill file is all that's needed to change what the model
|
||||
// sees here, no second edit to keep in sync.
|
||||
func officeSkillToolDescription() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Read one of enkly's office-document skills: short notes on how to use the Word/Excel/" +
|
||||
"PowerPoint tools well — conventions, best practices, and what each one honestly can't do yet. The " +
|
||||
"create_document/create_spreadsheet/create_presentation tool descriptions already cover the basics " +
|
||||
"needed for an ordinary document, so this is optional, not a required first step — skip it for a " +
|
||||
"straightforward request (a CV, a simple report, a one-sheet table of numbers) and go straight to the " +
|
||||
"create_*/edit_* tool; reach for this before anything that leans on a format's sharper edges (Excel " +
|
||||
"formulas or a multi-sheet workbook, a multi-slide PowerPoint deck, edit_document's replace_text run- " +
|
||||
"splitting caveat, redlining with Track Changes, or converting Markdown straight into a .docx) or when " +
|
||||
"you're not sure a feature someone asked for is even supported. Start with the " +
|
||||
"\"overview\" skill if you're not sure which format a request needs. No need to re-read a skill you've " +
|
||||
"already read earlier in this same conversation. Available skills:\n")
|
||||
for _, s := range skillkit.List() {
|
||||
fmt.Fprintf(&b, "- %s: %s\n", s.Name, s.Description)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// subagentToolNames is the read-only whitelist offered to a
|
||||
// dispatch_subagent run: everything that can observe the workspace,
|
||||
// nothing that can change it. toolexec.NewRestricted enforces this
|
||||
// same whitelist at execution time — belt and suspenders, since a
|
||||
// tool catalog only controls what the model is *offered*, not what it
|
||||
// could still try to call.
|
||||
var subagentToolNames = map[string]bool{
|
||||
"read_file": true,
|
||||
"list_directory": true,
|
||||
"glob_search": true,
|
||||
"grep": true,
|
||||
"search_codebase": true,
|
||||
"read_document": true,
|
||||
"read_spreadsheet": true,
|
||||
"read_presentation": true,
|
||||
"read_office_skill": true,
|
||||
"search_office_skills": true,
|
||||
"read_pdf": true,
|
||||
}
|
||||
|
||||
// SubagentDefs returns the read-only subset of Defs() handed to a
|
||||
// dispatch_subagent run.
|
||||
func SubagentDefs() []vllmkit.ToolDef {
|
||||
all := Defs()
|
||||
out := make([]vllmkit.ToolDef, 0, len(subagentToolNames))
|
||||
for _, d := range all {
|
||||
if subagentToolNames[d.Name] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsSubagentTool reports whether name is in the read-only whitelist —
|
||||
// used by toolexec.restrictedExecutor so the enforcement lives next to
|
||||
// the single source of truth for the whitelist itself.
|
||||
func IsSubagentTool(name string) bool {
|
||||
return subagentToolNames[name]
|
||||
}
|
||||
316
services/agent/internal/infrastructure/toolexec/executor.go
Normal file
316
services/agent/internal/infrastructure/toolexec/executor.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// Package toolexec is the direct replacement for the monolith's
|
||||
// Toolbox.Execute dispatch switch. Where that switch called a method on
|
||||
// the same in-process Toolbox for every tool, Executor.Execute calls
|
||||
// out over gRPC to whichever bounded context now owns that tool
|
||||
// (workspace for every filesystem/bash tool, knowledge for
|
||||
// search_codebase) or runs write_todos and solve_ml_math locally (both
|
||||
// are pure, in-memory computation with no filesystem/db dependency —
|
||||
// write_todos per the monolith's tools.go.writeTodos, and
|
||||
// solve_ml_math because pkg/mathkit is a self-contained numerical
|
||||
// engine with nothing to call out to; see mlmath.go).
|
||||
//
|
||||
// Every method here turns a transport-level error (the peer being
|
||||
// unreachable, timing out, ...) into an IsError ToolResult rather than
|
||||
// letting it propagate as a Go error — exactly the contract
|
||||
// Toolbox.Execute already had ("errors executing the tool are turned
|
||||
// into an IsError ToolResult... so the agent loop can feed the failure
|
||||
// back to the model and let it try again"), now also covering RPC
|
||||
// failures, which didn't exist as a failure mode in the monolith.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
knowledgev1 "enkly/gen/knowledge/v1"
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/skillkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
)
|
||||
|
||||
type Executor struct {
|
||||
Workspace workspacev1.WorkspaceServiceClient
|
||||
Knowledge knowledgev1.KnowledgeServiceClient
|
||||
|
||||
// SkillIndex backs the search_office_skills tool (see
|
||||
// office_skill.go): an in-memory, lazily-embedded semantic index
|
||||
// over pkg/skillkit's office-document skill content. Nil when New
|
||||
// was given a nil embedder (e.g. a deployment with no embedding
|
||||
// model configured) — search_office_skills reports itself
|
||||
// unavailable rather than panicking, the same nil-is-graceful
|
||||
// contract Subagent below has.
|
||||
SkillIndex *skillkit.Index
|
||||
|
||||
// Subagent powers both the dispatch_subagent tool (see subagent.go)
|
||||
// and dispatch_workflow (see workflow.go), which reuses this same
|
||||
// nested Agent for every one of its parallel steps rather than
|
||||
// building a separate one per step — sub.Run is safe to call
|
||||
// concurrently (its ChatStreamer and Executor are both stateless
|
||||
// gRPC/HTTP clients). Left nil by New — main.go sets it in a
|
||||
// second step, after building an Executor, because the sub-agent's
|
||||
// own executor (toolexec.NewRestricted) needs to wrap this very
|
||||
// Executor by pointer. Nil is handled gracefully: both tools just
|
||||
// report themselves unavailable rather than panicking.
|
||||
Subagent *agentrun.Agent
|
||||
|
||||
// WorkflowMaxParallel caps how many dispatch_workflow steps may run
|
||||
// concurrently in one call, before the resolved effort level's own
|
||||
// (smaller, per-level) cap is also applied — see dispatchWorkflow.
|
||||
// Zero (the New default) falls back to defaultWorkflowMaxParallel.
|
||||
// Set from ENKLY_WORKFLOW_MAX_PARALLEL in main.go; this is the hard
|
||||
// operational ceiling for a deployment's hardware, independent of
|
||||
// what any single turn's effort level would otherwise allow.
|
||||
WorkflowMaxParallel int
|
||||
// WorkflowVerifier checks each dispatch_workflow step's report —
|
||||
// see the WorkflowVerifier type. Nil (the New default) falls back
|
||||
// to defaultWorkflowVerifier.
|
||||
WorkflowVerifier WorkflowVerifier
|
||||
}
|
||||
|
||||
// defaultWorkflowMaxParallel is a conservative default for a single
|
||||
// vLLM chat replica. Unlike Ollama (which serves one model-inference
|
||||
// request at a time in practice, however many HTTP requests arrive
|
||||
// concurrently), vLLM's continuous-batching scheduler genuinely
|
||||
// services many in-flight requests on one GPU at once, batching new
|
||||
// tokens for whichever sequences are ready each scheduler step — so a
|
||||
// production vLLM deployment can sustain meaningfully higher
|
||||
// concurrent dispatch_workflow fan-out than an Ollama-backed one ever
|
||||
// could on the same hardware, bounded in practice by
|
||||
// --max-num-seqs/--gpu-memory-utilization on the chat deployment (see
|
||||
// k8s/11-vllm.yaml) rather than by the engine serializing requests.
|
||||
// This default stays deliberately conservative rather than assuming
|
||||
// every deployment's chat replica count and --max-num-seqs can absorb
|
||||
// a much larger number — see cmd/server/main.go's
|
||||
// ENKLY_WORKFLOW_MAX_PARALLEL for how a deployment with real
|
||||
// multi-GPU/multi-replica vLLM capacity raises this toward the
|
||||
// "hundreds" a cluster-backed deployment can actually make good use
|
||||
// of.
|
||||
const defaultWorkflowMaxParallel = 64
|
||||
|
||||
// New wires an Executor to its peer services plus, if embedder is
|
||||
// non-nil, the office-skills semantic index (see SkillIndex). embedder
|
||||
// is typically the same *vllmkit.Client already used for chat — it
|
||||
// satisfies skillkit.Embedder as-is, no adapter needed.
|
||||
func New(workspace workspacev1.WorkspaceServiceClient, knowledge knowledgev1.KnowledgeServiceClient, embedder skillkit.Embedder) *Executor {
|
||||
e := &Executor{Workspace: workspace, Knowledge: knowledge}
|
||||
if embedder != nil {
|
||||
e.SkillIndex = skillkit.NewIndex(embedder)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func rpcFail(name string, err error) vllmkit.ToolResult {
|
||||
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf("%s: service unavailable: %v", name, err), IsError: true}
|
||||
}
|
||||
|
||||
// Execute dispatches a single tool call, mirroring Toolbox.Execute's
|
||||
// switch one case at a time.
|
||||
func (e *Executor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
switch call.Name {
|
||||
case "read_file":
|
||||
return e.readFile(ctx, call)
|
||||
case "write_file":
|
||||
return e.writeFile(ctx, call)
|
||||
case "edit_file":
|
||||
return e.editFile(ctx, call)
|
||||
case "multi_edit_file":
|
||||
return e.multiEditFile(ctx, call)
|
||||
case "list_directory":
|
||||
return e.listDirectory(ctx, call)
|
||||
case "glob_search":
|
||||
return e.globSearch(ctx, call)
|
||||
case "grep":
|
||||
return e.grep(ctx, call)
|
||||
case "write_todos":
|
||||
return writeTodos(call)
|
||||
case "run_bash_command":
|
||||
return e.runBashCommand(ctx, call)
|
||||
case "search_codebase":
|
||||
return e.searchCodebase(ctx, call)
|
||||
case "solve_ml_math":
|
||||
return solveMLMath(call)
|
||||
case "dispatch_subagent":
|
||||
return e.dispatchSubagent(ctx, call)
|
||||
case "dispatch_workflow":
|
||||
return e.dispatchWorkflow(ctx, call)
|
||||
case "read_office_skill":
|
||||
return readOfficeSkill(call)
|
||||
case "search_office_skills":
|
||||
return e.searchOfficeSkills(ctx, call)
|
||||
case "create_document":
|
||||
return e.createDocument(ctx, call)
|
||||
case "read_document":
|
||||
return e.readDocument(ctx, call)
|
||||
case "edit_document":
|
||||
return e.editDocument(ctx, call)
|
||||
case "create_spreadsheet":
|
||||
return e.createSpreadsheet(ctx, call)
|
||||
case "read_spreadsheet":
|
||||
return e.readSpreadsheet(ctx, call)
|
||||
case "edit_spreadsheet":
|
||||
return e.editSpreadsheet(ctx, call)
|
||||
case "create_presentation":
|
||||
return e.createPresentation(ctx, call)
|
||||
case "read_presentation":
|
||||
return e.readPresentation(ctx, call)
|
||||
case "edit_presentation":
|
||||
return e.editPresentation(ctx, call)
|
||||
case "read_pdf":
|
||||
return e.readPdf(ctx, call)
|
||||
default:
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("unknown tool %q", call.Name), IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
func argStr(args map[string]any, key string) string {
|
||||
v, _ := args[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func argInt(args map[string]any, key string) int {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func argBool(args map[string]any, key string) bool {
|
||||
v, _ := args[key].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func (e *Executor) readFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.ReadFile(ctx, &workspacev1.ReadFileRequest{
|
||||
Path: argStr(call.Arguments, "path"), Offset: int32(argInt(call.Arguments, "offset")), Limit: int32(argInt(call.Arguments, "limit")),
|
||||
})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetContent()}
|
||||
}
|
||||
|
||||
func (e *Executor) writeFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.WriteFile(ctx, &workspacev1.WriteFileRequest{
|
||||
Path: argStr(call.Arguments, "path"), Content: argStr(call.Arguments, "content"),
|
||||
})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", resp.GetBytesWritten(), argStr(call.Arguments, "path"))}
|
||||
}
|
||||
|
||||
func (e *Executor) editFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.EditFile(ctx, &workspacev1.EditFileRequest{
|
||||
Path: argStr(call.Arguments, "path"), OldString: argStr(call.Arguments, "old_string"),
|
||||
NewString: argStr(call.Arguments, "new_string"), ReplaceAll: argBool(call.Arguments, "replace_all"),
|
||||
})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetDiffPreview()}
|
||||
}
|
||||
|
||||
func (e *Executor) multiEditFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
rawEdits, _ := call.Arguments["edits"].([]any)
|
||||
edits := make([]*workspacev1.TextEdit, 0, len(rawEdits))
|
||||
for _, re := range rawEdits {
|
||||
m, ok := re.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
edits = append(edits, &workspacev1.TextEdit{
|
||||
OldString: argStr(m, "old_string"), NewString: argStr(m, "new_string"), ReplaceAll: argBool(m, "replace_all"),
|
||||
})
|
||||
}
|
||||
resp, err := e.Workspace.MultiEditFile(ctx, &workspacev1.MultiEditFileRequest{
|
||||
Path: argStr(call.Arguments, "path"), Edits: edits,
|
||||
})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetDiffPreview()}
|
||||
}
|
||||
|
||||
func (e *Executor) listDirectory(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.ListDirectory(ctx, &workspacev1.ListDirectoryRequest{Path: argStr(call.Arguments, "path")})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetListing()}
|
||||
}
|
||||
|
||||
func (e *Executor) globSearch(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.GlobSearch(ctx, &workspacev1.GlobSearchRequest{Pattern: argStr(call.Arguments, "pattern")})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
|
||||
}
|
||||
|
||||
func (e *Executor) grep(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.Grep(ctx, &workspacev1.GrepRequest{
|
||||
Pattern: argStr(call.Arguments, "pattern"), Glob: argStr(call.Arguments, "path_glob"),
|
||||
CaseInsensitive: argBool(call.Arguments, "case_insensitive"),
|
||||
})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
|
||||
}
|
||||
|
||||
func (e *Executor) runBashCommand(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
resp, err := e.Workspace.RunBashCommand(ctx, &workspacev1.RunBashCommandRequest{Command: argStr(call.Arguments, "command")})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if resp.GetIsError() {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
|
||||
}
|
||||
|
||||
func (e *Executor) searchCodebase(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
query := argStr(call.Arguments, "query")
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "search_codebase: empty query", IsError: true}
|
||||
}
|
||||
resp, err := e.Knowledge.Search(ctx, &knowledgev1.SearchRequest{Query: query, TopK: 6})
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(resp.GetChunks()) == 0 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "no matching code found"}
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range resp.GetChunks() {
|
||||
fmt.Fprintf(&sb, "### %s (chunk %d, distance %.4f)\n```\n%s\n```\n\n", c.GetFilePath(), c.GetChunkIndex(), c.GetDistance(), c.GetContent())
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
29
services/agent/internal/infrastructure/toolexec/mlmath.go
Normal file
29
services/agent/internal/infrastructure/toolexec/mlmath.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"enkly/pkg/mathkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// solveMLMath runs the solve_ml_math tool. Like write_todos, this is
|
||||
// pure, in-memory computation (arithmetic over the arguments the
|
||||
// model already supplied) with no filesystem, database, or other
|
||||
// service dependency, so — unlike every tool in executor.go that
|
||||
// calls out to the workspace or knowledge service — it never leaves
|
||||
// this process. See pkg/mathkit/solve.go for the operation dispatch
|
||||
// table and pkg/mathkit's package doc for the math it covers.
|
||||
func solveMLMath(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
op := argStr(call.Arguments, "operation")
|
||||
if op == "" {
|
||||
return vllmkit.ToolResult{
|
||||
Name: call.Name,
|
||||
Content: `solve_ml_math: missing "operation" — see the tool description for the full list of supported operations`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
result, err := mathkit.Solve(op, call.Arguments)
|
||||
if err != nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "solve_ml_math: " + err.Error(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: result}
|
||||
}
|
||||
451
services/agent/internal/infrastructure/toolexec/office.go
Normal file
451
services/agent/internal/infrastructure/toolexec/office.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Document-management tools (create/read/edit_document,
|
||||
// create/read/edit_spreadsheet, create/read/edit_presentation) —
|
||||
// Word/Excel/PowerPoint support modeled on Claude Code's
|
||||
// document-editing skills. All the actual OOXML work happens in
|
||||
// pkg/officekit; this file just translates tool-call arguments to/from
|
||||
// it and persists bytes through the workspace service's existing
|
||||
// UploadFile/DownloadFile RPCs (binary-safe []byte fields — unlike
|
||||
// ReadFile/WriteFile's text-oriented string fields, which real .docx/
|
||||
// .xlsx/.pptx bytes wouldn't survive intact).
|
||||
//
|
||||
// Deliberately, nothing here ever puts raw XML into a ToolResult.Content
|
||||
// or reads raw XML out of call.Arguments — every value crossing that
|
||||
// boundary is plain text (a title, a paragraph, a cell value, a short
|
||||
// summary like "wrote report.docx (4 paragraphs)"). That's what keeps
|
||||
// these new tools from being a new way to trigger a tool-call parse
|
||||
// failure on the model's output — the historical Ollama chat-template
|
||||
// "XML syntax error" failure mode described in catalog.go's comment
|
||||
// above these tool defs, or vLLM's own tool-call parser choking on
|
||||
// unexpected markup in an argument or result.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/officekit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
func (e *Executor) download(ctx context.Context, path string) ([]byte, error) {
|
||||
resp, err := e.Workspace.DownloadFile(ctx, &workspacev1.DownloadFileRequest{Path: path})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetContent(), nil
|
||||
}
|
||||
|
||||
func (e *Executor) upload(ctx context.Context, path string, data []byte) error {
|
||||
_, err := e.Workspace.UploadFile(ctx, &workspacev1.UploadFileRequest{Path: path, Content: data})
|
||||
return err
|
||||
}
|
||||
|
||||
// errResult is a small helper matching the rest of this package's
|
||||
// vllmkit.ToolResult{IsError: true} convention.
|
||||
func errResult(name, format string, a ...any) vllmkit.ToolResult {
|
||||
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf(format, a...), IsError: true}
|
||||
}
|
||||
|
||||
// --- Word ---------------------------------------------------------
|
||||
|
||||
func parseParagraphArg(m map[string]any) officekit.Paragraph {
|
||||
style := officekit.ParaStyle(argStr(m, "style"))
|
||||
if style == "" {
|
||||
style = officekit.StyleNormal
|
||||
}
|
||||
return officekit.Paragraph{
|
||||
Text: argStr(m, "text"),
|
||||
Style: style,
|
||||
Bold: argBool(m, "bold"),
|
||||
Italic: argBool(m, "italic"),
|
||||
Underline: argBool(m, "underline"),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) createDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".docx") {
|
||||
return errResult(call.Name, "create_document: path must end in .docx, got %q", path)
|
||||
}
|
||||
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
||||
return errResult(call.Name, "create_document: %s already exists; use edit_document to change it", path)
|
||||
}
|
||||
var paras []officekit.Paragraph
|
||||
if md := argStr(call.Arguments, "markdown"); md != "" {
|
||||
// Conversion path: let officekit's Markdown parser do the
|
||||
// heading/bullet/paragraph structuring instead of the model
|
||||
// building "paragraphs" entries by hand — see markdown.go's
|
||||
// package doc for exactly what carries over and what doesn't.
|
||||
paras = officekit.MarkdownToParagraphs(md)
|
||||
if len(paras) == 0 {
|
||||
return errResult(call.Name, "create_document: markdown produced no recognizable content (headings/paragraphs/lists)")
|
||||
}
|
||||
} else {
|
||||
rawParas, _ := call.Arguments["paragraphs"].([]any)
|
||||
paras = make([]officekit.Paragraph, 0, len(rawParas))
|
||||
for _, rp := range rawParas {
|
||||
if m, ok := rp.(map[string]any); ok {
|
||||
paras = append(paras, parseParagraphArg(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := officekit.BuildDocx(paras)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "create_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, data); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d paragraph(s), %d bytes)", path, len(paras), len(data))}
|
||||
}
|
||||
|
||||
func (e *Executor) readDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_document: %s not found or empty", path)
|
||||
}
|
||||
text, err := officekit.ReadDocxText(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_document: %v", err)
|
||||
}
|
||||
// Surface any pending tracked changes alongside the plain text so
|
||||
// a caller reviewing a document doesn't need a second tool call to
|
||||
// discover it has open redlines — see redline.go's ReadDocxRevisions.
|
||||
if revisions, rerr := officekit.ReadDocxRevisions(data); rerr == nil && revisions != "" {
|
||||
text += "\n\n--- tracked changes pending review (edit_document: accept_all_changes / reject_all_changes) ---\n" + revisions
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: text}
|
||||
}
|
||||
|
||||
func (e *Executor) editDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "edit_document: %s not found", path)
|
||||
}
|
||||
|
||||
switch argStr(call.Arguments, "operation") {
|
||||
case "append_paragraph":
|
||||
p := parseParagraphArg(call.Arguments)
|
||||
out, err := officekit.AppendDocxParagraph(data, p)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s", path)}
|
||||
|
||||
case "replace_text":
|
||||
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
|
||||
if find == "" {
|
||||
return errResult(call.Name, "edit_document: replace_text requires a non-empty \"find\"")
|
||||
}
|
||||
out, n, err := officekit.ReplaceDocxText(data, find, replace)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("replaced text in %d paragraph(s) of %s", n, path)}
|
||||
|
||||
case "replace_text_tracked":
|
||||
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
|
||||
if find == "" {
|
||||
return errResult(call.Name, "edit_document: replace_text_tracked requires a non-empty \"find\"")
|
||||
}
|
||||
out, n, err := officekit.ReplaceDocxTextTracked(data, find, replace, argStr(call.Arguments, "author"))
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("tracked a change in %d run(s) of %s, pending review (Word: Review > Track Changes)", n, path)}
|
||||
|
||||
case "append_paragraph_tracked":
|
||||
p := parseParagraphArg(call.Arguments)
|
||||
out, err := officekit.AppendDocxParagraphTracked(data, p, argStr(call.Arguments, "author"))
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s as a tracked insertion, pending review", path)}
|
||||
|
||||
case "accept_all_changes":
|
||||
out, err := officekit.AcceptAllDocxChanges(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("accepted all tracked changes in %s", path)}
|
||||
|
||||
case "reject_all_changes":
|
||||
out, err := officekit.RejectAllDocxChanges(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("rejected all tracked changes in %s (original text restored)", path)}
|
||||
|
||||
default:
|
||||
return errResult(call.Name, "edit_document: unknown operation %q (expected append_paragraph, replace_text, append_paragraph_tracked, replace_text_tracked, accept_all_changes, or reject_all_changes)", argStr(call.Arguments, "operation"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Excel ----------------------------------------------------------
|
||||
|
||||
func (e *Executor) createSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".xlsx") {
|
||||
return errResult(call.Name, "create_spreadsheet: path must end in .xlsx, got %q", path)
|
||||
}
|
||||
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
||||
return errResult(call.Name, "create_spreadsheet: %s already exists; use edit_spreadsheet to change it", path)
|
||||
}
|
||||
|
||||
sheets := parseSheetsArg(call.Arguments)
|
||||
if len(sheets) == 0 {
|
||||
return errResult(call.Name, "create_spreadsheet: give either \"rows\" (one sheet) or \"sheets\" (multiple)")
|
||||
}
|
||||
data, err := officekit.BuildXlsx(sheets)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "create_spreadsheet: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, data); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
totalRows := 0
|
||||
for _, s := range sheets {
|
||||
totalRows += len(s.Rows)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d sheet(s), %d row(s) total, %d bytes)", path, len(sheets), totalRows, len(data))}
|
||||
}
|
||||
|
||||
// parseSheetsArg accepts either the multi-sheet "sheets" array
|
||||
// (each {name, rows}) or the original single-sheet "rows"/"sheet_name"
|
||||
// pair, so existing callers keep working unchanged while the model can
|
||||
// now also build a workbook with more than one tab in one call.
|
||||
func parseSheetsArg(args map[string]any) []officekit.Sheet {
|
||||
if rawSheets, ok := args["sheets"].([]any); ok && len(rawSheets) > 0 {
|
||||
sheets := make([]officekit.Sheet, 0, len(rawSheets))
|
||||
for _, rs := range rawSheets {
|
||||
m, ok := rs.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sheets = append(sheets, officekit.Sheet{Name: argStr(m, "name"), Rows: parseRowsArg(m["rows"])})
|
||||
}
|
||||
return sheets
|
||||
}
|
||||
if _, has := args["rows"]; !has {
|
||||
return nil
|
||||
}
|
||||
return []officekit.Sheet{{Name: argStr(args, "sheet_name"), Rows: parseRowsArg(args["rows"])}}
|
||||
}
|
||||
|
||||
func parseRowsArg(v any) [][]string {
|
||||
rawRows, _ := v.([]any)
|
||||
rows := make([][]string, 0, len(rawRows))
|
||||
for _, rr := range rawRows {
|
||||
rawRow, _ := rr.([]any)
|
||||
row := make([]string, 0, len(rawRow))
|
||||
for _, c := range rawRow {
|
||||
s, _ := c.(string)
|
||||
row = append(row, s)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (e *Executor) readSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_spreadsheet: %s not found or empty", path)
|
||||
}
|
||||
sheetRef := argStr(call.Arguments, "sheet")
|
||||
rows, sheetName, err := officekit.ReadXlsxCells(data, sheetRef)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_spreadsheet: %v", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
if all, nameErr := officekit.ReadXlsxSheetNames(data); nameErr == nil && len(all) > 1 {
|
||||
fmt.Fprintf(&sb, "# sheet %q of %d: %s\n", sheetName, len(all), strings.Join(all, ", "))
|
||||
}
|
||||
for _, row := range rows {
|
||||
sb.WriteString(strings.Join(row, "\t"))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
|
||||
func (e *Executor) editSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "edit_spreadsheet: %s not found", path)
|
||||
}
|
||||
rawCells, _ := call.Arguments["cells"].([]any)
|
||||
writes := make(map[string]string, len(rawCells))
|
||||
for _, rc := range rawCells {
|
||||
m, ok := rc.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
writes[strings.ToUpper(argStr(m, "cell"))] = argStr(m, "value")
|
||||
}
|
||||
if len(writes) == 0 {
|
||||
return errResult(call.Name, "edit_spreadsheet: no cells given")
|
||||
}
|
||||
sheetRef := argStr(call.Arguments, "sheet")
|
||||
out, err := officekit.SetXlsxCells(data, sheetRef, writes)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_spreadsheet: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d cell(s) to %s", len(writes), path)}
|
||||
}
|
||||
|
||||
// --- PowerPoint -------------------------------------------------------
|
||||
|
||||
func parseSlideArg(m map[string]any) officekit.Slide {
|
||||
rawBullets, _ := m["bullets"].([]any)
|
||||
bullets := make([]string, 0, len(rawBullets))
|
||||
for _, b := range rawBullets {
|
||||
if s, ok := b.(string); ok {
|
||||
bullets = append(bullets, s)
|
||||
}
|
||||
}
|
||||
return officekit.Slide{Title: argStr(m, "title"), Bullets: bullets}
|
||||
}
|
||||
|
||||
func (e *Executor) createPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".pptx") {
|
||||
return errResult(call.Name, "create_presentation: path must end in .pptx, got %q", path)
|
||||
}
|
||||
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
||||
return errResult(call.Name, "create_presentation: %s already exists; use edit_presentation to change it", path)
|
||||
}
|
||||
rawSlides, _ := call.Arguments["slides"].([]any)
|
||||
slides := make([]officekit.Slide, 0, len(rawSlides))
|
||||
for _, rs := range rawSlides {
|
||||
if m, ok := rs.(map[string]any); ok {
|
||||
slides = append(slides, parseSlideArg(m))
|
||||
}
|
||||
}
|
||||
data, err := officekit.BuildPptx(slides)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "create_presentation: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, data); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d slide(s), %d bytes)", path, len(slides), len(data))}
|
||||
}
|
||||
|
||||
func (e *Executor) readPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_presentation: %s not found or empty", path)
|
||||
}
|
||||
slides, err := officekit.ReadPptxSlides(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_presentation: %v", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, s := range slides {
|
||||
fmt.Fprintf(&sb, "## Slide %d: %s\n", i+1, s.Title)
|
||||
for _, b := range s.Bullets {
|
||||
fmt.Fprintf(&sb, "- %s\n", b)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
|
||||
func (e *Executor) editPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "edit_presentation: %s not found", path)
|
||||
}
|
||||
|
||||
slide := parseSlideArg(call.Arguments)
|
||||
switch argStr(call.Arguments, "operation") {
|
||||
case "add_slide":
|
||||
out, err := officekit.AppendPptxSlide(data, slide)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_presentation: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("added 1 slide to %s", path)}
|
||||
|
||||
case "update_slide":
|
||||
idx := argInt(call.Arguments, "index")
|
||||
out, err := officekit.UpdatePptxSlide(data, idx, slide)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_presentation: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("updated slide %d of %s", idx, path)}
|
||||
|
||||
default:
|
||||
return errResult(call.Name, "edit_presentation: unknown operation %q (expected add_slide or update_slide)", argStr(call.Arguments, "operation"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- PDF (read-only: data extraction, not creation) --------------------
|
||||
|
||||
func (e *Executor) readPdf(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_pdf: %s not found or empty", path)
|
||||
}
|
||||
text, err := officekit.ReadPdfText(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_pdf: %v", err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: text}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// read_office_skill and search_office_skills are the two office-skill
|
||||
// tools that aren't a gRPC call or an officekit XML operation.
|
||||
// read_office_skill is pure, in-memory lookup into pkg/skillkit's
|
||||
// embedded skill files (the same category as write_todos and
|
||||
// solve_ml_math — no workspace/knowledge dependency, see executor.go's
|
||||
// package doc); search_office_skills calls out to the vLLM-hosted
|
||||
// embedding model (via Executor.SkillIndex) to embed the query, same
|
||||
// as search_codebase
|
||||
// calling out to the knowledge service — see rag.go's package doc for
|
||||
// why this one stays in-process instead.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/skillkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
func readOfficeSkill(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
name := argStr(call.Arguments, "name")
|
||||
skill, ok := skillkit.Read(name)
|
||||
if !ok {
|
||||
available := skillkit.Names()
|
||||
return errResult(call.Name, "read_office_skill: no skill named %q (available: %s)", name, strings.Join(available, ", "))
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: skill.Body}
|
||||
}
|
||||
|
||||
func (e *Executor) searchOfficeSkills(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
query := strings.TrimSpace(argStr(call.Arguments, "query"))
|
||||
if query == "" {
|
||||
return errResult(call.Name, "search_office_skills: empty query")
|
||||
}
|
||||
if e.SkillIndex == nil {
|
||||
return errResult(call.Name, "search_office_skills: not available on this deployment (no embedding model configured); use read_office_skill instead")
|
||||
}
|
||||
skillFilter := argStr(call.Arguments, "skill")
|
||||
if skillFilter != "" {
|
||||
if _, ok := skillkit.Read(skillFilter); !ok {
|
||||
return errResult(call.Name, "search_office_skills: no skill named %q (available: %s)", skillFilter, strings.Join(skillkit.Names(), ", "))
|
||||
}
|
||||
}
|
||||
|
||||
matches, err := e.SkillIndex.Search(ctx, query, 4, skillFilter)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "search_office_skills: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "no matching guidance found"}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, m := range matches {
|
||||
fmt.Fprintf(&sb, "### %s — %s (similarity %.3f)\n%s\n\n", m.Skill, m.Heading, m.Score, m.Text)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: strings.TrimRight(sb.String(), "\n")}
|
||||
}
|
||||
84
services/agent/internal/infrastructure/toolexec/subagent.go
Normal file
84
services/agent/internal/infrastructure/toolexec/subagent.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// subagent.go implements the dispatch_subagent tool: the agent
|
||||
// service's equivalent of Claude Code's Task tool. The main agent
|
||||
// hands off a self-contained investigation to a nested agentrun.Agent
|
||||
// (see agentrun.NewSubagent) that runs with its own, much smaller tool
|
||||
// list and reports back in one string, so a broad exploration ("find
|
||||
// every place X happens") spends *its* context budget, not the
|
||||
// conversation's.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
"enkly/services/agent/internal/infrastructure/toolcatalog"
|
||||
)
|
||||
|
||||
// restrictedExecutor wraps an *Executor but only forwards calls whose
|
||||
// name is in toolcatalog's read-only whitelist, failing closed on
|
||||
// everything else (including, explicitly, dispatch_subagent itself —
|
||||
// a sub-agent can't spawn another one). This is the second of two
|
||||
// enforcement layers: toolcatalog.SubagentDefs() controls what the
|
||||
// model is *offered*, this controls what actually runs if the model
|
||||
// (or a future bug) asks for something outside that list anyway.
|
||||
type restrictedExecutor struct {
|
||||
inner *Executor
|
||||
}
|
||||
|
||||
// NewRestricted returns a ToolExecutor that only ever runs the
|
||||
// read-only tools in toolcatalog.SubagentDefs(), whatever inner
|
||||
// supports. Used to build the dispatch_subagent sub-agent's executor.
|
||||
func NewRestricted(inner *Executor) *restrictedExecutor {
|
||||
return &restrictedExecutor{inner: inner}
|
||||
}
|
||||
|
||||
func (r *restrictedExecutor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
if !toolcatalog.IsSubagentTool(call.Name) {
|
||||
return vllmkit.ToolResult{
|
||||
Name: call.Name,
|
||||
Content: fmt.Sprintf("%s: not available to a sub-agent (read-only tools only)", call.Name),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return r.inner.Execute(ctx, call)
|
||||
}
|
||||
|
||||
// dispatchSubagent runs the dispatch_subagent tool call: it launches
|
||||
// e.Subagent on a single user message (the "task" argument) and feeds
|
||||
// its final answer back as this tool call's result. e.Subagent is nil
|
||||
// until main.go wires it (see its comment there for why that can't
|
||||
// happen inside toolexec.New) — guarded here so a misconfigured
|
||||
// deployment degrades to a clear tool error instead of a nil-pointer
|
||||
// panic reaching the model's request.
|
||||
func (e *Executor) dispatchSubagent(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
if e.Subagent == nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: sub-agent not configured on this deployment", IsError: true}
|
||||
}
|
||||
task := strings.TrimSpace(argStr(call.Arguments, "task"))
|
||||
if task == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: task is required", IsError: true}
|
||||
}
|
||||
|
||||
report, _, err := e.Subagent.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: task}}, agentrun.Callbacks{})
|
||||
if err != nil {
|
||||
// A cancelled sub-agent (the whole turn was stopped, or it hit
|
||||
// its own round cap mid-investigation) still gets a useful
|
||||
// result back: whatever it had produced, plus a clear note —
|
||||
// not a bare tool error that discards partial progress.
|
||||
report = strings.TrimSpace(report)
|
||||
note := fmt.Sprintf("\n\n[sub-agent stopped early: %v]", err)
|
||||
if report == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: " + err.Error(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: report + note}
|
||||
}
|
||||
|
||||
report = strings.TrimSpace(report)
|
||||
if report == "" {
|
||||
report = "(the sub-agent finished without writing a report — try a more specific task description)"
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: report}
|
||||
}
|
||||
429
services/agent/internal/infrastructure/toolexec/workflow.go
Normal file
429
services/agent/internal/infrastructure/toolexec/workflow.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// workflow.go implements the dispatch_workflow tool: enkly's answer to
|
||||
// "dynamic workflows... orchestration of hundreds of subagents in
|
||||
// parallel in a single session, with automatic verification of results
|
||||
// and real-time plan adjustment." Where dispatch_subagent (subagent.go)
|
||||
// hands off ONE investigation and waits for it, dispatch_workflow takes
|
||||
// a whole batch of them — typically one per file, service, or call site
|
||||
// in a large mechanical job like a codebase migration — and runs as
|
||||
// many as the deployment's configured (and plan-entitled) parallelism
|
||||
// allows at once, respects any depends_on ordering between them, checks
|
||||
// every report against its own task before handing results back, and
|
||||
// retries a step once if that check fails, instead of silently treating
|
||||
// an empty or off-topic report as done.
|
||||
//
|
||||
// Every step is, under the hood, exactly the same kind of run
|
||||
// dispatch_subagent launches — e.Subagent.Run against a fresh,
|
||||
// read-only sub-agent — just many of them in flight at once instead of
|
||||
// one. That reuse is deliberate: it's the same nested agentrun.Agent,
|
||||
// the same restrictedExecutor read-only boundary, the same
|
||||
// SubagentSystemPrompt, so a workflow step can never write or run
|
||||
// anything any more than a lone dispatch_subagent call could. What
|
||||
// *is* new here is the scheduling, the bounded concurrency, and the
|
||||
// verification pass — everything below this point in the file.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"enkly/pkg/entitlements"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Entitlements: whether dispatch_workflow is usable this turn
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// entitlementsKey is WithEntitlements' context key.
|
||||
type entitlementsKey struct{}
|
||||
|
||||
// WithEntitlements attaches the caller's resolved capability Set (see
|
||||
// pkg/entitlements) to ctx for dispatchWorkflow to read back — set once
|
||||
// per turn by application.Service.StartTurn from the plan name
|
||||
// billing's CheckAndIncrementUsage already returned, the same place
|
||||
// agentrun.WithPreferredLanguage and agentrun.WithEffort get attached
|
||||
// to the same run context.
|
||||
func WithEntitlements(ctx context.Context, set entitlements.Set) context.Context {
|
||||
return context.WithValue(ctx, entitlementsKey{}, set)
|
||||
}
|
||||
|
||||
// entitlementsFrom reads back what WithEntitlements stored. The zero
|
||||
// Set (DynamicWorkflows: false) is the correct default for a context
|
||||
// nothing ever attached one to — see entitlements.Catalog.Resolve's
|
||||
// doc comment on failing closed.
|
||||
func entitlementsFrom(ctx context.Context) entitlements.Set {
|
||||
set, _ := ctx.Value(entitlementsKey{}).(entitlements.Set)
|
||||
return set
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// dispatch_workflow argument parsing
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type workflowStep struct {
|
||||
ID string
|
||||
Task string
|
||||
DependsOn []string
|
||||
}
|
||||
|
||||
// maxWorkflowSteps bounds one dispatch_workflow call regardless of
|
||||
// configured parallelism — "hundreds" of concurrent sub-agents is the
|
||||
// target, not an unbounded number typed into one tool call; well past
|
||||
// this a migration should be split into more than one dispatch_workflow
|
||||
// round anyway (the model can always call it again).
|
||||
const maxWorkflowSteps = 500
|
||||
|
||||
func parseWorkflowSteps(raw []any) ([]workflowStep, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("dispatch_workflow: \"steps\" must be a non-empty array")
|
||||
}
|
||||
if len(raw) > maxWorkflowSteps {
|
||||
return nil, fmt.Errorf("dispatch_workflow: %d steps exceeds the %d-step limit for one call; split into more than one dispatch_workflow", len(raw), maxWorkflowSteps)
|
||||
}
|
||||
seen := make(map[string]bool, len(raw))
|
||||
steps := make([]workflowStep, 0, len(raw))
|
||||
for i, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dispatch_workflow: step %d is not an object", i)
|
||||
}
|
||||
id := strings.TrimSpace(argStr(m, "id"))
|
||||
task := strings.TrimSpace(argStr(m, "task"))
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("dispatch_workflow: step %d is missing \"id\"", i)
|
||||
}
|
||||
if task == "" {
|
||||
return nil, fmt.Errorf("dispatch_workflow: step %q is missing \"task\"", id)
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, fmt.Errorf("dispatch_workflow: duplicate step id %q", id)
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
var deps []string
|
||||
if rawDeps, ok := m["depends_on"].([]any); ok {
|
||||
for _, d := range rawDeps {
|
||||
if s, ok := d.(string); ok && strings.TrimSpace(s) != "" {
|
||||
deps = append(deps, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
steps = append(steps, workflowStep{ID: id, Task: task, DependsOn: deps})
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// validateDependencies checks that every depends_on name refers to a
|
||||
// real step in the same batch and that the dependency graph has no
|
||||
// cycle (Kahn's algorithm — the same technique runWorkflow's own
|
||||
// scheduler uses, run here purely as an up-front check so a cyclic or
|
||||
// malformed workflow fails loudly as a single clear tool error instead
|
||||
// of silently deadlocking the scheduler).
|
||||
func validateDependencies(steps []workflowStep) error {
|
||||
index := make(map[string]int, len(steps))
|
||||
for i, s := range steps {
|
||||
index[s.ID] = i
|
||||
}
|
||||
indegree := make([]int, len(steps))
|
||||
adj := make([][]int, len(steps))
|
||||
for i, s := range steps {
|
||||
for _, dep := range s.DependsOn {
|
||||
j, ok := index[dep]
|
||||
if !ok {
|
||||
return fmt.Errorf("dispatch_workflow: step %q depends_on unknown step %q", s.ID, dep)
|
||||
}
|
||||
if j == i {
|
||||
return fmt.Errorf("dispatch_workflow: step %q cannot depend on itself", s.ID)
|
||||
}
|
||||
indegree[i]++
|
||||
adj[j] = append(adj[j], i)
|
||||
}
|
||||
}
|
||||
queue := make([]int, 0, len(steps))
|
||||
for i, d := range indegree {
|
||||
if d == 0 {
|
||||
queue = append(queue, i)
|
||||
}
|
||||
}
|
||||
visited := 0
|
||||
for len(queue) > 0 {
|
||||
n := queue[0]
|
||||
queue = queue[1:]
|
||||
visited++
|
||||
for _, m := range adj[n] {
|
||||
indegree[m]--
|
||||
if indegree[m] == 0 {
|
||||
queue = append(queue, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if visited != len(steps) {
|
||||
return fmt.Errorf("dispatch_workflow: depends_on forms a cycle — every step must be reachable without looping back")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Verification
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// WorkflowVerifier checks whether a step's report actually answers its
|
||||
// task, returning ok plus a short human-readable reason when it
|
||||
// doesn't. toolexec.Executor.WorkflowVerifier defaults to
|
||||
// defaultWorkflowVerifier; a deployment can swap in something richer
|
||||
// (e.g. one extra, cheap LLM call judging relevance) by setting that
|
||||
// field — see Executor's doc comment.
|
||||
type WorkflowVerifier func(task, report string) (ok bool, reason string)
|
||||
|
||||
// defaultWorkflowVerifier is a deliberately cheap, deterministic check
|
||||
// — no second model round-trip per step, consistent with this
|
||||
// codebase's preference (see pkg/mathkit) for dependency-free,
|
||||
// inspectable logic wherever a real engine isn't needed. It catches the
|
||||
// two failure shapes a stalled or confused sub-agent actually produces
|
||||
// in practice (an empty report, or the canned "finished without
|
||||
// writing a report" fallback from subagent.go's own dispatchSubagent)
|
||||
// plus an implausibly short report, without trying to judge the report's
|
||||
// substance — that judgment call is exactly what's handed back to the
|
||||
// dispatching model via each step's Verified/Reason, per "real-time
|
||||
// plan adjustment": the model, not this function, decides what a thin
|
||||
// report means for the overall task.
|
||||
func defaultWorkflowVerifier(task, report string) (bool, string) {
|
||||
r := strings.TrimSpace(report)
|
||||
if r == "" {
|
||||
return false, "empty report"
|
||||
}
|
||||
if strings.Contains(r, "finished without writing a report") {
|
||||
return false, "sub-agent produced no findings"
|
||||
}
|
||||
if strings.HasPrefix(r, "[step failed to run:") {
|
||||
return false, "sub-agent run did not complete"
|
||||
}
|
||||
const minPlausibleReportChars = 40
|
||||
if len(r) < minPlausibleReportChars {
|
||||
return false, "report too short to plausibly answer the task"
|
||||
}
|
||||
_ = task // reserved for a future content-aware verifier (e.g. keyword/file-path overlap with the task)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scheduling + execution
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// stepOutcome is one step's final result, after up to two attempts.
|
||||
type stepOutcome struct {
|
||||
ID string
|
||||
Report string
|
||||
Verified bool
|
||||
Reason string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// maxStepAttempts bounds the "plan adjustment" retry per step: one
|
||||
// retry, with the verifier's own reason folded into the retry's task
|
||||
// as a concrete note on what was missing, then accept whatever comes
|
||||
// back — a workflow step is meant to be a bounded, mechanical unit of
|
||||
// work, not an open-ended negotiation with the model.
|
||||
const maxStepAttempts = 2
|
||||
|
||||
// runStepWithVerification runs one step (possibly twice) against sub,
|
||||
// verifying the report each time and amending the task with the
|
||||
// verifier's reason before a retry.
|
||||
func runStepWithVerification(ctx context.Context, sub *agentrun.Agent, task string, verify WorkflowVerifier) (report string, attempts int, verified bool, reason string) {
|
||||
for attempts = 1; attempts <= maxStepAttempts; attempts++ {
|
||||
raw, _, err := sub.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: task}}, agentrun.Callbacks{})
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case raw != "":
|
||||
report = raw
|
||||
case err != nil:
|
||||
report = fmt.Sprintf("[step failed to run: %v]", err)
|
||||
default:
|
||||
report = ""
|
||||
}
|
||||
|
||||
ok, why := verify(task, report)
|
||||
if ok {
|
||||
return report, attempts, true, ""
|
||||
}
|
||||
reason = why
|
||||
if attempts < maxStepAttempts && ctx.Err() == nil {
|
||||
task = fmt.Sprintf("%s\n\n[Your previous attempt's report was judged insufficient: %s. Investigate more "+
|
||||
"thoroughly — check files you may have skipped, and make sure your report cites specific evidence — "+
|
||||
"then try again.]", task, why)
|
||||
}
|
||||
}
|
||||
return report, attempts, false, reason
|
||||
}
|
||||
|
||||
// runWorkflow executes every step, honoring depends_on and bounded by
|
||||
// maxParallel concurrent sub-agent runs at any moment — not maxParallel
|
||||
// per "wave", the whole run: a step becomes eligible the instant its
|
||||
// last dependency finishes and immediately competes for the same
|
||||
// semaphore every other ready step is drawing from, so a workflow with
|
||||
// an uneven dependency shape (a few early steps gating a large fan-out)
|
||||
// still keeps the configured number of sub-agents busy throughout,
|
||||
// rather than only during a step's own "wave".
|
||||
func runWorkflow(ctx context.Context, sub *agentrun.Agent, goal string, steps []workflowStep, maxParallel int, verify WorkflowVerifier) []stepOutcome {
|
||||
if maxParallel < 1 {
|
||||
maxParallel = 1
|
||||
}
|
||||
n := len(steps)
|
||||
index := make(map[string]int, n)
|
||||
for i, s := range steps {
|
||||
index[s.ID] = i
|
||||
}
|
||||
indegree := make([]int, n)
|
||||
dependents := make([][]int, n)
|
||||
for i, s := range steps {
|
||||
for _, dep := range s.DependsOn {
|
||||
j := index[dep]
|
||||
indegree[i]++
|
||||
dependents[j] = append(dependents[j], i)
|
||||
}
|
||||
}
|
||||
|
||||
outcomes := make([]stepOutcome, n)
|
||||
sem := make(chan struct{}, maxParallel)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var schedule func(i int)
|
||||
schedule = func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
mu.Lock()
|
||||
outcomes[i] = stepOutcome{ID: steps[i].ID, Report: "[skipped: session stopped before this step started]", Attempts: 0}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
task := fmt.Sprintf("Overall workflow goal: %s\n\nYour step (id %q): %s", goal, steps[i].ID, steps[i].Task)
|
||||
report, attempts, verified, reason := runStepWithVerification(ctx, sub, task, verify)
|
||||
<-sem
|
||||
|
||||
mu.Lock()
|
||||
outcomes[i] = stepOutcome{ID: steps[i].ID, Report: report, Verified: verified, Reason: reason, Attempts: attempts}
|
||||
var newlyReady []int
|
||||
for _, j := range dependents[i] {
|
||||
indegree[j]--
|
||||
if indegree[j] == 0 {
|
||||
newlyReady = append(newlyReady, j)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
for _, j := range newlyReady {
|
||||
wg.Add(1)
|
||||
go schedule(j)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if indegree[i] == 0 {
|
||||
wg.Add(1)
|
||||
go schedule(i)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// renderWorkflowSummary turns every step's outcome into the single
|
||||
// string handed back as the dispatch_workflow tool result — the "plan
|
||||
// adjustment" input the dispatching model actually reads: it sees at a
|
||||
// glance which steps verified and which didn't, by id, so it can decide
|
||||
// what to do about the shortfall (re-dispatch, handle manually, or
|
||||
// proceed if the unverified step turns out not to matter) rather than
|
||||
// that distinction being silently lost.
|
||||
func renderWorkflowSummary(goal string, outcomes []stepOutcome) string {
|
||||
verifiedN := 0
|
||||
for _, o := range outcomes {
|
||||
if o.Verified {
|
||||
verifiedN++
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Workflow complete: %d/%d steps verified. Goal: %s\n", verifiedN, len(outcomes), goal)
|
||||
if verifiedN < len(outcomes) {
|
||||
fmt.Fprintf(&b, "%d step(s) need attention — see the ⚠️ entries below before treating this workflow as done.\n", len(outcomes)-verifiedN)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, o := range outcomes {
|
||||
status := "✅ verified"
|
||||
if !o.Verified {
|
||||
status = fmt.Sprintf("⚠️ needs attention — %s", o.Reason)
|
||||
}
|
||||
fmt.Fprintf(&b, "### Step %q — %s (%d attempt(s))\n%s\n\n", o.ID, status, o.Attempts, o.Report)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The tool call itself
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// dispatchWorkflow runs the dispatch_workflow tool call: gated by
|
||||
// entitlementsFrom(ctx).DynamicWorkflows, bounded by
|
||||
// Executor.WorkflowMaxParallel and the resolved effort level's own
|
||||
// MaxParallelSubagents cap (whichever is smaller — see
|
||||
// agentrun.EffortFromContext), scheduled by runWorkflow, and rendered
|
||||
// by renderWorkflowSummary.
|
||||
func (e *Executor) dispatchWorkflow(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
if e.Subagent == nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_workflow: sub-agents are not configured on this deployment", IsError: true}
|
||||
}
|
||||
|
||||
ent := entitlementsFrom(ctx)
|
||||
if !ent.DynamicWorkflows {
|
||||
plan := ent.PlanName
|
||||
if plan == "" {
|
||||
plan = "your current plan"
|
||||
}
|
||||
return vllmkit.ToolResult{
|
||||
Name: call.Name,
|
||||
Content: fmt.Sprintf("dispatch_workflow: dynamic workflows aren't available on %s (this feature is part of "+
|
||||
"the Enterprise/Team/Max tier). Use dispatch_subagent for the same investigative work one step at a "+
|
||||
"time instead, or do it directly.", plan),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
goal := strings.TrimSpace(argStr(call.Arguments, "goal"))
|
||||
if goal == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `dispatch_workflow: "goal" is required`, IsError: true}
|
||||
}
|
||||
rawSteps, _ := call.Arguments["steps"].([]any)
|
||||
steps, err := parseWorkflowSteps(rawSteps)
|
||||
if err != nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
if err := validateDependencies(steps); err != nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
|
||||
maxParallel := e.WorkflowMaxParallel
|
||||
if maxParallel <= 0 {
|
||||
maxParallel = defaultWorkflowMaxParallel
|
||||
}
|
||||
if levelCap := agentrun.EffortFromContext(ctx).Resolve().MaxParallelSubagents; levelCap > 0 && levelCap < maxParallel {
|
||||
maxParallel = levelCap
|
||||
}
|
||||
if maxParallel > len(steps) {
|
||||
maxParallel = len(steps)
|
||||
}
|
||||
|
||||
verify := e.WorkflowVerifier
|
||||
if verify == nil {
|
||||
verify = defaultWorkflowVerifier
|
||||
}
|
||||
|
||||
outcomes := runWorkflow(ctx, e.Subagent, goal, steps, maxParallel, verify)
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: renderWorkflowSummary(goal, outcomes)}
|
||||
}
|
||||
118
services/agent/internal/infrastructure/toolexec/workflow_test.go
Normal file
118
services/agent/internal/infrastructure/toolexec/workflow_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package toolexec
|
||||
|
||||
import "testing"
|
||||
|
||||
func mustParseSteps(t *testing.T, raw []any) []workflowStep {
|
||||
t.Helper()
|
||||
steps, err := parseWorkflowSteps(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWorkflowSteps: unexpected error: %v", err)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func TestParseWorkflowSteps_Basic(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{"id": "a", "task": "do a"},
|
||||
map[string]any{"id": "b", "task": "do b", "depends_on": []any{"a"}},
|
||||
}
|
||||
steps := mustParseSteps(t, raw)
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("got %d steps, want 2", len(steps))
|
||||
}
|
||||
if steps[1].DependsOn[0] != "a" {
|
||||
t.Errorf("steps[1].DependsOn = %v, want [a]", steps[1].DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkflowSteps_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []any
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"missing id", []any{map[string]any{"task": "x"}}},
|
||||
{"missing task", []any{map[string]any{"id": "a"}}},
|
||||
{"duplicate id", []any{
|
||||
map[string]any{"id": "a", "task": "x"},
|
||||
map[string]any{"id": "a", "task": "y"},
|
||||
}},
|
||||
{"not an object", []any{"not-an-object"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := parseWorkflowSteps(tc.raw); err == nil {
|
||||
t.Errorf("parseWorkflowSteps(%s): expected an error, got nil", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkflowSteps_TooMany(t *testing.T) {
|
||||
raw := make([]any, maxWorkflowSteps+1)
|
||||
for i := range raw {
|
||||
raw[i] = map[string]any{"id": string(rune('a' + i%26)), "task": "x"}
|
||||
}
|
||||
if _, err := parseWorkflowSteps(raw); err == nil {
|
||||
t.Error("expected an error for exceeding maxWorkflowSteps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_OK(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x"},
|
||||
map[string]any{"id": "b", "task": "x", "depends_on": []any{"a"}},
|
||||
map[string]any{"id": "c", "task": "x", "depends_on": []any{"a", "b"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err != nil {
|
||||
t.Errorf("validateDependencies: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_UnknownDep(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x", "depends_on": []any{"nope"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err == nil {
|
||||
t.Error("expected an error for an unknown dependency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_SelfDependency(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x", "depends_on": []any{"a"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err == nil {
|
||||
t.Error("expected an error for a step depending on itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_Cycle(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x", "depends_on": []any{"b"}},
|
||||
map[string]any{"id": "b", "task": "x", "depends_on": []any{"a"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err == nil {
|
||||
t.Error("expected an error for a two-step cycle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWorkflowVerifier(t *testing.T) {
|
||||
cases := []struct {
|
||||
report string
|
||||
wantOK bool
|
||||
}{
|
||||
{"", false},
|
||||
{" ", false},
|
||||
{"short", false},
|
||||
{"(the sub-agent finished without writing a report — try a more specific task description)", false},
|
||||
{"[step failed to run: context deadline exceeded]", false},
|
||||
{"This is a plausible, substantial report with real findings in it about the codebase.", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ok, reason := defaultWorkflowVerifier("some task", tc.report)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("defaultWorkflowVerifier(%q) ok = %v (reason %q), want %v", tc.report, ok, reason, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// writeTodos replaces Toolbox.writeTodos verbatim. It was always pure,
|
||||
// in-memory formatting with no filesystem or database dependency, so
|
||||
// unlike every other tool it doesn't move to the workspace service —
|
||||
// there's nothing to execute remotely.
|
||||
func writeTodos(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
raw, ok := call.Arguments["todos"]
|
||||
if !ok {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: missing "todos" array`, IsError: true}
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: "todos" must be an array`, IsError: true}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "write_todos: the todo list is empty", IsError: true}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
inProgress := 0
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: each todo must be an object with "content" and "status"`, IsError: true}
|
||||
}
|
||||
content, _ := m["content"].(string)
|
||||
status, _ := m["status"].(string)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: a todo is missing "content"`, IsError: true}
|
||||
}
|
||||
var box string
|
||||
switch status {
|
||||
case "completed":
|
||||
box = "[x]"
|
||||
case "in_progress":
|
||||
box = "[~]"
|
||||
inProgress++
|
||||
case "pending", "":
|
||||
box = "[ ]"
|
||||
default:
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("write_todos: unknown status %q (want pending, in_progress, or completed)", status), IsError: true}
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s %s\n", box, content)
|
||||
}
|
||||
if inProgress > 1 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "write_todos: more than one todo is in_progress — focus on one step at a time", IsError: true}
|
||||
}
|
||||
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
179
services/agent/internal/interfaces/grpc/server.go
Normal file
179
services/agent/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Package grpc adapts application.Service onto the generated
|
||||
// agentv1.AgentServiceServer interface. StreamTurn's polling/diffing/
|
||||
// coalescing loop lives here rather than in the application layer
|
||||
// because it's intrinsically about the generated grpc.ServerStream
|
||||
// type — see its doc comment for how it maps to the monolith's
|
||||
// Handlers.StreamChat.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
agentv1 "enkly/gen/agent/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/pkg/i18n"
|
||||
"enkly/services/agent/internal/application"
|
||||
"enkly/services/agent/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
sseKeepAliveInterval = 15 * time.Second
|
||||
sseCoalesceDelay = 30 * time.Millisecond
|
||||
|
||||
// langMetadataKey mirrors the gateway's handlers.langMetadataKey —
|
||||
// the gRPC metadata key it sets on StartTurn to tell this service
|
||||
// which UI locale the visitor has chosen. Kept as a plain string
|
||||
// constant rather than a shared pkg symbol because it's the one
|
||||
// and only coupling point between the two services' transport
|
||||
// layers, and a shared constant for a single string isn't worth a
|
||||
// new shared pkg import on either side.
|
||||
langMetadataKey = "x-enkly-lang"
|
||||
)
|
||||
|
||||
// langFromIncomingContext reads the visitor's chosen locale off ctx's
|
||||
// incoming gRPC metadata (set by the gateway — see langMetadataKey's
|
||||
// doc comment), defaulting to i18n.Default when the call carries none
|
||||
// (any caller other than the gateway, or an older gateway build).
|
||||
func langFromIncomingContext(ctx context.Context) i18n.Locale {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return i18n.Default
|
||||
}
|
||||
values := md.Get(langMetadataKey)
|
||||
if len(values) == 0 {
|
||||
return i18n.Default
|
||||
}
|
||||
return i18n.ParseLocale(values[0])
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
agentv1.UnimplementedAgentServiceServer
|
||||
Svc *application.Service
|
||||
}
|
||||
|
||||
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
|
||||
|
||||
func toProtoOutcome(o application.Outcome) agentv1.StartTurnOutcome {
|
||||
switch o {
|
||||
case application.OutcomeOK:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_OK
|
||||
case application.OutcomeConversationNotFound:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_NOT_FOUND
|
||||
case application.OutcomeConversationBusy:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_BUSY
|
||||
case application.OutcomeTooManyStreams:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_TOO_MANY_STREAMS
|
||||
case application.OutcomeRateLimited:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_RATE_LIMITED
|
||||
case application.OutcomeQuotaExceeded:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED
|
||||
default:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_INTERNAL_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) StartTurn(ctx context.Context, req *agentv1.StartTurnRequest) (*agentv1.StartTurnResponse, error) {
|
||||
lang := langFromIncomingContext(ctx)
|
||||
res := s.Svc.StartTurn(ctx, req.GetUserId(), req.GetConversationId(), req.GetPrompt(), lang)
|
||||
return &agentv1.StartTurnResponse{Outcome: toProtoOutcome(res.Outcome), Message: res.Message, StreamId: res.StreamID}, nil
|
||||
}
|
||||
|
||||
func toProtoToolCall(tc domain.ToolCallResult) *agentv1.ToolCall {
|
||||
return &agentv1.ToolCall{Name: tc.Name, ArgumentsJson: tc.ArgumentsJSON, Result: tc.Result, IsError: tc.IsError}
|
||||
}
|
||||
|
||||
func toProtoThinkingRound(tr domain.ThinkingRound) *agentv1.ThinkingRound {
|
||||
return &agentv1.ThinkingRound{Round: int32(tr.Round), Thinking: tr.Thinking}
|
||||
}
|
||||
|
||||
// StreamTurn replaces Handlers.StreamChat, translated from SSE to
|
||||
// server-streaming gRPC: same snapshot/diff/coalesce loop, same
|
||||
// Last-Event-ID-style resume (resume_from_tool_index), same keepalive
|
||||
// interval — just agentv1.TurnEvent messages instead of SSE frames. The
|
||||
// gateway's own SSE handler reads this stream and does the reverse
|
||||
// translation back to SSE for the browser, plus all HTML rendering
|
||||
// (this service deals in plain text only).
|
||||
func (s *Server) StreamTurn(req *agentv1.StreamTurnRequest, stream agentv1.AgentService_StreamTurnServer) error {
|
||||
st := s.Svc.Streams.Get(req.GetStreamId())
|
||||
if st == nil || st.UserID != req.GetUserId() {
|
||||
return grpcerr.NotFound("stream")
|
||||
}
|
||||
|
||||
sentTools := int(req.GetResumeFromToolIndex())
|
||||
// sentThinkRounds has no resume-from-request counterpart the way
|
||||
// sentTools does (StreamTurnRequest carries no thinking-round
|
||||
// cursor) — a reconnect just resends every round from the start,
|
||||
// same as lastThinking resetting to "" below and re-sending the
|
||||
// full cumulative trace. Cheap and harmless: a run has at most
|
||||
// MaxRounds (12) of these, each a short text, never the
|
||||
// unboundedly-large payloads tool results can be.
|
||||
sentThinkRounds := 0
|
||||
var lastToken, lastThinking string
|
||||
|
||||
ctx := stream.Context()
|
||||
for {
|
||||
snap, changed := st.Snapshot()
|
||||
|
||||
for i := sentTools; i < len(snap.Tools); i++ {
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ToolCall{ToolCall: toProtoToolCall(snap.Tools[i])}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(snap.Tools) > sentTools {
|
||||
sentTools = len(snap.Tools)
|
||||
}
|
||||
for i := sentThinkRounds; i < len(snap.ThinkingRounds); i++ {
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingRound{ThinkingRound: toProtoThinkingRound(snap.ThinkingRounds[i])}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(snap.ThinkingRounds) > sentThinkRounds {
|
||||
sentThinkRounds = len(snap.ThinkingRounds)
|
||||
}
|
||||
if snap.ThinkText != lastThinking {
|
||||
lastThinking = snap.ThinkText
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingDelta{ThinkingDelta: lastThinking}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if snap.Done {
|
||||
done := &agentv1.TurnDone{FinalText: snap.FinalText}
|
||||
for _, tc := range snap.FinalToolCalls {
|
||||
done.ToolCalls = append(done.ToolCalls, toProtoToolCall(tc))
|
||||
}
|
||||
return stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_Done{Done: done}})
|
||||
}
|
||||
if snap.TokenText != lastToken {
|
||||
lastToken = snap.TokenText
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_TokenDelta{TokenDelta: lastToken}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
// Let a burst of tokens accumulate so we send one event, not fifty.
|
||||
select {
|
||||
case <-time.After(sseCoalesceDelay):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
case <-time.After(sseKeepAliveInterval):
|
||||
// Server-streaming gRPC has its own keepalive at the
|
||||
// transport level (HTTP/2 PINGs); nothing to send here.
|
||||
// The timer's only job is to re-loop and re-check ctx.Done.
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) StopTurn(ctx context.Context, req *agentv1.StopTurnRequest) (*agentv1.StopTurnResponse, error) {
|
||||
if !s.Svc.StopTurn(req.GetUserId(), req.GetStreamId()) {
|
||||
return nil, grpcerr.NotFound("stream")
|
||||
}
|
||||
return &agentv1.StopTurnResponse{}, nil
|
||||
}
|
||||
17
services/billing/Dockerfile
Normal file
17
services/billing/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/billing/ services/billing/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/billing-server ./services/billing/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/billing-server ./billing-server
|
||||
COPY --from=build /src/services/billing/migrations ./migrations
|
||||
ENV ENKLY_BILLING_GRPC_ADDR=:9002
|
||||
EXPOSE 9002
|
||||
ENTRYPOINT ["./billing-server"]
|
||||
109
services/billing/cmd/server/main.go
Normal file
109
services/billing/cmd/server/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Command server runs the billing microservice: a gRPC server exposing
|
||||
// api/proto/billing/v1, backed by its own Postgres database and the
|
||||
// Stripe REST API. Replaces internal/handlers/billing.go +
|
||||
// internal/billing/*.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/billing/internal/application"
|
||||
"enkly/services/billing/internal/infrastructure/postgres"
|
||||
"enkly/services/billing/internal/infrastructure/stripeapi"
|
||||
grpcsrv "enkly/services/billing/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
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 (
|
||||
grpcAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", ":9002")
|
||||
postgresDSN = envkit.String("ENKLY_BILLING_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_billing?sslmode=disable")
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
migrationsPath = envkit.String("ENKLY_BILLING_MIGRATIONS", "migrations/0001_init.sql")
|
||||
|
||||
stripeSecretKey = envkit.String("STRIPE_SECRET_KEY", "")
|
||||
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
|
||||
stripeWebhookKey = envkit.String("STRIPE_WEBHOOK_SECRET", "")
|
||||
)
|
||||
|
||||
db, err := pgkit.Open(postgresDSN)
|
||||
if err != nil {
|
||||
logger.Error("open postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
migrationSQL, err := os.ReadFile(migrationsPath)
|
||||
if err != nil {
|
||||
logger.Error("read migrations", "path", migrationsPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
|
||||
logger.Error("migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{
|
||||
Subscriptions: postgres.NewSubscriptionRepo(db),
|
||||
StripeEvents: postgres.NewStripeEventRepo(db),
|
||||
Usage: postgres.NewUsageRepo(db),
|
||||
Stripe: stripeapi.New(stripeSecretKey),
|
||||
WebhookSecret: stripeWebhookKey,
|
||||
Events: bus,
|
||||
Logger: logger,
|
||||
GetEnv: os.Getenv,
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
billingv1.RegisterBillingServiceServer(grpcServer, grpcsrv.New(svc, stripePublishKey))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("billing service listening", "addr", grpcAddr, "stripe_configured", stripeSecretKey != "")
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
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")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
320
services/billing/internal/application/service.go
Normal file
320
services/billing/internal/application/service.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Package application holds billing's use cases, transplanted from
|
||||
// internal/handlers/billing.go with the html/template "planView"
|
||||
// rendering left out — that's the gateway's job now (it calls
|
||||
// GetPlanCatalog + GetOrCreateSubscription and builds the page itself),
|
||||
// since presentation never belongs in a bounded context's core.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/services/billing/internal/domain"
|
||||
"enkly/services/billing/internal/infrastructure/stripeapi"
|
||||
)
|
||||
|
||||
type Publisher interface {
|
||||
PublishBestEffort(ctx context.Context, routingKey string, payload any)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Subscriptions domain.SubscriptionRepository
|
||||
StripeEvents domain.StripeEventRepository
|
||||
Usage domain.UsageRepository
|
||||
Stripe *stripeapi.Client
|
||||
WebhookSecret string
|
||||
Events Publisher
|
||||
Logger *slog.Logger
|
||||
|
||||
// GetPriceEnvVar resolves a plan's configured Stripe Price ID env
|
||||
// var at startup time (see cmd/server/main.go) — injected rather
|
||||
// than read from os.Getenv directly, same reasoning as the
|
||||
// monolith's billing.PriceIDs(getenv).
|
||||
GetEnv func(key string) string
|
||||
|
||||
priceMu sync.Mutex
|
||||
planPrices map[domain.Plan]string
|
||||
stripePrices map[string]domain.Plan
|
||||
}
|
||||
|
||||
// GetOrCreateSubscription replaces db.GetOrCreateSubscription.
|
||||
func (s *Service) GetOrCreateSubscription(ctx context.Context, userID int64) (*domain.Subscription, error) {
|
||||
return s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
}
|
||||
|
||||
// PlanCatalogEntry is GetPlanCatalog's per-plan result.
|
||||
type PlanCatalogEntry struct {
|
||||
domain.PlanInfo
|
||||
Available bool
|
||||
}
|
||||
|
||||
// GetPlanCatalog replaces the plan-availability half of
|
||||
// buildSubscriptionData: Free is always available, every paid plan is
|
||||
// available whenever Stripe itself is configured (its Price is created
|
||||
// on demand — see planPrice/EnsurePrice).
|
||||
func (s *Service) GetPlanCatalog(ctx context.Context) []PlanCatalogEntry {
|
||||
out := make([]PlanCatalogEntry, 0, len(domain.Catalog))
|
||||
for _, p := range domain.Catalog {
|
||||
out = append(out, PlanCatalogEntry{
|
||||
PlanInfo: p,
|
||||
Available: p.Plan == domain.PlanFree || s.Stripe.Configured(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateCheckoutSession replaces Handlers.CreateCheckout (minus the
|
||||
// "plan==free redirects to the portal" edge case, which the gateway
|
||||
// handles at the edge since it's pure routing, not a billing decision).
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, userID int64, email string, plan domain.Plan, successURL, cancelURL string) (string, error) {
|
||||
if !s.Stripe.Configured() {
|
||||
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
|
||||
}
|
||||
priceID, err := s.planPrice(ctx, plan)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, checkoutURL, err := s.Stripe.CreateCheckoutSession(ctx, stripeapi.CheckoutParams{
|
||||
PriceID: priceID,
|
||||
CustomerID: sub.StripeCustomerID,
|
||||
CustomerEmail: email,
|
||||
ClientReferenceID: strconv.FormatInt(userID, 10),
|
||||
UserID: userID,
|
||||
SuccessURL: successURL,
|
||||
CancelURL: cancelURL,
|
||||
})
|
||||
return checkoutURL, err
|
||||
}
|
||||
|
||||
// SyncCheckoutSession replaces Handlers.syncCheckoutSession.
|
||||
func (s *Service) SyncCheckoutSession(ctx context.Context, userID int64, sessionID string) (*domain.Subscription, error) {
|
||||
sess, err := s.Stripe.GetCheckoutSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sess.ClientReferenceID != strconv.FormatInt(userID, 10) {
|
||||
return nil, fmt.Errorf("session %s does not belong to user %d", sess.ID, userID)
|
||||
}
|
||||
if sess.Subscription == "" || sess.Customer == "" {
|
||||
return nil, fmt.Errorf("session %s has no subscription yet", sess.ID)
|
||||
}
|
||||
stripeSub, err := s.Stripe.GetSubscription(ctx, sess.Subscription)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan, ok := s.planForPrice(ctx, stripeSub.PriceID())
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("subscription %s: unrecognized price %q", stripeSub.ID, stripeSub.PriceID())
|
||||
}
|
||||
var periodEnd time.Time
|
||||
if unix := stripeSub.PeriodEndUnix(); unix > 0 {
|
||||
periodEnd = time.Unix(unix, 0)
|
||||
}
|
||||
if err := s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, sess.Customer, stripeSub.ID, stripeSub.PriceID(),
|
||||
string(plan), stripeSub.Status, periodEnd, stripeSub.CancelAtPeriodEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.publishChanged(ctx, userID, plan, stripeSub.Status, "checkout.session.completed")
|
||||
return s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
}
|
||||
|
||||
// CreateBillingPortalSession replaces Handlers.BillingPortal.
|
||||
func (s *Service) CreateBillingPortalSession(ctx context.Context, userID int64, returnURL string) (string, error) {
|
||||
if !s.Stripe.Configured() {
|
||||
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
|
||||
}
|
||||
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sub.StripeCustomerID == "" {
|
||||
return "", errNoBillingAccount
|
||||
}
|
||||
return s.Stripe.CreateBillingPortalSession(ctx, sub.StripeCustomerID, returnURL)
|
||||
}
|
||||
|
||||
var errNoBillingAccount = errors.New("billing: no stripe customer on file yet")
|
||||
|
||||
// IsNoBillingAccount reports whether err is the "subscribe to a paid
|
||||
// plan first" case Handlers.BillingPortal used to check for via a bare
|
||||
// StripeCustomerID == "" comparison.
|
||||
func IsNoBillingAccount(err error) bool { return errors.Is(err, errNoBillingAccount) }
|
||||
|
||||
// CheckAndIncrementUsage replaces the daily-cap check inlined in
|
||||
// Handlers.Chat, now its own use case since the agent service calls it
|
||||
// once per turn over gRPC.
|
||||
func (s *Service) CheckAndIncrementUsage(ctx context.Context, userID int64) (allowed bool, used, limit int, planName string, err error) {
|
||||
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
if err != nil {
|
||||
return false, 0, 0, "", err
|
||||
}
|
||||
info := domain.Get(sub.Plan)
|
||||
if info.DailyMessageLimit == 0 {
|
||||
// Unlimited plan: still counted (for the usage page), never capped.
|
||||
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
|
||||
return true, n, 0, info.Name, err
|
||||
}
|
||||
used, err = s.Usage.DailyUsage(ctx, userID)
|
||||
if err != nil {
|
||||
return false, 0, info.DailyMessageLimit, info.Name, err
|
||||
}
|
||||
if used >= info.DailyMessageLimit {
|
||||
return false, used, info.DailyMessageLimit, info.Name, nil
|
||||
}
|
||||
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
|
||||
return true, n, info.DailyMessageLimit, info.Name, err
|
||||
}
|
||||
|
||||
// GetDailyUsage replaces db.DailyUsage: a read-only lookup for the
|
||||
// /subscription page, which must not itself count as a sent message.
|
||||
func (s *Service) GetDailyUsage(ctx context.Context, userID int64) (int, error) {
|
||||
return s.Usage.DailyUsage(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) publishChanged(ctx context.Context, userID int64, plan domain.Plan, status, eventType string) {
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeySubscriptionChanged, events.SubscriptionChanged{
|
||||
UserID: userID, Plan: string(plan), Status: status, EventType: eventType,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Price resolution cache — replaces Handlers.planPrice / planForPrice.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) planPrice(ctx context.Context, plan domain.Plan) (string, error) {
|
||||
s.priceMu.Lock()
|
||||
defer s.priceMu.Unlock()
|
||||
if id, ok := s.planPrices[plan]; ok && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
id, err := s.Stripe.EnsurePrice(ctx, stripeapi.Info{
|
||||
Plan: string(domain.Get(string(plan)).Plan), Name: domain.Get(string(plan)).Name, PriceEUR: domain.Get(string(plan)).PriceEUR,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.planPrices == nil {
|
||||
s.planPrices = map[domain.Plan]string{}
|
||||
}
|
||||
if s.stripePrices == nil {
|
||||
s.stripePrices = map[string]domain.Plan{}
|
||||
}
|
||||
s.planPrices[plan] = id
|
||||
s.stripePrices[id] = plan
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) planForPrice(ctx context.Context, priceID string) (domain.Plan, bool) {
|
||||
s.priceMu.Lock()
|
||||
plan, ok := s.stripePrices[priceID]
|
||||
s.priceMu.Unlock()
|
||||
if ok || !s.Stripe.Configured() {
|
||||
return plan, ok
|
||||
}
|
||||
for _, p := range domain.Catalog {
|
||||
if p.PriceEnvVar == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.planPrice(ctx, p.Plan); err != nil {
|
||||
s.Logger.Warn("resolve stripe price", "plan", p.Name, "err", err)
|
||||
}
|
||||
}
|
||||
s.priceMu.Lock()
|
||||
defer s.priceMu.Unlock()
|
||||
plan, ok = s.stripePrices[priceID]
|
||||
return plan, ok
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Stripe webhook ingestion — replaces Handlers.StripeWebhook / applyStripeEvent.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) IngestStripeWebhook(ctx context.Context, payload []byte, sigHeader string) error {
|
||||
ev, err := stripeapi.ConstructEvent(payload, sigHeader, s.WebhookSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe webhook rejected: %w", err)
|
||||
}
|
||||
firstTime, err := s.StripeEvents.RecordIfNew(ctx, ev.ID, ev.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record stripe event: %w", err)
|
||||
}
|
||||
if !firstTime {
|
||||
return nil // already applied on an earlier delivery
|
||||
}
|
||||
if err := s.applyStripeEvent(ctx, ev); err != nil {
|
||||
// Logged, not propagated: the event is already recorded as
|
||||
// seen, so returning an error here would just make the
|
||||
// gateway tell Stripe to retry a delivery that (from Stripe's
|
||||
// side) already succeeded — see the monolith's identical
|
||||
// comment in StripeWebhook.
|
||||
s.Logger.Error("apply stripe event", "err", err, "type", ev.Type, "event_id", ev.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyStripeEvent(ctx context.Context, ev *stripeapi.Event) error {
|
||||
switch ev.Type {
|
||||
case "checkout.session.completed":
|
||||
var sess stripeapi.CheckoutSession
|
||||
if err := json.Unmarshal(ev.Data.Object, &sess); err != nil {
|
||||
return fmt.Errorf("decode checkout session: %w", err)
|
||||
}
|
||||
if sess.Customer == "" {
|
||||
return nil
|
||||
}
|
||||
userID, err := strconv.ParseInt(sess.ClientReferenceID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checkout session %s: unusable client_reference_id %q", sess.ID, sess.ClientReferenceID)
|
||||
}
|
||||
return s.Subscriptions.SetStripeCustomer(ctx, userID, sess.Customer)
|
||||
|
||||
case "customer.subscription.created", "customer.subscription.updated":
|
||||
var st stripeapi.StripeSubscription
|
||||
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
|
||||
return fmt.Errorf("decode subscription: %w", err)
|
||||
}
|
||||
plan, ok := s.planForPrice(ctx, st.PriceID())
|
||||
if !ok {
|
||||
return fmt.Errorf("subscription %s: unrecognized price %q (check the STRIPE_PRICE_* env vars)", st.ID, st.PriceID())
|
||||
}
|
||||
var periodEnd time.Time
|
||||
if unix := st.PeriodEndUnix(); unix > 0 {
|
||||
periodEnd = time.Unix(unix, 0)
|
||||
}
|
||||
err := s.Subscriptions.ApplyStripeSubscription(ctx, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
userID, perr := strconv.ParseInt(st.Metadata["user_id"], 10, 64)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("subscription %s: no matching customer row and no usable metadata.user_id", st.ID)
|
||||
}
|
||||
err = s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
|
||||
if err == nil {
|
||||
s.publishChanged(ctx, userID, plan, st.Status, ev.Type)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
var st stripeapi.StripeSubscription
|
||||
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
|
||||
return fmt.Errorf("decode subscription: %w", err)
|
||||
}
|
||||
return s.Subscriptions.CancelByStripeSubscriptionID(ctx, st.ID)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
112
services/billing/internal/domain/subscription.go
Normal file
112
services/billing/internal/domain/subscription.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package domain is the Billing bounded context's entities, plan
|
||||
// catalog and repository interfaces — transplanted from
|
||||
// internal/billing/plan.go (pure data, unchanged) plus the shape of
|
||||
// internal/db's subscriptions.go results.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("billing: not found")
|
||||
|
||||
// Plan identifies one of enkly's subscription tiers. Stored verbatim in
|
||||
// subscriptions.plan — see migrations/0001_init.sql's CHECK constraint.
|
||||
type Plan string
|
||||
|
||||
const (
|
||||
PlanFree Plan = "free"
|
||||
PlanBasic Plan = "basic"
|
||||
PlanPro Plan = "pro"
|
||||
PlanPremium Plan = "premium"
|
||||
)
|
||||
|
||||
// PlanInfo describes one plan: its display name/price and the env var
|
||||
// holding its Stripe Price ID (empty for Free).
|
||||
type PlanInfo struct {
|
||||
Plan Plan
|
||||
Name string
|
||||
PriceEUR int
|
||||
PriceEnvVar string
|
||||
DailyMessageLimit int // 0 = unlimited
|
||||
}
|
||||
|
||||
// Catalog lists every plan, in display order — identical to the
|
||||
// monolith's billing.Catalog.
|
||||
var Catalog = []PlanInfo{
|
||||
{Plan: PlanFree, Name: "Free", PriceEUR: 0, PriceEnvVar: "", DailyMessageLimit: 20},
|
||||
{Plan: PlanBasic, Name: "Basic", PriceEUR: 5, PriceEnvVar: "STRIPE_PRICE_BASIC", DailyMessageLimit: 100},
|
||||
{Plan: PlanPro, Name: "Pro", PriceEUR: 8, PriceEnvVar: "STRIPE_PRICE_PRO", DailyMessageLimit: 400},
|
||||
{Plan: PlanPremium, Name: "Premium", PriceEUR: 12, PriceEnvVar: "STRIPE_PRICE_PREMIUM", DailyMessageLimit: 0},
|
||||
}
|
||||
|
||||
func Get(plan string) PlanInfo {
|
||||
for _, p := range Catalog {
|
||||
if string(p.Plan) == plan {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return Catalog[0]
|
||||
}
|
||||
|
||||
func Valid(plan string) bool {
|
||||
for _, p := range Catalog {
|
||||
if string(p.Plan) == plan {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsPriceID(v string) bool { return strings.HasPrefix(v, "price_") && len(v) > len("price_") }
|
||||
|
||||
// Rank returns plan's position in the catalog, so an upgrade can be
|
||||
// told from a downgrade. Unknown plans rank as Free.
|
||||
func Rank(plan Plan) int {
|
||||
for i, p := range Catalog {
|
||||
if p.Plan == plan {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Subscription is one user's billing state — models.Subscription minus
|
||||
// json tags.
|
||||
type Subscription struct {
|
||||
UserID int64
|
||||
Plan string
|
||||
Status string
|
||||
StripeCustomerID string
|
||||
StripeSubscriptionID string
|
||||
StripePriceID string
|
||||
CurrentPeriodEnd time.Time
|
||||
CancelAtPeriodEnd bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SubscriptionRepository persists subscriptions, mirroring
|
||||
// internal/db/subscriptions.go's functions one-for-one.
|
||||
type SubscriptionRepository interface {
|
||||
GetOrCreate(ctx context.Context, userID int64) (*Subscription, error)
|
||||
SetStripeCustomer(ctx context.Context, userID int64, customerID string) error
|
||||
ApplyStripeSubscription(ctx context.Context, customerID, subscriptionID, priceID, plan, status string, periodEnd time.Time, cancelAtPeriodEnd bool) error
|
||||
ApplyStripeSubscriptionForUser(ctx context.Context, userID int64, customerID, subscriptionID, priceID, plan, status string, periodEnd time.Time, cancelAtPeriodEnd bool) error
|
||||
CancelByStripeSubscriptionID(ctx context.Context, subscriptionID string) error
|
||||
}
|
||||
|
||||
// StripeEventRepository is the webhook idempotency log.
|
||||
type StripeEventRepository interface {
|
||||
// RecordIfNew reports whether eventID hadn't been seen before.
|
||||
RecordIfNew(ctx context.Context, eventID, eventType string) (firstTime bool, err error)
|
||||
}
|
||||
|
||||
// UsageRepository is the per-user, per-UTC-day message counter.
|
||||
type UsageRepository interface {
|
||||
DailyUsage(ctx context.Context, userID int64) (int, error)
|
||||
IncrementDailyUsage(ctx context.Context, userID int64) (int, error)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package postgres implements billing's domain repositories against the
|
||||
// billing database (see services/billing/migrations/0001_init.sql).
|
||||
// One-for-one transplant of internal/db/subscriptions.go.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/billing/internal/domain"
|
||||
)
|
||||
|
||||
const subscriptionColumns = `user_id, plan, status, coalesce(stripe_customer_id, ''), coalesce(stripe_subscription_id, ''), ` +
|
||||
`coalesce(stripe_price_id, ''), current_period_end, cancel_at_period_end, created_at, updated_at`
|
||||
|
||||
func scanSubscription(row interface{ Scan(...any) error }) (*domain.Subscription, error) {
|
||||
var (
|
||||
s domain.Subscription
|
||||
periodEnd sql.NullTime
|
||||
)
|
||||
if err := row.Scan(&s.UserID, &s.Plan, &s.Status, &s.StripeCustomerID, &s.StripeSubscriptionID,
|
||||
&s.StripePriceID, &periodEnd, &s.CancelAtPeriodEnd, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if periodEnd.Valid {
|
||||
s.CurrentPeriodEnd = periodEnd.Time
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
type SubscriptionRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewSubscriptionRepo(db *pgkit.DB) *SubscriptionRepo { return &SubscriptionRepo{DB: db} }
|
||||
|
||||
func (r *SubscriptionRepo) GetOrCreate(ctx context.Context, userID int64) (*domain.Subscription, error) {
|
||||
if _, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO subscriptions (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanSubscription(r.DB.QueryRowContext(ctx,
|
||||
`SELECT `+subscriptionColumns+` FROM subscriptions WHERE user_id = $1`, userID))
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) SetStripeCustomer(ctx context.Context, userID int64, customerID string) error {
|
||||
_, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE subscriptions SET stripe_customer_id = $2, updated_at = now() WHERE user_id = $1`,
|
||||
userID, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) ApplyStripeSubscription(ctx context.Context, customerID, subscriptionID, priceID, plan, status string,
|
||||
periodEnd time.Time, cancelAtPeriodEnd bool) error {
|
||||
var pe any
|
||||
if !periodEnd.IsZero() {
|
||||
pe = periodEnd
|
||||
}
|
||||
res, err := r.DB.ExecContext(ctx, `
|
||||
UPDATE subscriptions SET
|
||||
plan = $2, status = $3, stripe_subscription_id = $4, stripe_price_id = $5,
|
||||
current_period_end = $6, cancel_at_period_end = $7, updated_at = now()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
customerID, plan, status, subscriptionID, priceID, pe, cancelAtPeriodEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) ApplyStripeSubscriptionForUser(ctx context.Context, userID int64, customerID, subscriptionID, priceID, plan, status string,
|
||||
periodEnd time.Time, cancelAtPeriodEnd bool) error {
|
||||
var pe any
|
||||
if !periodEnd.IsZero() {
|
||||
pe = periodEnd
|
||||
}
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO subscriptions (user_id, plan, status, stripe_customer_id, stripe_subscription_id, stripe_price_id, current_period_end, cancel_at_period_end)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan = excluded.plan, status = excluded.status, stripe_customer_id = excluded.stripe_customer_id,
|
||||
stripe_subscription_id = excluded.stripe_subscription_id, stripe_price_id = excluded.stripe_price_id,
|
||||
current_period_end = excluded.current_period_end, cancel_at_period_end = excluded.cancel_at_period_end,
|
||||
updated_at = now()`,
|
||||
userID, plan, status, customerID, subscriptionID, priceID, pe, cancelAtPeriodEnd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) CancelByStripeSubscriptionID(ctx context.Context, subscriptionID string) error {
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
UPDATE subscriptions SET
|
||||
plan = 'free', status = 'canceled', stripe_subscription_id = NULL,
|
||||
stripe_price_id = NULL, cancel_at_period_end = false, updated_at = now()
|
||||
WHERE stripe_subscription_id = $1`, subscriptionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Webhook idempotency
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type StripeEventRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewStripeEventRepo(db *pgkit.DB) *StripeEventRepo { return &StripeEventRepo{DB: db} }
|
||||
|
||||
func (r *StripeEventRepo) RecordIfNew(ctx context.Context, eventID, eventType string) (bool, error) {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, eventID, eventType)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Daily usage
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type UsageRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewUsageRepo(db *pgkit.DB) *UsageRepo { return &UsageRepo{DB: db} }
|
||||
|
||||
func (r *UsageRepo) DailyUsage(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT messages FROM usage_daily WHERE user_id = $1 AND day = current_date`, userID).Scan(&n)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *UsageRepo) IncrementDailyUsage(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := r.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO usage_daily (user_id, day, messages) VALUES ($1, current_date, 1)
|
||||
ON CONFLICT (user_id, day) DO UPDATE SET messages = usage_daily.messages + 1
|
||||
RETURNING messages`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
468
services/billing/internal/infrastructure/stripeapi/stripe.go
Normal file
468
services/billing/internal/infrastructure/stripeapi/stripe.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package stripeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiBase is Stripe's REST API root. Every call in this file talks
|
||||
// directly to it over net/http — enkly deliberately has no dependency
|
||||
// on Stripe's official Go SDK (see the comment on config/env.go about
|
||||
// keeping go.mod small), and the handful of endpoints subscriptions
|
||||
// need (Checkout Sessions, Billing Portal Sessions, webhook signature
|
||||
// verification) are simple enough not to need it.
|
||||
const apiBase = "https://api.stripe.com/v1"
|
||||
|
||||
// Info is the subset of domain.PlanInfo EnsurePrice needs to find-or-
|
||||
// create a plan's monthly price in Stripe. Kept as its own small type
|
||||
// here (rather than importing services/billing/internal/domain) so
|
||||
// this package stays a standalone Stripe API client with zero
|
||||
// dependency on billing's own domain model — the same separation the
|
||||
// monolith had between internal/billing (which owned both) and nothing
|
||||
// else, now made explicit by the package boundary.
|
||||
type Info struct {
|
||||
Plan string
|
||||
Name string
|
||||
PriceEUR int
|
||||
}
|
||||
|
||||
// Client is a small Stripe API client, authenticated with a secret key
|
||||
// (STRIPE_SECRET_KEY). Safe for concurrent use.
|
||||
type Client struct {
|
||||
SecretKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// New builds a Client. secretKey is Stripe's secret key (sk_test_… or
|
||||
// sk_live_…) — never the publishable key, and never exposed to the
|
||||
// browser.
|
||||
func New(secretKey string) *Client {
|
||||
return &Client{
|
||||
SecretKey: secretKey,
|
||||
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether a secret key was supplied, so callers can
|
||||
// give a clear error instead of a confusing Stripe 401 when it wasn't.
|
||||
func (c *Client) Configured() bool { return c != nil && c.SecretKey != "" }
|
||||
|
||||
// post sends a application/x-www-form-urlencoded POST to a Stripe
|
||||
// endpoint (Stripe's API accepts form-encoded bodies for every
|
||||
// resource, including nested params via PHP-style bracket keys — see
|
||||
// the callers below) and decodes the JSON response into out.
|
||||
func (c *Client) post(ctx context.Context, path string, form url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
// APIError is what do() returns for any non-2xx response from Stripe —
|
||||
// kept as its own type (rather than just fmt.Errorf-ing the message in)
|
||||
// specifically so callers showing this to a self-hosting admin (see
|
||||
// internal/handlers/billing.go's subscriptionError) can display
|
||||
// Message on its own: it's Stripe's own human-readable explanation
|
||||
// (e.g. "No such price: 'prod_ABC123'; a similar object exists in live
|
||||
// mode, but a test mode key was used to make this request.") and is
|
||||
// almost always more useful for fixing a misconfigured Price ID, a
|
||||
// test/live key mismatch, etc. than a generic "something went wrong".
|
||||
type APIError struct {
|
||||
Path string // the Stripe API path that was called, e.g. "/checkout/sessions"
|
||||
Status int
|
||||
Type string // Stripe's error "type", e.g. "invalid_request_error"
|
||||
Message string // Stripe's own explanation — safe and useful to show as-is
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("stripe %s: %s", e.Path, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("stripe %s: status %d", e.Path, e.Status)
|
||||
}
|
||||
|
||||
// Explain extracts a clean, safe-to-display message from an error one
|
||||
// of this package's Client methods returned: Stripe's own explanation
|
||||
// for an *APIError (a bad Price ID, a test/live key mismatch, an
|
||||
// invalid API key, ...), or the error's own message otherwise (a
|
||||
// network/DNS failure reaching Stripe, a malformed local response).
|
||||
// Never includes the secret key — it's only ever sent in a request
|
||||
// header, never echoed back into an error — so this is safe to show
|
||||
// directly to whoever administers this server.
|
||||
func Explain(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) && apiErr.Message != "" {
|
||||
return apiErr.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
if c.SecretKey == "" {
|
||||
return errors.New("billing: STRIPE_SECRET_KEY is not configured")
|
||||
}
|
||||
req.SetBasicAuth(c.SecretKey, "") // Stripe auth: secret key as the HTTP Basic username, empty password
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// Pin an API version so Stripe's response shape (e.g. where
|
||||
// current_period_end lives on a subscription) can't drift under us
|
||||
// between requests. Any recent version works; this one is well
|
||||
// after Price/nested line_items became standard.
|
||||
req.Header.Set("Stripe-Version", "2024-06-20")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("billing: request %s: %w", req.URL.Path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("billing: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
var parsed struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
return &APIError{Path: req.URL.Path, Status: resp.StatusCode, Type: parsed.Error.Type, Message: parsed.Error.Message}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("billing: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Customers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// CreateCustomer creates a Stripe Customer for a newly-paying user.
|
||||
// userID is stashed in metadata so a webhook can map a Stripe object
|
||||
// back to an enkly account even if the subscriptions row lookup by
|
||||
// stripe_customer_id ever misses.
|
||||
func (c *Client) CreateCustomer(ctx context.Context, email string, userID int64) (string, error) {
|
||||
form := url.Values{
|
||||
"email": {email},
|
||||
"metadata[user_id]": {strconv.FormatInt(userID, 10)},
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.post(ctx, "/customers", form, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Prices
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// EnsurePrice returns the Stripe Price ID for a paid plan, creating the
|
||||
// product and recurring monthly EUR price on first use. Prices are found
|
||||
// again on later runs through a Stripe "lookup key" (enkly_<plan>_monthly),
|
||||
// so calling this on every startup never creates duplicates. This is what
|
||||
// lets the app work when STRIPE_PRICE_* is unset or wrongly set to an
|
||||
// amount like "5" instead of a price_… ID.
|
||||
func (c *Client) EnsurePrice(ctx context.Context, info Info) (string, error) {
|
||||
lookupKey := "enkly_" + string(info.Plan) + "_monthly"
|
||||
|
||||
var found struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
UnitAmount int `json:"unit_amount"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"data"`
|
||||
}
|
||||
q := url.Values{"lookup_keys[]": {lookupKey}, "active": {"true"}}
|
||||
if err := c.get(ctx, "/prices?"+q.Encode(), &found); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, pr := range found.Data {
|
||||
if pr.UnitAmount == info.PriceEUR*100 && pr.Currency == "eur" {
|
||||
return pr.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var product struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.post(ctx, "/products", url.Values{"name": {"enkly " + info.Name}}, &product); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var price struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
err := c.post(ctx, "/prices", url.Values{
|
||||
"product": {product.ID},
|
||||
"currency": {"eur"},
|
||||
"unit_amount": {strconv.Itoa(info.PriceEUR * 100)},
|
||||
"recurring[interval]": {"month"},
|
||||
"nickname": {"enkly " + info.Name + " (monthly)"},
|
||||
"lookup_key": {lookupKey},
|
||||
"transfer_lookup_key": {"true"},
|
||||
}, &price)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return price.ID, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Checkout Sessions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// CheckoutParams describes one subscription checkout.
|
||||
type CheckoutParams struct {
|
||||
PriceID string
|
||||
CustomerID string // existing Stripe customer, if any (preferred over CustomerEmail)
|
||||
CustomerEmail string // used only when CustomerID is empty
|
||||
ClientReferenceID string // enkly user ID, as a string — echoed back on the session object
|
||||
UserID int64
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
}
|
||||
|
||||
// CreateCheckoutSession starts a Stripe-hosted Checkout page for a new
|
||||
// subscription. The caller redirects the browser to the returned URL;
|
||||
// enkly never collects card details itself.
|
||||
func (c *Client) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (sessionID, checkoutURL string, err error) {
|
||||
form := url.Values{
|
||||
"mode": {"subscription"},
|
||||
"success_url": {p.SuccessURL},
|
||||
"cancel_url": {p.CancelURL},
|
||||
"line_items[0][price]": {p.PriceID},
|
||||
"line_items[0][quantity]": {"1"},
|
||||
"client_reference_id": {p.ClientReferenceID},
|
||||
"subscription_data[metadata][user_id]": {strconv.FormatInt(p.UserID, 10)},
|
||||
"allow_promotion_codes": {"true"},
|
||||
"billing_address_collection": {"auto"},
|
||||
}
|
||||
if p.CustomerID != "" {
|
||||
form.Set("customer", p.CustomerID)
|
||||
} else if p.CustomerEmail != "" {
|
||||
form.Set("customer_email", p.CustomerEmail)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.post(ctx, "/checkout/sessions", form, &out); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return out.ID, out.URL, nil
|
||||
}
|
||||
|
||||
// CheckoutSession is the subset of a Checkout Session object handlers
|
||||
// need after a "checkout.session.completed" webhook.
|
||||
type CheckoutSession struct {
|
||||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
Subscription string `json:"subscription"`
|
||||
ClientReferenceID string `json:"client_reference_id"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// GetCheckoutSession retrieves a Checkout Session by ID. The success page
|
||||
// uses it to confirm a payment with Stripe directly, so a plan activates
|
||||
// even when webhooks aren't reaching the server (e.g. local testing).
|
||||
func (c *Client) GetCheckoutSession(ctx context.Context, id string) (*CheckoutSession, error) {
|
||||
var out CheckoutSession
|
||||
if err := c.get(ctx, "/checkout/sessions/"+url.PathEscape(id), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Billing Portal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// CreateBillingPortalSession opens Stripe's hosted "manage my
|
||||
// subscription" page (update card, change plan, cancel, view invoices)
|
||||
// for an existing customer.
|
||||
func (c *Client) CreateBillingPortalSession(ctx context.Context, customerID, returnURL string) (string, error) {
|
||||
form := url.Values{"customer": {customerID}, "return_url": {returnURL}}
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.post(ctx, "/billing_portal/sessions", form, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Subscriptions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// StripeSubscription is the subset of a Subscription object handlers
|
||||
// need to keep internal/db's subscriptions table in sync.
|
||||
type StripeSubscription struct {
|
||||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
Status string `json:"status"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"` // unix seconds; 0 on API versions that moved this onto items
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Items struct {
|
||||
Data []struct {
|
||||
Price struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"price"`
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"`
|
||||
} `json:"data"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// PriceID returns the subscription's single price ID (enkly's checkout
|
||||
// always creates a one-line-item subscription), or "" if absent.
|
||||
func (s StripeSubscription) PriceID() string {
|
||||
if len(s.Items.Data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.Items.Data[0].Price.ID
|
||||
}
|
||||
|
||||
// PeriodEndUnix returns current_period_end, preferring the
|
||||
// subscription-level field and falling back to the first item's (newer
|
||||
// Stripe API versions report it per-item instead).
|
||||
func (s StripeSubscription) PeriodEndUnix() int64 {
|
||||
if s.CurrentPeriodEnd != 0 {
|
||||
return s.CurrentPeriodEnd
|
||||
}
|
||||
if len(s.Items.Data) > 0 {
|
||||
return s.Items.Data[0].CurrentPeriodEnd
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetSubscription retrieves a subscription by ID — used as a fallback
|
||||
// when a webhook event's embedded object is missing a field this code
|
||||
// needs (e.g. an older API version, or a checkout.session.completed
|
||||
// event that only references the subscription by ID).
|
||||
func (c *Client) GetSubscription(ctx context.Context, id string) (*StripeSubscription, error) {
|
||||
var out StripeSubscription
|
||||
if err := c.get(ctx, "/subscriptions/"+url.PathEscape(id), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Webhooks
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Event is the minimal shape of a Stripe webhook event enkly needs:
|
||||
// enough to dispatch on Type and unmarshal Data.Object into the
|
||||
// concrete struct (CheckoutSession / StripeSubscription) each handler
|
||||
// expects.
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
Object json.RawMessage `json:"object"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// maxWebhookSkew rejects a webhook whose signed timestamp is further
|
||||
// than this from "now" — Stripe's own recommended replay-attack guard.
|
||||
const maxWebhookSkew = 5 * time.Minute
|
||||
|
||||
// ConstructEvent verifies a webhook payload's "Stripe-Signature" header
|
||||
// against webhookSecret and, only if it's genuine, parses it into an
|
||||
// Event. This reimplements Stripe's documented signature scheme
|
||||
// (HMAC-SHA256 over "{timestamp}.{payload}", header format
|
||||
// "t=…,v1=…,v1=…") by hand rather than pulling in the SDK just for
|
||||
// this one function.
|
||||
func ConstructEvent(payload []byte, sigHeader, webhookSecret string) (*Event, error) {
|
||||
if webhookSecret == "" {
|
||||
return nil, errors.New("billing: STRIPE_WEBHOOK_SECRET is not configured")
|
||||
}
|
||||
ts, sigs, err := parseSignatureHeader(sigHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skew := time.Since(time.Unix(ts, 0)); skew > maxWebhookSkew || skew < -maxWebhookSkew {
|
||||
return nil, fmt.Errorf("billing: webhook timestamp %s outside tolerance", time.Unix(ts, 0))
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(webhookSecret))
|
||||
mac.Write([]byte(strconv.FormatInt(ts, 10)))
|
||||
mac.Write([]byte("."))
|
||||
mac.Write(payload)
|
||||
want := mac.Sum(nil)
|
||||
|
||||
ok := false
|
||||
for _, sig := range sigs {
|
||||
got, err := hex.DecodeString(sig)
|
||||
if err == nil && hmac.Equal(got, want) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("billing: webhook signature verification failed")
|
||||
}
|
||||
|
||||
var ev Event
|
||||
if err := json.Unmarshal(payload, &ev); err != nil {
|
||||
return nil, fmt.Errorf("billing: decode webhook payload: %w", err)
|
||||
}
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// parseSignatureHeader splits "t=1614556800,v1=abc…,v1=def…" (Stripe
|
||||
// includes a second v1 briefly while rotating a webhook signing
|
||||
// secret) into the timestamp and the list of v1 signatures to try.
|
||||
func parseSignatureHeader(header string) (ts int64, sigs []string, err error) {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "t":
|
||||
ts, err = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("billing: bad webhook timestamp: %w", err)
|
||||
}
|
||||
case "v1":
|
||||
sigs = append(sigs, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
if ts == 0 || len(sigs) == 0 {
|
||||
return 0, nil, errors.New("billing: malformed Stripe-Signature header")
|
||||
}
|
||||
return ts, sigs, nil
|
||||
}
|
||||
141
services/billing/internal/interfaces/grpc/server.go
Normal file
141
services/billing/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/services/billing/internal/application"
|
||||
"enkly/services/billing/internal/domain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
billingv1.UnimplementedBillingServiceServer
|
||||
Svc *application.Service
|
||||
PublishableKey string // STRIPE_PUBLISH_KEY, for GetPlanCatalogResponse
|
||||
}
|
||||
|
||||
func New(svc *application.Service, publishableKey string) *Server {
|
||||
return &Server{Svc: svc, PublishableKey: publishableKey}
|
||||
}
|
||||
|
||||
func toProtoPlan(p domain.Plan) billingv1.Plan {
|
||||
switch p {
|
||||
case domain.PlanFree:
|
||||
return billingv1.Plan_PLAN_FREE
|
||||
case domain.PlanBasic:
|
||||
return billingv1.Plan_PLAN_BASIC
|
||||
case domain.PlanPro:
|
||||
return billingv1.Plan_PLAN_PRO
|
||||
case domain.PlanPremium:
|
||||
return billingv1.Plan_PLAN_PREMIUM
|
||||
default:
|
||||
return billingv1.Plan_PLAN_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoPlan(p billingv1.Plan) domain.Plan {
|
||||
switch p {
|
||||
case billingv1.Plan_PLAN_FREE:
|
||||
return domain.PlanFree
|
||||
case billingv1.Plan_PLAN_BASIC:
|
||||
return domain.PlanBasic
|
||||
case billingv1.Plan_PLAN_PRO:
|
||||
return domain.PlanPro
|
||||
case billingv1.Plan_PLAN_PREMIUM:
|
||||
return domain.PlanPremium
|
||||
default:
|
||||
return domain.PlanFree
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoSubscription(s *domain.Subscription) *billingv1.Subscription {
|
||||
out := &billingv1.Subscription{
|
||||
UserId: s.UserID,
|
||||
Plan: toProtoPlan(domain.Plan(s.Plan)),
|
||||
Status: s.Status,
|
||||
CancelAtPeriodEnd: s.CancelAtPeriodEnd,
|
||||
HasBillingAccount: s.StripeCustomerID != "",
|
||||
}
|
||||
if !s.CurrentPeriodEnd.IsZero() {
|
||||
out.CurrentPeriodEnd = timestamppb.New(s.CurrentPeriodEnd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) GetOrCreateSubscription(ctx context.Context, req *billingv1.GetOrCreateSubscriptionRequest) (*billingv1.Subscription, error) {
|
||||
sub, err := s.Svc.GetOrCreateSubscription(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return toProtoSubscription(sub), nil
|
||||
}
|
||||
|
||||
func (s *Server) GetPlanCatalog(ctx context.Context, req *billingv1.GetPlanCatalogRequest) (*billingv1.GetPlanCatalogResponse, error) {
|
||||
entries := s.Svc.GetPlanCatalog(ctx)
|
||||
resp := &billingv1.GetPlanCatalogResponse{PublishableKey: s.PublishableKey}
|
||||
for _, e := range entries {
|
||||
resp.Plans = append(resp.Plans, &billingv1.PlanInfo{
|
||||
Plan: toProtoPlan(e.Plan),
|
||||
Name: e.Name,
|
||||
PriceEur: int32(e.PriceEUR),
|
||||
DailyMessageLimit: int32(e.DailyMessageLimit),
|
||||
Available: e.Available,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateCheckoutSession(ctx context.Context, req *billingv1.CreateCheckoutSessionRequest) (*billingv1.CreateCheckoutSessionResponse, error) {
|
||||
url, err := s.Svc.CreateCheckoutSession(ctx, req.GetUserId(), req.GetEmail(), fromProtoPlan(req.GetPlan()), req.GetSuccessUrl(), req.GetCancelUrl())
|
||||
if err != nil {
|
||||
return nil, grpcerr.FailedPrecondition(err.Error())
|
||||
}
|
||||
return &billingv1.CreateCheckoutSessionResponse{CheckoutUrl: url}, nil
|
||||
}
|
||||
|
||||
func (s *Server) SyncCheckoutSession(ctx context.Context, req *billingv1.SyncCheckoutSessionRequest) (*billingv1.Subscription, error) {
|
||||
sub, err := s.Svc.SyncCheckoutSession(ctx, req.GetUserId(), req.GetStripeSessionId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.FailedPrecondition(err.Error())
|
||||
}
|
||||
return toProtoSubscription(sub), nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateBillingPortalSession(ctx context.Context, req *billingv1.CreateBillingPortalSessionRequest) (*billingv1.CreateBillingPortalSessionResponse, error) {
|
||||
url, err := s.Svc.CreateBillingPortalSession(ctx, req.GetUserId(), req.GetReturnUrl())
|
||||
if err != nil {
|
||||
if application.IsNoBillingAccount(err) {
|
||||
return nil, grpcerr.FailedPrecondition("no billing account yet — subscribe to a paid plan first")
|
||||
}
|
||||
return nil, grpcerr.FailedPrecondition(err.Error())
|
||||
}
|
||||
return &billingv1.CreateBillingPortalSessionResponse{PortalUrl: url}, nil
|
||||
}
|
||||
|
||||
func (s *Server) IngestStripeWebhook(ctx context.Context, req *billingv1.IngestStripeWebhookRequest) (*billingv1.IngestStripeWebhookResponse, error) {
|
||||
if err := s.Svc.IngestStripeWebhook(ctx, req.GetPayload(), req.GetSignatureHeader()); err != nil {
|
||||
return nil, grpcerr.InvalidArgument(err.Error())
|
||||
}
|
||||
return &billingv1.IngestStripeWebhookResponse{Accepted: true}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CheckAndIncrementUsage(ctx context.Context, req *billingv1.CheckAndIncrementUsageRequest) (*billingv1.CheckAndIncrementUsageResponse, error) {
|
||||
allowed, used, limit, planName, err := s.Svc.CheckAndIncrementUsage(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &billingv1.CheckAndIncrementUsageResponse{
|
||||
Allowed: allowed, Used: int32(used), Limit: int32(limit), PlanName: planName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetDailyUsage(ctx context.Context, req *billingv1.GetDailyUsageRequest) (*billingv1.GetDailyUsageResponse, error) {
|
||||
used, err := s.Svc.GetDailyUsage(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &billingv1.GetDailyUsageResponse{Used: int32(used)}, nil
|
||||
}
|
||||
47
services/billing/migrations/0001_init.sql
Normal file
47
services/billing/migrations/0001_init.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- =====================================================================
|
||||
-- billing service — 0001_init.sql
|
||||
-- Database-per-service: owns `subscriptions`, `stripe_events` and
|
||||
-- `usage_daily`. user_id columns below are bare BIGINTs, NOT a foreign
|
||||
-- key to iam's users(id) — that table lives in a different database.
|
||||
-- Referential integrity across that boundary is enforced by the
|
||||
-- application layer (iam is the only service that can create a
|
||||
-- user_id in the first place) rather than by Postgres; see
|
||||
-- ARCHITECTURE.md's "Database-per-service" section.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL UNIQUE,
|
||||
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'basic', 'pro', 'premium')),
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
stripe_price_id TEXT,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_stripe_customer_id_idx
|
||||
ON subscriptions (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_stripe_subscription_id_idx
|
||||
ON subscriptions (stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
|
||||
|
||||
-- Idempotency log for incoming webhooks — see application.Service.
|
||||
-- IngestStripeWebhook.
|
||||
CREATE TABLE IF NOT EXISTS stripe_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Per-user, per-UTC-day message counter backing each plan's
|
||||
-- DailyMessageLimit (see domain.PlanInfo).
|
||||
CREATE TABLE IF NOT EXISTS usage_daily (
|
||||
user_id BIGINT NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
messages INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, day)
|
||||
);
|
||||
17
services/conversation/Dockerfile
Normal file
17
services/conversation/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/conversation/ services/conversation/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/conversation-server ./services/conversation/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/conversation-server ./conversation-server
|
||||
COPY --from=build /src/services/conversation/migrations ./migrations
|
||||
ENV ENKLY_CONVERSATION_GRPC_ADDR=:9003
|
||||
EXPOSE 9003
|
||||
ENTRYPOINT ["./conversation-server"]
|
||||
98
services/conversation/cmd/server/main.go
Normal file
98
services/conversation/cmd/server/main.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Command server runs the conversation microservice: a gRPC server
|
||||
// exposing api/proto/conversation/v1, backed by its own Postgres
|
||||
// database. Replaces internal/db/conversations.go's callers.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/conversation/internal/application"
|
||||
"enkly/services/conversation/internal/infrastructure/postgres"
|
||||
grpcsrv "enkly/services/conversation/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
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 (
|
||||
grpcAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", ":9003")
|
||||
postgresDSN = envkit.String("ENKLY_CONVERSATION_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_conversation?sslmode=disable")
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
migrationsPath = envkit.String("ENKLY_CONVERSATION_MIGRATIONS", "migrations/0001_init.sql")
|
||||
)
|
||||
|
||||
db, err := pgkit.Open(postgresDSN)
|
||||
if err != nil {
|
||||
logger.Error("open postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
migrationSQL, err := os.ReadFile(migrationsPath)
|
||||
if err != nil {
|
||||
logger.Error("read migrations", "path", migrationsPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
|
||||
logger.Error("migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{
|
||||
Conversations: postgres.NewConversationRepo(db),
|
||||
Messages: postgres.NewMessageRepo(db),
|
||||
Events: bus,
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
conversationv1.RegisterConversationServiceServer(grpcServer, grpcsrv.New(svc))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("conversation service listening", "addr", grpcAddr)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
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")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
75
services/conversation/internal/application/service.go
Normal file
75
services/conversation/internal/application/service.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Package application holds conversation's use cases. Thin by design —
|
||||
// the monolith's own conversation handling was mostly direct db calls
|
||||
// with little business logic beyond ownership scoping, which the
|
||||
// repository layer already enforces (see domain.ConversationRepository's
|
||||
// doc comment).
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
type Publisher interface {
|
||||
PublishBestEffort(ctx context.Context, routingKey string, payload any)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Conversations domain.ConversationRepository
|
||||
Messages domain.MessageRepository
|
||||
Events Publisher
|
||||
}
|
||||
|
||||
func (s *Service) CreateConversation(ctx context.Context, userID int64, title string) (*domain.Conversation, error) {
|
||||
return s.Conversations.Create(ctx, userID, title)
|
||||
}
|
||||
|
||||
func (s *Service) GetConversation(ctx context.Context, userID, id int64) (*domain.Conversation, error) {
|
||||
return s.Conversations.Get(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) ListConversations(ctx context.Context, userID int64) ([]*domain.Conversation, error) {
|
||||
return s.Conversations.List(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) MostRecentConversation(ctx context.Context, userID int64) (*domain.Conversation, error) {
|
||||
return s.Conversations.MostRecent(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) RenameConversation(ctx context.Context, userID, id int64, title string) (*domain.Conversation, error) {
|
||||
return s.Conversations.Rename(ctx, userID, id, title)
|
||||
}
|
||||
|
||||
func (s *Service) AutoTitleConversation(ctx context.Context, userID, id int64, title string) (bool, error) {
|
||||
return s.Conversations.AutoTitle(ctx, userID, id, title)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteConversation(ctx context.Context, userID, id int64) error {
|
||||
return s.Conversations.Delete(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) CountMessages(ctx context.Context, conversationID int64) (int, error) {
|
||||
return s.Conversations.CountMessages(ctx, conversationID)
|
||||
}
|
||||
|
||||
func (s *Service) AddMessage(ctx context.Context, conversationID int64, role domain.Role, content, toolName string) (*domain.Message, error) {
|
||||
m, err := s.Messages.Add(ctx, conversationID, role, content, toolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyMessageCreated, events.MessageCreated{
|
||||
ConversationID: conversationID, MessageID: m.ID, Role: string(role),
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListMessages(ctx context.Context, conversationID int64) ([]*domain.Message, error) {
|
||||
return s.Messages.List(ctx, conversationID)
|
||||
}
|
||||
|
||||
func (s *Service) AddToolCall(ctx context.Context, tc domain.ToolCall) error {
|
||||
return s.Messages.AddToolCall(ctx, tc)
|
||||
}
|
||||
70
services/conversation/internal/domain/conversation.go
Normal file
70
services/conversation/internal/domain/conversation.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Package domain is the Conversation bounded context's entities and
|
||||
// repository interfaces — transplanted from internal/db/conversations.go.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("conversation: not found")
|
||||
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
LastMessageAt time.Time
|
||||
}
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
RoleSystem Role = "system"
|
||||
RoleTool Role = "tool"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
Role Role
|
||||
Content string
|
||||
ToolName string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
MessageID int64
|
||||
ToolName string
|
||||
ArgumentsJSON string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ConversationRepository persists Conversation rows, scoped to a
|
||||
// user_id on every read/write the same way db.conversations.go always
|
||||
// did — a conversation belonging to another user is simply ErrNotFound,
|
||||
// never a separate "forbidden" case, matching the monolith's behavior.
|
||||
type ConversationRepository interface {
|
||||
Create(ctx context.Context, userID int64, title string) (*Conversation, error)
|
||||
Get(ctx context.Context, userID, conversationID int64) (*Conversation, error)
|
||||
List(ctx context.Context, userID int64) ([]*Conversation, error)
|
||||
MostRecent(ctx context.Context, userID int64) (*Conversation, error)
|
||||
Rename(ctx context.Context, userID, conversationID int64, title string) (*Conversation, error)
|
||||
// AutoTitle sets title only if the conversation's title still
|
||||
// equals the default ("New conversation"), reporting whether it
|
||||
// actually changed it.
|
||||
AutoTitle(ctx context.Context, userID, conversationID int64, title string) (bool, error)
|
||||
Delete(ctx context.Context, userID, conversationID int64) error
|
||||
CountMessages(ctx context.Context, conversationID int64) (int, error)
|
||||
}
|
||||
|
||||
// MessageRepository persists Message and ToolCall rows.
|
||||
type MessageRepository interface {
|
||||
Add(ctx context.Context, conversationID int64, role Role, content, toolName string) (*Message, error)
|
||||
List(ctx context.Context, conversationID int64) ([]*Message, error)
|
||||
AddToolCall(ctx context.Context, tc ToolCall) error
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package postgres implements conversation's domain repositories
|
||||
// against the conversation database. One-for-one transplant of
|
||||
// internal/db/conversations.go.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
// DefaultConversationTitle matches the column default in
|
||||
// migrations/0001_init.sql.
|
||||
const DefaultConversationTitle = "New conversation"
|
||||
|
||||
const conversationSelect = `
|
||||
SELECT c.id, c.user_id, c.title, c.created_at, COALESCE(lm.last, c.created_at)
|
||||
FROM conversations c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(m.created_at) AS last FROM messages m WHERE m.conversation_id = c.id
|
||||
) lm ON true`
|
||||
|
||||
func scanConversation(row interface{ Scan(...any) error }) (*domain.Conversation, error) {
|
||||
var c domain.Conversation
|
||||
if err := row.Scan(&c.ID, &c.UserID, &c.Title, &c.CreatedAt, &c.LastMessageAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type ConversationRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewConversationRepo(db *pgkit.DB) *ConversationRepo { return &ConversationRepo{DB: db} }
|
||||
|
||||
func (r *ConversationRepo) Create(ctx context.Context, userID int64, title string) (*domain.Conversation, error) {
|
||||
if title == "" {
|
||||
title = DefaultConversationTitle
|
||||
}
|
||||
var id int64
|
||||
if err := r.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING id`,
|
||||
userID, title).Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) Get(ctx context.Context, userID, id int64) (*domain.Conversation, error) {
|
||||
return scanConversation(r.DB.QueryRowContext(ctx,
|
||||
conversationSelect+` WHERE c.id = $1 AND c.user_id = $2`, id, userID))
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) List(ctx context.Context, userID int64) ([]*domain.Conversation, error) {
|
||||
rows, err := r.DB.QueryContext(ctx,
|
||||
conversationSelect+` WHERE c.user_id = $1 ORDER BY 5 DESC, c.id DESC LIMIT 200`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Conversation
|
||||
for rows.Next() {
|
||||
c, err := scanConversation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) MostRecent(ctx context.Context, userID int64) (*domain.Conversation, error) {
|
||||
return scanConversation(r.DB.QueryRowContext(ctx,
|
||||
conversationSelect+` WHERE c.user_id = $1 ORDER BY 5 DESC, c.id DESC LIMIT 1`, userID))
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) Rename(ctx context.Context, userID, id int64, title string) (*domain.Conversation, error) {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE conversations SET title = $3 WHERE id = $1 AND user_id = $2`, id, userID, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return r.Get(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) AutoTitle(ctx context.Context, userID, id int64, title string) (bool, error) {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE conversations SET title = $3 WHERE id = $1 AND user_id = $2 AND title = $4`,
|
||||
id, userID, title, DefaultConversationTitle)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) Delete(ctx context.Context, userID, id int64) error {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`DELETE FROM conversations WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) CountMessages(ctx context.Context, conversationID int64) (int, error) {
|
||||
var n int
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM messages WHERE conversation_id = $1`, conversationID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
// cleanText makes arbitrary tool/model output safe for a Postgres TEXT
|
||||
// column, which rejects NUL bytes and invalid UTF-8 outright.
|
||||
func cleanText(s string) string {
|
||||
if strings.IndexByte(s, 0) >= 0 {
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
}
|
||||
return strings.ToValidUTF8(s, "\uFFFD")
|
||||
}
|
||||
|
||||
type MessageRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewMessageRepo(db *pgkit.DB) *MessageRepo { return &MessageRepo{DB: db} }
|
||||
|
||||
func (r *MessageRepo) Add(ctx context.Context, conversationID int64, role domain.Role, content, toolName string) (*domain.Message, error) {
|
||||
var tool any
|
||||
if toolName != "" {
|
||||
tool = toolName
|
||||
}
|
||||
var m domain.Message
|
||||
err := r.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO messages (conversation_id, role, content, tool_name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, conversation_id, role, content, COALESCE(tool_name, ''), created_at`,
|
||||
conversationID, string(role), cleanText(content), tool).
|
||||
Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.ToolName, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conversation: add message: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *MessageRepo) List(ctx context.Context, conversationID int64) ([]*domain.Message, error) {
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
SELECT id, conversation_id, role, content, COALESCE(tool_name, ''), created_at
|
||||
FROM messages WHERE conversation_id = $1 ORDER BY created_at, id`, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Message
|
||||
for rows.Next() {
|
||||
var m domain.Message
|
||||
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.ToolName, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MessageRepo) AddToolCall(ctx context.Context, tc domain.ToolCall) error {
|
||||
argsJSON := tc.ArgumentsJSON
|
||||
if argsJSON == "" {
|
||||
argsJSON = "{}"
|
||||
}
|
||||
// jsonb rejects the \u0000 escape that encoding/json emits for NUL.
|
||||
argsJSON = strings.ReplaceAll(argsJSON, `\u0000`, "")
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO tool_calls (message_id, tool_name, arguments, result, is_error)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
||||
tc.MessageID, tc.ToolName, argsJSON, cleanText(tc.Result), tc.IsError)
|
||||
return err
|
||||
}
|
||||
179
services/conversation/internal/interfaces/grpc/server.go
Normal file
179
services/conversation/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/services/conversation/internal/application"
|
||||
"enkly/services/conversation/internal/domain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
conversationv1.UnimplementedConversationServiceServer
|
||||
Svc *application.Service
|
||||
}
|
||||
|
||||
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
|
||||
|
||||
func toProtoRole(r domain.Role) conversationv1.Role {
|
||||
switch r {
|
||||
case domain.RoleUser:
|
||||
return conversationv1.Role_ROLE_USER
|
||||
case domain.RoleAssistant:
|
||||
return conversationv1.Role_ROLE_ASSISTANT
|
||||
case domain.RoleSystem:
|
||||
return conversationv1.Role_ROLE_SYSTEM
|
||||
case domain.RoleTool:
|
||||
return conversationv1.Role_ROLE_TOOL
|
||||
default:
|
||||
return conversationv1.Role_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoRole(r conversationv1.Role) domain.Role {
|
||||
switch r {
|
||||
case conversationv1.Role_ROLE_USER:
|
||||
return domain.RoleUser
|
||||
case conversationv1.Role_ROLE_ASSISTANT:
|
||||
return domain.RoleAssistant
|
||||
case conversationv1.Role_ROLE_SYSTEM:
|
||||
return domain.RoleSystem
|
||||
case conversationv1.Role_ROLE_TOOL:
|
||||
return domain.RoleTool
|
||||
default:
|
||||
return domain.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoConversation(c *domain.Conversation) *conversationv1.Conversation {
|
||||
return &conversationv1.Conversation{
|
||||
Id: c.ID,
|
||||
UserId: c.UserID,
|
||||
Title: c.Title,
|
||||
CreatedAt: timestamppb.New(c.CreatedAt),
|
||||
LastMessageAt: timestamppb.New(c.LastMessageAt),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoMessage(m *domain.Message) *conversationv1.Message {
|
||||
return &conversationv1.Message{
|
||||
Id: m.ID,
|
||||
ConversationId: m.ConversationID,
|
||||
Role: toProtoRole(m.Role),
|
||||
Content: m.Content,
|
||||
ToolName: m.ToolName,
|
||||
CreatedAt: timestamppb.New(m.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return grpcerr.NotFound("conversation")
|
||||
}
|
||||
return grpcerr.Internal(err)
|
||||
}
|
||||
|
||||
func (s *Server) CreateConversation(ctx context.Context, req *conversationv1.CreateConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.CreateConversation(ctx, req.GetUserId(), req.GetTitle())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) GetConversation(ctx context.Context, req *conversationv1.GetConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.GetConversation(ctx, req.GetUserId(), req.GetConversationId())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListConversations(ctx context.Context, req *conversationv1.ListConversationsRequest) (*conversationv1.ListConversationsResponse, error) {
|
||||
list, err := s.Svc.ListConversations(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
resp := &conversationv1.ListConversationsResponse{}
|
||||
for _, c := range list {
|
||||
resp.Conversations = append(resp.Conversations, toProtoConversation(c))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) MostRecentConversation(ctx context.Context, req *conversationv1.MostRecentConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.MostRecentConversation(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) RenameConversation(ctx context.Context, req *conversationv1.RenameConversationRequest) (*conversationv1.Conversation, error) {
|
||||
c, err := s.Svc.RenameConversation(ctx, req.GetUserId(), req.GetConversationId(), req.GetTitle())
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return toProtoConversation(c), nil
|
||||
}
|
||||
|
||||
func (s *Server) AutoTitleConversation(ctx context.Context, req *conversationv1.AutoTitleConversationRequest) (*conversationv1.AutoTitleConversationResponse, error) {
|
||||
changed, err := s.Svc.AutoTitleConversation(ctx, req.GetUserId(), req.GetConversationId(), req.GetTitle())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &conversationv1.AutoTitleConversationResponse{Retitled: changed}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteConversation(ctx context.Context, req *conversationv1.DeleteConversationRequest) (*conversationv1.DeleteConversationResponse, error) {
|
||||
if err := s.Svc.DeleteConversation(ctx, req.GetUserId(), req.GetConversationId()); err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return &conversationv1.DeleteConversationResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CountMessages(ctx context.Context, req *conversationv1.CountMessagesRequest) (*conversationv1.CountMessagesResponse, error) {
|
||||
n, err := s.Svc.CountMessages(ctx, req.GetConversationId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &conversationv1.CountMessagesResponse{Count: int32(n)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) AddMessage(ctx context.Context, req *conversationv1.AddMessageRequest) (*conversationv1.Message, error) {
|
||||
m, err := s.Svc.AddMessage(ctx, req.GetConversationId(), fromProtoRole(req.GetRole()), req.GetContent(), req.GetToolName())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return toProtoMessage(m), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListMessages(ctx context.Context, req *conversationv1.ListMessagesRequest) (*conversationv1.ListMessagesResponse, error) {
|
||||
list, err := s.Svc.ListMessages(ctx, req.GetConversationId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
resp := &conversationv1.ListMessagesResponse{}
|
||||
for _, m := range list {
|
||||
resp.Messages = append(resp.Messages, toProtoMessage(m))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) AddToolCall(ctx context.Context, req *conversationv1.AddToolCallRequest) (*conversationv1.AddToolCallResponse, error) {
|
||||
err := s.Svc.AddToolCall(ctx, domain.ToolCall{
|
||||
MessageID: req.GetMessageId(),
|
||||
ToolName: req.GetToolName(),
|
||||
ArgumentsJSON: req.GetArgumentsJson(),
|
||||
Result: req.GetResult(),
|
||||
IsError: req.GetIsError(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &conversationv1.AddToolCallResponse{}, nil
|
||||
}
|
||||
38
services/conversation/migrations/0001_init.sql
Normal file
38
services/conversation/migrations/0001_init.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- =====================================================================
|
||||
-- conversation service — 0001_init.sql
|
||||
-- Database-per-service: owns `conversations`, `messages`, `tool_calls`.
|
||||
-- user_id is a bare BIGINT (see iam's migration note on why there's no
|
||||
-- cross-database foreign key here).
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
title TEXT NOT NULL DEFAULT 'New conversation',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS conversations_user_id_idx ON conversations (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
tool_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS messages_conversation_id_idx ON messages (conversation_id, created_at);
|
||||
|
||||
-- Audit log of every tool the agent executed on the host, surfaced in
|
||||
-- the UI's tool cards and useful for debugging.
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments JSONB NOT NULL,
|
||||
result TEXT,
|
||||
is_error BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
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 }}
|
||||
28
services/iam/Dockerfile
Normal file
28
services/iam/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Build context is the repo root (see docker-compose.yml) — every
|
||||
# service's Dockerfile follows this same two-stage shape: compile one
|
||||
# cmd/server binary against the whole monorepo module, then ship just
|
||||
# the binary + that service's migrations in a distroless-ish runtime
|
||||
# image. See ARCHITECTURE.md's "One Go module, many deployables" note
|
||||
# for why this is a single `module enkly` rather than N go.mod files.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/iam/ services/iam/
|
||||
# No go.sum is committed yet (see "go mod tidy" in the repo root's
|
||||
# README), so resolve+lock the full dependency graph here, after all
|
||||
# source is present — `go mod tidy` needs to see every import (direct
|
||||
# AND the ones grpc/protobuf/etc. pull in transitively) to compute it.
|
||||
# Once a go.sum is committed to the repo, this can be replaced with
|
||||
# the faster, better-cached `go mod download`.
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/iam-server ./services/iam/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/iam-server ./iam-server
|
||||
COPY --from=build /src/services/iam/migrations ./migrations
|
||||
ENV ENKLY_IAM_GRPC_ADDR=:9001
|
||||
EXPOSE 9001
|
||||
ENTRYPOINT ["./iam-server"]
|
||||
132
services/iam/cmd/server/main.go
Normal file
132
services/iam/cmd/server/main.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Command server runs the iam microservice: a gRPC server exposing
|
||||
// api/proto/iam/v1 over ENKLY_IAM_GRPC_ADDR, backed by its own Postgres
|
||||
// database. Replaces the auth-related slice of the monolith's
|
||||
// cmd/server/main.go + internal/handlers/auth.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
iamv1 "enkly/gen/iam/v1"
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/iam/internal/application"
|
||||
"enkly/services/iam/internal/infrastructure/postgres"
|
||||
grpcsrv "enkly/services/iam/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
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 (
|
||||
grpcAddr = envkit.String("ENKLY_IAM_GRPC_ADDR", ":9001")
|
||||
postgresDSN = envkit.String("ENKLY_IAM_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_iam?sslmode=disable")
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
|
||||
migrationsPath = envkit.String("ENKLY_IAM_MIGRATIONS", "migrations/0001_init.sql")
|
||||
|
||||
smtpUsername = envkit.String("ENKLY_SMTP_USERNAME", "")
|
||||
smtpPassword = envkit.String("ENKLY_SMTP_PASSWORD", "")
|
||||
)
|
||||
|
||||
db, err := pgkit.Open(postgresDSN)
|
||||
if err != nil {
|
||||
logger.Error("open postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
migrationSQL, err := os.ReadFile(migrationsPath)
|
||||
if err != nil {
|
||||
logger.Error("read migrations", "path", migrationsPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
|
||||
logger.Error("migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{
|
||||
Users: postgres.NewUserRepo(db),
|
||||
Sessions: postgres.NewSessionRepo(db),
|
||||
Verification: postgres.NewVerificationRepo(db),
|
||||
PasswordReset: postgres.NewPasswordResetRepo(db),
|
||||
Events: bus,
|
||||
Logger: logger,
|
||||
BaseURL: baseURL,
|
||||
MailConfigured: smtpUsername != "" && smtpPassword != "",
|
||||
// Same windows/backoffs as the monolith's cmd/server/main.go —
|
||||
// progressive delay, not a hard lockout; see authsupport.Limiter.
|
||||
LoginLimiter: authsupport.NewLimiter(15*time.Minute, 500*time.Millisecond, 8*time.Second),
|
||||
ResendLimiter: authsupport.NewLimiter(1*time.Hour, 2*time.Second, 30*time.Second),
|
||||
VerifyLimiter: authsupport.NewLimiter(15*time.Minute, 500*time.Millisecond, 8*time.Second),
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired sessions/codes/tokens — replaces the
|
||||
// goroutine cmd/server/main.go started around db.CleanupExpired.
|
||||
go func() {
|
||||
t := time.NewTicker(1 * time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
n, err := postgres.CleanupExpired(context.Background(), db)
|
||||
if err != nil {
|
||||
logger.Error("cleanup expired", "err", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
logger.Info("cleanup expired", "rows_deleted", n)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
iamv1.RegisterIAMServiceServer(grpcServer, grpcsrv.New(svc))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("iam service listening", "addr", grpcAddr)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
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")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
92
services/iam/internal/application/login.go
Normal file
92
services/iam/internal/application/login.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/services/iam/internal/domain"
|
||||
)
|
||||
|
||||
// sessionTTL matches the monolith's cookie Max-Age for the session
|
||||
// cookie (30 days) — see handlers.Handlers.startSession.
|
||||
const sessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
// LoginResult is what the gRPC server maps onto LoginResponse.
|
||||
type LoginResult struct {
|
||||
OK bool
|
||||
NeedsVerification bool
|
||||
SessionToken string
|
||||
SessionExpiresAt time.Time
|
||||
User *domain.User
|
||||
}
|
||||
|
||||
// Login replaces Handlers.Login: verifies credentials in constant time
|
||||
// (an unknown email is checked against a dummy hash so the response
|
||||
// takes the same time either way) and, on success, mints a session.
|
||||
func (s *Service) Login(ctx context.Context, email, password, clientIP string) (LoginResult, error) {
|
||||
ipKey, emailKey := "login:ip:"+clientIP, "login:email:"+email
|
||||
if !throttle(ctx, s.LoginLimiter, ipKey, emailKey) {
|
||||
return LoginResult{}, ctx.Err()
|
||||
}
|
||||
|
||||
user, err := s.Users.GetByEmail(ctx, email)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
authsupport.VerifyPassword(password, s.getDummyHash()) // constant-time decoy
|
||||
return LoginResult{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if !authsupport.VerifyPassword(password, user.PasswordHash) {
|
||||
return LoginResult{}, nil
|
||||
}
|
||||
if !user.EmailVerified {
|
||||
return LoginResult{NeedsVerification: true}, nil
|
||||
}
|
||||
|
||||
s.LoginLimiter.RecordSuccess(ipKey)
|
||||
s.LoginLimiter.RecordSuccess(emailKey)
|
||||
|
||||
raw, hash, err := authsupport.NewToken()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
expiresAt := time.Now().Add(sessionTTL)
|
||||
if err := s.Sessions.Create(ctx, user.ID, hash, expiresAt); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{OK: true, SessionToken: raw, SessionExpiresAt: expiresAt, User: user}, nil
|
||||
}
|
||||
|
||||
// Logout replaces Handlers.Logout.
|
||||
func (s *Service) Logout(ctx context.Context, sessionToken string) error {
|
||||
if sessionToken == "" {
|
||||
return nil
|
||||
}
|
||||
return s.Sessions.Delete(ctx, authsupport.HashToken(sessionToken))
|
||||
}
|
||||
|
||||
// ValidateSession replaces the session half of Handlers.RequireAuth
|
||||
// (db.GetSessionUser). Called by the gateway on every authenticated
|
||||
// request, so this is the one IAM RPC on the hot path of practically
|
||||
// every request in the system — see ARCHITECTURE.md's "Session
|
||||
// validation latency" note for the caching strategy a production
|
||||
// rollout should add here.
|
||||
func (s *Service) ValidateSession(ctx context.Context, sessionToken string) (*domain.User, bool) {
|
||||
if sessionToken == "" {
|
||||
return nil, false
|
||||
}
|
||||
u, err := s.Sessions.GetUserByTokenHash(ctx, authsupport.HashToken(sessionToken))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// GetUser replaces a direct db.DB lookup other services occasionally
|
||||
// needed (e.g. billing resolving an email from a user_id for Stripe).
|
||||
func (s *Service) GetUser(ctx context.Context, userID int64) (*domain.User, error) {
|
||||
return s.Users.GetByID(ctx, userID)
|
||||
}
|
||||
69
services/iam/internal/application/password_reset.go
Normal file
69
services/iam/internal/application/password_reset.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/services/iam/internal/domain"
|
||||
)
|
||||
|
||||
// absURL builds BaseURL + path + ?key=value, replacing
|
||||
// Handlers.absURL.
|
||||
func (s *Service) absURL(path, key, value string) string {
|
||||
return strings.TrimRight(s.BaseURL, "/") + path + "?" + url.Values{key: {value}}.Encode()
|
||||
}
|
||||
|
||||
// ForgotPassword replaces Handlers.ForgotPassword. Always succeeds from
|
||||
// the caller's point of view — whether or not the address is registered
|
||||
// is never revealed.
|
||||
func (s *Service) ForgotPassword(ctx context.Context, email, clientIP string) error {
|
||||
if !throttle(ctx, s.ResendLimiter, "forgot:ip:"+clientIP, "forgot:email:"+email) {
|
||||
return ctx.Err()
|
||||
}
|
||||
u, err := s.Users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil // unknown address — say nothing
|
||||
}
|
||||
|
||||
raw, hash, err := authsupport.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt := time.Now().Add(resetTokenTTL)
|
||||
if err := s.PasswordReset.Create(ctx, u.ID, hash, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyPasswordResetRequested, events.PasswordResetRequested{
|
||||
UserID: u.ID, Email: u.Email, ResetLink: s.absURL("/reset-password", "token", raw), ExpiresAt: expiresAt,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPasswordResetToken replaces the GET-time validity check in
|
||||
// Handlers.ResetPasswordPage.
|
||||
func (s *Service) CheckPasswordResetToken(ctx context.Context, token string) (bool, error) {
|
||||
return s.PasswordReset.Valid(ctx, authsupport.HashToken(token))
|
||||
}
|
||||
|
||||
// ResetPassword replaces Handlers.ResetPassword.
|
||||
func (s *Service) ResetPassword(ctx context.Context, token, password string) (bool, error) {
|
||||
hash, err := authsupport.HashPassword(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = s.PasswordReset.ConsumeAndResetPassword(ctx, authsupport.HashToken(token), hash)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, domain.ErrInvalidToken):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
194
services/iam/internal/application/signup.go
Normal file
194
services/iam/internal/application/signup.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Package application holds IAM's use cases — the direct transplant of
|
||||
// internal/handlers/auth.go's business logic with every net/http,
|
||||
// html/template and cookie concern stripped out (those stayed behind in
|
||||
// the gateway service; see services/gateway). Each exported method here
|
||||
// corresponds 1:1 to an rpc in api/proto/iam/v1/iam.proto and to the
|
||||
// handler function named in its doc comment.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/services/iam/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
verifyCodeTTL = 15 * time.Minute
|
||||
resetTokenTTL = time.Hour
|
||||
)
|
||||
|
||||
// Publisher is the subset of *eventbus.Bus the application layer needs —
|
||||
// defined here (rather than importing eventbus.Bus directly into every
|
||||
// signature) so a unit test can supply a fake.
|
||||
type Publisher interface {
|
||||
PublishBestEffort(ctx context.Context, routingKey string, payload any)
|
||||
}
|
||||
|
||||
// Service implements every IAM use case. Constructed once in
|
||||
// cmd/server/main.go and handed to the gRPC server
|
||||
// (internal/interfaces/grpc).
|
||||
type Service struct {
|
||||
Users domain.UserRepository
|
||||
Sessions domain.SessionRepository
|
||||
Verification domain.VerificationRepository
|
||||
PasswordReset domain.PasswordResetRepository
|
||||
Events Publisher
|
||||
Logger *slog.Logger
|
||||
|
||||
BaseURL string // for building the password-reset link, same as Handlers.BaseURL
|
||||
// MailConfigured mirrors mail.Mailer.Configured() purely so SignUp/
|
||||
// ResendVerification can report the same "written to the server log
|
||||
// instead" hint the monolith showed — IAM never sends mail itself
|
||||
// (see services/notification), it just knows whether the deployment
|
||||
// has SMTP creds set, from the same ENKLY_SMTP_* env vars.
|
||||
MailConfigured bool
|
||||
|
||||
LoginLimiter *authsupport.Limiter
|
||||
ResendLimiter *authsupport.Limiter
|
||||
VerifyLimiter *authsupport.Limiter
|
||||
|
||||
dummyHashOnce sync.Once
|
||||
dummyHash string
|
||||
}
|
||||
|
||||
func (s *Service) getDummyHash() string {
|
||||
s.dummyHashOnce.Do(func() { s.dummyHash, _ = authsupport.HashPassword("enkly-dummy-password") })
|
||||
return s.dummyHash
|
||||
}
|
||||
|
||||
// SignUpResult is what the gRPC server maps onto SignUpResponse.
|
||||
type SignUpResult struct {
|
||||
CheckInbox bool
|
||||
Email string
|
||||
MailConfigured bool
|
||||
}
|
||||
|
||||
// SignUp replaces Handlers.Signup. Email normalization and password
|
||||
// confirmation are assumed already validated by the caller (the gateway
|
||||
// does this at the edge, same division as the monolith's normalizeEmail/
|
||||
// validatePassword helpers) — email arrives already normalized.
|
||||
func (s *Service) SignUp(ctx context.Context, email, password, clientIP string) (SignUpResult, error) {
|
||||
// Signups share the resend limiter (keyed per IP): creating accounts
|
||||
// triggers outbound email, the same abuse vector as resending it.
|
||||
if !throttle(ctx, s.ResendLimiter, "signup:"+clientIP) {
|
||||
return SignUpResult{}, ctx.Err()
|
||||
}
|
||||
|
||||
hash, err := authsupport.HashPassword(password)
|
||||
if err != nil {
|
||||
return SignUpResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.Users.Create(ctx, email, hash)
|
||||
switch {
|
||||
case err == nil:
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyUserRegistered, events.UserRegistered{
|
||||
UserID: user.ID, Email: user.Email, CreatedAt: user.CreatedAt,
|
||||
})
|
||||
case errors.Is(err, domain.ErrEmailTaken):
|
||||
// Don't reveal that the address is registered: fall through to
|
||||
// the same "check your inbox" outcome, quietly resending a
|
||||
// code if the existing account was never verified.
|
||||
existing, gerr := s.Users.GetByEmail(ctx, email)
|
||||
if gerr != nil || existing.EmailVerified {
|
||||
return SignUpResult{CheckInbox: true, Email: email, MailConfigured: s.MailConfigured}, nil
|
||||
}
|
||||
user = existing
|
||||
default:
|
||||
return SignUpResult{}, err
|
||||
}
|
||||
|
||||
if err := s.issueVerificationCode(ctx, user); err != nil {
|
||||
return SignUpResult{}, err
|
||||
}
|
||||
return SignUpResult{CheckInbox: true, Email: email, MailConfigured: s.MailConfigured}, nil
|
||||
}
|
||||
|
||||
// issueVerificationCode replaces sendVerificationEmail: mints a code,
|
||||
// stores its hash, and publishes an event for the notification service
|
||||
// to deliver it — decoupled from this call's own success/failure
|
||||
// (see pkg/eventbus.Bus.PublishBestEffort's doc comment on why this
|
||||
// must never block or fail the signup/resend response).
|
||||
func (s *Service) issueVerificationCode(ctx context.Context, u *domain.User) error {
|
||||
raw, hash, err := authsupport.NewCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt := time.Now().Add(verifyCodeTTL)
|
||||
if err := s.Verification.Create(ctx, u.ID, hash, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyVerificationCodeIssued, events.VerificationCodeIssued{
|
||||
UserID: u.ID, Email: u.Email, Code: raw, ExpiresAt: expiresAt,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResendVerification replaces Handlers.ResendVerification.
|
||||
func (s *Service) ResendVerification(ctx context.Context, email, clientIP string) error {
|
||||
// Limit by both address and IP — one address can't be spammed, and
|
||||
// one client can't spray many addresses.
|
||||
if !throttle(ctx, s.ResendLimiter, "resend:ip:"+clientIP, "resend:email:"+email) {
|
||||
return ctx.Err()
|
||||
}
|
||||
u, err := s.Users.GetByEmail(ctx, email)
|
||||
if err == nil && !u.EmailVerified {
|
||||
if err := s.issueVerificationCode(ctx, u); err != nil {
|
||||
s.Logger.Error("resend verification", "err", err, "user_id", u.ID)
|
||||
}
|
||||
}
|
||||
// Same answer whether or not the account exists / needs verifying —
|
||||
// errors swallowed deliberately, matching the monolith.
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyEmail replaces Handlers.VerifyEmail.
|
||||
func (s *Service) VerifyEmail(ctx context.Context, email, code, clientIP string) (bool, error) {
|
||||
ipKey, emailKey := "verify:ip:"+clientIP, "verify:email:"+email
|
||||
// Progressive delay by IP and by email — a distinct limiter from
|
||||
// login/resend, since guessing a 6-digit code is a distinct abuse
|
||||
// vector from password-guessing or triggering outbound mail.
|
||||
var wait time.Duration
|
||||
for _, k := range []string{ipKey, emailKey} {
|
||||
if dl := s.VerifyLimiter.Delay(k); dl > wait {
|
||||
wait = dl
|
||||
}
|
||||
}
|
||||
if !sleepCtx(ctx, wait) {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
|
||||
user, err := s.Users.GetByEmail(ctx, email)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
s.VerifyLimiter.RecordFailure(ipKey)
|
||||
s.VerifyLimiter.RecordFailure(emailKey)
|
||||
return false, nil // don't reveal whether the address is registered
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = s.Verification.VerifyAndConsume(ctx, user.ID, authsupport.HashToken(code))
|
||||
switch {
|
||||
case err == nil:
|
||||
s.VerifyLimiter.RecordSuccess(ipKey)
|
||||
s.VerifyLimiter.RecordSuccess(emailKey)
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyUserEmailVerified, events.UserEmailVerified{
|
||||
UserID: user.ID, Email: user.Email,
|
||||
})
|
||||
return true, nil
|
||||
case errors.Is(err, domain.ErrInvalidToken):
|
||||
s.VerifyLimiter.RecordFailure(ipKey)
|
||||
s.VerifyLimiter.RecordFailure(emailKey)
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
48
services/iam/internal/application/throttle.go
Normal file
48
services/iam/internal/application/throttle.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/authsupport"
|
||||
)
|
||||
|
||||
// throttle replaces Handlers.throttle: sleeps for the largest
|
||||
// progressive delay among keys (honoring ctx cancellation — a gRPC
|
||||
// client that gives up shouldn't leave this goroutine sleeping
|
||||
// uselessly), then records an attempt against every key regardless of
|
||||
// the eventual outcome. Returns false if ctx was cancelled first, in
|
||||
// which case the caller should abort without doing any work (the
|
||||
// monolith's handler returned straight to net/http in that case; here
|
||||
// the gRPC server maps it to context.Canceled).
|
||||
func throttle(ctx context.Context, l *authsupport.Limiter, keys ...string) bool {
|
||||
var wait time.Duration
|
||||
for _, k := range keys {
|
||||
if d := l.Delay(k); d > wait {
|
||||
wait = d
|
||||
}
|
||||
}
|
||||
if !sleepCtx(ctx, wait) {
|
||||
return false
|
||||
}
|
||||
for _, k := range keys {
|
||||
l.RecordFailure(k)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for d or until ctx is done, reporting whether the full
|
||||
// sleep elapsed (replaces handlers.sleepCtx).
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
15
services/iam/internal/domain/errors.go
Normal file
15
services/iam/internal/domain/errors.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors the application layer compares against with
|
||||
// errors.Is — the same three the monolith's internal/db package
|
||||
// exposed (db.ErrNotFound, db.ErrEmailTaken, db.ErrInvalidToken), now
|
||||
// owned by the domain layer instead of a database package, since
|
||||
// "email already taken" and "code invalid or expired" are business
|
||||
// rules, not SQL facts.
|
||||
var (
|
||||
ErrNotFound = errors.New("iam: not found")
|
||||
ErrEmailTaken = errors.New("iam: email already registered")
|
||||
ErrInvalidToken = errors.New("iam: invalid or expired token")
|
||||
)
|
||||
92
services/iam/internal/domain/user.go
Normal file
92
services/iam/internal/domain/user.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package domain holds the IAM bounded context's entities and the
|
||||
// repository interfaces its application layer depends on. Nothing here
|
||||
// imports database/sql, net/http or gRPC — see ARCHITECTURE.md's note
|
||||
// on dependency direction ("domain depends on nothing; everything else
|
||||
// depends on domain").
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is an enkly account — identical in shape to the monolith's
|
||||
// models.User, minus the json tags (this is an internal entity now, not
|
||||
// a value serialized straight to a template).
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
EmailVerified bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Session is one logged-in browser, identified at rest by the SHA-256
|
||||
// hash of its cookie token (see pkg/authsupport.HashToken) — the raw
|
||||
// token exists only in the cookie itself and the gRPC response that
|
||||
// minted it.
|
||||
type Session struct {
|
||||
UserID int64
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// VerificationCode is a 6-digit email-verification code, stored only as
|
||||
// its hash plus an attempt counter (see internal/db's original comment
|
||||
// on why a short code needs active guessing defense that a 256-bit
|
||||
// token wouldn't).
|
||||
type VerificationCode struct {
|
||||
UserID int64
|
||||
CodeHash string
|
||||
Attempts int
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
}
|
||||
|
||||
// PasswordResetToken is a "forgot password" link's token, stored only
|
||||
// as its hash.
|
||||
type PasswordResetToken struct {
|
||||
UserID int64
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
}
|
||||
|
||||
// UserRepository persists and retrieves User rows. Implemented by
|
||||
// internal/infrastructure/postgres.UserRepo.
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, email, passwordHash string) (*User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetByID(ctx context.Context, id int64) (*User, error)
|
||||
MarkEmailVerified(ctx context.Context, userID int64) error
|
||||
UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error
|
||||
}
|
||||
|
||||
// SessionRepository persists and retrieves Session rows.
|
||||
type SessionRepository interface {
|
||||
Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error
|
||||
GetUserByTokenHash(ctx context.Context, tokenHash string) (*User, error)
|
||||
Delete(ctx context.Context, tokenHash string) error
|
||||
DeleteAllForUser(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
// VerificationRepository persists and retrieves VerificationCode rows.
|
||||
type VerificationRepository interface {
|
||||
Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error
|
||||
// VerifyAndConsume replaces db.VerifyEmailCode: checks the most
|
||||
// recent unused, unexpired code for userID against codeHash,
|
||||
// incrementing its attempt counter on a mismatch and marking it
|
||||
// used (and the user verified) on a match. Returns domain.ErrInvalid
|
||||
// when no code matches.
|
||||
VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error
|
||||
}
|
||||
|
||||
// PasswordResetRepository persists and retrieves PasswordResetToken rows.
|
||||
type PasswordResetRepository interface {
|
||||
Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error
|
||||
Valid(ctx context.Context, tokenHash string) (bool, error)
|
||||
// ConsumeAndResetPassword replaces db.ResetPassword: validates the
|
||||
// token, updates the password hash, marks the token used, and
|
||||
// deletes every session for the user, all in one transaction.
|
||||
ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (userID int64, err error)
|
||||
}
|
||||
128
services/iam/internal/infrastructure/postgres/user_repo.go
Normal file
128
services/iam/internal/infrastructure/postgres/user_repo.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Package postgres implements IAM's domain repository interfaces against
|
||||
// the iam database (see services/iam/migrations/0001_init.sql). This is
|
||||
// a one-for-one transplant of the monolith's internal/db/users.go, split
|
||||
// across files by aggregate instead of living in one package shared
|
||||
// with every other table in the old schema.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/iam/internal/domain"
|
||||
)
|
||||
|
||||
const userColumns = `id, email, password_hash, email_verified, created_at`
|
||||
|
||||
func scanUser(row interface{ Scan(...any) error }) (*domain.User, error) {
|
||||
var u domain.User
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.EmailVerified, &u.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UserRepo implements domain.UserRepository.
|
||||
type UserRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewUserRepo(db *pgkit.DB) *UserRepo { return &UserRepo{DB: db} }
|
||||
|
||||
// Create inserts a new, unverified account. email must already be
|
||||
// normalized (trimmed + lower-cased) by the application layer;
|
||||
// passwordHash is the output of authsupport.HashPassword.
|
||||
func (r *UserRepo) Create(ctx context.Context, email, passwordHash string) (*domain.User, error) {
|
||||
u, err := scanUser(r.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING `+userColumns,
|
||||
email, passwordHash))
|
||||
if err != nil {
|
||||
if pgkit.IsUniqueViolation(err) {
|
||||
return nil, domain.ErrEmailTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
return scanUser(r.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE email = $1`, email))
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) {
|
||||
return scanUser(r.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (r *UserRepo) MarkEmailVerified(ctx context.Context, userID int64) error {
|
||||
_, err := r.DB.ExecContext(ctx, `UPDATE users SET email_verified = true WHERE id = $1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepo) UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error {
|
||||
_, err := r.DB.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Sessions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SessionRepo implements domain.SessionRepository.
|
||||
type SessionRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewSessionRepo(db *pgkit.DB) *SessionRepo { return &SessionRepo{DB: db} }
|
||||
|
||||
func (r *SessionRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
|
||||
_, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||
userID, tokenHash, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepo) GetUserByTokenHash(ctx context.Context, tokenHash string) (*domain.User, error) {
|
||||
return scanUser(r.DB.QueryRowContext(ctx, `
|
||||
SELECT u.id, u.email, u.password_hash, u.email_verified, u.created_at
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > now()`, tokenHash))
|
||||
}
|
||||
|
||||
func (r *SessionRepo) Delete(ctx context.Context, tokenHash string) error {
|
||||
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = $1`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepo) DeleteAllForUser(ctx context.Context, userID int64) error {
|
||||
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired sessions and used/expired verification
|
||||
// and password-reset tokens, mirroring internal/db.DB.CleanupExpired —
|
||||
// run periodically by cmd/server's cleanup loop (see main.go).
|
||||
func CleanupExpired(ctx context.Context, db *pgkit.DB) (int64, error) {
|
||||
var total int64
|
||||
for _, q := range []string{
|
||||
`DELETE FROM sessions WHERE expires_at < now()`,
|
||||
`DELETE FROM email_verification_codes WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
|
||||
`DELETE FROM password_reset_tokens WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
|
||||
} {
|
||||
res, err := db.ExecContext(ctx, q)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/iam/internal/domain"
|
||||
)
|
||||
|
||||
// maxVerificationAttempts caps how many wrong codes a single
|
||||
// verification row accepts before it's dead even to the correct code —
|
||||
// see domain.VerificationRepository's doc comment.
|
||||
const maxVerificationAttempts = 8
|
||||
|
||||
// VerificationRepo implements domain.VerificationRepository.
|
||||
type VerificationRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewVerificationRepo(db *pgkit.DB) *VerificationRepo { return &VerificationRepo{DB: db} }
|
||||
|
||||
func (r *VerificationRepo) Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error {
|
||||
tx, err := r.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM email_verification_codes WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO email_verification_codes (user_id, code_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||
userID, codeHash, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *VerificationRepo) VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error {
|
||||
tx, err := r.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var verified bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT email_verified FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&verified); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if verified {
|
||||
return nil // idempotent for an already-verified account
|
||||
}
|
||||
|
||||
var (
|
||||
rowID int64
|
||||
storedHash string
|
||||
attempts int
|
||||
expiresAt time.Time
|
||||
)
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT id, code_hash, attempts, expires_at
|
||||
FROM email_verification_codes
|
||||
WHERE user_id = $1 AND used_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
FOR UPDATE`, userID).Scan(&rowID, &storedHash, &attempts, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.ErrInvalidToken
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if attempts >= maxVerificationAttempts || time.Now().After(expiresAt) {
|
||||
return domain.ErrInvalidToken
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(codeHash)) != 1 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1`, rowID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return domain.ErrInvalidToken
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE email_verification_codes SET used_at = now() WHERE id = $1`, rowID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET email_verified = true WHERE id = $1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Password reset tokens
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// PasswordResetRepo implements domain.PasswordResetRepository.
|
||||
type PasswordResetRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewPasswordResetRepo(db *pgkit.DB) *PasswordResetRepo { return &PasswordResetRepo{DB: db} }
|
||||
|
||||
func (r *PasswordResetRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
|
||||
tx, err := r.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||
userID, tokenHash, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepo) Valid(ctx context.Context, tokenHash string) (bool, error) {
|
||||
var ok bool
|
||||
err := r.DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM password_reset_tokens
|
||||
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||||
)`, tokenHash).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepo) ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (int64, error) {
|
||||
tx, err := r.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var userID int64
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE password_reset_tokens SET used_at = now()
|
||||
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||||
RETURNING user_id`, tokenHash).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, domain.ErrInvalidToken
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
128
services/iam/internal/interfaces/grpc/server.go
Normal file
128
services/iam/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Package grpc adapts application.Service onto the generated
|
||||
// iamv1.IAMServiceServer interface — the only layer in this service
|
||||
// that imports google.golang.org/grpc or the generated gen/iam/v1
|
||||
// package. Keeping that import confined to this one file is what lets
|
||||
// internal/application stay transport-agnostic and unit-testable
|
||||
// without spinning up a gRPC server.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
iamv1 "enkly/gen/iam/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/services/iam/internal/application"
|
||||
"enkly/services/iam/internal/domain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
iamv1.UnimplementedIAMServiceServer
|
||||
Svc *application.Service
|
||||
}
|
||||
|
||||
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
|
||||
|
||||
func toProtoUser(u *domain.User) *iamv1.User {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &iamv1.User{
|
||||
Id: u.ID,
|
||||
Email: u.Email,
|
||||
EmailVerified: u.EmailVerified,
|
||||
CreatedAt: timestamppb.New(u.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) SignUp(ctx context.Context, req *iamv1.SignUpRequest) (*iamv1.SignUpResponse, error) {
|
||||
res, err := s.Svc.SignUp(ctx, req.GetEmail(), req.GetPassword(), req.GetClientIp())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.SignUpResponse{
|
||||
CheckInbox: res.CheckInbox,
|
||||
Email: res.Email,
|
||||
MailConfigured: res.MailConfigured,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ResendVerification(ctx context.Context, req *iamv1.ResendVerificationRequest) (*iamv1.ResendVerificationResponse, error) {
|
||||
if err := s.Svc.ResendVerification(ctx, req.GetEmail(), req.GetClientIp()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.ResendVerificationResponse{MailConfigured: s.Svc.MailConfigured}, nil
|
||||
}
|
||||
|
||||
func (s *Server) VerifyEmail(ctx context.Context, req *iamv1.VerifyEmailRequest) (*iamv1.VerifyEmailResponse, error) {
|
||||
ok, err := s.Svc.VerifyEmail(ctx, req.GetEmail(), req.GetCode(), req.GetClientIp())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.VerifyEmailResponse{Verified: ok}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Login(ctx context.Context, req *iamv1.LoginRequest) (*iamv1.LoginResponse, error) {
|
||||
res, err := s.Svc.Login(ctx, req.GetEmail(), req.GetPassword(), req.GetClientIp())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &iamv1.LoginResponse{Ok: res.OK, NeedsVerification: res.NeedsVerification}
|
||||
if res.OK {
|
||||
resp.SessionToken = res.SessionToken
|
||||
resp.SessionExpiresAt = timestamppb.New(res.SessionExpiresAt)
|
||||
resp.User = toProtoUser(res.User)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) Logout(ctx context.Context, req *iamv1.LogoutRequest) (*iamv1.LogoutResponse, error) {
|
||||
if err := s.Svc.Logout(ctx, req.GetSessionToken()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.LogoutResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ValidateSession(ctx context.Context, req *iamv1.ValidateSessionRequest) (*iamv1.ValidateSessionResponse, error) {
|
||||
u, ok := s.Svc.ValidateSession(ctx, req.GetSessionToken())
|
||||
if !ok {
|
||||
return &iamv1.ValidateSessionResponse{Valid: false}, nil
|
||||
}
|
||||
return &iamv1.ValidateSessionResponse{Valid: true, User: toProtoUser(u)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ForgotPassword(ctx context.Context, req *iamv1.ForgotPasswordRequest) (*iamv1.ForgotPasswordResponse, error) {
|
||||
if err := s.Svc.ForgotPassword(ctx, req.GetEmail(), req.GetClientIp()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.ForgotPasswordResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CheckPasswordResetToken(ctx context.Context, req *iamv1.CheckPasswordResetTokenRequest) (*iamv1.CheckPasswordResetTokenResponse, error) {
|
||||
ok, err := s.Svc.CheckPasswordResetToken(ctx, req.GetToken())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.CheckPasswordResetTokenResponse{Valid: ok}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ResetPassword(ctx context.Context, req *iamv1.ResetPasswordRequest) (*iamv1.ResetPasswordResponse, error) {
|
||||
ok, err := s.Svc.ResetPassword(ctx, req.GetToken(), req.GetPassword())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iamv1.ResetPasswordResponse{Ok: ok}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetUser(ctx context.Context, req *iamv1.GetUserRequest) (*iamv1.User, error) {
|
||||
u, err := s.Svc.GetUser(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, grpcerr.NotFound("user")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return toProtoUser(u), nil
|
||||
}
|
||||
67
services/iam/migrations/0001_init.sql
Normal file
67
services/iam/migrations/0001_init.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- =====================================================================
|
||||
-- iam service — 0001_init.sql
|
||||
-- Database-per-service: this is the ONLY schema that owns `users`,
|
||||
-- `sessions`, `email_verification_codes` and `password_reset_tokens`.
|
||||
-- No other service's migration may create a table with these names or
|
||||
-- reach into this database. Transplanted verbatim from the monolith's
|
||||
-- schema.sql section of the same name; see ARCHITECTURE.md's
|
||||
-- "Database-per-service" note for why the FKs below stay *within* this
|
||||
-- file only — every other service refers to a user by a bare
|
||||
-- `user_id BIGINT`, not a foreign key, since Postgres can't enforce a
|
||||
-- constraint across two separate databases.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Email verification codes: a short numeric code (not a link) mailed to
|
||||
-- the address at signup. `attempts` caps how many wrong codes a row
|
||||
-- will accept before it's dead (see internal/application.VerifyEmail);
|
||||
-- code_hash is deliberately NOT unique — two different users' current
|
||||
-- codes can coincidentally collide, which is expected and harmless
|
||||
-- since every lookup is scoped by user_id, never by code_hash alone.
|
||||
CREATE TABLE IF NOT EXISTS email_verification_codes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS email_verification_codes_user_id_idx
|
||||
ON email_verification_codes (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions (expires_at);
|
||||
|
||||
-- "Forgot password" tokens — same hash-only-at-rest pattern as
|
||||
-- email_verification_codes. A successful reset also deletes every row
|
||||
-- in `sessions` for that user, so a stolen session cookie doesn't
|
||||
-- survive a reset (see application.PasswordResetRepo.
|
||||
-- ConsumeAndResetPassword).
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
|
||||
ON password_reset_tokens (user_id);
|
||||
19
services/knowledge/Dockerfile
Normal file
19
services/knowledge/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation. This image's
|
||||
# counterpart in docker-compose.yml uses pgvector/pgvector as its
|
||||
# Postgres image (plain postgres lacks the vector extension).
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/knowledge/ services/knowledge/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/knowledge-server ./services/knowledge/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/knowledge-server ./knowledge-server
|
||||
COPY --from=build /src/services/knowledge/migrations ./migrations
|
||||
ENV ENKLY_KNOWLEDGE_GRPC_ADDR=:9005
|
||||
EXPOSE 9005
|
||||
ENTRYPOINT ["./knowledge-server"]
|
||||
109
services/knowledge/cmd/server/main.go
Normal file
109
services/knowledge/cmd/server/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Command server runs the knowledge microservice: a gRPC server
|
||||
// exposing api/proto/knowledge/v1, backed by its own pgvector-enabled
|
||||
// Postgres database and a vLLM-served embedding model. Replaces
|
||||
// internal/rag/*.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
knowledgev1 "enkly/gen/knowledge/v1"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/knowledge/internal/application"
|
||||
"enkly/services/knowledge/internal/infrastructure/postgres"
|
||||
grpcsrv "enkly/services/knowledge/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
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 (
|
||||
grpcAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", ":9005")
|
||||
postgresDSN = envkit.String("ENKLY_KNOWLEDGE_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_knowledge?sslmode=disable")
|
||||
migrationsPath = envkit.String("ENKLY_KNOWLEDGE_MIGRATIONS", "migrations/0001_init.sql")
|
||||
|
||||
// vllmEmbedURL points at the vLLM OpenAI-compatible deployment
|
||||
// serving the embedding model — its own vLLM engine process,
|
||||
// independent of (and typically scaled very differently from)
|
||||
// the chat deployment the agent service talks to, since vLLM
|
||||
// serves exactly one model per server. See k8s/11-vllm.yaml.
|
||||
// This service never calls the chat model, so it only ever
|
||||
// constructs a vllmkit.Client with chatBaseURL left empty.
|
||||
vllmEmbedURL = envkit.String("ENKLY_VLLM_EMBED_URL", "http://localhost:8001/v1")
|
||||
embedModel = envkit.String("ENKLY_EMBED_MODEL", vllmkit.DefaultEmbedModel)
|
||||
// vllmAPIKey is sent as a bearer token on every embed request
|
||||
// (vLLM's --api-key flag) — see k8s/02-secrets.yaml.
|
||||
vllmAPIKey = envkit.String("ENKLY_VLLM_API_KEY", "")
|
||||
)
|
||||
|
||||
db, err := pgkit.Open(postgresDSN)
|
||||
if err != nil {
|
||||
logger.Error("open postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
migrationSQL, err := os.ReadFile(migrationsPath)
|
||||
if err != nil {
|
||||
logger.Error("read migrations", "path", migrationsPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
|
||||
logger.Error("migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// chatBaseURL is deliberately left "" (defaults to vllmkit's own
|
||||
// unused-but-harmless placeholder) — this service never calls
|
||||
// ChatStream, only Embed, so only embedBaseURL matters here.
|
||||
llmClient := vllmkit.New("", vllmEmbedURL, "", embedModel)
|
||||
llmClient.APIKey = vllmAPIKey
|
||||
|
||||
svc := &application.Service{
|
||||
Documents: postgres.NewDocumentRepo(db),
|
||||
Embedder: llmClient,
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
knowledgev1.RegisterKnowledgeServiceServer(grpcServer, grpcsrv.New(svc))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("knowledge service listening", "addr", grpcAddr, "embed_model", embedModel)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
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")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
95
services/knowledge/internal/application/service.go
Normal file
95
services/knowledge/internal/application/service.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Package application holds knowledge's use cases — the content of
|
||||
// internal/rag/store.go's IndexFile/Search methods, now split from
|
||||
// their SQL (internal/infrastructure/postgres) so this package depends
|
||||
// only on domain.DocumentRepository/domain.Embedder.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"enkly/services/knowledge/internal/domain"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Documents domain.DocumentRepository
|
||||
Embedder domain.Embedder
|
||||
}
|
||||
|
||||
func hashContent(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// upsertChunk replaces rag.Store.UpsertChunk: embeds a single chunk and
|
||||
// stores it, skipping the embedding call entirely if the chunk's
|
||||
// content hasn't changed since it was last indexed.
|
||||
func (s *Service) upsertChunk(ctx context.Context, filePath string, chunkIndex int, content string) error {
|
||||
hash := hashContent(content)
|
||||
|
||||
existing, err := s.Documents.ExistingContentHash(ctx, filePath, chunkIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == hash {
|
||||
return nil // unchanged, skip re-embedding
|
||||
}
|
||||
|
||||
vec, err := s.Embedder.Embed(ctx, content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("knowledge: embed %s chunk %d: %w", filePath, chunkIndex, err)
|
||||
}
|
||||
if len(vec) != domain.EmbeddingDim {
|
||||
return fmt.Errorf("knowledge: embedding model returned %d-dimensional vectors but the schema declares vector(%d) — "+
|
||||
"use a %d-dim model (the default nomic-embed-text is) or change the column and re-index",
|
||||
len(vec), domain.EmbeddingDim, domain.EmbeddingDim)
|
||||
}
|
||||
if err := s.Documents.Upsert(ctx, filePath, chunkIndex, content, hash, vec); err != nil {
|
||||
return fmt.Errorf("knowledge: upsert %s chunk %d: %w", filePath, chunkIndex, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexFile replaces rag.Store.IndexFile.
|
||||
func (s *Service) IndexFile(ctx context.Context, filePath, content string) (indexed, skipped int, err error) {
|
||||
chunks := domain.ChunkText(content)
|
||||
if err := s.Documents.DeleteChunksFrom(ctx, filePath, len(chunks)); err != nil {
|
||||
return 0, 0, fmt.Errorf("knowledge: prune stale chunks for %s: %w", filePath, err)
|
||||
}
|
||||
for i, chunk := range chunks {
|
||||
before, _ := s.Documents.ExistingContentHash(ctx, filePath, i)
|
||||
if err := s.upsertChunk(ctx, filePath, i, chunk); err != nil {
|
||||
return indexed, skipped, err
|
||||
}
|
||||
if before == hashContent(chunk) {
|
||||
skipped++
|
||||
} else {
|
||||
indexed++
|
||||
}
|
||||
}
|
||||
return indexed, skipped, nil
|
||||
}
|
||||
|
||||
// PruneMissing replaces rag.Store.pruneMissing.
|
||||
func (s *Service) PruneMissing(ctx context.Context, keepFilePaths []string) (int, error) {
|
||||
return s.Documents.PruneMissing(ctx, keepFilePaths)
|
||||
}
|
||||
|
||||
// Search replaces rag.Store.Search.
|
||||
func (s *Service) Search(ctx context.Context, query string, topK int) ([]domain.CodeChunk, error) {
|
||||
if topK <= 0 {
|
||||
topK = 6
|
||||
}
|
||||
vec, err := s.Embedder.Embed(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("knowledge: embed query: %w", err)
|
||||
}
|
||||
return s.Documents.Search(ctx, vec, topK)
|
||||
}
|
||||
|
||||
// ListIndexedFiles replaces db.ListIndexedFiles.
|
||||
func (s *Service) ListIndexedFiles(ctx context.Context, limit int) ([]string, error) {
|
||||
return s.Documents.ListIndexedFiles(ctx, limit)
|
||||
}
|
||||
120
services/knowledge/internal/domain/chunk.go
Normal file
120
services/knowledge/internal/domain/chunk.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// chunkMaxChars is the target upper bound for one chunk. ~1,800 chars
|
||||
// is roughly 450–600 tokens of code: small enough that a similarity
|
||||
// hit points at a specific function rather than a whole file, large
|
||||
// enough that a function usually fits in one chunk. It is well under
|
||||
// nomic-embed-text's 2,048-token context, so nothing is truncated.
|
||||
chunkMaxChars = 1800
|
||||
|
||||
// chunkOverlapChars of trailing context (whole lines) is repeated at
|
||||
// the start of the next chunk, so a match that straddles a boundary
|
||||
// still lands in at least one chunk with its surroundings.
|
||||
chunkOverlapChars = 240
|
||||
)
|
||||
|
||||
// ChunkText splits source text into overlapping, line-aligned chunks of
|
||||
// at most ~chunkMaxChars characters. Whitespace-only input yields nil.
|
||||
//
|
||||
// Chunks break on line boundaries (never mid-line, except for a single
|
||||
// line longer than the limit, e.g. minified JS, which is split at rune
|
||||
// boundaries) so each chunk reads as valid-looking code.
|
||||
func ChunkText(content string) []string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil
|
||||
}
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
|
||||
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
|
||||
|
||||
var (
|
||||
chunks []string
|
||||
cur []string
|
||||
curSize int
|
||||
)
|
||||
|
||||
flush := func() {
|
||||
text := strings.Join(cur, "\n")
|
||||
if strings.TrimSpace(text) != "" {
|
||||
chunks = append(chunks, text)
|
||||
}
|
||||
}
|
||||
|
||||
// startNext seeds the next chunk with up to chunkOverlapChars of the
|
||||
// previous chunk's trailing lines.
|
||||
startNext := func() {
|
||||
var keep []string
|
||||
size := 0
|
||||
for i := len(cur) - 1; i >= 0; i-- {
|
||||
l := len(cur[i]) + 1
|
||||
if size+l > chunkOverlapChars {
|
||||
break
|
||||
}
|
||||
keep = append([]string{cur[i]}, keep...)
|
||||
size += l
|
||||
}
|
||||
cur, curSize = keep, size
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
for _, piece := range splitLongLine(line, chunkMaxChars) {
|
||||
add := len(piece) + 1
|
||||
if curSize+add > chunkMaxChars && len(cur) > 0 {
|
||||
flush()
|
||||
startNext()
|
||||
// If the overlap plus this piece still overflows, drop the overlap:
|
||||
// guarantees forward progress for pathological inputs.
|
||||
if curSize+add > chunkMaxChars {
|
||||
cur, curSize = nil, 0
|
||||
}
|
||||
}
|
||||
cur = append(cur, piece)
|
||||
curSize += add
|
||||
}
|
||||
}
|
||||
if len(cur) > 0 {
|
||||
flush()
|
||||
}
|
||||
return dedupeTail(chunks)
|
||||
}
|
||||
|
||||
// splitLongLine breaks a single line longer than max bytes into pieces of
|
||||
// at most max bytes, cutting only at rune boundaries.
|
||||
func splitLongLine(line string, max int) []string {
|
||||
if len(line) <= max {
|
||||
return []string{line}
|
||||
}
|
||||
var out []string
|
||||
for len(line) > max {
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(line[cut]) {
|
||||
cut--
|
||||
}
|
||||
if cut == 0 { // no rune boundary found (invalid UTF-8): hard cut
|
||||
cut = max
|
||||
}
|
||||
out = append(out, line[:cut])
|
||||
line = line[cut:]
|
||||
}
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeTail drops a final chunk that is nothing but overlap already
|
||||
// contained in the previous chunk (which happens when the last flush
|
||||
// carried only re-seeded lines).
|
||||
func dedupeTail(chunks []string) []string {
|
||||
n := len(chunks)
|
||||
if n >= 2 && strings.HasSuffix(chunks[n-2], chunks[n-1]) {
|
||||
return chunks[:n-1]
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
45
services/knowledge/internal/domain/document.go
Normal file
45
services/knowledge/internal/domain/document.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package domain
|
||||
|
||||
import "context"
|
||||
|
||||
// EmbeddingDim is the vector width migrations/0001_init.sql declares
|
||||
// for documents.embedding (vector(768), matching nomic-embed-text).
|
||||
const EmbeddingDim = 768
|
||||
|
||||
// CodeChunk is a single embedded chunk of a source file, as returned by
|
||||
// a similarity search — identical in shape to the monolith's
|
||||
// models.CodeChunk.
|
||||
type CodeChunk struct {
|
||||
ID int64
|
||||
FilePath string
|
||||
ChunkIndex int
|
||||
Content string
|
||||
Distance float64 // cosine distance; lower = more relevant
|
||||
}
|
||||
|
||||
// Embedder is the subset of *vllmkit.Client the application layer
|
||||
// needs — defined here (rather than depending on the concrete client
|
||||
// type) purely to keep the dependency explicit and mockable in tests,
|
||||
// exactly like the monolith's rag.Embedder.
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float32, error)
|
||||
}
|
||||
|
||||
// DocumentRepository persists chunked, embedded documents — the
|
||||
// pgvector-backed transplant of internal/rag/store.go +
|
||||
// internal/rag/index.go's pruneMissing.
|
||||
type DocumentRepository interface {
|
||||
// ExistingContentHash returns the content_hash stored for
|
||||
// (filePath, chunkIndex), or "" if no such row exists — used to
|
||||
// skip re-embedding an unchanged chunk (see UpsertChunk's doc
|
||||
// comment in the monolith).
|
||||
ExistingContentHash(ctx context.Context, filePath string, chunkIndex int) (string, error)
|
||||
Upsert(ctx context.Context, filePath string, chunkIndex int, content, contentHash string, embedding []float32) error
|
||||
// DeleteChunksFrom removes any chunk_index >= fromIndex for
|
||||
// filePath — stale trailing chunks left over from a previous,
|
||||
// longer version of the file.
|
||||
DeleteChunksFrom(ctx context.Context, filePath string, fromIndex int) error
|
||||
Search(ctx context.Context, embedding []float32, topK int) ([]CodeChunk, error)
|
||||
PruneMissing(ctx context.Context, keepFilePaths []string) (int, error)
|
||||
ListIndexedFiles(ctx context.Context, limit int) ([]string, error)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package postgres implements knowledge's DocumentRepository against
|
||||
// the pgvector-backed knowledge database. Transplant of
|
||||
// internal/rag/store.go's SQL (vectorLiteral and all) plus
|
||||
// internal/rag/index.go's pruneMissing, and internal/db.DB.
|
||||
// ListIndexedFiles.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/knowledge/internal/domain"
|
||||
)
|
||||
|
||||
// vectorLiteral formats a float32 embedding as the string pgvector
|
||||
// expects for input, e.g. "[0.011,-0.42,...]" — lib/pq has no native
|
||||
// vector type, so it's sent as text and cast with ::vector in SQL.
|
||||
func vectorLiteral(v []float32) string {
|
||||
parts := make([]string, len(v))
|
||||
for i, f := range v {
|
||||
parts[i] = strconv.FormatFloat(float64(f), 'f', 8, 32)
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
|
||||
type DocumentRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewDocumentRepo(db *pgkit.DB) *DocumentRepo { return &DocumentRepo{DB: db} }
|
||||
|
||||
func (r *DocumentRepo) ExistingContentHash(ctx context.Context, filePath string, chunkIndex int) (string, error) {
|
||||
var hash string
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT content_hash FROM documents WHERE file_path = $1 AND chunk_index = $2`,
|
||||
filePath, chunkIndex).Scan(&hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return hash, err
|
||||
}
|
||||
|
||||
func (r *DocumentRepo) Upsert(ctx context.Context, filePath string, chunkIndex int, content, contentHash string, embedding []float32) error {
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO documents (file_path, chunk_index, content, content_hash, embedding, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::vector, now())
|
||||
ON CONFLICT (file_path, chunk_index)
|
||||
DO UPDATE SET content = EXCLUDED.content,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
embedding = EXCLUDED.embedding,
|
||||
updated_at = now()`,
|
||||
filePath, chunkIndex, content, contentHash, vectorLiteral(embedding))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *DocumentRepo) DeleteChunksFrom(ctx context.Context, filePath string, fromIndex int) error {
|
||||
_, err := r.DB.ExecContext(ctx,
|
||||
`DELETE FROM documents WHERE file_path = $1 AND chunk_index >= $2`, filePath, fromIndex)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *DocumentRepo) Search(ctx context.Context, embedding []float32, topK int) ([]domain.CodeChunk, error) {
|
||||
lit := vectorLiteral(embedding)
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
SELECT id, file_path, chunk_index, content, embedding <=> $1::vector AS distance
|
||||
FROM documents
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $2`, lit, topK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.CodeChunk
|
||||
for rows.Next() {
|
||||
var c domain.CodeChunk
|
||||
if err := rows.Scan(&c.ID, &c.FilePath, &c.ChunkIndex, &c.Content, &c.Distance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *DocumentRepo) PruneMissing(ctx context.Context, keep []string) (int, error) {
|
||||
if keep == nil {
|
||||
keep = []string{}
|
||||
}
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`DELETE FROM documents WHERE NOT (file_path = ANY($1))`, pq.Array(keep))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (r *DocumentRepo) ListIndexedFiles(ctx context.Context, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
rows, err := r.DB.QueryContext(ctx,
|
||||
`SELECT DISTINCT file_path FROM documents ORDER BY file_path LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
73
services/knowledge/internal/interfaces/grpc/server.go
Normal file
73
services/knowledge/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
knowledgev1 "enkly/gen/knowledge/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/knowledge/internal/application"
|
||||
"enkly/services/knowledge/internal/domain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
knowledgev1.UnimplementedKnowledgeServiceServer
|
||||
Svc *application.Service
|
||||
}
|
||||
|
||||
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
|
||||
|
||||
// fromErr maps an application-layer error to the right gRPC status: an
|
||||
// unreachable vllm-embed deployment (Pending, cold-starting,
|
||||
// mid-rollout — see vllmkit.ErrUpstreamUnavailable) is
|
||||
// codes.Unavailable, safe for a well-behaved caller to retry, not the
|
||||
// opaque codes.Internal every other failure gets.
|
||||
func fromErr(err error) error {
|
||||
if vllmkit.IsUpstreamUnavailable(err) {
|
||||
return grpcerr.Unavailable(err)
|
||||
}
|
||||
return grpcerr.Internal(err)
|
||||
}
|
||||
|
||||
func toProtoChunk(c domain.CodeChunk) *knowledgev1.CodeChunk {
|
||||
return &knowledgev1.CodeChunk{
|
||||
Id: c.ID, FilePath: c.FilePath, ChunkIndex: int32(c.ChunkIndex),
|
||||
Content: c.Content, Distance: c.Distance,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) IndexFile(ctx context.Context, req *knowledgev1.IndexFileRequest) (*knowledgev1.IndexFileResponse, error) {
|
||||
indexed, skipped, err := s.Svc.IndexFile(ctx, req.GetFilePath(), req.GetContent())
|
||||
if err != nil {
|
||||
return nil, fromErr(err)
|
||||
}
|
||||
return &knowledgev1.IndexFileResponse{ChunksIndexed: int32(indexed), ChunksSkipped: int32(skipped)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) PruneMissing(ctx context.Context, req *knowledgev1.PruneMissingRequest) (*knowledgev1.PruneMissingResponse, error) {
|
||||
n, err := s.Svc.PruneMissing(ctx, req.GetKeepFilePaths())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &knowledgev1.PruneMissingResponse{RowsDeleted: int32(n)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Search(ctx context.Context, req *knowledgev1.SearchRequest) (*knowledgev1.SearchResponse, error) {
|
||||
chunks, err := s.Svc.Search(ctx, req.GetQuery(), int(req.GetTopK()))
|
||||
if err != nil {
|
||||
return nil, fromErr(err)
|
||||
}
|
||||
resp := &knowledgev1.SearchResponse{}
|
||||
for _, c := range chunks {
|
||||
resp.Chunks = append(resp.Chunks, toProtoChunk(c))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListIndexedFiles(ctx context.Context, req *knowledgev1.ListIndexedFilesRequest) (*knowledgev1.ListIndexedFilesResponse, error) {
|
||||
files, err := s.Svc.ListIndexedFiles(ctx, int(req.GetLimit()))
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &knowledgev1.ListIndexedFilesResponse{FilePaths: files}, nil
|
||||
}
|
||||
27
services/knowledge/migrations/0001_init.sql
Normal file
27
services/knowledge/migrations/0001_init.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- =====================================================================
|
||||
-- knowledge service — 0001_init.sql
|
||||
-- Database-per-service: owns `documents`, the pgvector-backed RAG
|
||||
-- index. Requires PostgreSQL 15+ with the pgvector extension available
|
||||
-- (see deploy — the knowledge database uses the pgvector/pgvector
|
||||
-- Docker image rather than plain postgres).
|
||||
-- =====================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
file_path TEXT NOT NULL,
|
||||
chunk_index INT NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL, -- sha256 of `content`, used to skip re-embedding unchanged chunks
|
||||
embedding vector(768) NOT NULL, -- 768 = nomic-embed-text's output size; see domain.EmbeddingDim
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (file_path, chunk_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS documents_embedding_hnsw_idx
|
||||
ON documents USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS documents_file_path_idx
|
||||
ON documents (file_path);
|
||||
17
services/notification/Dockerfile
Normal file
17
services/notification/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation. This service
|
||||
# has no gRPC API — only a RabbitMQ consumer and a tiny health endpoint.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/notification/ services/notification/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/notification-server ./services/notification/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/notification-server ./notification-server
|
||||
ENV ENKLY_NOTIFICATION_HEALTH_ADDR=:9007
|
||||
EXPOSE 9007
|
||||
ENTRYPOINT ["./notification-server"]
|
||||
81
services/notification/cmd/server/main.go
Normal file
81
services/notification/cmd/server/main.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Command server runs the notification microservice: a pure RabbitMQ
|
||||
// consumer with no gRPC server and no database of its own — it sends
|
||||
// email in reaction to events published by iam. Replaces the email-
|
||||
// sending half of internal/handlers/auth.go + internal/mail/mail.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/mailkit"
|
||||
"enkly/services/notification/internal/application"
|
||||
)
|
||||
|
||||
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 (
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
healthAddr = envkit.String("ENKLY_NOTIFICATION_HEALTH_ADDR", ":9007")
|
||||
|
||||
smtpHost = envkit.String("ENKLY_SMTP_HOST", "smtp.gmail.com")
|
||||
smtpPort = envkit.String("ENKLY_SMTP_PORT", "587")
|
||||
smtpUsername = envkit.String("ENKLY_SMTP_USERNAME", "")
|
||||
smtpPassword = envkit.String("ENKLY_SMTP_PASSWORD", "")
|
||||
smtpFrom = envkit.String("ENKLY_SMTP_FROM", "")
|
||||
)
|
||||
|
||||
mailer := mailkit.New(mailkit.Config{
|
||||
Host: smtpHost, Port: smtpPort, Username: smtpUsername, Password: smtpPassword, From: smtpFrom,
|
||||
})
|
||||
if !mailer.Configured() {
|
||||
logger.Warn("mail is not configured — verification codes and reset links will be logged, not emailed",
|
||||
"hint", "set ENKLY_SMTP_USERNAME and ENKLY_SMTP_PASSWORD (a Gmail App Password) in .env")
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{Mailer: mailer, Logger: logger}
|
||||
|
||||
// A minimal HTTP health endpoint — this service has no gRPC API
|
||||
// (nothing calls it synchronously), but docker-compose and an
|
||||
// orchestrator still want something to poll.
|
||||
go func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
lis, err := net.Listen("tcp", healthAddr)
|
||||
if err != nil {
|
||||
logger.Error("health listen", "err", err)
|
||||
return
|
||||
}
|
||||
_ = http.Serve(lis, mux)
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger.Info("notification service consuming", "queue", eventbus.NotificationQueueBindings.Queue, "mail_configured", mailer.Configured())
|
||||
if err := bus.Consume(ctx, eventbus.NotificationQueueBindings, svc.HandleEvent); err != nil && ctx.Err() == nil {
|
||||
logger.Error("consume", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("shutting down")
|
||||
}
|
||||
96
services/notification/internal/application/service.go
Normal file
96
services/notification/internal/application/service.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Package application is the notification service's entire job: listen
|
||||
// for the events iam publishes when it needs an email sent, and send
|
||||
// it. Replaces the parts of internal/handlers/auth.go that called
|
||||
// mail.Mailer.Send inline (sendVerificationEmail, sendResetEmail),
|
||||
// decoupled onto RabbitMQ per the task's requirement that asynchronous,
|
||||
// no-reply-needed work use the message bus rather than a synchronous
|
||||
// call — sending an email has no result the caller
|
||||
// (iam's SignUp/ResendVerification/ForgotPassword RPCs) needs to wait
|
||||
// for.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/pkg/mailkit"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Mailer *mailkit.Mailer
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// HandleEvent is the single eventbus.Handler this service registers —
|
||||
// see cmd/server/main.go's Consume call. It switches on the envelope's
|
||||
// routing key the same way the monolith's handlers switched on which
|
||||
// function (sendVerificationEmail vs. sendResetEmail) was calling
|
||||
// mail.Send.
|
||||
func (s *Service) HandleEvent(ctx context.Context, env eventbus.Envelope) error {
|
||||
switch env.RoutingKey {
|
||||
case eventbus.RoutingKeyVerificationCodeIssued:
|
||||
var ev events.VerificationCodeIssued
|
||||
if err := decode(env, &ev); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendVerificationEmail(ctx, ev)
|
||||
|
||||
case eventbus.RoutingKeyPasswordResetRequested:
|
||||
var ev events.PasswordResetRequested
|
||||
if err := decode(env, &ev); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendResetEmail(ctx, ev)
|
||||
|
||||
default:
|
||||
// Bound to this queue by mistake (topology drift) — log and ack
|
||||
// rather than requeue-looping forever on a message we'll never
|
||||
// know how to handle.
|
||||
s.Logger.Warn("notification: unhandled routing key", "routing_key", env.RoutingKey)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func decode(env eventbus.Envelope, v any) error {
|
||||
if err := json.Unmarshal(env.Payload, v); err != nil {
|
||||
return fmt.Errorf("decode %s payload: %w", env.RoutingKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendVerificationEmail replaces Handlers.sendVerificationEmail.
|
||||
func (s *Service) sendVerificationEmail(_ context.Context, ev events.VerificationCodeIssued) error {
|
||||
subject := "Verify your enkly account"
|
||||
body := fmt.Sprintf(
|
||||
"Hi,\n\nYour enkly verification code is: %s\n\nThis code expires in 15 minutes. If you didn't request this, you can ignore this email.\n",
|
||||
ev.Code)
|
||||
if !s.Mailer.Configured() {
|
||||
s.Logger.Info("mail not configured — verification code logged instead", "email", ev.Email, "code", ev.Code)
|
||||
return nil
|
||||
}
|
||||
if err := s.Mailer.Send(ev.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("send verification email to %s: %w", ev.Email, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendResetEmail replaces Handlers.sendResetEmail.
|
||||
func (s *Service) sendResetEmail(_ context.Context, ev events.PasswordResetRequested) error {
|
||||
subject := "Reset your enkly password"
|
||||
body := fmt.Sprintf(
|
||||
"Hi,\n\nSomeone (hopefully you) requested a password reset for your enkly account.\n\n"+
|
||||
"Reset it here: %s\n\nThis link expires in an hour. If you didn't request this, you can ignore this email.\n",
|
||||
ev.ResetLink)
|
||||
if !s.Mailer.Configured() {
|
||||
s.Logger.Info("mail not configured — reset link logged instead", "email", ev.Email, "link", ev.ResetLink)
|
||||
return nil
|
||||
}
|
||||
if err := s.Mailer.Send(ev.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("send reset email to %s: %w", ev.Email, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
23
services/workspace/Dockerfile
Normal file
23
services/workspace/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation. Workspace
|
||||
# additionally needs common CLI tools available inside the container
|
||||
# for run_bash_command (git, go, node, python...) — extend this image's
|
||||
# second stage as your allowlist (ENKLY_BASH_ALLOWLIST) requires; kept
|
||||
# minimal here since the allowlist itself is operator-configured.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/workspace/ services/workspace/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/workspace-server ./services/workspace/cmd/server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/workspace-server ./workspace-server
|
||||
ENV ENKLY_WORKSPACE_GRPC_ADDR=:9004
|
||||
ENV ENKLY_WORKSPACE=/data/workspace
|
||||
VOLUME ["/data/workspace"]
|
||||
EXPOSE 9004
|
||||
ENTRYPOINT ["./workspace-server"]
|
||||
96
services/workspace/cmd/server/main.go
Normal file
96
services/workspace/cmd/server/main.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Command server runs the workspace microservice: a gRPC server
|
||||
// exposing api/proto/workspace/v1 over the ENKLY_WORKSPACE directory on
|
||||
// its own volume. Replaces internal/llm/tools.go + sandbox.go (as
|
||||
// agent tool executors) and internal/handlers/files.go (as the human
|
||||
// upload/download/tree backend).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/services/workspace/internal/infrastructure/sandboxfs"
|
||||
grpcsrv "enkly/services/workspace/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
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 (
|
||||
grpcAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", ":9004")
|
||||
workspaceRoot = envkit.String("ENKLY_WORKSPACE", "./workspace")
|
||||
bashTimeoutSeconds = envkit.Int("ENKLY_BASH_TIMEOUT_SECONDS", 0)
|
||||
bashAllowlistRaw = envkit.String("ENKLY_BASH_ALLOWLIST", "")
|
||||
)
|
||||
|
||||
toolbox, err := sandboxfs.NewToolbox(workspaceRoot)
|
||||
if err != nil {
|
||||
logger.Error("init toolbox", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if bashTimeoutSeconds > 0 {
|
||||
toolbox.CommandTimeout = time.Duration(bashTimeoutSeconds) * time.Second
|
||||
}
|
||||
// ENKLY_BASH_ALLOWLIST: comma-separated base binaries (first token
|
||||
// only) run_bash_command may execute. Subcommand rules for go/git
|
||||
// live in code (sandbox.go), not in this list — unchanged from the
|
||||
// monolith.
|
||||
if strings.TrimSpace(bashAllowlistRaw) != "" {
|
||||
list, err := sandboxfs.ParseAllowlist(bashAllowlistRaw)
|
||||
if err != nil {
|
||||
logger.Error("invalid ENKLY_BASH_ALLOWLIST", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
toolbox.AllowedCommands = list
|
||||
}
|
||||
logger.Info("workspace ready", "root", toolbox.WorkspaceRoot, "bash_timeout", toolbox.CommandTimeout)
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// MaxRecvMsgSize/MaxSendMsgSize raised above gRPC's 4 MiB default:
|
||||
// UploadFile/DownloadFile/WriteFile can legitimately carry a
|
||||
// multi-megabyte file (see maxUploadFileBytes in the gateway's
|
||||
// files handler, which is also 20 MiB).
|
||||
grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(24<<20), grpc.MaxSendMsgSize(24<<20))
|
||||
workspacev1.RegisterWorkspaceServiceServer(grpcServer, grpcsrv.New(toolbox))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("workspace service listening", "addr", grpcAddr)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
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")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package sandboxfs
|
||||
|
||||
import "context"
|
||||
|
||||
// The methods below are thin, exported wrappers around the Toolbox's
|
||||
// unexported per-tool methods (readFile, writeFile, ...), built so the
|
||||
// gRPC layer (internal/interfaces/grpc) can call them without needing
|
||||
// package-private access. Each one keeps the original tool's exact
|
||||
// map[string]any argument contract internally — this is deliberate:
|
||||
// agent-service tool-calling, the gRPC layer here, and the underlying
|
||||
// tool implementation all agree on the same argument names
|
||||
// ("old_string", "replace_all", "path_glob", ...), so nothing about the
|
||||
// monolith's tool behavior (including its exact success/error message
|
||||
// text, which the model has been prompted around) has to change.
|
||||
|
||||
func (t *Toolbox) ReadFile(path string, offset, limit int) ToolResult {
|
||||
args := map[string]any{"path": path}
|
||||
if offset != 0 {
|
||||
args["offset"] = float64(offset)
|
||||
}
|
||||
if limit != 0 {
|
||||
args["limit"] = float64(limit)
|
||||
}
|
||||
return t.readFile(ToolCallRequest{Name: "read_file", Arguments: args})
|
||||
}
|
||||
|
||||
func (t *Toolbox) WriteFile(path, content string) ToolResult {
|
||||
return t.writeFile(ToolCallRequest{Name: "write_file", Arguments: map[string]any{
|
||||
"path": path, "content": content,
|
||||
}})
|
||||
}
|
||||
|
||||
func (t *Toolbox) EditFile(path, oldString, newString string, replaceAll bool) ToolResult {
|
||||
return t.editFile(ToolCallRequest{Name: "edit_file", Arguments: map[string]any{
|
||||
"path": path, "old_string": oldString, "new_string": newString, "replace_all": replaceAll,
|
||||
}})
|
||||
}
|
||||
|
||||
// WorkspaceEdit mirrors one element of multi_edit_file's "edits" array.
|
||||
type WorkspaceEdit struct {
|
||||
OldString string
|
||||
NewString string
|
||||
ReplaceAll bool
|
||||
}
|
||||
|
||||
func (t *Toolbox) MultiEditFile(path string, edits []WorkspaceEdit) ToolResult {
|
||||
rawEdits := make([]any, len(edits))
|
||||
for i, e := range edits {
|
||||
rawEdits[i] = map[string]any{
|
||||
"old_string": e.OldString, "new_string": e.NewString, "replace_all": e.ReplaceAll,
|
||||
}
|
||||
}
|
||||
return t.multiEditFile(ToolCallRequest{Name: "multi_edit_file", Arguments: map[string]any{
|
||||
"path": path, "edits": rawEdits,
|
||||
}})
|
||||
}
|
||||
|
||||
func (t *Toolbox) ListDirectory(path string) ToolResult {
|
||||
return t.listDirectory(ToolCallRequest{Name: "list_directory", Arguments: map[string]any{"path": path}})
|
||||
}
|
||||
|
||||
func (t *Toolbox) GlobSearch(pattern, path string) ToolResult {
|
||||
return t.globSearch(ToolCallRequest{Name: "glob_search", Arguments: map[string]any{
|
||||
"pattern": pattern, "path": path,
|
||||
}})
|
||||
}
|
||||
|
||||
func (t *Toolbox) Grep(pattern, path, pathGlob string, caseInsensitive bool) ToolResult {
|
||||
return t.grep(ToolCallRequest{Name: "grep", Arguments: map[string]any{
|
||||
"pattern": pattern, "path": path, "path_glob": pathGlob, "case_insensitive": caseInsensitive,
|
||||
}})
|
||||
}
|
||||
|
||||
func (t *Toolbox) RunBashCommand(ctx context.Context, command string) ToolResult {
|
||||
return t.runBashCommand(ctx, ToolCallRequest{Name: "run_bash_command", Arguments: map[string]any{
|
||||
"command": command,
|
||||
}})
|
||||
}
|
||||
437
services/workspace/internal/infrastructure/sandboxfs/sandbox.go
Normal file
437
services/workspace/internal/infrastructure/sandboxfs/sandbox.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package sandboxfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file implements the run_bash_command tool's sandbox. The name is
|
||||
// historical: nothing here ever invokes a shell. The model's command
|
||||
// string is tokenized into an argv slice and executed directly with
|
||||
// os/exec, so there is no shell to inject into.
|
||||
//
|
||||
// Layers of defense, outermost first:
|
||||
//
|
||||
// 1. Tokenizer: a command string containing an unquoted shell
|
||||
// metacharacter (| & ; < > ` $ or a newline) is rejected with a clear
|
||||
// message instead of being silently passed through as literal
|
||||
// arguments, which would confuse the model.
|
||||
// 2. Allowlist on the base binary ONLY (first token, no path separators).
|
||||
// Subcommand-level restrictions (go, git) are enforced separately, in
|
||||
// code, below - never by string-matching the allowlist.
|
||||
// 3. Per-binary flag denylist for flags that execute other programs or
|
||||
// follow symlinks (find -exec, go test -exec, grep -R, ...).
|
||||
// 4. Path validation: every argument that could be a path is resolved
|
||||
// (symlinks included) and must land inside WorkspaceRoot.
|
||||
// 5. A scrubbed environment (no DB DSN / SMTP / Stripe secrets reach the
|
||||
// child), a wall-clock timeout, and a cap on captured output.
|
||||
//
|
||||
// Residual risk, stated plainly: `go test` and `go build` compile and run
|
||||
// workspace code, and that code can do anything the enkly process can.
|
||||
// Run enkly in a container (docker-compose.yml does) for real isolation.
|
||||
|
||||
// DefaultAllowedCommands is the default set of base binaries the agent may
|
||||
// run. Override with ENKLY_BASH_ALLOWLIST (comma-separated base binaries).
|
||||
var DefaultAllowedCommands = []string{"go", "git", "ls", "cat", "grep", "find", "gofmt"}
|
||||
|
||||
// allowedSubcommands restricts binaries that have dangerous subcommands.
|
||||
// A binary that is not a key here has no subcommand restriction.
|
||||
var allowedSubcommands = map[string]map[string]bool{
|
||||
"go": {"build": true, "test": true, "vet": true, "fmt": true, "list": true, "version": true, "mod": true},
|
||||
"git": {"status": true, "diff": true, "log": true, "show": true, "branch": true,
|
||||
"rev-parse": true, "ls-files": true, "blame": true},
|
||||
}
|
||||
|
||||
// deniedFlags are flags (matched exactly, or as a "--flag=value" prefix)
|
||||
// that let an otherwise-harmless binary run other programs or follow
|
||||
// symlinks out of the workspace.
|
||||
var deniedFlags = map[string][]string{
|
||||
"find": {"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls", "-L", "-H", "-follow"},
|
||||
"grep": {"-R", "--dereference-recursive"},
|
||||
"go": {"-exec", "-toolexec", "-vettool", "-overlay", "-modfile"},
|
||||
"git": {"--output", "--ext-diff", "--textconv", "-c", "--exec-path", "--git-dir", "--work-tree"},
|
||||
}
|
||||
|
||||
// maxCommandOutput bounds how much stdout+stderr is captured. Anything
|
||||
// beyond it is dropped with a marker, so a runaway `cat` of a huge file
|
||||
// can't balloon memory or flood the model's context.
|
||||
const maxCommandOutput = 64 * 1024
|
||||
|
||||
// ParseAllowlist parses ENKLY_BASH_ALLOWLIST. Entries are base binaries
|
||||
// only; anything with a space or a path separator is rejected so the
|
||||
// ambiguous "go vet" / "go test" style entries can't creep back in.
|
||||
func ParseAllowlist(raw string) ([]string, error) {
|
||||
var out []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(part, " \t/\\") {
|
||||
return nil, fmt.Errorf("allowlist entry %q must be a bare binary name (subcommand rules live in code)", part)
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("allowlist is empty")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *Toolbox) allowed(bin string) bool {
|
||||
list := t.AllowedCommands
|
||||
if len(list) == 0 {
|
||||
list = DefaultAllowedCommands
|
||||
}
|
||||
for _, a := range list {
|
||||
if a == bin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tokenize splits a command line into argv, honoring single quotes,
|
||||
// double quotes and backslash escapes, and refusing unquoted shell
|
||||
// metacharacters.
|
||||
func tokenize(s string) ([]string, error) {
|
||||
var (
|
||||
args []string
|
||||
cur strings.Builder
|
||||
inTok bool
|
||||
quote rune
|
||||
escaped bool
|
||||
)
|
||||
flush := func() {
|
||||
if inTok {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
inTok = false
|
||||
}
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case escaped:
|
||||
cur.WriteRune(r)
|
||||
inTok = true
|
||||
escaped = false
|
||||
case quote == '\'':
|
||||
if r == '\'' {
|
||||
quote = 0
|
||||
} else {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case quote == '"':
|
||||
switch r {
|
||||
case '"':
|
||||
quote = 0
|
||||
case '\\':
|
||||
escaped = true
|
||||
case '$', '`':
|
||||
return nil, fmt.Errorf("%q is not supported inside double quotes (no shell expansion happens here); use single quotes", string(r))
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case r == '\\':
|
||||
escaped = true
|
||||
inTok = true
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
inTok = true
|
||||
case r == ' ' || r == '\t':
|
||||
flush()
|
||||
case strings.ContainsRune("|&;<>`$\n\r*?", r):
|
||||
return nil, fmt.Errorf("shell syntax %q is not supported: commands run directly, without a shell (no pipes, redirects, globbing, substitution or chaining) - run one simple command per call", string(r))
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
inTok = true
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, errors.New("unterminated quote")
|
||||
}
|
||||
if escaped {
|
||||
return nil, errors.New("trailing backslash")
|
||||
}
|
||||
flush()
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func flagDenied(bin, arg string) bool {
|
||||
for _, d := range deniedFlags[bin] {
|
||||
if arg == d || strings.HasPrefix(arg, d+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkArgs validates the argv (already known to start with an allowlisted
|
||||
// binary) and returns a user-facing error describing the first violation.
|
||||
func (t *Toolbox) checkArgs(argv []string) error {
|
||||
bin := argv[0]
|
||||
rest := argv[1:]
|
||||
|
||||
if subs, ok := allowedSubcommands[bin]; ok {
|
||||
if len(rest) == 0 {
|
||||
return fmt.Errorf("%s needs a subcommand; allowed: %s", bin, sortedKeys(subs))
|
||||
}
|
||||
if !subs[rest[0]] {
|
||||
return fmt.Errorf("%s %s is not allowed; allowed subcommands: %s", bin, rest[0], sortedKeys(subs))
|
||||
}
|
||||
}
|
||||
|
||||
// grep's first positional argument is a regex, not a path (unless the
|
||||
// pattern came from -e/--regexp, in which case every positional is a path).
|
||||
skipPattern := bin == "grep"
|
||||
for _, a := range rest {
|
||||
if a == "-e" || strings.HasPrefix(a, "--regexp") {
|
||||
skipPattern = false
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range rest {
|
||||
if flagDenied(bin, a) {
|
||||
return fmt.Errorf("flag %s is not allowed for %s", a, bin)
|
||||
}
|
||||
if strings.HasPrefix(a, "-") && len(a) > 1 {
|
||||
// Flag. Validate a value glued on with "=" or a slash.
|
||||
if k := strings.Index(a, "="); k >= 0 {
|
||||
if err := t.checkPathArg(a[k+1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if k := strings.Index(a, "/"); k >= 0 {
|
||||
if err := t.checkPathArg(a[k:]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if skipPattern {
|
||||
// The first non-flag argument to grep is the pattern.
|
||||
skipPattern = false
|
||||
continue
|
||||
}
|
||||
// `go test ./...`-style package patterns and git revisions are not
|
||||
// files, but they are also harmless to run through the path check:
|
||||
// they resolve inside the workspace or don't exist.
|
||||
if err := t.checkPathArg(a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// checkPathArg rejects an argument that resolves outside WorkspaceRoot.
|
||||
// Arguments that are not paths at all (a package name, a git revision)
|
||||
// simply don't exist on disk and pass as long as they don't look like an
|
||||
// escape (absolute or containing "..").
|
||||
func (t *Toolbox) checkPathArg(arg string) error {
|
||||
if arg == "" {
|
||||
return nil
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(t.WorkspaceRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace root: %w", err)
|
||||
}
|
||||
|
||||
var full string
|
||||
if filepath.IsAbs(arg) {
|
||||
full = filepath.Clean(arg)
|
||||
} else {
|
||||
full = filepath.Join(t.WorkspaceRoot, arg)
|
||||
}
|
||||
looksLikeEscape := filepath.IsAbs(arg) || hasDotDot(arg)
|
||||
|
||||
resolved, exists := evalExisting(full)
|
||||
if !exists && !looksLikeEscape {
|
||||
return nil
|
||||
}
|
||||
if !within(root, resolved) {
|
||||
return fmt.Errorf("argument %q resolves outside the workspace; use paths inside the project", arg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasDotDot(p string) bool {
|
||||
for _, seg := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||
if seg == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// evalExisting resolves symlinks in the longest existing prefix of p and
|
||||
// re-appends the non-existent tail. exists reports whether the whole path
|
||||
// exists.
|
||||
func evalExisting(p string) (resolved string, exists bool) {
|
||||
if r, err := filepath.EvalSymlinks(p); err == nil {
|
||||
return r, true
|
||||
}
|
||||
tail := ""
|
||||
cur := p
|
||||
for {
|
||||
parent := filepath.Dir(cur)
|
||||
tail = filepath.Join(filepath.Base(cur), tail)
|
||||
if r, err := filepath.EvalSymlinks(parent); err == nil {
|
||||
return filepath.Join(r, tail), false
|
||||
}
|
||||
if parent == cur {
|
||||
return p, false
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// within reports whether path is root itself or nested under it, comparing
|
||||
// whole path segments (so /ws-evil is NOT within /ws).
|
||||
func within(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// cappedBuffer is a mutex-guarded writer that stops growing at max bytes.
|
||||
type cappedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (c *cappedBuffer) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
room := c.max - c.buf.Len()
|
||||
if room <= 0 {
|
||||
c.truncated = true
|
||||
return len(p), nil // swallow, so the child never blocks on a full pipe
|
||||
}
|
||||
if len(p) > room {
|
||||
c.buf.Write(p[:room])
|
||||
c.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.buf.Write(p)
|
||||
}
|
||||
|
||||
func (c *cappedBuffer) String() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
s := strings.ToValidUTF8(c.buf.String(), "\uFFFD")
|
||||
if c.truncated {
|
||||
s += fmt.Sprintf("\n... [output truncated at %d bytes]", c.max)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// scrubbedEnv builds the child's environment from a small allowlist so
|
||||
// secrets held by the enkly process (DB DSN, SMTP and Stripe keys) never
|
||||
// reach a command the model controls.
|
||||
func (t *Toolbox) scrubbedEnv() []string {
|
||||
keep := []string{"PATH", "HOME", "LANG", "TMPDIR", "GOPATH", "GOCACHE", "GOMODCACHE", "GOROOT"}
|
||||
env := make([]string, 0, len(keep)+6)
|
||||
for _, k := range keep {
|
||||
if v, ok := os.LookupEnv(k); ok {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
env = append(env,
|
||||
"GOPROXY=off", // the stack must not need the internet
|
||||
"GOTOOLCHAIN=local",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
"GIT_PAGER=cat",
|
||||
"PAGER=cat",
|
||||
)
|
||||
return env
|
||||
}
|
||||
|
||||
func (t *Toolbox) runBashCommand(ctx context.Context, call ToolCallRequest) ToolResult {
|
||||
fail := func(format string, a ...any) ToolResult {
|
||||
return ToolResult{Name: call.Name, Content: "run_bash_command: " + fmt.Sprintf(format, a...), IsError: true}
|
||||
}
|
||||
|
||||
command, _ := call.Arguments["command"].(string)
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return fail("empty command")
|
||||
}
|
||||
|
||||
argv, err := tokenize(command)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
if len(argv) == 0 {
|
||||
return fail("empty command")
|
||||
}
|
||||
|
||||
bin := argv[0]
|
||||
if strings.ContainsAny(bin, `/\`) {
|
||||
return fail("run the binary by name (e.g. %q), not by path", filepath.Base(bin))
|
||||
}
|
||||
if !t.allowed(bin) {
|
||||
list := t.AllowedCommands
|
||||
if len(list) == 0 {
|
||||
list = DefaultAllowedCommands
|
||||
}
|
||||
return fail("%q is not an allowed command; allowed: %s", bin, strings.Join(list, ", "))
|
||||
}
|
||||
if err := t.checkArgs(argv); err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
|
||||
timeout := t.CommandTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultCommandTimeout
|
||||
}
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// argv slice, never a shell string.
|
||||
cmd := exec.CommandContext(cmdCtx, bin, argv[1:]...)
|
||||
cmd.Dir = t.WorkspaceRoot
|
||||
cmd.Env = t.scrubbedEnv()
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
|
||||
out := &cappedBuffer{max: maxCommandOutput}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
|
||||
runErr := cmd.Run()
|
||||
text := out.String()
|
||||
if runErr != nil {
|
||||
msg := runErr.Error()
|
||||
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
|
||||
msg = fmt.Sprintf("timed out after %s", timeout)
|
||||
} else if errors.Is(runErr, exec.ErrNotFound) {
|
||||
msg = fmt.Sprintf("%s is not installed in this environment", bin)
|
||||
}
|
||||
return ToolResult{
|
||||
Name: call.Name,
|
||||
Content: fmt.Sprintf("exit error: %s\n\n%s", msg, text),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return ToolResult{Name: call.Name, Content: text}
|
||||
}
|
||||
764
services/workspace/internal/infrastructure/sandboxfs/tools.go
Normal file
764
services/workspace/internal/infrastructure/sandboxfs/tools.go
Normal file
@@ -0,0 +1,764 @@
|
||||
package sandboxfs
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ignoredDirNames are skipped by every tool that walks the workspace tree
|
||||
// (list_directory, glob_search, grep) — dependency/build/VCS directories
|
||||
// that are almost never useful to an agent browsing a codebase and can be
|
||||
// enormous (node_modules, vendor, .git), which would otherwise blow past
|
||||
// the result caps below on the first call.
|
||||
var ignoredDirNames = map[string]bool{
|
||||
".git": true, "node_modules": true, "vendor": true, ".venv": true,
|
||||
"venv": true, "__pycache__": true, "dist": true, "build": true,
|
||||
".idea": true, ".vscode": true, "target": true, ".next": true,
|
||||
}
|
||||
|
||||
const (
|
||||
// maxReadLines/maxReadFileBytes bound read_file: a local model's
|
||||
// context window is small relative to a real codebase, so reading an
|
||||
// entire large generated file (a lockfile, a minified bundle) would
|
||||
// silently crowd out everything else in the conversation. offset/
|
||||
// limit let the model page through a large file deliberately instead.
|
||||
maxReadLines = 2000
|
||||
maxReadFileSize = 5 * 1024 * 1024 // 5 MiB
|
||||
|
||||
maxWalkEntries = 500 // cap for list_directory / glob_search results
|
||||
maxGrepMatches = 200
|
||||
maxGrepFileSize = 2 * 1024 * 1024
|
||||
|
||||
// diffContextLines is how many unchanged lines of context are shown
|
||||
// before and after each changed block in the diff preview that
|
||||
// edit_file/multi_edit_file return — enough to orient a reviewer
|
||||
// (human or model) without dumping the whole file back into context,
|
||||
// mirroring how a frontier coding agent confirms an edit.
|
||||
diffContextLines = 4
|
||||
// maxHunkChangedLines caps how many changed (-/+) lines a single hunk
|
||||
// prints before eliding the middle, so replacing a huge block doesn't
|
||||
// flood the tool result.
|
||||
maxHunkChangedLines = 120
|
||||
// maxDiffHunksPerEdit caps how many occurrences of a replace_all edit
|
||||
// get a full diff hunk; beyond that, later occurrences are just
|
||||
// counted, not rendered.
|
||||
maxDiffHunksPerEdit = 8
|
||||
)
|
||||
|
||||
// Toolbox executes the filesystem/bash tools on the host machine,
|
||||
// jailed to WorkspaceRoot. This is the same Toolbox the monolith's
|
||||
// internal/llm package had — minus Defs()/Execute() (tool-schema
|
||||
// presentation and dispatch are now the agent service's job; see its
|
||||
// internal/infrastructure/toolcatalog) and minus the Searcher field
|
||||
// (search_codebase is now a direct agent-to-knowledge-service gRPC
|
||||
// call, not something the workspace service's toolbox mediates).
|
||||
type Toolbox struct {
|
||||
WorkspaceRoot string
|
||||
// CommandTimeout bounds how long run_bash_command may run. Overridable
|
||||
// per Toolbox (see NewToolbox / ENKLY_BASH_TIMEOUT_SECONDS in main.go)
|
||||
// since a "go test ./..." or a from-scratch npm install can easily
|
||||
// outrun a timeout tuned for quick commands.
|
||||
CommandTimeout time.Duration
|
||||
// AllowedCommands is the allowlist of base binaries run_bash_command
|
||||
// may execute (first token only; see sandbox.go). Empty means
|
||||
// DefaultAllowedCommands.
|
||||
AllowedCommands []string
|
||||
}
|
||||
|
||||
// DefaultCommandTimeout is how long run_bash_command may run when
|
||||
// ENKLY_BASH_TIMEOUT_SECONDS isn't set — long enough for a typical build
|
||||
// or test run, short enough that a genuinely hung command (e.g. a
|
||||
// process waiting on stdin) doesn't stall the whole agent turn.
|
||||
const DefaultCommandTimeout = 60 * time.Second
|
||||
|
||||
// NewToolbox builds a Toolbox rooted at workspaceRoot (created if missing).
|
||||
func NewToolbox(workspaceRoot string) (*Toolbox, error) {
|
||||
abs, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tools: resolve workspace root: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("tools: create workspace root: %w", err)
|
||||
}
|
||||
return &Toolbox{
|
||||
WorkspaceRoot: abs,
|
||||
CommandTimeout: DefaultCommandTimeout,
|
||||
}, nil
|
||||
}
|
||||
func (t *Toolbox) resolvePath(rel string) (string, error) {
|
||||
cleaned := filepath.Clean("/" + rel) // force it to look absolute-from-root, collapsing ".."
|
||||
full := filepath.Join(t.WorkspaceRoot, cleaned)
|
||||
// Compare whole path segments: a plain HasPrefix would accept a sibling
|
||||
// directory like "<root>-evil".
|
||||
if !within(t.WorkspaceRoot, full) {
|
||||
return "", fmt.Errorf("path %q escapes the workspace root", rel)
|
||||
}
|
||||
// Symlinks inside the workspace must not lead out of it.
|
||||
realRoot, err := filepath.EvalSymlinks(t.WorkspaceRoot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace root: %w", err)
|
||||
}
|
||||
if resolved, _ := evalExisting(full); !within(realRoot, resolved) {
|
||||
return "", fmt.Errorf("path %q escapes the workspace root via a symlink", rel)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// ResolvePath is the exported form of resolvePath, for callers outside
|
||||
// this package that need the exact same jail the read_file/write_file/
|
||||
// etc. tools use — namely the file upload/download HTTP handlers in
|
||||
// internal/handlers, which save/serve files under WorkspaceRoot and must
|
||||
// never let a client-supplied path escape it. Keeping one implementation
|
||||
// here (rather than a second copy of the containment check in handlers)
|
||||
// means both the agent's tools and the HTTP endpoints stay in lockstep if
|
||||
// the jailing logic ever changes.
|
||||
func (t *Toolbox) ResolvePath(rel string) (string, error) {
|
||||
return t.resolvePath(rel)
|
||||
}
|
||||
|
||||
// argInt reads an optional numeric argument. A tool call's "arguments"
|
||||
// JSON string is decoded via encoding/json into map[string]any, which
|
||||
// always produces float64 for a JSON number regardless of the schema's
|
||||
// declared type, so this accepts float64 (the common case) and falls
|
||||
// back to a string (seen from some models that emit stringified
|
||||
// numbers) before giving up.
|
||||
func argInt(args map[string]any, key string) (int, bool) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case string:
|
||||
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func argBool(args map[string]any, key string) bool {
|
||||
v, _ := args[key].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func (t *Toolbox) readFile(call ToolCallRequest) ToolResult {
|
||||
relPath, _ := call.Arguments["path"].(string)
|
||||
full, err := t.resolvePath(relPath)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is a directory, not a file", relPath), IsError: true}
|
||||
}
|
||||
if info.Size() > maxReadFileSize {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is %d bytes, larger than the %d-byte limit — use run_bash_command (e.g. sed/head/grep) to inspect it in pieces instead", relPath, info.Size(), maxReadFileSize), IsError: true}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
offset, _ := argInt(call.Arguments, "offset")
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
}
|
||||
limit, ok := argInt(call.Arguments, "limit")
|
||||
if !ok || limit <= 0 || limit > maxReadLines {
|
||||
limit = maxReadLines
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if offset > len(lines) {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: offset %d is past end of file (%d lines)", offset, len(lines)), IsError: true}
|
||||
}
|
||||
end := offset - 1 + limit
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i := offset - 1; i < end; i++ {
|
||||
fmt.Fprintf(&sb, "%6d\t%s\n", i+1, lines[i])
|
||||
}
|
||||
if end < len(lines) {
|
||||
fmt.Fprintf(&sb, "\n… %d more lines not shown; re-call read_file with offset=%d to continue …\n", len(lines)-end, end+1)
|
||||
}
|
||||
|
||||
return ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
|
||||
func (t *Toolbox) writeFile(call ToolCallRequest) ToolResult {
|
||||
relPath, _ := call.Arguments["path"].(string)
|
||||
content, _ := call.Arguments["content"].(string)
|
||||
|
||||
full, err := t.resolvePath(relPath)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: mkdir: %v", err), IsError: true}
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", len(content), relPath)}
|
||||
}
|
||||
|
||||
func (t *Toolbox) editFile(call ToolCallRequest) ToolResult {
|
||||
relPath, _ := call.Arguments["path"].(string)
|
||||
oldStr, _ := call.Arguments["old_string"].(string)
|
||||
newStr, _ := call.Arguments["new_string"].(string)
|
||||
replaceAll := argBool(call.Arguments, "replace_all")
|
||||
|
||||
if oldStr == "" {
|
||||
return ToolResult{Name: call.Name, Content: "edit_file: old_string must not be empty (use write_file to create a new file)", IsError: true}
|
||||
}
|
||||
if oldStr == newStr {
|
||||
return ToolResult{Name: call.Name, Content: "edit_file: old_string and new_string are identical — nothing to do", IsError: true}
|
||||
}
|
||||
|
||||
full, err := t.resolvePath(relPath)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: %v", err), IsError: true}
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
count := strings.Count(content, oldStr)
|
||||
switch {
|
||||
case count == 0:
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string not found in %s — it must match the file's current content exactly (re-read the file if you're not sure)", relPath), IsError: true}
|
||||
case count > 1 && !replaceAll:
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string appears %d times in %s, so the edit is ambiguous — include more surrounding context to make it unique, or pass replace_all=true if you really mean every occurrence", count, relPath), IsError: true}
|
||||
}
|
||||
|
||||
updated, hunks := applyEditWithDiff(content, oldStr, newStr, replaceAll)
|
||||
|
||||
if err := os.WriteFile(full, []byte(updated), 0o644); err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: write: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
verb := "1 occurrence"
|
||||
if replaceAll && count > 1 {
|
||||
verb = fmt.Sprintf("%d occurrences", count)
|
||||
}
|
||||
header := fmt.Sprintf("edited %s: replaced %s\n\n", relPath, verb)
|
||||
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(hunks)}
|
||||
}
|
||||
|
||||
// multiEditFile applies a sequence of edit_file-style replacements to one
|
||||
// file as a single all-or-nothing operation. Each edit is checked against
|
||||
// the content as it stands after every prior edit in the batch, so later
|
||||
// edits can depend on earlier ones (e.g. renaming a symbol's definition,
|
||||
// then a call site that only exists after the first edit) — but the
|
||||
// moment any edit fails, the function returns without writing anything,
|
||||
// so a batch that's wrong in step 4 of 5 can never leave the file
|
||||
// half-edited.
|
||||
func (t *Toolbox) multiEditFile(call ToolCallRequest) ToolResult {
|
||||
relPath, _ := call.Arguments["path"].(string)
|
||||
rawEdits, ok := call.Arguments["edits"].([]any)
|
||||
if !ok || len(rawEdits) == 0 {
|
||||
return ToolResult{Name: call.Name, Content: "multi_edit_file: \"edits\" must be a non-empty array", IsError: true}
|
||||
}
|
||||
|
||||
type editSpec struct {
|
||||
old, new string
|
||||
replaceAll bool
|
||||
}
|
||||
edits := make([]editSpec, 0, len(rawEdits))
|
||||
for i, raw := range rawEdits {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d] must be an object", i), IsError: true}
|
||||
}
|
||||
oldStr, _ := m["old_string"].(string)
|
||||
newStr, _ := m["new_string"].(string)
|
||||
if oldStr == "" {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string must not be empty", i), IsError: true}
|
||||
}
|
||||
if oldStr == newStr {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string and new_string are identical — nothing to do", i), IsError: true}
|
||||
}
|
||||
edits = append(edits, editSpec{old: oldStr, new: newStr, replaceAll: argBool(m, "replace_all")})
|
||||
}
|
||||
|
||||
full, err := t.resolvePath(relPath)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
var allHunks []string
|
||||
totalReplacements := 0
|
||||
for i, e := range edits {
|
||||
count := strings.Count(content, e.old)
|
||||
switch {
|
||||
case count == 0:
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
|
||||
"multi_edit_file: edits[%d]: old_string not found (after applying edits[0:%d]) — no changes were written to %s; the whole batch is atomic, so fix this edit and retry from the top", i, i, relPath), IsError: true}
|
||||
case count > 1 && !e.replaceAll:
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
|
||||
"multi_edit_file: edits[%d]: old_string appears %d times (after applying edits[0:%d]), so it's ambiguous — no changes were written to %s; add context or set this edit's replace_all", i, count, i, relPath), IsError: true}
|
||||
}
|
||||
updated, hunks := applyEditWithDiff(content, e.old, e.new, e.replaceAll)
|
||||
allHunks = append(allHunks, hunks...)
|
||||
content = updated
|
||||
if e.replaceAll {
|
||||
totalReplacements += count
|
||||
} else {
|
||||
totalReplacements++
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: write: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("edited %s: applied %d edit(s), %d replacement(s)\n\n", relPath, len(edits), totalReplacements)
|
||||
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(allHunks)}
|
||||
}
|
||||
|
||||
// applyEditWithDiff replaces old with new in content — every occurrence if
|
||||
// replaceAll, otherwise the sole occurrence (callers must already have
|
||||
// checked count/ambiguity themselves) — and returns the updated content
|
||||
// alongside a unified-diff-style hunk for each replacement performed. This
|
||||
// is shared by edit_file and multi_edit_file so both tools report changes
|
||||
// the same way.
|
||||
func applyEditWithDiff(content, oldStr, newStr string, replaceAll bool) (updated string, hunks []string) {
|
||||
if !replaceAll {
|
||||
idx := strings.Index(content, oldStr)
|
||||
if idx < 0 { // shouldn't happen; callers check first, but stay safe
|
||||
return content, nil
|
||||
}
|
||||
return content[:idx] + newStr + content[idx+len(oldStr):], []string{diffHunk(content, idx, oldStr, newStr)}
|
||||
}
|
||||
|
||||
total := strings.Count(content, oldStr)
|
||||
var sb strings.Builder
|
||||
pos, shown := 0, 0
|
||||
for {
|
||||
idx := strings.Index(content[pos:], oldStr)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
abs := pos + idx
|
||||
if shown < maxDiffHunksPerEdit {
|
||||
hunks = append(hunks, diffHunk(content, abs, oldStr, newStr))
|
||||
shown++
|
||||
}
|
||||
sb.WriteString(content[pos:abs])
|
||||
sb.WriteString(newStr)
|
||||
pos = abs + len(oldStr)
|
||||
}
|
||||
sb.WriteString(content[pos:])
|
||||
if total > shown {
|
||||
hunks = append(hunks, fmt.Sprintf("… %d further occurrence(s) replaced but not individually shown …", total-shown))
|
||||
}
|
||||
return sb.String(), hunks
|
||||
}
|
||||
|
||||
// renderDiffHunks joins hunks with a small separator so multiple
|
||||
// non-adjacent changes in one tool result read clearly as distinct
|
||||
// hunks rather than one continuous block.
|
||||
func renderDiffHunks(hunks []string) string {
|
||||
if len(hunks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(hunks, " ⋮\n")
|
||||
}
|
||||
|
||||
// diffHunk renders one unified-diff-style hunk for replacing old with new
|
||||
// at byte offset `at` in content, expanded outward to whole lines (so a
|
||||
// hunk never splits a line mid-way even though old/new themselves may
|
||||
// start or end mid-line) and padded with up to diffContextLines lines of
|
||||
// unchanged context on each side. It's built directly from the known
|
||||
// old/new span — rather than a general line-diff algorithm — since
|
||||
// edit_file's contract already guarantees the exact substring replaced.
|
||||
func diffHunk(content string, at int, oldStr, newStr string) string {
|
||||
lineStart, lineEnd := wholeLineRange(content, at, len(oldStr))
|
||||
oldBlock := content[lineStart:lineEnd]
|
||||
newBlock := oldBlock[:at-lineStart] + newStr + oldBlock[at-lineStart+len(oldStr):]
|
||||
|
||||
ctxStart := expandLinesBack(content, lineStart, diffContextLines)
|
||||
ctxEnd := expandLinesForward(content, lineEnd, diffContextLines)
|
||||
lineNo := strings.Count(content[:ctxStart], "\n") + 1
|
||||
|
||||
var sb strings.Builder
|
||||
lineNo = writeDiffBlock(&sb, linesOf(content[ctxStart:lineStart]), " ", lineNo)
|
||||
oldEnd := writeDiffBlock(&sb, linesOf(oldBlock), "-", lineNo)
|
||||
writeDiffBlock(&sb, linesOf(newBlock), "+", lineNo)
|
||||
writeDiffBlock(&sb, linesOf(content[lineEnd:ctxEnd]), " ", oldEnd)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// wholeLineRange returns the [start,end) byte offsets of the smallest run
|
||||
// of whole lines in content that fully contains the byte range
|
||||
// [at, at+length), including the trailing newline of the last line (or
|
||||
// running to EOF if there isn't one).
|
||||
func wholeLineRange(content string, at, length int) (start, end int) {
|
||||
start = strings.LastIndexByte(content[:at], '\n') + 1 // 0 if none found
|
||||
end = at + length
|
||||
if idx := strings.IndexByte(content[end:], '\n'); idx >= 0 {
|
||||
end += idx + 1
|
||||
} else {
|
||||
end = len(content)
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func expandLinesBack(content string, pos, n int) int {
|
||||
for i := 0; i < n && pos > 0; i++ {
|
||||
pos = strings.LastIndexByte(content[:pos-1], '\n') + 1
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func expandLinesForward(content string, pos, n int) int {
|
||||
for i := 0; i < n && pos < len(content); i++ {
|
||||
idx := strings.IndexByte(content[pos:], '\n')
|
||||
if idx < 0 {
|
||||
return len(content)
|
||||
}
|
||||
pos += idx + 1
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// linesOf splits a whole-lines block (as produced by wholeLineRange) into
|
||||
// its constituent lines, dropping the trailing empty element a final "\n"
|
||||
// would otherwise produce.
|
||||
func linesOf(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
|
||||
}
|
||||
|
||||
// writeDiffBlock prints lines with a diff marker ("-", "+", or " ") and
|
||||
// 1-based line numbers, eliding the middle of very long blocks (see
|
||||
// maxHunkChangedLines) so a giant replacement doesn't flood the result.
|
||||
// It returns the next line number after this block.
|
||||
func writeDiffBlock(sb *strings.Builder, lines []string, marker string, lineNo int) int {
|
||||
if len(lines) > maxHunkChangedLines {
|
||||
half := maxHunkChangedLines / 2
|
||||
for i := 0; i < half; i++ {
|
||||
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
|
||||
}
|
||||
fmt.Fprintf(sb, " %s … %d lines omitted …\n", marker, len(lines)-maxHunkChangedLines)
|
||||
for i := len(lines) - half; i < len(lines); i++ {
|
||||
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
|
||||
}
|
||||
} else {
|
||||
for i, l := range lines {
|
||||
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, l)
|
||||
}
|
||||
}
|
||||
return lineNo + len(lines)
|
||||
}
|
||||
|
||||
func (t *Toolbox) listDirectory(call ToolCallRequest) ToolResult {
|
||||
relPath, _ := call.Arguments["path"].(string)
|
||||
if strings.TrimSpace(relPath) == "" {
|
||||
relPath = "."
|
||||
}
|
||||
maxDepth, ok := argInt(call.Arguments, "max_depth")
|
||||
if !ok || maxDepth <= 0 {
|
||||
maxDepth = 3
|
||||
}
|
||||
|
||||
root, err := t.resolvePath(relPath)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %q is not a directory in this workspace", relPath), IsError: true}
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
rel string
|
||||
isDir bool
|
||||
}
|
||||
var entries []entry
|
||||
truncated := false
|
||||
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable entries rather than failing the whole listing
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
depth := strings.Count(rel, string(filepath.Separator)) + 1
|
||||
|
||||
if d.IsDir() && ignoredDirNames[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if depth > maxDepth {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(entries) >= maxWalkEntries {
|
||||
truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
entries = append(entries, entry{rel: filepath.ToSlash(rel), isDir: d.IsDir()})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].isDir != entries[j].isDir {
|
||||
return entries[i].isDir // directories first
|
||||
}
|
||||
return entries[i].rel < entries[j].rel
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "%s/\n", strings.TrimSuffix(relPath, "/"))
|
||||
for _, e := range entries {
|
||||
suffix := ""
|
||||
if e.isDir {
|
||||
suffix = "/"
|
||||
}
|
||||
fmt.Fprintf(&sb, " %s%s\n", e.rel, suffix)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
sb.WriteString(" (empty)\n")
|
||||
}
|
||||
if truncated {
|
||||
fmt.Fprintf(&sb, "\n… output truncated at %d entries; narrow `path` or `max_depth` for a fuller view of one area …\n", maxWalkEntries)
|
||||
}
|
||||
return ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
|
||||
// segmentToRegexp converts one path segment (no "/" in it) of a glob into
|
||||
// regexp source, treating `*` as "within this segment" and `?` as a single
|
||||
// rune, and escaping everything else.
|
||||
func segmentToRegexp(seg string) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range seg {
|
||||
switch r {
|
||||
case '*':
|
||||
sb.WriteString("[^/]*")
|
||||
case '?':
|
||||
sb.WriteString("[^/]")
|
||||
default:
|
||||
sb.WriteString(regexp.QuoteMeta(string(r)))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// globToRegexp compiles a shell-style glob into an anchored regexp over
|
||||
// forward-slash-separated relative paths. `*` matches within one path
|
||||
// segment; `?` matches a single rune within a segment; a `**` segment
|
||||
// matches zero or more whole path segments, so "**/*.go" matches both
|
||||
// "main.go" at the root and "internal/llm/tools.go" — the standard
|
||||
// "globstar" behavior, which a naive character-by-character translation
|
||||
// (treating `**` as ".*" in place) gets wrong for the common leading-`**/`
|
||||
// case, since it would then require a literal "/" for a root-level match.
|
||||
func globToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = filepath.ToSlash(strings.TrimPrefix(pattern, "./"))
|
||||
segments := strings.Split(pattern, "/")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("^")
|
||||
for i, seg := range segments {
|
||||
last := i == len(segments)-1
|
||||
if seg == "**" {
|
||||
if last {
|
||||
sb.WriteString(".*") // trailing "**" matches everything under here, any depth
|
||||
} else {
|
||||
sb.WriteString(`(?:[^/]+/)*`) // zero or more whole segments, each including its "/"
|
||||
}
|
||||
continue
|
||||
}
|
||||
sb.WriteString(segmentToRegexp(seg))
|
||||
if !last {
|
||||
sb.WriteString("/")
|
||||
}
|
||||
}
|
||||
sb.WriteString("$")
|
||||
return regexp.Compile(sb.String())
|
||||
}
|
||||
|
||||
func (t *Toolbox) globSearch(call ToolCallRequest) ToolResult {
|
||||
pattern, _ := call.Arguments["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return ToolResult{Name: call.Name, Content: "glob_search: empty pattern", IsError: true}
|
||||
}
|
||||
re, err := globToRegexp(pattern)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: invalid pattern: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
var matches []string
|
||||
truncated := false
|
||||
err = filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if path == t.WorkspaceRoot {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if ignoredDirNames[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !re.MatchString(rel) {
|
||||
return nil
|
||||
}
|
||||
if len(matches) >= maxWalkEntries {
|
||||
truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
matches = append(matches, rel)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
sort.Strings(matches)
|
||||
if len(matches) == 0 {
|
||||
return ToolResult{Name: call.Name, Content: "no files matched that pattern"}
|
||||
}
|
||||
out := strings.Join(matches, "\n")
|
||||
if truncated {
|
||||
out += fmt.Sprintf("\n\n… truncated at %d matches; narrow the pattern for a fuller view …", maxWalkEntries)
|
||||
}
|
||||
return ToolResult{Name: call.Name, Content: out}
|
||||
}
|
||||
|
||||
func (t *Toolbox) grep(call ToolCallRequest) ToolResult {
|
||||
pattern, _ := call.Arguments["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return ToolResult{Name: call.Name, Content: "grep: empty pattern", IsError: true}
|
||||
}
|
||||
if argBool(call.Arguments, "case_insensitive") && !strings.HasPrefix(pattern, "(?i)") {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid regular expression: %v", err), IsError: true}
|
||||
}
|
||||
|
||||
var pathRe *regexp.Regexp
|
||||
if pathGlob, _ := call.Arguments["path_glob"].(string); strings.TrimSpace(pathGlob) != "" {
|
||||
pathRe, err = globToRegexp(pathGlob)
|
||||
if err != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid path_glob: %v", err), IsError: true}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
matchCount := 0
|
||||
stop := false
|
||||
|
||||
walkErr := filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || stop {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != t.WorkspaceRoot && ignoredDirNames[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if pathRe != nil && !pathRe.MatchString(rel) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil || info.Size() > maxGrepFileSize || info.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := scanner.Text()
|
||||
if lineNo == 1 && strings.ContainsRune(line, 0) {
|
||||
break // looks binary (NUL byte on the first line) — skip it
|
||||
}
|
||||
if re.MatchString(line) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if len(trimmed) > 300 {
|
||||
trimmed = trimmed[:300] + "…"
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s:%d: %s\n", rel, lineNo, trimmed)
|
||||
matchCount++
|
||||
if matchCount >= maxGrepMatches {
|
||||
stop = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: %v", walkErr), IsError: true}
|
||||
}
|
||||
|
||||
if matchCount == 0 {
|
||||
return ToolResult{Name: call.Name, Content: "no matches found"}
|
||||
}
|
||||
out := sb.String()
|
||||
if stop {
|
||||
out += fmt.Sprintf("\n… truncated at %d matches; narrow `pattern` or `path_glob` …\n", maxGrepMatches)
|
||||
}
|
||||
return ToolResult{Name: call.Name, Content: out}
|
||||
}
|
||||
|
||||
// writeTodos doesn't touch the filesystem at all — it just validates and
|
||||
// reformats the model's plan into a checklist. The tool call + result is
|
||||
// persisted to the messages/tool_calls tables exactly like any other tool
|
||||
// (see internal/handlers' chat flow), which is what makes the plan visible
|
||||
// to the user as it updates, and gives the model a compact record of its
|
||||
// own plan to refer back to on later rounds.
|
||||
@@ -0,0 +1,21 @@
|
||||
package sandboxfs
|
||||
|
||||
// ToolCallRequest and ToolResult are local equivalents of the
|
||||
// monolith's internal/models.ToolCallRequest/ToolResult — kept as
|
||||
// plain structs here (rather than importing a shared models package)
|
||||
// so this package's only dependency is the Go standard library, the
|
||||
// same isolation internal/llm/tools.go itself had from internal/db,
|
||||
// internal/handlers, etc. The workspace gRPC server (see
|
||||
// internal/interfaces/grpc) is what actually constructs/consumes
|
||||
// these, translating to and from the generated workspacev1 proto
|
||||
// types.
|
||||
type ToolCallRequest struct {
|
||||
Name string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
Name string
|
||||
Content string
|
||||
IsError bool
|
||||
}
|
||||
189
services/workspace/internal/interfaces/grpc/files.go
Normal file
189
services/workspace/internal/interfaces/grpc/files.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
)
|
||||
|
||||
// ignoredWorkspaceDirNames mirrors sandboxfs's own ignoredDirNames
|
||||
// (unexported there): dependency/build/VCS directories that are noise
|
||||
// in a file *listing* the same way they're noise for the agent's own
|
||||
// list_directory/glob_search tools. Display-only — nothing security
|
||||
// sensitive depends on it, since every path below still goes through
|
||||
// Toolbox.ResolvePath regardless of what Tree skips.
|
||||
var ignoredWorkspaceDirNames = map[string]bool{
|
||||
".git": true, "node_modules": true, "vendor": true, ".venv": true,
|
||||
"venv": true, "__pycache__": true, "dist": true, "build": true,
|
||||
".idea": true, ".vscode": true, "target": true, ".next": true,
|
||||
}
|
||||
|
||||
// maxWorkspaceFilesListed caps how many files Tree returns, mirroring
|
||||
// internal/handlers/files.go's identical cap for the same reason: a
|
||||
// workspace with an enormous tree shouldn't produce an enormous
|
||||
// sidebar (or an enormous gRPC response).
|
||||
const maxWorkspaceFilesListed = 500
|
||||
|
||||
// UploadFile replaces Handlers.saveUploadedFile. The gateway has
|
||||
// already done every HTTP-specific part (multipart parsing, per-file
|
||||
// size limits, CSRF, stripping any directory component the browser
|
||||
// supplied) — see services/gateway/internal/handlers/files.go — and
|
||||
// hands this RPC just a clean relative destination path and the bytes.
|
||||
func (s *Server) UploadFile(ctx context.Context, req *workspacev1.UploadFileRequest) (*workspacev1.UploadFileResponse, error) {
|
||||
full, err := s.Toolbox.ResolvePath(req.GetPath())
|
||||
if err != nil {
|
||||
return nil, grpcerr.InvalidArgument(err.Error())
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, req.GetContent(), 0o644); err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &workspacev1.UploadFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
|
||||
}
|
||||
|
||||
// DownloadFile replaces Handlers.DownloadFile's file-reading half (the
|
||||
// gateway still owns the actual HTTP streaming/Content-Disposition
|
||||
// response).
|
||||
func (s *Server) DownloadFile(ctx context.Context, req *workspacev1.DownloadFileRequest) (*workspacev1.DownloadFileResponse, error) {
|
||||
full, err := s.Toolbox.ResolvePath(req.GetPath())
|
||||
if err != nil {
|
||||
return nil, grpcerr.NotFound("file")
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, grpcerr.NotFound("file")
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &workspacev1.DownloadFileResponse{Content: data, Filename: filepath.Base(full)}, nil
|
||||
}
|
||||
|
||||
// Tree replaces Handlers.listWorkspaceFiles (one_level = false, the
|
||||
// default) AND the directory-listing half of Handlers.FileTree
|
||||
// (one_level = true) — see TreeRequest's doc comment in the proto.
|
||||
func (s *Server) Tree(ctx context.Context, req *workspacev1.TreeRequest) (*workspacev1.TreeResponse, error) {
|
||||
if req.GetOneLevel() {
|
||||
return s.treeOneLevel(req.GetPath())
|
||||
}
|
||||
return s.treeRecursive()
|
||||
}
|
||||
|
||||
func (s *Server) treeOneLevel(relDir string) (*workspacev1.TreeResponse, error) {
|
||||
full, err := s.Toolbox.ResolvePath(relDir)
|
||||
if err != nil {
|
||||
return nil, grpcerr.InvalidArgument(err.Error())
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, grpcerr.NotFound("directory")
|
||||
}
|
||||
entries, err := os.ReadDir(full)
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
|
||||
var nodes []*workspacev1.TreeNode
|
||||
for _, e := range entries {
|
||||
if e.Type()&fs.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if e.IsDir() && ignoredWorkspaceDirNames[name] {
|
||||
continue
|
||||
}
|
||||
childRel := name
|
||||
if relDir != "" {
|
||||
childRel = relDir + "/" + name
|
||||
}
|
||||
node := &workspacev1.TreeNode{Name: name, RelPath: childRel, IsDir: e.IsDir()}
|
||||
if !e.IsDir() {
|
||||
if fi, err := e.Info(); err == nil {
|
||||
node.Size = fi.Size()
|
||||
node.ModifiedAt = timestamppb.New(fi.ModTime())
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
// Directories first, then files, each alphabetical — matches
|
||||
// readTreeChildren's ordering exactly.
|
||||
sort.SliceStable(nodes, func(i, j int) bool {
|
||||
if nodes[i].IsDir != nodes[j].IsDir {
|
||||
return nodes[i].IsDir
|
||||
}
|
||||
return nodes[i].Name < nodes[j].Name
|
||||
})
|
||||
if len(nodes) > maxWorkspaceFilesListed {
|
||||
nodes = nodes[:maxWorkspaceFilesListed]
|
||||
}
|
||||
return &workspacev1.TreeResponse{Nodes: nodes}, nil
|
||||
}
|
||||
|
||||
func (s *Server) treeRecursive() (*workspacev1.TreeResponse, error) {
|
||||
root := s.Toolbox.WorkspaceRoot
|
||||
var nodes []*workspacev1.TreeNode
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable entries rather than failing the whole listing
|
||||
}
|
||||
if p == root {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if ignoredWorkspaceDirNames[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, p)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
info, infoErr := d.Info()
|
||||
if infoErr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
nodes = append(nodes, &workspacev1.TreeNode{
|
||||
Name: d.Name(), RelPath: rel, IsDir: false, Size: info.Size(),
|
||||
ModifiedAt: timestamppb.New(info.ModTime()),
|
||||
})
|
||||
if len(nodes) >= maxWorkspaceFilesListed {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].RelPath < nodes[j].RelPath })
|
||||
return &workspacev1.TreeResponse{Nodes: nodes}, nil
|
||||
}
|
||||
|
||||
// StatFile replaces the Agent.Toolbox.ResolvePath + os.Stat pairing
|
||||
// internal/handlers/deliverables.go used to decide whether a tool call
|
||||
// "produced" a downloadable file.
|
||||
func (s *Server) StatFile(ctx context.Context, req *workspacev1.StatFileRequest) (*workspacev1.StatFileResponse, error) {
|
||||
full, err := s.Toolbox.ResolvePath(req.GetPath())
|
||||
if err != nil {
|
||||
return &workspacev1.StatFileResponse{Exists: false}, nil
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return &workspacev1.StatFileResponse{Exists: false}, nil
|
||||
}
|
||||
return &workspacev1.StatFileResponse{
|
||||
Exists: true, IsDir: info.IsDir(), Size: info.Size(),
|
||||
ModifiedAt: timestamppb.New(info.ModTime()),
|
||||
}, nil
|
||||
}
|
||||
97
services/workspace/internal/interfaces/grpc/server.go
Normal file
97
services/workspace/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package grpc adapts sandboxfs.Toolbox onto the generated
|
||||
// workspacev1.WorkspaceServiceServer interface. Every RPC here either
|
||||
// replaces one agent tool (internal/llm/tools.go) or one human-facing
|
||||
// files-panel endpoint (internal/handlers/files.go) from the monolith —
|
||||
// see each method's doc comment.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/services/workspace/internal/infrastructure/sandboxfs"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
workspacev1.UnimplementedWorkspaceServiceServer
|
||||
Toolbox *sandboxfs.Toolbox
|
||||
}
|
||||
|
||||
func New(tb *sandboxfs.Toolbox) *Server { return &Server{Toolbox: tb} }
|
||||
|
||||
func (s *Server) ReadFile(ctx context.Context, req *workspacev1.ReadFileRequest) (*workspacev1.ReadFileResponse, error) {
|
||||
res := s.Toolbox.ReadFile(req.GetPath(), int(req.GetOffset()), int(req.GetLimit()))
|
||||
if res.IsError {
|
||||
return &workspacev1.ReadFileResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.ReadFileResponse{Content: res.Content}, nil
|
||||
}
|
||||
|
||||
func (s *Server) WriteFile(ctx context.Context, req *workspacev1.WriteFileRequest) (*workspacev1.WriteFileResponse, error) {
|
||||
res := s.Toolbox.WriteFile(req.GetPath(), req.GetContent())
|
||||
if res.IsError {
|
||||
return &workspacev1.WriteFileResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.WriteFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
|
||||
}
|
||||
|
||||
func (s *Server) EditFile(ctx context.Context, req *workspacev1.EditFileRequest) (*workspacev1.EditFileResponse, error) {
|
||||
res := s.Toolbox.EditFile(req.GetPath(), req.GetOldString(), req.GetNewString(), req.GetReplaceAll())
|
||||
if res.IsError {
|
||||
return &workspacev1.EditFileResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.EditFileResponse{DiffPreview: res.Content}, nil
|
||||
}
|
||||
|
||||
func (s *Server) MultiEditFile(ctx context.Context, req *workspacev1.MultiEditFileRequest) (*workspacev1.MultiEditFileResponse, error) {
|
||||
edits := make([]sandboxfs.WorkspaceEdit, 0, len(req.GetEdits()))
|
||||
for _, e := range req.GetEdits() {
|
||||
edits = append(edits, sandboxfs.WorkspaceEdit{
|
||||
OldString: e.GetOldString(), NewString: e.GetNewString(), ReplaceAll: e.GetReplaceAll(),
|
||||
})
|
||||
}
|
||||
res := s.Toolbox.MultiEditFile(req.GetPath(), edits)
|
||||
if res.IsError {
|
||||
return &workspacev1.MultiEditFileResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.MultiEditFileResponse{DiffPreview: res.Content}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListDirectory(ctx context.Context, req *workspacev1.ListDirectoryRequest) (*workspacev1.ListDirectoryResponse, error) {
|
||||
res := s.Toolbox.ListDirectory(req.GetPath())
|
||||
if res.IsError {
|
||||
return &workspacev1.ListDirectoryResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.ListDirectoryResponse{Listing: res.Content}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GlobSearch(ctx context.Context, req *workspacev1.GlobSearchRequest) (*workspacev1.GlobSearchResponse, error) {
|
||||
res := s.Toolbox.GlobSearch(req.GetPattern(), req.GetPath())
|
||||
if res.IsError {
|
||||
return &workspacev1.GlobSearchResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.GlobSearchResponse{Output: res.Content}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Grep(ctx context.Context, req *workspacev1.GrepRequest) (*workspacev1.GrepResponse, error) {
|
||||
res := s.Toolbox.Grep(req.GetPattern(), req.GetPath(), req.GetGlob(), req.GetCaseInsensitive())
|
||||
if res.IsError {
|
||||
return &workspacev1.GrepResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.GrepResponse{Output: res.Content}, nil
|
||||
}
|
||||
|
||||
// RunBashCommand replaces the run_bash_command tool. req.TimeoutSeconds
|
||||
// is accepted but not applied per-call — the monolith's sandbox only
|
||||
// ever used one process-wide timeout (ENKLY_BASH_TIMEOUT_SECONDS /
|
||||
// Toolbox.CommandTimeout, set once at startup; see cmd/server/main.go),
|
||||
// and a per-call override wasn't a feature of the original tool, so
|
||||
// introducing one here would be a behavior change, not a neutral
|
||||
// restructuring.
|
||||
func (s *Server) RunBashCommand(ctx context.Context, req *workspacev1.RunBashCommandRequest) (*workspacev1.RunBashCommandResponse, error) {
|
||||
res := s.Toolbox.RunBashCommand(ctx, req.GetCommand())
|
||||
if res.IsError {
|
||||
return &workspacev1.RunBashCommandResponse{IsError: true, ErrorMessage: res.Content}, nil
|
||||
}
|
||||
return &workspacev1.RunBashCommandResponse{Output: res.Content}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user