This commit is contained in:
60
.dockerignore
Normal file
60
.dockerignore
Normal file
@@ -0,0 +1,60 @@
|
||||
.git/
|
||||
.gitea/
|
||||
|
||||
# --- Secrets: never let these reach the Docker daemon ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- Other services' sources (each build only needs its own) ---
|
||||
# Deliberately NOT excluded here because the Dockerfiles already COPY
|
||||
# only the one services/<name>/ directory they need — Docker's build
|
||||
# context still includes the rest unless listed below. Uncomment the
|
||||
# ones you don't need for a given service's build, or leave as-is for
|
||||
# simplicity (COPY-level filtering already prevents cross-contamination).
|
||||
# services/agent/
|
||||
# services/billing/
|
||||
# services/conversation/
|
||||
# services/gateway/
|
||||
# services/iam/
|
||||
# services/knowledge/
|
||||
# services/notification/
|
||||
# services/workspace/
|
||||
|
||||
# --- Docs (not needed inside any image) ---
|
||||
*.md
|
||||
ARCHITECTURE.md
|
||||
CHANGES.md
|
||||
README.md
|
||||
UI-REDESIGN.md
|
||||
|
||||
# --- Kubernetes / CI / infra manifests (not part of any Go build) ---
|
||||
k8s/
|
||||
.gitea/workflows/
|
||||
docker-compose.yml
|
||||
docker-compose.*.yml
|
||||
|
||||
# --- Go build artifacts / local tooling ---
|
||||
/bin/
|
||||
/dist/
|
||||
*.test
|
||||
*.out
|
||||
coverage.out
|
||||
coverage.html
|
||||
/tools/bin/
|
||||
go.work
|
||||
go.work.sum
|
||||
/vendor/
|
||||
|
||||
# --- Editor / OS cruft ---
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Misc ---
|
||||
*.log
|
||||
tmp/
|
||||
211
.gitea/workflows/ci.yaml
Normal file
211
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,211 @@
|
||||
name: Go CI Pipeline
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Start a Docker-in-Docker service container
|
||||
services:
|
||||
docker:
|
||||
image: docker:dind
|
||||
# --dns pins the dind container to reliable public resolvers instead
|
||||
# of whatever it inherits from the runner host. Without this, the
|
||||
# embedded 127.0.0.11 resolver inside dind intermittently fails to
|
||||
# forward lookups for the registry host ("server misbehaving"),
|
||||
# which otherwise kills `docker push` nondeterministically.
|
||||
options: --privileged --dns 1.1.1.1 --dns 8.8.8.8
|
||||
env:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
# Point docker CLI to the dind container
|
||||
env:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod tidy && go mod download
|
||||
|
||||
- name: Vet and test
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
- name: Login to private registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push container images
|
||||
run: |
|
||||
set -e
|
||||
REG="${{ secrets.REGISTRY_URL }}/enkly"
|
||||
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||||
|
||||
# Retry helper: docker push against the dind service occasionally
|
||||
# hits a transient "server misbehaving" DNS failure (127.0.0.11
|
||||
# couldn't resolve the registry host) rather than a real error.
|
||||
# Retry a few times with backoff before giving up for real.
|
||||
push_with_retry() {
|
||||
local ref="$1"
|
||||
local attempt=1
|
||||
local max_attempts=5
|
||||
until docker push "$ref"; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::docker push $ref failed after $max_attempts attempts"
|
||||
return 1
|
||||
fi
|
||||
local wait=$((attempt * 5))
|
||||
echo "push of $ref failed (attempt $attempt/$max_attempts); retrying in ${wait}s..."
|
||||
sleep "$wait"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
for svc in iam billing conversation workspace knowledge agent notification gateway; do
|
||||
docker build -f services/$svc/Dockerfile -t "$REG/$svc:$SHA" -t "$REG/$svc:latest" .
|
||||
push_with_retry "$REG/$svc:$SHA"
|
||||
push_with_retry "$REG/$svc:latest"
|
||||
done
|
||||
|
||||
- name: Build and push vLLM CPU (no AVX512) image
|
||||
# This is NOT one of the 8 enkly service images above — it's the
|
||||
# custom vllm/vllm-openai build that k8s/11-vllm.yaml's vllm-chat
|
||||
# and vllm-embed Deployments require instead of the official
|
||||
# public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo image, because
|
||||
# that pre-built image hard-requires avx512f (missing on the
|
||||
# target cluster's nodes) and crashes with "Illegal instruction"
|
||||
# without it. Source and build args match k8s/11-vllm.yaml's and
|
||||
# k8s/README.md's documented command, plus an explicit max_jobs
|
||||
# cap explained below.
|
||||
#
|
||||
# Root cause of the previous failure ("Cannot connect to the
|
||||
# Docker daemon at tcp://localhost:2375" mid-build): vLLM's
|
||||
# docker/Dockerfile.cpu defaults to `ARG max_jobs=32`, i.e. 32
|
||||
# parallel C++ translation units (oneDNN/cutlass kernels etc.)
|
||||
# compiling at once, each easily using >1GB RES. The dind
|
||||
# service here — like the target cluster's own nodes (see
|
||||
# k8s/README.md: ~7949Mi/~7.76Gi per node) — only has a few GB
|
||||
# of RAM and 2 vCPUs, nowhere near enough for 32-way parallel
|
||||
# compilation. The build log showed exactly this: individual
|
||||
# objects going from ~1-10s to 317s (heavy swapping) right
|
||||
# before the dind container's dockerd was OOM-killed, which
|
||||
# drops the daemon out from under the still-running `docker
|
||||
# build` and surfaces as a generic "cannot connect" error rather
|
||||
# than an explicit OOM message. A plain retry doesn't fix this —
|
||||
# dockerd itself died, so the service container is gone — only
|
||||
# bounding parallelism does. We size MAX_JOBS off the runner's
|
||||
# actual CPU count and memory (~2GB headroom per job) instead of
|
||||
# trusting the Dockerfile's hardcoded default, floor 1.
|
||||
#
|
||||
# Caveat: this clones and compiles vLLM from source on every run
|
||||
# of this job, on a small runner — expect this step alone to
|
||||
# take a long time and to be the slowest part of the pipeline.
|
||||
# If that becomes a problem, the usual fixes are: only run this
|
||||
# step on a path filter scoped to a pinned vLLM version/tag
|
||||
# instead of vLLM's default branch, add Docker layer caching
|
||||
# (e.g. actions/cache or a registry-backed BuildKit cache), or
|
||||
# move it to its own workflow that isn't triggered on every
|
||||
# push/PR. We already pin the clone to a tagged vLLM release
|
||||
# below so a build is reproducible and isn't broken by upstream
|
||||
# `main` changing under us.
|
||||
run: |
|
||||
set -e
|
||||
REG="${{ secrets.REGISTRY_URL }}/enkly"
|
||||
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||||
VLLM_REF="v0.8.5"
|
||||
|
||||
push_with_retry() {
|
||||
local ref="$1"
|
||||
local attempt=1
|
||||
local max_attempts=5
|
||||
until docker push "$ref"; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::docker push $ref failed after $max_attempts attempts"
|
||||
return 1
|
||||
fi
|
||||
local wait=$((attempt * 5))
|
||||
echo "push of $ref failed (attempt $attempt/$max_attempts); retrying in ${wait}s..."
|
||||
sleep "$wait"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# Make sure the dind daemon is actually reachable before we
|
||||
# kick off a build that can run for tens of minutes.
|
||||
for i in $(seq 1 30); do
|
||||
docker info >/dev/null 2>&1 && break
|
||||
echo "waiting for docker daemon ($i/30)..."
|
||||
sleep 2
|
||||
done
|
||||
docker info >/dev/null 2>&1 || { echo "::error::docker daemon at $DOCKER_HOST is not reachable"; exit 1; }
|
||||
|
||||
# Cap compile parallelism to what this runner can actually
|
||||
# hold in memory, instead of the Dockerfile's hardcoded
|
||||
# max_jobs=32 (see note above for why that OOM-kills dockerd).
|
||||
# Budget ~2GB per compile job and never exceed the vCPU count.
|
||||
CPU_COUNT="$(nproc 2>/dev/null || echo 2)"
|
||||
MEM_KB="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)"
|
||||
MEM_JOBS=$((MEM_KB / 1024 / 1024 / 2))
|
||||
MAX_JOBS="$CPU_COUNT"
|
||||
if [ "$MEM_JOBS" -gt 0 ] && [ "$MEM_JOBS" -lt "$MAX_JOBS" ]; then
|
||||
MAX_JOBS="$MEM_JOBS"
|
||||
fi
|
||||
[ "$MAX_JOBS" -lt 1 ] && MAX_JOBS=1
|
||||
echo "Building vLLM with MAX_JOBS=$MAX_JOBS (cpu=$CPU_COUNT, mem_kb=$MEM_KB)"
|
||||
|
||||
# `git clone` below runs directly on the runner host (unlike
|
||||
# `docker build`/push, it never touches $DOCKER_HOST), so the
|
||||
# --dns 1.1.1.1/8.8.8.8 pin on the dind *service* above does
|
||||
# nothing for it — that only fixes resolution for things the
|
||||
# dind daemon itself does (image pulls, registry push). A
|
||||
# host-level resolver hiccup ("Could not resolve host:
|
||||
# github.com") is the same class of transient DNS flakiness
|
||||
# called out in the dind `options` comment, just hitting a
|
||||
# different resolver. Retry with backoff, and nudge the host
|
||||
# resolver between attempts where we can (best-effort, never
|
||||
# fatal if the tool/permission isn't there).
|
||||
clone_with_retry() {
|
||||
local attempt=1
|
||||
local max_attempts=5
|
||||
until git clone --depth 1 --branch "$VLLM_REF" https://github.com/vllm-project/vllm.git /tmp/vllm-build; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::git clone of vllm-project/vllm failed after $max_attempts attempts"
|
||||
return 1
|
||||
fi
|
||||
local wait=$((attempt * 5))
|
||||
echo "git clone failed (attempt $attempt/$max_attempts, likely DNS); retrying in ${wait}s..."
|
||||
sleep "$wait"
|
||||
# Best-effort: if the host resolver cache is stuck, poke it.
|
||||
# Ignore failures - these commands may not exist/be permitted.
|
||||
(command -v resolvectl >/dev/null 2>&1 && sudo resolvectl flush-caches) || true
|
||||
(command -v systemd-resolve >/dev/null 2>&1 && sudo systemd-resolve --flush-caches) || true
|
||||
getent hosts github.com >/dev/null 2>&1 || true
|
||||
rm -rf /tmp/vllm-build
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
rm -rf /tmp/vllm-build
|
||||
clone_with_retry
|
||||
|
||||
docker build -f /tmp/vllm-build/docker/Dockerfile.cpu \
|
||||
--build-arg VLLM_CPU_DISABLE_AVX512=true \
|
||||
--build-arg max_jobs="$MAX_JOBS" \
|
||||
--tag "$REG/vllm-cpu:$SHA" \
|
||||
--tag "$REG/vllm-cpu:latest" \
|
||||
--target vllm-openai \
|
||||
/tmp/vllm-build
|
||||
|
||||
push_with_retry "$REG/vllm-cpu:$SHA"
|
||||
push_with_retry "$REG/vllm-cpu:latest"
|
||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- Go build artifacts ---
|
||||
/bin/
|
||||
/dist/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
# Go workspace files (only relevant if someone adds a multi-module go.work locally)
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Dependency vendoring (this repo doesn't vendor; ignore in case it's added ad hoc)
|
||||
/vendor/
|
||||
|
||||
# --- Protobuf tooling ---
|
||||
# gen/ is intentionally tracked (every Dockerfile COPYs it as-is,
|
||||
# see services/*/Dockerfile) — do NOT ignore it. Only ignore the
|
||||
# plugin binaries `make tools` would install locally.
|
||||
/tools/bin/
|
||||
*.pb.go.tmp
|
||||
|
||||
# --- Runtime data (bind-mount targets if compose volumes are ever
|
||||
# switched from named volumes to host paths) ---
|
||||
/data/
|
||||
/.cache/huggingface/
|
||||
/pgdata/
|
||||
|
||||
# --- Editor / OS cruft ---
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Misc local scratch ---
|
||||
*.log
|
||||
.air.toml
|
||||
tmp/
|
||||
332
ARCHITECTURE.md
Normal file
332
ARCHITECTURE.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Architecture
|
||||
|
||||
This document records the bounded-context decomposition of the
|
||||
**enkly-ai** monolith, why each boundary is drawn where it is, the
|
||||
communication rules enforced throughout, and the handful of places
|
||||
where splitting a single process into eight forced a small, deliberate
|
||||
change in behavior.
|
||||
|
||||
## 1. Bounded contexts (the context map)
|
||||
|
||||
The monolith's packages were grouped by **technical layer**
|
||||
(`internal/handlers`, `internal/db`, `internal/llm`, ...); this port
|
||||
regroups them by **business capability**, each with its own data and
|
||||
its own deployable:
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ gateway │ (HTTP/HTMX edge — no DB)
|
||||
└──────┬──────┘
|
||||
┌──────────┬───────┼────────┬──────────┬──────────┐
|
||||
▼ ▼ ▼ ▼ ▼ │
|
||||
┌───────┐ ┌───────┐┌──────┐┌─────────┐┌─────────┐ │
|
||||
│ iam │ │billing││ conv │ │workspace││knowledge│ │
|
||||
└───┬───┘ └───┬───┘└──────┘└─────────┘└────┬────┘ │
|
||||
│ │ │ │
|
||||
│ events │ events │ ▼
|
||||
▼ ▼ │ ┌───────┐
|
||||
┌──────────────────┐ └──▶│ agent │
|
||||
│ notification │ └───┬───┘
|
||||
└──────────────────┘ │
|
||||
(agent also calls billing,
|
||||
conversation, workspace,
|
||||
knowledge — see §3)
|
||||
```
|
||||
|
||||
| Context | Aggregate(s) | Replaces (monolith) |
|
||||
|---|---|---|
|
||||
| **IAM** | User, Session, VerificationCode, PasswordResetToken | `internal/handlers/auth.go`, `internal/db/users.go`, `internal/auth/*` |
|
||||
| **Billing** | Subscription, StripeEvent, DailyUsage | `internal/handlers/billing.go`, `internal/billing/*`, `internal/db/subscriptions.go` |
|
||||
| **Conversation** | Conversation, Message, ToolCall | `internal/db/conversations.go` |
|
||||
| **Workspace** | (the sandboxed filesystem itself) | `internal/llm/tools.go` + `sandbox.go`, `internal/handlers/files.go` |
|
||||
| **Knowledge** | CodeChunk (pgvector document) | `internal/rag/*` |
|
||||
| **Agent** | Stream (an in-flight run) — a saga coordinator, owns no table | `internal/llm/agent.go` + `client.go`, the run-orchestration half of `internal/handlers/chat.go` |
|
||||
| **Notification** | (stateless consumer) | the email-sending half of `internal/handlers/auth.go`, `internal/mail/*` |
|
||||
| **Gateway (BFF)** | (no domain state) | `internal/handlers/{pages,ui,deliverables,middleware}.go`, `internal/render/*` |
|
||||
|
||||
**Why Agent is its own context, not folded into Workspace or
|
||||
Conversation:** the agent run loop is a process that *uses* workspace
|
||||
(tool execution), knowledge (semantic search) and conversation
|
||||
(persistence) to do its job, but owns none of their data. That's a
|
||||
textbook application service / saga, not an aggregate of any of the
|
||||
three — giving it its own deployable keeps "what tools exist and how a
|
||||
turn is orchestrated" independently scalable and independently
|
||||
restartable from "where files live" or "what was said."
|
||||
|
||||
**Why Workspace is separate from Agent**, even though today only Agent
|
||||
calls it: the sandboxed filesystem is also directly reachable by a
|
||||
human through the gateway's upload/download/explorer panel. Coupling
|
||||
it to Agent would mean every file-browsing click goes through the LLM
|
||||
orchestration service for no reason, and would block scaling
|
||||
"concurrent agent runs" independently of "workspace storage."
|
||||
|
||||
**Why rendering lives only in Gateway:** `pkg/renderkit` (Markdown,
|
||||
syntax highlighting, code-snippet extraction) and every `html/template`
|
||||
file are a presentation concern, not a domain concept — no bounded
|
||||
context's *business rule* depends on HTML. Agent streams plain text and
|
||||
structured tool-call data; Gateway is the one place that turns that
|
||||
into HTML. This is also what makes Agent's gRPC contract
|
||||
(`api/proto/agent/v1/agent.proto`) reusable by a future non-HTML client
|
||||
(a CLI, a different front end) without change.
|
||||
|
||||
## 2. Database-per-service
|
||||
|
||||
Every context that owns data gets its **own Postgres database** (not
|
||||
just its own schema/tables in a shared instance):
|
||||
|
||||
- `iam` → `enkly_iam` (users, sessions, email_verification_codes, password_reset_tokens)
|
||||
- `billing` → `enkly_billing` (subscriptions, stripe_events, usage_daily)
|
||||
- `conversation` → `enkly_conversation` (conversations, messages, tool_calls)
|
||||
- `knowledge` → `enkly_knowledge` (documents, pgvector)
|
||||
|
||||
`workspace` has no database — the sandboxed directory tree itself
|
||||
*is* its data, on its own Docker volume.
|
||||
|
||||
**Consequence worth naming explicitly:** the monolith's `schema.sql`
|
||||
had `user_id BIGINT REFERENCES users(id) ON DELETE CASCADE` on
|
||||
`conversations`, `subscriptions`, and `usage_daily`. Postgres cannot
|
||||
enforce a foreign key across two separate databases, so every one of
|
||||
those is now a bare `user_id BIGINT` with **no cascade delete**. The
|
||||
monolith never actually exposed an "delete my account" feature, so no
|
||||
behavior is lost today — but if one is added later, deleting a user
|
||||
must become a saga (iam deletes the user, then publishes a
|
||||
`user.deleted` event for billing/conversation to react to and delete
|
||||
their own rows), not a single SQL statement. This is the standard,
|
||||
unavoidable cost of database-per-service and is called out here so it
|
||||
isn't rediscovered the hard way.
|
||||
|
||||
## 3. Communication rules
|
||||
|
||||
> *"gRPC for synchronous communication, RabbitMQ for asynchronous
|
||||
> communication"* — applied throughout as: **if the caller needs an
|
||||
> answer (or needs to know the write succeeded) before it can proceed,
|
||||
> it's a gRPC call; if the caller doesn't need to wait for the result,
|
||||
> it's an event.**
|
||||
|
||||
### Synchronous (gRPC) — `api/proto/*/v1/*.proto`
|
||||
|
||||
Every cross-context call that's part of a request's critical path:
|
||||
|
||||
- Gateway → IAM: session validation on (almost) every request, login, signup, ...
|
||||
- Gateway → every other service: composing a page from their data
|
||||
- Agent → Billing: check-and-increment the daily quota before a run starts
|
||||
- Agent → Conversation: persist each message/tool-call as a turn proceeds
|
||||
- Agent → Workspace: every filesystem/bash tool call
|
||||
- Agent → Knowledge: the `search_codebase` tool
|
||||
- `dispatch_subagent` makes **no new outbound call of its own kind**: it runs a second,
|
||||
nested `agentrun.Agent` in-process, sharing the Agent service's own vLLM-backed client and
|
||||
making the *same* Workspace/Knowledge gRPC calls an ordinary tool call would, just via a
|
||||
`toolexec.restrictedExecutor` that only forwards the read-only subset
|
||||
(`read_file`/`list_directory`/`glob_search`/`grep`/`search_codebase`). No new service, no
|
||||
new proto — see `services/agent/internal/infrastructure/toolexec/subagent.go`.
|
||||
- `solve_ml_math` (like `write_todos`) makes **no** outbound call at all: it's pure
|
||||
computation over `pkg/mathkit`, a dependency-free numerical engine (linear algebra,
|
||||
calculus/gradients, statistics, optimization, ML primitives) that runs entirely inside
|
||||
the agent process — see `pkg/mathkit`'s package doc and
|
||||
`services/agent/internal/infrastructure/toolexec/mlmath.go`.
|
||||
- Agent `StreamTurn`: a **server-streaming** RPC — the gRPC replacement
|
||||
for the monolith's Server-Sent Events, carrying the same
|
||||
token/thinking/tool-call/done event sequence; the gateway re-emits
|
||||
each message as an actual SSE frame to the browser (see
|
||||
`services/gateway/internal/handlers/chat.go`).
|
||||
|
||||
### Asynchronous (RabbitMQ) — `pkg/eventbus`
|
||||
|
||||
One topic exchange (`enkly.events`); routing keys of the form
|
||||
`<context>.<aggregate>.<event>`. Today's only real consumer is
|
||||
`notification`, bound to:
|
||||
|
||||
- `iam.user.verification_code_issued` → send the signup/resend email
|
||||
- `iam.user.password_reset_requested` → send the reset-link email
|
||||
|
||||
Also published, with no consumer yet (kept so the *pattern* — every
|
||||
aggregate commit gets an event — is consistent across every context,
|
||||
and so a future service can bind without a producer-side change):
|
||||
`iam.user.registered`, `iam.user.email_verified`,
|
||||
`billing.subscription.changed`, `conversation.message.created`.
|
||||
|
||||
**Trust boundary of the event bus:** `VerificationCodeIssued` and
|
||||
`PasswordResetRequested` carry the raw code/token on the bus, not just
|
||||
an ID. This is a deliberate trade-off (documented in
|
||||
`pkg/eventbus/events/iam_events.go`) rather than an oversight: it lets
|
||||
`notification` send the exact email the monolith sent without needing
|
||||
read access to `iam`'s token tables (which would violate
|
||||
database-per-service far more seriously than this does). It's safe
|
||||
*only* because RabbitMQ is never exposed outside the private service
|
||||
network — the same trust level the monolith implicitly gave its own
|
||||
in-process function call. A production hardening pass should put this
|
||||
exchange behind TLS + per-service credentials (RabbitMQ supports both)
|
||||
even on a private network, as defense in depth.
|
||||
|
||||
## 4. One Go module, many deployables
|
||||
|
||||
Every service shares a single `module enkly` (one `go.mod` at the repo
|
||||
root) rather than one `go.mod` per service. This is a deliberate,
|
||||
pragmatic choice: true microservice *independence* comes from separate
|
||||
processes, separate databases, separate containers, and a versioned
|
||||
API contract between them (all of which this repo has) — not from
|
||||
separate Go dependency graphs, which mainly adds `replace`-directive
|
||||
and version-skew overhead in a monorepo without buying any of the
|
||||
actual deployability benefits. Each service still has its own
|
||||
`cmd/server/main.go`, its own `Dockerfile` that builds only that one
|
||||
binary, and starts, scales, and fails independently of the others.
|
||||
|
||||
## 5. Layering within a service
|
||||
|
||||
```
|
||||
internal/domain/ entities + repository interfaces (port). No sql, no net/http, no generated gRPC code.
|
||||
internal/application/ use cases. Depends on domain only. Transport-agnostic — unit-testable without a server.
|
||||
internal/infrastructure/ adapters: Postgres repos, the Stripe/vLLM HTTP clients, the sandboxed filesystem.
|
||||
internal/interfaces/grpc/ the ONLY files that import this service's generated `gen/<ctx>/v1` package.
|
||||
```
|
||||
|
||||
Dependency direction is inward: `interfaces` and `infrastructure` both
|
||||
depend on `domain` (through its interfaces); `domain` depends on
|
||||
nothing. This is what lets, e.g., `services/iam/internal/application`
|
||||
be tested with a fake `UserRepository` and no database at all.
|
||||
|
||||
## 6. What's verified, what isn't
|
||||
|
||||
Every `.proto` file in `api/proto/` was compiled for real with
|
||||
`protoc` + the actual `protoc-gen-go`/`protoc-gen-go-grpc` plugins —
|
||||
`gen/` is genuine generated code, not hand-written stand-ins. Every
|
||||
hand-written `.go` file in this repo passes `gofmt -l` (verified file
|
||||
by file as it was written).
|
||||
|
||||
What this port could **not** do, for lack of outbound network access
|
||||
to `proxy.golang.org`/`google.golang.org` in the authoring sandbox: run
|
||||
`go build`/`go mod tidy` end-to-end across the whole dependency graph
|
||||
(`google.golang.org/grpc`, `github.com/lib/pq`,
|
||||
`github.com/rabbitmq/amqp091-go`, `github.com/pgvector/pgvector-go`,
|
||||
`github.com/gomarkdown/markdown`), and so no `go.sum` is committed.
|
||||
Every service's Dockerfile therefore runs `go mod tidy` at build time
|
||||
(after copying in `gen/`, `pkg/`, and that service's own directory) to
|
||||
resolve and lock the graph on first build — this needs ordinary
|
||||
internet access, same as any other Go project, and is expected to
|
||||
succeed on any normal developer machine or CI runner. `make build`
|
||||
(plain `go build`, no Docker) needs a `go.sum` to exist first, so run
|
||||
`go mod tidy` at the repo root once before using it locally. Once a
|
||||
`go.sum` is committed, each Dockerfile's `go mod tidy` line should be
|
||||
swapped back to `go mod download` (noted inline in
|
||||
`services/iam/Dockerfile`) for faster, better-cached rebuilds.
|
||||
|
||||
The same constraint applied to the later Ollama→vLLM migration
|
||||
(`pkg/vllmkit`, replacing `pkg/ollamakit`): `pkg/vllmkit` itself has no
|
||||
dependency beyond the Go standard library, so it was verified for real
|
||||
— `go build`/`go test` both pass in an isolated module — but every
|
||||
file that imports it still depends on the same ungenerated `go.sum`,
|
||||
so those files could only be checked with `gofmt -l` (a full syntax
|
||||
parse; it does not resolve imports or type-check across packages), not
|
||||
a full `go build ./...`. Run `go build ./...` (or `make build`) as the
|
||||
first gate on that change in an environment with normal network
|
||||
access, before trusting it beyond what `gofmt` already confirms. See
|
||||
`CHANGES.md`'s most recent entry for the full migration writeup.
|
||||
|
||||
## 7. Known gaps / simplifications
|
||||
|
||||
Called out here rather than silently shipped, each with why it's safe
|
||||
to defer:
|
||||
|
||||
- **Session validation latency.** `gateway`'s `RequireAuth` now makes
|
||||
one `IAM.ValidateSession` gRPC call per request, where the monolith
|
||||
did one local Postgres query in the same process. For most
|
||||
deployments this is low-single-digit-millisecond overhead on an
|
||||
internal network and is the correct place to start; if it becomes a
|
||||
bottleneck, the standard fix is a short-TTL cache in the gateway (or
|
||||
switching sessions to signed, self-contained tokens) — not
|
||||
re-merging the services.
|
||||
- **Reattaching to a run after a page reload.** The monolith could show
|
||||
a still-in-flight reply immediately on reload
|
||||
(`StreamManager.activeFor`). The gateway doesn't re-wire this today
|
||||
(it would need a new "is there an active run for this conversation"
|
||||
Agent RPC with no other caller) — a reload mid-reply shows the
|
||||
completed answer on next load instead of resuming the live view. The
|
||||
reply itself is unaffected; this is UI polish, not correctness.
|
||||
- **Deleting a conversation mid-run.** The monolith proactively
|
||||
cancelled any in-flight stream for a conversation being deleted.
|
||||
Agent's API has no "stop by conversation" RPC (only by `stream_id`),
|
||||
so a straggling run's writes will fail (logged) against the deleted
|
||||
conversation rather than being pre-empted. Low-impact and easy to
|
||||
close later by adding that RPC.
|
||||
- **Verification/reset email confirmation.** Because sending email is
|
||||
now asynchronous (RabbitMQ), `iam`'s `SignUp`/`ForgotPassword` RPCs
|
||||
can no longer report back "the email failed to send" synchronously
|
||||
the way the monolith occasionally could — they report success once
|
||||
the code/token is persisted and the event is published. This is the
|
||||
expected, intentional cost of decoupling a side effect onto a
|
||||
message bus, not a bug.
|
||||
- **Per-call bash timeout.** `WorkspaceService.RunBashCommand` accepts
|
||||
a `timeout_seconds` field for forward-compatibility, but (matching
|
||||
the monolith, which only ever had one process-wide timeout) does not
|
||||
yet apply it per call.
|
||||
- **Chat attachments aren't a schema field.** The composer's "Add
|
||||
files or photos" (`services/gateway/internal/handlers/attachments.go`)
|
||||
uploads into the workspace under `attachments/` and folds the
|
||||
resulting paths into the prompt string itself, as a trailing
|
||||
`[Attached files]` block — `conversation.Message` has no attachments
|
||||
field of its own, and adding one would be a schema change across the
|
||||
conversation/gateway boundary. The trade-off: attachments only ever
|
||||
round-trip as a specific text shape inside `content`, parsed back out
|
||||
by `renderUserMessage`, instead of being structured data the
|
||||
conversation service understands natively. Also: removing a staged
|
||||
chip before sending, or never sending at all, leaves the uploaded
|
||||
file behind in the workspace — nothing currently reaps attachments
|
||||
that were uploaded but never attached to a sent message.
|
||||
- **Zip attachments extract one level deep.** A `.zip` attached to the
|
||||
chat is unpacked into `attachments/<name>/` (gateway-side, with its
|
||||
own zip-slip guard on top of workspace's existing path jail, plus
|
||||
caps on entry count and total uncompressed size) so the agent's
|
||||
existing `read_file`/`list_directory`/`grep`/`glob_search` tools can
|
||||
read what's inside it — no new agent tool needed. A `.zip` nested
|
||||
inside that `.zip` is written out as-is rather than recursively
|
||||
extracted; `unzip` isn't in `DefaultAllowedCommands`
|
||||
(`services/workspace/internal/infrastructure/sandboxfs/sandbox.go`),
|
||||
so the agent can't shell out to unpack it further unless an operator
|
||||
adds it to `ENKLY_BASH_ALLOWLIST`.
|
||||
- **Language preference storage.** The language switcher (`pkg/i18n`,
|
||||
`services/gateway/internal/handlers/i18n.go`) persists a visitor's
|
||||
chosen UI locale in a year-long `enkly_lang` cookie, not on the IAM
|
||||
`User` row. That was a deliberate choice, not an oversight: adding a
|
||||
`preferred_language` field means extending `iam.proto`'s `User`
|
||||
message and an RPC, which means regenerating every language's
|
||||
generated client/server code under `gen/` — this repo's `gen/`
|
||||
directory is checked in and hand-maintained, and doing that
|
||||
regeneration safely needs `protoc` and the project's `buf`/codegen
|
||||
toolchain, which isn't always available in every environment this
|
||||
code is edited in. A cookie needs neither: it survives logins and
|
||||
browser restarts, just not a switch to a second device. The same
|
||||
reasoning is why the visitor's locale reaches the agent service as
|
||||
gRPC metadata (`x-enkly-lang`, set in `chat.go`'s `withLangMetadata`,
|
||||
read in `services/agent/internal/interfaces/grpc/server.go`'s
|
||||
`langFromIncomingContext`) rather than as a new `StartTurnRequest`
|
||||
field. Moving either onto the User row/proto later is a clean,
|
||||
backward-compatible addition — `localeFrom`/`langFromIncomingContext`
|
||||
are the only two places that would need a new "check the account
|
||||
first" branch — just not one to do without `protoc` on hand to
|
||||
verify the regenerated code.
|
||||
- **UI translation coverage.** `pkg/i18n` ships English and French
|
||||
complete; Spanish, German and Portuguese cover the nav/auth/composer
|
||||
chrome and fall back to English for the rest (quota/error copy, the
|
||||
subscription and files panels) — see `pkg/i18n/locales/*.json` and
|
||||
`T`'s fallback behavior in `pkg/i18n/i18n.go`. `subscription.html`
|
||||
and `files.html` aren't wired to the switcher yet (no `Lang` field on
|
||||
their page-data structs). None of this affects how the agent itself
|
||||
talks to a user — see `agentrun.SystemPrompt`'s "## Language" section
|
||||
and `agentrun.WithPreferredLanguage`, which work from any locale
|
||||
regardless of how much of the surrounding chrome has a catalog entry
|
||||
for it.
|
||||
|
||||
## 8. Operational hardening before a real rollout
|
||||
|
||||
Deliberately out of scope for this restructuring (it's infrastructure
|
||||
polish orthogonal to the DDD boundary work), but worth a checklist:
|
||||
|
||||
- mTLS between services (today: plaintext gRPC on a private Docker
|
||||
network — fine inside a single trusted VPC, not fine across one)
|
||||
- RabbitMQ publisher confirms + a dead-letter exchange for poison
|
||||
messages (today: at-least-once with a bounded retry via `Nack`)
|
||||
- Replacing the in-memory `authsupport.Limiter`/`domain.Manager`
|
||||
(agent's stream registry) with something shared across replicas
|
||||
(Redis) once any service runs more than one instance
|
||||
— both are currently per-process, same as the monolith's own
|
||||
in-memory limiter was
|
||||
1337
CHANGES.md
Normal file
1337
CHANGES.md
Normal file
File diff suppressed because it is too large
Load Diff
74
Makefile
Normal file
74
Makefile
Normal file
@@ -0,0 +1,74 @@
|
||||
.PHONY: proto build up down logs ps tidy gpu-preflight k8s-deploy k8s-bootstrap
|
||||
|
||||
SERVICES := iam billing conversation workspace knowledge agent notification gateway
|
||||
|
||||
# Regenerate every *.pb.go / *_grpc.pb.go from api/proto/**/*.proto.
|
||||
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc on PATH
|
||||
# (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest &&
|
||||
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest).
|
||||
proto:
|
||||
rm -rf gen
|
||||
mkdir -p gen
|
||||
protoc -I api/proto \
|
||||
--go_out=gen --go_opt=module=enkly/gen \
|
||||
--go-grpc_out=gen --go-grpc_opt=module=enkly/gen \
|
||||
api/proto/common/v1/*.proto \
|
||||
api/proto/iam/v1/*.proto \
|
||||
api/proto/billing/v1/*.proto \
|
||||
api/proto/conversation/v1/*.proto \
|
||||
api/proto/workspace/v1/*.proto \
|
||||
api/proto/knowledge/v1/*.proto \
|
||||
api/proto/agent/v1/*.proto
|
||||
|
||||
# Compile every service's binary locally (sanity check before building
|
||||
# images) — requires normal internet access to resolve go.mod's
|
||||
# dependencies, which this sandboxed authoring environment did not
|
||||
# have; see ARCHITECTURE.md's "What's verified, what isn't".
|
||||
build:
|
||||
@for s in $(SERVICES); do \
|
||||
echo "== $$s =="; \
|
||||
go build -o /dev/null ./services/$$s/cmd/server || exit 1; \
|
||||
done
|
||||
go build -o /dev/null ./tools/indexer/cmd/indexer
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
up:
|
||||
# No separate model-pull step needed: vllm-chat/vllm-embed download
|
||||
# their own weights from Hugging Face on first start, and agent/
|
||||
# knowledge's depends_on: condition: service_healthy (see
|
||||
# docker-compose.yml) already blocks them from starting until both
|
||||
# vLLM servers' /health check passes — which, on a cold cache, can
|
||||
# take a few minutes for the Qwen3.5 chat model. `make logs` to
|
||||
# watch that progress.
|
||||
docker compose up --build -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
# Diagnoses why vllm-chat/vllm-embed are Pending on the k8s deployment
|
||||
# (GPU capacity, node label/taint, PVC binding) — see k8s/README.md
|
||||
# "GPU prerequisites" before running this against a real cluster.
|
||||
gpu-preflight:
|
||||
./k8s/scripts/gpu-preflight.sh
|
||||
|
||||
# Apply + wait for rollout only — assumes the GPU node is already
|
||||
# labeled/tainted and 02-secrets.yaml is already filled in. Normally
|
||||
# called by bootstrap-cluster.sh (k8s-bootstrap, below); use this
|
||||
# directly only when you've done the GPU setup yourself.
|
||||
k8s-deploy:
|
||||
cd k8s && ./deploy.sh
|
||||
|
||||
# ONE command for the whole k8s/ manifest set: auto-detects + labels/
|
||||
# taints the GPU node(s), applies time-slicing if needed, generates
|
||||
# ENKLY_VLLM_API_KEY, applies everything (via k8s-deploy above), then
|
||||
# runs gpu-preflight. See k8s/README.md "Deploy".
|
||||
k8s-bootstrap:
|
||||
cd k8s && ./bootstrap-cluster.sh
|
||||
178
README.md
178
README.md
@@ -1,2 +1,178 @@
|
||||
# enkly-ai
|
||||
# enkly — DDD microservices
|
||||
|
||||
This is the monolithic **enkly-ai** codebase (a vLLM-backed agentic
|
||||
coding assistant with an HTMX chat UI, billing, and a RAG index)
|
||||
restructured into eight independently deployable,
|
||||
Domain-Driven-Design-oriented microservices. No product feature was
|
||||
removed; see [ARCHITECTURE.md](./ARCHITECTURE.md) for the bounded-context
|
||||
map, the handful of behavioral trade-offs the split required, and what
|
||||
to review before a production rollout.
|
||||
|
||||
Inference runs on two GPU-backed [vLLM](https://docs.vllm.ai)
|
||||
OpenAI-compatible servers — one serving the chat model (Qwen, with
|
||||
tool calling and extended thinking enabled), one serving the embedding
|
||||
model — rather than a single local Ollama daemon. See `pkg/vllmkit`
|
||||
for the client, `docker-compose.yml` / `k8s/11-vllm.yaml` for how the
|
||||
two servers are deployed, and `CHANGES.md`'s most recent entry for the
|
||||
full writeup of what the migration off Ollama changed and why.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Owns |
|
||||
|---|---|---|
|
||||
| `gateway` | 8080 (HTTP) | The HTMX web UI; the only service with a public HTTP surface |
|
||||
| `iam` | 9001 (gRPC) | Accounts, sessions, email verification, password reset |
|
||||
| `billing` | 9002 (gRPC) | Plans, Stripe checkout/portal/webhooks, daily usage quota |
|
||||
| `conversation` | 9003 (gRPC) | Conversations, messages, tool-call audit log |
|
||||
| `workspace` | 9004 (gRPC) | The agent's sandboxed filesystem/bash tools; file upload/download |
|
||||
| `knowledge` | 9005 (gRPC) | pgvector-backed RAG index/search |
|
||||
| `agent` | 9006 (gRPC) | The LLM run loop; orchestrates billing/conversation/workspace/knowledge |
|
||||
| `notification` | — (RabbitMQ only) | Sends email in reaction to iam's events |
|
||||
|
||||
Communication: **gRPC** for every synchronous, request/response call
|
||||
between services (see `api/proto/`); **RabbitMQ** for asynchronous,
|
||||
no-reply-needed side effects — today, exactly the email-sending that
|
||||
`iam` triggers (see `pkg/eventbus`).
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
cp .env.example .env # fill in STRIPE_* / ENKLY_SMTP_* if you want those features
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
`vllm-chat` and `vllm-embed` download their own model weights from
|
||||
Hugging Face on first start — no separate `pull` step — so the first
|
||||
`up` can take several minutes before `agent`/`knowledge` actually come
|
||||
online (they wait on both vLLM servers' `/health` check via
|
||||
`depends_on`). `docker compose logs -f vllm-chat` to watch progress.
|
||||
This requires a host with an NVIDIA GPU and the NVIDIA Container
|
||||
Toolkit installed (`docker info | grep -i nvidia` to check) — vLLM's
|
||||
production performance depends on real GPU acceleration; see
|
||||
`docker-compose.yml`'s `vllm-chat`/`vllm-embed` services for the
|
||||
details (pinned image tag, `--gpu-memory-utilization`, `/dev/shm`
|
||||
sizing).
|
||||
|
||||
Then open <http://localhost:8080>.
|
||||
|
||||
To index a codebase for the `search_codebase` tool:
|
||||
|
||||
```sh
|
||||
docker compose run --rm -v "$(pwd)":/src indexer -path /src
|
||||
```
|
||||
|
||||
## Claude-Code-inspired agent features
|
||||
|
||||
Two additions to the agent service, beyond the tool-for-tool monolith port, modeled on
|
||||
Claude Code:
|
||||
|
||||
- **Project memory (`ENKLY.md`)** — drop an `ENKLY.md` file in the sandboxed workspace root
|
||||
(same mechanism as `write_file`/the file explorer) and the agent folds it into its system
|
||||
prompt on every turn, under a "Project memory" heading — conventions, gotchas, how to run
|
||||
things in *this* repo. No file means no change in behavior. See
|
||||
`services/agent/internal/infrastructure/agentrun/agent.go`'s `MemoryLoader`.
|
||||
- **`dispatch_subagent` (a Task tool)** — the model can hand off a broad, open-ended
|
||||
investigation ("find every place X happens", "map out how Y flows through this service")
|
||||
to a nested, read-only sub-agent that explores on its own and reports back in one message.
|
||||
The sub-agent's own tool calls never enter the main conversation's context — only its
|
||||
final report does — and it structurally cannot write a file, edit a file, or run a
|
||||
command (its tool list excludes them, and its executor refuses them even if asked). See
|
||||
`services/agent/internal/infrastructure/toolexec/subagent.go` and
|
||||
`toolcatalog.SubagentDefs()`.
|
||||
- **Adaptive, interleaved thinking** — the agent already streamed Qwen's reasoning trace
|
||||
back per round (`ChatEvent.Thinking`); two refinements bring that closer to how Claude's
|
||||
interleaved thinking actually behaves. First, thinking effort now varies by round instead
|
||||
of being one static setting for the whole run: round 0 (pure planning, no tool results
|
||||
yet) asks the chat model for `"high"` effort, later rounds defer to whatever default you've
|
||||
configured, and a template-parse retry asks for `"low"` so recovery stays fast — see
|
||||
`agentrun.ThinkPolicy` / `DefaultThinkPolicy`. Second, a round's reasoning trace is no
|
||||
longer resent to the model on every subsequent round for the rest of the turn: once a
|
||||
newer round's thinking exists, the older one is dropped from the resent history (only the
|
||||
most recent round's trace is ever "in flight," matching the Claude API's own behavior of
|
||||
only needing the thinking block immediately preceding the current tool use) — see
|
||||
`agentrun.compactThinkingHistory`, called alongside the existing `compactToolHistory` on
|
||||
every round. A new optional `Callbacks.OnThinkingRound(round int, thinking string)` also
|
||||
fires once per round with just that round's own trace, for a caller that wants to render
|
||||
reasoning as discrete per-step blocks rather than one running blob. The gateway now does
|
||||
exactly that: `domain.Stream.AddThinkingRound` records each round, `agent.proto`'s new
|
||||
`TurnEvent.thinking_round` (a `ThinkingRound{round, thinking}` oneof case) carries it over
|
||||
gRPC, and `StreamChat` emits it as a `thinking_round` SSE event rendered as its own
|
||||
collapsible card (`message.html`'s `thinking_round`/`thinking_round_oob`, styled via
|
||||
`.tool-card.is-thinking-round` in `style.css`) inserted above the reply in execution
|
||||
order — the same `beforebegin` OOB trick tool-call cards already use. This sits alongside,
|
||||
not in place of, the existing single auto-collapsing `.thinking-card`: that one shows the
|
||||
round currently in flight streaming live; the per-round cards are the settled record of
|
||||
every round once done, positioned right before the tool call each one led to.
|
||||
|
||||
## Dynamic workflows, effort control, and long-context recovery
|
||||
|
||||
A further round of additions, modeled on Claude's agentic-coding feature set and scoped
|
||||
to what's honestly deliverable against a vLLM-served Qwen backend — see `CHANGES.md`
|
||||
§7 for the full writeup (written against the Ollama backend these features originally
|
||||
shipped on; see `CHANGES.md`'s latest entry for what the later vLLM migration changed).
|
||||
The short version:
|
||||
|
||||
- **`dispatch_workflow`** — the large-scale sibling of `dispatch_subagent`: give it a
|
||||
batch of steps (one per file/service/call site in a migration) and it runs them as
|
||||
parallel, read-only sub-agents, honoring any `depends_on` ordering, verifying each
|
||||
report against its own task, and retrying once on a weak one — automatic verification
|
||||
and plan adjustment, without a second LLM call per step. Gated by plan
|
||||
(`pkg/entitlements`) and bounded by `ENKLY_WORKFLOW_MAX_PARALLEL`.
|
||||
- **Effort control** (`pkg/effort`) — five levels, Low through Max, picked per turn via a
|
||||
leading `/effort <level>` line in the chat message (see `pkg/turnopts` for why a
|
||||
directive line rather than a new proto field, for now) or `ENKLY_DEFAULT_EFFORT` for the
|
||||
deployment default. Each level sizes max rounds, context budget, thinking-effort floor,
|
||||
and `dispatch_workflow`'s own parallelism cap.
|
||||
- **Adaptive thinking** — escalates one round's thinking effort above its level's floor on
|
||||
a concrete difficulty signal (the prior tool call errored, produced an unusually large
|
||||
result, or the turn is running long), instead of holding every round at one fixed
|
||||
setting. On by default (`ENKLY_ADAPTIVE_THINKING`).
|
||||
- **Compaction ledger** — `compactToolHistory` now keeps a running, auto-maintained note
|
||||
of every tool result it's trimmed away, so a long turn that's compacted its 30th result
|
||||
still has a stable map of what happened earlier instead of silently losing the thread.
|
||||
- **Live system entries** — `/system <instruction>` (and `permission:`/`budget:` variants)
|
||||
fold additional guidance into the system prompt mid-conversation, always appended after
|
||||
the stable base prompt rather than edited in place.
|
||||
- **Fast mode** — `/fast` runs the turn on a second, smaller/quantized model served by
|
||||
its own vLLM deployment (`ENKLY_FAST_CHAT_MODEL` + `ENKLY_FAST_VLLM_CHAT_URL`) when
|
||||
one's configured; otherwise it's a no-op, so it's always safe to request.
|
||||
|
||||
## First build
|
||||
|
||||
No `go.sum` is committed. Every Dockerfile runs `go mod tidy` during
|
||||
the build to resolve and lock the full dependency graph (including
|
||||
what grpc/protobuf/etc. pull in transitively) — this needs normal
|
||||
outbound network access the first time, same as any other Go project.
|
||||
After the first successful `docker compose up --build`, consider
|
||||
running `go mod tidy` once at the repo root and committing the
|
||||
resulting `go.sum`, then switching each Dockerfile's `RUN go mod tidy`
|
||||
back to `RUN go mod download` for faster, better-cached rebuilds (see
|
||||
the comment in `services/iam/Dockerfile`).
|
||||
|
||||
## Regenerating gRPC code
|
||||
|
||||
`gen/` is committed so the repo is buildable without protoc installed,
|
||||
but if you edit anything under `api/proto/`, regenerate it:
|
||||
|
||||
```sh
|
||||
make proto # requires protoc, protoc-gen-go, protoc-gen-go-grpc on PATH
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
api/proto/<context>/v1/*.proto gRPC contracts — the API surface between services
|
||||
gen/ generated *.pb.go / *_grpc.pb.go (do not hand-edit)
|
||||
pkg/ shared libraries (eventbus, auth helpers, render, vLLM client, Postgres helper, ...)
|
||||
services/<name>/
|
||||
cmd/server/main.go entrypoint: wiring, not logic
|
||||
internal/domain/ entities, repository interfaces — no framework imports
|
||||
internal/application/ use cases — transplanted business logic, transport-agnostic
|
||||
internal/infrastructure/ Postgres/Stripe/vLLM/filesystem adapters implementing domain interfaces
|
||||
internal/interfaces/grpc/ the only files that import the generated gRPC package
|
||||
migrations/0001_init.sql this service's own schema (database-per-service)
|
||||
tools/indexer/ standalone CLI, a knowledge-service gRPC client
|
||||
docker-compose.yml full local stack
|
||||
```
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for the design rationale.
|
||||
19
UI-REDESIGN.md
Normal file
19
UI-REDESIGN.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# enkly UI redesign (gateway web)
|
||||
|
||||
Touched: `web/static/css/style.css` (rewritten), `web/static/js/app.js` (additions at the end),
|
||||
`web/templates/index.html`, `web/templates/tree.html` (status bar). All other templates and every
|
||||
Go handler are unchanged; class names used by Go-rendered fragments (tool cards, thinking, deliverables,
|
||||
attachments, tree, auth, billing) are preserved.
|
||||
|
||||
## Design moves (from the reference screenshot)
|
||||
- Glowing command bar -> top-bar search that opens a command palette (Cmd/Ctrl+K): chats + quick actions.
|
||||
- Glow-ring composer with "+" and a pill send button ("Ask") -> composer is the single luminous element.
|
||||
- Recipe cards -> "Start with" starter cards (click fills or sends a prompt).
|
||||
- "Ingredients you have" -> "What enkly can use" tool chips; Explorer is now a glass panel.
|
||||
- "More to explore" -> dashed prompt chips that fill the composer.
|
||||
- Source cards -> agent tool steps with a per-kind coloured icon (read / write / search / run / plan / sub-agent).
|
||||
- Context usage in the status bar is now a meter (amber at 70%, red at 90%).
|
||||
|
||||
## Constraints respected
|
||||
System fonts only, no inline scripts, no CDN, no new endpoints (CSP: script-src 'self').
|
||||
Light and dark themes both supported; reduced-motion respected; visible focus rings.
|
||||
159
api/proto/agent/v1/agent.proto
Normal file
159
api/proto/agent/v1/agent.proto
Normal file
@@ -0,0 +1,159 @@
|
||||
// Package agent is the Agent Orchestration bounded context: the agentic
|
||||
// run loop (prompt -> vLLM -> tool calls -> final reply), the chat-run
|
||||
// quota/concurrency guard, and the in-flight/just-finished run registry
|
||||
// that used to live in internal/handlers/chat.go's StreamManager.
|
||||
//
|
||||
// This is a saga coordinator, not a data owner: it holds no SQL table of
|
||||
// its own. It calls billing (usage quota), conversation (persisting
|
||||
// turns) and knowledge (the search_codebase tool) over gRPC, and
|
||||
// workspace (every filesystem/bash tool) over gRPC, exactly replacing
|
||||
// internal/llm/agent.go + internal/llm/tools.go's direct, in-process
|
||||
// calls into internal/db and the local filesystem.
|
||||
//
|
||||
// Rendering (Markdown, syntax highlighting, deliverable cards) stays out
|
||||
// of this service on purpose — see internal/render's doc comment in the
|
||||
// gateway service — so this API deals in plain text and structured tool
|
||||
// results, never HTML.
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.agent.v1;
|
||||
|
||||
option go_package = "enkly/gen/agent/v1;agentv1";
|
||||
|
||||
service AgentService {
|
||||
// StartTurn replaces the synchronous prefix of Handlers.Chat: the
|
||||
// per-user throttle (ChatLimiter), the billing quota check, loading
|
||||
// the conversation, persisting the user's prompt, auto-titling, and
|
||||
// registering a new run (StreamManager.create) before handing off to
|
||||
// a background goroutine. Returns immediately with a stream_id; the
|
||||
// reply itself arrives via StreamTurn.
|
||||
rpc StartTurn(StartTurnRequest) returns (StartTurnResponse);
|
||||
|
||||
// StreamTurn replaces Handlers.StreamChat: a server-streaming view of
|
||||
// one run's progress. resume_from_tool_index mirrors the monolith's
|
||||
// SSE Last-Event-ID handling, so a reconnecting client doesn't see
|
||||
// tool cards it already has.
|
||||
rpc StreamTurn(StreamTurnRequest) returns (stream TurnEvent);
|
||||
|
||||
// StopTurn replaces Handlers.StopStream.
|
||||
rpc StopTurn(StopTurnRequest) returns (StopTurnResponse);
|
||||
}
|
||||
|
||||
enum StartTurnOutcome {
|
||||
START_TURN_OUTCOME_UNSPECIFIED = 0;
|
||||
START_TURN_OUTCOME_OK = 1;
|
||||
START_TURN_OUTCOME_CONVERSATION_NOT_FOUND = 2;
|
||||
START_TURN_OUTCOME_CONVERSATION_BUSY = 3;
|
||||
START_TURN_OUTCOME_TOO_MANY_STREAMS = 4;
|
||||
START_TURN_OUTCOME_RATE_LIMITED = 5;
|
||||
START_TURN_OUTCOME_QUOTA_EXCEEDED = 6;
|
||||
START_TURN_OUTCOME_INTERNAL_ERROR = 7;
|
||||
}
|
||||
|
||||
// SystemEntry is a mid-conversation instruction/permission/budget
|
||||
// update — see agentrun.SystemEntry (services/agent/internal/
|
||||
// infrastructure/agentrun/effort.go) for what each kind means and how
|
||||
// it's folded into the system prompt.
|
||||
message SystemEntry {
|
||||
enum Kind {
|
||||
KIND_UNSPECIFIED = 0;
|
||||
KIND_INSTRUCTION = 1;
|
||||
KIND_PERMISSION = 2;
|
||||
KIND_BUDGET = 3;
|
||||
}
|
||||
Kind kind = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message StartTurnRequest {
|
||||
int64 user_id = 1;
|
||||
int64 conversation_id = 2;
|
||||
string prompt = 3;
|
||||
|
||||
// --- Fields below this line are the TARGET structured API surface
|
||||
// for effort control, fast mode and live system-entry updates — see
|
||||
// pkg/turnopts' package doc comment for why they aren't wired up yet
|
||||
// (this environment has no protoc/go toolchain to regenerate
|
||||
// gen/agent/v1 from this file) and what the working fallback is
|
||||
// today (leading /effort, /fast, /system directive lines parsed out
|
||||
// of `prompt` itself, by pkg/turnopts.Parse — see
|
||||
// application.Service.StartTurn). Once `make proto` is run against
|
||||
// this file, a follow-up change points StartTurn at these fields
|
||||
// directly, with turnopts kept on as the fallback path for any
|
||||
// client that still sends plain-text directives instead. Proto3
|
||||
// fields are purely additive and backward compatible to add, so
|
||||
// declaring them now costs nothing even before that regeneration —
|
||||
// every field below defaults to its zero value for any client built
|
||||
// against today's compiled stubs.
|
||||
|
||||
// effort selects one of pkg/effort's five levels ("low", "medium",
|
||||
// "high", "extra", "max") for this turn; empty means "use the
|
||||
// deployment's configured default" (see agentrun.Agent.Effort).
|
||||
string effort = 4;
|
||||
// fast_mode requests fast mode for this turn (see
|
||||
// application.Service.FastAgent) — available on every plan, unlike
|
||||
// dynamic_workflows below.
|
||||
bool fast_mode = 5;
|
||||
// system_entries are folded into the system prompt for this turn
|
||||
// onward (see agentrun.WithSystemEntries) — appended, never
|
||||
// replacing anything, so earlier turns' prefix stays stable.
|
||||
repeated SystemEntry system_entries = 6;
|
||||
}
|
||||
|
||||
message StartTurnResponse {
|
||||
StartTurnOutcome outcome = 1;
|
||||
string message = 2; // user-facing text for any non-OK outcome
|
||||
string stream_id = 3; // set when outcome == OK
|
||||
}
|
||||
|
||||
message StreamTurnRequest {
|
||||
int64 user_id = 1;
|
||||
string stream_id = 2;
|
||||
int32 resume_from_tool_index = 3;
|
||||
}
|
||||
|
||||
message ToolCall {
|
||||
string name = 1;
|
||||
string arguments_json = 2;
|
||||
string result = 3;
|
||||
bool is_error = 4;
|
||||
}
|
||||
|
||||
// ThinkingRound is one agent round's own reasoning trace, standalone
|
||||
// rather than folded into the running total thinking_delta carries —
|
||||
// the same per-step granularity Claude's interleaved thinking exposes
|
||||
// (one distinct thinking block per tool-use step) rather than one
|
||||
// cumulative blob for the whole turn. round is 0-indexed and matches
|
||||
// the round the agent service's internal run loop was on when this
|
||||
// trace was produced; a client can use it to render reasoning as
|
||||
// discrete, collapsible steps next to the tool call each one preceded,
|
||||
// instead of a single growing "thinking" pane.
|
||||
message ThinkingRound {
|
||||
int32 round = 1;
|
||||
string thinking = 2;
|
||||
}
|
||||
|
||||
message TurnEvent {
|
||||
oneof event {
|
||||
string thinking_delta = 1; // cumulative reasoning text so far, plain
|
||||
string token_delta = 2; // cumulative reply text so far, plain
|
||||
ToolCall tool_call = 3; // one completed tool call
|
||||
TurnDone done = 4;
|
||||
ThinkingRound thinking_round = 5; // one round's own reasoning trace, standalone
|
||||
}
|
||||
}
|
||||
|
||||
message TurnDone {
|
||||
string final_text = 1; // plain Markdown source, not yet rendered
|
||||
// every tool call made during the turn, successful or not — the
|
||||
// gateway uses this to build deliverable (download) cards exactly
|
||||
// like internal/handlers/deliverables.go.attachDeliverables.
|
||||
repeated ToolCall tool_calls = 2;
|
||||
}
|
||||
|
||||
message StopTurnRequest {
|
||||
int64 user_id = 1;
|
||||
string stream_id = 2;
|
||||
}
|
||||
|
||||
message StopTurnResponse {}
|
||||
153
api/proto/billing/v1/billing.proto
Normal file
153
api/proto/billing/v1/billing.proto
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package billing is the Billing & Subscription bounded context: plans,
|
||||
// Stripe checkout/portal, webhook ingestion, and the per-plan daily
|
||||
// message quota. System of record for `subscriptions`, `stripe_events`
|
||||
// and `usage_daily` (see services/billing/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/handlers/billing.go + internal/billing/*.go +
|
||||
// internal/db/subscriptions.go.
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.billing.v1;
|
||||
|
||||
option go_package = "enkly/gen/billing/v1;billingv1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service BillingService {
|
||||
// GetOrCreateSubscription replaces db.GetOrCreateSubscription: lazily
|
||||
// inserts the default free-plan row the first time a user's
|
||||
// subscription is looked up.
|
||||
rpc GetOrCreateSubscription(GetOrCreateSubscriptionRequest) returns (Subscription);
|
||||
|
||||
// GetPlanCatalog replaces billing.Catalog + billing.PlanPriceIDs for
|
||||
// rendering /subscription — returns every plan and whether its Stripe
|
||||
// price is currently configured (so an unconfigured plan renders as
|
||||
// "Unavailable", same as today).
|
||||
rpc GetPlanCatalog(GetPlanCatalogRequest) returns (GetPlanCatalogResponse);
|
||||
|
||||
// CreateCheckoutSession replaces Handlers.CreateCheckout.
|
||||
rpc CreateCheckoutSession(CreateCheckoutSessionRequest) returns (CreateCheckoutSessionResponse);
|
||||
|
||||
// SyncCheckoutSession replaces Handlers.syncCheckoutSession, the
|
||||
// fallback path SubscriptionSuccess uses when the webhook hasn't
|
||||
// landed yet.
|
||||
rpc SyncCheckoutSession(SyncCheckoutSessionRequest) returns (Subscription);
|
||||
|
||||
// CreateBillingPortalSession replaces Handlers.BillingPortal.
|
||||
rpc CreateBillingPortalSession(CreateBillingPortalSessionRequest) returns (CreateBillingPortalSessionResponse);
|
||||
|
||||
// IngestStripeWebhook replaces Handlers.StripeWebhook /
|
||||
// applyStripeEvent. The gateway still terminates the public
|
||||
// POST /webhooks/stripe route (so the URL Stripe is configured with
|
||||
// never changes) but forwards the raw body and signature header here
|
||||
// untouched — signature verification needs STRIPE_WEBHOOK_SECRET,
|
||||
// which only billing holds.
|
||||
rpc IngestStripeWebhook(IngestStripeWebhookRequest) returns (IngestStripeWebhookResponse);
|
||||
|
||||
// CheckAndIncrementUsage replaces the daily-cap check + db.
|
||||
// IncrementDailyUsage call in Handlers.Chat. Called by the agent
|
||||
// service once per chat turn, after the message is accepted and
|
||||
// before the agent run starts (same ordering as the monolith).
|
||||
rpc CheckAndIncrementUsage(CheckAndIncrementUsageRequest) returns (CheckAndIncrementUsageResponse);
|
||||
|
||||
// GetDailyUsage replaces db.DailyUsage — a read-only lookup (no
|
||||
// increment) used by the gateway's /subscription page to show
|
||||
// today's count without miscounting it as a sent message.
|
||||
rpc GetDailyUsage(GetDailyUsageRequest) returns (GetDailyUsageResponse);
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
PLAN_UNSPECIFIED = 0;
|
||||
PLAN_FREE = 1;
|
||||
PLAN_BASIC = 2;
|
||||
PLAN_PRO = 3;
|
||||
PLAN_PREMIUM = 4;
|
||||
}
|
||||
|
||||
message PlanInfo {
|
||||
Plan plan = 1;
|
||||
string name = 2;
|
||||
int32 price_eur = 3;
|
||||
int32 daily_message_limit = 4; // 0 = unlimited
|
||||
bool available = 5; // Stripe price configured (always true for free)
|
||||
}
|
||||
|
||||
message Subscription {
|
||||
int64 user_id = 1;
|
||||
Plan plan = 2;
|
||||
string status = 3; // Stripe's subscription status, or "active" for free
|
||||
google.protobuf.Timestamp current_period_end = 4;
|
||||
bool cancel_at_period_end = 5;
|
||||
// has_billing_account reports whether a Stripe customer exists for
|
||||
// this user (so the gateway knows whether to show "Manage billing"),
|
||||
// without exposing the actual customer ID. The raw
|
||||
// stripe_customer_id / stripe_subscription_id / stripe_price_id are
|
||||
// deliberately NOT exposed here, mirroring models.Subscription's
|
||||
// json:"-" — they never need to leave billing.
|
||||
bool has_billing_account = 6;
|
||||
}
|
||||
|
||||
message GetOrCreateSubscriptionRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message GetPlanCatalogRequest {}
|
||||
|
||||
message GetPlanCatalogResponse {
|
||||
repeated PlanInfo plans = 1;
|
||||
string publishable_key = 2; // Stripe publishable key, for Checkout.js
|
||||
}
|
||||
|
||||
message CreateCheckoutSessionRequest {
|
||||
int64 user_id = 1;
|
||||
string email = 2;
|
||||
Plan plan = 3;
|
||||
string success_url = 4;
|
||||
string cancel_url = 5;
|
||||
}
|
||||
|
||||
message CreateCheckoutSessionResponse {
|
||||
string checkout_url = 1;
|
||||
}
|
||||
|
||||
message SyncCheckoutSessionRequest {
|
||||
int64 user_id = 1;
|
||||
string stripe_session_id = 2;
|
||||
}
|
||||
|
||||
message CreateBillingPortalSessionRequest {
|
||||
int64 user_id = 1;
|
||||
string return_url = 2;
|
||||
}
|
||||
|
||||
message CreateBillingPortalSessionResponse {
|
||||
string portal_url = 1;
|
||||
}
|
||||
|
||||
message IngestStripeWebhookRequest {
|
||||
bytes payload = 1;
|
||||
string signature_header = 2;
|
||||
}
|
||||
|
||||
message IngestStripeWebhookResponse {
|
||||
bool accepted = 1;
|
||||
}
|
||||
|
||||
message CheckAndIncrementUsageRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message CheckAndIncrementUsageResponse {
|
||||
bool allowed = 1;
|
||||
int32 used = 2;
|
||||
int32 limit = 3; // 0 = unlimited
|
||||
string plan_name = 4;
|
||||
}
|
||||
|
||||
message GetDailyUsageRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message GetDailyUsageResponse {
|
||||
int32 used = 1;
|
||||
}
|
||||
28
api/proto/common/v1/common.proto
Normal file
28
api/proto/common/v1/common.proto
Normal file
@@ -0,0 +1,28 @@
|
||||
// Package common holds message types shared across every enkly bounded
|
||||
// context's gRPC API, so each context's own .proto only needs to import
|
||||
// this one file instead of depending on each other directly.
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.common.v1;
|
||||
|
||||
option go_package = "enkly/gen/common/v1;commonv1";
|
||||
|
||||
// Money is a decimal amount in the smallest unit of currency (cents),
|
||||
// matching how internal/billing.Info prices plans today (EUR integer
|
||||
// amounts) without pulling in a floating point rounding bug.
|
||||
message Money {
|
||||
int64 minor_units = 1; // e.g. 500 = 5.00
|
||||
string currency_code = 2; // ISO 4217, e.g. "EUR"
|
||||
}
|
||||
|
||||
// PageRequest / PageResponse: not used by today's list endpoints (the
|
||||
// monolith never paginates conversations or messages), reserved so a
|
||||
// service can add pagination later without a breaking proto change.
|
||||
message PageRequest {
|
||||
int32 page_size = 1;
|
||||
string page_token = 2;
|
||||
}
|
||||
|
||||
message PageResponse {
|
||||
string next_page_token = 1;
|
||||
}
|
||||
136
api/proto/conversation/v1/conversation.proto
Normal file
136
api/proto/conversation/v1/conversation.proto
Normal file
@@ -0,0 +1,136 @@
|
||||
// Package conversation is the Conversation bounded context: chat threads
|
||||
// and their messages/tool-call audit log. System of record for
|
||||
// `conversations`, `messages` and `tool_calls` (see
|
||||
// services/conversation/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/db/conversations.go, called both by the gateway
|
||||
// (listing/renaming/deleting threads, loading history to render) and by
|
||||
// the agent service (appending messages as a run proceeds).
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.conversation.v1;
|
||||
|
||||
option go_package = "enkly/gen/conversation/v1;conversationv1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service ConversationService {
|
||||
rpc CreateConversation(CreateConversationRequest) returns (Conversation);
|
||||
rpc GetConversation(GetConversationRequest) returns (Conversation);
|
||||
rpc ListConversations(ListConversationsRequest) returns (ListConversationsResponse);
|
||||
rpc MostRecentConversation(MostRecentConversationRequest) returns (Conversation);
|
||||
rpc RenameConversation(RenameConversationRequest) returns (Conversation);
|
||||
// AutoTitleConversation replaces db.AutoTitleConversation: sets the
|
||||
// title from the first prompt only if the conversation still has the
|
||||
// default title (returns retitled=true when it actually changed it).
|
||||
rpc AutoTitleConversation(AutoTitleConversationRequest) returns (AutoTitleConversationResponse);
|
||||
rpc DeleteConversation(DeleteConversationRequest) returns (DeleteConversationResponse);
|
||||
rpc CountMessages(CountMessagesRequest) returns (CountMessagesResponse);
|
||||
|
||||
rpc AddMessage(AddMessageRequest) returns (Message);
|
||||
rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse);
|
||||
rpc AddToolCall(AddToolCallRequest) returns (AddToolCallResponse);
|
||||
}
|
||||
|
||||
message Conversation {
|
||||
int64 id = 1;
|
||||
int64 user_id = 2;
|
||||
string title = 3;
|
||||
google.protobuf.Timestamp created_at = 4;
|
||||
google.protobuf.Timestamp last_message_at = 5;
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ROLE_UNSPECIFIED = 0;
|
||||
ROLE_USER = 1;
|
||||
ROLE_ASSISTANT = 2;
|
||||
ROLE_SYSTEM = 3;
|
||||
ROLE_TOOL = 4;
|
||||
}
|
||||
|
||||
message Message {
|
||||
int64 id = 1;
|
||||
int64 conversation_id = 2;
|
||||
Role role = 3;
|
||||
string content = 4;
|
||||
string tool_name = 5;
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
}
|
||||
|
||||
message CreateConversationRequest {
|
||||
int64 user_id = 1;
|
||||
string title = 2;
|
||||
}
|
||||
|
||||
message GetConversationRequest {
|
||||
int64 user_id = 1;
|
||||
int64 conversation_id = 2;
|
||||
}
|
||||
|
||||
message ListConversationsRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message ListConversationsResponse {
|
||||
repeated Conversation conversations = 1;
|
||||
}
|
||||
|
||||
message MostRecentConversationRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message RenameConversationRequest {
|
||||
int64 user_id = 1;
|
||||
int64 conversation_id = 2;
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
message AutoTitleConversationRequest {
|
||||
int64 user_id = 1;
|
||||
int64 conversation_id = 2;
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
message AutoTitleConversationResponse {
|
||||
bool retitled = 1;
|
||||
}
|
||||
|
||||
message DeleteConversationRequest {
|
||||
int64 user_id = 1;
|
||||
int64 conversation_id = 2;
|
||||
}
|
||||
|
||||
message DeleteConversationResponse {}
|
||||
|
||||
message CountMessagesRequest {
|
||||
int64 conversation_id = 1;
|
||||
}
|
||||
|
||||
message CountMessagesResponse {
|
||||
int32 count = 1;
|
||||
}
|
||||
|
||||
message AddMessageRequest {
|
||||
int64 conversation_id = 1;
|
||||
Role role = 2;
|
||||
string content = 3;
|
||||
string tool_name = 4;
|
||||
}
|
||||
|
||||
message ListMessagesRequest {
|
||||
int64 conversation_id = 1;
|
||||
}
|
||||
|
||||
message ListMessagesResponse {
|
||||
repeated Message messages = 1;
|
||||
}
|
||||
|
||||
message AddToolCallRequest {
|
||||
int64 message_id = 1;
|
||||
string tool_name = 2;
|
||||
string arguments_json = 3;
|
||||
string result = 4;
|
||||
bool is_error = 5;
|
||||
}
|
||||
|
||||
message AddToolCallResponse {}
|
||||
165
api/proto/iam/v1/iam.proto
Normal file
165
api/proto/iam/v1/iam.proto
Normal file
@@ -0,0 +1,165 @@
|
||||
// Package iam is the Identity & Access bounded context: accounts,
|
||||
// sessions, email verification and password reset. It is the system of
|
||||
// record for `users`, `sessions`, `email_verification_codes` and
|
||||
// `password_reset_tokens` (see services/iam/migrations/0001_init.sql) —
|
||||
// no other service may read or write those tables directly.
|
||||
//
|
||||
// Mirrors the monolith's internal/handlers/auth.go + internal/db/users.go
|
||||
// + internal/auth/*.go one-for-one; see each rpc's comment for the
|
||||
// handler function it replaces.
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.iam.v1;
|
||||
|
||||
option go_package = "enkly/gen/iam/v1;iamv1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service IAMService {
|
||||
// SignUp replaces Handlers.Signup. Validates and normalizes the email,
|
||||
// hashes the password (PBKDF2, see internal/auth/password.go), creates
|
||||
// the account (or silently reuses an existing unverified one — same
|
||||
// anti-enumeration behavior as the monolith), issues a verification
|
||||
// code and publishes an iam.verification_code.issued event for the
|
||||
// notification service to deliver it. Never returns whether the email
|
||||
// already existed.
|
||||
rpc SignUp(SignUpRequest) returns (SignUpResponse);
|
||||
|
||||
// ResendVerification replaces Handlers.ResendVerification.
|
||||
rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse);
|
||||
|
||||
// VerifyEmail replaces Handlers.VerifyEmail.
|
||||
rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse);
|
||||
|
||||
// Login replaces Handlers.Login: verifies credentials (constant-time
|
||||
// against a dummy hash for unknown emails) and, on success, mints a
|
||||
// session and returns its raw token for the gateway to set as a
|
||||
// cookie. Never persists the raw token — only its SHA-256 hash.
|
||||
rpc Login(LoginRequest) returns (LoginResponse);
|
||||
|
||||
// Logout replaces Handlers.Logout: deletes the session row for the
|
||||
// given raw session token.
|
||||
rpc Logout(LogoutRequest) returns (LogoutResponse);
|
||||
|
||||
// ValidateSession replaces the session half of Handlers.RequireAuth /
|
||||
// db.GetSessionUser. Called by the gateway on every authenticated
|
||||
// request.
|
||||
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
|
||||
|
||||
// ForgotPassword replaces Handlers.ForgotPassword.
|
||||
rpc ForgotPassword(ForgotPasswordRequest) returns (ForgotPasswordResponse);
|
||||
|
||||
// CheckPasswordResetToken replaces the GET-time validity check in
|
||||
// Handlers.ResetPasswordPage (db.PasswordResetTokenValid).
|
||||
rpc CheckPasswordResetToken(CheckPasswordResetTokenRequest) returns (CheckPasswordResetTokenResponse);
|
||||
|
||||
// ResetPassword replaces Handlers.ResetPassword. Also revokes every
|
||||
// session for the account (db.UpdatePassword's cascade delete).
|
||||
rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse);
|
||||
|
||||
// GetUser is used by other services (e.g. billing, when it only has a
|
||||
// user_id) to resolve an email address. Never returns PasswordHash.
|
||||
rpc GetUser(GetUserRequest) returns (User);
|
||||
}
|
||||
|
||||
message User {
|
||||
int64 id = 1;
|
||||
string email = 2;
|
||||
bool email_verified = 3;
|
||||
google.protobuf.Timestamp created_at = 4;
|
||||
}
|
||||
|
||||
message SignUpRequest {
|
||||
string email = 1;
|
||||
string password = 2;
|
||||
string password_confirm = 3;
|
||||
string client_ip = 4; // for the shared signup/resend rate limiter
|
||||
}
|
||||
|
||||
message SignUpResponse {
|
||||
// Always true unless a hard infrastructure error occurred — the
|
||||
// monolith never reveals whether the address was already registered.
|
||||
bool check_inbox = 1;
|
||||
string email = 2;
|
||||
// mail_configured is echoed back so the gateway can show the same
|
||||
// "written to the server log instead" notice the monolith shows.
|
||||
bool mail_configured = 3;
|
||||
}
|
||||
|
||||
message ResendVerificationRequest {
|
||||
string email = 1;
|
||||
string client_ip = 2;
|
||||
}
|
||||
|
||||
message ResendVerificationResponse {
|
||||
bool mail_configured = 1;
|
||||
}
|
||||
|
||||
message VerifyEmailRequest {
|
||||
string email = 1;
|
||||
string code = 2;
|
||||
string client_ip = 3;
|
||||
}
|
||||
|
||||
message VerifyEmailResponse {
|
||||
bool verified = 1;
|
||||
}
|
||||
|
||||
message LoginRequest {
|
||||
string email = 1;
|
||||
string password = 2;
|
||||
string client_ip = 3;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
bool ok = 1;
|
||||
// Only one of these is meaningful depending on `ok` / `needs_verification`.
|
||||
bool needs_verification = 2;
|
||||
string session_token = 3; // raw token; gateway stores only this, in a cookie
|
||||
google.protobuf.Timestamp session_expires_at = 4;
|
||||
User user = 5;
|
||||
}
|
||||
|
||||
message LogoutRequest {
|
||||
string session_token = 1;
|
||||
}
|
||||
|
||||
message LogoutResponse {}
|
||||
|
||||
message ValidateSessionRequest {
|
||||
string session_token = 1;
|
||||
}
|
||||
|
||||
message ValidateSessionResponse {
|
||||
bool valid = 1;
|
||||
User user = 2;
|
||||
}
|
||||
|
||||
message ForgotPasswordRequest {
|
||||
string email = 1;
|
||||
string client_ip = 2;
|
||||
}
|
||||
|
||||
message ForgotPasswordResponse {}
|
||||
|
||||
message CheckPasswordResetTokenRequest {
|
||||
string token = 1;
|
||||
}
|
||||
|
||||
message CheckPasswordResetTokenResponse {
|
||||
bool valid = 1;
|
||||
}
|
||||
|
||||
message ResetPasswordRequest {
|
||||
string token = 1;
|
||||
string password = 2;
|
||||
string password_confirm = 3;
|
||||
}
|
||||
|
||||
message ResetPasswordResponse {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message GetUserRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
75
api/proto/knowledge/v1/knowledge.proto
Normal file
75
api/proto/knowledge/v1/knowledge.proto
Normal file
@@ -0,0 +1,75 @@
|
||||
// Package knowledge is the Code Intelligence (RAG) bounded context:
|
||||
// chunking, embedding (via the knowledge service's vLLM-backed embed client) and
|
||||
// cosine-similarity search over workspace source files. System of
|
||||
// record for the pgvector-backed `documents` table (see
|
||||
// services/knowledge/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/rag/*.go. IndexDirectory (cmd/indexer) now walks the
|
||||
// workspace through the workspace service instead of the local
|
||||
// filesystem — see tools/indexer.
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.knowledge.v1;
|
||||
|
||||
option go_package = "enkly/gen/knowledge/v1;knowledgev1";
|
||||
|
||||
service KnowledgeService {
|
||||
// IndexFile replaces rag.Store.IndexFile: chunks the given file
|
||||
// content, embeds each new/changed chunk, and upserts it.
|
||||
rpc IndexFile(IndexFileRequest) returns (IndexFileResponse);
|
||||
|
||||
// PruneMissing replaces rag.Store.pruneMissing, called by the indexer
|
||||
// after a full directory walk to drop rows for files no longer
|
||||
// present.
|
||||
rpc PruneMissing(PruneMissingRequest) returns (PruneMissingResponse);
|
||||
|
||||
// Search replaces rag.Store.Search — the search_codebase agent tool.
|
||||
rpc Search(SearchRequest) returns (SearchResponse);
|
||||
|
||||
// ListIndexedFiles replaces db.ListIndexedFiles, used by the gateway's
|
||||
// sidebar "indexed files" panel (Handlers.renderConversation).
|
||||
rpc ListIndexedFiles(ListIndexedFilesRequest) returns (ListIndexedFilesResponse);
|
||||
}
|
||||
|
||||
message CodeChunk {
|
||||
int64 id = 1;
|
||||
string file_path = 2;
|
||||
int32 chunk_index = 3;
|
||||
string content = 4;
|
||||
double distance = 5; // cosine distance; lower = more relevant
|
||||
}
|
||||
|
||||
message IndexFileRequest {
|
||||
string file_path = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message IndexFileResponse {
|
||||
int32 chunks_indexed = 1;
|
||||
int32 chunks_skipped = 2; // unchanged content_hash
|
||||
}
|
||||
|
||||
message PruneMissingRequest {
|
||||
repeated string keep_file_paths = 1;
|
||||
}
|
||||
|
||||
message PruneMissingResponse {
|
||||
int32 rows_deleted = 1;
|
||||
}
|
||||
|
||||
message SearchRequest {
|
||||
string query = 1;
|
||||
int32 top_k = 2;
|
||||
}
|
||||
|
||||
message SearchResponse {
|
||||
repeated CodeChunk chunks = 1;
|
||||
}
|
||||
|
||||
message ListIndexedFilesRequest {
|
||||
int32 limit = 1; // 0 = default (200)
|
||||
}
|
||||
|
||||
message ListIndexedFilesResponse {
|
||||
repeated string file_paths = 1;
|
||||
}
|
||||
196
api/proto/workspace/v1/workspace.proto
Normal file
196
api/proto/workspace/v1/workspace.proto
Normal file
@@ -0,0 +1,196 @@
|
||||
// Package workspace is the Workspace (agent sandbox / files) bounded
|
||||
// context: the single shared ENKLY_WORKSPACE directory the coding agent
|
||||
// reads, writes and runs commands in, and that the "Files" panel in the
|
||||
// UI lets a human upload into / download from. Every path-jailing rule
|
||||
// from internal/llm/sandbox.go (ResolvePath, the bash allowlist, the
|
||||
// filesystem guards) is enforced here and nowhere else — no other
|
||||
// service is allowed to touch the workspace volume directly.
|
||||
//
|
||||
// Mirrors internal/llm/tools.go (filesystem + bash tools) and
|
||||
// internal/handlers/files.go / ui.go (upload, download, tree).
|
||||
syntax = "proto3";
|
||||
|
||||
package enkly.workspace.v1;
|
||||
|
||||
option go_package = "enkly/gen/workspace/v1;workspacev1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service WorkspaceService {
|
||||
// --- agent filesystem/bash tools (internal/llm/tools.go, sandbox.go) ---
|
||||
rpc ReadFile(ReadFileRequest) returns (ReadFileResponse);
|
||||
rpc WriteFile(WriteFileRequest) returns (WriteFileResponse);
|
||||
rpc EditFile(EditFileRequest) returns (EditFileResponse);
|
||||
rpc MultiEditFile(MultiEditFileRequest) returns (MultiEditFileResponse);
|
||||
rpc ListDirectory(ListDirectoryRequest) returns (ListDirectoryResponse);
|
||||
rpc GlobSearch(GlobSearchRequest) returns (GlobSearchResponse);
|
||||
rpc Grep(GrepRequest) returns (GrepResponse);
|
||||
rpc RunBashCommand(RunBashCommandRequest) returns (RunBashCommandResponse);
|
||||
|
||||
// --- human-facing files panel (internal/handlers/files.go, ui.go) ---
|
||||
rpc UploadFile(UploadFileRequest) returns (UploadFileResponse);
|
||||
rpc DownloadFile(DownloadFileRequest) returns (DownloadFileResponse);
|
||||
rpc Tree(TreeRequest) returns (TreeResponse);
|
||||
rpc StatFile(StatFileRequest) returns (StatFileResponse); // backs deliverable cards
|
||||
}
|
||||
|
||||
message ReadFileRequest {
|
||||
string path = 1;
|
||||
int32 offset = 2; // 0-based line offset
|
||||
int32 limit = 3; // 0 = default (maxReadLines)
|
||||
}
|
||||
|
||||
message ReadFileResponse {
|
||||
string content = 1;
|
||||
bool truncated = 2;
|
||||
bool is_error = 3;
|
||||
string error_message = 4;
|
||||
}
|
||||
|
||||
message WriteFileRequest {
|
||||
string path = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message WriteFileResponse {
|
||||
int64 bytes_written = 1;
|
||||
bool is_error = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message EditFileRequest {
|
||||
string path = 1;
|
||||
string old_string = 2;
|
||||
string new_string = 3;
|
||||
bool replace_all = 4;
|
||||
}
|
||||
|
||||
message EditFileResponse {
|
||||
string diff_preview = 1;
|
||||
int32 occurrences = 2;
|
||||
bool is_error = 3;
|
||||
string error_message = 4;
|
||||
}
|
||||
|
||||
message TextEdit {
|
||||
string old_string = 1;
|
||||
string new_string = 2;
|
||||
bool replace_all = 3;
|
||||
}
|
||||
|
||||
message MultiEditFileRequest {
|
||||
string path = 1;
|
||||
repeated TextEdit edits = 2;
|
||||
}
|
||||
|
||||
message MultiEditFileResponse {
|
||||
string diff_preview = 1;
|
||||
bool is_error = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message ListDirectoryRequest {
|
||||
string path = 1;
|
||||
}
|
||||
|
||||
message ListDirectoryResponse {
|
||||
string listing = 1; // pre-rendered, matching the monolith's tool output format
|
||||
bool is_error = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message GlobSearchRequest {
|
||||
string pattern = 1;
|
||||
string path = 2;
|
||||
}
|
||||
|
||||
message GlobSearchResponse {
|
||||
// Pre-rendered exactly like every other tool's text output (a
|
||||
// newline-separated path list, "no files matched that pattern", or a
|
||||
// truncation notice) — deliberately NOT a repeated string, since that
|
||||
// would require guessing which lines are paths vs. a status message,
|
||||
// a lossy transformation the original tool output never needed.
|
||||
string output = 1;
|
||||
bool is_error = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message GrepRequest {
|
||||
string pattern = 1;
|
||||
string path = 2;
|
||||
string glob = 3;
|
||||
bool case_insensitive = 4;
|
||||
}
|
||||
|
||||
message GrepResponse {
|
||||
string output = 1;
|
||||
bool is_error = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message RunBashCommandRequest {
|
||||
string command = 1;
|
||||
int32 timeout_seconds = 2; // 0 = service default
|
||||
}
|
||||
|
||||
message RunBashCommandResponse {
|
||||
string output = 1;
|
||||
int32 exit_code = 2;
|
||||
bool is_error = 3;
|
||||
string error_message = 4;
|
||||
}
|
||||
|
||||
message UploadFileRequest {
|
||||
string path = 1; // destination relative path
|
||||
bytes content = 2;
|
||||
}
|
||||
|
||||
message UploadFileResponse {
|
||||
int64 bytes_written = 1;
|
||||
}
|
||||
|
||||
message DownloadFileRequest {
|
||||
string path = 1;
|
||||
}
|
||||
|
||||
message DownloadFileResponse {
|
||||
bytes content = 1;
|
||||
string filename = 2;
|
||||
}
|
||||
|
||||
message TreeRequest {
|
||||
string path = 1; // "" = workspace root
|
||||
// one_level: list only the immediate children of path (both files
|
||||
// and directories, directories included as entries in their own
|
||||
// right) instead of recursing — backs the gateway's lazy-expanding
|
||||
// file explorer panel (internal/handlers/ui.go's FileTree), where
|
||||
// each folder click fetches just that folder. false (the default)
|
||||
// recurses fully and returns only files, for the flat "Workspace
|
||||
// files" upload/download panel (internal/handlers/files.go).
|
||||
bool one_level = 2;
|
||||
}
|
||||
|
||||
message TreeNode {
|
||||
string name = 1;
|
||||
string rel_path = 2;
|
||||
bool is_dir = 3;
|
||||
int64 size = 4;
|
||||
google.protobuf.Timestamp modified_at = 5;
|
||||
// download_url is left empty by this service — the gateway owns URL
|
||||
// construction (it knows its own public route prefix), not workspace.
|
||||
}
|
||||
|
||||
message TreeResponse {
|
||||
repeated TreeNode nodes = 1;
|
||||
}
|
||||
|
||||
message StatFileRequest {
|
||||
string path = 1;
|
||||
}
|
||||
|
||||
message StatFileResponse {
|
||||
bool exists = 1;
|
||||
bool is_dir = 2;
|
||||
int64 size = 3;
|
||||
google.protobuf.Timestamp modified_at = 4;
|
||||
}
|
||||
256
docker-compose.yml
Normal file
256
docker-compose.yml
Normal file
@@ -0,0 +1,256 @@
|
||||
services:
|
||||
# ---------------------------------------------------------------------
|
||||
# Infrastructure
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management-alpine
|
||||
ports: ["5672:5672", "15672:15672"]
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: enkly
|
||||
RABBITMQ_DEFAULT_PASS: enkly
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# vLLM OpenAI-compatible inference servers. One container per model —
|
||||
# a vLLM engine process serves exactly one model, so chat (Qwen,
|
||||
# tool-calling + reasoning) and embeddings (Nomic) are two separate
|
||||
# services with independent lifecycles, rather than the two "tags"
|
||||
# one shared Ollama daemon used to serve. Both need an NVIDIA GPU and
|
||||
# the nvidia container runtime (`docker info | grep nvidia` to check)
|
||||
# — on a CPU-only dev box, run `docker compose --profile cpu up` isn't
|
||||
# provided here because vLLM's CPU backend is a materially different
|
||||
# build/image; see https://docs.vllm.ai for that path if you need it.
|
||||
#
|
||||
# Pin the image tag to a version you've actually tested — "latest" on
|
||||
# a GPU inference server is how a routine `docker compose pull`
|
||||
# becomes an unplanned model-serving outage. v0.31.0 below is this
|
||||
# deployment's current pin (matches k8s/11-vllm.yaml); bump
|
||||
# deliberately. Must stay >= the first tag with Qwen3.5 model
|
||||
# support or the server fails to load with an unrecognized
|
||||
# architecture error.
|
||||
vllm-chat:
|
||||
image: vllm/vllm-openai:v0.31.0
|
||||
command:
|
||||
- --model=Qwen/Qwen3.5-35B-A3B-GPTQ-Int4
|
||||
- --served-model-name=${ENKLY_CHAT_MODEL:-qwen3.5}
|
||||
- --quantization=gptq_marlin
|
||||
- --tool-call-parser=qwen3_coder
|
||||
- --enable-auto-tool-choice
|
||||
- --reasoning-parser=qwen3
|
||||
- --max-model-len=32768
|
||||
- --gpu-memory-utilization=0.90
|
||||
- --tensor-parallel-size=${ENKLY_CHAT_TP_SIZE:-1}
|
||||
- --api-key=${ENKLY_VLLM_API_KEY:-}
|
||||
ports: ["8000:8000"]
|
||||
volumes: ["hf-cache:/root/.cache/huggingface"]
|
||||
environment:
|
||||
HUGGING_FACE_HUB_TOKEN: "${HUGGING_FACE_HUB_TOKEN:-}"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: ${ENKLY_CHAT_TP_SIZE:-1}
|
||||
capabilities: [gpu]
|
||||
# vLLM's own /dev/shm requirement for NCCL/CUDA-graph scratch space
|
||||
# — without this, multi-GPU (tensor-parallel) startup or even
|
||||
# single-GPU CUDA-graph capture can fail or silently fall back to a
|
||||
# slower path. 2gb is generous for a single-GPU chat replica; raise
|
||||
# it if ENKLY_CHAT_TP_SIZE > 1.
|
||||
shm_size: "2gb"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
|
||||
# Model load (Qwen3.5-35B-A3B-GPTQ-Int4, ~20GB on disk) can still
|
||||
# take a few minutes from a cold Hugging Face cache — a short
|
||||
# interval just means more log noise while it warms up, not a
|
||||
# faster start.
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 40
|
||||
start_period: 10m
|
||||
|
||||
vllm-embed:
|
||||
image: vllm/vllm-openai:v0.6.3
|
||||
command:
|
||||
- --model=nomic-ai/nomic-embed-text-v1.5
|
||||
- --served-model-name=${ENKLY_EMBED_MODEL:-nomic-embed-text}
|
||||
- --task=embed
|
||||
- --trust-remote-code
|
||||
- --gpu-memory-utilization=0.30
|
||||
ports: ["8001:8000"]
|
||||
volumes: ["hf-cache:/root/.cache/huggingface"]
|
||||
environment:
|
||||
HUGGING_FACE_HUB_TOKEN: "${HUGGING_FACE_HUB_TOKEN:-}"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
shm_size: "1gb"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 3m
|
||||
|
||||
# Database-per-service: four separate Postgres databases, never one
|
||||
# shared instance a service could accidentally cross-query. knowledge
|
||||
# uses the pgvector image; the other three a bare postgres image.
|
||||
iam-db:
|
||||
image: postgres:16-alpine
|
||||
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_iam}
|
||||
volumes: ["iam-db-data:/var/lib/postgresql/data"]
|
||||
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
|
||||
|
||||
billing-db:
|
||||
image: postgres:16-alpine
|
||||
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_billing}
|
||||
volumes: ["billing-db-data:/var/lib/postgresql/data"]
|
||||
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
|
||||
|
||||
conversation-db:
|
||||
image: postgres:16-alpine
|
||||
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_conversation}
|
||||
volumes: ["conversation-db-data:/var/lib/postgresql/data"]
|
||||
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
|
||||
|
||||
knowledge-db:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_knowledge}
|
||||
volumes: ["knowledge-db-data:/var/lib/postgresql/data"]
|
||||
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Application services
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
iam:
|
||||
build: {context: ., dockerfile: services/iam/Dockerfile}
|
||||
depends_on:
|
||||
iam-db: {condition: service_healthy}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
environment:
|
||||
ENKLY_IAM_GRPC_ADDR: ":9001"
|
||||
ENKLY_IAM_POSTGRES_DSN: "postgres://enkly:enkly@iam-db:5432/enkly_iam?sslmode=disable"
|
||||
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
|
||||
ENKLY_BASE_URL: "${ENKLY_BASE_URL:-http://localhost:8080}"
|
||||
ENKLY_SMTP_USERNAME: "${ENKLY_SMTP_USERNAME:-}"
|
||||
ENKLY_SMTP_PASSWORD: "${ENKLY_SMTP_PASSWORD:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
billing:
|
||||
build: {context: ., dockerfile: services/billing/Dockerfile}
|
||||
depends_on:
|
||||
billing-db: {condition: service_healthy}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
environment:
|
||||
ENKLY_BILLING_GRPC_ADDR: ":9002"
|
||||
ENKLY_BILLING_POSTGRES_DSN: "postgres://enkly:enkly@billing-db:5432/enkly_billing?sslmode=disable"
|
||||
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
|
||||
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
|
||||
STRIPE_PUBLISH_KEY: "${STRIPE_PUBLISH_KEY:-}"
|
||||
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
|
||||
STRIPE_PRICE_BASIC: "${STRIPE_PRICE_BASIC:-}"
|
||||
STRIPE_PRICE_PRO: "${STRIPE_PRICE_PRO:-}"
|
||||
STRIPE_PRICE_PREMIUM: "${STRIPE_PRICE_PREMIUM:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
conversation:
|
||||
build: {context: ., dockerfile: services/conversation/Dockerfile}
|
||||
depends_on:
|
||||
conversation-db: {condition: service_healthy}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
environment:
|
||||
ENKLY_CONVERSATION_GRPC_ADDR: ":9003"
|
||||
ENKLY_CONVERSATION_POSTGRES_DSN: "postgres://enkly:enkly@conversation-db:5432/enkly_conversation?sslmode=disable"
|
||||
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
|
||||
restart: unless-stopped
|
||||
|
||||
workspace:
|
||||
build: {context: ., dockerfile: services/workspace/Dockerfile}
|
||||
volumes: ["workspace-data:/data/workspace"]
|
||||
environment:
|
||||
ENKLY_WORKSPACE_GRPC_ADDR: ":9004"
|
||||
ENKLY_WORKSPACE: "/data/workspace"
|
||||
ENKLY_BASH_TIMEOUT_SECONDS: "${ENKLY_BASH_TIMEOUT_SECONDS:-0}"
|
||||
ENKLY_BASH_ALLOWLIST: "${ENKLY_BASH_ALLOWLIST:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
knowledge:
|
||||
build: {context: ., dockerfile: services/knowledge/Dockerfile}
|
||||
depends_on:
|
||||
knowledge-db: {condition: service_healthy}
|
||||
vllm-embed: {condition: service_healthy}
|
||||
environment:
|
||||
ENKLY_KNOWLEDGE_GRPC_ADDR: ":9005"
|
||||
ENKLY_KNOWLEDGE_POSTGRES_DSN: "postgres://enkly:enkly@knowledge-db:5432/enkly_knowledge?sslmode=disable"
|
||||
ENKLY_VLLM_EMBED_URL: "https://vllm-embed.enkly.org/v1"
|
||||
ENKLY_EMBED_MODEL: "${ENKLY_EMBED_MODEL:-nomic-embed-text}"
|
||||
ENKLY_VLLM_API_KEY: "${ENKLY_VLLM_API_KEY:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
agent:
|
||||
build: {context: ., dockerfile: services/agent/Dockerfile}
|
||||
depends_on:
|
||||
workspace: {condition: service_started}
|
||||
knowledge: {condition: service_started}
|
||||
billing: {condition: service_started}
|
||||
conversation: {condition: service_started}
|
||||
vllm-chat: {condition: service_healthy}
|
||||
vllm-embed: {condition: service_healthy}
|
||||
environment:
|
||||
ENKLY_AGENT_GRPC_ADDR: ":9006"
|
||||
ENKLY_WORKSPACE_GRPC_ADDR: "workspace:9004"
|
||||
ENKLY_KNOWLEDGE_GRPC_ADDR: "knowledge:9005"
|
||||
ENKLY_BILLING_GRPC_ADDR: "billing:9002"
|
||||
ENKLY_CONVERSATION_GRPC_ADDR: "conversation:9003"
|
||||
ENKLY_VLLM_CHAT_URL: "https://vllm-chat.enkly.org/v1"
|
||||
ENKLY_VLLM_EMBED_URL: "https://vllm-embed.enkly.org/v1"
|
||||
ENKLY_CHAT_MODEL: "${ENKLY_CHAT_MODEL:-qwen3.5}"
|
||||
ENKLY_EMBED_MODEL: "${ENKLY_EMBED_MODEL:-nomic-embed-text}"
|
||||
ENKLY_VLLM_API_KEY: "${ENKLY_VLLM_API_KEY:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
notification:
|
||||
build: {context: ., dockerfile: services/notification/Dockerfile}
|
||||
depends_on:
|
||||
rabbitmq: {condition: service_healthy}
|
||||
environment:
|
||||
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
|
||||
ENKLY_SMTP_HOST: "${ENKLY_SMTP_HOST:-smtp.gmail.com}"
|
||||
ENKLY_SMTP_PORT: "${ENKLY_SMTP_PORT:-587}"
|
||||
ENKLY_SMTP_USERNAME: "${ENKLY_SMTP_USERNAME:-}"
|
||||
ENKLY_SMTP_PASSWORD: "${ENKLY_SMTP_PASSWORD:-}"
|
||||
ENKLY_SMTP_FROM: "${ENKLY_SMTP_FROM:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
gateway:
|
||||
build: {context: ., dockerfile: services/gateway/Dockerfile}
|
||||
depends_on: [iam, billing, conversation, workspace, knowledge, agent]
|
||||
ports: ["8080:8080"]
|
||||
environment:
|
||||
ENKLY_GATEWAY_HTTP_ADDR: ":8080"
|
||||
ENKLY_BASE_URL: "${ENKLY_BASE_URL:-http://localhost:8080}"
|
||||
ENKLY_IAM_GRPC_ADDR: "iam:9001"
|
||||
ENKLY_BILLING_GRPC_ADDR: "billing:9002"
|
||||
ENKLY_CONVERSATION_GRPC_ADDR: "conversation:9003"
|
||||
ENKLY_WORKSPACE_GRPC_ADDR: "workspace:9004"
|
||||
ENKLY_KNOWLEDGE_GRPC_ADDR: "knowledge:9005"
|
||||
ENKLY_AGENT_GRPC_ADDR: "agent:9006"
|
||||
STRIPE_PUBLISH_KEY: "${STRIPE_PUBLISH_KEY:-}"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hf-cache:
|
||||
iam-db-data:
|
||||
billing-db-data:
|
||||
conversation-db-data:
|
||||
knowledge-db-data:
|
||||
workspace-data:
|
||||
999
gen/agent/v1/agent.pb.go
Normal file
999
gen/agent/v1/agent.pb.go
Normal file
@@ -0,0 +1,999 @@
|
||||
// Package agent is the Agent Orchestration bounded context: the agentic
|
||||
// run loop (prompt -> vLLM -> tool calls -> final reply), the chat-run
|
||||
// quota/concurrency guard, and the in-flight/just-finished run registry
|
||||
// that used to live in internal/handlers/chat.go's StreamManager.
|
||||
//
|
||||
// This is a saga coordinator, not a data owner: it holds no SQL table of
|
||||
// its own. It calls billing (usage quota), conversation (persisting
|
||||
// turns) and knowledge (the search_codebase tool) over gRPC, and
|
||||
// workspace (every filesystem/bash tool) over gRPC, exactly replacing
|
||||
// internal/llm/agent.go + internal/llm/tools.go's direct, in-process
|
||||
// calls into internal/db and the local filesystem.
|
||||
//
|
||||
// Rendering (Markdown, syntax highlighting, deliverable cards) stays out
|
||||
// of this service on purpose — see internal/render's doc comment in the
|
||||
// gateway service — so this API deals in plain text and structured tool
|
||||
// results, never HTML.
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v3.21.12
|
||||
// source: agent/v1/agent.proto
|
||||
|
||||
package agentv1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type StartTurnOutcome int32
|
||||
|
||||
const (
|
||||
StartTurnOutcome_START_TURN_OUTCOME_UNSPECIFIED StartTurnOutcome = 0
|
||||
StartTurnOutcome_START_TURN_OUTCOME_OK StartTurnOutcome = 1
|
||||
StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_NOT_FOUND StartTurnOutcome = 2
|
||||
StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_BUSY StartTurnOutcome = 3
|
||||
StartTurnOutcome_START_TURN_OUTCOME_TOO_MANY_STREAMS StartTurnOutcome = 4
|
||||
StartTurnOutcome_START_TURN_OUTCOME_RATE_LIMITED StartTurnOutcome = 5
|
||||
StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED StartTurnOutcome = 6
|
||||
StartTurnOutcome_START_TURN_OUTCOME_INTERNAL_ERROR StartTurnOutcome = 7
|
||||
)
|
||||
|
||||
// Enum value maps for StartTurnOutcome.
|
||||
var (
|
||||
StartTurnOutcome_name = map[int32]string{
|
||||
0: "START_TURN_OUTCOME_UNSPECIFIED",
|
||||
1: "START_TURN_OUTCOME_OK",
|
||||
2: "START_TURN_OUTCOME_CONVERSATION_NOT_FOUND",
|
||||
3: "START_TURN_OUTCOME_CONVERSATION_BUSY",
|
||||
4: "START_TURN_OUTCOME_TOO_MANY_STREAMS",
|
||||
5: "START_TURN_OUTCOME_RATE_LIMITED",
|
||||
6: "START_TURN_OUTCOME_QUOTA_EXCEEDED",
|
||||
7: "START_TURN_OUTCOME_INTERNAL_ERROR",
|
||||
}
|
||||
StartTurnOutcome_value = map[string]int32{
|
||||
"START_TURN_OUTCOME_UNSPECIFIED": 0,
|
||||
"START_TURN_OUTCOME_OK": 1,
|
||||
"START_TURN_OUTCOME_CONVERSATION_NOT_FOUND": 2,
|
||||
"START_TURN_OUTCOME_CONVERSATION_BUSY": 3,
|
||||
"START_TURN_OUTCOME_TOO_MANY_STREAMS": 4,
|
||||
"START_TURN_OUTCOME_RATE_LIMITED": 5,
|
||||
"START_TURN_OUTCOME_QUOTA_EXCEEDED": 6,
|
||||
"START_TURN_OUTCOME_INTERNAL_ERROR": 7,
|
||||
}
|
||||
)
|
||||
|
||||
func (x StartTurnOutcome) Enum() *StartTurnOutcome {
|
||||
p := new(StartTurnOutcome)
|
||||
*p = x
|
||||
return p
|
||||
}
|
||||
|
||||
func (x StartTurnOutcome) String() string {
|
||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||
}
|
||||
|
||||
func (StartTurnOutcome) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_agent_v1_agent_proto_enumTypes[0].Descriptor()
|
||||
}
|
||||
|
||||
func (StartTurnOutcome) Type() protoreflect.EnumType {
|
||||
return &file_agent_v1_agent_proto_enumTypes[0]
|
||||
}
|
||||
|
||||
func (x StartTurnOutcome) Number() protoreflect.EnumNumber {
|
||||
return protoreflect.EnumNumber(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StartTurnOutcome.Descriptor instead.
|
||||
func (StartTurnOutcome) EnumDescriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type StartTurnRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
ConversationId int64 `protobuf:"varint,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
|
||||
Prompt string `protobuf:"bytes,3,opt,name=prompt,proto3" json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
func (x *StartTurnRequest) Reset() {
|
||||
*x = StartTurnRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *StartTurnRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StartTurnRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StartTurnRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StartTurnRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StartTurnRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *StartTurnRequest) GetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *StartTurnRequest) GetConversationId() int64 {
|
||||
if x != nil {
|
||||
return x.ConversationId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *StartTurnRequest) GetPrompt() string {
|
||||
if x != nil {
|
||||
return x.Prompt
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type StartTurnResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Outcome StartTurnOutcome `protobuf:"varint,1,opt,name=outcome,proto3,enum=enkly.agent.v1.StartTurnOutcome" json:"outcome,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // user-facing text for any non-OK outcome
|
||||
StreamId string `protobuf:"bytes,3,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` // set when outcome == OK
|
||||
}
|
||||
|
||||
func (x *StartTurnResponse) Reset() {
|
||||
*x = StartTurnResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *StartTurnResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StartTurnResponse) ProtoMessage() {}
|
||||
|
||||
func (x *StartTurnResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StartTurnResponse.ProtoReflect.Descriptor instead.
|
||||
func (*StartTurnResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *StartTurnResponse) GetOutcome() StartTurnOutcome {
|
||||
if x != nil {
|
||||
return x.Outcome
|
||||
}
|
||||
return StartTurnOutcome_START_TURN_OUTCOME_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *StartTurnResponse) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *StartTurnResponse) GetStreamId() string {
|
||||
if x != nil {
|
||||
return x.StreamId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type StreamTurnRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
StreamId string `protobuf:"bytes,2,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"`
|
||||
ResumeFromToolIndex int32 `protobuf:"varint,3,opt,name=resume_from_tool_index,json=resumeFromToolIndex,proto3" json:"resume_from_tool_index,omitempty"`
|
||||
}
|
||||
|
||||
func (x *StreamTurnRequest) Reset() {
|
||||
*x = StreamTurnRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *StreamTurnRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StreamTurnRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StreamTurnRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StreamTurnRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StreamTurnRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *StreamTurnRequest) GetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *StreamTurnRequest) GetStreamId() string {
|
||||
if x != nil {
|
||||
return x.StreamId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *StreamTurnRequest) GetResumeFromToolIndex() int32 {
|
||||
if x != nil {
|
||||
return x.ResumeFromToolIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
ArgumentsJson string `protobuf:"bytes,2,opt,name=arguments_json,json=argumentsJson,proto3" json:"arguments_json,omitempty"`
|
||||
Result string `protobuf:"bytes,3,opt,name=result,proto3" json:"result,omitempty"`
|
||||
IsError bool `protobuf:"varint,4,opt,name=is_error,json=isError,proto3" json:"is_error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ToolCall) Reset() {
|
||||
*x = ToolCall{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ToolCall) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ToolCall) ProtoMessage() {}
|
||||
|
||||
func (x *ToolCall) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
|
||||
func (*ToolCall) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetArgumentsJson() string {
|
||||
if x != nil {
|
||||
return x.ArgumentsJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetResult() string {
|
||||
if x != nil {
|
||||
return x.Result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ToolCall) GetIsError() bool {
|
||||
if x != nil {
|
||||
return x.IsError
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ThinkingRound is one agent round's own reasoning trace, standalone
|
||||
// rather than folded into the running total thinking_delta carries —
|
||||
// the same per-step granularity Claude's interleaved thinking exposes
|
||||
// (one distinct thinking block per tool-use step) rather than one
|
||||
// cumulative blob for the whole turn. round is 0-indexed and matches
|
||||
// the round the agent service's internal run loop was on when this
|
||||
// trace was produced; a client can use it to render reasoning as
|
||||
// discrete, collapsible steps next to the tool call each one preceded,
|
||||
// instead of a single growing "thinking" pane.
|
||||
type ThinkingRound struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Round int32 `protobuf:"varint,1,opt,name=round,proto3" json:"round,omitempty"`
|
||||
Thinking string `protobuf:"bytes,2,opt,name=thinking,proto3" json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ThinkingRound) Reset() {
|
||||
*x = ThinkingRound{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ThinkingRound) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ThinkingRound) ProtoMessage() {}
|
||||
|
||||
func (x *ThinkingRound) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ThinkingRound.ProtoReflect.Descriptor instead.
|
||||
func (*ThinkingRound) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *ThinkingRound) GetRound() int32 {
|
||||
if x != nil {
|
||||
return x.Round
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ThinkingRound) GetThinking() string {
|
||||
if x != nil {
|
||||
return x.Thinking
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type TurnEvent struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// Types that are assignable to Event:
|
||||
//
|
||||
// *TurnEvent_ThinkingDelta
|
||||
// *TurnEvent_TokenDelta
|
||||
// *TurnEvent_ToolCall
|
||||
// *TurnEvent_Done
|
||||
// *TurnEvent_ThinkingRound
|
||||
Event isTurnEvent_Event `protobuf_oneof:"event"`
|
||||
}
|
||||
|
||||
func (x *TurnEvent) Reset() {
|
||||
*x = TurnEvent{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *TurnEvent) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TurnEvent) ProtoMessage() {}
|
||||
|
||||
func (x *TurnEvent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TurnEvent.ProtoReflect.Descriptor instead.
|
||||
func (*TurnEvent) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (m *TurnEvent) GetEvent() isTurnEvent_Event {
|
||||
if m != nil {
|
||||
return m.Event
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TurnEvent) GetThinkingDelta() string {
|
||||
if x, ok := x.GetEvent().(*TurnEvent_ThinkingDelta); ok {
|
||||
return x.ThinkingDelta
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TurnEvent) GetTokenDelta() string {
|
||||
if x, ok := x.GetEvent().(*TurnEvent_TokenDelta); ok {
|
||||
return x.TokenDelta
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TurnEvent) GetToolCall() *ToolCall {
|
||||
if x, ok := x.GetEvent().(*TurnEvent_ToolCall); ok {
|
||||
return x.ToolCall
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TurnEvent) GetDone() *TurnDone {
|
||||
if x, ok := x.GetEvent().(*TurnEvent_Done); ok {
|
||||
return x.Done
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TurnEvent) GetThinkingRound() *ThinkingRound {
|
||||
if x, ok := x.GetEvent().(*TurnEvent_ThinkingRound); ok {
|
||||
return x.ThinkingRound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isTurnEvent_Event interface {
|
||||
isTurnEvent_Event()
|
||||
}
|
||||
|
||||
type TurnEvent_ThinkingDelta struct {
|
||||
ThinkingDelta string `protobuf:"bytes,1,opt,name=thinking_delta,json=thinkingDelta,proto3,oneof"` // cumulative reasoning text so far, plain
|
||||
}
|
||||
|
||||
type TurnEvent_TokenDelta struct {
|
||||
TokenDelta string `protobuf:"bytes,2,opt,name=token_delta,json=tokenDelta,proto3,oneof"` // cumulative reply text so far, plain
|
||||
}
|
||||
|
||||
type TurnEvent_ToolCall struct {
|
||||
ToolCall *ToolCall `protobuf:"bytes,3,opt,name=tool_call,json=toolCall,proto3,oneof"` // one completed tool call
|
||||
}
|
||||
|
||||
type TurnEvent_Done struct {
|
||||
Done *TurnDone `protobuf:"bytes,4,opt,name=done,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TurnEvent_ThinkingRound struct {
|
||||
ThinkingRound *ThinkingRound `protobuf:"bytes,5,opt,name=thinking_round,json=thinkingRound,proto3,oneof"` // one round's own reasoning trace, standalone
|
||||
}
|
||||
|
||||
func (*TurnEvent_ThinkingDelta) isTurnEvent_Event() {}
|
||||
|
||||
func (*TurnEvent_TokenDelta) isTurnEvent_Event() {}
|
||||
|
||||
func (*TurnEvent_ToolCall) isTurnEvent_Event() {}
|
||||
|
||||
func (*TurnEvent_Done) isTurnEvent_Event() {}
|
||||
|
||||
func (*TurnEvent_ThinkingRound) isTurnEvent_Event() {}
|
||||
|
||||
type TurnDone struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
FinalText string `protobuf:"bytes,1,opt,name=final_text,json=finalText,proto3" json:"final_text,omitempty"` // plain Markdown source, not yet rendered
|
||||
// every tool call made during the turn, successful or not — the
|
||||
// gateway uses this to build deliverable (download) cards exactly
|
||||
// like internal/handlers/deliverables.go.attachDeliverables.
|
||||
ToolCalls []*ToolCall `protobuf:"bytes,2,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
func (x *TurnDone) Reset() {
|
||||
*x = TurnDone{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *TurnDone) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TurnDone) ProtoMessage() {}
|
||||
|
||||
func (x *TurnDone) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TurnDone.ProtoReflect.Descriptor instead.
|
||||
func (*TurnDone) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *TurnDone) GetFinalText() string {
|
||||
if x != nil {
|
||||
return x.FinalText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TurnDone) GetToolCalls() []*ToolCall {
|
||||
if x != nil {
|
||||
return x.ToolCalls
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type StopTurnRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
StreamId string `protobuf:"bytes,2,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"`
|
||||
}
|
||||
|
||||
func (x *StopTurnRequest) Reset() {
|
||||
*x = StopTurnRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *StopTurnRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StopTurnRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StopTurnRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StopTurnRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StopTurnRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *StopTurnRequest) GetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *StopTurnRequest) GetStreamId() string {
|
||||
if x != nil {
|
||||
return x.StreamId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type StopTurnResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *StopTurnResponse) Reset() {
|
||||
*x = StopTurnResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *StopTurnResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StopTurnResponse) ProtoMessage() {}
|
||||
|
||||
func (x *StopTurnResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_v1_agent_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StopTurnResponse.ProtoReflect.Descriptor instead.
|
||||
func (*StopTurnResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_v1_agent_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
var File_agent_v1_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_agent_v1_agent_proto_rawDesc = []byte{
|
||||
0x0a, 0x14, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x22, 0x6c, 0x0a, 0x10, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54,
|
||||
0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
|
||||
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x76, 0x65, 0x72, 0x73, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x63, 0x6f,
|
||||
0x6e, 0x76, 0x65, 0x72, 0x73, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06,
|
||||
0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72,
|
||||
0x6f, 0x6d, 0x70, 0x74, 0x22, 0x86, 0x01, 0x0a, 0x11, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54, 0x75,
|
||||
0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x07, 0x6f, 0x75,
|
||||
0x74, 0x63, 0x6f, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x65, 0x6e,
|
||||
0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61,
|
||||
0x72, 0x74, 0x54, 0x75, 0x72, 0x6e, 0x4f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x52, 0x07, 0x6f,
|
||||
0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, 0x7e, 0x0a,
|
||||
0x11, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73,
|
||||
0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
|
||||
0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x72, 0x65, 0x73, 0x75,
|
||||
0x6d, 0x65, 0x5f, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x6e, 0x64,
|
||||
0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x13, 0x72, 0x65, 0x73, 0x75, 0x6d, 0x65,
|
||||
0x46, 0x72, 0x6f, 0x6d, 0x54, 0x6f, 0x6f, 0x6c, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x78, 0x0a,
|
||||
0x08, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
|
||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x25, 0x0a,
|
||||
0x0e, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73,
|
||||
0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x19, 0x0a, 0x08,
|
||||
0x69, 0x73, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
|
||||
0x69, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x41, 0x0a, 0x0d, 0x54, 0x68, 0x69, 0x6e, 0x6b,
|
||||
0x69, 0x6e, 0x67, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x75, 0x6e,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x91, 0x02, 0x0a, 0x09, 0x54,
|
||||
0x75, 0x72, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0e, 0x74, 0x68, 0x69, 0x6e,
|
||||
0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x48, 0x00, 0x52, 0x0d, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x6c, 0x74,
|
||||
0x61, 0x12, 0x21, 0x0a, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x44,
|
||||
0x65, 0x6c, 0x74, 0x61, 0x12, 0x37, 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c,
|
||||
0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c,
|
||||
0x6c, 0x48, 0x00, 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x2e, 0x0a,
|
||||
0x04, 0x64, 0x6f, 0x6e, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x65, 0x6e,
|
||||
0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x72,
|
||||
0x6e, 0x44, 0x6f, 0x6e, 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x12, 0x46, 0x0a,
|
||||
0x0e, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x52,
|
||||
0x6f, 0x75, 0x6e, 0x64, 0x48, 0x00, 0x52, 0x0d, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67,
|
||||
0x52, 0x6f, 0x75, 0x6e, 0x64, 0x42, 0x07, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x62,
|
||||
0x0a, 0x08, 0x54, 0x75, 0x72, 0x6e, 0x44, 0x6f, 0x6e, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x69,
|
||||
0x6e, 0x61, 0x6c, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||
0x66, 0x69, 0x6e, 0x61, 0x6c, 0x54, 0x65, 0x78, 0x74, 0x12, 0x37, 0x0a, 0x0a, 0x74, 0x6f, 0x6f,
|
||||
0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e,
|
||||
0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54,
|
||||
0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c,
|
||||
0x6c, 0x73, 0x22, 0x47, 0x0a, 0x0f, 0x53, 0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b,
|
||||
0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, 0x12, 0x0a, 0x10, 0x53,
|
||||
0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a,
|
||||
0xc6, 0x02, 0x0a, 0x10, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54, 0x75, 0x72, 0x6e, 0x4f, 0x75, 0x74,
|
||||
0x63, 0x6f, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x1e, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55,
|
||||
0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45,
|
||||
0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x53, 0x54, 0x41, 0x52,
|
||||
0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x4f,
|
||||
0x4b, 0x10, 0x01, 0x12, 0x2d, 0x0a, 0x29, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52,
|
||||
0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x56, 0x45, 0x52,
|
||||
0x53, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x46, 0x4f, 0x55, 0x4e, 0x44,
|
||||
0x10, 0x02, 0x12, 0x28, 0x0a, 0x24, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e,
|
||||
0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x56, 0x45, 0x52, 0x53,
|
||||
0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x42, 0x55, 0x53, 0x59, 0x10, 0x03, 0x12, 0x27, 0x0a, 0x23,
|
||||
0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f,
|
||||
0x4d, 0x45, 0x5f, 0x54, 0x4f, 0x4f, 0x5f, 0x4d, 0x41, 0x4e, 0x59, 0x5f, 0x53, 0x54, 0x52, 0x45,
|
||||
0x41, 0x4d, 0x53, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54,
|
||||
0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x52, 0x41, 0x54, 0x45,
|
||||
0x5f, 0x4c, 0x49, 0x4d, 0x49, 0x54, 0x45, 0x44, 0x10, 0x05, 0x12, 0x25, 0x0a, 0x21, 0x53, 0x54,
|
||||
0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45,
|
||||
0x5f, 0x51, 0x55, 0x4f, 0x54, 0x41, 0x5f, 0x45, 0x58, 0x43, 0x45, 0x45, 0x44, 0x45, 0x44, 0x10,
|
||||
0x06, 0x12, 0x25, 0x0a, 0x21, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f,
|
||||
0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x49, 0x4e, 0x54, 0x45, 0x52, 0x4e, 0x41, 0x4c,
|
||||
0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x07, 0x32, 0xfd, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65,
|
||||
0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x50, 0x0a, 0x09, 0x53, 0x74, 0x61,
|
||||
0x72, 0x74, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x20, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54, 0x75, 0x72,
|
||||
0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79,
|
||||
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54,
|
||||
0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0a, 0x53,
|
||||
0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x21, 0x2e, 0x65, 0x6e, 0x6b, 0x6c,
|
||||
0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61,
|
||||
0x6d, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x65,
|
||||
0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75,
|
||||
0x72, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x4d, 0x0a, 0x08, 0x53, 0x74, 0x6f,
|
||||
0x70, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x1f, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x1c, 0x5a, 0x1a, 0x65, 0x6e, 0x6b, 0x6c,
|
||||
0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_agent_v1_agent_proto_rawDescOnce sync.Once
|
||||
file_agent_v1_agent_proto_rawDescData = file_agent_v1_agent_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_agent_v1_agent_proto_rawDescGZIP() []byte {
|
||||
file_agent_v1_agent_proto_rawDescOnce.Do(func() {
|
||||
file_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(file_agent_v1_agent_proto_rawDescData)
|
||||
})
|
||||
return file_agent_v1_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_agent_v1_agent_proto_goTypes = []any{
|
||||
(StartTurnOutcome)(0), // 0: enkly.agent.v1.StartTurnOutcome
|
||||
(*StartTurnRequest)(nil), // 1: enkly.agent.v1.StartTurnRequest
|
||||
(*StartTurnResponse)(nil), // 2: enkly.agent.v1.StartTurnResponse
|
||||
(*StreamTurnRequest)(nil), // 3: enkly.agent.v1.StreamTurnRequest
|
||||
(*ToolCall)(nil), // 4: enkly.agent.v1.ToolCall
|
||||
(*ThinkingRound)(nil), // 5: enkly.agent.v1.ThinkingRound
|
||||
(*TurnEvent)(nil), // 6: enkly.agent.v1.TurnEvent
|
||||
(*TurnDone)(nil), // 7: enkly.agent.v1.TurnDone
|
||||
(*StopTurnRequest)(nil), // 8: enkly.agent.v1.StopTurnRequest
|
||||
(*StopTurnResponse)(nil), // 9: enkly.agent.v1.StopTurnResponse
|
||||
}
|
||||
var file_agent_v1_agent_proto_depIdxs = []int32{
|
||||
0, // 0: enkly.agent.v1.StartTurnResponse.outcome:type_name -> enkly.agent.v1.StartTurnOutcome
|
||||
4, // 1: enkly.agent.v1.TurnEvent.tool_call:type_name -> enkly.agent.v1.ToolCall
|
||||
7, // 2: enkly.agent.v1.TurnEvent.done:type_name -> enkly.agent.v1.TurnDone
|
||||
5, // 3: enkly.agent.v1.TurnEvent.thinking_round:type_name -> enkly.agent.v1.ThinkingRound
|
||||
4, // 4: enkly.agent.v1.TurnDone.tool_calls:type_name -> enkly.agent.v1.ToolCall
|
||||
1, // 5: enkly.agent.v1.AgentService.StartTurn:input_type -> enkly.agent.v1.StartTurnRequest
|
||||
3, // 6: enkly.agent.v1.AgentService.StreamTurn:input_type -> enkly.agent.v1.StreamTurnRequest
|
||||
8, // 7: enkly.agent.v1.AgentService.StopTurn:input_type -> enkly.agent.v1.StopTurnRequest
|
||||
2, // 8: enkly.agent.v1.AgentService.StartTurn:output_type -> enkly.agent.v1.StartTurnResponse
|
||||
6, // 9: enkly.agent.v1.AgentService.StreamTurn:output_type -> enkly.agent.v1.TurnEvent
|
||||
9, // 10: enkly.agent.v1.AgentService.StopTurn:output_type -> enkly.agent.v1.StopTurnResponse
|
||||
8, // [8:11] is the sub-list for method output_type
|
||||
5, // [5:8] is the sub-list for method input_type
|
||||
5, // [5:5] is the sub-list for extension type_name
|
||||
5, // [5:5] is the sub-list for extension extendee
|
||||
0, // [0:5] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_agent_v1_agent_proto_init() }
|
||||
func file_agent_v1_agent_proto_init() {
|
||||
if File_agent_v1_agent_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_agent_v1_agent_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*StartTurnRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*StartTurnResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*StreamTurnRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ToolCall); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ThinkingRound); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*TurnEvent); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*TurnDone); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*StopTurnRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*StopTurnResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
file_agent_v1_agent_proto_msgTypes[5].OneofWrappers = []any{
|
||||
(*TurnEvent_ThinkingDelta)(nil),
|
||||
(*TurnEvent_TokenDelta)(nil),
|
||||
(*TurnEvent_ToolCall)(nil),
|
||||
(*TurnEvent_Done)(nil),
|
||||
(*TurnEvent_ThinkingRound)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_agent_v1_agent_proto_rawDesc,
|
||||
NumEnums: 1,
|
||||
NumMessages: 9,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_agent_v1_agent_proto_goTypes,
|
||||
DependencyIndexes: file_agent_v1_agent_proto_depIdxs,
|
||||
EnumInfos: file_agent_v1_agent_proto_enumTypes,
|
||||
MessageInfos: file_agent_v1_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_agent_v1_agent_proto = out.File
|
||||
file_agent_v1_agent_proto_rawDesc = nil
|
||||
file_agent_v1_agent_proto_goTypes = nil
|
||||
file_agent_v1_agent_proto_depIdxs = nil
|
||||
}
|
||||
240
gen/agent/v1/agent_grpc.pb.go
Normal file
240
gen/agent/v1/agent_grpc.pb.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Package agent is the Agent Orchestration bounded context: the agentic
|
||||
// run loop (prompt -> vLLM -> tool calls -> final reply), the chat-run
|
||||
// quota/concurrency guard, and the in-flight/just-finished run registry
|
||||
// that used to live in internal/handlers/chat.go's StreamManager.
|
||||
//
|
||||
// This is a saga coordinator, not a data owner: it holds no SQL table of
|
||||
// its own. It calls billing (usage quota), conversation (persisting
|
||||
// turns) and knowledge (the search_codebase tool) over gRPC, and
|
||||
// workspace (every filesystem/bash tool) over gRPC, exactly replacing
|
||||
// internal/llm/agent.go + internal/llm/tools.go's direct, in-process
|
||||
// calls into internal/db and the local filesystem.
|
||||
//
|
||||
// Rendering (Markdown, syntax highlighting, deliverable cards) stays out
|
||||
// of this service on purpose — see internal/render's doc comment in the
|
||||
// gateway service — so this API deals in plain text and structured tool
|
||||
// results, never HTML.
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: agent/v1/agent.proto
|
||||
|
||||
package agentv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
AgentService_StartTurn_FullMethodName = "/enkly.agent.v1.AgentService/StartTurn"
|
||||
AgentService_StreamTurn_FullMethodName = "/enkly.agent.v1.AgentService/StreamTurn"
|
||||
AgentService_StopTurn_FullMethodName = "/enkly.agent.v1.AgentService/StopTurn"
|
||||
)
|
||||
|
||||
// AgentServiceClient is the client API for AgentService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type AgentServiceClient interface {
|
||||
// StartTurn replaces the synchronous prefix of Handlers.Chat: the
|
||||
// per-user throttle (ChatLimiter), the billing quota check, loading
|
||||
// the conversation, persisting the user's prompt, auto-titling, and
|
||||
// registering a new run (StreamManager.create) before handing off to
|
||||
// a background goroutine. Returns immediately with a stream_id; the
|
||||
// reply itself arrives via StreamTurn.
|
||||
StartTurn(ctx context.Context, in *StartTurnRequest, opts ...grpc.CallOption) (*StartTurnResponse, error)
|
||||
// StreamTurn replaces Handlers.StreamChat: a server-streaming view of
|
||||
// one run's progress. resume_from_tool_index mirrors the monolith's
|
||||
// SSE Last-Event-ID handling, so a reconnecting client doesn't see
|
||||
// tool cards it already has.
|
||||
StreamTurn(ctx context.Context, in *StreamTurnRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TurnEvent], error)
|
||||
// StopTurn replaces Handlers.StopStream.
|
||||
StopTurn(ctx context.Context, in *StopTurnRequest, opts ...grpc.CallOption) (*StopTurnResponse, error)
|
||||
}
|
||||
|
||||
type agentServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
|
||||
return &agentServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) StartTurn(ctx context.Context, in *StartTurnRequest, opts ...grpc.CallOption) (*StartTurnResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StartTurnResponse)
|
||||
err := c.cc.Invoke(ctx, AgentService_StartTurn_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) StreamTurn(ctx context.Context, in *StreamTurnRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TurnEvent], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], AgentService_StreamTurn_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[StreamTurnRequest, TurnEvent]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_StreamTurnClient = grpc.ServerStreamingClient[TurnEvent]
|
||||
|
||||
func (c *agentServiceClient) StopTurn(ctx context.Context, in *StopTurnRequest, opts ...grpc.CallOption) (*StopTurnResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StopTurnResponse)
|
||||
err := c.cc.Invoke(ctx, AgentService_StopTurn_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AgentServiceServer is the server API for AgentService service.
|
||||
// All implementations must embed UnimplementedAgentServiceServer
|
||||
// for forward compatibility.
|
||||
type AgentServiceServer interface {
|
||||
// StartTurn replaces the synchronous prefix of Handlers.Chat: the
|
||||
// per-user throttle (ChatLimiter), the billing quota check, loading
|
||||
// the conversation, persisting the user's prompt, auto-titling, and
|
||||
// registering a new run (StreamManager.create) before handing off to
|
||||
// a background goroutine. Returns immediately with a stream_id; the
|
||||
// reply itself arrives via StreamTurn.
|
||||
StartTurn(context.Context, *StartTurnRequest) (*StartTurnResponse, error)
|
||||
// StreamTurn replaces Handlers.StreamChat: a server-streaming view of
|
||||
// one run's progress. resume_from_tool_index mirrors the monolith's
|
||||
// SSE Last-Event-ID handling, so a reconnecting client doesn't see
|
||||
// tool cards it already has.
|
||||
StreamTurn(*StreamTurnRequest, grpc.ServerStreamingServer[TurnEvent]) error
|
||||
// StopTurn replaces Handlers.StopStream.
|
||||
StopTurn(context.Context, *StopTurnRequest) (*StopTurnResponse, error)
|
||||
mustEmbedUnimplementedAgentServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedAgentServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedAgentServiceServer struct{}
|
||||
|
||||
func (UnimplementedAgentServiceServer) StartTurn(context.Context, *StartTurnRequest) (*StartTurnResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method StartTurn not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamTurn(*StreamTurnRequest, grpc.ServerStreamingServer[TurnEvent]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamTurn not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StopTurn(context.Context, *StopTurnRequest) (*StopTurnResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method StopTurn not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
|
||||
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AgentServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAgentServiceServer interface {
|
||||
mustEmbedUnimplementedAgentServiceServer()
|
||||
}
|
||||
|
||||
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedAgentServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&AgentService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _AgentService_StartTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StartTurnRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AgentServiceServer).StartTurn(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AgentService_StartTurn_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AgentServiceServer).StartTurn(ctx, req.(*StartTurnRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AgentService_StreamTurn_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(StreamTurnRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(AgentServiceServer).StreamTurn(m, &grpc.GenericServerStream[StreamTurnRequest, TurnEvent]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_StreamTurnServer = grpc.ServerStreamingServer[TurnEvent]
|
||||
|
||||
func _AgentService_StopTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StopTurnRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AgentServiceServer).StopTurn(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AgentService_StopTurn_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AgentServiceServer).StopTurn(ctx, req.(*StopTurnRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var AgentService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "enkly.agent.v1.AgentService",
|
||||
HandlerType: (*AgentServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "StartTurn",
|
||||
Handler: _AgentService_StartTurn_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StopTurn",
|
||||
Handler: _AgentService_StopTurn_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "StreamTurn",
|
||||
Handler: _AgentService_StreamTurn_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "agent/v1/agent.proto",
|
||||
}
|
||||
1455
gen/billing/v1/billing.pb.go
Normal file
1455
gen/billing/v1/billing.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
445
gen/billing/v1/billing_grpc.pb.go
Normal file
445
gen/billing/v1/billing_grpc.pb.go
Normal file
@@ -0,0 +1,445 @@
|
||||
// Package billing is the Billing & Subscription bounded context: plans,
|
||||
// Stripe checkout/portal, webhook ingestion, and the per-plan daily
|
||||
// message quota. System of record for `subscriptions`, `stripe_events`
|
||||
// and `usage_daily` (see services/billing/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/handlers/billing.go + internal/billing/*.go +
|
||||
// internal/db/subscriptions.go.
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: billing/v1/billing.proto
|
||||
|
||||
package billingv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
BillingService_GetOrCreateSubscription_FullMethodName = "/enkly.billing.v1.BillingService/GetOrCreateSubscription"
|
||||
BillingService_GetPlanCatalog_FullMethodName = "/enkly.billing.v1.BillingService/GetPlanCatalog"
|
||||
BillingService_CreateCheckoutSession_FullMethodName = "/enkly.billing.v1.BillingService/CreateCheckoutSession"
|
||||
BillingService_SyncCheckoutSession_FullMethodName = "/enkly.billing.v1.BillingService/SyncCheckoutSession"
|
||||
BillingService_CreateBillingPortalSession_FullMethodName = "/enkly.billing.v1.BillingService/CreateBillingPortalSession"
|
||||
BillingService_IngestStripeWebhook_FullMethodName = "/enkly.billing.v1.BillingService/IngestStripeWebhook"
|
||||
BillingService_CheckAndIncrementUsage_FullMethodName = "/enkly.billing.v1.BillingService/CheckAndIncrementUsage"
|
||||
BillingService_GetDailyUsage_FullMethodName = "/enkly.billing.v1.BillingService/GetDailyUsage"
|
||||
)
|
||||
|
||||
// BillingServiceClient is the client API for BillingService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type BillingServiceClient interface {
|
||||
// GetOrCreateSubscription replaces db.GetOrCreateSubscription: lazily
|
||||
// inserts the default free-plan row the first time a user's
|
||||
// subscription is looked up.
|
||||
GetOrCreateSubscription(ctx context.Context, in *GetOrCreateSubscriptionRequest, opts ...grpc.CallOption) (*Subscription, error)
|
||||
// GetPlanCatalog replaces billing.Catalog + billing.PlanPriceIDs for
|
||||
// rendering /subscription — returns every plan and whether its Stripe
|
||||
// price is currently configured (so an unconfigured plan renders as
|
||||
// "Unavailable", same as today).
|
||||
GetPlanCatalog(ctx context.Context, in *GetPlanCatalogRequest, opts ...grpc.CallOption) (*GetPlanCatalogResponse, error)
|
||||
// CreateCheckoutSession replaces Handlers.CreateCheckout.
|
||||
CreateCheckoutSession(ctx context.Context, in *CreateCheckoutSessionRequest, opts ...grpc.CallOption) (*CreateCheckoutSessionResponse, error)
|
||||
// SyncCheckoutSession replaces Handlers.syncCheckoutSession, the
|
||||
// fallback path SubscriptionSuccess uses when the webhook hasn't
|
||||
// landed yet.
|
||||
SyncCheckoutSession(ctx context.Context, in *SyncCheckoutSessionRequest, opts ...grpc.CallOption) (*Subscription, error)
|
||||
// CreateBillingPortalSession replaces Handlers.BillingPortal.
|
||||
CreateBillingPortalSession(ctx context.Context, in *CreateBillingPortalSessionRequest, opts ...grpc.CallOption) (*CreateBillingPortalSessionResponse, error)
|
||||
// IngestStripeWebhook replaces Handlers.StripeWebhook /
|
||||
// applyStripeEvent. The gateway still terminates the public
|
||||
// POST /webhooks/stripe route (so the URL Stripe is configured with
|
||||
// never changes) but forwards the raw body and signature header here
|
||||
// untouched — signature verification needs STRIPE_WEBHOOK_SECRET,
|
||||
// which only billing holds.
|
||||
IngestStripeWebhook(ctx context.Context, in *IngestStripeWebhookRequest, opts ...grpc.CallOption) (*IngestStripeWebhookResponse, error)
|
||||
// CheckAndIncrementUsage replaces the daily-cap check + db.
|
||||
// IncrementDailyUsage call in Handlers.Chat. Called by the agent
|
||||
// service once per chat turn, after the message is accepted and
|
||||
// before the agent run starts (same ordering as the monolith).
|
||||
CheckAndIncrementUsage(ctx context.Context, in *CheckAndIncrementUsageRequest, opts ...grpc.CallOption) (*CheckAndIncrementUsageResponse, error)
|
||||
// GetDailyUsage replaces db.DailyUsage — a read-only lookup (no
|
||||
// increment) used by the gateway's /subscription page to show
|
||||
// today's count without miscounting it as a sent message.
|
||||
GetDailyUsage(ctx context.Context, in *GetDailyUsageRequest, opts ...grpc.CallOption) (*GetDailyUsageResponse, error)
|
||||
}
|
||||
|
||||
type billingServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewBillingServiceClient(cc grpc.ClientConnInterface) BillingServiceClient {
|
||||
return &billingServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) GetOrCreateSubscription(ctx context.Context, in *GetOrCreateSubscriptionRequest, opts ...grpc.CallOption) (*Subscription, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Subscription)
|
||||
err := c.cc.Invoke(ctx, BillingService_GetOrCreateSubscription_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) GetPlanCatalog(ctx context.Context, in *GetPlanCatalogRequest, opts ...grpc.CallOption) (*GetPlanCatalogResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetPlanCatalogResponse)
|
||||
err := c.cc.Invoke(ctx, BillingService_GetPlanCatalog_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) CreateCheckoutSession(ctx context.Context, in *CreateCheckoutSessionRequest, opts ...grpc.CallOption) (*CreateCheckoutSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateCheckoutSessionResponse)
|
||||
err := c.cc.Invoke(ctx, BillingService_CreateCheckoutSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) SyncCheckoutSession(ctx context.Context, in *SyncCheckoutSessionRequest, opts ...grpc.CallOption) (*Subscription, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Subscription)
|
||||
err := c.cc.Invoke(ctx, BillingService_SyncCheckoutSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) CreateBillingPortalSession(ctx context.Context, in *CreateBillingPortalSessionRequest, opts ...grpc.CallOption) (*CreateBillingPortalSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateBillingPortalSessionResponse)
|
||||
err := c.cc.Invoke(ctx, BillingService_CreateBillingPortalSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) IngestStripeWebhook(ctx context.Context, in *IngestStripeWebhookRequest, opts ...grpc.CallOption) (*IngestStripeWebhookResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(IngestStripeWebhookResponse)
|
||||
err := c.cc.Invoke(ctx, BillingService_IngestStripeWebhook_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) CheckAndIncrementUsage(ctx context.Context, in *CheckAndIncrementUsageRequest, opts ...grpc.CallOption) (*CheckAndIncrementUsageResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckAndIncrementUsageResponse)
|
||||
err := c.cc.Invoke(ctx, BillingService_CheckAndIncrementUsage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *billingServiceClient) GetDailyUsage(ctx context.Context, in *GetDailyUsageRequest, opts ...grpc.CallOption) (*GetDailyUsageResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetDailyUsageResponse)
|
||||
err := c.cc.Invoke(ctx, BillingService_GetDailyUsage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BillingServiceServer is the server API for BillingService service.
|
||||
// All implementations must embed UnimplementedBillingServiceServer
|
||||
// for forward compatibility.
|
||||
type BillingServiceServer interface {
|
||||
// GetOrCreateSubscription replaces db.GetOrCreateSubscription: lazily
|
||||
// inserts the default free-plan row the first time a user's
|
||||
// subscription is looked up.
|
||||
GetOrCreateSubscription(context.Context, *GetOrCreateSubscriptionRequest) (*Subscription, error)
|
||||
// GetPlanCatalog replaces billing.Catalog + billing.PlanPriceIDs for
|
||||
// rendering /subscription — returns every plan and whether its Stripe
|
||||
// price is currently configured (so an unconfigured plan renders as
|
||||
// "Unavailable", same as today).
|
||||
GetPlanCatalog(context.Context, *GetPlanCatalogRequest) (*GetPlanCatalogResponse, error)
|
||||
// CreateCheckoutSession replaces Handlers.CreateCheckout.
|
||||
CreateCheckoutSession(context.Context, *CreateCheckoutSessionRequest) (*CreateCheckoutSessionResponse, error)
|
||||
// SyncCheckoutSession replaces Handlers.syncCheckoutSession, the
|
||||
// fallback path SubscriptionSuccess uses when the webhook hasn't
|
||||
// landed yet.
|
||||
SyncCheckoutSession(context.Context, *SyncCheckoutSessionRequest) (*Subscription, error)
|
||||
// CreateBillingPortalSession replaces Handlers.BillingPortal.
|
||||
CreateBillingPortalSession(context.Context, *CreateBillingPortalSessionRequest) (*CreateBillingPortalSessionResponse, error)
|
||||
// IngestStripeWebhook replaces Handlers.StripeWebhook /
|
||||
// applyStripeEvent. The gateway still terminates the public
|
||||
// POST /webhooks/stripe route (so the URL Stripe is configured with
|
||||
// never changes) but forwards the raw body and signature header here
|
||||
// untouched — signature verification needs STRIPE_WEBHOOK_SECRET,
|
||||
// which only billing holds.
|
||||
IngestStripeWebhook(context.Context, *IngestStripeWebhookRequest) (*IngestStripeWebhookResponse, error)
|
||||
// CheckAndIncrementUsage replaces the daily-cap check + db.
|
||||
// IncrementDailyUsage call in Handlers.Chat. Called by the agent
|
||||
// service once per chat turn, after the message is accepted and
|
||||
// before the agent run starts (same ordering as the monolith).
|
||||
CheckAndIncrementUsage(context.Context, *CheckAndIncrementUsageRequest) (*CheckAndIncrementUsageResponse, error)
|
||||
// GetDailyUsage replaces db.DailyUsage — a read-only lookup (no
|
||||
// increment) used by the gateway's /subscription page to show
|
||||
// today's count without miscounting it as a sent message.
|
||||
GetDailyUsage(context.Context, *GetDailyUsageRequest) (*GetDailyUsageResponse, error)
|
||||
mustEmbedUnimplementedBillingServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedBillingServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedBillingServiceServer struct{}
|
||||
|
||||
func (UnimplementedBillingServiceServer) GetOrCreateSubscription(context.Context, *GetOrCreateSubscriptionRequest) (*Subscription, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetOrCreateSubscription not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) GetPlanCatalog(context.Context, *GetPlanCatalogRequest) (*GetPlanCatalogResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPlanCatalog not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) CreateCheckoutSession(context.Context, *CreateCheckoutSessionRequest) (*CreateCheckoutSessionResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateCheckoutSession not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) SyncCheckoutSession(context.Context, *SyncCheckoutSessionRequest) (*Subscription, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SyncCheckoutSession not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) CreateBillingPortalSession(context.Context, *CreateBillingPortalSessionRequest) (*CreateBillingPortalSessionResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateBillingPortalSession not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) IngestStripeWebhook(context.Context, *IngestStripeWebhookRequest) (*IngestStripeWebhookResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method IngestStripeWebhook not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) CheckAndIncrementUsage(context.Context, *CheckAndIncrementUsageRequest) (*CheckAndIncrementUsageResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckAndIncrementUsage not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) GetDailyUsage(context.Context, *GetDailyUsageRequest) (*GetDailyUsageResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetDailyUsage not implemented")
|
||||
}
|
||||
func (UnimplementedBillingServiceServer) mustEmbedUnimplementedBillingServiceServer() {}
|
||||
func (UnimplementedBillingServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeBillingServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to BillingServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeBillingServiceServer interface {
|
||||
mustEmbedUnimplementedBillingServiceServer()
|
||||
}
|
||||
|
||||
func RegisterBillingServiceServer(s grpc.ServiceRegistrar, srv BillingServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedBillingServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&BillingService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _BillingService_GetOrCreateSubscription_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetOrCreateSubscriptionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).GetOrCreateSubscription(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_GetOrCreateSubscription_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).GetOrCreateSubscription(ctx, req.(*GetOrCreateSubscriptionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_GetPlanCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetPlanCatalogRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).GetPlanCatalog(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_GetPlanCatalog_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).GetPlanCatalog(ctx, req.(*GetPlanCatalogRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_CreateCheckoutSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateCheckoutSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).CreateCheckoutSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_CreateCheckoutSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).CreateCheckoutSession(ctx, req.(*CreateCheckoutSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_SyncCheckoutSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SyncCheckoutSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).SyncCheckoutSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_SyncCheckoutSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).SyncCheckoutSession(ctx, req.(*SyncCheckoutSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_CreateBillingPortalSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateBillingPortalSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).CreateBillingPortalSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_CreateBillingPortalSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).CreateBillingPortalSession(ctx, req.(*CreateBillingPortalSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_IngestStripeWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(IngestStripeWebhookRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).IngestStripeWebhook(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_IngestStripeWebhook_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).IngestStripeWebhook(ctx, req.(*IngestStripeWebhookRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_CheckAndIncrementUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CheckAndIncrementUsageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).CheckAndIncrementUsage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_CheckAndIncrementUsage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).CheckAndIncrementUsage(ctx, req.(*CheckAndIncrementUsageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BillingService_GetDailyUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetDailyUsageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BillingServiceServer).GetDailyUsage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BillingService_GetDailyUsage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BillingServiceServer).GetDailyUsage(ctx, req.(*GetDailyUsageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// BillingService_ServiceDesc is the grpc.ServiceDesc for BillingService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var BillingService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "enkly.billing.v1.BillingService",
|
||||
HandlerType: (*BillingServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetOrCreateSubscription",
|
||||
Handler: _BillingService_GetOrCreateSubscription_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetPlanCatalog",
|
||||
Handler: _BillingService_GetPlanCatalog_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateCheckoutSession",
|
||||
Handler: _BillingService_CreateCheckoutSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SyncCheckoutSession",
|
||||
Handler: _BillingService_SyncCheckoutSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateBillingPortalSession",
|
||||
Handler: _BillingService_CreateBillingPortalSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "IngestStripeWebhook",
|
||||
Handler: _BillingService_IngestStripeWebhook_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckAndIncrementUsage",
|
||||
Handler: _BillingService_CheckAndIncrementUsage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetDailyUsage",
|
||||
Handler: _BillingService_GetDailyUsage_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "billing/v1/billing.proto",
|
||||
}
|
||||
301
gen/common/v1/common.pb.go
Normal file
301
gen/common/v1/common.pb.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Package common holds message types shared across every enkly bounded
|
||||
// context's gRPC API, so each context's own .proto only needs to import
|
||||
// this one file instead of depending on each other directly.
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v3.21.12
|
||||
// source: common/v1/common.proto
|
||||
|
||||
package commonv1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// Money is a decimal amount in the smallest unit of currency (cents),
|
||||
// matching how internal/billing.Info prices plans today (EUR integer
|
||||
// amounts) without pulling in a floating point rounding bug.
|
||||
type Money struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
MinorUnits int64 `protobuf:"varint,1,opt,name=minor_units,json=minorUnits,proto3" json:"minor_units,omitempty"` // e.g. 500 = 5.00
|
||||
CurrencyCode string `protobuf:"bytes,2,opt,name=currency_code,json=currencyCode,proto3" json:"currency_code,omitempty"` // ISO 4217, e.g. "EUR"
|
||||
}
|
||||
|
||||
func (x *Money) Reset() {
|
||||
*x = Money{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_common_v1_common_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Money) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Money) ProtoMessage() {}
|
||||
|
||||
func (x *Money) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_common_v1_common_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Money.ProtoReflect.Descriptor instead.
|
||||
func (*Money) Descriptor() ([]byte, []int) {
|
||||
return file_common_v1_common_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *Money) GetMinorUnits() int64 {
|
||||
if x != nil {
|
||||
return x.MinorUnits
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Money) GetCurrencyCode() string {
|
||||
if x != nil {
|
||||
return x.CurrencyCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PageRequest / PageResponse: not used by today's list endpoints (the
|
||||
// monolith never paginates conversations or messages), reserved so a
|
||||
// service can add pagination later without a breaking proto change.
|
||||
type PageRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PageRequest) Reset() {
|
||||
*x = PageRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_common_v1_common_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PageRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PageRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PageRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_common_v1_common_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PageRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PageRequest) Descriptor() ([]byte, []int) {
|
||||
return file_common_v1_common_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *PageRequest) GetPageSize() int32 {
|
||||
if x != nil {
|
||||
return x.PageSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *PageRequest) GetPageToken() string {
|
||||
if x != nil {
|
||||
return x.PageToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type PageResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
NextPageToken string `protobuf:"bytes,1,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PageResponse) Reset() {
|
||||
*x = PageResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_common_v1_common_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PageResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PageResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PageResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_common_v1_common_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PageResponse.ProtoReflect.Descriptor instead.
|
||||
func (*PageResponse) Descriptor() ([]byte, []int) {
|
||||
return file_common_v1_common_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *PageResponse) GetNextPageToken() string {
|
||||
if x != nil {
|
||||
return x.NextPageToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_common_v1_common_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_common_v1_common_proto_rawDesc = []byte{
|
||||
0x0a, 0x16, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d,
|
||||
0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e,
|
||||
0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x22, 0x4d, 0x0a, 0x05, 0x4d, 0x6f, 0x6e,
|
||||
0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x69, 0x6e, 0x6f, 0x72, 0x5f, 0x75, 0x6e, 0x69, 0x74,
|
||||
0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x6d, 0x69, 0x6e, 0x6f, 0x72, 0x55, 0x6e,
|
||||
0x69, 0x74, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x5f,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x72, 0x72,
|
||||
0x65, 0x6e, 0x63, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x49, 0x0a, 0x0b, 0x50, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f,
|
||||
0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65,
|
||||
0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f,
|
||||
0x6b, 0x65, 0x6e, 0x22, 0x36, 0x0a, 0x0c, 0x50, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65,
|
||||
0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65,
|
||||
0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x42, 0x1e, 0x5a, 0x1c, 0x65,
|
||||
0x6e, 0x6b, 0x6c, 0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f,
|
||||
0x76, 0x31, 0x3b, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_common_v1_common_proto_rawDescOnce sync.Once
|
||||
file_common_v1_common_proto_rawDescData = file_common_v1_common_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_common_v1_common_proto_rawDescGZIP() []byte {
|
||||
file_common_v1_common_proto_rawDescOnce.Do(func() {
|
||||
file_common_v1_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_common_v1_common_proto_rawDescData)
|
||||
})
|
||||
return file_common_v1_common_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_common_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_common_v1_common_proto_goTypes = []any{
|
||||
(*Money)(nil), // 0: enkly.common.v1.Money
|
||||
(*PageRequest)(nil), // 1: enkly.common.v1.PageRequest
|
||||
(*PageResponse)(nil), // 2: enkly.common.v1.PageResponse
|
||||
}
|
||||
var file_common_v1_common_proto_depIdxs = []int32{
|
||||
0, // [0:0] is the sub-list for method output_type
|
||||
0, // [0:0] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_common_v1_common_proto_init() }
|
||||
func file_common_v1_common_proto_init() {
|
||||
if File_common_v1_common_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_common_v1_common_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*Money); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_common_v1_common_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*PageRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_common_v1_common_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*PageResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_common_v1_common_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_common_v1_common_proto_goTypes,
|
||||
DependencyIndexes: file_common_v1_common_proto_depIdxs,
|
||||
MessageInfos: file_common_v1_common_proto_msgTypes,
|
||||
}.Build()
|
||||
File_common_v1_common_proto = out.File
|
||||
file_common_v1_common_proto_rawDesc = nil
|
||||
file_common_v1_common_proto_goTypes = nil
|
||||
file_common_v1_common_proto_depIdxs = nil
|
||||
}
|
||||
1690
gen/conversation/v1/conversation.pb.go
Normal file
1690
gen/conversation/v1/conversation.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
516
gen/conversation/v1/conversation_grpc.pb.go
Normal file
516
gen/conversation/v1/conversation_grpc.pb.go
Normal file
@@ -0,0 +1,516 @@
|
||||
// Package conversation is the Conversation bounded context: chat threads
|
||||
// and their messages/tool-call audit log. System of record for
|
||||
// `conversations`, `messages` and `tool_calls` (see
|
||||
// services/conversation/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/db/conversations.go, called both by the gateway
|
||||
// (listing/renaming/deleting threads, loading history to render) and by
|
||||
// the agent service (appending messages as a run proceeds).
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: conversation/v1/conversation.proto
|
||||
|
||||
package conversationv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
ConversationService_CreateConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/CreateConversation"
|
||||
ConversationService_GetConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/GetConversation"
|
||||
ConversationService_ListConversations_FullMethodName = "/enkly.conversation.v1.ConversationService/ListConversations"
|
||||
ConversationService_MostRecentConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/MostRecentConversation"
|
||||
ConversationService_RenameConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/RenameConversation"
|
||||
ConversationService_AutoTitleConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/AutoTitleConversation"
|
||||
ConversationService_DeleteConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/DeleteConversation"
|
||||
ConversationService_CountMessages_FullMethodName = "/enkly.conversation.v1.ConversationService/CountMessages"
|
||||
ConversationService_AddMessage_FullMethodName = "/enkly.conversation.v1.ConversationService/AddMessage"
|
||||
ConversationService_ListMessages_FullMethodName = "/enkly.conversation.v1.ConversationService/ListMessages"
|
||||
ConversationService_AddToolCall_FullMethodName = "/enkly.conversation.v1.ConversationService/AddToolCall"
|
||||
)
|
||||
|
||||
// ConversationServiceClient is the client API for ConversationService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type ConversationServiceClient interface {
|
||||
CreateConversation(ctx context.Context, in *CreateConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
|
||||
GetConversation(ctx context.Context, in *GetConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
|
||||
ListConversations(ctx context.Context, in *ListConversationsRequest, opts ...grpc.CallOption) (*ListConversationsResponse, error)
|
||||
MostRecentConversation(ctx context.Context, in *MostRecentConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
|
||||
RenameConversation(ctx context.Context, in *RenameConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
|
||||
// AutoTitleConversation replaces db.AutoTitleConversation: sets the
|
||||
// title from the first prompt only if the conversation still has the
|
||||
// default title (returns retitled=true when it actually changed it).
|
||||
AutoTitleConversation(ctx context.Context, in *AutoTitleConversationRequest, opts ...grpc.CallOption) (*AutoTitleConversationResponse, error)
|
||||
DeleteConversation(ctx context.Context, in *DeleteConversationRequest, opts ...grpc.CallOption) (*DeleteConversationResponse, error)
|
||||
CountMessages(ctx context.Context, in *CountMessagesRequest, opts ...grpc.CallOption) (*CountMessagesResponse, error)
|
||||
AddMessage(ctx context.Context, in *AddMessageRequest, opts ...grpc.CallOption) (*Message, error)
|
||||
ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error)
|
||||
AddToolCall(ctx context.Context, in *AddToolCallRequest, opts ...grpc.CallOption) (*AddToolCallResponse, error)
|
||||
}
|
||||
|
||||
type conversationServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewConversationServiceClient(cc grpc.ClientConnInterface) ConversationServiceClient {
|
||||
return &conversationServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) CreateConversation(ctx context.Context, in *CreateConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Conversation)
|
||||
err := c.cc.Invoke(ctx, ConversationService_CreateConversation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) GetConversation(ctx context.Context, in *GetConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Conversation)
|
||||
err := c.cc.Invoke(ctx, ConversationService_GetConversation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) ListConversations(ctx context.Context, in *ListConversationsRequest, opts ...grpc.CallOption) (*ListConversationsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListConversationsResponse)
|
||||
err := c.cc.Invoke(ctx, ConversationService_ListConversations_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) MostRecentConversation(ctx context.Context, in *MostRecentConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Conversation)
|
||||
err := c.cc.Invoke(ctx, ConversationService_MostRecentConversation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) RenameConversation(ctx context.Context, in *RenameConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Conversation)
|
||||
err := c.cc.Invoke(ctx, ConversationService_RenameConversation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) AutoTitleConversation(ctx context.Context, in *AutoTitleConversationRequest, opts ...grpc.CallOption) (*AutoTitleConversationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AutoTitleConversationResponse)
|
||||
err := c.cc.Invoke(ctx, ConversationService_AutoTitleConversation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) DeleteConversation(ctx context.Context, in *DeleteConversationRequest, opts ...grpc.CallOption) (*DeleteConversationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DeleteConversationResponse)
|
||||
err := c.cc.Invoke(ctx, ConversationService_DeleteConversation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) CountMessages(ctx context.Context, in *CountMessagesRequest, opts ...grpc.CallOption) (*CountMessagesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CountMessagesResponse)
|
||||
err := c.cc.Invoke(ctx, ConversationService_CountMessages_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) AddMessage(ctx context.Context, in *AddMessageRequest, opts ...grpc.CallOption) (*Message, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Message)
|
||||
err := c.cc.Invoke(ctx, ConversationService_AddMessage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListMessagesResponse)
|
||||
err := c.cc.Invoke(ctx, ConversationService_ListMessages_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *conversationServiceClient) AddToolCall(ctx context.Context, in *AddToolCallRequest, opts ...grpc.CallOption) (*AddToolCallResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AddToolCallResponse)
|
||||
err := c.cc.Invoke(ctx, ConversationService_AddToolCall_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ConversationServiceServer is the server API for ConversationService service.
|
||||
// All implementations must embed UnimplementedConversationServiceServer
|
||||
// for forward compatibility.
|
||||
type ConversationServiceServer interface {
|
||||
CreateConversation(context.Context, *CreateConversationRequest) (*Conversation, error)
|
||||
GetConversation(context.Context, *GetConversationRequest) (*Conversation, error)
|
||||
ListConversations(context.Context, *ListConversationsRequest) (*ListConversationsResponse, error)
|
||||
MostRecentConversation(context.Context, *MostRecentConversationRequest) (*Conversation, error)
|
||||
RenameConversation(context.Context, *RenameConversationRequest) (*Conversation, error)
|
||||
// AutoTitleConversation replaces db.AutoTitleConversation: sets the
|
||||
// title from the first prompt only if the conversation still has the
|
||||
// default title (returns retitled=true when it actually changed it).
|
||||
AutoTitleConversation(context.Context, *AutoTitleConversationRequest) (*AutoTitleConversationResponse, error)
|
||||
DeleteConversation(context.Context, *DeleteConversationRequest) (*DeleteConversationResponse, error)
|
||||
CountMessages(context.Context, *CountMessagesRequest) (*CountMessagesResponse, error)
|
||||
AddMessage(context.Context, *AddMessageRequest) (*Message, error)
|
||||
ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error)
|
||||
AddToolCall(context.Context, *AddToolCallRequest) (*AddToolCallResponse, error)
|
||||
mustEmbedUnimplementedConversationServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedConversationServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedConversationServiceServer struct{}
|
||||
|
||||
func (UnimplementedConversationServiceServer) CreateConversation(context.Context, *CreateConversationRequest) (*Conversation, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateConversation not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) GetConversation(context.Context, *GetConversationRequest) (*Conversation, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetConversation not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) ListConversations(context.Context, *ListConversationsRequest) (*ListConversationsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListConversations not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) MostRecentConversation(context.Context, *MostRecentConversationRequest) (*Conversation, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method MostRecentConversation not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) RenameConversation(context.Context, *RenameConversationRequest) (*Conversation, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RenameConversation not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) AutoTitleConversation(context.Context, *AutoTitleConversationRequest) (*AutoTitleConversationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AutoTitleConversation not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) DeleteConversation(context.Context, *DeleteConversationRequest) (*DeleteConversationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteConversation not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) CountMessages(context.Context, *CountMessagesRequest) (*CountMessagesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CountMessages not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) AddMessage(context.Context, *AddMessageRequest) (*Message, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AddMessage not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListMessages not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) AddToolCall(context.Context, *AddToolCallRequest) (*AddToolCallResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AddToolCall not implemented")
|
||||
}
|
||||
func (UnimplementedConversationServiceServer) mustEmbedUnimplementedConversationServiceServer() {}
|
||||
func (UnimplementedConversationServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeConversationServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to ConversationServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeConversationServiceServer interface {
|
||||
mustEmbedUnimplementedConversationServiceServer()
|
||||
}
|
||||
|
||||
func RegisterConversationServiceServer(s grpc.ServiceRegistrar, srv ConversationServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedConversationServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&ConversationService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _ConversationService_CreateConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateConversationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).CreateConversation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_CreateConversation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).CreateConversation(ctx, req.(*CreateConversationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_GetConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetConversationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).GetConversation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_GetConversation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).GetConversation(ctx, req.(*GetConversationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_ListConversations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListConversationsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).ListConversations(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_ListConversations_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).ListConversations(ctx, req.(*ListConversationsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_MostRecentConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MostRecentConversationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).MostRecentConversation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_MostRecentConversation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).MostRecentConversation(ctx, req.(*MostRecentConversationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_RenameConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RenameConversationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).RenameConversation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_RenameConversation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).RenameConversation(ctx, req.(*RenameConversationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_AutoTitleConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AutoTitleConversationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).AutoTitleConversation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_AutoTitleConversation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).AutoTitleConversation(ctx, req.(*AutoTitleConversationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_DeleteConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteConversationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).DeleteConversation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_DeleteConversation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).DeleteConversation(ctx, req.(*DeleteConversationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_CountMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CountMessagesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).CountMessages(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_CountMessages_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).CountMessages(ctx, req.(*CountMessagesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_AddMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddMessageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).AddMessage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_AddMessage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).AddMessage(ctx, req.(*AddMessageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_ListMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListMessagesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).ListMessages(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_ListMessages_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).ListMessages(ctx, req.(*ListMessagesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ConversationService_AddToolCall_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddToolCallRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ConversationServiceServer).AddToolCall(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ConversationService_AddToolCall_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ConversationServiceServer).AddToolCall(ctx, req.(*AddToolCallRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// ConversationService_ServiceDesc is the grpc.ServiceDesc for ConversationService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var ConversationService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "enkly.conversation.v1.ConversationService",
|
||||
HandlerType: (*ConversationServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CreateConversation",
|
||||
Handler: _ConversationService_CreateConversation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetConversation",
|
||||
Handler: _ConversationService_GetConversation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListConversations",
|
||||
Handler: _ConversationService_ListConversations_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MostRecentConversation",
|
||||
Handler: _ConversationService_MostRecentConversation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RenameConversation",
|
||||
Handler: _ConversationService_RenameConversation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AutoTitleConversation",
|
||||
Handler: _ConversationService_AutoTitleConversation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteConversation",
|
||||
Handler: _ConversationService_DeleteConversation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CountMessages",
|
||||
Handler: _ConversationService_CountMessages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddMessage",
|
||||
Handler: _ConversationService_AddMessage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListMessages",
|
||||
Handler: _ConversationService_ListMessages_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddToolCall",
|
||||
Handler: _ConversationService_AddToolCall_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "conversation/v1/conversation.proto",
|
||||
}
|
||||
1632
gen/iam/v1/iam.pb.go
Normal file
1632
gen/iam/v1/iam.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
523
gen/iam/v1/iam_grpc.pb.go
Normal file
523
gen/iam/v1/iam_grpc.pb.go
Normal file
@@ -0,0 +1,523 @@
|
||||
// Package iam is the Identity & Access bounded context: accounts,
|
||||
// sessions, email verification and password reset. It is the system of
|
||||
// record for `users`, `sessions`, `email_verification_codes` and
|
||||
// `password_reset_tokens` (see services/iam/migrations/0001_init.sql) —
|
||||
// no other service may read or write those tables directly.
|
||||
//
|
||||
// Mirrors the monolith's internal/handlers/auth.go + internal/db/users.go
|
||||
// + internal/auth/*.go one-for-one; see each rpc's comment for the
|
||||
// handler function it replaces.
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: iam/v1/iam.proto
|
||||
|
||||
package iamv1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
IAMService_SignUp_FullMethodName = "/enkly.iam.v1.IAMService/SignUp"
|
||||
IAMService_ResendVerification_FullMethodName = "/enkly.iam.v1.IAMService/ResendVerification"
|
||||
IAMService_VerifyEmail_FullMethodName = "/enkly.iam.v1.IAMService/VerifyEmail"
|
||||
IAMService_Login_FullMethodName = "/enkly.iam.v1.IAMService/Login"
|
||||
IAMService_Logout_FullMethodName = "/enkly.iam.v1.IAMService/Logout"
|
||||
IAMService_ValidateSession_FullMethodName = "/enkly.iam.v1.IAMService/ValidateSession"
|
||||
IAMService_ForgotPassword_FullMethodName = "/enkly.iam.v1.IAMService/ForgotPassword"
|
||||
IAMService_CheckPasswordResetToken_FullMethodName = "/enkly.iam.v1.IAMService/CheckPasswordResetToken"
|
||||
IAMService_ResetPassword_FullMethodName = "/enkly.iam.v1.IAMService/ResetPassword"
|
||||
IAMService_GetUser_FullMethodName = "/enkly.iam.v1.IAMService/GetUser"
|
||||
)
|
||||
|
||||
// IAMServiceClient is the client API for IAMService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type IAMServiceClient interface {
|
||||
// SignUp replaces Handlers.Signup. Validates and normalizes the email,
|
||||
// hashes the password (PBKDF2, see internal/auth/password.go), creates
|
||||
// the account (or silently reuses an existing unverified one — same
|
||||
// anti-enumeration behavior as the monolith), issues a verification
|
||||
// code and publishes an iam.verification_code.issued event for the
|
||||
// notification service to deliver it. Never returns whether the email
|
||||
// already existed.
|
||||
SignUp(ctx context.Context, in *SignUpRequest, opts ...grpc.CallOption) (*SignUpResponse, error)
|
||||
// ResendVerification replaces Handlers.ResendVerification.
|
||||
ResendVerification(ctx context.Context, in *ResendVerificationRequest, opts ...grpc.CallOption) (*ResendVerificationResponse, error)
|
||||
// VerifyEmail replaces Handlers.VerifyEmail.
|
||||
VerifyEmail(ctx context.Context, in *VerifyEmailRequest, opts ...grpc.CallOption) (*VerifyEmailResponse, error)
|
||||
// Login replaces Handlers.Login: verifies credentials (constant-time
|
||||
// against a dummy hash for unknown emails) and, on success, mints a
|
||||
// session and returns its raw token for the gateway to set as a
|
||||
// cookie. Never persists the raw token — only its SHA-256 hash.
|
||||
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
|
||||
// Logout replaces Handlers.Logout: deletes the session row for the
|
||||
// given raw session token.
|
||||
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
|
||||
// ValidateSession replaces the session half of Handlers.RequireAuth /
|
||||
// db.GetSessionUser. Called by the gateway on every authenticated
|
||||
// request.
|
||||
ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error)
|
||||
// ForgotPassword replaces Handlers.ForgotPassword.
|
||||
ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error)
|
||||
// CheckPasswordResetToken replaces the GET-time validity check in
|
||||
// Handlers.ResetPasswordPage (db.PasswordResetTokenValid).
|
||||
CheckPasswordResetToken(ctx context.Context, in *CheckPasswordResetTokenRequest, opts ...grpc.CallOption) (*CheckPasswordResetTokenResponse, error)
|
||||
// ResetPassword replaces Handlers.ResetPassword. Also revokes every
|
||||
// session for the account (db.UpdatePassword's cascade delete).
|
||||
ResetPassword(ctx context.Context, in *ResetPasswordRequest, opts ...grpc.CallOption) (*ResetPasswordResponse, error)
|
||||
// GetUser is used by other services (e.g. billing, when it only has a
|
||||
// user_id) to resolve an email address. Never returns PasswordHash.
|
||||
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
|
||||
}
|
||||
|
||||
type iAMServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewIAMServiceClient(cc grpc.ClientConnInterface) IAMServiceClient {
|
||||
return &iAMServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) SignUp(ctx context.Context, in *SignUpRequest, opts ...grpc.CallOption) (*SignUpResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SignUpResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_SignUp_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) ResendVerification(ctx context.Context, in *ResendVerificationRequest, opts ...grpc.CallOption) (*ResendVerificationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResendVerificationResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_ResendVerification_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) VerifyEmail(ctx context.Context, in *VerifyEmailRequest, opts ...grpc.CallOption) (*VerifyEmailResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(VerifyEmailResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_VerifyEmail_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(LoginResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_Login_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(LogoutResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_Logout_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ValidateSessionResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_ValidateSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ForgotPasswordResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_ForgotPassword_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) CheckPasswordResetToken(ctx context.Context, in *CheckPasswordResetTokenRequest, opts ...grpc.CallOption) (*CheckPasswordResetTokenResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckPasswordResetTokenResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_CheckPasswordResetToken_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) ResetPassword(ctx context.Context, in *ResetPasswordRequest, opts ...grpc.CallOption) (*ResetPasswordResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResetPasswordResponse)
|
||||
err := c.cc.Invoke(ctx, IAMService_ResetPassword_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *iAMServiceClient) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(User)
|
||||
err := c.cc.Invoke(ctx, IAMService_GetUser_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IAMServiceServer is the server API for IAMService service.
|
||||
// All implementations must embed UnimplementedIAMServiceServer
|
||||
// for forward compatibility.
|
||||
type IAMServiceServer interface {
|
||||
// SignUp replaces Handlers.Signup. Validates and normalizes the email,
|
||||
// hashes the password (PBKDF2, see internal/auth/password.go), creates
|
||||
// the account (or silently reuses an existing unverified one — same
|
||||
// anti-enumeration behavior as the monolith), issues a verification
|
||||
// code and publishes an iam.verification_code.issued event for the
|
||||
// notification service to deliver it. Never returns whether the email
|
||||
// already existed.
|
||||
SignUp(context.Context, *SignUpRequest) (*SignUpResponse, error)
|
||||
// ResendVerification replaces Handlers.ResendVerification.
|
||||
ResendVerification(context.Context, *ResendVerificationRequest) (*ResendVerificationResponse, error)
|
||||
// VerifyEmail replaces Handlers.VerifyEmail.
|
||||
VerifyEmail(context.Context, *VerifyEmailRequest) (*VerifyEmailResponse, error)
|
||||
// Login replaces Handlers.Login: verifies credentials (constant-time
|
||||
// against a dummy hash for unknown emails) and, on success, mints a
|
||||
// session and returns its raw token for the gateway to set as a
|
||||
// cookie. Never persists the raw token — only its SHA-256 hash.
|
||||
Login(context.Context, *LoginRequest) (*LoginResponse, error)
|
||||
// Logout replaces Handlers.Logout: deletes the session row for the
|
||||
// given raw session token.
|
||||
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
|
||||
// ValidateSession replaces the session half of Handlers.RequireAuth /
|
||||
// db.GetSessionUser. Called by the gateway on every authenticated
|
||||
// request.
|
||||
ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error)
|
||||
// ForgotPassword replaces Handlers.ForgotPassword.
|
||||
ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error)
|
||||
// CheckPasswordResetToken replaces the GET-time validity check in
|
||||
// Handlers.ResetPasswordPage (db.PasswordResetTokenValid).
|
||||
CheckPasswordResetToken(context.Context, *CheckPasswordResetTokenRequest) (*CheckPasswordResetTokenResponse, error)
|
||||
// ResetPassword replaces Handlers.ResetPassword. Also revokes every
|
||||
// session for the account (db.UpdatePassword's cascade delete).
|
||||
ResetPassword(context.Context, *ResetPasswordRequest) (*ResetPasswordResponse, error)
|
||||
// GetUser is used by other services (e.g. billing, when it only has a
|
||||
// user_id) to resolve an email address. Never returns PasswordHash.
|
||||
GetUser(context.Context, *GetUserRequest) (*User, error)
|
||||
mustEmbedUnimplementedIAMServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedIAMServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedIAMServiceServer struct{}
|
||||
|
||||
func (UnimplementedIAMServiceServer) SignUp(context.Context, *SignUpRequest) (*SignUpResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SignUp not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) ResendVerification(context.Context, *ResendVerificationRequest) (*ResendVerificationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ResendVerification not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) VerifyEmail(context.Context, *VerifyEmailRequest) (*VerifyEmailResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method VerifyEmail not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ValidateSession not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ForgotPassword not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) CheckPasswordResetToken(context.Context, *CheckPasswordResetTokenRequest) (*CheckPasswordResetTokenResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckPasswordResetToken not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) ResetPassword(context.Context, *ResetPasswordRequest) (*ResetPasswordResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ResetPassword not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) GetUser(context.Context, *GetUserRequest) (*User, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
|
||||
}
|
||||
func (UnimplementedIAMServiceServer) mustEmbedUnimplementedIAMServiceServer() {}
|
||||
func (UnimplementedIAMServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeIAMServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to IAMServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeIAMServiceServer interface {
|
||||
mustEmbedUnimplementedIAMServiceServer()
|
||||
}
|
||||
|
||||
func RegisterIAMServiceServer(s grpc.ServiceRegistrar, srv IAMServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedIAMServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&IAMService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _IAMService_SignUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SignUpRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).SignUp(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_SignUp_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).SignUp(ctx, req.(*SignUpRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_ResendVerification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResendVerificationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).ResendVerification(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_ResendVerification_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).ResendVerification(ctx, req.(*ResendVerificationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_VerifyEmail_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VerifyEmailRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).VerifyEmail(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_VerifyEmail_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).VerifyEmail(ctx, req.(*VerifyEmailRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(LoginRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).Login(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_Login_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).Login(ctx, req.(*LoginRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(LogoutRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).Logout(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_Logout_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).Logout(ctx, req.(*LogoutRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_ValidateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ValidateSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).ValidateSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_ValidateSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).ValidateSession(ctx, req.(*ValidateSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_ForgotPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ForgotPasswordRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).ForgotPassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_ForgotPassword_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).ForgotPassword(ctx, req.(*ForgotPasswordRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_CheckPasswordResetToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CheckPasswordResetTokenRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).CheckPasswordResetToken(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_CheckPasswordResetToken_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).CheckPasswordResetToken(ctx, req.(*CheckPasswordResetTokenRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_ResetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResetPasswordRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).ResetPassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_ResetPassword_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).ResetPassword(ctx, req.(*ResetPasswordRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _IAMService_GetUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(IAMServiceServer).GetUser(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: IAMService_GetUser_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(IAMServiceServer).GetUser(ctx, req.(*GetUserRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// IAMService_ServiceDesc is the grpc.ServiceDesc for IAMService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var IAMService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "enkly.iam.v1.IAMService",
|
||||
HandlerType: (*IAMServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "SignUp",
|
||||
Handler: _IAMService_SignUp_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ResendVerification",
|
||||
Handler: _IAMService_ResendVerification_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "VerifyEmail",
|
||||
Handler: _IAMService_VerifyEmail_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Login",
|
||||
Handler: _IAMService_Login_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Logout",
|
||||
Handler: _IAMService_Logout_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ValidateSession",
|
||||
Handler: _IAMService_ValidateSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ForgotPassword",
|
||||
Handler: _IAMService_ForgotPassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckPasswordResetToken",
|
||||
Handler: _IAMService_CheckPasswordResetToken_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ResetPassword",
|
||||
Handler: _IAMService_ResetPassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUser",
|
||||
Handler: _IAMService_GetUser_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "iam/v1/iam.proto",
|
||||
}
|
||||
765
gen/knowledge/v1/knowledge.pb.go
Normal file
765
gen/knowledge/v1/knowledge.pb.go
Normal file
@@ -0,0 +1,765 @@
|
||||
// Package knowledge is the Code Intelligence (RAG) bounded context:
|
||||
// chunking, embedding (via the knowledge service's vLLM-backed embed client) and
|
||||
// cosine-similarity search over workspace source files. System of
|
||||
// record for the pgvector-backed `documents` table (see
|
||||
// services/knowledge/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/rag/*.go. IndexDirectory (cmd/indexer) now walks the
|
||||
// workspace through the workspace service instead of the local
|
||||
// filesystem — see tools/indexer.
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v3.21.12
|
||||
// source: knowledge/v1/knowledge.proto
|
||||
|
||||
package knowledgev1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type CodeChunk struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"`
|
||||
ChunkIndex int32 `protobuf:"varint,3,opt,name=chunk_index,json=chunkIndex,proto3" json:"chunk_index,omitempty"`
|
||||
Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"`
|
||||
Distance float64 `protobuf:"fixed64,5,opt,name=distance,proto3" json:"distance,omitempty"` // cosine distance; lower = more relevant
|
||||
}
|
||||
|
||||
func (x *CodeChunk) Reset() {
|
||||
*x = CodeChunk{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *CodeChunk) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CodeChunk) ProtoMessage() {}
|
||||
|
||||
func (x *CodeChunk) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CodeChunk.ProtoReflect.Descriptor instead.
|
||||
func (*CodeChunk) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *CodeChunk) GetId() int64 {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CodeChunk) GetFilePath() string {
|
||||
if x != nil {
|
||||
return x.FilePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CodeChunk) GetChunkIndex() int32 {
|
||||
if x != nil {
|
||||
return x.ChunkIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CodeChunk) GetContent() string {
|
||||
if x != nil {
|
||||
return x.Content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CodeChunk) GetDistance() float64 {
|
||||
if x != nil {
|
||||
return x.Distance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type IndexFileRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
FilePath string `protobuf:"bytes,1,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"`
|
||||
Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"`
|
||||
}
|
||||
|
||||
func (x *IndexFileRequest) Reset() {
|
||||
*x = IndexFileRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *IndexFileRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*IndexFileRequest) ProtoMessage() {}
|
||||
|
||||
func (x *IndexFileRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use IndexFileRequest.ProtoReflect.Descriptor instead.
|
||||
func (*IndexFileRequest) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *IndexFileRequest) GetFilePath() string {
|
||||
if x != nil {
|
||||
return x.FilePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *IndexFileRequest) GetContent() string {
|
||||
if x != nil {
|
||||
return x.Content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type IndexFileResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ChunksIndexed int32 `protobuf:"varint,1,opt,name=chunks_indexed,json=chunksIndexed,proto3" json:"chunks_indexed,omitempty"`
|
||||
ChunksSkipped int32 `protobuf:"varint,2,opt,name=chunks_skipped,json=chunksSkipped,proto3" json:"chunks_skipped,omitempty"` // unchanged content_hash
|
||||
}
|
||||
|
||||
func (x *IndexFileResponse) Reset() {
|
||||
*x = IndexFileResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *IndexFileResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*IndexFileResponse) ProtoMessage() {}
|
||||
|
||||
func (x *IndexFileResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use IndexFileResponse.ProtoReflect.Descriptor instead.
|
||||
func (*IndexFileResponse) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *IndexFileResponse) GetChunksIndexed() int32 {
|
||||
if x != nil {
|
||||
return x.ChunksIndexed
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *IndexFileResponse) GetChunksSkipped() int32 {
|
||||
if x != nil {
|
||||
return x.ChunksSkipped
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type PruneMissingRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
KeepFilePaths []string `protobuf:"bytes,1,rep,name=keep_file_paths,json=keepFilePaths,proto3" json:"keep_file_paths,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PruneMissingRequest) Reset() {
|
||||
*x = PruneMissingRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PruneMissingRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PruneMissingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PruneMissingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PruneMissingRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PruneMissingRequest) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *PruneMissingRequest) GetKeepFilePaths() []string {
|
||||
if x != nil {
|
||||
return x.KeepFilePaths
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PruneMissingResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
RowsDeleted int32 `protobuf:"varint,1,opt,name=rows_deleted,json=rowsDeleted,proto3" json:"rows_deleted,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PruneMissingResponse) Reset() {
|
||||
*x = PruneMissingResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PruneMissingResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PruneMissingResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PruneMissingResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PruneMissingResponse.ProtoReflect.Descriptor instead.
|
||||
func (*PruneMissingResponse) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *PruneMissingResponse) GetRowsDeleted() int32 {
|
||||
if x != nil {
|
||||
return x.RowsDeleted
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type SearchRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"`
|
||||
TopK int32 `protobuf:"varint,2,opt,name=top_k,json=topK,proto3" json:"top_k,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SearchRequest) Reset() {
|
||||
*x = SearchRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SearchRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SearchRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SearchRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SearchRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SearchRequest) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *SearchRequest) GetQuery() string {
|
||||
if x != nil {
|
||||
return x.Query
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SearchRequest) GetTopK() int32 {
|
||||
if x != nil {
|
||||
return x.TopK
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Chunks []*CodeChunk `protobuf:"bytes,1,rep,name=chunks,proto3" json:"chunks,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SearchResponse) Reset() {
|
||||
*x = SearchResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SearchResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SearchResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SearchResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SearchResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SearchResponse) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *SearchResponse) GetChunks() []*CodeChunk {
|
||||
if x != nil {
|
||||
return x.Chunks
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListIndexedFilesRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Limit int32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` // 0 = default (200)
|
||||
}
|
||||
|
||||
func (x *ListIndexedFilesRequest) Reset() {
|
||||
*x = ListIndexedFilesRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ListIndexedFilesRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListIndexedFilesRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListIndexedFilesRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListIndexedFilesRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListIndexedFilesRequest) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *ListIndexedFilesRequest) GetLimit() int32 {
|
||||
if x != nil {
|
||||
return x.Limit
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListIndexedFilesResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
FilePaths []string `protobuf:"bytes,1,rep,name=file_paths,json=filePaths,proto3" json:"file_paths,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ListIndexedFilesResponse) Reset() {
|
||||
*x = ListIndexedFilesResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ListIndexedFilesResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListIndexedFilesResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListIndexedFilesResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_knowledge_v1_knowledge_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListIndexedFilesResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListIndexedFilesResponse) Descriptor() ([]byte, []int) {
|
||||
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *ListIndexedFilesResponse) GetFilePaths() []string {
|
||||
if x != nil {
|
||||
return x.FilePaths
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_knowledge_v1_knowledge_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_knowledge_v1_knowledge_proto_rawDesc = []byte{
|
||||
0x0a, 0x1c, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6b,
|
||||
0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x12,
|
||||
0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e,
|
||||
0x76, 0x31, 0x22, 0x8f, 0x01, 0x0a, 0x09, 0x43, 0x6f, 0x64, 0x65, 0x43, 0x68, 0x75, 0x6e, 0x6b,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64,
|
||||
0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a,
|
||||
0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x05, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x18,
|
||||
0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x69, 0x73, 0x74,
|
||||
0x61, 0x6e, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x64, 0x69, 0x73, 0x74,
|
||||
0x61, 0x6e, 0x63, 0x65, 0x22, 0x49, 0x0a, 0x10, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c,
|
||||
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65,
|
||||
0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c,
|
||||
0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22,
|
||||
0x61, 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x5f, 0x69,
|
||||
0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x68,
|
||||
0x75, 0x6e, 0x6b, 0x73, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x63,
|
||||
0x68, 0x75, 0x6e, 0x6b, 0x73, 0x5f, 0x73, 0x6b, 0x69, 0x70, 0x70, 0x65, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x53, 0x6b, 0x69, 0x70, 0x70,
|
||||
0x65, 0x64, 0x22, 0x3d, 0x0a, 0x13, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69,
|
||||
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6b, 0x65, 0x65,
|
||||
0x70, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x01, 0x20, 0x03,
|
||||
0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x65, 0x70, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68,
|
||||
0x73, 0x22, 0x39, 0x0a, 0x14, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e,
|
||||
0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x6f, 0x77,
|
||||
0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52,
|
||||
0x0b, 0x72, 0x6f, 0x77, 0x73, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x0a, 0x0d,
|
||||
0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75,
|
||||
0x65, 0x72, 0x79, 0x12, 0x13, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x5f, 0x6b, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x05, 0x52, 0x04, 0x74, 0x6f, 0x70, 0x4b, 0x22, 0x47, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72,
|
||||
0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x68,
|
||||
0x75, 0x6e, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x65, 0x6e, 0x6b,
|
||||
0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e,
|
||||
0x43, 0x6f, 0x64, 0x65, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x52, 0x06, 0x63, 0x68, 0x75, 0x6e, 0x6b,
|
||||
0x73, 0x22, 0x2f, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64,
|
||||
0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x69, 0x6d,
|
||||
0x69, 0x74, 0x22, 0x39, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65,
|
||||
0x64, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d,
|
||||
0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x01, 0x20, 0x03,
|
||||
0x28, 0x09, 0x52, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x73, 0x32, 0x8f, 0x03,
|
||||
0x0a, 0x10, 0x4b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69,
|
||||
0x63, 0x65, 0x12, 0x58, 0x0a, 0x09, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c, 0x65, 0x12,
|
||||
0x24, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67,
|
||||
0x65, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e,
|
||||
0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78,
|
||||
0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x0c,
|
||||
0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x27, 0x2e, 0x65,
|
||||
0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e,
|
||||
0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x75, 0x6e, 0x65,
|
||||
0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x4f, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x21, 0x2e, 0x65, 0x6e, 0x6b, 0x6c,
|
||||
0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x65,
|
||||
0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x6d, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x46,
|
||||
0x69, 0x6c, 0x65, 0x73, 0x12, 0x2b, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f,
|
||||
0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e,
|
||||
0x64, 0x65, 0x78, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x2c, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65,
|
||||
0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78,
|
||||
0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42,
|
||||
0x24, 0x5a, 0x22, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6b, 0x6e, 0x6f,
|
||||
0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65,
|
||||
0x64, 0x67, 0x65, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_knowledge_v1_knowledge_proto_rawDescOnce sync.Once
|
||||
file_knowledge_v1_knowledge_proto_rawDescData = file_knowledge_v1_knowledge_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_knowledge_v1_knowledge_proto_rawDescGZIP() []byte {
|
||||
file_knowledge_v1_knowledge_proto_rawDescOnce.Do(func() {
|
||||
file_knowledge_v1_knowledge_proto_rawDescData = protoimpl.X.CompressGZIP(file_knowledge_v1_knowledge_proto_rawDescData)
|
||||
})
|
||||
return file_knowledge_v1_knowledge_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_knowledge_v1_knowledge_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_knowledge_v1_knowledge_proto_goTypes = []any{
|
||||
(*CodeChunk)(nil), // 0: enkly.knowledge.v1.CodeChunk
|
||||
(*IndexFileRequest)(nil), // 1: enkly.knowledge.v1.IndexFileRequest
|
||||
(*IndexFileResponse)(nil), // 2: enkly.knowledge.v1.IndexFileResponse
|
||||
(*PruneMissingRequest)(nil), // 3: enkly.knowledge.v1.PruneMissingRequest
|
||||
(*PruneMissingResponse)(nil), // 4: enkly.knowledge.v1.PruneMissingResponse
|
||||
(*SearchRequest)(nil), // 5: enkly.knowledge.v1.SearchRequest
|
||||
(*SearchResponse)(nil), // 6: enkly.knowledge.v1.SearchResponse
|
||||
(*ListIndexedFilesRequest)(nil), // 7: enkly.knowledge.v1.ListIndexedFilesRequest
|
||||
(*ListIndexedFilesResponse)(nil), // 8: enkly.knowledge.v1.ListIndexedFilesResponse
|
||||
}
|
||||
var file_knowledge_v1_knowledge_proto_depIdxs = []int32{
|
||||
0, // 0: enkly.knowledge.v1.SearchResponse.chunks:type_name -> enkly.knowledge.v1.CodeChunk
|
||||
1, // 1: enkly.knowledge.v1.KnowledgeService.IndexFile:input_type -> enkly.knowledge.v1.IndexFileRequest
|
||||
3, // 2: enkly.knowledge.v1.KnowledgeService.PruneMissing:input_type -> enkly.knowledge.v1.PruneMissingRequest
|
||||
5, // 3: enkly.knowledge.v1.KnowledgeService.Search:input_type -> enkly.knowledge.v1.SearchRequest
|
||||
7, // 4: enkly.knowledge.v1.KnowledgeService.ListIndexedFiles:input_type -> enkly.knowledge.v1.ListIndexedFilesRequest
|
||||
2, // 5: enkly.knowledge.v1.KnowledgeService.IndexFile:output_type -> enkly.knowledge.v1.IndexFileResponse
|
||||
4, // 6: enkly.knowledge.v1.KnowledgeService.PruneMissing:output_type -> enkly.knowledge.v1.PruneMissingResponse
|
||||
6, // 7: enkly.knowledge.v1.KnowledgeService.Search:output_type -> enkly.knowledge.v1.SearchResponse
|
||||
8, // 8: enkly.knowledge.v1.KnowledgeService.ListIndexedFiles:output_type -> enkly.knowledge.v1.ListIndexedFilesResponse
|
||||
5, // [5:9] is the sub-list for method output_type
|
||||
1, // [1:5] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_knowledge_v1_knowledge_proto_init() }
|
||||
func file_knowledge_v1_knowledge_proto_init() {
|
||||
if File_knowledge_v1_knowledge_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*CodeChunk); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*IndexFileRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*IndexFileResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*PruneMissingRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*PruneMissingResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*SearchRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*SearchResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ListIndexedFilesRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_knowledge_v1_knowledge_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*ListIndexedFilesResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_knowledge_v1_knowledge_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 9,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_knowledge_v1_knowledge_proto_goTypes,
|
||||
DependencyIndexes: file_knowledge_v1_knowledge_proto_depIdxs,
|
||||
MessageInfos: file_knowledge_v1_knowledge_proto_msgTypes,
|
||||
}.Build()
|
||||
File_knowledge_v1_knowledge_proto = out.File
|
||||
file_knowledge_v1_knowledge_proto_rawDesc = nil
|
||||
file_knowledge_v1_knowledge_proto_goTypes = nil
|
||||
file_knowledge_v1_knowledge_proto_depIdxs = nil
|
||||
}
|
||||
261
gen/knowledge/v1/knowledge_grpc.pb.go
Normal file
261
gen/knowledge/v1/knowledge_grpc.pb.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Package knowledge is the Code Intelligence (RAG) bounded context:
|
||||
// chunking, embedding (via the knowledge service's vLLM-backed embed client) and
|
||||
// cosine-similarity search over workspace source files. System of
|
||||
// record for the pgvector-backed `documents` table (see
|
||||
// services/knowledge/migrations/0001_init.sql).
|
||||
//
|
||||
// Mirrors internal/rag/*.go. IndexDirectory (cmd/indexer) now walks the
|
||||
// workspace through the workspace service instead of the local
|
||||
// filesystem — see tools/indexer.
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: knowledge/v1/knowledge.proto
|
||||
|
||||
package knowledgev1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
KnowledgeService_IndexFile_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/IndexFile"
|
||||
KnowledgeService_PruneMissing_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/PruneMissing"
|
||||
KnowledgeService_Search_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/Search"
|
||||
KnowledgeService_ListIndexedFiles_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/ListIndexedFiles"
|
||||
)
|
||||
|
||||
// KnowledgeServiceClient is the client API for KnowledgeService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type KnowledgeServiceClient interface {
|
||||
// IndexFile replaces rag.Store.IndexFile: chunks the given file
|
||||
// content, embeds each new/changed chunk, and upserts it.
|
||||
IndexFile(ctx context.Context, in *IndexFileRequest, opts ...grpc.CallOption) (*IndexFileResponse, error)
|
||||
// PruneMissing replaces rag.Store.pruneMissing, called by the indexer
|
||||
// after a full directory walk to drop rows for files no longer
|
||||
// present.
|
||||
PruneMissing(ctx context.Context, in *PruneMissingRequest, opts ...grpc.CallOption) (*PruneMissingResponse, error)
|
||||
// Search replaces rag.Store.Search — the search_codebase agent tool.
|
||||
Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error)
|
||||
// ListIndexedFiles replaces db.ListIndexedFiles, used by the gateway's
|
||||
// sidebar "indexed files" panel (Handlers.renderConversation).
|
||||
ListIndexedFiles(ctx context.Context, in *ListIndexedFilesRequest, opts ...grpc.CallOption) (*ListIndexedFilesResponse, error)
|
||||
}
|
||||
|
||||
type knowledgeServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewKnowledgeServiceClient(cc grpc.ClientConnInterface) KnowledgeServiceClient {
|
||||
return &knowledgeServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *knowledgeServiceClient) IndexFile(ctx context.Context, in *IndexFileRequest, opts ...grpc.CallOption) (*IndexFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(IndexFileResponse)
|
||||
err := c.cc.Invoke(ctx, KnowledgeService_IndexFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *knowledgeServiceClient) PruneMissing(ctx context.Context, in *PruneMissingRequest, opts ...grpc.CallOption) (*PruneMissingResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PruneMissingResponse)
|
||||
err := c.cc.Invoke(ctx, KnowledgeService_PruneMissing_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *knowledgeServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SearchResponse)
|
||||
err := c.cc.Invoke(ctx, KnowledgeService_Search_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *knowledgeServiceClient) ListIndexedFiles(ctx context.Context, in *ListIndexedFilesRequest, opts ...grpc.CallOption) (*ListIndexedFilesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListIndexedFilesResponse)
|
||||
err := c.cc.Invoke(ctx, KnowledgeService_ListIndexedFiles_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// KnowledgeServiceServer is the server API for KnowledgeService service.
|
||||
// All implementations must embed UnimplementedKnowledgeServiceServer
|
||||
// for forward compatibility.
|
||||
type KnowledgeServiceServer interface {
|
||||
// IndexFile replaces rag.Store.IndexFile: chunks the given file
|
||||
// content, embeds each new/changed chunk, and upserts it.
|
||||
IndexFile(context.Context, *IndexFileRequest) (*IndexFileResponse, error)
|
||||
// PruneMissing replaces rag.Store.pruneMissing, called by the indexer
|
||||
// after a full directory walk to drop rows for files no longer
|
||||
// present.
|
||||
PruneMissing(context.Context, *PruneMissingRequest) (*PruneMissingResponse, error)
|
||||
// Search replaces rag.Store.Search — the search_codebase agent tool.
|
||||
Search(context.Context, *SearchRequest) (*SearchResponse, error)
|
||||
// ListIndexedFiles replaces db.ListIndexedFiles, used by the gateway's
|
||||
// sidebar "indexed files" panel (Handlers.renderConversation).
|
||||
ListIndexedFiles(context.Context, *ListIndexedFilesRequest) (*ListIndexedFilesResponse, error)
|
||||
mustEmbedUnimplementedKnowledgeServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedKnowledgeServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedKnowledgeServiceServer struct{}
|
||||
|
||||
func (UnimplementedKnowledgeServiceServer) IndexFile(context.Context, *IndexFileRequest) (*IndexFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method IndexFile not implemented")
|
||||
}
|
||||
func (UnimplementedKnowledgeServiceServer) PruneMissing(context.Context, *PruneMissingRequest) (*PruneMissingResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method PruneMissing not implemented")
|
||||
}
|
||||
func (UnimplementedKnowledgeServiceServer) Search(context.Context, *SearchRequest) (*SearchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Search not implemented")
|
||||
}
|
||||
func (UnimplementedKnowledgeServiceServer) ListIndexedFiles(context.Context, *ListIndexedFilesRequest) (*ListIndexedFilesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListIndexedFiles not implemented")
|
||||
}
|
||||
func (UnimplementedKnowledgeServiceServer) mustEmbedUnimplementedKnowledgeServiceServer() {}
|
||||
func (UnimplementedKnowledgeServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeKnowledgeServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to KnowledgeServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeKnowledgeServiceServer interface {
|
||||
mustEmbedUnimplementedKnowledgeServiceServer()
|
||||
}
|
||||
|
||||
func RegisterKnowledgeServiceServer(s grpc.ServiceRegistrar, srv KnowledgeServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedKnowledgeServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&KnowledgeService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _KnowledgeService_IndexFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(IndexFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KnowledgeServiceServer).IndexFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: KnowledgeService_IndexFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KnowledgeServiceServer).IndexFile(ctx, req.(*IndexFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _KnowledgeService_PruneMissing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PruneMissingRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KnowledgeServiceServer).PruneMissing(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: KnowledgeService_PruneMissing_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KnowledgeServiceServer).PruneMissing(ctx, req.(*PruneMissingRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _KnowledgeService_Search_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SearchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KnowledgeServiceServer).Search(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: KnowledgeService_Search_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KnowledgeServiceServer).Search(ctx, req.(*SearchRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _KnowledgeService_ListIndexedFiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListIndexedFilesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KnowledgeServiceServer).ListIndexedFiles(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: KnowledgeService_ListIndexedFiles_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KnowledgeServiceServer).ListIndexedFiles(ctx, req.(*ListIndexedFilesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// KnowledgeService_ServiceDesc is the grpc.ServiceDesc for KnowledgeService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var KnowledgeService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "enkly.knowledge.v1.KnowledgeService",
|
||||
HandlerType: (*KnowledgeServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "IndexFile",
|
||||
Handler: _KnowledgeService_IndexFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PruneMissing",
|
||||
Handler: _KnowledgeService_PruneMissing_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Search",
|
||||
Handler: _KnowledgeService_Search_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListIndexedFiles",
|
||||
Handler: _KnowledgeService_ListIndexedFiles_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "knowledge/v1/knowledge.proto",
|
||||
}
|
||||
2271
gen/workspace/v1/workspace.pb.go
Normal file
2271
gen/workspace/v1/workspace.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
554
gen/workspace/v1/workspace_grpc.pb.go
Normal file
554
gen/workspace/v1/workspace_grpc.pb.go
Normal file
@@ -0,0 +1,554 @@
|
||||
// Package workspace is the Workspace (agent sandbox / files) bounded
|
||||
// context: the single shared ENKLY_WORKSPACE directory the coding agent
|
||||
// reads, writes and runs commands in, and that the "Files" panel in the
|
||||
// UI lets a human upload into / download from. Every path-jailing rule
|
||||
// from internal/llm/sandbox.go (ResolvePath, the bash allowlist, the
|
||||
// filesystem guards) is enforced here and nowhere else — no other
|
||||
// service is allowed to touch the workspace volume directly.
|
||||
//
|
||||
// Mirrors internal/llm/tools.go (filesystem + bash tools) and
|
||||
// internal/handlers/files.go / ui.go (upload, download, tree).
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: workspace/v1/workspace.proto
|
||||
|
||||
package workspacev1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
WorkspaceService_ReadFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/ReadFile"
|
||||
WorkspaceService_WriteFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/WriteFile"
|
||||
WorkspaceService_EditFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/EditFile"
|
||||
WorkspaceService_MultiEditFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/MultiEditFile"
|
||||
WorkspaceService_ListDirectory_FullMethodName = "/enkly.workspace.v1.WorkspaceService/ListDirectory"
|
||||
WorkspaceService_GlobSearch_FullMethodName = "/enkly.workspace.v1.WorkspaceService/GlobSearch"
|
||||
WorkspaceService_Grep_FullMethodName = "/enkly.workspace.v1.WorkspaceService/Grep"
|
||||
WorkspaceService_RunBashCommand_FullMethodName = "/enkly.workspace.v1.WorkspaceService/RunBashCommand"
|
||||
WorkspaceService_UploadFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/UploadFile"
|
||||
WorkspaceService_DownloadFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/DownloadFile"
|
||||
WorkspaceService_Tree_FullMethodName = "/enkly.workspace.v1.WorkspaceService/Tree"
|
||||
WorkspaceService_StatFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/StatFile"
|
||||
)
|
||||
|
||||
// WorkspaceServiceClient is the client API for WorkspaceService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type WorkspaceServiceClient interface {
|
||||
// --- agent filesystem/bash tools (internal/llm/tools.go, sandbox.go) ---
|
||||
ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (*ReadFileResponse, error)
|
||||
WriteFile(ctx context.Context, in *WriteFileRequest, opts ...grpc.CallOption) (*WriteFileResponse, error)
|
||||
EditFile(ctx context.Context, in *EditFileRequest, opts ...grpc.CallOption) (*EditFileResponse, error)
|
||||
MultiEditFile(ctx context.Context, in *MultiEditFileRequest, opts ...grpc.CallOption) (*MultiEditFileResponse, error)
|
||||
ListDirectory(ctx context.Context, in *ListDirectoryRequest, opts ...grpc.CallOption) (*ListDirectoryResponse, error)
|
||||
GlobSearch(ctx context.Context, in *GlobSearchRequest, opts ...grpc.CallOption) (*GlobSearchResponse, error)
|
||||
Grep(ctx context.Context, in *GrepRequest, opts ...grpc.CallOption) (*GrepResponse, error)
|
||||
RunBashCommand(ctx context.Context, in *RunBashCommandRequest, opts ...grpc.CallOption) (*RunBashCommandResponse, error)
|
||||
// --- human-facing files panel (internal/handlers/files.go, ui.go) ---
|
||||
UploadFile(ctx context.Context, in *UploadFileRequest, opts ...grpc.CallOption) (*UploadFileResponse, error)
|
||||
DownloadFile(ctx context.Context, in *DownloadFileRequest, opts ...grpc.CallOption) (*DownloadFileResponse, error)
|
||||
Tree(ctx context.Context, in *TreeRequest, opts ...grpc.CallOption) (*TreeResponse, error)
|
||||
StatFile(ctx context.Context, in *StatFileRequest, opts ...grpc.CallOption) (*StatFileResponse, error)
|
||||
}
|
||||
|
||||
type workspaceServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewWorkspaceServiceClient(cc grpc.ClientConnInterface) WorkspaceServiceClient {
|
||||
return &workspaceServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (*ReadFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ReadFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_ReadFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) WriteFile(ctx context.Context, in *WriteFileRequest, opts ...grpc.CallOption) (*WriteFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(WriteFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_WriteFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) EditFile(ctx context.Context, in *EditFileRequest, opts ...grpc.CallOption) (*EditFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(EditFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_EditFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) MultiEditFile(ctx context.Context, in *MultiEditFileRequest, opts ...grpc.CallOption) (*MultiEditFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MultiEditFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_MultiEditFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) ListDirectory(ctx context.Context, in *ListDirectoryRequest, opts ...grpc.CallOption) (*ListDirectoryResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListDirectoryResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_ListDirectory_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) GlobSearch(ctx context.Context, in *GlobSearchRequest, opts ...grpc.CallOption) (*GlobSearchResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GlobSearchResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_GlobSearch_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) Grep(ctx context.Context, in *GrepRequest, opts ...grpc.CallOption) (*GrepResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GrepResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_Grep_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) RunBashCommand(ctx context.Context, in *RunBashCommandRequest, opts ...grpc.CallOption) (*RunBashCommandResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RunBashCommandResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_RunBashCommand_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) UploadFile(ctx context.Context, in *UploadFileRequest, opts ...grpc.CallOption) (*UploadFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UploadFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_UploadFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) DownloadFile(ctx context.Context, in *DownloadFileRequest, opts ...grpc.CallOption) (*DownloadFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DownloadFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_DownloadFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) Tree(ctx context.Context, in *TreeRequest, opts ...grpc.CallOption) (*TreeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(TreeResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_Tree_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workspaceServiceClient) StatFile(ctx context.Context, in *StatFileRequest, opts ...grpc.CallOption) (*StatFileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StatFileResponse)
|
||||
err := c.cc.Invoke(ctx, WorkspaceService_StatFile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WorkspaceServiceServer is the server API for WorkspaceService service.
|
||||
// All implementations must embed UnimplementedWorkspaceServiceServer
|
||||
// for forward compatibility.
|
||||
type WorkspaceServiceServer interface {
|
||||
// --- agent filesystem/bash tools (internal/llm/tools.go, sandbox.go) ---
|
||||
ReadFile(context.Context, *ReadFileRequest) (*ReadFileResponse, error)
|
||||
WriteFile(context.Context, *WriteFileRequest) (*WriteFileResponse, error)
|
||||
EditFile(context.Context, *EditFileRequest) (*EditFileResponse, error)
|
||||
MultiEditFile(context.Context, *MultiEditFileRequest) (*MultiEditFileResponse, error)
|
||||
ListDirectory(context.Context, *ListDirectoryRequest) (*ListDirectoryResponse, error)
|
||||
GlobSearch(context.Context, *GlobSearchRequest) (*GlobSearchResponse, error)
|
||||
Grep(context.Context, *GrepRequest) (*GrepResponse, error)
|
||||
RunBashCommand(context.Context, *RunBashCommandRequest) (*RunBashCommandResponse, error)
|
||||
// --- human-facing files panel (internal/handlers/files.go, ui.go) ---
|
||||
UploadFile(context.Context, *UploadFileRequest) (*UploadFileResponse, error)
|
||||
DownloadFile(context.Context, *DownloadFileRequest) (*DownloadFileResponse, error)
|
||||
Tree(context.Context, *TreeRequest) (*TreeResponse, error)
|
||||
StatFile(context.Context, *StatFileRequest) (*StatFileResponse, error)
|
||||
mustEmbedUnimplementedWorkspaceServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedWorkspaceServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedWorkspaceServiceServer struct{}
|
||||
|
||||
func (UnimplementedWorkspaceServiceServer) ReadFile(context.Context, *ReadFileRequest) (*ReadFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReadFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) WriteFile(context.Context, *WriteFileRequest) (*WriteFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method WriteFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) EditFile(context.Context, *EditFileRequest) (*EditFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method EditFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) MultiEditFile(context.Context, *MultiEditFileRequest) (*MultiEditFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method MultiEditFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) ListDirectory(context.Context, *ListDirectoryRequest) (*ListDirectoryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListDirectory not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) GlobSearch(context.Context, *GlobSearchRequest) (*GlobSearchResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GlobSearch not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) Grep(context.Context, *GrepRequest) (*GrepResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Grep not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) RunBashCommand(context.Context, *RunBashCommandRequest) (*RunBashCommandResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RunBashCommand not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) UploadFile(context.Context, *UploadFileRequest) (*UploadFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UploadFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) DownloadFile(context.Context, *DownloadFileRequest) (*DownloadFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DownloadFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) Tree(context.Context, *TreeRequest) (*TreeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Tree not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) StatFile(context.Context, *StatFileRequest) (*StatFileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method StatFile not implemented")
|
||||
}
|
||||
func (UnimplementedWorkspaceServiceServer) mustEmbedUnimplementedWorkspaceServiceServer() {}
|
||||
func (UnimplementedWorkspaceServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeWorkspaceServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to WorkspaceServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeWorkspaceServiceServer interface {
|
||||
mustEmbedUnimplementedWorkspaceServiceServer()
|
||||
}
|
||||
|
||||
func RegisterWorkspaceServiceServer(s grpc.ServiceRegistrar, srv WorkspaceServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedWorkspaceServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&WorkspaceService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _WorkspaceService_ReadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReadFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).ReadFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_ReadFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).ReadFile(ctx, req.(*ReadFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_WriteFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(WriteFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).WriteFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_WriteFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).WriteFile(ctx, req.(*WriteFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_EditFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(EditFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).EditFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_EditFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).EditFile(ctx, req.(*EditFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_MultiEditFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MultiEditFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).MultiEditFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_MultiEditFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).MultiEditFile(ctx, req.(*MultiEditFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_ListDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListDirectoryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).ListDirectory(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_ListDirectory_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).ListDirectory(ctx, req.(*ListDirectoryRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_GlobSearch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GlobSearchRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).GlobSearch(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_GlobSearch_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).GlobSearch(ctx, req.(*GlobSearchRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_Grep_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GrepRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).Grep(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_Grep_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).Grep(ctx, req.(*GrepRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_RunBashCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RunBashCommandRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).RunBashCommand(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_RunBashCommand_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).RunBashCommand(ctx, req.(*RunBashCommandRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_UploadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UploadFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).UploadFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_UploadFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).UploadFile(ctx, req.(*UploadFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_DownloadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DownloadFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).DownloadFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_DownloadFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).DownloadFile(ctx, req.(*DownloadFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_Tree_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TreeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).Tree(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_Tree_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).Tree(ctx, req.(*TreeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WorkspaceService_StatFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StatFileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WorkspaceServiceServer).StatFile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WorkspaceService_StatFile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkspaceServiceServer).StatFile(ctx, req.(*StatFileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// WorkspaceService_ServiceDesc is the grpc.ServiceDesc for WorkspaceService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var WorkspaceService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "enkly.workspace.v1.WorkspaceService",
|
||||
HandlerType: (*WorkspaceServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ReadFile",
|
||||
Handler: _WorkspaceService_ReadFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "WriteFile",
|
||||
Handler: _WorkspaceService_WriteFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "EditFile",
|
||||
Handler: _WorkspaceService_EditFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MultiEditFile",
|
||||
Handler: _WorkspaceService_MultiEditFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListDirectory",
|
||||
Handler: _WorkspaceService_ListDirectory_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GlobSearch",
|
||||
Handler: _WorkspaceService_GlobSearch_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Grep",
|
||||
Handler: _WorkspaceService_Grep_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RunBashCommand",
|
||||
Handler: _WorkspaceService_RunBashCommand_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UploadFile",
|
||||
Handler: _WorkspaceService_UploadFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DownloadFile",
|
||||
Handler: _WorkspaceService_DownloadFile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Tree",
|
||||
Handler: _WorkspaceService_Tree_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StatFile",
|
||||
Handler: _WorkspaceService_StatFile_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "workspace/v1/workspace.proto",
|
||||
}
|
||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
||||
module enkly
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/pgvector/pgvector-go v0.3.0
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.34.2
|
||||
)
|
||||
20
go.sum
Normal file
20
go.sum
Normal file
@@ -0,0 +1,20 @@
|
||||
github.com/golang/net v0.29.0 h1:Mx+h6LzLbooApFP35Nyl7cml9lqVseBibdIPtwjD37M=
|
||||
github.com/golang/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
github.com/golang/sys v0.31.0 h1:Eq5bV7nV7QPJtg45rs39GBjQmAuNXx0aW0W/Bebr9Y8=
|
||||
github.com/golang/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
github.com/golang/text v0.18.0 h1:ewE2C+lYYgGrAGia0P2syP9ssbUUciSvg/TGTNgDQJE=
|
||||
github.com/golang/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/googleapis/go-genproto v0.0.0-20240903143218-8af14fe29dc1 h1:AVDHBFR7Yj+saskSkvMh4BNLQRgf0aasVu58Y/vVA6k=
|
||||
github.com/googleapis/go-genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:UUIF5TYSIIUc2HQX9xZl/bwTV3BpN7Rqxyh9dIJ65ZM=
|
||||
github.com/googleapis/go-genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
github.com/grpc/grpc-go v1.68.0 h1:pdoraKWBZDoz3kz6sukUu89Phodtmn4i84kxFAaggXM=
|
||||
github.com/grpc/grpc-go v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
||||
github.com/protocolbuffers/protobuf-go v1.34.2 h1:/mKZtacKo6mtfYX8RvoyXDQFPAgeslXDSTWuxZYnewA=
|
||||
github.com/protocolbuffers/protobuf-go v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
6
k8s/00-namespace.yaml
Normal file
6
k8s/00-namespace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: enkly
|
||||
labels:
|
||||
app.kubernetes.io/part-of: enkly
|
||||
103
k8s/01-config.yaml
Normal file
103
k8s/01-config.yaml
Normal file
@@ -0,0 +1,103 @@
|
||||
# Non-secret configuration shared by every service. Keys mirror the
|
||||
# ENKLY_* env vars from docker-compose.yml / .env.example 1:1 so the
|
||||
# application code needs zero changes — only the hostnames change,
|
||||
# from docker-compose service names to in-cluster Kubernetes Service
|
||||
# names (which are identical, so most values don't even change).
|
||||
# NOTE: the three *_POSTGRES_DSN values are NOT here — ConfigMap data
|
||||
# is static text and cannot expand $(POSTGRES_PASSWORD); each DSN is
|
||||
# instead composed inline in its Deployment's env: list (see the
|
||||
# per-service files), where Kubernetes does support that expansion.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: enkly-config
|
||||
namespace: enkly
|
||||
data:
|
||||
# --- shared ---
|
||||
ENKLY_BASE_URL: "https://ai.enkly.org" # set to your real public URL / Ingress host
|
||||
# Back on vLLM (per your request), still on a GPU-less cluster: both
|
||||
# Deployments in 11-vllm.yaml now run vLLM's CPU backend — a custom
|
||||
# build with AVX-512 disabled (registry.enkly.org/enkly/vllm-cpu),
|
||||
# not vLLM's official pre-built image, after vllm-embed's init
|
||||
# container confirmed this cluster's nodes actually lack avx512f —
|
||||
# instead of the CUDA vllm/vllm-openai image, with no nvidia.com/gpu
|
||||
# request and no GPU-pool nodeSelector/toleration. See 11-vllm.yaml's
|
||||
# header for the build command and the memory-sizing fix that went
|
||||
# with it (vllm-chat's original 8Gi request exceeded this cluster's
|
||||
# confirmed ~7.76Gi per-node RAM outright).
|
||||
# Two Deployments, two Services, same as the original GPU manifest
|
||||
# (one vLLM engine process serves exactly one model — see
|
||||
# pkg/vllmkit's doc comment) — unlike the brief Ollama detour this
|
||||
# cluster also tried, which could multiplex both models behind one
|
||||
# daemon. Internal-only ClusterIP Services this time (no public
|
||||
# vllm-chat.enkly.org/vllm-embed.enkly.org Ingress) since agent and
|
||||
# knowledge are the only callers and both are in-cluster.
|
||||
ENKLY_VLLM_CHAT_URL: "http://vllm-chat:8000/v1"
|
||||
ENKLY_VLLM_EMBED_URL: "http://vllm-embed:8000/v1"
|
||||
# qwen3.5 (the original GPU deployment's model, ~35B-parameter MoE,
|
||||
# served via a GPTQ-Marlin checkpoint) isn't viable here twice over:
|
||||
# Marlin's INT4 kernels are CUDA-only, and 35B active-weight decode
|
||||
# at CPU speeds wouldn't be usable latency regardless. Went with
|
||||
# qwen2.5-1.5b-instruct rather than the 3B variant tried first —
|
||||
# unquantized bf16 (CPU quantized-GPTQ/AWQ kernel support exists but
|
||||
# is far less battle-tested than Marlin's GPU path, so plain bf16 is
|
||||
# the safer default here) — specifically because this cluster's
|
||||
# nodes only have ~7.76Gi RAM total each (see 11-vllm.yaml's header):
|
||||
# the 3B model's 8Gi request alone couldn't fit on any node, full
|
||||
# stop. 1.5B still supports OpenAI-style tool calling via vLLM's
|
||||
# hermes parser (required by
|
||||
# services/agent/internal/infrastructure/agentrun). See
|
||||
# 11-vllm.yaml's resources/VLLM_CPU_KVCACHE_SPACE for how this maps
|
||||
# to actual CPU/RAM, and its header for going back up to 3B or
|
||||
# higher once `kubectl describe node` confirms real free headroom.
|
||||
ENKLY_CHAT_MODEL: "qwen2.5-1.5b-instruct"
|
||||
# Note one real behavior gap versus the original Qwen3.5 GPU
|
||||
# deployment: Qwen2.5 has no equivalent to --reasoning-parser=qwen3's
|
||||
# separate "thinking" trace (Message.Thinking / wire
|
||||
# reasoning_content) — vLLM won't emit that field for this model, so
|
||||
# Thinking comes back empty. Doesn't break chat or tool calling,
|
||||
# just drops the reasoning-trace UI panel's content.
|
||||
#
|
||||
# Unchanged — nomic-embed-text was already the right size/cost
|
||||
# trade-off on CPU (see pkg/vllmkit's DefaultEmbedModel comment and
|
||||
# 11-vllm.yaml's vllm-embed Deployment), and no schema.sql
|
||||
# vector(768) dimension change is needed either.
|
||||
ENKLY_EMBED_MODEL: "nomic-embed-text"
|
||||
|
||||
# --- iam ---
|
||||
ENKLY_IAM_GRPC_ADDR: ":9001"
|
||||
ENKLY_SMTP_HOST: "smtp.gmail.com"
|
||||
ENKLY_SMTP_PORT: "587"
|
||||
|
||||
# --- billing ---
|
||||
ENKLY_BILLING_GRPC_ADDR: ":9002"
|
||||
|
||||
# --- conversation ---
|
||||
ENKLY_CONVERSATION_GRPC_ADDR: ":9003"
|
||||
|
||||
# --- workspace ---
|
||||
ENKLY_WORKSPACE_GRPC_ADDR: ":9004"
|
||||
ENKLY_WORKSPACE: "/data/workspace"
|
||||
ENKLY_BASH_TIMEOUT_SECONDS: "120"
|
||||
# Only tools actually present in the workspace image (see its
|
||||
# Dockerfile: ca-certificates + git). Extend the image before
|
||||
# widening this list.
|
||||
ENKLY_BASH_ALLOWLIST: "git,ls,cat,grep,find,pwd,echo"
|
||||
|
||||
# --- knowledge ---
|
||||
ENKLY_KNOWLEDGE_GRPC_ADDR: ":9005"
|
||||
|
||||
# --- agent ---
|
||||
ENKLY_AGENT_GRPC_ADDR: ":9006"
|
||||
|
||||
# --- notification ---
|
||||
ENKLY_NOTIFICATION_HEALTH_ADDR: ":9007"
|
||||
ENKLY_SMTP_FROM: "noreply <contact.enkly@gmail.com>"
|
||||
|
||||
# --- gateway ---
|
||||
ENKLY_GATEWAY_HTTP_ADDR: ":8080"
|
||||
ENKLY_TEMPLATES_DIR: "/app/web/templates"
|
||||
ENKLY_STATIC_DIR: "/app/web/static"
|
||||
|
||||
# --- rabbitmq (non-secret half; the password half is in enkly-secrets) ---
|
||||
RABBITMQ_DEFAULT_USER: "enkly"
|
||||
67
k8s/02-secrets.yaml
Normal file
67
k8s/02-secrets.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
# TEMPLATE — replace every REPLACE_ME before applying, then either
|
||||
# `kubectl apply -f 02-secrets.yaml` or, better, keep this file out of
|
||||
# git and manage it with `kubectl create secret generic` / sealed-secrets
|
||||
# / SOPS instead. Postgres/RabbitMQ passwords are pre-filled with the
|
||||
# same "enkly" default the app ships with (see .env.example) purely so
|
||||
# the stack comes up unmodified on first try — rotate them before any
|
||||
# real deployment.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: enkly-secrets
|
||||
namespace: enkly
|
||||
type: Opaque
|
||||
stringData:
|
||||
POSTGRES_PASSWORD: "enkly"
|
||||
RABBITMQ_DEFAULT_PASS: "enkly"
|
||||
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
|
||||
|
||||
STRIPE_SECRET_KEY: "sk_test_51Sk6S809EijW3hh9YFenSRh3gT2R8QzQ1XwDb4GpgwZx7k1zADSY4ApXh92fP39Clbn5vieX5vKYRM8Ts4t878S800OJfRc1zf"
|
||||
STRIPE_PUBLISH_KEY: "pk_test_51Sk6S809EijW3hh95Tu0y6PyjbYXEKNus9dPlgFFCFqm3WYNRgO8dK2qoUGSbaRT4fDCQko1wyUQ2PrqbfP1FQD100xrrf9gzY"
|
||||
STRIPE_WEBHOOK_SECRET: ""
|
||||
STRIPE_PRICE_BASIC: "5"
|
||||
STRIPE_PRICE_PRO: "8"
|
||||
STRIPE_PRICE_PREMIUM: "12"
|
||||
|
||||
ENKLY_SMTP_USERNAME: "contact.enkly@gmail.com"
|
||||
ENKLY_SMTP_PASSWORD: "ntqjsksezwjqpcac"
|
||||
|
||||
# vLLM (11-vllm.yaml). ENKLY_VLLM_API_KEY is the bearer token both
|
||||
# vllm-chat and vllm-embed require (--api-key) and that agent/
|
||||
# knowledge authenticate with (see services/agent/cmd/server/main.go,
|
||||
# services/knowledge/cmd/server/main.go — both read this exact env
|
||||
# var and default to "" if it's unset, which vLLM's --api-key will
|
||||
# then reject with a 401 on every single request). Generate a real
|
||||
# value BEFORE applying this file — YAML stringData does not run
|
||||
# shell commands, so a literal "$(openssl rand -hex 32)" string here
|
||||
# would silently become the actual, non-random API key instead of
|
||||
# being executed:
|
||||
# kubectl create secret generic enkly-secrets -n enkly \
|
||||
# --from-literal=ENKLY_VLLM_API_KEY=$(openssl rand -hex 32) --dry-run=client -o yaml
|
||||
# or just replace the line below by hand with a real random string.
|
||||
ENKLY_VLLM_API_KEY: "OWVmOGNiZjM1YzFjNjUxNTY0YWJlMWZmYTg0M2FiYWM5ZTkwMjI3NDBmZjAwMzVjMDdlZDUwMjNlZTg1MTk1OQ=="
|
||||
# HUGGING_FACE_HUB_TOKEN is only required if Qwen/Qwen2.5-3B-Instruct
|
||||
# or nomic-ai/nomic-embed-text-v1.5 is gated/private for this org's
|
||||
# HF account; both are public weights today, so leave this as an
|
||||
# empty string unless that changes. Re-added now that 11-vllm.yaml
|
||||
# pulls straight from the Hugging Face Hub again (unlike the brief
|
||||
# Ollama detour, which pulled from Ollama's own registry instead).
|
||||
HUGGING_FACE_HUB_TOKEN: ""
|
||||
|
||||
# Pull secret for registry.enkly.org. Do NOT hand-edit this one — it's
|
||||
# generated, not templated, because .dockerconfigjson must be valid
|
||||
# base64 JSON. Create it with:
|
||||
#
|
||||
# kubectl create secret docker-registry enkly-registry \
|
||||
# --namespace enkly \
|
||||
# --docker-server=registry.enkly.org \
|
||||
# --docker-username=<user> \
|
||||
# --docker-password=<password-or-token> \
|
||||
# --docker-email=<email>
|
||||
#
|
||||
# Every Deployment below references it as imagePullSecrets: [enkly-registry].
|
||||
# If registry.enkly.org serves a self-signed/internal CA certificate,
|
||||
# that trust also has to be configured at the containerd level on
|
||||
# every node (/etc/containerd/certs.d/registry.enkly.org/hosts.toml) —
|
||||
# that's node config, not something a k8s manifest can do; see the
|
||||
# README's "Known issues to fix" section.
|
||||
174
k8s/10-rabbitmq.yaml
Normal file
174
k8s/10-rabbitmq.yaml
Normal file
@@ -0,0 +1,174 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: rabbitmq-data, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
resources: {requests: {storage: 10Gi}}
|
||||
---
|
||||
# Root cause of the alarm in the pod logs: RabbitMQ's default memory
|
||||
# watermark is 40% of what it detects as "total system memory". Inside
|
||||
# this container that detection comes back as the *node's* memory
|
||||
# (7949 MiB), not the 512Mi the container is actually capped to — so
|
||||
# the watermark it set (3179 MiB) is six times higher than the limit
|
||||
# Kubernetes will actually enforce. In practice that means the broker
|
||||
# never throttles publishers in time and gets OOMKilled by the kubelet
|
||||
# instead of gracefully blocking connections, which is what the
|
||||
# watermark is supposed to prevent. Pinning the watermark explicitly,
|
||||
# below the container's real memory limit, fixes that.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata: {name: rabbitmq-conf, namespace: enkly}
|
||||
data:
|
||||
20-enkly.conf: |
|
||||
# Keep this comfortably under the container memory limit below so
|
||||
# RabbitMQ blocks publishers well before Kubernetes OOMKills the pod.
|
||||
vm_memory_high_watermark.absolute = 550MB
|
||||
vm_memory_calculation_strategy = allocated
|
||||
disk_free_limit.absolute = 2GB
|
||||
# NOTE: management_metrics_collection logs its deprecation warning on
|
||||
# every boot regardless of the deprecated_features.permit setting while
|
||||
# it's in the "permitted by default" phase — there is no config to
|
||||
# silence it. It's informational, not actionable, until RabbitMQ moves
|
||||
# it to "denied by default" in a future release. The real work item is
|
||||
# migrating anything scraping the management API for metrics (Grafana,
|
||||
# alerting) over to the rabbitmq_prometheus plugin before that happens.
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: rabbitmq
|
||||
namespace: enkly
|
||||
labels: {app: rabbitmq}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: rabbitmq}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: rabbitmq}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
# RabbitMQ resolves hostnames during boot even with RABBITMQ_NODENAME
|
||||
# pinned to rabbit@localhost (the management plugin and Erlang's
|
||||
# networking stack still do their own lookups). The default resolver
|
||||
# in a pod walks every *.svc.cluster.local search-domain suffix
|
||||
# before giving up on each failed query, and RabbitMQ's own k8s
|
||||
# deployment docs call out exactly this as a cause of ~30-50s boot
|
||||
# stalls. Shortening the per-query timeout/attempts here is the
|
||||
# pod-level fix that doesn't require touching cluster-wide CoreDNS.
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
options:
|
||||
- {name: ndots, value: "2"}
|
||||
- {name: timeout, value: "2"}
|
||||
- {name: attempts, value: "2"}
|
||||
# Runs before the rabbitmq container every single boot, sharing the
|
||||
# same PVC. Deletes any leftover mnesia directory that isn't the one
|
||||
# actually in use (rabbit@localhost). Those directories are what
|
||||
# caused the ~120s boot time and the resulting liveness-probe kills:
|
||||
# every pod restart before RABBITMQ_NODENAME was pinned minted a new
|
||||
# directory named after that pod's hostname, and the entrypoint's
|
||||
# ownership pass over /var/lib/rabbitmq scans all of them. This is
|
||||
# idempotent — once clean, it's a no-op on every future boot, and it
|
||||
# means boot time is never again at the mercy of accumulated cruft,
|
||||
# so the probe timings below don't need to keep being widened.
|
||||
initContainers:
|
||||
- name: clean-stale-mnesia-dirs
|
||||
image: rabbitmq:3.13-management-alpine
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
cd /var/lib/rabbitmq/mnesia 2>/dev/null || exit 0
|
||||
for d in rabbit@*; do
|
||||
[ -e "$d" ] || continue
|
||||
case "$d" in
|
||||
rabbit@localhost|rabbit@localhost-plugins-expand|rabbit@localhost-feature_flags)
|
||||
continue ;;
|
||||
esac
|
||||
echo "removing stale mnesia dir: $d"
|
||||
rm -rf -- "$d"
|
||||
done
|
||||
volumeMounts:
|
||||
- {name: data, mountPath: /var/lib/rabbitmq}
|
||||
containers:
|
||||
- name: rabbitmq
|
||||
image: rabbitmq:3.13-management-alpine
|
||||
ports:
|
||||
- {name: amqp, containerPort: 5672}
|
||||
- {name: mgmt, containerPort: 15672}
|
||||
env:
|
||||
# Pins the Erlang node identity so it does NOT follow the pod's
|
||||
# hostname. Without this, every rollout/reschedule gets a new
|
||||
# pod name -> new node name (rabbit@<pod-name>) -> RabbitMQ
|
||||
# mounts the same PVC but looks for mnesia data under a path
|
||||
# for a node it's never seen before, so it reports "virgin
|
||||
# node" and silently reseeds an empty vhost every time. Safe
|
||||
# as a static value here because replicas is 1 and strategy is
|
||||
# Recreate, so there's never two pods needing distinct identities.
|
||||
- name: RABBITMQ_NODENAME
|
||||
value: "rabbit@localhost"
|
||||
- name: RABBITMQ_DEFAULT_USER
|
||||
valueFrom: {configMapKeyRef: {name: enkly-config, key: RABBITMQ_DEFAULT_USER}}
|
||||
- name: RABBITMQ_DEFAULT_PASS
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: RABBITMQ_DEFAULT_PASS}}
|
||||
resources:
|
||||
# request == limit (Guaranteed QoS): a Burstable broker pod is
|
||||
# one of the kubelet's first eviction targets under node memory
|
||||
# pressure, which is the last thing you want for the message
|
||||
# bus. 768Mi gives the 550MB watermark above real headroom
|
||||
# before the container limit, instead of the old 512Mi limit
|
||||
# sitting below the watermark that was actually configured.
|
||||
# Raised from 250m/500m: gives the liveness probe's
|
||||
# rabbitmq-diagnostics CLI (a separate, short-lived BEAM VM
|
||||
# each time it runs) enough CPU headroom to finish inside its
|
||||
# 5s timeout without contending with the broker process itself.
|
||||
requests: {cpu: 500m, memory: 768Mi}
|
||||
limits: {cpu: 500m, memory: 768Mi}
|
||||
readinessProbe:
|
||||
# Switched from an exec-based "rabbitmq-diagnostics
|
||||
# check_port_connectivity" to a plain TCP check on the AMQP
|
||||
# port. The exec form spins up a brand-new Erlang/BEAM VM on
|
||||
# every single invocation just to run the check — under this
|
||||
# container's CPU limit that overhead can exceed the probe's
|
||||
# own timeout even while the broker itself is completely
|
||||
# healthy, which reads as a permanently-failing readiness
|
||||
# probe with no corresponding error in the app's own logs.
|
||||
# RabbitMQ's own Kubernetes deployment docs recommend a
|
||||
# TCP-based readiness check for exactly this reason — it's
|
||||
# handled by the kubelet directly with no process spawned
|
||||
# inside the container, so it isn't subject to CPU contention
|
||||
# with the broker or the probe's own VM startup cost.
|
||||
tcpSocket: {port: amqp}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
exec: {command: ["rabbitmq-diagnostics", "-q", "ping"]}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
volumeMounts:
|
||||
- {name: data, mountPath: /var/lib/rabbitmq}
|
||||
- {name: conf, mountPath: /etc/rabbitmq/conf.d/20-enkly.conf, subPath: 20-enkly.conf}
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: rabbitmq-data}
|
||||
- name: conf
|
||||
configMap: {name: rabbitmq-conf}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: rabbitmq
|
||||
namespace: enkly
|
||||
labels: {app: rabbitmq}
|
||||
spec:
|
||||
selector: {app: rabbitmq}
|
||||
ports:
|
||||
- {name: amqp, port: 5672, targetPort: amqp}
|
||||
- {name: mgmt, port: 15672, targetPort: mgmt}
|
||||
326
k8s/11-vllm.yaml
Normal file
326
k8s/11-vllm.yaml
Normal file
@@ -0,0 +1,326 @@
|
||||
# vLLM, per your request — but still on kmaster/kworker1/kworker2,
|
||||
# which have no physical GPU. The original vllm-chat/vllm-embed
|
||||
# (restored structurally below: two Deployments, one model each, same
|
||||
# as pkg/vllmkit's doc comment describes) ran the CUDA vllm/vllm-openai
|
||||
# image and requested nvidia.com/gpu: "1" — that's what made both pods
|
||||
# sit Pending forever with no node able to advertise that resource.
|
||||
#
|
||||
# This version runs vLLM's CPU backend instead, built from source
|
||||
# with AVX-512 disabled, rather than vLLM's official pre-built
|
||||
# public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo image. Two real,
|
||||
# observed problems drove both the image choice and the sizing below
|
||||
# — not hypothetical ones:
|
||||
#
|
||||
# 1. vllm-embed's init container (`check-cpu-avx512` further down)
|
||||
# reported no `avx512f` CPU flag on the node it landed on. That's
|
||||
# consistent with VirtualBox/Vagrant nested virtualization, which
|
||||
# historically doesn't expose AVX-512 to guest VMs at all — and
|
||||
# this cluster's own vagrant-provisioning/ setup is exactly that.
|
||||
# The pre-built image hard-requires avx512f and crashes with
|
||||
# "Illegal instruction" without it; there is no runtime flag to
|
||||
# turn that off, only a build-time one. So `image:` below now
|
||||
# points at a custom build instead — **you must build and push it
|
||||
# before applying this file**:
|
||||
# git clone https://github.com/vllm-project/vllm.git && cd vllm
|
||||
# docker build -f docker/Dockerfile.cpu \
|
||||
# --build-arg VLLM_CPU_DISABLE_AVX512=true \
|
||||
# --tag registry.enkly.org/enkly/vllm-cpu:latest \
|
||||
# --target vllm-openai .
|
||||
# docker push registry.enkly.org/enkly/vllm-cpu:latest
|
||||
# This build falls back to plain AVX2, which is universal on any
|
||||
# x86_64 node (physical or virtualized) built since ~2013 — there's
|
||||
# no equivalent flag check needed for it the way avx512f needed
|
||||
# one, so the init container that used to gate on avx512f is gone;
|
||||
# only a lightweight informational one remains (see below).
|
||||
#
|
||||
# 2. vllm-chat's 8Gi memory *request* alone exceeded a single node's
|
||||
# total capacity: 10-rabbitmq.yaml's memory-watermark fix already
|
||||
# established each node has ~7949Mi (~7.76Gi) total RAM, so an 8Gi
|
||||
# request could never be satisfied regardless of what else was
|
||||
# scheduled — that's what "Pending" (not a crash) meant. Fixed two
|
||||
# ways together: the chat model dropped from Qwen2.5-3B-Instruct to
|
||||
# Qwen2.5-1.5B-Instruct (bf16 weights ~3GB vs. ~6GB), and
|
||||
# resources.requests/limits and VLLM_CPU_KVCACHE_SPACE below were
|
||||
# cut to fit comfortably inside a ~7.76Gi node alongside everything
|
||||
# else already running there (4Gi/6Gi request/limit — realistic for
|
||||
# ~3GB weights + 1GiB KV cache + CPU-backend runtime overhead, not
|
||||
# the bare-minimum 3Gi this previously requested, which left too
|
||||
# little room to the limit during startup warmup). If
|
||||
# `kubectl describe node` shows more real headroom than that once
|
||||
# this is stable, both the model and these numbers can be raised
|
||||
# again — see the sizing comments inline.
|
||||
#
|
||||
# 3. The two Deployments previously shared one ReadWriteMany PVC for
|
||||
# the Hugging Face cache. managed-nfs-storage (nfs-subdir-external-
|
||||
# provisioner) does advertise RWX, so that likely would have bound
|
||||
# — but an unverified "likely" is exactly the kind of thing that
|
||||
# produces a silent Pending pod (a PVC stuck Pending blocks its
|
||||
# Pod the same way insufficient node resources does, just with a
|
||||
# different `kubectl describe pod` event, easy to mis-read as a
|
||||
# repeat of problem #2). Split into vllm-chat-cache and
|
||||
# vllm-embed-cache below, both plain ReadWriteOnce — the same
|
||||
# access mode every other PVC in this project already binds with
|
||||
# (10-rabbitmq.yaml, 12-databases.yaml, 23-workspace.yaml) — so
|
||||
# neither Deployment's scheduling can ever depend on the other's
|
||||
# PVC or on RWX support this cluster's storage class may or may
|
||||
# not actually have been exercised with yet.
|
||||
#
|
||||
# Everything else about the original design is unchanged: two
|
||||
# Deployments (one vLLM engine process serves exactly one model), the
|
||||
# OpenAI-compatible /v1/chat/completions + /v1/embeddings API,
|
||||
# --api-key, tool calling — so pkg/vllmkit needs no code changes,
|
||||
# only this file and 01-config.yaml's model/URL values.
|
||||
#
|
||||
# If real GPU nodes get added to this cluster later: point `image:` on
|
||||
# both Deployments back at `vllm/vllm-openai`, add back
|
||||
# `nvidia.com/gpu` requests + a GPU-pool nodeSelector/toleration + an
|
||||
# NVIDIA device-plugin DaemonSet, and size the model/resources up.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: vllm-chat-cache, namespace: enkly}
|
||||
spec:
|
||||
# ReadWriteOnce, deliberately not a single shared ReadWriteMany PVC
|
||||
# across both Deployments. nfs-subdir-external-provisioner (behind
|
||||
# managed-nfs-storage) does advertise RWX, so a shared PVC would
|
||||
# probably bind — but "probably" is exactly the kind of unverified
|
||||
# assumption that produces a silent, hard-to-diagnose Pending pod
|
||||
# (PVC stuck Pending blocks the Pod the same way insufficient node
|
||||
# resources does, just with a different `kubectl describe pod`
|
||||
# event). Two independent RWO claims remove that failure mode
|
||||
# entirely: each is the same well-proven access mode every other
|
||||
# PVC in this project already binds with (10-rabbitmq.yaml,
|
||||
# 12-databases.yaml, 23-workspace.yaml), neither Deployment's
|
||||
# scheduling depends on the other's PVC, and losing the (small,
|
||||
# one-time-download) benefit of a shared cache between a 3GB chat
|
||||
# model and a 550MB embed model costs nothing worth trading that
|
||||
# reliability for.
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
# Qwen2.5-1.5B-Instruct at bf16 is ~3GB on disk; sized with headroom
|
||||
# to try Qwen2.5-3B-Instruct (~6GB bf16) later without resizing
|
||||
# first, once `kubectl describe node` confirms real free RAM.
|
||||
resources: {requests: {storage: 10Gi}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: vllm-embed-cache, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
# nomic-embed-text-v1.5 is ~550MB on disk.
|
||||
resources: {requests: {storage: 5Gi}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: vllm-chat, namespace: enkly, labels: {app: vllm-chat}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: vllm-chat}}
|
||||
template:
|
||||
metadata: {labels: {app: vllm-chat}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
# Deliberately NO nodeSelector/toleration/nvidia.com/gpu request
|
||||
# here (unlike the original GPU version of this file) — this
|
||||
# cluster has no node that could ever satisfy either.
|
||||
initContainers:
|
||||
- name: check-cpu-avx2
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -e
|
||||
if ! grep -q avx2 /proc/cpuinfo; then
|
||||
echo "FATAL: this node's CPU has no avx2 flag." >&2
|
||||
echo "registry.enkly.org/enkly/vllm-cpu (built with VLLM_CPU_DISABLE_AVX512=true) still needs AVX2 at minimum." >&2
|
||||
echo "This is a much lower bar than the avx512f this cluster was already confirmed to lack, but if it's missing too, CPU inference isn't viable on this node." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "avx2 present on this node — continuing (no avx512 requirement with this build)."
|
||||
containers:
|
||||
- name: vllm-chat
|
||||
# Custom build, not public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo —
|
||||
# see this file's header comment for why (confirmed missing
|
||||
# avx512f) and the exact build/push command. This image must
|
||||
# exist in your registry before applying.
|
||||
image: registry.enkly.org/enkly/vllm-cpu:latest
|
||||
args:
|
||||
- --model=Qwen/Qwen2.5-1.5B-Instruct
|
||||
- --served-model-name=$(CHAT_MODEL_NAME)
|
||||
# Torch CPU float16 is unstable per vLLM's own CPU FAQ;
|
||||
# bfloat16 is the documented safe default.
|
||||
- --dtype=bfloat16
|
||||
# Cut from 8192: the KV cache budget below is deliberately
|
||||
# small to fit this node's real ~7.76Gi, and a shorter
|
||||
# context is the lever that buys that back without
|
||||
# dropping the KV cache to the point of thrashing.
|
||||
- --max-model-len=4096
|
||||
# Qwen2.5-Instruct's tool-call token format matches vLLM's
|
||||
# hermes parser (the qwen3_coder parser the GPU deployment
|
||||
# used is Qwen3.5-specific).
|
||||
- --tool-call-parser=hermes
|
||||
- --enable-auto-tool-choice
|
||||
- --api-key=$(VLLM_API_KEY)
|
||||
env:
|
||||
- {name: CHAT_MODEL_NAME, valueFrom: {configMapKeyRef: {name: enkly-config, key: ENKLY_CHAT_MODEL}}}
|
||||
- {name: VLLM_API_KEY, valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}}
|
||||
- {name: HUGGING_FACE_HUB_TOKEN, valueFrom: {secretKeyRef: {name: enkly-secrets, key: HUGGING_FACE_HUB_TOKEN}}}
|
||||
# Cut from 4 — this plus ~3GB of resident bf16 weights is
|
||||
# what resources.requests.memory below is actually sized
|
||||
# against. Raise only after `kubectl describe node` shows
|
||||
# real free RAM on top of what's already running, and
|
||||
# raise the memory request/limit by the same amount.
|
||||
- {name: VLLM_CPU_KVCACHE_SPACE, value: "1"}
|
||||
# auto binds each OpenMP thread to a physical core and
|
||||
# reserves VLLM_CPU_NUM_OF_RESERVED_CPU cores for the
|
||||
# front-end process — vLLM's own recommended default for
|
||||
# single-rank (no tensor/pipeline parallel) deployments
|
||||
# like this one.
|
||||
- {name: VLLM_CPU_OMP_THREADS_BIND, value: "auto"}
|
||||
- {name: VLLM_CPU_NUM_OF_RESERVED_CPU, value: "1"}
|
||||
ports: [{name: http, containerPort: 8000}]
|
||||
resources:
|
||||
# ~3GB resident for bf16 weights + 1GiB KV cache
|
||||
# (VLLM_CPU_KVCACHE_SPACE above) + CPU-backend
|
||||
# interpreter/runtime overhead (torch, OpenMP worker
|
||||
# threads, inference graph buffers — typically 0.5-1GiB
|
||||
# for a model this size) adds up to a realistic ~4.5Gi
|
||||
# steady-state, not ~4Gi — the previous 3Gi *request*
|
||||
# undersold that, which doesn't block scheduling (the
|
||||
# scheduler only bin-packs on the request) but left too
|
||||
# little daylight to the 5Gi *limit* during the kernel
|
||||
# warmup/compile the startupProbe below already budgets
|
||||
# extra time for, risking an OOMKill right at startup.
|
||||
# 4Gi/6Gi still sits well under this cluster's ~7.76Gi
|
||||
# per-node total (10-rabbitmq.yaml) with room for whatever
|
||||
# else is already scheduled there — the original 8Gi
|
||||
# request couldn't fit on *any* node here, full stop,
|
||||
# which is why it sat Pending; this isn't a return to
|
||||
# that. Raise only after `kubectl describe node` confirms
|
||||
# real headroom.
|
||||
requests: {cpu: "2", memory: 4Gi}
|
||||
limits: {cpu: "3", memory: 6Gi}
|
||||
startupProbe:
|
||||
# No model-download wait this time on a warm PVC (unlike
|
||||
# the brief Ollama detour's explicit pull step) — HF Hub
|
||||
# download only happens once, cached on vllm-chat-cache
|
||||
# after that. What's slow instead is the CPU backend's own
|
||||
# kernel warmup/compile on every start, hence the still
|
||||
# generous budget.
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 15
|
||||
failureThreshold: 60 # 15 minutes
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- {name: hf-cache, mountPath: /root/.cache/huggingface}
|
||||
# vLLM's CPU docs still recommend a real /dev/shm for its
|
||||
# multiprocessing workers, same reasoning as the GPU
|
||||
# version had for NCCL — just a smaller budget since
|
||||
# there's no multi-GPU scratch space involved here.
|
||||
- {name: dshm, mountPath: /dev/shm}
|
||||
volumes:
|
||||
- name: hf-cache
|
||||
persistentVolumeClaim: {claimName: vllm-chat-cache}
|
||||
- name: dshm
|
||||
emptyDir: {medium: Memory, sizeLimit: 1Gi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: vllm-chat, namespace: enkly, labels: {app: vllm-chat}}
|
||||
spec:
|
||||
# ClusterIP only — no public Ingress this time. agent is the only
|
||||
# caller and it's in-cluster; the original GPU manifest's
|
||||
# vllm-chat.enkly.org Ingress existed for direct external testing,
|
||||
# which isn't needed for this deployment to work. Add one back (same
|
||||
# shape as 30-ingress.yaml) if external access is genuinely wanted.
|
||||
selector: {app: vllm-chat}
|
||||
ports: [{name: http, port: 8000, targetPort: http}]
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: vllm-embed, namespace: enkly, labels: {app: vllm-embed}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: vllm-embed}}
|
||||
template:
|
||||
metadata: {labels: {app: vllm-embed}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
initContainers:
|
||||
- name: check-cpu-avx2
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -e
|
||||
if ! grep -q avx2 /proc/cpuinfo; then
|
||||
echo "FATAL: this node's CPU has no avx2 flag." >&2
|
||||
echo "registry.enkly.org/enkly/vllm-cpu (built with VLLM_CPU_DISABLE_AVX512=true) still needs AVX2 at minimum." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "avx2 present on this node — continuing (no avx512 requirement with this build)."
|
||||
containers:
|
||||
- name: vllm-embed
|
||||
image: registry.enkly.org/enkly/vllm-cpu:latest
|
||||
args:
|
||||
- --model=nomic-ai/nomic-embed-text-v1.5
|
||||
- --served-model-name=$(EMBED_MODEL_NAME)
|
||||
- --task=embed
|
||||
# Nomic's embedding model config uses custom (non-standard
|
||||
# transformers) modeling code — needs explicit opt-in.
|
||||
- --trust-remote-code
|
||||
- --dtype=bfloat16
|
||||
- --api-key=$(VLLM_API_KEY)
|
||||
env:
|
||||
- {name: EMBED_MODEL_NAME, valueFrom: {configMapKeyRef: {name: enkly-config, key: ENKLY_EMBED_MODEL}}}
|
||||
- {name: VLLM_API_KEY, valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}}
|
||||
- {name: HUGGING_FACE_HUB_TOKEN, valueFrom: {secretKeyRef: {name: enkly-secrets, key: HUGGING_FACE_HUB_TOKEN}}}
|
||||
# A 137M-parameter embedding model needs far less KV cache
|
||||
# headroom than the chat model.
|
||||
- {name: VLLM_CPU_KVCACHE_SPACE, value: "1"}
|
||||
- {name: VLLM_CPU_OMP_THREADS_BIND, value: "auto"}
|
||||
- {name: VLLM_CPU_NUM_OF_RESERVED_CPU, value: "1"}
|
||||
ports: [{name: http, containerPort: 8000}]
|
||||
resources:
|
||||
# Trimmed from 2Gi/4Gi for the same per-node headroom
|
||||
# reason as vllm-chat above — nomic-embed-text-v1.5 is
|
||||
# ~550MB on disk, so even this is generous.
|
||||
requests: {cpu: "1", memory: 1Gi}
|
||||
limits: {cpu: "2", memory: 2Gi}
|
||||
startupProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 36 # 6 minutes — small model, no large download after first boot
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- {name: hf-cache, mountPath: /root/.cache/huggingface}
|
||||
- {name: dshm, mountPath: /dev/shm}
|
||||
volumes:
|
||||
- name: hf-cache
|
||||
persistentVolumeClaim: {claimName: vllm-embed-cache}
|
||||
- name: dshm
|
||||
emptyDir: {medium: Memory, sizeLimit: 256Mi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: vllm-embed, namespace: enkly, labels: {app: vllm-embed}}
|
||||
spec:
|
||||
selector: {app: vllm-embed}
|
||||
ports: [{name: http, port: 8000, targetPort: http}]
|
||||
222
k8s/12-databases.yaml
Normal file
222
k8s/12-databases.yaml
Normal file
@@ -0,0 +1,222 @@
|
||||
# ---------- iam-db ----------
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: iam-db-data, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
resources: {requests: {storage: 5Gi}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: iam-db, namespace: enkly, labels: {app: iam-db}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: iam-db}}
|
||||
template:
|
||||
metadata: {labels: {app: iam-db}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports: [{name: pg, containerPort: 5432}]
|
||||
env:
|
||||
- {name: POSTGRES_USER, value: "enkly"}
|
||||
- {name: POSTGRES_DB, value: "enkly_iam"}
|
||||
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
readinessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
failureThreshold: 6
|
||||
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: iam-db-data}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: iam-db, namespace: enkly, labels: {app: iam-db}}
|
||||
spec:
|
||||
selector: {app: iam-db}
|
||||
ports: [{name: pg, port: 5432, targetPort: pg}]
|
||||
|
||||
# ---------- billing-db ----------
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: billing-db-data, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
resources: {requests: {storage: 5Gi}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: billing-db, namespace: enkly, labels: {app: billing-db}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: billing-db}}
|
||||
template:
|
||||
metadata: {labels: {app: billing-db}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports: [{name: pg, containerPort: 5432}]
|
||||
env:
|
||||
- {name: POSTGRES_USER, value: "enkly"}
|
||||
- {name: POSTGRES_DB, value: "enkly_billing"}
|
||||
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
readinessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
failureThreshold: 6
|
||||
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: billing-db-data}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: billing-db, namespace: enkly, labels: {app: billing-db}}
|
||||
spec:
|
||||
selector: {app: billing-db}
|
||||
ports: [{name: pg, port: 5432, targetPort: pg}]
|
||||
|
||||
# ---------- conversation-db ----------
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: conversation-db-data, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
resources: {requests: {storage: 5Gi}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: conversation-db, namespace: enkly, labels: {app: conversation-db}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: conversation-db}}
|
||||
template:
|
||||
metadata: {labels: {app: conversation-db}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports: [{name: pg, containerPort: 5432}]
|
||||
env:
|
||||
- {name: POSTGRES_USER, value: "enkly"}
|
||||
- {name: POSTGRES_DB, value: "enkly_conversation"}
|
||||
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
readinessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
failureThreshold: 6
|
||||
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: conversation-db-data}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: conversation-db, namespace: enkly, labels: {app: conversation-db}}
|
||||
spec:
|
||||
selector: {app: conversation-db}
|
||||
ports: [{name: pg, port: 5432, targetPort: pg}]
|
||||
|
||||
# ---------- knowledge-db (pgvector) ----------
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: knowledge-db-data, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
resources: {requests: {storage: 10Gi}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: knowledge-db, namespace: enkly, labels: {app: knowledge-db}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: knowledge-db}}
|
||||
template:
|
||||
metadata: {labels: {app: knowledge-db}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: postgres
|
||||
image: pgvector/pgvector:pg16
|
||||
ports: [{name: pg, containerPort: 5432}]
|
||||
env:
|
||||
- {name: POSTGRES_USER, value: "enkly"}
|
||||
- {name: POSTGRES_DB, value: "enkly_knowledge"}
|
||||
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
resources:
|
||||
requests: {cpu: 200m, memory: 512Mi}
|
||||
limits: {cpu: 1, memory: 1Gi}
|
||||
readinessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
exec: {command: ["pg_isready", "-U", "enkly"]}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
failureThreshold: 6
|
||||
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: knowledge-db-data}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: knowledge-db, namespace: enkly, labels: {app: knowledge-db}}
|
||||
spec:
|
||||
selector: {app: knowledge-db}
|
||||
ports: [{name: pg, port: 5432, targetPort: pg}]
|
||||
45
k8s/20-iam.yaml
Normal file
45
k8s/20-iam.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: iam, namespace: enkly, labels: {app: iam}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: iam}}
|
||||
template:
|
||||
metadata: {labels: {app: iam}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
# Nothing in Kubernetes enforces start order between Deployments, and
|
||||
# rabbitmq's own readiness probe can take a while to go green (see
|
||||
# 10-rabbitmq.yaml). Without this, iam races the broker on a fresh
|
||||
# rollout and can hit the same "connection refused" notification saw.
|
||||
initContainers:
|
||||
- name: wait-for-rabbitmq
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
|
||||
containers:
|
||||
- name: iam
|
||||
image: registry.enkly.org/enkly/iam:latest
|
||||
ports: [{name: grpc, containerPort: 9001}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
- {name: ENKLY_IAM_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@iam-db:5432/enkly_iam?sslmode=disable"}
|
||||
- name: ENKLY_RABBITMQ_URL
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
|
||||
- name: ENKLY_SMTP_USERNAME
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_USERNAME}}
|
||||
- name: ENKLY_SMTP_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_PASSWORD}}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 64Mi}
|
||||
limits: {cpu: 500m, memory: 256Mi}
|
||||
readinessProbe: {grpc: {port: 9001}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {grpc: {port: 9001}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: iam, namespace: enkly, labels: {app: iam}}
|
||||
spec:
|
||||
selector: {app: iam}
|
||||
ports: [{name: grpc, port: 9001, targetPort: grpc}]
|
||||
49
k8s/21-billing.yaml
Normal file
49
k8s/21-billing.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: billing, namespace: enkly, labels: {app: billing}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: billing}}
|
||||
template:
|
||||
metadata: {labels: {app: billing}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
initContainers:
|
||||
- name: wait-for-rabbitmq
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
|
||||
containers:
|
||||
- name: billing
|
||||
image: registry.enkly.org/enkly/billing:latest
|
||||
ports: [{name: grpc, containerPort: 9002}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
- {name: ENKLY_BILLING_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@billing-db:5432/enkly_billing?sslmode=disable"}
|
||||
- name: ENKLY_RABBITMQ_URL
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
|
||||
- name: STRIPE_SECRET_KEY
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_SECRET_KEY}}
|
||||
- name: STRIPE_PUBLISH_KEY
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PUBLISH_KEY}}
|
||||
- name: STRIPE_WEBHOOK_SECRET
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_WEBHOOK_SECRET}}
|
||||
- name: STRIPE_PRICE_BASIC
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PRICE_BASIC}}
|
||||
- name: STRIPE_PRICE_PRO
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PRICE_PRO}}
|
||||
- name: STRIPE_PRICE_PREMIUM
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PRICE_PREMIUM}}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 64Mi}
|
||||
limits: {cpu: 500m, memory: 256Mi}
|
||||
readinessProbe: {grpc: {port: 9002}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {grpc: {port: 9002}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: billing, namespace: enkly, labels: {app: billing}}
|
||||
spec:
|
||||
selector: {app: billing}
|
||||
ports: [{name: grpc, port: 9002, targetPort: grpc}]
|
||||
37
k8s/22-conversation.yaml
Normal file
37
k8s/22-conversation.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: conversation, namespace: enkly, labels: {app: conversation}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: conversation}}
|
||||
template:
|
||||
metadata: {labels: {app: conversation}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
initContainers:
|
||||
- name: wait-for-rabbitmq
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
|
||||
containers:
|
||||
- name: conversation
|
||||
image: registry.enkly.org/enkly/conversation:latest
|
||||
ports: [{name: grpc, containerPort: 9003}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
- {name: ENKLY_CONVERSATION_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@conversation-db:5432/enkly_conversation?sslmode=disable"}
|
||||
- name: ENKLY_RABBITMQ_URL
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 64Mi}
|
||||
limits: {cpu: 500m, memory: 256Mi}
|
||||
readinessProbe: {grpc: {port: 9003}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {grpc: {port: 9003}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: conversation, namespace: enkly, labels: {app: conversation}}
|
||||
spec:
|
||||
selector: {app: conversation}
|
||||
ports: [{name: grpc, port: 9003, targetPort: grpc}]
|
||||
45
k8s/23-workspace.yaml
Normal file
45
k8s/23-workspace.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Matches docker-compose's workspace-data volume: a PVC mounted at
|
||||
# /data/workspace. Single replica + Recreate, same reasoning as the
|
||||
# databases — this is where run_bash_command actually executes, so a
|
||||
# second concurrent pod writing the same tree would be worse than a
|
||||
# brief restart gap.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: workspace-data, namespace: enkly}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: managed-nfs-storage
|
||||
resources: {requests: {storage: 10Gi}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: workspace, namespace: enkly, labels: {app: workspace}}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: {type: Recreate}
|
||||
selector: {matchLabels: {app: workspace}}
|
||||
template:
|
||||
metadata: {labels: {app: workspace}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: workspace
|
||||
image: registry.enkly.org/enkly/workspace:latest
|
||||
ports: [{name: grpc, containerPort: 9004}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {cpu: 1, memory: 512Mi}
|
||||
readinessProbe: {grpc: {port: 9004}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {grpc: {port: 9004}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
volumeMounts: [{name: data, mountPath: /data/workspace}]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim: {claimName: workspace-data}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: workspace, namespace: enkly, labels: {app: workspace}}
|
||||
spec:
|
||||
selector: {app: workspace}
|
||||
ports: [{name: grpc, port: 9004, targetPort: grpc}]
|
||||
36
k8s/24-knowledge.yaml
Normal file
36
k8s/24-knowledge.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: knowledge, namespace: enkly, labels: {app: knowledge}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: knowledge}}
|
||||
template:
|
||||
metadata: {labels: {app: knowledge}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: knowledge
|
||||
image: registry.enkly.org/enkly/knowledge:latest
|
||||
ports: [{name: grpc, containerPort: 9005}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
|
||||
- {name: ENKLY_KNOWLEDGE_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@knowledge-db:5432/enkly_knowledge?sslmode=disable"}
|
||||
# Bearer token knowledge authenticates to vllm-embed with
|
||||
# (see services/knowledge/cmd/server/main.go) — without
|
||||
# this, embedding calls 401 against vllm-embed's --api-key.
|
||||
- name: ENKLY_VLLM_API_KEY
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {cpu: 1, memory: 512Mi}
|
||||
readinessProbe: {grpc: {port: 9005}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {grpc: {port: 9005}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: knowledge, namespace: enkly, labels: {app: knowledge}}
|
||||
spec:
|
||||
selector: {app: knowledge}
|
||||
ports: [{name: grpc, port: 9005, targetPort: grpc}]
|
||||
42
k8s/25-agent.yaml
Normal file
42
k8s/25-agent.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# ENKLY_WORKSPACE_GRPC_ADDR here means something different from the
|
||||
# same-named var on the workspace Deployment: there it's "what address
|
||||
# do I listen on", here it's "what address do I dial" (agent is a
|
||||
# gRPC client of workspace/knowledge/billing/conversation). That's how
|
||||
# docker-compose.yml defines it too — kept identical on purpose.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: agent, namespace: enkly, labels: {app: agent}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: agent}}
|
||||
template:
|
||||
metadata: {labels: {app: agent}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: agent
|
||||
image: registry.enkly.org/enkly/agent:latest
|
||||
ports: [{name: grpc, containerPort: 9006}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- {name: ENKLY_WORKSPACE_GRPC_ADDR, value: "workspace:9004"}
|
||||
- {name: ENKLY_KNOWLEDGE_GRPC_ADDR, value: "knowledge:9005"}
|
||||
- {name: ENKLY_BILLING_GRPC_ADDR, value: "billing:9002"}
|
||||
- {name: ENKLY_CONVERSATION_GRPC_ADDR, value: "conversation:9003"}
|
||||
# Bearer token agent authenticates to vllm-chat/vllm-embed
|
||||
# with (see services/agent/cmd/server/main.go) — without
|
||||
# this, chat/tool-calling calls 401 against vLLM's --api-key.
|
||||
- name: ENKLY_VLLM_API_KEY
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {cpu: 1, memory: 512Mi}
|
||||
readinessProbe: {grpc: {port: 9006}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {grpc: {port: 9006}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: agent, namespace: enkly, labels: {app: agent}}
|
||||
spec:
|
||||
selector: {app: agent}
|
||||
ports: [{name: grpc, port: 9006, targetPort: grpc}]
|
||||
38
k8s/26-notification.yaml
Normal file
38
k8s/26-notification.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: notification, namespace: enkly, labels: {app: notification}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: notification}}
|
||||
template:
|
||||
metadata: {labels: {app: notification}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
initContainers:
|
||||
- name: wait-for-rabbitmq
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
|
||||
containers:
|
||||
- name: notification
|
||||
image: registry.enkly.org/enkly/notification:latest
|
||||
ports: [{name: http, containerPort: 9007}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- name: ENKLY_RABBITMQ_URL
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
|
||||
- name: ENKLY_SMTP_USERNAME
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_USERNAME}}
|
||||
- name: ENKLY_SMTP_PASSWORD
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_PASSWORD}}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 64Mi}
|
||||
limits: {cpu: 300m, memory: 192Mi}
|
||||
readinessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: notification, namespace: enkly, labels: {app: notification}}
|
||||
spec:
|
||||
selector: {app: notification}
|
||||
ports: [{name: http, port: 9007, targetPort: http}]
|
||||
36
k8s/27-gateway.yaml
Normal file
36
k8s/27-gateway.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: gateway, namespace: enkly, labels: {app: gateway}}
|
||||
spec:
|
||||
replicas: 2
|
||||
selector: {matchLabels: {app: gateway}}
|
||||
template:
|
||||
metadata: {labels: {app: gateway}}
|
||||
spec:
|
||||
imagePullSecrets: [{name: enkly-registry}]
|
||||
containers:
|
||||
- name: gateway
|
||||
image: registry.enkly.org/enkly/gateway:latest
|
||||
ports: [{name: http, containerPort: 8080}]
|
||||
envFrom: [{configMapRef: {name: enkly-config}}]
|
||||
env:
|
||||
- {name: ENKLY_IAM_GRPC_ADDR, value: "iam:9001"}
|
||||
- {name: ENKLY_BILLING_GRPC_ADDR, value: "billing:9002"}
|
||||
- {name: ENKLY_CONVERSATION_GRPC_ADDR, value: "conversation:9003"}
|
||||
- {name: ENKLY_WORKSPACE_GRPC_ADDR, value: "workspace:9004"}
|
||||
- {name: ENKLY_KNOWLEDGE_GRPC_ADDR, value: "knowledge:9005"}
|
||||
- {name: ENKLY_AGENT_GRPC_ADDR, value: "agent:9006"}
|
||||
- name: STRIPE_PUBLISH_KEY
|
||||
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PUBLISH_KEY}}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 96Mi}
|
||||
limits: {cpu: 500m, memory: 256Mi}
|
||||
readinessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
|
||||
livenessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: gateway, namespace: enkly, labels: {app: gateway}}
|
||||
spec:
|
||||
selector: {app: gateway}
|
||||
ports: [{name: http, port: 8080, targetPort: http}]
|
||||
40
k8s/30-ingress.yaml
Normal file
40
k8s/30-ingress.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Requires an ingress controller (e.g. ingress-nginx) already running
|
||||
# in the cluster — `kubectl get nodes` output didn't show one and
|
||||
# none was mentioned, so install one first:
|
||||
# kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/baremetal/deploy.yaml
|
||||
# If you'd rather not run an ingress controller, delete this file and
|
||||
# uncomment the NodePort Service below instead.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: enkly
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: production
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "25m" # chat file/photo attachments
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "300" # long-running agent/chat responses
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- ai.enkly.org # match ENKLY_BASE_URL in 01-config.yaml, or set to your real domain
|
||||
secretName: ai-enkly-org
|
||||
rules:
|
||||
- host: ai.enkly.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service: {name: gateway, port: {number: 8080}}
|
||||
---
|
||||
# Bare-metal fallback with no ingress controller installed: exposes
|
||||
# the gateway directly on every node's IP at a fixed high port. Apply
|
||||
# this INSTEAD OF (not in addition to) the Ingress above if preferred.
|
||||
# apiVersion: v1
|
||||
# kind: Service
|
||||
# metadata: {name: gateway-nodeport, namespace: enkly}
|
||||
# spec:
|
||||
# type: NodePort
|
||||
# selector: {app: gateway}
|
||||
# ports: [{name: http, port: 8080, targetPort: http, nodePort: 30080}]
|
||||
222
k8s/README.md
Normal file
222
k8s/README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# enkly-ai — Kubernetes deployment
|
||||
|
||||
Plain manifests (no Helm) for the 8 Go microservices + RabbitMQ + 4
|
||||
Postgres databases + two CPU-only vLLM inference servers, for the
|
||||
`kmaster` / `kworker1` / `kworker2` cluster described in
|
||||
`docker-compose.yml`. One file per concern, applied individually or
|
||||
via `kustomization.yaml`.
|
||||
|
||||
## vLLM on a cluster with no GPU
|
||||
|
||||
An earlier version of this manifest set ran `vllm-chat`/`vllm-embed`
|
||||
on the CUDA `vllm/vllm-openai` image, each requesting
|
||||
`nvidia.com/gpu: "1"`. **This cluster has no physical GPU on any of
|
||||
its three nodes.** `nvidia.com/gpu: "1"` is not a resource any node
|
||||
here can ever advertise — with no node reporting it, both pods sat
|
||||
`Pending` permanently (`Insufficient nvidia.com/gpu`), not just slow
|
||||
to schedule.
|
||||
|
||||
`11-vllm.yaml` runs vLLM's CPU backend instead — same two Deployments,
|
||||
same OpenAI-compatible API, same `--api-key` enforcement, no
|
||||
application code changes. Two real, *observed* problems (not just
|
||||
theoretical GPU-vs-CPU ones) shaped the rest of the fix:
|
||||
|
||||
1. **`avx512f` genuinely missing on this cluster's nodes.** vLLM's
|
||||
official pre-built CPU image
|
||||
(`public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo`) hard-requires
|
||||
that flag and crashes with `Illegal instruction` without it — and
|
||||
an init container added to both Deployments confirmed it's absent
|
||||
here, consistent with VirtualBox/Vagrant nested virtualization
|
||||
(this project's own `vagrant-provisioning/` setup) historically not
|
||||
exposing AVX-512 to guest VMs at all. There's no runtime flag for
|
||||
this, only a build-time one, so `image:` on both Deployments now
|
||||
points at a **custom build you must create first**:
|
||||
`registry.enkly.org/enkly/vllm-cpu:latest`, built with
|
||||
`--build-arg VLLM_CPU_DISABLE_AVX512=true` — see `11-vllm.yaml`'s
|
||||
header for the exact command. That build falls back to plain AVX2,
|
||||
universal on any x86_64 node since ~2013, so the init container now
|
||||
only checks for that (a much lower, already-satisfied bar) instead
|
||||
of gating on `avx512f`.
|
||||
2. **The chat model's memory request alone exceeded a node's total
|
||||
RAM.** `10-rabbitmq.yaml`'s memory-watermark fix already
|
||||
established each node has ~7949Mi (~7.76Gi) total — `vllm-chat`'s
|
||||
original `8Gi` request could never be satisfied by *any* node here,
|
||||
which is exactly why it sat `Pending` instead of crashing. Fixed by
|
||||
dropping the chat model to `Qwen/Qwen2.5-1.5B-Instruct` (~3GB bf16
|
||||
vs. the 3B variant's ~6GB) and cutting
|
||||
`resources`/`VLLM_CPU_KVCACHE_SPACE`/`--max-model-len` on both
|
||||
Deployments to fit comfortably inside ~7.76Gi alongside everything
|
||||
else already running on that node (`vllm-chat` requests `4Gi`/limits
|
||||
`6Gi` — sized to the realistic weights + KV-cache + runtime-overhead
|
||||
total, not the bare minimum, so there's headroom left to the limit
|
||||
during startup warmup).
|
||||
3. **A shared `ReadWriteMany` PVC was an unverified assumption, not a
|
||||
confirmed fix.** `vllm-chat` and `vllm-embed` originally bind-mounted
|
||||
one PVC for their Hugging Face cache. `managed-nfs-storage`
|
||||
(`nfs-subdir-external-provisioner`) does advertise RWX, so that
|
||||
probably binds fine — but "probably" produces exactly the kind of
|
||||
pod that's silently stuck `Pending` on an unbound PVC, easy to
|
||||
mis-diagnose as a repeat of the GPU/memory issues above. Split into
|
||||
`vllm-chat-cache` and `vllm-embed-cache`, both plain `ReadWriteOnce`
|
||||
— the same access mode every other PVC in this project already binds
|
||||
with — so neither Deployment's scheduling can depend on the other's
|
||||
claim or on RWX ever having been exercised here.
|
||||
|
||||
If real GPU nodes get added to this cluster later: point `image:` on
|
||||
both Deployments back at `vllm/vllm-openai`, add back
|
||||
`nvidia.com/gpu` requests + a GPU-pool `nodeSelector`/`toleration` +
|
||||
an NVIDIA device-plugin DaemonSet, and size the model/resources back
|
||||
up.
|
||||
|
||||
## Other issues found in the manifest set and how they were fixed here
|
||||
|
||||
1. **Postgres on NFS-backed PVCs fails `initdb`.**
|
||||
`nfs-subdir-external-provisioner` volumes aren't always empty at
|
||||
the Postgres data-dir mount point, and `initdb` refuses to
|
||||
initialize a non-empty directory. All four databases set
|
||||
`PGDATA=/var/lib/postgresql/data/pgdata` (a subdirectory of the
|
||||
mount) to sidestep this — see `12-databases.yaml`.
|
||||
2. **`agent`/`knowledge` never received the vLLM API key.** Both
|
||||
services' own code (`services/agent/cmd/server/main.go`,
|
||||
`services/knowledge/cmd/server/main.go`) reads
|
||||
`ENKLY_VLLM_API_KEY` from the environment and defaults to `""` if
|
||||
unset — but neither `24-knowledge.yaml` nor `25-agent.yaml` sourced
|
||||
it from `enkly-secrets`. Since `11-vllm.yaml` starts both vLLM
|
||||
servers with `--api-key=$(VLLM_API_KEY)`, every chat/embedding call
|
||||
from these two services would get rejected with `401`. Both files
|
||||
now set `ENKLY_VLLM_API_KEY` from `secretKeyRef: {name:
|
||||
enkly-secrets, key: ENKLY_VLLM_API_KEY}`.
|
||||
3. **`02-secrets.yaml` had what read as live credentials committed as
|
||||
if they were placeholders.** `STRIPE_SECRET_KEY`/
|
||||
`STRIPE_PUBLISH_KEY` were filled with what look like real Stripe
|
||||
test-mode keys, and `ENKLY_SMTP_USERNAME`/`ENKLY_SMTP_PASSWORD`
|
||||
held a real-looking Gmail address and a 16-character app password.
|
||||
**Rotate the Gmail app password and the Stripe keys if this file
|
||||
was ever pushed anywhere outside a local sandbox** — replacing the
|
||||
values here doesn't undo prior exposure. Replace all four with
|
||||
your own before applying. Separately, `ENKLY_VLLM_API_KEY` should
|
||||
be a real random value before applying — YAML `stringData` doesn't
|
||||
run shell commands, so a literal `"$(openssl rand -hex 32)"` string
|
||||
would become the actual, non-random key; see that key's comment in
|
||||
`02-secrets.yaml` for the correct one-liner.
|
||||
4. **Registry auth/TLS is cluster-level, not manifest-level.** Every
|
||||
Deployment references `imagePullSecrets: [enkly-registry]`, but
|
||||
that Secret must be created out-of-band (real credentials can't
|
||||
live in a file meant for a repo — see `02-secrets.yaml`'s header
|
||||
comment). Likewise, if `registry.enkly.org` serves a self-signed or
|
||||
internal CA cert, every node's containerd needs that CA trusted via
|
||||
`/etc/containerd/certs.d/registry.enkly.org/hosts.toml` — that's
|
||||
node provisioning, outside what any Kubernetes manifest can
|
||||
express.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `00-namespace.yaml` | the `enkly` namespace |
|
||||
| `01-config.yaml` | non-secret `ENKLY_*` config, shared by every service |
|
||||
| `02-secrets.yaml` | **template** — DB/AMQP/Stripe/SMTP/HF secret + instructions for the registry pull secret |
|
||||
| `10-rabbitmq.yaml` | RabbitMQ, with a PVC (so broker state survives a pod restart) |
|
||||
| `11-vllm.yaml` | vLLM chat + embed Deployments/Services, CPU backend (no GPU), each with its own RWO Hugging Face cache PVC — see above |
|
||||
| `12-databases.yaml` | iam-db, billing-db, conversation-db, knowledge-db (pgvector), each with its own PVC |
|
||||
| `20`–`27` | iam, billing, conversation, workspace, knowledge, agent, notification, gateway |
|
||||
| `30-ingress.yaml` | Ingress for the gateway (+ commented NodePort fallback) |
|
||||
| `kustomization.yaml` | ties the above together for `kubectl apply -k .` |
|
||||
|
||||
Every Deployment's env vars are a 1:1 mapping from
|
||||
`docker-compose.yml` — service hostnames are identical to the compose
|
||||
service names, so the only real change the application sees is DNS
|
||||
resolving inside a namespace instead of a compose network.
|
||||
|
||||
## Deploy
|
||||
|
||||
Two things have to be true *before* any apply, because nothing in
|
||||
Kubernetes can discover them on its own: your image registry has to
|
||||
have the 8 service images, and your real Stripe/SMTP credentials have
|
||||
to come from you.
|
||||
|
||||
```bash
|
||||
# 1. Build & push the 8 images
|
||||
REG=registry.enkly.org/enkly
|
||||
for svc in iam billing conversation workspace knowledge agent notification gateway; do
|
||||
docker build -f services/$svc/Dockerfile -t $REG/$svc:latest .
|
||||
docker push $REG/$svc:latest
|
||||
done
|
||||
|
||||
# 2. Registry pull secret (every Deployment references this)
|
||||
kubectl create secret docker-registry enkly-registry -n enkly \
|
||||
--docker-server=registry.enkly.org \
|
||||
--docker-username=<user> --docker-password=<token> --docker-email=<email>
|
||||
|
||||
# 3. Edit 02-secrets.yaml: fill in STRIPE_*/ENKLY_SMTP_* with your own
|
||||
# rotated values (see issue #3 above), and generate a real
|
||||
# ENKLY_VLLM_API_KEY (the file documents the exact one-liner).
|
||||
|
||||
# 4. Build & push the CPU-only, no-AVX512 vLLM image — this cluster's
|
||||
# nodes were confirmed to lack avx512f, so the official pre-built
|
||||
# image (public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo) will crash
|
||||
# with "Illegal instruction"; 11-vllm.yaml's image: already points
|
||||
# at the custom tag this produces:
|
||||
git clone https://github.com/vllm-project/vllm.git /tmp/vllm-build && cd /tmp/vllm-build
|
||||
docker build -f docker/Dockerfile.cpu \
|
||||
--build-arg VLLM_CPU_DISABLE_AVX512=true \
|
||||
--tag registry.enkly.org/enkly/vllm-cpu:latest \
|
||||
--target vllm-openai .
|
||||
docker push registry.enkly.org/enkly/vllm-cpu:latest
|
||||
|
||||
# 5. Apply everything
|
||||
kubectl apply -k k8s/
|
||||
|
||||
# 6. Watch the rollout — first boot downloads ~3.5GB of Hugging Face
|
||||
# weights total onto vllm-chat-cache/vllm-embed-cache, so the
|
||||
# startupProbe budget is generous.
|
||||
kubectl -n enkly get pods -w
|
||||
kubectl -n enkly rollout status deploy/vllm-chat --timeout=15m
|
||||
kubectl -n enkly rollout status deploy/vllm-embed --timeout=15m
|
||||
kubectl -n enkly rollout status deploy/gateway
|
||||
```
|
||||
|
||||
Then point DNS/`/etc/hosts` at the Ingress controller's address for
|
||||
the host set in `30-ingress.yaml` / `ENKLY_BASE_URL` in
|
||||
`01-config.yaml`, or use the commented NodePort Service in
|
||||
`30-ingress.yaml` if no ingress controller is installed
|
||||
(`kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/baremetal/deploy.yaml`
|
||||
installs one on bare metal).
|
||||
|
||||
### Verify the LLM path specifically
|
||||
|
||||
```bash
|
||||
kubectl -n enkly logs deploy/vllm-chat -f # watch the HF download + CPU backend warmup
|
||||
kubectl -n enkly logs deploy/vllm-embed -f
|
||||
kubectl -n enkly port-forward svc/gateway 8080:8080 # then run a chat turn end to end
|
||||
```
|
||||
|
||||
If a pod is stuck in `Init:Error` / `Init:CrashLoopBackOff`, that's
|
||||
the `check-cpu-avx2` init container — this cluster already needed the
|
||||
custom no-AVX512 image (see above), so seeing this now would mean a
|
||||
node lacks even AVX2, which no mainstream x86_64 CPU built since
|
||||
~2013 should be missing; read its log for which node and double-check
|
||||
`lscpu` there directly. If instead the main `vllm-chat`/`vllm-embed`
|
||||
container itself is crashing after the init container passed, check
|
||||
for `OOMKilled` in `kubectl -n enkly describe pod` specifically (this
|
||||
cluster's nodes only have ~7.76Gi RAM total each — raise
|
||||
`resources.limits.memory` cautiously, if at all, and prefer lowering
|
||||
`VLLM_CPU_KVCACHE_SPACE` or `--max-model-len` instead) over assuming
|
||||
it's a repeat of the AVX-512 issue.
|
||||
|
||||
## Scaling beyond the defaults
|
||||
|
||||
`iam`, `billing`, `conversation`, `knowledge`, `agent`, `notification`
|
||||
and `gateway` are stateless and safe to scale (`kubectl -n enkly scale
|
||||
deploy/gateway --replicas=3`). `workspace`, the four Postgres
|
||||
Deployments, `vllm-chat` and `vllm-embed` are all pinned to
|
||||
`replicas: 1` with `strategy: Recreate` because each owns a
|
||||
`ReadWriteOnce` PVC — don't raise their replica counts without first
|
||||
either moving to a clustered/replicated backend (e.g. Patroni for
|
||||
Postgres) or, for the vLLM pair specifically, converting
|
||||
`vllm-chat-cache`/`vllm-embed-cache` to `ReadWriteMany` (cheap to try:
|
||||
`managed-nfs-storage` already advertises it) and switching
|
||||
`strategy.type` to `RollingUpdate`. Either way, remember each replica
|
||||
is a full, independent CPU-bound process (no GPU to share), so scaling
|
||||
trades throughput for a proportional increase in CPU/RAM footprint,
|
||||
not a free win the way request-level load balancing sometimes is.
|
||||
19
k8s/kustomization.yaml
Normal file
19
k8s/kustomization.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: enkly
|
||||
resources:
|
||||
- 00-namespace.yaml
|
||||
- 01-config.yaml
|
||||
- 02-secrets.yaml
|
||||
- 10-rabbitmq.yaml
|
||||
- 11-vllm.yaml
|
||||
- 12-databases.yaml
|
||||
- 20-iam.yaml
|
||||
- 21-billing.yaml
|
||||
- 22-conversation.yaml
|
||||
- 23-workspace.yaml
|
||||
- 24-knowledge.yaml
|
||||
- 25-agent.yaml
|
||||
- 26-notification.yaml
|
||||
- 27-gateway.yaml
|
||||
- 30-ingress.yaml
|
||||
37
pkg/authsupport/csrf.go
Normal file
37
pkg/authsupport/csrf.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// csrfTokenBytes is the amount of entropy in a CSRF token before
|
||||
// encoding. This isn't a secret that needs to resist offline brute
|
||||
// force the way a password hash does — it just needs to be
|
||||
// unguessable to a third-party site, so 128 bits is comfortably enough.
|
||||
const csrfTokenBytes = 16
|
||||
|
||||
// NewCSRFToken returns a fresh, URL-safe random token suitable for the
|
||||
// double-submit-cookie pattern: the same value is set as a cookie and
|
||||
// embedded as a hidden form field, and a request is rejected unless the
|
||||
// two match (see VerifyCSRF). Unlike session/verification tokens, the
|
||||
// raw value itself is what's compared — there's no server-side lookup,
|
||||
// so no hash-at-rest step is needed here.
|
||||
func NewCSRFToken() (string, error) {
|
||||
b := make([]byte, csrfTokenBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("auth: generate csrf token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// VerifyCSRF reports whether a cookie value and a submitted form value
|
||||
// match, in constant time. Both empty is never valid.
|
||||
func VerifyCSRF(cookieValue, formValue string) bool {
|
||||
if cookieValue == "" || formValue == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(formValue)) == 1
|
||||
}
|
||||
110
pkg/authsupport/password.go
Normal file
110
pkg/authsupport/password.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Package auth holds enkly's password hashing, token generation, and
|
||||
// session/rate-limiting primitives.
|
||||
//
|
||||
// Password hashing deliberately avoids golang.org/x/crypto/bcrypt: this
|
||||
// module has no third-party dependencies beyond lib/pq and gomarkdown
|
||||
// (see go.mod), and pulling in x/crypto just for bcrypt isn't worth the
|
||||
// extra supply-chain surface when the stdlib's crypto/hmac + crypto/sha256
|
||||
// are enough to implement PBKDF2 (RFC 8018) directly — a well-defined,
|
||||
// widely reviewed construction, not a home-grown cipher.
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pbkdf2Iterations follows OWASP's 2023 password-storage cheat sheet
|
||||
// recommendation for PBKDF2-HMAC-SHA256 (>= 600,000 was later raised;
|
||||
// 210,000 is the still-current floor for SHA256 specifically). Bump this
|
||||
// if hashing becomes cheap enough to be a problem — old hashes keep
|
||||
// verifying fine since the iteration count travels with the hash.
|
||||
const pbkdf2Iterations = 210_000
|
||||
|
||||
const (
|
||||
saltLen = 16 // bytes
|
||||
keyLen = 32 // bytes (SHA-256 output size)
|
||||
)
|
||||
|
||||
// pbkdf2 derives a keyLen-byte key from password+salt using HMAC-SHA256
|
||||
// as the PRF, per RFC 8018 section 5.2.
|
||||
func pbkdf2(password, salt []byte, iterations, length int) []byte {
|
||||
prf := hmac.New(sha256.New, password)
|
||||
hLen := prf.Size()
|
||||
numBlocks := (length + hLen - 1) / hLen
|
||||
|
||||
var derived []byte
|
||||
buf := make([]byte, 4)
|
||||
for block := 1; block <= numBlocks; block++ {
|
||||
buf[0] = byte(block >> 24)
|
||||
buf[1] = byte(block >> 16)
|
||||
buf[2] = byte(block >> 8)
|
||||
buf[3] = byte(block)
|
||||
|
||||
prf.Reset()
|
||||
prf.Write(salt)
|
||||
prf.Write(buf)
|
||||
u := prf.Sum(nil)
|
||||
|
||||
t := make([]byte, len(u))
|
||||
copy(t, u)
|
||||
|
||||
for i := 1; i < iterations; i++ {
|
||||
prf.Reset()
|
||||
prf.Write(u)
|
||||
u = prf.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
derived = append(derived, t...)
|
||||
}
|
||||
return derived[:length]
|
||||
}
|
||||
|
||||
// HashPassword returns an encoded hash of the form
|
||||
// "pbkdf2-sha256$<iterations>$<base64 salt>$<base64 hash>", suitable for
|
||||
// storing directly in users.password_hash.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("auth: generate salt: %w", err)
|
||||
}
|
||||
hash := pbkdf2([]byte(password), salt, pbkdf2Iterations, keyLen)
|
||||
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
|
||||
pbkdf2Iterations,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a plaintext password against an encoded hash
|
||||
// produced by HashPassword, in constant time.
|
||||
func VerifyPassword(password, encoded string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iterations, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iterations <= 0 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
got := pbkdf2([]byte(password), salt, iterations, len(want))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
87
pkg/authsupport/ratelimit.go
Normal file
87
pkg/authsupport/ratelimit.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter slows repeated attempts against a single key (an email
|
||||
// address or a client IP) instead of flatly blocking them: each
|
||||
// consecutive failure within the window makes the *next* attempt's
|
||||
// artificial delay longer, capped at maxDelay. That's enough to make
|
||||
// password-guessing or verification-email-spam scripts painfully slow
|
||||
// without needing a CAPTCHA or an IP ban list, and it degrades
|
||||
// gracefully — a genuine user who mistypes their password twice waits a
|
||||
// second, not minutes.
|
||||
//
|
||||
// State is in-memory and per-process, which is fine for enkly's
|
||||
// single-instance deployment (see docker-compose.yml); it resets on
|
||||
// restart and doesn't need a database round-trip on the hot path.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptState
|
||||
window time.Duration
|
||||
baseWait time.Duration
|
||||
maxWait time.Duration
|
||||
}
|
||||
|
||||
type attemptState struct {
|
||||
count int
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// NewLimiter builds a Limiter. window is how long a run of failures is
|
||||
// remembered before resetting; baseWait/maxWait bound the artificial
|
||||
// delay (baseWait * 2^(failures-1), capped at maxWait).
|
||||
func NewLimiter(window, baseWait, maxWait time.Duration) *Limiter {
|
||||
return &Limiter{
|
||||
attempts: make(map[string]*attemptState),
|
||||
window: window,
|
||||
baseWait: baseWait,
|
||||
maxWait: maxWait,
|
||||
}
|
||||
}
|
||||
|
||||
// Delay returns how long the caller should sleep before processing the
|
||||
// next attempt for key, based on how many recent failures it has seen.
|
||||
// It does not itself sleep or record anything — call RecordFailure /
|
||||
// RecordSuccess to update state.
|
||||
func (l *Limiter) Delay(key string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
st, ok := l.attempts[key]
|
||||
if !ok || time.Since(st.lastSeen) > l.window {
|
||||
return 0
|
||||
}
|
||||
if st.count <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
wait := l.baseWait << (st.count - 1) // baseWait * 2^(count-1)
|
||||
if wait > l.maxWait || wait <= 0 { // <=0 guards against overflow
|
||||
wait = l.maxWait
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// RecordFailure increments key's failure streak.
|
||||
func (l *Limiter) RecordFailure(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
st, ok := l.attempts[key]
|
||||
if !ok || time.Since(st.lastSeen) > l.window {
|
||||
st = &attemptState{}
|
||||
l.attempts[key] = st
|
||||
}
|
||||
st.count++
|
||||
st.lastSeen = time.Now()
|
||||
}
|
||||
|
||||
// RecordSuccess clears key's failure streak.
|
||||
func (l *Limiter) RecordSuccess(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
58
pkg/authsupport/token.go
Normal file
58
pkg/authsupport/token.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// tokenBytes is the amount of entropy in a raw token before encoding —
|
||||
// 32 bytes (256 bits) is comfortably enough that guessing is infeasible.
|
||||
const tokenBytes = 32
|
||||
|
||||
// NewToken returns a fresh, URL-safe random token (for password-reset
|
||||
// links and session cookies) and the hex-encoded SHA-256 hash of it that
|
||||
// should be persisted in its place. Only the hash ever touches the
|
||||
// database — the raw token lives solely in the email link or the
|
||||
// browser's cookie, so a DB dump alone can't be replayed.
|
||||
func NewToken() (raw string, hash string, err error) {
|
||||
b := make([]byte, tokenBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate token: %w", err)
|
||||
}
|
||||
raw = base64.RawURLEncoding.EncodeToString(b)
|
||||
return raw, HashToken(raw), nil
|
||||
}
|
||||
|
||||
// codeSpace is how many distinct 6-digit codes NewCode can produce
|
||||
// (000000 through 999999).
|
||||
const codeSpace = 1_000_000
|
||||
|
||||
// NewCode returns a fresh random 6-digit numeric code — as a
|
||||
// zero-padded string, so "007123" round-trips correctly — for emailing
|
||||
// as an email-verification code, and the hex-encoded SHA-256 hash of it
|
||||
// that should be persisted in its place. Unlike NewToken's 256 bits of
|
||||
// entropy, a 6-digit code is deliberately guessable-by-brute-force if
|
||||
// nothing else stops repeated attempts; callers must pair it with an
|
||||
// attempt limit and a short expiry (see internal/db.VerifyEmailCode and
|
||||
// verifyCodeTTL) rather than relying on the code space alone.
|
||||
func NewCode() (raw string, hash string, err error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(codeSpace))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate code: %w", err)
|
||||
}
|
||||
raw = fmt.Sprintf("%06d", n.Int64())
|
||||
return raw, HashToken(raw), nil
|
||||
}
|
||||
|
||||
// HashToken hashes a raw token or code the same way NewToken/NewCode do,
|
||||
// so a value received back from the client (a reset link's ?token=, a
|
||||
// session cookie, or a typed verification code) can be looked up by its
|
||||
// hash.
|
||||
func HashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
44
pkg/authsupport/token_test.go
Normal file
44
pkg/authsupport/token_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package authsupport
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewCodeShape(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 200; i++ {
|
||||
raw, hash, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) != 6 {
|
||||
t.Fatalf("code %q is not 6 digits", raw)
|
||||
}
|
||||
for _, c := range raw {
|
||||
if c < '0' || c > '9' {
|
||||
t.Fatalf("code %q has a non-digit", raw)
|
||||
}
|
||||
}
|
||||
if hash != HashToken(raw) {
|
||||
t.Fatalf("hash mismatch for %q", raw)
|
||||
}
|
||||
seen[raw] = true
|
||||
}
|
||||
// Not a strict randomness test, just a sanity check that we're not
|
||||
// somehow returning the same code every time.
|
||||
if len(seen) < 190 {
|
||||
t.Fatalf("suspiciously few distinct codes out of 200 draws: %d", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodeZeroPadded(t *testing.T) {
|
||||
// %06d must preserve leading zeros (e.g. a draw of 42 -> "000042"),
|
||||
// or VerifyEmailCode would compare against a mis-shapen string.
|
||||
for i := 0; i < 2000; i++ {
|
||||
raw, _, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) != 6 {
|
||||
t.Fatalf("code %q is not zero-padded to 6 digits", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
pkg/effort/effort.go
Normal file
175
pkg/effort/effort.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Package effort defines enkly's manual reasoning-depth control: five
|
||||
// named levels (Low, Medium, High, Extra, Max) a caller can pick to
|
||||
// arbitrate between speed and precision for one agent turn, modeled on
|
||||
// Claude's effort parameter. It is deliberately a small, dependency-free
|
||||
// package — just a Level type plus the static Profile each one maps to
|
||||
// — so both the agent service (which actually runs a turn at a given
|
||||
// level) and any future transport (gRPC field, HTTP query param, a
|
||||
// gateway settings dropdown) can depend on it without pulling in
|
||||
// agentrun or vllmkit.
|
||||
//
|
||||
// vLLM/Qwen has no first-class notion of "effort" the way the
|
||||
// Anthropic API does (a token budget plus a reasoning-depth knob) — the
|
||||
// only dial it exposes per request is vllmkit.Client's think string
|
||||
// ("" / "true" / "false" / "low" / "medium" / "high", all of which
|
||||
// collapse to a single on/off enable_thinking toggle on the wire — see
|
||||
// vllmkit.Client.Think's doc comment) plus the ordinary generation
|
||||
// options (max_tokens, temperature, top_p). Profile is this package's
|
||||
// honest mapping of Claude's five-level vocabulary onto that smaller
|
||||
// vocabulary: Think picks the per-round reasoning-effort floor (see
|
||||
// agentrun.EffortThinkPolicy), MaxRounds caps how many tool-call
|
||||
// round-trips a turn is allowed before Low has to converge fast and Max
|
||||
// can keep investigating, and NumPredictCap bounds a single reply's
|
||||
// length so Low genuinely costs less and Max isn't truncated mid-answer.
|
||||
package effort
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is one of five named reasoning-depth settings, ordered from
|
||||
// fastest/cheapest to deepest/most thorough.
|
||||
type Level string
|
||||
|
||||
const (
|
||||
Low Level = "low"
|
||||
Medium Level = "medium"
|
||||
High Level = "high"
|
||||
Extra Level = "extra"
|
||||
Max Level = "max"
|
||||
)
|
||||
|
||||
// Default is used whenever no level was explicitly selected — the same
|
||||
// balance the agent struck before effort control existed.
|
||||
const Default = Medium
|
||||
|
||||
// All lists every level in ascending order of depth, for validation
|
||||
// messages and a future settings-UI dropdown.
|
||||
var All = []Level{Low, Medium, High, Extra, Max}
|
||||
|
||||
// Parse normalizes free-form text (a slash-directive argument, a query
|
||||
// param, a gRPC field once wired) into a Level. Case/space-insensitive;
|
||||
// a handful of obvious synonyms are accepted so "quick"/"fast" from the
|
||||
// fast-mode vocabulary and "deep"/"thorough" from everyday language
|
||||
// both resolve sensibly instead of erroring on a near-miss.
|
||||
func Parse(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return Default, nil
|
||||
case "low", "fast", "quick", "speed":
|
||||
return Low, nil
|
||||
case "medium", "default", "normal", "balanced":
|
||||
return Medium, nil
|
||||
case "high", "deep", "careful":
|
||||
return High, nil
|
||||
case "extra", "extra-high", "extra_high", "thorough":
|
||||
return Extra, nil
|
||||
case "max", "maximum", "deepest", "ultra":
|
||||
return Max, nil
|
||||
default:
|
||||
return "", fmt.Errorf("effort: unknown level %q (want one of low, medium, high, extra, max)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether l is one of the five defined levels — distinct
|
||||
// from Parse accepting synonyms: Valid is for checking an already
|
||||
// canonical value (e.g. one stored verbatim), Parse is for user input.
|
||||
func (l Level) Valid() bool {
|
||||
switch l {
|
||||
case Low, Medium, High, Extra, Max:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Profile is the concrete tuning one Level resolves to. Every field has
|
||||
// a conservative, locally-reasonable value — there's no remote
|
||||
// "effort=max" service to defer to the way the Anthropic API has, so
|
||||
// these numbers are enkly's own calibration for enkly's vLLM
|
||||
// deployment, meant to be adjusted (see cmd/server/main.go's
|
||||
// ENKLY_EFFORT_* overrides) rather than treated as gospel.
|
||||
type Profile struct {
|
||||
// Level this profile was resolved from, for logging.
|
||||
Level Level
|
||||
// Think is the thinking-effort floor passed to the chat model on
|
||||
// every round of the turn (see vllmkit.Client's Think field). A
|
||||
// per-round policy (agentrun.EffortThinkPolicy) may still ask for
|
||||
// more on round 0 or less on a retry — this is the floor beneath
|
||||
// which it won't go, not an override of every round alike.
|
||||
Think string
|
||||
// MaxRounds caps tool-call round-trips for a turn run at this
|
||||
// level. Low converges fast on purpose; Max gets the same ceiling
|
||||
// dispatch_workflow's own sub-runs use, so a Max-effort turn can
|
||||
// still delegate broad work instead of exhausting its own budget
|
||||
// investigating serially.
|
||||
MaxRounds int
|
||||
// NumPredictCap bounds a single model reply's length in tokens (0
|
||||
// = no cap beyond the client's own configured default). Only Low
|
||||
// sets one — the other levels trust the model to stop when it's
|
||||
// done rather than truncating a legitimately long answer.
|
||||
NumPredictCap int
|
||||
// ContextBudgetChars overrides agentrun's default context-budget
|
||||
// threshold (see agentrun.Agent.ContextBudgetChars) for a turn run
|
||||
// at this level — Max keeps more tool-result history verbatim
|
||||
// before compacting, trading context budget for fewer "re-call the
|
||||
// tool if you need it again" gaps on a long investigation.
|
||||
ContextBudgetChars int
|
||||
// AdaptiveThinking, when true, lets the per-round policy raise
|
||||
// effort above Think on a round that looks like it needs it (an
|
||||
// error result, a round deep into a long turn) instead of holding
|
||||
// every round at exactly Think — see agentrun.AdaptiveThinkPolicy.
|
||||
// Low and Medium leave it off (predictable cost); High and above
|
||||
// turn it on (predictable quality).
|
||||
AdaptiveThinking bool
|
||||
// MaxParallelSubagents is the ceiling dispatch_workflow enforces
|
||||
// for a single dispatch_workflow call made during a turn run at
|
||||
// this level — see toolexec's workflow orchestrator. Low effort
|
||||
// still allows some parallel fan-out (a migration-shaped request
|
||||
// at Low effort is still a migration), just a smaller one.
|
||||
MaxParallelSubagents int
|
||||
}
|
||||
|
||||
// defaultContextBudgetChars mirrors agentrun's own unexported default
|
||||
// (contextBudgetChars) so Resolve's Medium profile reproduces the
|
||||
// agent's pre-effort-control behavior exactly. Keeping one literal
|
||||
// value here rather than importing agentrun (which would create an
|
||||
// import cycle: agentrun -> effort -> agentrun) is deliberate — see
|
||||
// agentrun.Agent.ContextBudgetChars's doc comment for the other half of
|
||||
// this contract.
|
||||
const defaultContextBudgetChars = 60_000
|
||||
|
||||
// Resolve returns the Profile for l. An invalid l (never produced by
|
||||
// Parse, but possible from a stored/decoded value) resolves as Medium
|
||||
// rather than panicking or erroring — effort control degrading to "the
|
||||
// behavior enkly always had" is always a safe failure mode.
|
||||
func (l Level) Resolve() Profile {
|
||||
switch l {
|
||||
case Low:
|
||||
return Profile{
|
||||
Level: Low, Think: "low", MaxRounds: 6, NumPredictCap: 700,
|
||||
ContextBudgetChars: defaultContextBudgetChars / 2, AdaptiveThinking: false, MaxParallelSubagents: 16,
|
||||
}
|
||||
case High:
|
||||
return Profile{
|
||||
Level: High, Think: "high", MaxRounds: 16,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 2, AdaptiveThinking: true, MaxParallelSubagents: 96,
|
||||
}
|
||||
case Extra:
|
||||
return Profile{
|
||||
Level: Extra, Think: "high", MaxRounds: 24,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 4, AdaptiveThinking: true, MaxParallelSubagents: 192,
|
||||
}
|
||||
case Max:
|
||||
return Profile{
|
||||
Level: Max, Think: "high", MaxRounds: 32,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 8, AdaptiveThinking: true, MaxParallelSubagents: 256,
|
||||
}
|
||||
default: // Medium and anything unrecognized
|
||||
return Profile{
|
||||
Level: Medium, Think: "", MaxRounds: 12,
|
||||
ContextBudgetChars: defaultContextBudgetChars, AdaptiveThinking: false, MaxParallelSubagents: 48,
|
||||
}
|
||||
}
|
||||
}
|
||||
102
pkg/effort/effort_test.go
Normal file
102
pkg/effort/effort_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package effort
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Level
|
||||
wantErr bool
|
||||
}{
|
||||
{"", Default, false},
|
||||
{"low", Low, false},
|
||||
{" LOW ", Low, false},
|
||||
{"fast", Low, false},
|
||||
{"medium", Medium, false},
|
||||
{"default", Medium, false},
|
||||
{"high", High, false},
|
||||
{"deep", High, false},
|
||||
{"extra", Extra, false},
|
||||
{"thorough", Extra, false},
|
||||
{"max", Max, false},
|
||||
{"ultra", Max, false},
|
||||
{"bogus", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := Parse(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Parse(%q) = %q, nil; want an error", tc.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q) returned unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValid(t *testing.T) {
|
||||
for _, l := range All {
|
||||
if !l.Valid() {
|
||||
t.Errorf("%q should be Valid", l)
|
||||
}
|
||||
}
|
||||
if Level("bogus").Valid() {
|
||||
t.Error(`Level("bogus").Valid() = true, want false`)
|
||||
}
|
||||
if Level("").Valid() {
|
||||
t.Error(`Level("").Valid() = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrdering(t *testing.T) {
|
||||
// Depth should be monotonic across the five levels for every knob
|
||||
// that's supposed to scale with effort — a regression here (e.g.
|
||||
// Extra accidentally configured with a smaller budget than High)
|
||||
// would quietly break the "arbitrate between speed and precision"
|
||||
// contract the whole feature exists to provide.
|
||||
levels := []Level{Low, Medium, High, Extra, Max}
|
||||
var prevRounds, prevBudget, prevParallel int
|
||||
for i, l := range levels {
|
||||
p := l.Resolve()
|
||||
if p.Level != l {
|
||||
t.Errorf("%s.Resolve().Level = %s, want %s", l, p.Level, l)
|
||||
}
|
||||
if i > 0 {
|
||||
if p.MaxRounds < prevRounds {
|
||||
t.Errorf("%s.MaxRounds (%d) < %s.MaxRounds (%d); want non-decreasing", l, p.MaxRounds, levels[i-1], prevRounds)
|
||||
}
|
||||
if p.ContextBudgetChars < prevBudget {
|
||||
t.Errorf("%s.ContextBudgetChars (%d) < %s.ContextBudgetChars (%d); want non-decreasing", l, p.ContextBudgetChars, levels[i-1], prevBudget)
|
||||
}
|
||||
if p.MaxParallelSubagents < prevParallel {
|
||||
t.Errorf("%s.MaxParallelSubagents (%d) < %s.MaxParallelSubagents (%d); want non-decreasing", l, p.MaxParallelSubagents, levels[i-1], prevParallel)
|
||||
}
|
||||
}
|
||||
prevRounds, prevBudget, prevParallel = p.MaxRounds, p.ContextBudgetChars, p.MaxParallelSubagents
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInvalidFallsBackToMedium(t *testing.T) {
|
||||
got := Level("not-a-real-level").Resolve()
|
||||
want := Medium.Resolve()
|
||||
if got != want {
|
||||
t.Errorf("invalid Level.Resolve() = %+v, want Medium's profile %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowHasOutputCapOthersDont(t *testing.T) {
|
||||
if Low.Resolve().NumPredictCap == 0 {
|
||||
t.Error("Low should set a NumPredictCap")
|
||||
}
|
||||
for _, l := range []Level{Medium, High, Extra, Max} {
|
||||
if got := l.Resolve().NumPredictCap; got != 0 {
|
||||
t.Errorf("%s.NumPredictCap = %d, want 0 (uncapped)", l, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
pkg/entitlements/entitlements.go
Normal file
105
pkg/entitlements/entitlements.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package entitlements resolves which premium agent capabilities a
|
||||
// given subscription plan unlocks — today, specifically dynamic
|
||||
// workflows (dispatch_workflow's parallel multi-sub-agent
|
||||
// orchestration). Effort control and fast mode are deliberately NOT
|
||||
// gated here: per the product brief, those two stay available on every
|
||||
// plan, so there is nothing for this package to decide about them.
|
||||
//
|
||||
// Why this is its own small package rather than a field on billing's
|
||||
// domain.Plan: the agent service never imports billing's domain
|
||||
// package directly (see ARCHITECTURE.md's layering rules — Agent calls
|
||||
// Billing only over gRPC, as a saga coordinator, and billing's
|
||||
// CheckAndIncrementUsageResponse already hands back the plan as a
|
||||
// plain string, PlanName, specifically so a caller doesn't need
|
||||
// billing's own types to make a decision from it). Keeping the
|
||||
// plan-name -> capability mapping here, keyed by that same string,
|
||||
// lets the agent service gate a feature without a new cross-service
|
||||
// dependency, and lets ops retarget which plan names unlock dynamic
|
||||
// workflows via one env var (see DynamicWorkflowPlansEnv) with no code
|
||||
// change — which matters because, as of this package's introduction,
|
||||
// billing's own plan catalog (services/billing/internal/domain/
|
||||
// subscription.go) only names four tiers (free/basic/pro/premium), not
|
||||
// the Enterprise/Team/Max vocabulary dynamic workflows are meant to
|
||||
// ship on. "premium" — today's top tier — is the default stand-in for
|
||||
// all three until billing's catalog is extended with real Team/
|
||||
// Enterprise/Max entries (a Catalog append plus a migration touching
|
||||
// the `plan` CHECK constraint — see that file's own doc comment — not
|
||||
// a change this package needs to know about).
|
||||
package entitlements
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DynamicWorkflowPlansEnv is the environment variable
|
||||
// cmd/server/main.go reads to build a Set's plan allowlist — a
|
||||
// comma-separated list of plan names (matched case-insensitively
|
||||
// against billing's CheckAndIncrementUsageResponse.PlanName), e.g.
|
||||
// "premium,team,enterprise,max". Unset or empty means "use
|
||||
// DefaultDynamicWorkflowPlans".
|
||||
const DynamicWorkflowPlansEnv = "ENKLY_DYNAMIC_WORKFLOW_PLANS"
|
||||
|
||||
// DefaultDynamicWorkflowPlans is used when DynamicWorkflowPlansEnv is
|
||||
// unset — "premium" is billing's existing top tier; see the package
|
||||
// doc comment for why that's today's honest stand-in for
|
||||
// Enterprise/Team/Max.
|
||||
var DefaultDynamicWorkflowPlans = []string{"premium"}
|
||||
|
||||
// Catalog is the resolved plan allowlist — built once at startup (see
|
||||
// ParsePlanList) and consulted per request via Resolve. Kept as a
|
||||
// small value type rather than a package-level global so a test (or a
|
||||
// future per-tenant override) can construct its own without mutating
|
||||
// shared state.
|
||||
type Catalog struct {
|
||||
dynamicWorkflowPlans map[string]bool
|
||||
}
|
||||
|
||||
// ParsePlanList builds a Catalog from a comma-separated plan list (see
|
||||
// DynamicWorkflowPlansEnv's format); an empty/whitespace-only raw
|
||||
// falls back to DefaultDynamicWorkflowPlans.
|
||||
func ParsePlanList(raw string) Catalog {
|
||||
fields := strings.Split(raw, ",")
|
||||
names := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f = strings.ToLower(strings.TrimSpace(f)); f != "" {
|
||||
names = append(names, f)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
names = DefaultDynamicWorkflowPlans
|
||||
}
|
||||
set := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
set[n] = true
|
||||
}
|
||||
return Catalog{dynamicWorkflowPlans: set}
|
||||
}
|
||||
|
||||
// Set is one user's resolved capabilities for one turn — what
|
||||
// application.Service computes once (from the plan name billing's
|
||||
// CheckAndIncrementUsage already returned) and attaches to the run
|
||||
// context via toolexec.WithEntitlements, for dispatch_workflow to read
|
||||
// back without any gRPC call of its own.
|
||||
type Set struct {
|
||||
// DynamicWorkflows reports whether dispatch_workflow is usable this
|
||||
// turn.
|
||||
DynamicWorkflows bool
|
||||
// PlanName is the plan the decision was made from, carried along
|
||||
// purely so a denial message can name it (e.g. "not available on
|
||||
// the Free plan") without a second lookup.
|
||||
PlanName string
|
||||
}
|
||||
|
||||
// Resolve decides a Set for planName against c's allowlist. An unknown
|
||||
// or empty planName (a billing lookup that failed open, or a caller
|
||||
// that never set one) resolves as not entitled — a gated feature
|
||||
// failing closed on missing information is the safe default, unlike
|
||||
// the daily-message-quota check in application.Service.StartTurn,
|
||||
// which deliberately fails *open* because getting that wrong costs
|
||||
// someone a chat message, not an unintended unlock of a paid feature.
|
||||
func (c Catalog) Resolve(planName string) Set {
|
||||
return Set{
|
||||
DynamicWorkflows: c.dynamicWorkflowPlans[strings.ToLower(strings.TrimSpace(planName))],
|
||||
PlanName: planName,
|
||||
}
|
||||
}
|
||||
79
pkg/envkit/dotenv.go
Normal file
79
pkg/envkit/dotenv.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package config loads ENKLY_* configuration from a ".env" file into the
|
||||
// process environment, so `os.Getenv` calls throughout the app (see
|
||||
// getenv() in cmd/server/main.go) pick up values from ".env" the same
|
||||
// way they'd pick up real environment variables — without adding a
|
||||
// third-party dependency like godotenv (this module intentionally keeps
|
||||
// go.mod to lib/pq + gomarkdown; see the comment on that file).
|
||||
package envkit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadDotEnv reads simple KEY=VALUE lines from path and calls
|
||||
// os.Setenv for each one whose key isn't already set in the real
|
||||
// environment — actual environment variables always win, so
|
||||
// `ENKLY_SMTP_PASSWORD=x make run` still overrides whatever ".env"
|
||||
// says. Missing files are not an error: ".env" is optional (e.g. in
|
||||
// docker-compose.yml, config comes from `environment:` instead).
|
||||
//
|
||||
// Supported syntax, deliberately small:
|
||||
//
|
||||
// KEY=value
|
||||
// KEY="value with spaces, or a # that isn't a comment"
|
||||
// KEY='value'
|
||||
// # full-line comments and blank lines are ignored
|
||||
// export KEY=value (a leading "export " is stripped, for .env files
|
||||
// written to be `source`-able in a shell too)
|
||||
//
|
||||
// No variable interpolation, no multi-line values.
|
||||
func LoadDotEnv(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("config: open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: %s:%d: expected KEY=VALUE, got %q", path, lineNo, line)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" {
|
||||
return fmt.Errorf("config: %s:%d: empty key", path, lineNo)
|
||||
}
|
||||
|
||||
// Strip a single matching pair of surrounding quotes, if present.
|
||||
if len(value) >= 2 {
|
||||
first, last := value[0], value[len(value)-1]
|
||||
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if _, alreadySet := os.LookupEnv(key); alreadySet {
|
||||
continue // real environment variables take precedence
|
||||
}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("config: set %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
60
pkg/envkit/getenv.go
Normal file
60
pkg/envkit/getenv.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package envkit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// String returns the environment variable key, or fallback if unset/empty.
|
||||
// Same semantics as the getenv() helper every cmd/server/main.go in the
|
||||
// monolith defined locally — hoisted here once since every microservice
|
||||
// now needs it.
|
||||
func String(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Int parses the environment variable key as an integer, or returns
|
||||
// fallback if unset or unparseable.
|
||||
func Int(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Float parses the environment variable key as a float64, or returns
|
||||
// fallback if unset or unparseable.
|
||||
func Float(key string, fallback float64) float64 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Bool parses the environment variable key as a bool ("true"/"1"/"yes",
|
||||
// case-insensitive, or their opposites), or returns fallback if unset
|
||||
// or unparseable.
|
||||
func Bool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
159
pkg/eventbus/bus.go
Normal file
159
pkg/eventbus/bus.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// Bus is a small, reconnect-free wrapper over one amqp091 connection and
|
||||
// channel. It declares ExchangeName as a durable topic exchange on
|
||||
// connect so publishers and consumers never race over who creates it
|
||||
// first.
|
||||
//
|
||||
// Deliberately minimal: no automatic reconnect/retry loop, no publisher
|
||||
// confirms, no dead-letter exchange. Those are real production
|
||||
// concerns, but adding them here would be infrastructure plumbing
|
||||
// unrelated to the DDD restructuring this package exists to
|
||||
// demonstrate — see the "Operational hardening" section of
|
||||
// ARCHITECTURE.md for what a production rollout should add before this
|
||||
// goes live.
|
||||
type Bus struct {
|
||||
conn *amqp.Connection
|
||||
channel *amqp.Channel
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Connect dials url (an amqp:// URI) and declares the shared topology.
|
||||
func Connect(url string, logger *slog.Logger) (*Bus, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("open channel: %w", err)
|
||||
}
|
||||
if err := ch.ExchangeDeclare(ExchangeName, "topic", true, false, false, false, nil); err != nil {
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("declare exchange: %w", err)
|
||||
}
|
||||
return &Bus{conn: conn, channel: ch, logger: logger}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying channel and connection.
|
||||
func (b *Bus) Close() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.channel.Close(); err != nil {
|
||||
_ = b.conn.Close()
|
||||
return err
|
||||
}
|
||||
return b.conn.Close()
|
||||
}
|
||||
|
||||
// Envelope is the JSON structure every event is published as. OccurredAt
|
||||
// and RoutingKey are set by Publish; Payload is whatever event-specific
|
||||
// struct the caller passes (see pkg/eventbus/events).
|
||||
type Envelope struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Publish marshals payload and sends it to ExchangeName with the given
|
||||
// routing key, as a persistent message (survives a broker restart).
|
||||
// Fire-and-forget: the caller's own request/response flow (over gRPC)
|
||||
// must never block on this succeeding — a publish failure is logged,
|
||||
// not propagated, exactly like the monolith's own "log, don't fail the
|
||||
// request" stance on e.g. a cleanup-loop error.
|
||||
func (b *Bus) Publish(ctx context.Context, routingKey string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event payload: %w", err)
|
||||
}
|
||||
env := Envelope{RoutingKey: routingKey, OccurredAt: time.Now().UTC(), Payload: body}
|
||||
envBody, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
return b.channel.PublishWithContext(ctx, ExchangeName, routingKey, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: envBody,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishBestEffort calls Publish and logs (rather than returns) any
|
||||
// error — the shape every call site in this codebase actually wants,
|
||||
// since a notification being late or lost must never fail the
|
||||
// synchronous gRPC call that triggered it.
|
||||
func (b *Bus) PublishBestEffort(ctx context.Context, routingKey string, payload any) {
|
||||
if err := b.Publish(ctx, routingKey, payload); err != nil {
|
||||
b.logger.Error("publish event", "routing_key", routingKey, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler processes one decoded event. Returning an error nacks the
|
||||
// delivery with requeue=true; a consumer that can't make progress
|
||||
// should log and return nil instead of spinning forever on one poison
|
||||
// message (the notification service does exactly this — see
|
||||
// services/notification/internal/infrastructure/eventing).
|
||||
type Handler func(ctx context.Context, env Envelope) error
|
||||
|
||||
// Consume declares binding.Queue durable, binds it to every routing key
|
||||
// in binding.RoutingKeys, and runs handler for each delivery until ctx
|
||||
// is cancelled. Blocks the calling goroutine.
|
||||
func (b *Bus) Consume(ctx context.Context, binding QueueBinding, handler Handler) error {
|
||||
q, err := b.channel.QueueDeclare(binding.Queue, true, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("declare queue %s: %w", binding.Queue, err)
|
||||
}
|
||||
for _, key := range binding.RoutingKeys {
|
||||
if err := b.channel.QueueBind(q.Name, key, ExchangeName, false, nil); err != nil {
|
||||
return fmt.Errorf("bind queue %s to %s: %w", binding.Queue, key, err)
|
||||
}
|
||||
}
|
||||
// Process one message at a time per consumer goroutine — the
|
||||
// notification service's send-an-email handler is I/O bound on
|
||||
// SMTP, not CPU bound, so this keeps ordering simple without
|
||||
// needing a worker pool for the volumes this is sized for.
|
||||
if err := b.channel.Qos(1, 0, false); err != nil {
|
||||
return fmt.Errorf("set qos: %w", err)
|
||||
}
|
||||
deliveries, err := b.channel.Consume(q.Name, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume %s: %w", q.Name, err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery channel closed for queue %s", q.Name)
|
||||
}
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(d.Body, &env); err != nil {
|
||||
b.logger.Error("decode event envelope", "queue", q.Name, "err", err)
|
||||
_ = d.Nack(false, false) // malformed — don't requeue
|
||||
continue
|
||||
}
|
||||
if err := handler(ctx, env); err != nil {
|
||||
b.logger.Error("handle event", "queue", q.Name, "routing_key", env.RoutingKey, "err", err)
|
||||
_ = d.Nack(false, true)
|
||||
continue
|
||||
}
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
pkg/eventbus/events/billing_events.go
Normal file
14
pkg/eventbus/events/billing_events.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package events
|
||||
|
||||
// SubscriptionChanged is published by billing whenever applyStripeEvent
|
||||
// (now billing's internal/application use case of the same name)
|
||||
// commits a plan/status change from a Stripe webhook. No consumer in
|
||||
// this port of the monolith needs it synchronously — it's published so
|
||||
// a future consumer (e.g. a usage-analytics or dunning-email service)
|
||||
// can react without billing needing to know about it.
|
||||
type SubscriptionChanged struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
EventType string `json:"stripe_event_type"` // e.g. "customer.subscription.updated"
|
||||
}
|
||||
12
pkg/eventbus/events/conversation_events.go
Normal file
12
pkg/eventbus/events/conversation_events.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package events
|
||||
|
||||
// MessageCreated is published by conversation on every db.AddMessage
|
||||
// equivalent. Unused by any consumer in this port — present so the
|
||||
// pattern (every aggregate commit gets an event, even before a consumer
|
||||
// exists for it) is consistent across contexts; see
|
||||
// pkg/eventbus.RoutingKeyMessageCreated.
|
||||
type MessageCreated struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
52
pkg/eventbus/events/iam_events.go
Normal file
52
pkg/eventbus/events/iam_events.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package events holds the JSON payload shape for every event listed in
|
||||
// pkg/eventbus's routing-key constants. Each type is versioned
|
||||
// implicitly by its routing key (a breaking change gets a new key, e.g.
|
||||
// "iam.user.registered.v2") rather than by a field in the struct, to
|
||||
// keep consumers' decoding code simple.
|
||||
package events
|
||||
|
||||
import "time"
|
||||
|
||||
// VerificationCodeIssued is published by iam whenever a 6-digit email
|
||||
// verification code is minted — at signup (Handlers.Signup) and on
|
||||
// resend (Handlers.ResendVerification). The notification service is its
|
||||
// only consumer today (pkg/eventbus.RoutingKeyVerificationCodeIssued).
|
||||
//
|
||||
// Carrying the raw code on the bus (rather than just a code ID) is a
|
||||
// deliberate, documented trade-off: it lets the email's content stay
|
||||
// exactly what the monolith sent without notification needing its own
|
||||
// read access to iam's verification_codes table (which would violate
|
||||
// "each service owns its own tables"). This is safe only because
|
||||
// RabbitMQ sits on the private service network, never reachable from
|
||||
// outside the cluster — see ARCHITECTURE.md's "Trust boundary of the
|
||||
// event bus" note.
|
||||
type VerificationCodeIssued struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// PasswordResetRequested is published by iam from Handlers.ForgotPassword.
|
||||
type PasswordResetRequested struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
ResetLink string `json:"reset_link"` // already absolute (BaseURL + path + token), matching h.absURL
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// UserRegistered is published once a new account row is committed
|
||||
// (before email verification). Reserved for consumers that care about
|
||||
// the account existing at all rather than about delivering mail — e.g.
|
||||
// an analytics or growth service added later.
|
||||
type UserRegistered struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserEmailVerified is published when VerifyEmailCode succeeds.
|
||||
type UserEmailVerified struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
63
pkg/eventbus/topology.go
Normal file
63
pkg/eventbus/topology.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package eventbus is enkly's thin wrapper around RabbitMQ (via
|
||||
// github.com/rabbitmq/amqp091-go) for every *asynchronous*,
|
||||
// fire-and-forget cross-context notification — the counterpart to the
|
||||
// synchronous, request/response calls services make over gRPC (see
|
||||
// api/proto). Per-service request/response traffic (e.g. the agent
|
||||
// service asking billing "is this user over quota?") always uses gRPC;
|
||||
// RabbitMQ is reserved for events a publisher doesn't need an answer to,
|
||||
// mirroring the rule stated in the task: "gRPC for synchronous
|
||||
// communication, RabbitMQ for asynchronous communication."
|
||||
//
|
||||
// Topology: one durable topic exchange, "enkly.events". Each event is
|
||||
// published with a routing key of the form "<context>.<aggregate>.
|
||||
// <event>" (e.g. "iam.user.registered"); a consumer binds a durable
|
||||
// queue to the routing keys it cares about. Using a topic exchange
|
||||
// rather than one queue per consumer lets a new consumer be added later
|
||||
// (e.g. an analytics service) without touching the publisher.
|
||||
package eventbus
|
||||
|
||||
// ExchangeName is the single durable topic exchange every service
|
||||
// publishes domain events to.
|
||||
const ExchangeName = "enkly.events"
|
||||
|
||||
// Routing keys. Each constant is "<owning context>.<aggregate>.<event,
|
||||
// past tense>" — only the owning bounded context ever publishes to its
|
||||
// own keys (see each service's internal/infrastructure/eventing
|
||||
// package), but any service may subscribe.
|
||||
const (
|
||||
// Published by iam (services/iam) — see internal/handlers/auth.go's
|
||||
// sendVerificationEmail/sendResetEmail, now decoupled from the
|
||||
// synchronous request path.
|
||||
RoutingKeyVerificationCodeIssued = "iam.user.verification_code_issued"
|
||||
RoutingKeyPasswordResetRequested = "iam.user.password_reset_requested"
|
||||
RoutingKeyUserRegistered = "iam.user.registered"
|
||||
RoutingKeyUserEmailVerified = "iam.user.email_verified"
|
||||
|
||||
// Published by billing (services/billing).
|
||||
RoutingKeySubscriptionChanged = "billing.subscription.changed"
|
||||
|
||||
// Published by conversation (services/conversation) — not consumed
|
||||
// by anything in this port of the monolith, but kept so a future
|
||||
// consumer (search indexing, analytics, audit) has something to
|
||||
// bind to without a producer-side change.
|
||||
RoutingKeyMessageCreated = "conversation.message.created"
|
||||
)
|
||||
|
||||
// QueueBinding names the durable queue a consumer should declare and
|
||||
// the routing key(s) it should bind, so every consumer's topology is
|
||||
// declared in one place next to the keys it reads.
|
||||
type QueueBinding struct {
|
||||
Queue string
|
||||
RoutingKeys []string
|
||||
}
|
||||
|
||||
// NotificationQueueBindings is the notification service's subscription
|
||||
// (services/notification/internal/infrastructure/eventing) — every
|
||||
// event that results in an outbound email.
|
||||
var NotificationQueueBindings = QueueBinding{
|
||||
Queue: "enkly.notification.verification_and_reset",
|
||||
RoutingKeys: []string{
|
||||
RoutingKeyVerificationCodeIssued,
|
||||
RoutingKeyPasswordResetRequested,
|
||||
},
|
||||
}
|
||||
24
pkg/grpcerr/grpcerr.go
Normal file
24
pkg/grpcerr/grpcerr.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package grpcerr has the handful of status-coded error constructors
|
||||
// every service's interfaces/grpc package uses, so "not found" maps to
|
||||
// codes.NotFound the same way in all eight of them instead of each
|
||||
// service inventing its own convention.
|
||||
package grpcerr
|
||||
|
||||
import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func NotFound(what string) error { return status.Errorf(codes.NotFound, "%s not found", what) }
|
||||
func InvalidArgument(msg string) error { return status.Error(codes.InvalidArgument, msg) }
|
||||
func FailedPrecondition(msg string) error { return status.Error(codes.FailedPrecondition, msg) }
|
||||
func ResourceExhausted(msg string) error { return status.Error(codes.ResourceExhausted, msg) }
|
||||
func Internal(err error) error { return status.Error(codes.Internal, err.Error()) }
|
||||
func Unauthenticated(msg string) error { return status.Error(codes.Unauthenticated, msg) }
|
||||
|
||||
// Unavailable maps a dependency that is reachable-in-principle but not
|
||||
// reachable right now (a vLLM deployment stuck Pending/restarting, a
|
||||
// downstream service mid-rollout) onto codes.Unavailable instead of
|
||||
// codes.Internal — gRPC clients and well-behaved gateways treat
|
||||
// Unavailable as safe-to-retry, which an opaque Internal never is.
|
||||
func Unavailable(err error) error { return status.Error(codes.Unavailable, err.Error()) }
|
||||
26
pkg/i18n/embed.go
Normal file
26
pkg/i18n/embed.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFiles embed.FS
|
||||
|
||||
// catalogFS is the raw JSON for each locale in Supported, read once at
|
||||
// package init time (see i18n.go's init). Keeping the embed.FS read
|
||||
// here, separate from the JSON-decoding in init, means adding a
|
||||
// locale is purely data: list it in Supported above, drop its file
|
||||
// under locales/, and this loop picks it up — no new Go code.
|
||||
var catalogFS = func() map[Locale][]byte {
|
||||
out := make(map[Locale][]byte, len(Supported))
|
||||
for _, info := range Supported {
|
||||
data, err := localeFiles.ReadFile(fmt.Sprintf("locales/%s.json", info.Code))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("i18n: locale %q listed in Supported but locales/%s.json is missing: %v", info.Code, info.Code, err))
|
||||
}
|
||||
out[info.Code] = data
|
||||
}
|
||||
return out
|
||||
}()
|
||||
166
pkg/i18n/i18n.go
Normal file
166
pkg/i18n/i18n.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Package i18n is enkly's shared translation catalog: the single
|
||||
// source of truth, for every service, of which UI languages exist and
|
||||
// what their strings are. It is deliberately tiny and dependency-free
|
||||
// (encoding/json + embed, nothing else) so both the gateway (which
|
||||
// renders the HTML chrome a visitor reads) and the agent service
|
||||
// (which needs the same locale codes and display names to tell the
|
||||
// model which language the visitor has chosen — see
|
||||
// agentrun.WithPreferredLanguage) can import it without pulling either
|
||||
// service into the other's build.
|
||||
//
|
||||
// This is UI-chrome localization — buttons, labels, system-generated
|
||||
// notices ("you've reached today's message limit") — not a substitute
|
||||
// for the agent's own language behavior: the model already detects
|
||||
// and mirrors whatever language a user types in conversation (see
|
||||
// agentrun.SystemPrompt's "## Language" section). The two compose: a
|
||||
// visitor who has never touched the language switcher still gets
|
||||
// replies in their own language because the model notices; a visitor
|
||||
// who *has* set a UI language gets the chrome in that language too,
|
||||
// and the agent treats it as their standing preference (see
|
||||
// agentrun.WithPreferredLanguage's doc comment for exactly how the
|
||||
// two signals are reconciled when they disagree).
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Locale is a BCP-47-ish language tag — in practice just the short
|
||||
// codes listed in Supported below ("en", "fr", …). It's a defined
|
||||
// type rather than a bare string so a caller can't accidentally pass
|
||||
// a translation key where a locale was expected, or vice versa.
|
||||
type Locale string
|
||||
|
||||
// Default is used whenever no locale can be determined at all (no
|
||||
// cookie, no Accept-Language header, no account preference) and as
|
||||
// the fallback catalog for any key missing from a more specific
|
||||
// locale's translation file.
|
||||
const Default Locale = "en"
|
||||
|
||||
// Info is one supported locale's code and its own display name (in
|
||||
// that language, not in the viewer's — "Français", not "French" —
|
||||
// which is how every language picker from a browser's own settings to
|
||||
// Claude Code's `/language` command does it).
|
||||
type Info struct {
|
||||
Code Locale
|
||||
Name string
|
||||
}
|
||||
|
||||
// Supported lists every locale enkly ships a catalog for, in the
|
||||
// order a language switcher should present them. Adding a language is
|
||||
// a two-line change: a new entry here, naming the file added under
|
||||
// locales/, and the entry in catalogs below the embed directive.
|
||||
//
|
||||
// enkly's first deployments are French West African (see the FCFA
|
||||
// pricing in the billing service), so French ships complete; the rest
|
||||
// cover enough of the chrome (nav, composer, auth, errors) to be
|
||||
// usable, falling back to English for anything not yet translated —
|
||||
// see T's doc comment. Extending any of them to full coverage is a
|
||||
// matter of filling in locales/<code>.json, not touching any Go code.
|
||||
var Supported = []Info{
|
||||
{Code: "en", Name: "English"},
|
||||
{Code: "fr", Name: "Français"},
|
||||
{Code: "es", Name: "Español"},
|
||||
{Code: "de", Name: "Deutsch"},
|
||||
{Code: "pt", Name: "Português"},
|
||||
}
|
||||
|
||||
// IsSupported reports whether code names a locale enkly ships a
|
||||
// catalog for.
|
||||
func IsSupported(code string) bool {
|
||||
for _, info := range Supported {
|
||||
if string(info.Code) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseLocale returns code as a Locale if it's one enkly supports,
|
||||
// else Default. Use this anywhere an untrusted string (a cookie
|
||||
// value, a query parameter, one tag out of an Accept-Language header)
|
||||
// needs to become a Locale — it never returns an unsupported value.
|
||||
func ParseLocale(code string) Locale {
|
||||
if IsSupported(code) {
|
||||
return Locale(code)
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// NameOf returns a locale's own display name ("Français" for "fr"),
|
||||
// or its bare code if it isn't one of Supported (shouldn't happen for
|
||||
// a Locale that came from ParseLocale, but T and callers that build a
|
||||
// prompt string from a Locale value want something printable either
|
||||
// way).
|
||||
func NameOf(l Locale) string {
|
||||
for _, info := range Supported {
|
||||
if info.Code == l {
|
||||
return info.Name
|
||||
}
|
||||
}
|
||||
return string(l)
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
missLog = map[string]bool{} // "locale/key" already warned about, so a hot template doesn't spam the log
|
||||
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
catalogs map[Locale]map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
catalogs = make(map[Locale]map[string]string, len(catalogFS))
|
||||
for locale, raw := range catalogFS {
|
||||
var strings map[string]string
|
||||
if err := json.Unmarshal(raw, &strings); err != nil {
|
||||
// A malformed embedded catalog is a build-time mistake, not
|
||||
// a runtime condition any caller can recover from — fail
|
||||
// loudly at process start rather than silently serving
|
||||
// blank strings in production.
|
||||
panic(fmt.Sprintf("i18n: locale %q: invalid catalog JSON: %v", locale, err))
|
||||
}
|
||||
catalogs[locale] = strings
|
||||
}
|
||||
}
|
||||
|
||||
// T looks up key in locale's catalog and, if args is non-empty, runs
|
||||
// the result through fmt.Sprintf (so a catalog entry can contain %s /
|
||||
// %d verbs the same way Go source would). A key missing from locale
|
||||
// falls back to the Default catalog, and a key missing from Default
|
||||
// too falls back to the key itself — so a template that references a
|
||||
// not-yet-translated string degrades to showing its own key (an
|
||||
// unmistakable "translate me" placeholder during development) instead
|
||||
// of a blank space or a panic in production. Each distinct miss is
|
||||
// logged once per process, not once per request.
|
||||
func T(locale Locale, key string, args ...any) string {
|
||||
mu.RLock()
|
||||
s, ok := catalogs[locale][key]
|
||||
if !ok {
|
||||
s, ok = catalogs[Default][key]
|
||||
}
|
||||
mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
warnMissing(locale, key)
|
||||
s = key
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
|
||||
func warnMissing(locale Locale, key string) {
|
||||
id := string(locale) + "/" + key
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if missLog[id] {
|
||||
return
|
||||
}
|
||||
missLog[id] = true
|
||||
logger.Warn("i18n: missing translation key", "locale", locale, "key", key)
|
||||
}
|
||||
54
pkg/i18n/i18n_test.go
Normal file
54
pkg/i18n/i18n_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSupportedLocalesLoad(t *testing.T) {
|
||||
for _, info := range Supported {
|
||||
cat, ok := catalogs[info.Code]
|
||||
if !ok || len(cat) == 0 {
|
||||
t.Fatalf("locale %q: catalog missing or empty", info.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishHasEveryKeyUsedElsewhere(t *testing.T) {
|
||||
// English is the fallback catalog (see T's doc comment), so every
|
||||
// key any other locale defines must also exist in English — a
|
||||
// locale-only key would be silently unreachable from the fallback
|
||||
// path and is almost certainly a typo.
|
||||
en := catalogs[Default]
|
||||
for _, info := range Supported {
|
||||
if info.Code == Default {
|
||||
continue
|
||||
}
|
||||
for key := range catalogs[info.Code] {
|
||||
if _, ok := en[key]; !ok {
|
||||
t.Errorf("locale %q defines key %q that is missing from %q (the fallback catalog)", info.Code, key, Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFallsBackToEnglishThenToKey(t *testing.T) {
|
||||
if got := T("fr", "nav.new_chat"); got != "Nouvelle discussion" {
|
||||
t.Errorf("fr nav.new_chat = %q", got)
|
||||
}
|
||||
// "nav.delete" only exists in a handful of locales' full set; pick
|
||||
// a locale/key combination guaranteed to be English-only to prove
|
||||
// the fallback path, without hard-coding which keys those are.
|
||||
if got := T("es", "billing.quota_exceeded", "Pro", 200); got == "billing.quota_exceeded" {
|
||||
t.Errorf("expected fallback to the English catalog, got the raw key back")
|
||||
}
|
||||
if got := T(Default, "this.key.does.not.exist"); got != "this.key.does.not.exist" {
|
||||
t.Errorf("unknown key should fall back to itself, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocale(t *testing.T) {
|
||||
if got := ParseLocale("fr"); got != "fr" {
|
||||
t.Errorf("ParseLocale(fr) = %q", got)
|
||||
}
|
||||
if got := ParseLocale("xx-not-a-locale"); got != Default {
|
||||
t.Errorf("ParseLocale(unsupported) = %q, want Default", got)
|
||||
}
|
||||
}
|
||||
74
pkg/i18n/locales/de.json
Normal file
74
pkg/i18n/locales/de.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Neuer Chat",
|
||||
"nav.conversations": "Unterhaltungen",
|
||||
"nav.no_conversations": "Noch keine Unterhaltungen.",
|
||||
"nav.plan_suffix": "Tarif",
|
||||
"nav.manage": "Verwalten",
|
||||
"nav.log_out": "Abmelden",
|
||||
"nav.theme_light": "Hell",
|
||||
"nav.theme_dark": "Dunkel",
|
||||
"nav.theme_auto": "Automatisch",
|
||||
"nav.language": "Sprache",
|
||||
"nav.search_placeholder": "Chats, Dateien, Aktionen durchsuchen…",
|
||||
"nav.agent_ready": "Agent bereit",
|
||||
"nav.rename_save": "Namen speichern",
|
||||
"nav.delete": "Löschen",
|
||||
"nav.close_sidebar": "Seitenleiste schließen",
|
||||
"nav.open_sidebar": "Seitenleiste öffnen",
|
||||
|
||||
"hero.title": "Was bauen wir heute?",
|
||||
"hero.subtitle": "enkly arbeitet direkt in deinem Arbeitsbereich: liest, durchsucht, bearbeitet und führt Code aus und zeigt jeden Schritt.",
|
||||
"hero.start_with": "Beginne mit",
|
||||
"hero.tools_heading": "Was enkly nutzen kann",
|
||||
"hero.explore_heading": "Mehr entdecken",
|
||||
|
||||
"starter.tour_title": "Arbeitsbereich erkunden",
|
||||
"starter.tour_desc": "Ordner, Dienste und Einstiegspunkte kartieren.",
|
||||
"starter.trace_title": "Eine Funktion verfolgen",
|
||||
"starter.trace_desc": "Eine Anfrage durch den Code verfolgen.",
|
||||
"starter.fix_title": "Einen Fehler beheben",
|
||||
"starter.fix_desc": "Beschreibe ihn, enkly findet und behebt ihn.",
|
||||
"starter.tests_title": "Tests ausführen",
|
||||
"starter.tests_desc": "Testsuite ausführen und Fehlschläge erklären.",
|
||||
|
||||
"tool.read": "Dateien lesen",
|
||||
"tool.write": "Schreiben und bearbeiten",
|
||||
"tool.search": "Codebasis durchsuchen",
|
||||
"tool.run": "Erlaubte Befehle ausführen",
|
||||
"tool.subagents": "Subagenten",
|
||||
|
||||
"explore.tests": "Tests schreiben für…",
|
||||
"explore.review": "Code überprüfen",
|
||||
"explore.explain": "Eine Datei erklären",
|
||||
"explore.document": "Ein Dokument entwerfen",
|
||||
"explore.refactor": "Refaktorisieren",
|
||||
|
||||
"composer.placeholder": "Bitte enkly, etwas zu bauen, zu reparieren oder zu erklären…",
|
||||
"composer.send": "Senden",
|
||||
"composer.attach": "Dateien oder Fotos hinzufügen",
|
||||
"composer.hint_send": "senden",
|
||||
"composer.hint_newline": "neue Zeile",
|
||||
"composer.hint_drop": "Dateien ablegen, Screenshots einfügen oder ein .zip-Projekt hinzufügen",
|
||||
|
||||
"auth.login_title": "Anmelden",
|
||||
"auth.login_button": "Anmelden",
|
||||
"auth.email_label": "E-Mail",
|
||||
"auth.password_label": "Passwort",
|
||||
"auth.confirm_password_label": "Passwort bestätigen",
|
||||
"auth.no_account": "Noch kein Konto?",
|
||||
"auth.signup_link": "Registrieren",
|
||||
"auth.signup_title": "Konto erstellen",
|
||||
"auth.signup_button": "Registrieren",
|
||||
"auth.have_account": "Bereits ein Konto?",
|
||||
"auth.login_link": "Anmelden",
|
||||
"auth.forgot_link": "Passwort vergessen?",
|
||||
"auth.verify_title": "E-Mail bestätigen",
|
||||
"auth.verify_code_label": "6-stelliger Code",
|
||||
"auth.verify_button": "Bestätigen",
|
||||
"auth.resend_link": "Code erneut senden",
|
||||
"auth.forgot_title": "Passwort zurücksetzen",
|
||||
"auth.forgot_button": "Link zum Zurücksetzen senden",
|
||||
"auth.back_to_login": "Zurück zur Anmeldung",
|
||||
"auth.reset_title": "Neues Passwort wählen",
|
||||
"auth.reset_button": "Passwort zurücksetzen"
|
||||
}
|
||||
104
pkg/i18n/locales/en.json
Normal file
104
pkg/i18n/locales/en.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"nav.new_chat": "New chat",
|
||||
"nav.conversations": "Conversations",
|
||||
"nav.no_conversations": "No conversations yet.",
|
||||
"nav.plan_suffix": "plan",
|
||||
"nav.manage": "Manage",
|
||||
"nav.log_out": "Log out",
|
||||
"nav.theme_light": "Light",
|
||||
"nav.theme_dark": "Dark",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Language",
|
||||
"nav.search_placeholder": "Search chats, files, actions…",
|
||||
"nav.agent_ready": "Agent ready",
|
||||
"nav.rename_save": "Save name",
|
||||
"nav.delete": "Delete",
|
||||
"nav.close_sidebar": "Close sidebar",
|
||||
"nav.open_sidebar": "Open sidebar",
|
||||
|
||||
"hero.title": "What should we build today?",
|
||||
"hero.subtitle": "enkly works inside your workspace: it reads, searches, edits and runs code, then shows every step.",
|
||||
"hero.start_with": "Start with",
|
||||
"hero.tools_heading": "What enkly can use",
|
||||
"hero.explore_heading": "More to explore",
|
||||
|
||||
"starter.tour_title": "Tour this workspace",
|
||||
"starter.tour_desc": "Map the folders, services and entry points.",
|
||||
"starter.trace_title": "Trace a feature",
|
||||
"starter.trace_desc": "Follow a request through the codebase.",
|
||||
"starter.fix_title": "Fix a bug",
|
||||
"starter.fix_desc": "Describe it, enkly finds and patches it.",
|
||||
"starter.tests_title": "Run the tests",
|
||||
"starter.tests_desc": "Execute the suite and explain what broke.",
|
||||
|
||||
"tool.read": "Read files",
|
||||
"tool.write": "Write and edit",
|
||||
"tool.search": "Search the codebase",
|
||||
"tool.run": "Run allowlisted commands",
|
||||
"tool.subagents": "Sub-agents",
|
||||
|
||||
"explore.tests": "Write tests for…",
|
||||
"explore.review": "Review code",
|
||||
"explore.explain": "Explain a file",
|
||||
"explore.document": "Draft a document",
|
||||
"explore.refactor": "Refactor",
|
||||
|
||||
"composer.placeholder": "Ask enkly to build, fix, or explain anything…",
|
||||
"composer.send": "Send",
|
||||
"composer.attach": "Add files or photos",
|
||||
"composer.hint_send": "send",
|
||||
"composer.hint_newline": "new line",
|
||||
"composer.hint_drop": "Drop files, paste screenshots or add a .zip project",
|
||||
|
||||
"auth.login_title": "Log in",
|
||||
"auth.login_button": "Log in",
|
||||
"auth.email_label": "Email",
|
||||
"auth.password_label": "Password",
|
||||
"auth.confirm_password_label": "Confirm password",
|
||||
"auth.no_account": "Don't have an account?",
|
||||
"auth.signup_link": "Sign up",
|
||||
"auth.signup_title": "Create your account",
|
||||
"auth.signup_button": "Sign up",
|
||||
"auth.have_account": "Already have an account?",
|
||||
"auth.login_link": "Log in",
|
||||
"auth.forgot_link": "Forgot your password?",
|
||||
"auth.verify_title": "Verify your email",
|
||||
"auth.verify_code_label": "6-digit code",
|
||||
"auth.verify_button": "Verify",
|
||||
"auth.resend_link": "Resend code",
|
||||
"auth.forgot_title": "Reset your password",
|
||||
"auth.forgot_button": "Send reset link",
|
||||
"auth.back_to_login": "Back to log in",
|
||||
"auth.reset_title": "Choose a new password",
|
||||
"auth.reset_button": "Reset password",
|
||||
|
||||
"errors.generic": "Something went wrong. Please try again.",
|
||||
"errors.invalid_email": "Enter a valid email address.",
|
||||
"errors.password_min": "Password must be at least %d characters.",
|
||||
"errors.password_max": "Password must be at most %d characters.",
|
||||
"errors.password_mismatch": "Passwords don't match.",
|
||||
"errors.incorrect_credentials": "Incorrect email or password.",
|
||||
"errors.invalid_code": "Enter the 6-digit code.",
|
||||
"errors.code_incorrect": "That code is incorrect or has expired.",
|
||||
"errors.reset_link_invalid": "That reset link is invalid or has expired. Request a new one.",
|
||||
"errors.csrf": "Invalid or missing security token — reload the page and try again.",
|
||||
|
||||
"info.check_inbox": "Check your inbox for a 6-digit verification code.",
|
||||
"info.mail_not_configured": "Email isn't configured on this server — check the server log for your verification code.",
|
||||
"info.resend_sent": "If that account needs verifying, a new code is on its way.",
|
||||
"info.email_verified": "Your email is verified — log in to continue.",
|
||||
"info.password_reset_done": "Your password has been reset — log in with your new password.",
|
||||
"info.forgot_sent": "If that address has an account, a reset link is on its way.",
|
||||
|
||||
"chat.type_message_first": "Type a message first.",
|
||||
"chat.message_too_long": "That message is too long (limit %d characters).",
|
||||
"chat.rate_limited": "Too many requests — slow down a little.",
|
||||
"chat.conversation_gone": "That conversation no longer exists. Start a new chat.",
|
||||
"chat.conversation_busy": "enkly is still replying in this conversation — wait for it to finish, or press Stop.",
|
||||
"chat.too_many_streams": "You have several replies in progress already. Wait for one to finish or press Stop.",
|
||||
"chat.save_failed": "Couldn't save your message. Please try again.",
|
||||
"chat.load_failed": "Couldn't load the conversation. Please try again.",
|
||||
"chat.send_failed": "Something went wrong. Please try again.",
|
||||
|
||||
"billing.quota_exceeded": "You've reached the %s plan's limit of %d messages today. Upgrade on the Subscription page for more."
|
||||
}
|
||||
74
pkg/i18n/locales/es.json
Normal file
74
pkg/i18n/locales/es.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Nueva conversación",
|
||||
"nav.conversations": "Conversaciones",
|
||||
"nav.no_conversations": "Aún no hay conversaciones.",
|
||||
"nav.plan_suffix": "plan",
|
||||
"nav.manage": "Gestionar",
|
||||
"nav.log_out": "Cerrar sesión",
|
||||
"nav.theme_light": "Claro",
|
||||
"nav.theme_dark": "Oscuro",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Idioma",
|
||||
"nav.search_placeholder": "Buscar chats, archivos, acciones…",
|
||||
"nav.agent_ready": "Agente listo",
|
||||
"nav.rename_save": "Guardar nombre",
|
||||
"nav.delete": "Eliminar",
|
||||
"nav.close_sidebar": "Cerrar barra lateral",
|
||||
"nav.open_sidebar": "Abrir barra lateral",
|
||||
|
||||
"hero.title": "¿Qué construimos hoy?",
|
||||
"hero.subtitle": "enkly trabaja dentro de tu espacio de trabajo: lee, busca, edita y ejecuta código, y muestra cada paso.",
|
||||
"hero.start_with": "Empieza con",
|
||||
"hero.tools_heading": "Lo que enkly puede usar",
|
||||
"hero.explore_heading": "Más para explorar",
|
||||
|
||||
"starter.tour_title": "Recorrer este espacio de trabajo",
|
||||
"starter.tour_desc": "Mapea las carpetas, servicios y puntos de entrada.",
|
||||
"starter.trace_title": "Rastrear una funcionalidad",
|
||||
"starter.trace_desc": "Sigue una solicitud a través del código.",
|
||||
"starter.fix_title": "Corregir un error",
|
||||
"starter.fix_desc": "Descríbelo y enkly lo encuentra y lo corrige.",
|
||||
"starter.tests_title": "Ejecutar las pruebas",
|
||||
"starter.tests_desc": "Ejecuta la suite y explica qué falló.",
|
||||
|
||||
"tool.read": "Leer archivos",
|
||||
"tool.write": "Escribir y editar",
|
||||
"tool.search": "Buscar en el código",
|
||||
"tool.run": "Ejecutar comandos permitidos",
|
||||
"tool.subagents": "Subagentes",
|
||||
|
||||
"explore.tests": "Escribir pruebas para…",
|
||||
"explore.review": "Revisar código",
|
||||
"explore.explain": "Explicar un archivo",
|
||||
"explore.document": "Redactar un documento",
|
||||
"explore.refactor": "Refactorizar",
|
||||
|
||||
"composer.placeholder": "Pide a enkly que construya, corrija o explique cualquier cosa…",
|
||||
"composer.send": "Enviar",
|
||||
"composer.attach": "Añadir archivos o fotos",
|
||||
"composer.hint_send": "enviar",
|
||||
"composer.hint_newline": "nueva línea",
|
||||
"composer.hint_drop": "Suelta archivos, pega capturas de pantalla o añade un proyecto .zip",
|
||||
|
||||
"auth.login_title": "Iniciar sesión",
|
||||
"auth.login_button": "Iniciar sesión",
|
||||
"auth.email_label": "Correo electrónico",
|
||||
"auth.password_label": "Contraseña",
|
||||
"auth.confirm_password_label": "Confirmar contraseña",
|
||||
"auth.no_account": "¿No tienes una cuenta?",
|
||||
"auth.signup_link": "Regístrate",
|
||||
"auth.signup_title": "Crea tu cuenta",
|
||||
"auth.signup_button": "Registrarse",
|
||||
"auth.have_account": "¿Ya tienes una cuenta?",
|
||||
"auth.login_link": "Iniciar sesión",
|
||||
"auth.forgot_link": "¿Olvidaste tu contraseña?",
|
||||
"auth.verify_title": "Verifica tu correo",
|
||||
"auth.verify_code_label": "Código de 6 dígitos",
|
||||
"auth.verify_button": "Verificar",
|
||||
"auth.resend_link": "Reenviar código",
|
||||
"auth.forgot_title": "Restablece tu contraseña",
|
||||
"auth.forgot_button": "Enviar enlace de restablecimiento",
|
||||
"auth.back_to_login": "Volver a iniciar sesión",
|
||||
"auth.reset_title": "Elige una nueva contraseña",
|
||||
"auth.reset_button": "Restablecer contraseña"
|
||||
}
|
||||
104
pkg/i18n/locales/fr.json
Normal file
104
pkg/i18n/locales/fr.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"nav.new_chat": "Nouvelle discussion",
|
||||
"nav.conversations": "Conversations",
|
||||
"nav.no_conversations": "Aucune conversation pour l'instant.",
|
||||
"nav.plan_suffix": "forfait",
|
||||
"nav.manage": "Gérer",
|
||||
"nav.log_out": "Déconnexion",
|
||||
"nav.theme_light": "Clair",
|
||||
"nav.theme_dark": "Sombre",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Langue",
|
||||
"nav.search_placeholder": "Rechercher des discussions, fichiers, actions…",
|
||||
"nav.agent_ready": "Agent prêt",
|
||||
"nav.rename_save": "Enregistrer le nom",
|
||||
"nav.delete": "Supprimer",
|
||||
"nav.close_sidebar": "Fermer la barre latérale",
|
||||
"nav.open_sidebar": "Ouvrir la barre latérale",
|
||||
|
||||
"hero.title": "Que devons-nous construire aujourd'hui ?",
|
||||
"hero.subtitle": "enkly travaille directement dans votre espace de travail : il lit, recherche, modifie et exécute du code, puis affiche chaque étape.",
|
||||
"hero.start_with": "Commencer par",
|
||||
"hero.tools_heading": "Ce qu'enkly peut utiliser",
|
||||
"hero.explore_heading": "À explorer",
|
||||
|
||||
"starter.tour_title": "Visiter cet espace de travail",
|
||||
"starter.tour_desc": "Cartographier les dossiers, services et points d'entrée.",
|
||||
"starter.trace_title": "Suivre une fonctionnalité",
|
||||
"starter.trace_desc": "Suivre une requête à travers le code.",
|
||||
"starter.fix_title": "Corriger un bug",
|
||||
"starter.fix_desc": "Décrivez-le, enkly le trouve et le corrige.",
|
||||
"starter.tests_title": "Lancer les tests",
|
||||
"starter.tests_desc": "Exécuter la suite de tests et expliquer les échecs.",
|
||||
|
||||
"tool.read": "Lire des fichiers",
|
||||
"tool.write": "Écrire et modifier",
|
||||
"tool.search": "Rechercher dans le code",
|
||||
"tool.run": "Exécuter des commandes autorisées",
|
||||
"tool.subagents": "Sous-agents",
|
||||
|
||||
"explore.tests": "Écrire des tests pour…",
|
||||
"explore.review": "Relire du code",
|
||||
"explore.explain": "Expliquer un fichier",
|
||||
"explore.document": "Rédiger un document",
|
||||
"explore.refactor": "Refactoriser",
|
||||
|
||||
"composer.placeholder": "Demandez à enkly de construire, corriger ou expliquer n'importe quoi…",
|
||||
"composer.send": "Envoyer",
|
||||
"composer.attach": "Ajouter des fichiers ou des photos",
|
||||
"composer.hint_send": "envoyer",
|
||||
"composer.hint_newline": "nouvelle ligne",
|
||||
"composer.hint_drop": "Déposez des fichiers, collez des captures d'écran ou ajoutez un projet .zip",
|
||||
|
||||
"auth.login_title": "Se connecter",
|
||||
"auth.login_button": "Se connecter",
|
||||
"auth.email_label": "E-mail",
|
||||
"auth.password_label": "Mot de passe",
|
||||
"auth.confirm_password_label": "Confirmer le mot de passe",
|
||||
"auth.no_account": "Vous n'avez pas de compte ?",
|
||||
"auth.signup_link": "S'inscrire",
|
||||
"auth.signup_title": "Créer votre compte",
|
||||
"auth.signup_button": "S'inscrire",
|
||||
"auth.have_account": "Vous avez déjà un compte ?",
|
||||
"auth.login_link": "Se connecter",
|
||||
"auth.forgot_link": "Mot de passe oublié ?",
|
||||
"auth.verify_title": "Vérifiez votre e-mail",
|
||||
"auth.verify_code_label": "Code à 6 chiffres",
|
||||
"auth.verify_button": "Vérifier",
|
||||
"auth.resend_link": "Renvoyer le code",
|
||||
"auth.forgot_title": "Réinitialiser votre mot de passe",
|
||||
"auth.forgot_button": "Envoyer le lien de réinitialisation",
|
||||
"auth.back_to_login": "Retour à la connexion",
|
||||
"auth.reset_title": "Choisissez un nouveau mot de passe",
|
||||
"auth.reset_button": "Réinitialiser le mot de passe",
|
||||
|
||||
"errors.generic": "Une erreur est survenue. Veuillez réessayer.",
|
||||
"errors.invalid_email": "Saisissez une adresse e-mail valide.",
|
||||
"errors.password_min": "Le mot de passe doit comporter au moins %d caractères.",
|
||||
"errors.password_max": "Le mot de passe doit comporter au plus %d caractères.",
|
||||
"errors.password_mismatch": "Les mots de passe ne correspondent pas.",
|
||||
"errors.incorrect_credentials": "E-mail ou mot de passe incorrect.",
|
||||
"errors.invalid_code": "Saisissez le code à 6 chiffres.",
|
||||
"errors.code_incorrect": "Ce code est incorrect ou a expiré.",
|
||||
"errors.reset_link_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau.",
|
||||
"errors.csrf": "Jeton de sécurité invalide ou manquant — rechargez la page et réessayez.",
|
||||
|
||||
"info.check_inbox": "Consultez votre boîte de réception pour le code de vérification à 6 chiffres.",
|
||||
"info.mail_not_configured": "La messagerie n'est pas configurée sur ce serveur — consultez le journal du serveur pour votre code de vérification.",
|
||||
"info.resend_sent": "Si ce compte doit être vérifié, un nouveau code est en route.",
|
||||
"info.email_verified": "Votre e-mail est vérifié — connectez-vous pour continuer.",
|
||||
"info.password_reset_done": "Votre mot de passe a été réinitialisé — connectez-vous avec votre nouveau mot de passe.",
|
||||
"info.forgot_sent": "Si cette adresse possède un compte, un lien de réinitialisation est en route.",
|
||||
|
||||
"chat.type_message_first": "Saisissez d'abord un message.",
|
||||
"chat.message_too_long": "Ce message est trop long (limite de %d caractères).",
|
||||
"chat.rate_limited": "Trop de requêtes — ralentissez un peu.",
|
||||
"chat.conversation_gone": "Cette conversation n'existe plus. Démarrez une nouvelle discussion.",
|
||||
"chat.conversation_busy": "enkly est encore en train de répondre dans cette conversation — attendez la fin, ou appuyez sur Arrêter.",
|
||||
"chat.too_many_streams": "Vous avez déjà plusieurs réponses en cours. Attendez qu'une se termine ou appuyez sur Arrêter.",
|
||||
"chat.save_failed": "Impossible d'enregistrer votre message. Veuillez réessayer.",
|
||||
"chat.load_failed": "Impossible de charger la conversation. Veuillez réessayer.",
|
||||
"chat.send_failed": "Une erreur est survenue. Veuillez réessayer.",
|
||||
|
||||
"billing.quota_exceeded": "Vous avez atteint la limite du forfait %s : %d messages par jour. Passez à un forfait supérieur depuis la page Abonnement pour en obtenir plus."
|
||||
}
|
||||
74
pkg/i18n/locales/pt.json
Normal file
74
pkg/i18n/locales/pt.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Nova conversa",
|
||||
"nav.conversations": "Conversas",
|
||||
"nav.no_conversations": "Ainda não há conversas.",
|
||||
"nav.plan_suffix": "plano",
|
||||
"nav.manage": "Gerenciar",
|
||||
"nav.log_out": "Sair",
|
||||
"nav.theme_light": "Claro",
|
||||
"nav.theme_dark": "Escuro",
|
||||
"nav.theme_auto": "Automático",
|
||||
"nav.language": "Idioma",
|
||||
"nav.search_placeholder": "Pesquisar conversas, arquivos, ações…",
|
||||
"nav.agent_ready": "Agente pronto",
|
||||
"nav.rename_save": "Salvar nome",
|
||||
"nav.delete": "Excluir",
|
||||
"nav.close_sidebar": "Fechar barra lateral",
|
||||
"nav.open_sidebar": "Abrir barra lateral",
|
||||
|
||||
"hero.title": "O que vamos construir hoje?",
|
||||
"hero.subtitle": "enkly trabalha dentro do seu espaço de trabalho: lê, pesquisa, edita e executa código, mostrando cada etapa.",
|
||||
"hero.start_with": "Comece com",
|
||||
"hero.tools_heading": "O que o enkly pode usar",
|
||||
"hero.explore_heading": "Mais para explorar",
|
||||
|
||||
"starter.tour_title": "Conhecer este espaço de trabalho",
|
||||
"starter.tour_desc": "Mapeie as pastas, serviços e pontos de entrada.",
|
||||
"starter.trace_title": "Rastrear uma funcionalidade",
|
||||
"starter.trace_desc": "Acompanhe uma requisição pelo código.",
|
||||
"starter.fix_title": "Corrigir um bug",
|
||||
"starter.fix_desc": "Descreva-o, o enkly encontra e corrige.",
|
||||
"starter.tests_title": "Executar os testes",
|
||||
"starter.tests_desc": "Execute a suíte e explique o que falhou.",
|
||||
|
||||
"tool.read": "Ler arquivos",
|
||||
"tool.write": "Escrever e editar",
|
||||
"tool.search": "Pesquisar no código",
|
||||
"tool.run": "Executar comandos permitidos",
|
||||
"tool.subagents": "Subagentes",
|
||||
|
||||
"explore.tests": "Escrever testes para…",
|
||||
"explore.review": "Revisar código",
|
||||
"explore.explain": "Explicar um arquivo",
|
||||
"explore.document": "Redigir um documento",
|
||||
"explore.refactor": "Refatorar",
|
||||
|
||||
"composer.placeholder": "Peça ao enkly para construir, corrigir ou explicar qualquer coisa…",
|
||||
"composer.send": "Enviar",
|
||||
"composer.attach": "Adicionar arquivos ou fotos",
|
||||
"composer.hint_send": "enviar",
|
||||
"composer.hint_newline": "nova linha",
|
||||
"composer.hint_drop": "Solte arquivos, cole capturas de tela ou adicione um projeto .zip",
|
||||
|
||||
"auth.login_title": "Entrar",
|
||||
"auth.login_button": "Entrar",
|
||||
"auth.email_label": "E-mail",
|
||||
"auth.password_label": "Senha",
|
||||
"auth.confirm_password_label": "Confirmar senha",
|
||||
"auth.no_account": "Não tem uma conta?",
|
||||
"auth.signup_link": "Cadastre-se",
|
||||
"auth.signup_title": "Crie sua conta",
|
||||
"auth.signup_button": "Cadastrar",
|
||||
"auth.have_account": "Já tem uma conta?",
|
||||
"auth.login_link": "Entrar",
|
||||
"auth.forgot_link": "Esqueceu sua senha?",
|
||||
"auth.verify_title": "Verifique seu e-mail",
|
||||
"auth.verify_code_label": "Código de 6 dígitos",
|
||||
"auth.verify_button": "Verificar",
|
||||
"auth.resend_link": "Reenviar código",
|
||||
"auth.forgot_title": "Redefinir sua senha",
|
||||
"auth.forgot_button": "Enviar link de redefinição",
|
||||
"auth.back_to_login": "Voltar para o login",
|
||||
"auth.reset_title": "Escolha uma nova senha",
|
||||
"auth.reset_button": "Redefinir senha"
|
||||
}
|
||||
64
pkg/i18n/negotiate.go
Normal file
64
pkg/i18n/negotiate.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// weightedTag is one entry of a parsed Accept-Language header.
|
||||
type weightedTag struct {
|
||||
primary string // "fr" out of "fr-FR"
|
||||
q float64
|
||||
}
|
||||
|
||||
// parseAcceptLanguage turns a raw "Accept-Language" header value (RFC
|
||||
// 9110 §12.5.4 — e.g. "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7") into its
|
||||
// tags' primary subtags, ordered by descending quality weight. A
|
||||
// malformed segment is skipped rather than aborting the whole parse —
|
||||
// browsers have been known to send odd values, and the worst case of
|
||||
// ignoring one bad segment is falling back to Default, never a panic
|
||||
// or a rejected request.
|
||||
func parseAcceptLanguage(header string) []weightedTag {
|
||||
var tags []weightedTag
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tag, qStr, hasQ := strings.Cut(part, ";")
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || tag == "*" {
|
||||
continue
|
||||
}
|
||||
q := 1.0
|
||||
if hasQ {
|
||||
qStr = strings.TrimSpace(qStr)
|
||||
if v, ok := strings.CutPrefix(qStr, "q="); ok {
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
q = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
primary, _, _ := strings.Cut(tag, "-")
|
||||
tags = append(tags, weightedTag{primary: strings.ToLower(primary), q: q})
|
||||
}
|
||||
sort.SliceStable(tags, func(i, j int) bool { return tags[i].q > tags[j].q })
|
||||
return tags
|
||||
}
|
||||
|
||||
// NegotiateAcceptLanguage picks the best Supported locale for a raw
|
||||
// "Accept-Language" header value, falling back to Default if nothing
|
||||
// in the header is one enkly ships a catalog for (or the header is
|
||||
// empty / unparseable). Callers only reach for this when there's no
|
||||
// stronger signal available — see the gateway's localeFrom, which
|
||||
// tries the enkly_lang cookie first and only falls through to this
|
||||
// for a visitor who has never touched the language switcher.
|
||||
func NegotiateAcceptLanguage(header string) Locale {
|
||||
for _, tag := range parseAcceptLanguage(header) {
|
||||
if IsSupported(tag.primary) {
|
||||
return Locale(tag.primary)
|
||||
}
|
||||
}
|
||||
return Default
|
||||
}
|
||||
20
pkg/i18n/negotiate_test.go
Normal file
20
pkg/i18n/negotiate_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNegotiateAcceptLanguage(t *testing.T) {
|
||||
cases := map[string]Locale{
|
||||
"": Default,
|
||||
"fr-FR,fr;q=0.9,en;q=0.8": "fr",
|
||||
"de;q=0.5,fr;q=0.9": "fr",
|
||||
"xx-XX,yy-YY": Default,
|
||||
"pt-BR": "pt",
|
||||
"en-US,en;q=0.9": "en",
|
||||
" , ;q=bogus , es-MX;q=0.4": "es",
|
||||
}
|
||||
for header, want := range cases {
|
||||
if got := NegotiateAcceptLanguage(header); got != want {
|
||||
t.Errorf("NegotiateAcceptLanguage(%q) = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
pkg/mailkit/mail.go
Normal file
173
pkg/mailkit/mail.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Package mail sends outgoing email (verification codes and password
|
||||
// reset links) over SMTP with STARTTLS — the scheme smtp.gmail.com
|
||||
// requires on port 587. It's built on net/smtp + crypto/tls directly
|
||||
// rather than a third-party mailer library, matching the rest of
|
||||
// enkly's minimal-dependencies stance (see go.mod).
|
||||
package mailkit
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds SMTP connection details, normally sourced from the
|
||||
// ENKLY_SMTP_* environment variables (see cmd/server/main.go and
|
||||
// .env.example). Host/Port default to Gmail's submission endpoint;
|
||||
// Username/Password should be a Gmail *App Password*
|
||||
// (https://myaccount.google.com/apppasswords), not the account's normal
|
||||
// login password — Gmail rejects plain-password SMTP auth for accounts
|
||||
// with 2FA, and discourages it even without 2FA.
|
||||
type Config struct {
|
||||
Host string // e.g. "smtp.gmail.com"
|
||||
Port string // e.g. "587"
|
||||
Username string // full Gmail address
|
||||
Password string // Gmail App Password
|
||||
// From is used for the message's From: header, and may be either a
|
||||
// bare address ("noreply@example.com") or a full RFC 5322 mailbox
|
||||
// with a display name ("enkly <noreply@example.com>") — Send picks
|
||||
// the bare address back out of either form for the SMTP envelope
|
||||
// (see envelopeAddress), since MAIL FROM must never be given a
|
||||
// display name. Defaults to Username if empty.
|
||||
From string
|
||||
}
|
||||
|
||||
// Mailer sends email via a configured SMTP server.
|
||||
type Mailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New builds a Mailer from cfg, filling in Gmail's defaults for any
|
||||
// blank Host/Port so a minimal .env (just username + app password)
|
||||
// works out of the box. Every field is also trimmed of surrounding
|
||||
// whitespace, including a stray trailing "\r" — a real failure mode
|
||||
// when an operator's .env or docker-compose.yml was saved with CRLF
|
||||
// line endings and the value reaches os.Getenv un-normalized: an
|
||||
// embedded "\r" in an SMTP command line reads to the server as a
|
||||
// premature line break, and Gmail answers with a "555 Syntax error"
|
||||
// that gives no hint the cause was whitespace.
|
||||
func New(cfg Config) *Mailer {
|
||||
cfg.Host = strings.TrimSpace(cfg.Host)
|
||||
cfg.Port = strings.TrimSpace(cfg.Port)
|
||||
cfg.Username = strings.TrimSpace(cfg.Username)
|
||||
cfg.Password = strings.TrimSpace(cfg.Password)
|
||||
cfg.From = strings.TrimSpace(cfg.From)
|
||||
if cfg.Host == "" {
|
||||
cfg.Host = "smtp.gmail.com"
|
||||
}
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "587"
|
||||
}
|
||||
if cfg.From == "" {
|
||||
cfg.From = cfg.Username
|
||||
}
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Configured reports whether enough SMTP settings are present to
|
||||
// actually send mail. main.go logs a warning instead of failing to
|
||||
// start when this is false, so the rest of the app (chat, RAG) keeps
|
||||
// working even if email hasn't been set up yet — signup will just fail
|
||||
// with a clear error until it is.
|
||||
func (m *Mailer) Configured() bool {
|
||||
return m.cfg.Username != "" && m.cfg.Password != ""
|
||||
}
|
||||
|
||||
// Send delivers a plain-text email to a single recipient, using
|
||||
// STARTTLS on the configured host/port (Gmail requires this on 587;
|
||||
// plaintext SMTP is refused). Uses net/smtp's low-level Client instead
|
||||
// of smtp.SendMail because SendMail only supports implicit TLS (port
|
||||
// 465) or no TLS at all, not STARTTLS-then-auth.
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
if !m.Configured() {
|
||||
return fmt.Errorf("mail: not configured (set ENKLY_SMTP_USERNAME / ENKLY_SMTP_PASSWORD)")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: dial %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, m.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: smtp handshake: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsConfig := &tls.Config{ServerName: m.cfg.Host}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("mail: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("mail: auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(envelopeAddress(m.cfg.From)); err != nil {
|
||||
return fmt.Errorf("mail: MAIL FROM: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(envelopeAddress(to)); err != nil {
|
||||
return fmt.Errorf("mail: RCPT TO: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: DATA: %w", err)
|
||||
}
|
||||
msg := buildMessage(m.cfg.From, to, subject, body)
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("mail: write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("mail: close body: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// buildMessage assembles a minimal RFC 5322 message. Headers are kept
|
||||
// deliberately simple (plain text, no MIME multipart) since enkly only
|
||||
// ever sends short verification emails.
|
||||
func buildMessage(from, to, subject, body string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", subject)
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(body)
|
||||
b.WriteString("\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// envelopeAddress reduces addr to the bare mailbox SMTP's envelope
|
||||
// commands require (RFC 5321 §4.1.2 reverse-/forward-path: an address
|
||||
// only, never a display name or angle brackets). addr may already be
|
||||
// bare ("user@example.com") or a full RFC 5322 mailbox with a display
|
||||
// name ("enkly <user@example.com>") — either way client.Mail/client.Rcpt
|
||||
// wrap it in its own "<...>", so passing a mailbox that already
|
||||
// contains a display name and brackets there produces something like
|
||||
// "MAIL FROM:<enkly <user@example.com>>", which Gmail rejects with
|
||||
// exactly "555 5.5.2 Syntax error, cannot decode response" — the bug
|
||||
// this function exists to prevent. If addr doesn't parse as an address
|
||||
// at all, it's returned unchanged so the SMTP server's own error (not a
|
||||
// swallowed one here) explains what's wrong with it.
|
||||
func envelopeAddress(addr string) string {
|
||||
if parsed, err := mail.ParseAddress(addr); err == nil {
|
||||
return parsed.Address
|
||||
}
|
||||
return addr
|
||||
}
|
||||
99
pkg/mailkit/mail_test.go
Normal file
99
pkg/mailkit/mail_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mailkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnvelopeAddress covers the exact bug this fixes: a configured
|
||||
// From address that includes a display name (a very natural thing to
|
||||
// set ENKLY_SMTP_FROM to, e.g. "enkly <noreply@example.com>") must not
|
||||
// be handed to MAIL FROM/RCPT TO as-is, or Gmail answers with "555
|
||||
// 5.5.2 Syntax error, cannot decode response" because the envelope
|
||||
// command only accepts a bare address (RFC 5321 §4.1.2).
|
||||
func TestEnvelopeAddress(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"user@example.com", "user@example.com"},
|
||||
{"enkly <user@example.com>", "user@example.com"},
|
||||
{" enkly <user@example.com> ", "user@example.com"},
|
||||
{"\"Enkly Notifications\" <user@example.com>", "user@example.com"},
|
||||
// Not parseable as an address at all: returned unchanged so the
|
||||
// SMTP server's own rejection explains the problem, rather than
|
||||
// this function silently swallowing it.
|
||||
{"not-an-address", "not-an-address"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := envelopeAddress(c.in); got != c.want {
|
||||
t.Errorf("envelopeAddress(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTrimsConfig guards against the same class of bug from a
|
||||
// different angle: SMTP config values arriving from os.Getenv with
|
||||
// stray whitespace or an embedded "\r" (e.g. a CRLF-saved .env or
|
||||
// docker-compose.yml) must not reach the wire as part of an SMTP
|
||||
// command line, or the server sees a corrupted command.
|
||||
func TestNewTrimsConfig(t *testing.T) {
|
||||
m := New(Config{
|
||||
Host: " smtp.gmail.com\r\n",
|
||||
Port: " 587 ",
|
||||
Username: " user@example.com \r",
|
||||
Password: " app-password \r",
|
||||
From: " enkly <user@example.com> \r",
|
||||
})
|
||||
if m.cfg.Host != "smtp.gmail.com" {
|
||||
t.Errorf("Host = %q", m.cfg.Host)
|
||||
}
|
||||
if m.cfg.Port != "587" {
|
||||
t.Errorf("Port = %q", m.cfg.Port)
|
||||
}
|
||||
if m.cfg.Username != "user@example.com" {
|
||||
t.Errorf("Username = %q", m.cfg.Username)
|
||||
}
|
||||
if m.cfg.Password != "app-password" {
|
||||
t.Errorf("Password = %q", m.cfg.Password)
|
||||
}
|
||||
if m.cfg.From != "enkly <user@example.com>" {
|
||||
t.Errorf("From = %q", m.cfg.From)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDefaults checks the Gmail host/port/from defaults still apply
|
||||
// after adding trimming, so a minimal config keeps working out of the
|
||||
// box.
|
||||
func TestNewDefaults(t *testing.T) {
|
||||
m := New(Config{Username: "user@example.com", Password: "app-password"})
|
||||
if m.cfg.Host != "smtp.gmail.com" {
|
||||
t.Errorf("default Host = %q", m.cfg.Host)
|
||||
}
|
||||
if m.cfg.Port != "587" {
|
||||
t.Errorf("default Port = %q", m.cfg.Port)
|
||||
}
|
||||
if m.cfg.From != "user@example.com" {
|
||||
t.Errorf("default From = %q", m.cfg.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
if New(Config{}).Configured() {
|
||||
t.Fatal("empty config must not report Configured")
|
||||
}
|
||||
if !New(Config{Username: "u", Password: "p"}).Configured() {
|
||||
t.Fatal("username+password must report Configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageShape(t *testing.T) {
|
||||
msg := buildMessage("enkly <from@example.com>", "to@example.com", "Subject line", "body text")
|
||||
for _, want := range []string{
|
||||
"From: enkly <from@example.com>\r\n",
|
||||
"To: to@example.com\r\n",
|
||||
"Subject: Subject line\r\n",
|
||||
"\r\nbody text\r\n",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("buildMessage output missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
pkg/mathkit/calculus.go
Normal file
115
pkg/mathkit/calculus.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mathkit
|
||||
|
||||
import "fmt"
|
||||
|
||||
// defaultStep is the finite-difference step size used when the
|
||||
// caller doesn't specify one. 1e-5 is the standard sweet spot for
|
||||
// float64 central differences: small enough for truncation error to
|
||||
// be negligible, large enough that it doesn't get swallowed by
|
||||
// float64 rounding error (the classic bias-variance trade-off of
|
||||
// numerical differentiation).
|
||||
const defaultStep = 1e-5
|
||||
|
||||
// Derivative estimates d/d(variable) of expr at the point `at`
|
||||
// (variable -> value, including any other free variables the
|
||||
// expression needs) using the central-difference formula
|
||||
// (f(x+h)-f(x-h))/(2h), which is second-order accurate — far better
|
||||
// than the naive forward difference (f(x+h)-f(x))/h for the same h.
|
||||
// h<=0 uses defaultStep.
|
||||
func Derivative(expr *Expr, variable string, at map[string]float64, h float64) (float64, error) {
|
||||
if h <= 0 {
|
||||
h = defaultStep
|
||||
}
|
||||
x0, ok := at[variable]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("derivative: %q is not bound in \"at\"", variable)
|
||||
}
|
||||
plus := cloneVars(at)
|
||||
plus[variable] = x0 + h
|
||||
minus := cloneVars(at)
|
||||
minus[variable] = x0 - h
|
||||
fPlus, err := expr.Eval(plus)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("derivative: evaluating at %s+h: %w", variable, err)
|
||||
}
|
||||
fMinus, err := expr.Eval(minus)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("derivative: evaluating at %s-h: %w", variable, err)
|
||||
}
|
||||
return (fPlus - fMinus) / (2 * h), nil
|
||||
}
|
||||
|
||||
// Gradient estimates ∇f at the point `at` with respect to each name
|
||||
// in variables, in order — the central operation every ML training
|
||||
// loop runs millions of times (just normally via backprop's chain
|
||||
// rule rather than finite differences; this is the numerical
|
||||
// stand-in that's useful for checking a hand-derived gradient, or for
|
||||
// expressions where deriving the symbolic gradient isn't worth it).
|
||||
func Gradient(expr *Expr, variables []string, at map[string]float64, h float64) ([]float64, error) {
|
||||
if len(variables) == 0 {
|
||||
return nil, fmt.Errorf("gradient: \"variables\" must list at least one variable")
|
||||
}
|
||||
grad := make([]float64, len(variables))
|
||||
for i, v := range variables {
|
||||
d, err := Derivative(expr, v, at, h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grad[i] = d
|
||||
}
|
||||
return grad, nil
|
||||
}
|
||||
|
||||
func cloneVars(m map[string]float64) map[string]float64 {
|
||||
out := make(map[string]float64, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Integrate estimates the definite integral of expr with respect to
|
||||
// variable over [from, to] using Simpson's rule, which is exact for
|
||||
// up to cubic polynomials and converges much faster than the
|
||||
// trapezoid rule for smooth functions. steps (the number of
|
||||
// subintervals) is rounded up to the nearest even number, since
|
||||
// Simpson's rule needs an even number of subintervals; steps<=0
|
||||
// defaults to 1000.
|
||||
func Integrate(expr *Expr, variable string, from, to float64, steps int, fixed map[string]float64) (float64, error) {
|
||||
if steps <= 0 {
|
||||
steps = 1000
|
||||
}
|
||||
if steps%2 != 0 {
|
||||
steps++
|
||||
}
|
||||
h := (to - from) / float64(steps)
|
||||
vars := cloneVars(fixed)
|
||||
|
||||
eval := func(x float64) (float64, error) {
|
||||
vars[variable] = x
|
||||
return expr.Eval(vars)
|
||||
}
|
||||
|
||||
f0, err := eval(from)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("integral: %w", err)
|
||||
}
|
||||
fn, err := eval(to)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("integral: %w", err)
|
||||
}
|
||||
sum := f0 + fn
|
||||
for i := 1; i < steps; i++ {
|
||||
x := from + float64(i)*h
|
||||
fx, err := eval(x)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("integral: %w", err)
|
||||
}
|
||||
if i%2 == 0 {
|
||||
sum += 2 * fx
|
||||
} else {
|
||||
sum += 4 * fx
|
||||
}
|
||||
}
|
||||
return sum * h / 3, nil
|
||||
}
|
||||
421
pkg/mathkit/expr.go
Normal file
421
pkg/mathkit/expr.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Package mathkit is a dependency-free math engine for the problems
|
||||
// that actually come up when building and debugging ML code: linear
|
||||
// algebra (the shape of every tensor op), calculus (gradients are how
|
||||
// everything trains), probability/statistics (data, metrics, losses),
|
||||
// numerical optimization (gradient descent, root finding), and the
|
||||
// handful of ML-specific primitives (activations, loss functions,
|
||||
// least-squares linear regression) that sit on top of those four
|
||||
// pillars. It has no third-party dependency — see the comment on
|
||||
// pkg/envkit/dotenv.go for why this module keeps go.mod minimal — so
|
||||
// the whole engine is just the Go standard library's math/sort/fmt.
|
||||
//
|
||||
// Every sub-area (linalg.go, calculus.go, statistics.go, optimize.go,
|
||||
// ml.go) is usable directly from Go code. solve.go adds one more
|
||||
// layer on top: Solve(operation, params) dispatches a string
|
||||
// operation name and a loosely-typed params map (exactly the shape
|
||||
// a JSON tool-call argument object decodes to) to the right function
|
||||
// and renders a human-readable report — that's the layer the agent
|
||||
// service's solve_ml_math tool calls (see
|
||||
// services/agent/internal/infrastructure/toolexec/mlmath.go), because
|
||||
// an LLM tool call arrives as JSON, not as typed Go arguments.
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Expr is a parsed arithmetic expression that can be evaluated
|
||||
// repeatedly against different variable bindings without re-parsing —
|
||||
// exactly what Gradient and GradientDescent need, since they evaluate
|
||||
// the same expression at many points.
|
||||
type Expr struct {
|
||||
root node
|
||||
src string
|
||||
}
|
||||
|
||||
// String returns the original source text the expression was parsed
|
||||
// from, for error messages and report rendering.
|
||||
func (e *Expr) String() string { return e.src }
|
||||
|
||||
// node is the parsed AST. A tiny tagged union kept as an interface
|
||||
// rather than a struct-with-a-kind-field: each node type carries only
|
||||
// the fields it needs, and eval is a single recursive method.
|
||||
type node interface {
|
||||
eval(vars map[string]float64) (float64, error)
|
||||
}
|
||||
|
||||
type numNode float64
|
||||
|
||||
func (n numNode) eval(map[string]float64) (float64, error) { return float64(n), nil }
|
||||
|
||||
type varNode string
|
||||
|
||||
func (n varNode) eval(vars map[string]float64) (float64, error) {
|
||||
v, ok := vars[string(n)]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unbound variable %q (pass it in \"variables\" or \"at\")", string(n))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type binNode struct {
|
||||
op byte // '+', '-', '*', '/', '^'
|
||||
lhs, rhs node
|
||||
}
|
||||
|
||||
func (n binNode) eval(vars map[string]float64) (float64, error) {
|
||||
l, err := n.lhs.eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r, err := n.rhs.eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch n.op {
|
||||
case '+':
|
||||
return l + r, nil
|
||||
case '-':
|
||||
return l - r, nil
|
||||
case '*':
|
||||
return l * r, nil
|
||||
case '/':
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
return l / r, nil
|
||||
case '^':
|
||||
return math.Pow(l, r), nil
|
||||
}
|
||||
return 0, fmt.Errorf("internal: unknown operator %q", n.op)
|
||||
}
|
||||
|
||||
type negNode struct{ x node }
|
||||
|
||||
func (n negNode) eval(vars map[string]float64) (float64, error) {
|
||||
v, err := n.x.eval(vars)
|
||||
return -v, err
|
||||
}
|
||||
|
||||
type callNode struct {
|
||||
fn string
|
||||
args []node
|
||||
}
|
||||
|
||||
// unaryFuncs are functions of exactly one argument — the overwhelming
|
||||
// majority of what shows up in ML math (activations, logs, norms).
|
||||
var unaryFuncs = map[string]func(float64) float64{
|
||||
"sin": math.Sin, "cos": math.Cos, "tan": math.Tan,
|
||||
"asin": math.Asin, "acos": math.Acos, "atan": math.Atan,
|
||||
"sinh": math.Sinh, "cosh": math.Cosh, "tanh": math.Tanh,
|
||||
"exp": math.Exp, "sqrt": math.Sqrt, "abs": math.Abs,
|
||||
"ln": math.Log, "log": math.Log, "log2": math.Log2, "log10": math.Log10,
|
||||
"floor": math.Floor, "ceil": math.Ceil, "round": math.Round,
|
||||
"sigmoid": Sigmoid, "relu": ReLU, "sign": func(x float64) float64 { return math.Copysign(1, x) * boolF(x != 0) },
|
||||
}
|
||||
|
||||
func boolF(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// binaryFuncs are functions of exactly two arguments.
|
||||
var binaryFuncs = map[string]func(float64, float64) float64{
|
||||
"max": math.Max, "min": math.Min, "pow": math.Pow,
|
||||
"mod": math.Mod, "atan2": math.Atan2,
|
||||
}
|
||||
|
||||
func (n callNode) eval(vars map[string]float64) (float64, error) {
|
||||
switch len(n.args) {
|
||||
case 1:
|
||||
if f, ok := unaryFuncs[n.fn]; ok {
|
||||
v, err := n.args[0].eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return f(v), nil
|
||||
}
|
||||
case 2:
|
||||
if f, ok := binaryFuncs[n.fn]; ok {
|
||||
a, err := n.args[0].eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b, err := n.args[1].eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return f(a, b), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unknown function %q with %d argument(s)", n.fn, len(n.args))
|
||||
}
|
||||
|
||||
// Eval evaluates the expression against a variable binding. Missing
|
||||
// variables are a reported error (never silently treated as zero),
|
||||
// since a silent zero would turn a typo into a wrong-but-plausible
|
||||
// answer — exactly the failure mode numerical tools must not have.
|
||||
func (e *Expr) Eval(vars map[string]float64) (float64, error) {
|
||||
return e.root.eval(vars)
|
||||
}
|
||||
|
||||
// ParseExpr parses a math expression written in ordinary infix
|
||||
// notation: + - * / ^ (right-associative power), unary minus,
|
||||
// parentheses, named variables, the constants pi/e, and function
|
||||
// calls (sin(x), sigmoid(x), max(a,b), ...). See unaryFuncs and
|
||||
// binaryFuncs for the full function list.
|
||||
func ParseExpr(src string) (*Expr, error) {
|
||||
p := &parser{toks: tokenize(src)}
|
||||
n, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %q: %w", src, err)
|
||||
}
|
||||
if p.pos != len(p.toks) {
|
||||
return nil, fmt.Errorf("parsing %q: unexpected trailing input near %q", src, p.toks[p.pos].text)
|
||||
}
|
||||
return &Expr{root: n, src: src}, nil
|
||||
}
|
||||
|
||||
// --- tokenizer ---
|
||||
|
||||
type tokKind int
|
||||
|
||||
const (
|
||||
tokNum tokKind = iota
|
||||
tokIdent
|
||||
tokOp
|
||||
tokLParen
|
||||
tokRParen
|
||||
tokComma
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokKind
|
||||
text string
|
||||
num float64
|
||||
}
|
||||
|
||||
func tokenize(src string) []token {
|
||||
var toks []token
|
||||
r := []rune(src)
|
||||
i := 0
|
||||
for i < len(r) {
|
||||
c := r[i]
|
||||
switch {
|
||||
case unicode.IsSpace(c):
|
||||
i++
|
||||
case c == '(':
|
||||
toks = append(toks, token{kind: tokLParen, text: "("})
|
||||
i++
|
||||
case c == ')':
|
||||
toks = append(toks, token{kind: tokRParen, text: ")"})
|
||||
i++
|
||||
case c == ',':
|
||||
toks = append(toks, token{kind: tokComma, text: ","})
|
||||
i++
|
||||
case strings.ContainsRune("+-*/^", c):
|
||||
toks = append(toks, token{kind: tokOp, text: string(c)})
|
||||
i++
|
||||
case unicode.IsDigit(c) || c == '.':
|
||||
j := i
|
||||
for j < len(r) && (unicode.IsDigit(r[j]) || r[j] == '.') {
|
||||
j++
|
||||
}
|
||||
// scientific notation, e.g. 1e-3
|
||||
if j < len(r) && (r[j] == 'e' || r[j] == 'E') {
|
||||
k := j + 1
|
||||
if k < len(r) && (r[k] == '+' || r[k] == '-') {
|
||||
k++
|
||||
}
|
||||
if k < len(r) && unicode.IsDigit(r[k]) {
|
||||
j = k
|
||||
for j < len(r) && unicode.IsDigit(r[j]) {
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
text := string(r[i:j])
|
||||
var f float64
|
||||
fmt.Sscanf(text, "%g", &f)
|
||||
toks = append(toks, token{kind: tokNum, text: text, num: f})
|
||||
i = j
|
||||
case unicode.IsLetter(c) || c == '_':
|
||||
j := i
|
||||
for j < len(r) && (unicode.IsLetter(r[j]) || unicode.IsDigit(r[j]) || r[j] == '_') {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, token{kind: tokIdent, text: string(r[i:j])})
|
||||
i = j
|
||||
default:
|
||||
// Unknown character: emit as its own operator token so the
|
||||
// parser reports a clear "unexpected token" error instead
|
||||
// of the tokenizer silently dropping it.
|
||||
toks = append(toks, token{kind: tokOp, text: string(c)})
|
||||
i++
|
||||
}
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// --- recursive-descent parser ---
|
||||
//
|
||||
// expr := term (('+' | '-') term)*
|
||||
// term := unary (('*' | '/') unary)*
|
||||
// unary := ('-' | '+') unary | power
|
||||
// power := primary ('^' unary)? -- right-associative; the
|
||||
// exponent recurses through unary (not power) so the
|
||||
// precedence matches ordinary math/Python convention:
|
||||
// unary minus binds LOOSER than '^' (-2^2 == -(2^2) == -4)
|
||||
// but the exponent itself may carry its own sign
|
||||
// (2^-2 == 0.25).
|
||||
// primary:= number | const | ident '(' args ')' | ident | '(' expr ')'
|
||||
// args := expr (',' expr)*
|
||||
|
||||
type parser struct {
|
||||
toks []token
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *parser) peek() (token, bool) {
|
||||
if p.pos >= len(p.toks) {
|
||||
return token{}, false
|
||||
}
|
||||
return p.toks[p.pos], true
|
||||
}
|
||||
|
||||
func (p *parser) next() (token, bool) {
|
||||
t, ok := p.peek()
|
||||
if ok {
|
||||
p.pos++
|
||||
}
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (p *parser) parseExpr() (node, error) {
|
||||
lhs, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := p.peek()
|
||||
if !ok || t.kind != tokOp || (t.text != "+" && t.text != "-") {
|
||||
return lhs, nil
|
||||
}
|
||||
p.next()
|
||||
rhs, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseTerm() (node, error) {
|
||||
lhs, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := p.peek()
|
||||
if !ok || t.kind != tokOp || (t.text != "*" && t.text != "/") {
|
||||
return lhs, nil
|
||||
}
|
||||
p.next()
|
||||
rhs, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseUnary() (node, error) {
|
||||
if t, ok := p.peek(); ok && t.kind == tokOp && (t.text == "-" || t.text == "+") {
|
||||
p.next()
|
||||
x, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.text == "-" {
|
||||
return negNode{x: x}, nil
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
return p.parsePower()
|
||||
}
|
||||
|
||||
func (p *parser) parsePower() (node, error) {
|
||||
lhs, err := p.parsePrimary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := p.peek(); ok && t.kind == tokOp && t.text == "^" {
|
||||
p.next()
|
||||
rhs, err := p.parseUnary() // right-associative; lets the exponent carry its own sign
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return binNode{op: '^', lhs: lhs, rhs: rhs}, nil
|
||||
}
|
||||
return lhs, nil
|
||||
}
|
||||
|
||||
func (p *parser) parsePrimary() (node, error) {
|
||||
t, ok := p.next()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
switch t.kind {
|
||||
case tokNum:
|
||||
return numNode(t.num), nil
|
||||
case tokLParen:
|
||||
n, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c, ok := p.next(); !ok || c.kind != tokRParen {
|
||||
return nil, fmt.Errorf("missing closing ')'")
|
||||
}
|
||||
return n, nil
|
||||
case tokIdent:
|
||||
switch strings.ToLower(t.text) {
|
||||
case "pi":
|
||||
return numNode(math.Pi), nil
|
||||
case "e":
|
||||
return numNode(math.E), nil
|
||||
}
|
||||
if nx, ok := p.peek(); ok && nx.kind == tokLParen {
|
||||
p.next()
|
||||
var args []node
|
||||
if c, ok := p.peek(); !ok || c.kind != tokRParen {
|
||||
for {
|
||||
a, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
c, ok := p.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
|
||||
}
|
||||
if c.kind == tokComma {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if c, ok := p.next(); !ok || c.kind != tokRParen {
|
||||
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
|
||||
}
|
||||
return callNode{fn: strings.ToLower(t.text), args: args}, nil
|
||||
}
|
||||
return varNode(t.text), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected token %q", t.text)
|
||||
}
|
||||
409
pkg/mathkit/linalg.go
Normal file
409
pkg/mathkit/linalg.go
Normal file
@@ -0,0 +1,409 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Matrix is a dense matrix stored row-major: Matrix[i][j] is row i,
|
||||
// column j. Every row must have the same length — functions that take
|
||||
// a Matrix validate that with Matrix.Dims before doing any math, so a
|
||||
// ragged input is reported as an error rather than panicking.
|
||||
type Matrix [][]float64
|
||||
|
||||
// Dims returns the matrix's row and column counts and an error if the
|
||||
// rows aren't all the same length (a ragged "matrix" isn't one).
|
||||
func (m Matrix) Dims() (rows, cols int, err error) {
|
||||
rows = len(m)
|
||||
if rows == 0 {
|
||||
return 0, 0, fmt.Errorf("empty matrix")
|
||||
}
|
||||
cols = len(m[0])
|
||||
for i, row := range m {
|
||||
if len(row) != cols {
|
||||
return 0, 0, fmt.Errorf("ragged matrix: row 0 has %d columns, row %d has %d", cols, i, len(row))
|
||||
}
|
||||
}
|
||||
return rows, cols, nil
|
||||
}
|
||||
|
||||
// Identity returns the n×n identity matrix.
|
||||
func Identity(n int) Matrix {
|
||||
m := make(Matrix, n)
|
||||
for i := range m {
|
||||
m[i] = make([]float64, n)
|
||||
m[i][i] = 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sameDims(a, b Matrix) (rows, cols int, err error) {
|
||||
ra, ca, err := a.Dims()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rb, cb, err := b.Dims()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if ra != rb || ca != cb {
|
||||
return 0, 0, fmt.Errorf("shape mismatch: %dx%d vs %dx%d", ra, ca, rb, cb)
|
||||
}
|
||||
return ra, ca, nil
|
||||
}
|
||||
|
||||
// MatAdd returns a+b, element-wise.
|
||||
func MatAdd(a, b Matrix) (Matrix, error) {
|
||||
rows, cols, err := sameDims(a, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_add: %w", err)
|
||||
}
|
||||
out := make(Matrix, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
out[i] = make([]float64, cols)
|
||||
for j := 0; j < cols; j++ {
|
||||
out[i][j] = a[i][j] + b[i][j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MatSub returns a-b, element-wise.
|
||||
func MatSub(a, b Matrix) (Matrix, error) {
|
||||
rows, cols, err := sameDims(a, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_sub: %w", err)
|
||||
}
|
||||
out := make(Matrix, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
out[i] = make([]float64, cols)
|
||||
for j := 0; j < cols; j++ {
|
||||
out[i][j] = a[i][j] - b[i][j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MatMul returns the matrix product a×b (standard inner-dimension
|
||||
// matching: a is m×n, b must be n×p).
|
||||
func MatMul(a, b Matrix) (Matrix, error) {
|
||||
ra, ca, err := a.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_mul: left operand: %w", err)
|
||||
}
|
||||
rb, cb, err := b.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_mul: right operand: %w", err)
|
||||
}
|
||||
if ca != rb {
|
||||
return nil, fmt.Errorf("matrix_mul: inner dimensions must match: %dx%d times %dx%d", ra, ca, rb, cb)
|
||||
}
|
||||
out := make(Matrix, ra)
|
||||
for i := 0; i < ra; i++ {
|
||||
out[i] = make([]float64, cb)
|
||||
for j := 0; j < cb; j++ {
|
||||
var sum float64
|
||||
for k := 0; k < ca; k++ {
|
||||
sum += a[i][k] * b[k][j]
|
||||
}
|
||||
out[i][j] = sum
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Scale multiplies every element of m by k.
|
||||
func Scale(m Matrix, k float64) Matrix {
|
||||
out := make(Matrix, len(m))
|
||||
for i, row := range m {
|
||||
out[i] = make([]float64, len(row))
|
||||
for j, v := range row {
|
||||
out[i][j] = v * k
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Transpose returns m^T.
|
||||
func Transpose(m Matrix) (Matrix, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_transpose: %w", err)
|
||||
}
|
||||
out := make(Matrix, cols)
|
||||
for j := 0; j < cols; j++ {
|
||||
out[j] = make([]float64, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
out[j][i] = m[i][j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Trace returns the sum of the diagonal of a square matrix.
|
||||
func Trace(m Matrix) (float64, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("matrix_trace: %w", err)
|
||||
}
|
||||
if rows != cols {
|
||||
return 0, fmt.Errorf("matrix_trace: matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
var sum float64
|
||||
for i := 0; i < rows; i++ {
|
||||
sum += m[i][i]
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// clone makes a deep copy so Gaussian-elimination routines never
|
||||
// mutate the caller's matrix.
|
||||
func (m Matrix) clone() Matrix {
|
||||
out := make(Matrix, len(m))
|
||||
for i, row := range m {
|
||||
out[i] = append([]float64(nil), row...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// luDecompose runs Gaussian elimination with partial pivoting on a
|
||||
// clone of m, returning the resulting upper-triangular-ish matrix and
|
||||
// the sign of the permutation (needed to get the determinant's sign
|
||||
// right) and the pivots used. Both Determinant and Inverse build on
|
||||
// this one routine rather than duplicating elimination logic.
|
||||
func luDecompose(m Matrix) (a Matrix, sign float64, err error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if rows != cols {
|
||||
return nil, 0, fmt.Errorf("matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
n := rows
|
||||
a = m.clone()
|
||||
sign = 1
|
||||
for col := 0; col < n; col++ {
|
||||
// Partial pivot: swap in the row with the largest absolute
|
||||
// value in this column, for numerical stability.
|
||||
pivotRow := col
|
||||
best := math.Abs(a[col][col])
|
||||
for r := col + 1; r < n; r++ {
|
||||
if v := math.Abs(a[r][col]); v > best {
|
||||
best, pivotRow = v, r
|
||||
}
|
||||
}
|
||||
if best < 1e-12 {
|
||||
return a, 0, nil // singular; determinant is 0, caller decides
|
||||
}
|
||||
if pivotRow != col {
|
||||
a[col], a[pivotRow] = a[pivotRow], a[col]
|
||||
sign = -sign
|
||||
}
|
||||
for r := col + 1; r < n; r++ {
|
||||
factor := a[r][col] / a[col][col]
|
||||
for c := col; c < n; c++ {
|
||||
a[r][c] -= factor * a[col][c]
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, sign, nil
|
||||
}
|
||||
|
||||
// Determinant computes det(m) via LU (Gaussian elimination with
|
||||
// partial pivoting), O(n^3) — the standard numerically-stable
|
||||
// approach, versus the O(n!) cofactor expansion a direct reading of
|
||||
// the definition would suggest.
|
||||
func Determinant(m Matrix) (float64, error) {
|
||||
a, sign, err := luDecompose(m)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("matrix_determinant: %w", err)
|
||||
}
|
||||
n := len(a)
|
||||
det := sign
|
||||
for i := 0; i < n; i++ {
|
||||
det *= a[i][i]
|
||||
}
|
||||
return det, nil
|
||||
}
|
||||
|
||||
// Inverse computes m^-1 via Gauss-Jordan elimination on [m | I].
|
||||
// Returns an error for a non-square or singular (determinant ≈ 0)
|
||||
// matrix — ML code hitting a singular matrix (e.g. a rank-deficient
|
||||
// design matrix in least squares) usually wants to know that, not a
|
||||
// silently garbage answer.
|
||||
func Inverse(m Matrix) (Matrix, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_inverse: %w", err)
|
||||
}
|
||||
if rows != cols {
|
||||
return nil, fmt.Errorf("matrix_inverse: matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
n := rows
|
||||
aug := make(Matrix, n)
|
||||
for i := 0; i < n; i++ {
|
||||
aug[i] = make([]float64, 2*n)
|
||||
copy(aug[i], m[i])
|
||||
aug[i][n+i] = 1
|
||||
}
|
||||
for col := 0; col < n; col++ {
|
||||
pivotRow := col
|
||||
best := math.Abs(aug[col][col])
|
||||
for r := col + 1; r < n; r++ {
|
||||
if v := math.Abs(aug[r][col]); v > best {
|
||||
best, pivotRow = v, r
|
||||
}
|
||||
}
|
||||
if best < 1e-12 {
|
||||
return nil, fmt.Errorf("matrix_inverse: matrix is singular (not invertible)")
|
||||
}
|
||||
aug[col], aug[pivotRow] = aug[pivotRow], aug[col]
|
||||
pivot := aug[col][col]
|
||||
for c := 0; c < 2*n; c++ {
|
||||
aug[col][c] /= pivot
|
||||
}
|
||||
for r := 0; r < n; r++ {
|
||||
if r == col {
|
||||
continue
|
||||
}
|
||||
factor := aug[r][col]
|
||||
for c := 0; c < 2*n; c++ {
|
||||
aug[r][c] -= factor * aug[col][c]
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make(Matrix, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = append([]float64(nil), aug[i][n:]...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Rank computes the matrix rank as the number of non-negligible
|
||||
// pivots found by Gaussian elimination (works for non-square
|
||||
// matrices too, unlike Determinant/Inverse).
|
||||
func Rank(m Matrix) (int, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("matrix_rank: %w", err)
|
||||
}
|
||||
a := m.clone()
|
||||
rank := 0
|
||||
for col := 0; col < cols && rank < rows; col++ {
|
||||
pivotRow := -1
|
||||
best := 1e-9
|
||||
for r := rank; r < rows; r++ {
|
||||
if v := math.Abs(a[r][col]); v > best {
|
||||
best, pivotRow = v, r
|
||||
}
|
||||
}
|
||||
if pivotRow == -1 {
|
||||
continue
|
||||
}
|
||||
a[rank], a[pivotRow] = a[pivotRow], a[rank]
|
||||
for r := rank + 1; r < rows; r++ {
|
||||
factor := a[r][col] / a[rank][col]
|
||||
for c := col; c < cols; c++ {
|
||||
a[r][c] -= factor * a[rank][c]
|
||||
}
|
||||
}
|
||||
rank++
|
||||
}
|
||||
return rank, nil
|
||||
}
|
||||
|
||||
// MatVecMul returns m×v for a column vector v.
|
||||
func MatVecMul(m Matrix, v []float64) ([]float64, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix-vector multiply: %w", err)
|
||||
}
|
||||
if cols != len(v) {
|
||||
return nil, fmt.Errorf("matrix-vector multiply: matrix has %d columns but vector has %d entries", cols, len(v))
|
||||
}
|
||||
out := make([]float64, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
var sum float64
|
||||
for j := 0; j < cols; j++ {
|
||||
sum += m[i][j] * v[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Dot returns the dot (inner) product of two equal-length vectors.
|
||||
func Dot(a, b []float64) (float64, error) {
|
||||
if len(a) != len(b) {
|
||||
return 0, fmt.Errorf("dot_product: vectors have different lengths (%d vs %d)", len(a), len(b))
|
||||
}
|
||||
var sum float64
|
||||
for i := range a {
|
||||
sum += a[i] * b[i]
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// Norm computes the Lp norm of a vector. p=2 is Euclidean (the
|
||||
// default any ML codebase means when it just says "norm"); p=1 is
|
||||
// Manhattan; p=math.Inf(1) is the max-absolute-value norm.
|
||||
func Norm(v []float64, p float64) float64 {
|
||||
if math.IsInf(p, 1) {
|
||||
var m float64
|
||||
for _, x := range v {
|
||||
if a := math.Abs(x); a > m {
|
||||
m = a
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
var sum float64
|
||||
for _, x := range v {
|
||||
sum += math.Pow(math.Abs(x), p)
|
||||
}
|
||||
return math.Pow(sum, 1/p)
|
||||
}
|
||||
|
||||
// DominantEigen estimates the eigenvalue of largest absolute value
|
||||
// and its eigenvector via power iteration — the workhorse behind
|
||||
// things like PageRank and quick PCA-component estimates, and much
|
||||
// simpler/more numerically robust to implement from scratch than a
|
||||
// full QR eigendecomposition, which is the right trade-off for a
|
||||
// hand-rolled numerical toolkit. iterations<=0 defaults to 200, which
|
||||
// converges for any matrix with a well-separated dominant eigenvalue
|
||||
// to within float64 precision.
|
||||
func DominantEigen(m Matrix, iterations int) (eigenvalue float64, eigenvector []float64, err error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("dominant_eigen: %w", err)
|
||||
}
|
||||
if rows != cols {
|
||||
return 0, nil, fmt.Errorf("dominant_eigen: matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 200
|
||||
}
|
||||
n := rows
|
||||
v := make([]float64, n)
|
||||
for i := range v {
|
||||
v[i] = 1 / math.Sqrt(float64(n)) // deterministic, reproducible start
|
||||
}
|
||||
var lambda float64
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
next, err := MatVecMul(m, v)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
norm := Norm(next, 2)
|
||||
if norm < 1e-15 {
|
||||
return 0, nil, fmt.Errorf("dominant_eigen: did not converge (matrix may be nilpotent or zero)")
|
||||
}
|
||||
for i := range next {
|
||||
next[i] /= norm
|
||||
}
|
||||
v = next
|
||||
mv, _ := MatVecMul(m, v)
|
||||
lambda, _ = Dot(v, mv)
|
||||
}
|
||||
return lambda, v, nil
|
||||
}
|
||||
364
pkg/mathkit/mathkit_test.go
Normal file
364
pkg/mathkit/mathkit_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func almostEqual(a, b, tol float64) bool { return math.Abs(a-b) <= tol }
|
||||
|
||||
func mustExpr(t *testing.T, src string) *Expr {
|
||||
t.Helper()
|
||||
e, err := ParseExpr(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExpr(%q): %v", src, err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestExprEval(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
vars map[string]float64
|
||||
want float64
|
||||
}{
|
||||
{"2 + 3 * 4", nil, 14},
|
||||
{"(2 + 3) * 4", nil, 20},
|
||||
{"2 ^ 3 ^ 2", nil, 512}, // right-associative: 2^(3^2) = 2^9
|
||||
{"-2 ^ 2", nil, -4}, // unary minus binds looser than ^: -(2^2)
|
||||
{"x * x + 2*x + 1", map[string]float64{"x": 3}, 16},
|
||||
{"sigmoid(0)", nil, 0.5},
|
||||
{"relu(-5)", nil, 0},
|
||||
{"relu(5)", nil, 5},
|
||||
{"max(3, 7)", nil, 7},
|
||||
{"sqrt(16)", nil, 4},
|
||||
{"pi", nil, math.Pi},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := mustExpr(t, c.src)
|
||||
got, err := e.Eval(c.vars)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval(%q): %v", c.src, err)
|
||||
}
|
||||
if !almostEqual(got, c.want, 1e-9) {
|
||||
t.Errorf("Eval(%q) = %v, want %v", c.src, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExprUnboundVariable(t *testing.T) {
|
||||
e := mustExpr(t, "x + 1")
|
||||
if _, err := e.Eval(nil); err == nil {
|
||||
t.Fatal("expected an error for an unbound variable, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatMul(t *testing.T) {
|
||||
a := Matrix{{1, 2}, {3, 4}}
|
||||
b := Matrix{{5, 6}, {7, 8}}
|
||||
got, err := MatMul(a, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Matrix{{19, 22}, {43, 50}}
|
||||
for i := range want {
|
||||
for j := range want[i] {
|
||||
if got[i][j] != want[i][j] {
|
||||
t.Errorf("MatMul[%d][%d] = %v, want %v", i, j, got[i][j], want[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatMulShapeMismatch(t *testing.T) {
|
||||
a := Matrix{{1, 2, 3}}
|
||||
b := Matrix{{1, 2}}
|
||||
if _, err := MatMul(a, b); err == nil {
|
||||
t.Fatal("expected a shape-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterminantAndInverse(t *testing.T) {
|
||||
a := Matrix{{4, 7}, {2, 6}}
|
||||
det, err := Determinant(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(det, 10, 1e-9) {
|
||||
t.Errorf("det = %v, want 10", det)
|
||||
}
|
||||
inv, err := Inverse(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A * A^-1 should be the identity.
|
||||
prod, err := MatMul(a, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
for j := 0; j < 2; j++ {
|
||||
want := 0.0
|
||||
if i == j {
|
||||
want = 1
|
||||
}
|
||||
if !almostEqual(prod[i][j], want, 1e-9) {
|
||||
t.Errorf("A*A^-1[%d][%d] = %v, want %v", i, j, prod[i][j], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInverseSingular(t *testing.T) {
|
||||
a := Matrix{{1, 2}, {2, 4}} // row 2 = 2 * row 1
|
||||
if _, err := Inverse(a); err == nil {
|
||||
t.Fatal("expected a singular-matrix error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDominantEigen(t *testing.T) {
|
||||
// [[2,0],[0,1]] has eigenvalues 2 and 1 — dominant is 2, eigenvector (1,0).
|
||||
a := Matrix{{2, 0}, {0, 1}}
|
||||
lambda, v, err := DominantEigen(a, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(lambda, 2, 1e-6) {
|
||||
t.Errorf("eigenvalue = %v, want 2", lambda)
|
||||
}
|
||||
if !almostEqual(math.Abs(v[0]), 1, 1e-6) || !almostEqual(v[1], 0, 1e-6) {
|
||||
t.Errorf("eigenvector = %v, want ~(1,0)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivative(t *testing.T) {
|
||||
// d/dx[x^2] at x=3 is 6.
|
||||
e := mustExpr(t, "x^2")
|
||||
d, err := Derivative(e, "x", map[string]float64{"x": 3}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(d, 6, 1e-4) {
|
||||
t.Errorf("derivative = %v, want 6", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradient(t *testing.T) {
|
||||
// f(x,y) = x^2 + y^2 ; gradient at (3,4) is (6,8).
|
||||
e := mustExpr(t, "x^2 + y^2")
|
||||
g, err := Gradient(e, []string{"x", "y"}, map[string]float64{"x": 3, "y": 4}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(g[0], 6, 1e-4) || !almostEqual(g[1], 8, 1e-4) {
|
||||
t.Errorf("gradient = %v, want [6 8]", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrate(t *testing.T) {
|
||||
// ∫0..1 x^2 dx = 1/3.
|
||||
e := mustExpr(t, "x^2")
|
||||
v, err := Integrate(e, "x", 0, 1, 100, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(v, 1.0/3.0, 1e-9) {
|
||||
t.Errorf("integral = %v, want 1/3", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradientDescentConvergesToMinimum(t *testing.T) {
|
||||
// f(x) = (x-3)^2 has its minimum at x=3.
|
||||
e := mustExpr(t, "(x - 3)^2")
|
||||
res, err := GradientDescent(e, []string{"x"}, []float64{0}, 0.1, 200, 1e-10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := res.Path[len(res.Path)-1][0]
|
||||
if !almostEqual(final, 3, 1e-3) {
|
||||
t.Errorf("gradient descent converged to x=%v, want ~3", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewtonRootFindsSquareRoot(t *testing.T) {
|
||||
// Root of x^2 - 2 = 0 is sqrt(2).
|
||||
e := mustExpr(t, "x^2 - 2")
|
||||
res, err := NewtonRoot(e, "x", 1, 0, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := res.Path[len(res.Path)-1]
|
||||
if !almostEqual(root, math.Sqrt2, 1e-6) {
|
||||
t.Errorf("newton root = %v, want %v", root, math.Sqrt2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics(t *testing.T) {
|
||||
data := []float64{2, 4, 4, 4, 5, 5, 7, 9}
|
||||
mean, _ := Mean(data)
|
||||
if !almostEqual(mean, 5, 1e-9) {
|
||||
t.Errorf("mean = %v, want 5", mean)
|
||||
}
|
||||
popVar, _ := Variance(data, true)
|
||||
if !almostEqual(popVar, 4, 1e-9) {
|
||||
t.Errorf("population variance = %v, want 4", popVar)
|
||||
}
|
||||
std, _ := StdDev(data, true)
|
||||
if !almostEqual(std, 2, 1e-9) {
|
||||
t.Errorf("population std_dev = %v, want 2", std)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrelationPerfect(t *testing.T) {
|
||||
x := []float64{1, 2, 3, 4, 5}
|
||||
y := []float64{2, 4, 6, 8, 10}
|
||||
c, err := Correlation(x, y)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(c, 1, 1e-9) {
|
||||
t.Errorf("correlation = %v, want 1", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalDistribution(t *testing.T) {
|
||||
// Standard normal at x=0: pdf = 1/sqrt(2*pi), cdf = 0.5.
|
||||
pdf, _ := NormalPDF(0, 0, 1)
|
||||
if !almostEqual(pdf, 1/math.Sqrt(2*math.Pi), 1e-9) {
|
||||
t.Errorf("pdf(0) = %v", pdf)
|
||||
}
|
||||
cdf, _ := NormalCDF(0, 0, 1)
|
||||
if !almostEqual(cdf, 0.5, 1e-9) {
|
||||
t.Errorf("cdf(0) = %v, want 0.5", cdf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigmoidNumericallyStable(t *testing.T) {
|
||||
if v := Sigmoid(-1000); v != 0 {
|
||||
t.Errorf("sigmoid(-1000) = %v, want 0", v)
|
||||
}
|
||||
if v := Sigmoid(1000); !almostEqual(v, 1, 1e-12) {
|
||||
t.Errorf("sigmoid(1000) = %v, want ~1", v)
|
||||
}
|
||||
if v := Sigmoid(0); v != 0.5 {
|
||||
t.Errorf("sigmoid(0) = %v, want 0.5", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftmaxSumsToOne(t *testing.T) {
|
||||
out := Softmax([]float64{1, 2, 3})
|
||||
var sum float64
|
||||
for _, v := range out {
|
||||
sum += v
|
||||
}
|
||||
if !almostEqual(sum, 1, 1e-9) {
|
||||
t.Errorf("softmax sums to %v, want 1", sum)
|
||||
}
|
||||
// Largest logit should get the largest probability.
|
||||
if out[2] <= out[1] || out[1] <= out[0] {
|
||||
t.Errorf("softmax ordering wrong: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSELoss(t *testing.T) {
|
||||
v, err := MSELoss([]float64{1, 2, 3}, []float64{1, 2, 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// errors: 0, 0, -1 -> squared: 0,0,1 -> mean = 1/3
|
||||
if !almostEqual(v, 1.0/3.0, 1e-9) {
|
||||
t.Errorf("mse = %v, want 1/3", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinearRegressionPerfectFit(t *testing.T) {
|
||||
// y = 2 + 3x, exactly.
|
||||
X := Matrix{{1}, {2}, {3}, {4}, {5}}
|
||||
y := []float64{5, 8, 11, 14, 17}
|
||||
res, err := LinearRegression(X, y)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(res.Coefficients[0], 2, 1e-6) {
|
||||
t.Errorf("intercept = %v, want 2", res.Coefficients[0])
|
||||
}
|
||||
if !almostEqual(res.Coefficients[1], 3, 1e-6) {
|
||||
t.Errorf("slope = %v, want 3", res.Coefficients[1])
|
||||
}
|
||||
if !almostEqual(res.RSquared, 1, 1e-6) {
|
||||
t.Errorf("R^2 = %v, want 1", res.RSquared)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Solve dispatcher: the JSON-boundary layer the agent tool calls ---
|
||||
|
||||
func TestSolveEvaluate(t *testing.T) {
|
||||
out, err := Solve("evaluate", map[string]any{
|
||||
"expression": "x^2 + y^2",
|
||||
"variables": map[string]any{"x": 3.0, "y": 4.0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "25") {
|
||||
t.Errorf("Solve(evaluate) = %q, want it to contain 25", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveMatrixMul(t *testing.T) {
|
||||
out, err := Solve("matrix_mul", map[string]any{
|
||||
"a": []any{[]any{1.0, 2.0}, []any{3.0, 4.0}},
|
||||
"b": []any{[]any{5.0, 6.0}, []any{7.0, 8.0}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "19") || !strings.Contains(out, "50") {
|
||||
t.Errorf("Solve(matrix_mul) = %q, missing expected entries", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveUnknownOperation(t *testing.T) {
|
||||
if _, err := Solve("not_a_real_operation", nil); err == nil {
|
||||
t.Fatal("expected an error for an unknown operation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveGradientDescent(t *testing.T) {
|
||||
out, err := Solve("gradient_descent", map[string]any{
|
||||
"expression": "(x - 3)^2",
|
||||
"variables": []any{"x"},
|
||||
"start": []any{0.0},
|
||||
"learning_rate": 0.1,
|
||||
"iterations": 200.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "converged=true") {
|
||||
t.Errorf("Solve(gradient_descent) = %q, expected convergence", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveMissingField(t *testing.T) {
|
||||
_, err := Solve("mean", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected a missing-field error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "data") {
|
||||
t.Errorf("error %q should mention the missing field", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsListIsSorted(t *testing.T) {
|
||||
ops := Operations()
|
||||
if len(ops) == 0 {
|
||||
t.Fatal("Operations() returned nothing")
|
||||
}
|
||||
for i := 1; i < len(ops); i++ {
|
||||
if ops[i-1] > ops[i] {
|
||||
t.Fatalf("Operations() not sorted: %q before %q", ops[i-1], ops[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
191
pkg/mathkit/ml.go
Normal file
191
pkg/mathkit/ml.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Sigmoid is the logistic function 1 / (1 + e^-x), computed in the
|
||||
// numerically stable branch-on-sign form so it doesn't overflow
|
||||
// math.Exp for very negative x (a plain 1/(1+exp(-x)) blows up
|
||||
// exp(-x) to +Inf for x below about -745 and silently returns 0
|
||||
// instead of propagating the overflow visibly).
|
||||
func Sigmoid(x float64) float64 {
|
||||
if x >= 0 {
|
||||
z := math.Exp(-x)
|
||||
return 1 / (1 + z)
|
||||
}
|
||||
z := math.Exp(x)
|
||||
return z / (1 + z)
|
||||
}
|
||||
|
||||
// ReLU is max(0, x).
|
||||
func ReLU(x float64) float64 {
|
||||
if x > 0 {
|
||||
return x
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Softmax turns a vector of raw scores ("logits") into a probability
|
||||
// distribution: non-negative entries that sum to 1. Subtracts the max
|
||||
// before exponentiating (the standard numerically-stable formulation)
|
||||
// so large logits don't overflow math.Exp.
|
||||
func Softmax(x []float64) []float64 {
|
||||
if len(x) == 0 {
|
||||
return nil
|
||||
}
|
||||
max := x[0]
|
||||
for _, v := range x[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
out := make([]float64, len(x))
|
||||
var sum float64
|
||||
for i, v := range x {
|
||||
e := math.Exp(v - max)
|
||||
out[i] = e
|
||||
sum += e
|
||||
}
|
||||
for i := range out {
|
||||
out[i] /= sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MSELoss returns the mean squared error between predictions and
|
||||
// targets: mean((pred - target)^2), the default regression loss.
|
||||
func MSELoss(predictions, targets []float64) (float64, error) {
|
||||
if len(predictions) != len(targets) {
|
||||
return 0, fmt.Errorf("mse_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
|
||||
}
|
||||
if len(predictions) == 0 {
|
||||
return 0, fmt.Errorf("mse_loss: empty input")
|
||||
}
|
||||
var sum float64
|
||||
for i := range predictions {
|
||||
d := predictions[i] - targets[i]
|
||||
sum += d * d
|
||||
}
|
||||
return sum / float64(len(predictions)), nil
|
||||
}
|
||||
|
||||
// crossEntropyEps clamps predicted probabilities away from exactly 0
|
||||
// or 1 before taking a log — ln(0) is -Inf, which would make a single
|
||||
// confidently-wrong prediction blow up the whole averaged loss to
|
||||
// +Inf instead of just contributing a large-but-finite penalty. Every
|
||||
// production cross-entropy implementation (e.g. scikit-learn's
|
||||
// log_loss) clips for exactly this reason.
|
||||
const crossEntropyEps = 1e-12
|
||||
|
||||
// CrossEntropyLoss returns the mean binary cross-entropy between
|
||||
// predicted probabilities and 0/1 (or soft, in [0,1]) targets:
|
||||
// mean(-(y*ln(p) + (1-y)*ln(1-p))), the default classification loss.
|
||||
func CrossEntropyLoss(predictions, targets []float64) (float64, error) {
|
||||
if len(predictions) != len(targets) {
|
||||
return 0, fmt.Errorf("cross_entropy_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
|
||||
}
|
||||
if len(predictions) == 0 {
|
||||
return 0, fmt.Errorf("cross_entropy_loss: empty input")
|
||||
}
|
||||
var sum float64
|
||||
for i, p := range predictions {
|
||||
if p < 0 || p > 1 {
|
||||
return 0, fmt.Errorf("cross_entropy_loss: predictions must be probabilities in [0, 1], got %g at index %d", p, i)
|
||||
}
|
||||
p = math.Min(math.Max(p, crossEntropyEps), 1-crossEntropyEps)
|
||||
y := targets[i]
|
||||
sum += -(y*math.Log(p) + (1-y)*math.Log(1-p))
|
||||
}
|
||||
return sum / float64(len(predictions)), nil
|
||||
}
|
||||
|
||||
// LinearRegressionResult is a fitted ordinary-least-squares linear
|
||||
// model.
|
||||
type LinearRegressionResult struct {
|
||||
// Coefficients[0] is the intercept, Coefficients[1:] line up with
|
||||
// the feature columns of X in order.
|
||||
Coefficients []float64
|
||||
Predictions []float64
|
||||
Residuals []float64
|
||||
RSquared float64
|
||||
}
|
||||
|
||||
// LinearRegression fits y ≈ X·β + intercept by ordinary least squares
|
||||
// via the normal equations β = (XᵀX)^-1 Xᵀy, with an intercept column
|
||||
// of 1s prepended automatically (the caller supplies only the real
|
||||
// feature columns in X). The normal-equation approach is the direct,
|
||||
// closed-form solution to minimizing squared error — the textbook
|
||||
// ML-101 regression fit, as opposed to reaching it iteratively via
|
||||
// GradientDescent on the MSE loss.
|
||||
func LinearRegression(X Matrix, y []float64) (*LinearRegressionResult, error) {
|
||||
rows, cols, err := X.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
if rows != len(y) {
|
||||
return nil, fmt.Errorf("linear_regression: X has %d rows but y has %d entries", rows, len(y))
|
||||
}
|
||||
if rows <= cols {
|
||||
return nil, fmt.Errorf("linear_regression: need more observations (%d) than features+1 (%d) to fit uniquely", rows, cols+1)
|
||||
}
|
||||
|
||||
// Prepend the intercept column of 1s.
|
||||
design := make(Matrix, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
row := make([]float64, cols+1)
|
||||
row[0] = 1
|
||||
copy(row[1:], X[i])
|
||||
design[i] = row
|
||||
}
|
||||
|
||||
designT, err := Transpose(design)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
xtx, err := MatMul(designT, design)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
xtxInv, err := Inverse(xtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: features are collinear (XᵀX is singular): %w", err)
|
||||
}
|
||||
xty, err := MatVecMul(designT, y)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
beta, err := MatVecMul(xtxInv, xty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
|
||||
preds, err := MatVecMul(design, beta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
residuals := make([]float64, rows)
|
||||
var ssRes float64
|
||||
for i := range preds {
|
||||
residuals[i] = y[i] - preds[i]
|
||||
ssRes += residuals[i] * residuals[i]
|
||||
}
|
||||
meanY, _ := Mean(y)
|
||||
var ssTot float64
|
||||
for _, v := range y {
|
||||
d := v - meanY
|
||||
ssTot += d * d
|
||||
}
|
||||
rSquared := 1.0
|
||||
if ssTot > 0 {
|
||||
rSquared = 1 - ssRes/ssTot
|
||||
}
|
||||
|
||||
return &LinearRegressionResult{
|
||||
Coefficients: beta,
|
||||
Predictions: preds,
|
||||
Residuals: residuals,
|
||||
RSquared: rSquared,
|
||||
}, nil
|
||||
}
|
||||
135
pkg/mathkit/optimize.go
Normal file
135
pkg/mathkit/optimize.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// GradientDescentResult is the trace of one gradient-descent run.
|
||||
type GradientDescentResult struct {
|
||||
// Path holds the point visited at the start of every iteration,
|
||||
// Path[0] being the caller's starting point and the last entry
|
||||
// being the final point — so len(Path) == Iterations+1. Kept in
|
||||
// full (rather than just the final point) because seeing the
|
||||
// trajectory is usually the point of running this: it's how you
|
||||
// tell "converging nicely" apart from "diverging" or
|
||||
// "oscillating because the learning rate is too high".
|
||||
Path [][]float64
|
||||
// Values holds f(Path[i]) for each point in Path, same length.
|
||||
Values []float64
|
||||
// Converged is true if the gradient norm dropped below the
|
||||
// requested tolerance before Iterations ran out.
|
||||
Converged bool
|
||||
}
|
||||
|
||||
// GradientDescent runs plain (batch) gradient descent on expr, over
|
||||
// the variables listed (in order — that order defines the vector
|
||||
// space the descent happens in), starting at `start`, for at most
|
||||
// `iterations` steps of size `learningRate`. tol<=0 disables the
|
||||
// early-stop check (it always runs the full iteration count).
|
||||
//
|
||||
// This is deliberately vanilla gradient descent — no momentum, no
|
||||
// Adam-style adaptive rates — because the point of this tool is to
|
||||
// let a user see and reason about the bare update rule
|
||||
// x := x - lr * ∇f(x) that every fancier optimizer builds on, not to
|
||||
// re-implement a training framework.
|
||||
func GradientDescent(expr *Expr, variables []string, start []float64, learningRate float64, iterations int, tol float64) (*GradientDescentResult, error) {
|
||||
if len(variables) != len(start) {
|
||||
return nil, fmt.Errorf("gradient_descent: %d variable name(s) but start point has %d coordinate(s)", len(variables), len(start))
|
||||
}
|
||||
if learningRate <= 0 {
|
||||
return nil, fmt.Errorf("gradient_descent: learning_rate must be positive")
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 100
|
||||
}
|
||||
point := append([]float64(nil), start...)
|
||||
res := &GradientDescentResult{}
|
||||
addPoint := func(p []float64) error {
|
||||
vars := make(map[string]float64, len(variables))
|
||||
for i, name := range variables {
|
||||
vars[name] = p[i]
|
||||
}
|
||||
v, err := expr.Eval(vars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Path = append(res.Path, append([]float64(nil), p...))
|
||||
res.Values = append(res.Values, v)
|
||||
return nil
|
||||
}
|
||||
if err := addPoint(point); err != nil {
|
||||
return nil, fmt.Errorf("gradient_descent: evaluating start point: %w", err)
|
||||
}
|
||||
vars := make(map[string]float64, len(variables))
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
for i, name := range variables {
|
||||
vars[name] = point[i]
|
||||
}
|
||||
grad, err := Gradient(expr, variables, vars, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
|
||||
}
|
||||
if tol > 0 && Norm(grad, 2) < tol {
|
||||
res.Converged = true
|
||||
break
|
||||
}
|
||||
for i := range point {
|
||||
point[i] -= learningRate * grad[i]
|
||||
}
|
||||
if err := addPoint(point); err != nil {
|
||||
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
|
||||
}
|
||||
if math.IsNaN(point[0]) || math.IsInf(point[0], 0) {
|
||||
return nil, fmt.Errorf("gradient_descent: diverged (values went to NaN/Inf) — try a smaller learning_rate")
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// NewtonRootResult is the trace of one Newton's-method root-finding
|
||||
// run on a single-variable expression.
|
||||
type NewtonRootResult struct {
|
||||
Path []float64 // x_0, x_1, ..., x_final
|
||||
Converged bool
|
||||
}
|
||||
|
||||
// NewtonRoot finds a root of expr(variable) = 0 via Newton's method:
|
||||
// x_{n+1} = x_n - f(x_n)/f'(x_n), with f' estimated by central
|
||||
// difference (see Derivative). Any other free variables in expr must
|
||||
// be supplied in `fixed`. tol<=0 defaults to 1e-10.
|
||||
func NewtonRoot(expr *Expr, variable string, start float64, iterations int, tol float64, fixed map[string]float64) (*NewtonRootResult, error) {
|
||||
if iterations <= 0 {
|
||||
iterations = 100
|
||||
}
|
||||
if tol <= 0 {
|
||||
tol = 1e-10
|
||||
}
|
||||
x := start
|
||||
res := &NewtonRootResult{Path: []float64{x}}
|
||||
vars := cloneVars(fixed)
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
vars[variable] = x
|
||||
fx, err := expr.Eval(vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
|
||||
}
|
||||
if math.Abs(fx) < tol {
|
||||
res.Converged = true
|
||||
break
|
||||
}
|
||||
dfx, err := Derivative(expr, variable, vars, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
|
||||
}
|
||||
if math.Abs(dfx) < 1e-14 {
|
||||
return nil, fmt.Errorf("newton_root: derivative vanished near x=%g (flat spot) — try a different start", x)
|
||||
}
|
||||
x = x - fx/dfx
|
||||
res.Path = append(res.Path, x)
|
||||
if math.IsNaN(x) || math.IsInf(x, 0) {
|
||||
return nil, fmt.Errorf("newton_root: diverged (x went to NaN/Inf) — try a different start")
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
835
pkg/mathkit/solve.go
Normal file
835
pkg/mathkit/solve.go
Normal file
@@ -0,0 +1,835 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Solve dispatches a math problem by string operation name against a
|
||||
// loosely-typed params map — exactly the shape a JSON object (an LLM
|
||||
// tool-call argument blob, decoded with encoding/json into
|
||||
// map[string]any) takes once decoded. It exists as one layer above
|
||||
// the typed functions in linalg.go/calculus.go/statistics.go/
|
||||
// optimize.go/ml.go specifically so a caller at the JSON boundary
|
||||
// (services/agent/internal/infrastructure/toolexec/mlmath.go) has one
|
||||
// function to call and one error type to handle, instead of a type
|
||||
// switch per operation.
|
||||
//
|
||||
// Returns a human-readable report (not raw JSON or a bare float) on
|
||||
// success, because the caller here is ultimately an LLM deciding what
|
||||
// to do next — a labeled, explained answer is directly usable in its
|
||||
// response, where a bare number would have to be re-explained.
|
||||
func Solve(operation string, params map[string]any) (string, error) {
|
||||
op := strings.ToLower(strings.TrimSpace(operation))
|
||||
switch op {
|
||||
// --- general ---
|
||||
case "evaluate":
|
||||
return solveEvaluate(params)
|
||||
|
||||
// --- linear algebra ---
|
||||
case "matrix_add", "matrix_sub", "matrix_mul":
|
||||
return solveMatrixBinOp(op, params)
|
||||
case "matrix_transpose":
|
||||
return solveMatrixTranspose(params)
|
||||
case "matrix_determinant":
|
||||
return solveMatrixDeterminant(params)
|
||||
case "matrix_inverse":
|
||||
return solveMatrixInverse(params)
|
||||
case "matrix_rank":
|
||||
return solveMatrixRank(params)
|
||||
case "matrix_trace":
|
||||
return solveMatrixTrace(params)
|
||||
case "dot_product":
|
||||
return solveDotProduct(params)
|
||||
case "vector_norm":
|
||||
return solveVectorNorm(params)
|
||||
case "dominant_eigen":
|
||||
return solveDominantEigen(params)
|
||||
|
||||
// --- calculus ---
|
||||
case "derivative":
|
||||
return solveDerivative(params)
|
||||
case "gradient":
|
||||
return solveGradient(params)
|
||||
case "integral":
|
||||
return solveIntegral(params)
|
||||
|
||||
// --- optimization ---
|
||||
case "gradient_descent":
|
||||
return solveGradientDescent(params)
|
||||
case "newton_root":
|
||||
return solveNewtonRoot(params)
|
||||
|
||||
// --- statistics & probability ---
|
||||
case "mean":
|
||||
return solveReduce1(params, "mean", func(d []float64) (float64, error) { return Mean(d) })
|
||||
case "variance":
|
||||
return solveReduce1Pop(params, "variance", Variance)
|
||||
case "std_dev":
|
||||
return solveReduce1Pop(params, "std_dev", StdDev)
|
||||
case "median":
|
||||
return solveReduce1(params, "median", Median)
|
||||
case "covariance":
|
||||
return solveReduce2Pop(params, "covariance", Covariance)
|
||||
case "correlation":
|
||||
return solveReduce2(params, "correlation", Correlation)
|
||||
case "z_score_normalize":
|
||||
return solveNormalize(params, "z_score_normalize", ZScoreNormalize)
|
||||
case "min_max_normalize":
|
||||
return solveNormalize(params, "min_max_normalize", MinMaxNormalize)
|
||||
case "normal_pdf":
|
||||
return solveNormalDist(params, "normal_pdf", NormalPDF)
|
||||
case "normal_cdf":
|
||||
return solveNormalDist(params, "normal_cdf", NormalCDF)
|
||||
case "binomial_pmf":
|
||||
return solveBinomialPMF(params)
|
||||
|
||||
// --- ML primitives ---
|
||||
case "sigmoid":
|
||||
return solveElementwise(params, "sigmoid", Sigmoid)
|
||||
case "relu":
|
||||
return solveElementwise(params, "relu", ReLU)
|
||||
case "tanh":
|
||||
return solveElementwise(params, "tanh", math.Tanh)
|
||||
case "softmax":
|
||||
return solveSoftmax(params)
|
||||
case "mse_loss":
|
||||
return solveLoss(params, "mse_loss", MSELoss)
|
||||
case "cross_entropy_loss":
|
||||
return solveLoss(params, "cross_entropy_loss", CrossEntropyLoss)
|
||||
case "linear_regression":
|
||||
return solveLinearRegression(params)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown operation %q — see the tool description for the full list of supported operations", operation)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- param extraction helpers ----------
|
||||
//
|
||||
// Every helper below reports a specific, actionable error (which
|
||||
// field was wrong and why) rather than a generic type-assertion
|
||||
// panic recovery message, because these errors are fed straight back
|
||||
// to the calling model as the tool result — see toolexec's rpcFail /
|
||||
// IsError convention — and "field X must be a number, got a string"
|
||||
// is something the model can act on; "interface conversion failed"
|
||||
// is not.
|
||||
|
||||
func reqFloat(params map[string]any, key string) (float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing required field %q", key)
|
||||
}
|
||||
f, ok := toFloat(v)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("field %q must be a number", key)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func optFloat(params map[string]any, key string, fallback float64) float64 {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if f, ok := toFloat(v); ok {
|
||||
return f
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func optInt(params map[string]any, key string, fallback int) int {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if f, ok := toFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func optBool(params map[string]any, key string, fallback bool) bool {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func reqString(params map[string]any, key string) (string, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required field %q", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("field %q must be a non-empty string", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optString(params map[string]any, key, fallback string) string {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func reqFloatSlice(params map[string]any, key string) ([]float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required field %q (expected an array of numbers)", key)
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be an array of numbers", key)
|
||||
}
|
||||
out := make([]float64, len(raw))
|
||||
for i, x := range raw {
|
||||
f, ok := toFloat(x)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: element %d is not a number", key, i)
|
||||
}
|
||||
out[i] = f
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reqStringSlice(params map[string]any, key string) ([]string, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required field %q (expected an array of strings)", key)
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be an array of strings", key)
|
||||
}
|
||||
out := make([]string, len(raw))
|
||||
for i, x := range raw {
|
||||
s, ok := x.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: element %d is not a string", key, i)
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reqMatrix(params map[string]any, key string) (Matrix, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required field %q (expected a 2D array of numbers)", key)
|
||||
}
|
||||
rows, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be a 2D array of numbers, e.g. [[1,2],[3,4]]", key)
|
||||
}
|
||||
m := make(Matrix, len(rows))
|
||||
for i, r := range rows {
|
||||
rowRaw, ok := r.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: row %d is not an array", key, i)
|
||||
}
|
||||
row := make([]float64, len(rowRaw))
|
||||
for j, x := range rowRaw {
|
||||
f, ok := toFloat(x)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: row %d, column %d is not a number", key, i, j)
|
||||
}
|
||||
row[j] = f
|
||||
}
|
||||
m[i] = row
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// varMap builds the variable bindings an expression needs from an
|
||||
// "at" (or "variables") object field — {"x": 1, "y": 2} — falling
|
||||
// back to an empty map if the field is absent (a single-variable
|
||||
// expression evaluated via the "variable"/"at" pair still works).
|
||||
func varMap(params map[string]any, key string) (map[string]float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return map[string]float64{}, nil
|
||||
}
|
||||
raw, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be an object mapping variable names to numbers, e.g. {\"x\": 1.5}", key)
|
||||
}
|
||||
out := make(map[string]float64, len(raw))
|
||||
for k, x := range raw {
|
||||
f, ok := toFloat(x)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: value for %q is not a number", key, k)
|
||||
}
|
||||
out[k] = f
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseExprField(params map[string]any) (*Expr, error) {
|
||||
src, err := reqString(params, "expression")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expr, err := ParseExpr(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
func fmtFloat(f float64) string {
|
||||
if math.IsNaN(f) {
|
||||
return "NaN"
|
||||
}
|
||||
if math.IsInf(f, 1) {
|
||||
return "+Inf"
|
||||
}
|
||||
if math.IsInf(f, -1) {
|
||||
return "-Inf"
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.10f", f), "0"), ".")
|
||||
}
|
||||
|
||||
func fmtFloats(fs []float64) string {
|
||||
parts := make([]string, len(fs))
|
||||
for i, f := range fs {
|
||||
parts[i] = fmtFloat(f)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
|
||||
func fmtMatrix(m Matrix) string {
|
||||
var sb strings.Builder
|
||||
for _, row := range m {
|
||||
sb.WriteString(fmtFloats(row))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// ---------- operation implementations ----------
|
||||
|
||||
func solveEvaluate(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
vars, err := varMap(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := expr.Eval(vars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s = %s", expr.String(), fmtFloat(result)), nil
|
||||
}
|
||||
|
||||
func solveMatrixBinOp(op string, params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := reqMatrix(params, "b")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out Matrix
|
||||
switch op {
|
||||
case "matrix_add":
|
||||
out, err = MatAdd(a, b)
|
||||
case "matrix_sub":
|
||||
out, err = MatSub(a, b)
|
||||
case "matrix_mul":
|
||||
out, err = MatMul(a, b)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s result:\n%s", op, fmtMatrix(out)), nil
|
||||
}
|
||||
|
||||
func solveMatrixTranspose(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := Transpose(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("transpose result:\n%s", fmtMatrix(out)), nil
|
||||
}
|
||||
|
||||
func solveMatrixDeterminant(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
det, err := Determinant(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("det(A) = %s", fmtFloat(det)), nil
|
||||
}
|
||||
|
||||
func solveMatrixInverse(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
inv, err := Inverse(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("A^-1:\n%s", fmtMatrix(inv)), nil
|
||||
}
|
||||
|
||||
func solveMatrixRank(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r, err := Rank(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("rank(A) = %d", r), nil
|
||||
}
|
||||
|
||||
func solveMatrixTrace(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tr, err := Trace(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("trace(A) = %s", fmtFloat(tr)), nil
|
||||
}
|
||||
|
||||
func solveDotProduct(params map[string]any) (string, error) {
|
||||
a, err := reqFloatSlice(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := reqFloatSlice(params, "b")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d, err := Dot(a, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("a · b = %s", fmtFloat(d)), nil
|
||||
}
|
||||
|
||||
func solveVectorNorm(params map[string]any) (string, error) {
|
||||
a, err := reqFloatSlice(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := optFloat(params, "p", 2)
|
||||
n := Norm(a, p)
|
||||
label := fmt.Sprintf("L%g", p)
|
||||
if math.IsInf(p, 1) {
|
||||
label = "L∞"
|
||||
}
|
||||
return fmt.Sprintf("%s norm = %s", label, fmtFloat(n)), nil
|
||||
}
|
||||
|
||||
func solveDominantEigen(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iters := optInt(params, "iterations", 0)
|
||||
lambda, v, err := DominantEigen(a, iters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("dominant eigenvalue ≈ %s\neigenvector ≈ %s", fmtFloat(lambda), fmtFloats(v)), nil
|
||||
}
|
||||
|
||||
func solveDerivative(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variable, err := reqString(params, "variable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
at, err := varMap(params, "at")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, ok := at[variable]; !ok {
|
||||
at[variable] = optFloat(params, "value", 0)
|
||||
}
|
||||
h := optFloat(params, "h", 0)
|
||||
d, err := Derivative(expr, variable, at, h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("d/d%s[%s] at %s=%s ≈ %s", variable, expr.String(), variable, fmtFloat(at[variable]), fmtFloat(d)), nil
|
||||
}
|
||||
|
||||
func solveGradient(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variables, err := reqStringSlice(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
at, err := varMap(params, "at")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := optFloat(params, "h", 0)
|
||||
grad, err := Gradient(expr, variables, at, h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("∇[%s] with respect to %s ≈ %s", expr.String(), strings.Join(variables, ","), fmtFloats(grad)), nil
|
||||
}
|
||||
|
||||
func solveIntegral(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variable, err := reqString(params, "variable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
from, err := reqFloat(params, "from")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
to, err := reqFloat(params, "to")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
steps := optInt(params, "steps", 0)
|
||||
fixed, err := varMap(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := Integrate(expr, variable, from, to, steps, fixed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("∫[%s to %s] %s d%s ≈ %s", fmtFloat(from), fmtFloat(to), expr.String(), variable, fmtFloat(result)), nil
|
||||
}
|
||||
|
||||
func solveGradientDescent(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variables, err := reqStringSlice(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start, err := reqFloatSlice(params, "start")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lr, err := reqFloat(params, "learning_rate")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iterations := optInt(params, "iterations", 100)
|
||||
tol := optFloat(params, "tolerance", 1e-8)
|
||||
res, err := GradientDescent(expr, variables, start, lr, iterations, tol)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
last := len(res.Path) - 1
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "gradient descent on f(%s) = %s\n", strings.Join(variables, ","), expr.String())
|
||||
fmt.Fprintf(&sb, "start: %s -> f = %s\n", fmtFloats(res.Path[0]), fmtFloat(res.Values[0]))
|
||||
fmt.Fprintf(&sb, "ran %d step(s), converged=%v\n", last, res.Converged)
|
||||
fmt.Fprintf(&sb, "final: %s -> f = %s\n", fmtFloats(res.Path[last]), fmtFloat(res.Values[last]))
|
||||
// Show a short trajectory sample so the model can see the descent
|
||||
// shape without a huge wall of numbers for large iteration counts.
|
||||
sampleN := 5
|
||||
if last+1 <= sampleN*2 {
|
||||
for i := 0; i <= last; i++ {
|
||||
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < sampleN; i++ {
|
||||
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
|
||||
}
|
||||
sb.WriteString(" ...\n")
|
||||
for i := last - sampleN + 1; i <= last; i++ {
|
||||
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), nil
|
||||
}
|
||||
|
||||
func solveNewtonRoot(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variable, err := reqString(params, "variable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start, err := reqFloat(params, "start")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iterations := optInt(params, "iterations", 0)
|
||||
tol := optFloat(params, "tolerance", 0)
|
||||
fixed, err := varMap(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := NewtonRoot(expr, variable, start, iterations, tol, fixed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
last := res.Path[len(res.Path)-1]
|
||||
return fmt.Sprintf("newton's method on %s(%s)=0: converged=%v after %d step(s), root ≈ %s=%s",
|
||||
expr.String(), variable, res.Converged, len(res.Path)-1, variable, fmtFloat(last)), nil
|
||||
}
|
||||
|
||||
func solveReduce1(params map[string]any, name string, f func([]float64) (float64, error)) (string, error) {
|
||||
data, err := reqFloatSlice(params, "data")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := f(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveReduce1Pop(params map[string]any, name string, f func([]float64, bool) (float64, error)) (string, error) {
|
||||
data, err := reqFloatSlice(params, "data")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
population := optBool(params, "population", false)
|
||||
v, err := f(data, population)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kind := "sample"
|
||||
if population {
|
||||
kind = "population"
|
||||
}
|
||||
return fmt.Sprintf("%s %s(%s) = %s", kind, name, fmtFloats(data), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveReduce2(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
|
||||
x, err := reqFloatSlice(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
y, err := reqFloatSlice(params, "y")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := f(x, y)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(x, y) = %s", name, fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveReduce2Pop(params map[string]any, name string, f func([]float64, []float64, bool) (float64, error)) (string, error) {
|
||||
x, err := reqFloatSlice(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
y, err := reqFloatSlice(params, "y")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
population := optBool(params, "population", false)
|
||||
v, err := f(x, y, population)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kind := "sample"
|
||||
if population {
|
||||
kind = "population"
|
||||
}
|
||||
return fmt.Sprintf("%s %s(x, y) = %s", kind, name, fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveNormalize(params map[string]any, name string, f func([]float64) ([]float64, error)) (string, error) {
|
||||
data, err := reqFloatSlice(params, "data")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := f(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
|
||||
}
|
||||
|
||||
func solveNormalDist(params map[string]any, name string, f func(float64, float64, float64) (float64, error)) (string, error) {
|
||||
x, err := reqFloat(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mean := optFloat(params, "mean", 0)
|
||||
std := optFloat(params, "std_dev", 1)
|
||||
v, err := f(x, mean, std)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(x=%s, mean=%s, std_dev=%s) = %s", name, fmtFloat(x), fmtFloat(mean), fmtFloat(std), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveBinomialPMF(params map[string]any) (string, error) {
|
||||
k := optInt(params, "k", -1)
|
||||
if k < 0 {
|
||||
return "", fmt.Errorf("missing required field %q", "k")
|
||||
}
|
||||
n := optInt(params, "n", -1)
|
||||
if n < 0 {
|
||||
return "", fmt.Errorf("missing required field %q", "n")
|
||||
}
|
||||
p, err := reqFloat(params, "p")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := BinomialPMF(k, n, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("P(X=%d) for X~Binomial(n=%d, p=%s) = %s", k, n, fmtFloat(p), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveElementwise(params map[string]any, name string, f func(float64) float64) (string, error) {
|
||||
if raw, ok := params["x"].([]any); ok {
|
||||
data := make([]float64, len(raw))
|
||||
for i, v := range raw {
|
||||
fv, ok := toFloat(v)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("field \"x\": element %d is not a number", i)
|
||||
}
|
||||
data[i] = fv
|
||||
}
|
||||
out := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
out[i] = f(v)
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
|
||||
}
|
||||
x, err := reqFloat(params, "x")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(`field "x" must be a number or an array of numbers: %w`, err)
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloat(x), fmtFloat(f(x))), nil
|
||||
}
|
||||
|
||||
func solveSoftmax(params map[string]any) (string, error) {
|
||||
x, err := reqFloatSlice(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := Softmax(x)
|
||||
sum := 0.0
|
||||
for _, v := range out {
|
||||
sum += v
|
||||
}
|
||||
return fmt.Sprintf("softmax(%s) = %s (sums to %s)", fmtFloats(x), fmtFloats(out), fmtFloat(sum)), nil
|
||||
}
|
||||
|
||||
func solveLoss(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
|
||||
pred, err := reqFloatSlice(params, "predictions")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target, err := reqFloatSlice(params, "targets")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := f(pred, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(predictions, targets) = %s", name, fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveLinearRegression(params map[string]any) (string, error) {
|
||||
X, err := reqMatrix(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
y, err := reqFloatSlice(params, "y")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := LinearRegression(X, y)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "ordinary least squares fit: y ≈ %s", fmtFloat(res.Coefficients[0]))
|
||||
for i, c := range res.Coefficients[1:] {
|
||||
fmt.Fprintf(&sb, " + %s*x%d", fmtFloat(c), i+1)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\nR² = %s\n", fmtFloat(res.RSquared))
|
||||
fmt.Fprintf(&sb, "predictions: %s\n", fmtFloats(res.Predictions))
|
||||
fmt.Fprintf(&sb, "residuals: %s", fmtFloats(res.Residuals))
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// Operations returns the sorted list of every operation name Solve
|
||||
// accepts — used by the agent's tool-catalog description so the
|
||||
// supported operation list is generated from the real dispatch table
|
||||
// instead of hand-copied prose that can drift out of sync with it.
|
||||
func Operations() []string {
|
||||
ops := []string{
|
||||
"evaluate",
|
||||
"matrix_add", "matrix_sub", "matrix_mul", "matrix_transpose", "matrix_determinant",
|
||||
"matrix_inverse", "matrix_rank", "matrix_trace", "dot_product", "vector_norm", "dominant_eigen",
|
||||
"derivative", "gradient", "integral",
|
||||
"gradient_descent", "newton_root",
|
||||
"mean", "variance", "std_dev", "median", "covariance", "correlation",
|
||||
"z_score_normalize", "min_max_normalize", "normal_pdf", "normal_cdf", "binomial_pmf",
|
||||
"sigmoid", "relu", "tanh", "softmax", "mse_loss", "cross_entropy_loss", "linear_regression",
|
||||
}
|
||||
sort.Strings(ops)
|
||||
return ops
|
||||
}
|
||||
224
pkg/mathkit/statistics.go
Normal file
224
pkg/mathkit/statistics.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Mean returns the arithmetic mean of data.
|
||||
func Mean(data []float64) (float64, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, fmt.Errorf("mean: empty data set")
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range data {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(data)), nil
|
||||
}
|
||||
|
||||
// Variance returns the variance of data. population=true divides by
|
||||
// n (the population variance); population=false (the default for ML
|
||||
// work, where `data` is almost always a sample) divides by n-1
|
||||
// (Bessel's correction, the unbiased sample-variance estimator).
|
||||
func Variance(data []float64, population bool) (float64, error) {
|
||||
n := len(data)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("variance: empty data set")
|
||||
}
|
||||
if !population && n < 2 {
|
||||
return 0, fmt.Errorf("variance: sample variance needs at least 2 points (use population variance for a single point)")
|
||||
}
|
||||
mean, _ := Mean(data)
|
||||
var ss float64
|
||||
for _, v := range data {
|
||||
d := v - mean
|
||||
ss += d * d
|
||||
}
|
||||
denom := float64(n)
|
||||
if !population {
|
||||
denom = float64(n - 1)
|
||||
}
|
||||
return ss / denom, nil
|
||||
}
|
||||
|
||||
// StdDev returns the standard deviation (sqrt of Variance).
|
||||
func StdDev(data []float64, population bool) (float64, error) {
|
||||
v, err := Variance(data, population)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return math.Sqrt(v), nil
|
||||
}
|
||||
|
||||
// Median returns the median of data (average of the two middle
|
||||
// values for an even-length slice). Does not mutate the input.
|
||||
func Median(data []float64) (float64, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, fmt.Errorf("median: empty data set")
|
||||
}
|
||||
sorted := append([]float64(nil), data...)
|
||||
insertionSort(sorted)
|
||||
n := len(sorted)
|
||||
if n%2 == 1 {
|
||||
return sorted[n/2], nil
|
||||
}
|
||||
return (sorted[n/2-1] + sorted[n/2]) / 2, nil
|
||||
}
|
||||
|
||||
// insertionSort is O(n^2) but this package only ever sorts small
|
||||
// stats samples, where it beats sort.Float64s' overhead and keeps
|
||||
// the package allocation-free apart from the clone itself.
|
||||
func insertionSort(a []float64) {
|
||||
for i := 1; i < len(a); i++ {
|
||||
key := a[i]
|
||||
j := i - 1
|
||||
for j >= 0 && a[j] > key {
|
||||
a[j+1] = a[j]
|
||||
j--
|
||||
}
|
||||
a[j+1] = key
|
||||
}
|
||||
}
|
||||
|
||||
// Covariance returns the sample (or population) covariance of two
|
||||
// equal-length data sets — the building block of both Correlation and
|
||||
// a PCA covariance matrix.
|
||||
func Covariance(x, y []float64, population bool) (float64, error) {
|
||||
if len(x) != len(y) {
|
||||
return 0, fmt.Errorf("covariance: data sets have different lengths (%d vs %d)", len(x), len(y))
|
||||
}
|
||||
n := len(x)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("covariance: empty data set")
|
||||
}
|
||||
if !population && n < 2 {
|
||||
return 0, fmt.Errorf("covariance: sample covariance needs at least 2 points")
|
||||
}
|
||||
mx, _ := Mean(x)
|
||||
my, _ := Mean(y)
|
||||
var sum float64
|
||||
for i := 0; i < n; i++ {
|
||||
sum += (x[i] - mx) * (y[i] - my)
|
||||
}
|
||||
denom := float64(n)
|
||||
if !population {
|
||||
denom = float64(n - 1)
|
||||
}
|
||||
return sum / denom, nil
|
||||
}
|
||||
|
||||
// Correlation returns the Pearson correlation coefficient of x and y,
|
||||
// in [-1, 1].
|
||||
func Correlation(x, y []float64) (float64, error) {
|
||||
cov, err := Covariance(x, y, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("correlation: %w", err)
|
||||
}
|
||||
sx, err := StdDev(x, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("correlation: %w", err)
|
||||
}
|
||||
sy, err := StdDev(y, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("correlation: %w", err)
|
||||
}
|
||||
if sx == 0 || sy == 0 {
|
||||
return 0, fmt.Errorf("correlation: a constant data set has undefined correlation")
|
||||
}
|
||||
return cov / (sx * sy), nil
|
||||
}
|
||||
|
||||
// ZScoreNormalize returns (x - mean) / stddev for each point — the
|
||||
// standardization every ML feature-scaling pipeline applies before
|
||||
// training a distance- or gradient-sensitive model.
|
||||
func ZScoreNormalize(data []float64) ([]float64, error) {
|
||||
mean, err := Mean(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("z_score_normalize: %w", err)
|
||||
}
|
||||
std, err := StdDev(data, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("z_score_normalize: %w", err)
|
||||
}
|
||||
if std == 0 {
|
||||
return nil, fmt.Errorf("z_score_normalize: a constant data set has zero standard deviation")
|
||||
}
|
||||
out := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
out[i] = (v - mean) / std
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MinMaxNormalize rescales data to [0, 1].
|
||||
func MinMaxNormalize(data []float64) ([]float64, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("min_max_normalize: empty data set")
|
||||
}
|
||||
lo, hi := data[0], data[0]
|
||||
for _, v := range data {
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
if hi == lo {
|
||||
return nil, fmt.Errorf("min_max_normalize: a constant data set has zero range")
|
||||
}
|
||||
out := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
out[i] = (v - lo) / (hi - lo)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalPDF is the probability density function of N(mean, stdDev^2)
|
||||
// at x.
|
||||
func NormalPDF(x, mean, stdDev float64) (float64, error) {
|
||||
if stdDev <= 0 {
|
||||
return 0, fmt.Errorf("normal_pdf: std_dev must be positive")
|
||||
}
|
||||
z := (x - mean) / stdDev
|
||||
return math.Exp(-0.5*z*z) / (stdDev * math.Sqrt(2*math.Pi)), nil
|
||||
}
|
||||
|
||||
// NormalCDF is the cumulative distribution function of N(mean,
|
||||
// stdDev^2) at x, computed via math.Erf (the standard closed form:
|
||||
// Φ(x) = (1 + erf((x-μ)/(σ√2))) / 2).
|
||||
func NormalCDF(x, mean, stdDev float64) (float64, error) {
|
||||
if stdDev <= 0 {
|
||||
return 0, fmt.Errorf("normal_cdf: std_dev must be positive")
|
||||
}
|
||||
return 0.5 * (1 + math.Erf((x-mean)/(stdDev*math.Sqrt2))), nil
|
||||
}
|
||||
|
||||
// BinomialPMF returns P(X = k) for X ~ Binomial(n, p).
|
||||
func BinomialPMF(k, n int, p float64) (float64, error) {
|
||||
if n < 0 || k < 0 || k > n {
|
||||
return 0, fmt.Errorf("binomial_pmf: require 0 <= k <= n, got k=%d n=%d", k, n)
|
||||
}
|
||||
if p < 0 || p > 1 {
|
||||
return 0, fmt.Errorf("binomial_pmf: p must be in [0, 1], got %g", p)
|
||||
}
|
||||
return binomialCoeff(n, k) * math.Pow(p, float64(k)) * math.Pow(1-p, float64(n-k)), nil
|
||||
}
|
||||
|
||||
// binomialCoeff computes C(n, k) via the multiplicative formula,
|
||||
// which stays in float64 range far longer than computing n!/(k!(n-k)!)
|
||||
// from raw factorials would.
|
||||
func binomialCoeff(n, k int) float64 {
|
||||
if k < 0 || k > n {
|
||||
return 0
|
||||
}
|
||||
if k > n-k {
|
||||
k = n - k
|
||||
}
|
||||
result := 1.0
|
||||
for i := 0; i < k; i++ {
|
||||
result *= float64(n-i) / float64(i+1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
557
pkg/officekit/docx.go
Normal file
557
pkg/officekit/docx.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParaStyle is the small, Claude-Code-skill-inspired vocabulary of
|
||||
// paragraph styles the model is offered — deliberately not "any OOXML
|
||||
// style name", so the model can never need to type XML to format
|
||||
// something.
|
||||
type ParaStyle string
|
||||
|
||||
const (
|
||||
StyleNormal ParaStyle = "normal"
|
||||
StyleHeading1 ParaStyle = "heading1"
|
||||
StyleHeading2 ParaStyle = "heading2"
|
||||
StyleHeading3 ParaStyle = "heading3"
|
||||
StyleBullet ParaStyle = "bullet"
|
||||
)
|
||||
|
||||
// Paragraph is one structured paragraph of a Word document, as the
|
||||
// model supplies it via the create_document/edit_document tool
|
||||
// arguments — plain fields, never markup.
|
||||
type Paragraph struct {
|
||||
Text string
|
||||
Style ParaStyle
|
||||
Bold bool
|
||||
Italic bool
|
||||
Underline bool
|
||||
}
|
||||
|
||||
const docxDocumentPart = "word/document.xml"
|
||||
|
||||
// --- word/document.xml, built with encoding/xml so every run of text
|
||||
// is correctly escaped no matter what characters it contains (a stray
|
||||
// "<" or "&" in a paragraph's text becomes "<"/"&" the same way
|
||||
// Go's XML encoder would escape it anywhere else — it can never end up
|
||||
// as literal, parseable-looking markup). ---
|
||||
|
||||
// wDocument's XMLName tag uses Go's "namespace-URI local-name" form
|
||||
// (space-separated, not a colon prefix — see the package doc comment
|
||||
// in zipkit.go's header and this file's note above BuildDocx for why
|
||||
// colon-prefixed tags like "w:document" don't round-trip). Marshal
|
||||
// emits this as the default namespace (xmlns="...") on the root
|
||||
// element; every child struct below uses a plain, unprefixed local
|
||||
// name with no namespace component, which both (a) is valid XML —
|
||||
// unprefixed descendants simply inherit the nearest enclosing default
|
||||
// namespace — and (b) unmarshals back correctly, because an
|
||||
// unnamespaced field tag matches an element by local name only,
|
||||
// regardless of which namespace it actually resolved to.
|
||||
type wDocument struct {
|
||||
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main document"`
|
||||
Body wBody `xml:"body"`
|
||||
}
|
||||
|
||||
type wBody struct {
|
||||
Paragraphs []wParagraph `xml:"p"`
|
||||
SectPr wSectPr `xml:"sectPr"`
|
||||
}
|
||||
|
||||
type wSectPr struct {
|
||||
PgSz wPgSz `xml:"pgSz"`
|
||||
}
|
||||
|
||||
type wPgSz struct {
|
||||
W int `xml:"w,attr"`
|
||||
H int `xml:"h,attr"`
|
||||
}
|
||||
|
||||
type wParagraph struct {
|
||||
PPr *wPPr `xml:"pPr,omitempty"`
|
||||
Run wRun `xml:"r"`
|
||||
}
|
||||
|
||||
type wPPr struct {
|
||||
NumPr *wNumPr `xml:"numPr,omitempty"`
|
||||
}
|
||||
|
||||
type wNumPr struct {
|
||||
Ilvl wVal `xml:"ilvl"`
|
||||
NumId wVal `xml:"numId"`
|
||||
}
|
||||
|
||||
type wVal struct {
|
||||
Val int `xml:"val,attr"`
|
||||
}
|
||||
|
||||
type wRun struct {
|
||||
RPr *wRPr `xml:"rPr,omitempty"`
|
||||
Text wText `xml:"t"`
|
||||
}
|
||||
|
||||
// wRPr's field order follows OOXML's CT_RPr schema sequence (b, i,
|
||||
// sz, u, ...) — Go's xml.Marshal emits struct fields in declaration
|
||||
// order, and while real-world readers (Word included) tend to be
|
||||
// lenient about run-property order, writing it correctly costs
|
||||
// nothing and keeps generated files schema-valid, not just
|
||||
// Word-openable.
|
||||
type wRPr struct {
|
||||
Bold *struct{} `xml:"b,omitempty"`
|
||||
Italic *struct{} `xml:"i,omitempty"`
|
||||
Sz *wSz `xml:"sz,omitempty"`
|
||||
Underline *wU `xml:"u,omitempty"`
|
||||
}
|
||||
|
||||
type wSz struct {
|
||||
Val int `xml:"val,attr"`
|
||||
}
|
||||
|
||||
type wU struct {
|
||||
Val string `xml:"val,attr"`
|
||||
}
|
||||
|
||||
type wText struct {
|
||||
Space string `xml:"xml:space,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// wParagraphFragment is the same content as wParagraph, but with its
|
||||
// own explicit WordprocessingML namespace declaration on XMLName.
|
||||
// It exists only for AppendDocxParagraph, which marshals ONE paragraph
|
||||
// standalone and splices its bytes directly into an existing
|
||||
// document.xml rather than re-marshaling the whole document (see that
|
||||
// function's doc comment for why). A spliced fragment has no parent
|
||||
// element to inherit a namespace from if the target document uses
|
||||
// Word's own "w:"-prefixed convention instead of this package's
|
||||
// default-namespace one — so this type redeclares the namespace
|
||||
// locally on the fragment's own root element. XML lets any element do
|
||||
// that regardless of its surrounding document's prefix convention, and
|
||||
// every OOXML-consuming application (Word included) resolves element
|
||||
// identity by namespace URI + local name, not by literal prefix text,
|
||||
// so `<p xmlns="...wordprocessingml.../main">` and `<w:p>` are the same
|
||||
// element as far as Word is concerned. wParagraph itself deliberately
|
||||
// keeps no such tag, so BuildDocx's normal output — where every
|
||||
// paragraph already inherits the root's default namespace — doesn't
|
||||
// pay for a redundant xmlns on every single paragraph.
|
||||
type wParagraphFragment struct {
|
||||
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
|
||||
PPr *wPPr `xml:"pPr,omitempty"`
|
||||
Run wRun `xml:"r"`
|
||||
}
|
||||
|
||||
func newParagraphFragment(p Paragraph) wParagraphFragment {
|
||||
wp := toWParagraph(p)
|
||||
return wParagraphFragment{PPr: wp.PPr, Run: wp.Run}
|
||||
}
|
||||
|
||||
// runProps maps the model's small formatting vocabulary (bold, italic,
|
||||
// underline, plus the size/boldness implied by a heading style) to a
|
||||
// run's rPr, or nil if the run needs no rPr at all (the common case:
|
||||
// a plain, unstyled normal/bullet paragraph).
|
||||
func runProps(p Paragraph) *wRPr {
|
||||
bold, italic, underline := p.Bold, p.Italic, p.Underline
|
||||
var sz *wSz
|
||||
switch p.Style {
|
||||
case StyleHeading1:
|
||||
bold = true
|
||||
sz = &wSz{Val: 32} // 16pt
|
||||
case StyleHeading2:
|
||||
bold = true
|
||||
sz = &wSz{Val: 28} // 14pt
|
||||
case StyleHeading3:
|
||||
bold = true
|
||||
sz = &wSz{Val: 24} // 12pt
|
||||
}
|
||||
if !bold && !italic && !underline && sz == nil {
|
||||
return nil
|
||||
}
|
||||
rpr := &wRPr{Sz: sz}
|
||||
if bold {
|
||||
rpr.Bold = &struct{}{}
|
||||
}
|
||||
if italic {
|
||||
rpr.Italic = &struct{}{}
|
||||
}
|
||||
if underline {
|
||||
rpr.Underline = &wU{Val: "single"}
|
||||
}
|
||||
return rpr
|
||||
}
|
||||
|
||||
func toWParagraph(p Paragraph) wParagraph {
|
||||
wp := wParagraph{
|
||||
Run: wRun{
|
||||
RPr: runProps(p),
|
||||
Text: wText{Space: "preserve", Value: p.Text},
|
||||
},
|
||||
}
|
||||
if p.Style == StyleBullet {
|
||||
wp.PPr = &wPPr{NumPr: &wNumPr{Ilvl: wVal{Val: 0}, NumId: wVal{Val: 1}}}
|
||||
}
|
||||
return wp
|
||||
}
|
||||
|
||||
// BuildDocx renders paragraphs into a complete, valid .docx file.
|
||||
func BuildDocx(paragraphs []Paragraph) ([]byte, error) {
|
||||
body := wBody{SectPr: wSectPr{PgSz: wPgSz{W: 12240, H: 15840}}}
|
||||
for _, p := range paragraphs {
|
||||
body.Paragraphs = append(body.Paragraphs, toWParagraph(p))
|
||||
}
|
||||
doc := wDocument{Body: body}
|
||||
docXML, err := xml.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal document.xml: %w", err)
|
||||
}
|
||||
|
||||
// A minimal numbering part is included (word/numbering.xml) only so
|
||||
// StyleBullet's w:numId="1" resolves to something rather than being
|
||||
// a dangling reference; every bullet paragraph shares the same
|
||||
// single bullet-list definition.
|
||||
parts := []zipPart{
|
||||
{Name: "[Content_Types].xml", Body: docxContentTypes},
|
||||
{Name: "_rels/.rels", Body: docxRootRels},
|
||||
{Name: "word/_rels/document.xml.rels", Body: docxDocumentRels},
|
||||
{Name: docxDocumentPart, Body: xml.Header + string(docXML)},
|
||||
{Name: "word/numbering.xml", Body: docxNumberingXML},
|
||||
{Name: "docProps/core.xml", Body: officeCoreProps},
|
||||
{Name: "docProps/app.xml", Body: docxAppProps},
|
||||
}
|
||||
return buildZip(parts)
|
||||
}
|
||||
|
||||
// ReadDocxText extracts the plain text of a .docx file, one line per
|
||||
// paragraph (bullets prefixed with "- "), for the read_document tool.
|
||||
// This is what the model actually sees — never the underlying XML.
|
||||
//
|
||||
// This walks document.xml's raw token stream rather than unmarshaling
|
||||
// into a struct that only models direct <w:body> children: a real
|
||||
// document's paragraphs can be nested inside a table cell
|
||||
// (<w:tbl>/<w:tr>/<w:tc>/<w:p>), and a struct-based Unmarshal into
|
||||
// wBody.Paragraphs would silently skip every one of those, making a
|
||||
// user-uploaded document with tables look emptier than it is. The
|
||||
// token walk picks up every <w:p> wherever it occurs, in document
|
||||
// order; it still doesn't reconstruct a table's row/column grid (see
|
||||
// the docx skill's documented limitation) — each cell's paragraphs
|
||||
// simply appear as their own lines.
|
||||
func ReadDocxText(data []byte) (string, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
lines, err := walkParagraphText(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("officekit: parse document.xml: %w", err)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// walkParagraphText returns one text line per <w:p> paragraph found
|
||||
// anywhere in document.xml, in document order: the concatenation of
|
||||
// every <w:t> run's text within it, with <w:tab/> rendered as a
|
||||
// literal tab, prefixed "- " when the paragraph carries a <w:numPr>
|
||||
// (list) property. Matches by local element name only (see the
|
||||
// package-level namespace note on wDocument), so it works the same
|
||||
// whether the source uses Word's "w:" prefix or this package's own
|
||||
// default-namespace convention.
|
||||
func walkParagraphText(raw []byte) ([]string, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var (
|
||||
lines []string
|
||||
cur strings.Builder
|
||||
inPara bool
|
||||
inText bool
|
||||
isBullet bool
|
||||
)
|
||||
flushPara := func() {
|
||||
text := cur.String()
|
||||
if isBullet {
|
||||
text = "- " + text
|
||||
}
|
||||
lines = append(lines, text)
|
||||
cur.Reset()
|
||||
inPara, isBullet = false, false
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch t.Name.Local {
|
||||
case "p":
|
||||
inPara = true
|
||||
case "t":
|
||||
inText = true
|
||||
case "tab":
|
||||
if inPara {
|
||||
cur.WriteByte('\t')
|
||||
}
|
||||
case "numPr":
|
||||
if inPara {
|
||||
isBullet = true
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
switch t.Name.Local {
|
||||
case "p":
|
||||
if inPara {
|
||||
flushPara()
|
||||
}
|
||||
case "t":
|
||||
inText = false
|
||||
}
|
||||
case xml.CharData:
|
||||
if inPara && inText {
|
||||
cur.Write(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// AppendDocxParagraph adds one paragraph as the new last content
|
||||
// element of the document body and returns the new file bytes.
|
||||
//
|
||||
// This splices the new paragraph's marshaled XML directly into
|
||||
// document.xml's existing raw bytes, immediately before the body's
|
||||
// trailing <w:sectPr> (OOXML requires section properties to be the
|
||||
// body's last child) — or immediately before </w:body> if there's no
|
||||
// sectPr at all — instead of the unmarshal-modify-marshal round trip
|
||||
// this used to share with ReplaceDocxText. That round trip only ever
|
||||
// modeled direct <w:body> children (paragraphs and the trailing
|
||||
// sectPr), so re-marshaling it back would silently drop everything
|
||||
// else a real document has: tables, images, headers, multiple runs
|
||||
// per paragraph, tracked changes. Splicing means every byte of the
|
||||
// original document this package doesn't model survives untouched.
|
||||
func AppendDocxParagraph(data []byte, p Paragraph) ([]byte, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
paraXML, err := xml.Marshal(newParagraphFragment(p))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal paragraph: %w", err)
|
||||
}
|
||||
at := bodyInsertionPoint(raw)
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := make([]byte, 0, len(raw)+len(paraXML))
|
||||
newDoc = append(newDoc, raw[:at]...)
|
||||
newDoc = append(newDoc, paraXML...)
|
||||
newDoc = append(newDoc, raw[at:]...)
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// bodyInsertionPoint returns the byte offset in document.xml where new
|
||||
// body content should be spliced in: right before the body's trailing
|
||||
// <w:sectPr> if present, else right before </w:body>. Returns -1 if
|
||||
// neither tag is found at all (not a recognizable Word document body).
|
||||
func bodyInsertionPoint(doc []byte) int {
|
||||
for _, tag := range []string{"<w:sectPr", "<sectPr"} {
|
||||
if i := lastTagIndex(doc, tag); i >= 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for _, tag := range []string{"</w:body>", "</body>"} {
|
||||
if i := bytes.Index(doc, []byte(tag)); i >= 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// lastTagIndex finds the last byte offset of an opening tag whose name
|
||||
// is exactly tag (e.g. "<w:sectPr"), rejecting a longer tag name that
|
||||
// merely starts with the same bytes — such as the tracked-changes
|
||||
// element "<w:sectPrChange", which would otherwise look like a match.
|
||||
func lastTagIndex(doc []byte, tag string) int {
|
||||
needle := []byte(tag)
|
||||
end := len(doc)
|
||||
for {
|
||||
i := bytes.LastIndex(doc[:end], needle)
|
||||
if i < 0 {
|
||||
return -1
|
||||
}
|
||||
after := i + len(needle)
|
||||
if after >= len(doc) {
|
||||
return i
|
||||
}
|
||||
switch doc[after] {
|
||||
case ' ', '\t', '\n', '\r', '>', '/':
|
||||
return i
|
||||
}
|
||||
end = i // false positive (e.g. sectPrChange); keep searching earlier
|
||||
}
|
||||
}
|
||||
|
||||
// wTextElementRe matches one WordprocessingML text run's element —
|
||||
// <w:t>...</w:t>, the unprefixed <t>...</t> this package's own output
|
||||
// uses, or any other single-letter-prefixed variant — capturing the
|
||||
// opening tag, the raw (still XML-escaped) inner text, and the closing
|
||||
// tag separately, so ReplaceDocxText only ever rewrites the inner text
|
||||
// and leaves the tag itself, and everything outside it, byte-for-byte
|
||||
// untouched. The boundary check after the local name (next character
|
||||
// must be whitespace, "/", or ">") excludes longer names that merely
|
||||
// start with "t", such as "tab", "tbl", or "tc".
|
||||
var wTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?t(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?t>)`)
|
||||
|
||||
// ReplaceDocxText replaces every exact, literal occurrence of find
|
||||
// with replace inside the document's text runs — including runs
|
||||
// nested inside a table cell, which the old paragraph-struct-based
|
||||
// approach never saw at all — and returns the new file bytes plus how
|
||||
// many text runs were changed. It rewrites only the matched <w:t>
|
||||
// elements' inner text in document.xml's raw bytes; every other part
|
||||
// of the document (formatting, tables, images, headers) passes through
|
||||
// completely untouched.
|
||||
//
|
||||
// Because Word often splits a single sentence across more than one run
|
||||
// (spell-check and revision tracking both do this), a phrase that
|
||||
// spans a run boundary may not be found even though it reads as one
|
||||
// phrase in Word — read_document first, and prefer a shorter, more
|
||||
// literal match string when a longer one doesn't seem to apply.
|
||||
func ReplaceDocxText(data []byte, find, replace string) ([]byte, int, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
|
||||
changed := 0
|
||||
newDoc := wTextElementRe.ReplaceAllFunc(raw, func(match []byte) []byte {
|
||||
groups := wTextElementRe.FindSubmatch(match)
|
||||
open, inner, closeTag := groups[1], groups[2], groups[3]
|
||||
decoded := xmlUnescapeText(inner)
|
||||
if !strings.Contains(decoded, find) {
|
||||
return match
|
||||
}
|
||||
changed++
|
||||
replaced := strings.ReplaceAll(decoded, find, replace)
|
||||
out := make([]byte, 0, len(open)+len(replaced)+len(closeTag))
|
||||
out = append(out, open...)
|
||||
out = append(out, []byte(xmlEscapeText(replaced))...)
|
||||
out = append(out, closeTag...)
|
||||
return out
|
||||
})
|
||||
if changed == 0 {
|
||||
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
|
||||
}
|
||||
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
return out, changed, err
|
||||
}
|
||||
|
||||
// xmlUnescapeText reverses XML's five predefined entities plus numeric
|
||||
// character references (&#NNN; / &#xHH;) — the inverse of
|
||||
// xmlEscapeText (pptx.go) — so ReplaceDocxText can match and replace
|
||||
// against the plain text the model actually passed, not its
|
||||
// XML-escaped form. An unknown or malformed entity is left verbatim
|
||||
// rather than erroring: this only ever feeds a literal-substring
|
||||
// comparison, so there's nothing to fail loudly about.
|
||||
func xmlUnescapeText(b []byte) string {
|
||||
s := string(b)
|
||||
if !strings.ContainsRune(s, '&') {
|
||||
return s
|
||||
}
|
||||
var out strings.Builder
|
||||
for {
|
||||
i := strings.IndexByte(s, '&')
|
||||
if i < 0 {
|
||||
out.WriteString(s)
|
||||
break
|
||||
}
|
||||
out.WriteString(s[:i])
|
||||
rest := s[i+1:]
|
||||
semi := strings.IndexByte(rest, ';')
|
||||
if semi < 0 {
|
||||
out.WriteByte('&')
|
||||
s = rest
|
||||
continue
|
||||
}
|
||||
entity, tail := rest[:semi], rest[semi+1:]
|
||||
switch {
|
||||
case entity == "amp":
|
||||
out.WriteByte('&')
|
||||
case entity == "lt":
|
||||
out.WriteByte('<')
|
||||
case entity == "gt":
|
||||
out.WriteByte('>')
|
||||
case entity == "apos":
|
||||
out.WriteByte('\'')
|
||||
case entity == "quot":
|
||||
out.WriteByte('"')
|
||||
case strings.HasPrefix(entity, "#x"), strings.HasPrefix(entity, "#X"):
|
||||
if n, err := strconv.ParseInt(entity[2:], 16, 32); err == nil {
|
||||
out.WriteRune(rune(n))
|
||||
} else {
|
||||
out.WriteByte('&')
|
||||
out.WriteString(entity)
|
||||
out.WriteByte(';')
|
||||
}
|
||||
case strings.HasPrefix(entity, "#"):
|
||||
if n, err := strconv.ParseInt(entity[1:], 10, 32); err == nil {
|
||||
out.WriteRune(rune(n))
|
||||
} else {
|
||||
out.WriteByte('&')
|
||||
out.WriteString(entity)
|
||||
out.WriteByte(';')
|
||||
}
|
||||
default:
|
||||
out.WriteByte('&')
|
||||
out.WriteString(entity)
|
||||
out.WriteByte(';')
|
||||
}
|
||||
s = tail
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
const docxContentTypes = xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||
</Types>`
|
||||
|
||||
const docxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const docxDocumentRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const docxNumberingXML = xml.Header + `<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:abstractNum w:abstractNumId="0">
|
||||
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val=""/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
|
||||
</w:abstractNum>
|
||||
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
|
||||
</w:numbering>`
|
||||
|
||||
const docxAppProps = xml.Header + `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>enkly</Application></Properties>`
|
||||
|
||||
// officeCoreProps is shared verbatim by docx/xlsx/pptx — the schema is
|
||||
// package-level (docProps/core.xml), not format-specific.
|
||||
const officeCoreProps = xml.Header + `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>enkly agent</dc:creator></cp:coreProperties>`
|
||||
128
pkg/officekit/markdown.go
Normal file
128
pkg/officekit/markdown.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// markdown.go adds Markdown → the same structured []Paragraph
|
||||
// vocabulary create_document and BuildDocx already use, so an
|
||||
// existing Markdown document (a README, a chat reply, something an
|
||||
// upload already contains) can become a real .docx without the model
|
||||
// ever hand-translating headings and bullets into paragraph arguments
|
||||
// itself. It reuses the gomarkdown parser already vendored for
|
||||
// chat-message rendering (pkg/renderkit) rather than hand-rolling a
|
||||
// second Markdown parser — one less thing in this codebase that can
|
||||
// disagree about what counts as a heading or a list item.
|
||||
//
|
||||
// Scope: block-level structure only — headings, paragraphs, and
|
||||
// bullet/numbered lists (numbered lists render as bullets, since
|
||||
// Paragraph has no numbered-list style of its own yet). Fenced or
|
||||
// indented code blocks come through as plain paragraphs, since
|
||||
// Paragraph has no monospace style either. Inline formatting
|
||||
// (bold/italic spans within a paragraph, links, inline code) is
|
||||
// flattened to plain text — Paragraph only supports whole-paragraph
|
||||
// bold/italic/underline (see docx.go), the same limit
|
||||
// create_document's tool description already states. Tables, raw HTML
|
||||
// blocks, footnotes, and anything else without a Paragraph-vocabulary
|
||||
// equivalent are silently dropped rather than guessed at.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
mdparser "github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// MarkdownToParagraphs parses a Markdown document into the paragraph
|
||||
// vocabulary BuildDocx/AppendDocxParagraph already understand, so a
|
||||
// caller can go straight from Markdown source to a .docx via
|
||||
// BuildDocx(MarkdownToParagraphs(src)).
|
||||
func MarkdownToParagraphs(src string) []Paragraph {
|
||||
p := mdparser.NewWithExtensions(mdparser.CommonExtensions)
|
||||
doc := p.Parse([]byte(src))
|
||||
var out []Paragraph
|
||||
for _, block := range childNodes(doc) {
|
||||
out = append(out, blockToParagraphs(block)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func blockToParagraphs(n ast.Node) []Paragraph {
|
||||
switch v := n.(type) {
|
||||
case *ast.Heading:
|
||||
text := leafText(v)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
style := StyleHeading3
|
||||
switch {
|
||||
case v.Level <= 1:
|
||||
style = StyleHeading1
|
||||
case v.Level == 2:
|
||||
style = StyleHeading2
|
||||
}
|
||||
return []Paragraph{{Text: text, Style: style}}
|
||||
case *ast.Paragraph:
|
||||
text := leafText(v)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Paragraph{{Text: text, Style: StyleNormal}}
|
||||
case *ast.List:
|
||||
var out []Paragraph
|
||||
for _, item := range childNodes(v) {
|
||||
text := leafText(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Paragraph{Text: text, Style: StyleBullet})
|
||||
}
|
||||
return out
|
||||
case *ast.CodeBlock:
|
||||
text := strings.TrimRight(string(v.Literal), "\n")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Paragraph{{Text: text, Style: StyleNormal}}
|
||||
case *ast.BlockQuote:
|
||||
var out []Paragraph
|
||||
for _, c := range childNodes(v) {
|
||||
out = append(out, blockToParagraphs(c)...)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
// HorizontalRule, Table, HTMLBlock, and anything else this
|
||||
// package has no Paragraph-vocabulary equivalent for.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// childNodes returns n's direct block-level children via gomarkdown's
|
||||
// Container/Leaf node split: a Leaf node (Text, Code, ...) has none.
|
||||
func childNodes(n ast.Node) []ast.Node {
|
||||
if c := n.AsContainer(); c != nil {
|
||||
return c.Children
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// leafText concatenates every Text/Code leaf found under n, depth
|
||||
// first — the inline-formatting-flattening step described in this
|
||||
// file's package doc.
|
||||
func leafText(n ast.Node) string {
|
||||
var sb strings.Builder
|
||||
collectText(n, &sb)
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func collectText(n ast.Node, sb *strings.Builder) {
|
||||
switch v := n.(type) {
|
||||
case *ast.Text:
|
||||
sb.Write(v.Literal)
|
||||
return
|
||||
case *ast.Code:
|
||||
sb.Write(v.Literal)
|
||||
return
|
||||
case *ast.Hardbreak:
|
||||
sb.WriteByte(' ')
|
||||
return
|
||||
}
|
||||
for _, c := range childNodes(n) {
|
||||
collectText(c, sb)
|
||||
}
|
||||
}
|
||||
74
pkg/officekit/markdown_test.go
Normal file
74
pkg/officekit/markdown_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package officekit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMarkdownToParagraphsStructure(t *testing.T) {
|
||||
src := "# Title\n\nAn intro paragraph.\n\n## Section\n\n- First item\n- Second item\n"
|
||||
paras := MarkdownToParagraphs(src)
|
||||
|
||||
want := []Paragraph{
|
||||
{Text: "Title", Style: StyleHeading1},
|
||||
{Text: "An intro paragraph.", Style: StyleNormal},
|
||||
{Text: "Section", Style: StyleHeading2},
|
||||
{Text: "First item", Style: StyleBullet},
|
||||
{Text: "Second item", Style: StyleBullet},
|
||||
}
|
||||
if len(paras) != len(want) {
|
||||
t.Fatalf("got %d paragraphs, want %d: %+v", len(paras), len(want), paras)
|
||||
}
|
||||
for i := range want {
|
||||
if paras[i].Text != want[i].Text || paras[i].Style != want[i].Style {
|
||||
t.Errorf("paragraph %d: got %+v, want %+v", i, paras[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToParagraphsFlattensInlineFormatting(t *testing.T) {
|
||||
paras := MarkdownToParagraphs("This has **bold** and *italic* and `code` text.")
|
||||
if len(paras) != 1 {
|
||||
t.Fatalf("got %d paragraphs, want 1: %+v", len(paras), paras)
|
||||
}
|
||||
want := "This has bold and italic and code text."
|
||||
if paras[0].Text != want {
|
||||
t.Errorf("got %q, want %q", paras[0].Text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToParagraphsFeedsBuildDocx(t *testing.T) {
|
||||
paras := MarkdownToParagraphs("# Report\n\nBody text here.\n")
|
||||
data, err := BuildDocx(paras)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDocx(MarkdownToParagraphs(...)): %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Report", "Body text here."} {
|
||||
if !containsLine(text, want) {
|
||||
t.Errorf("expected read-back text to contain line %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsLine(text, line string) bool {
|
||||
for _, l := range splitLines(text) {
|
||||
if l == line {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
274
pkg/officekit/officekit_test.go
Normal file
274
pkg/officekit/officekit_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalWordDoc builds a tiny, realistic .docx — using Word's own
|
||||
// "w:"-prefixed convention, not this package's unprefixed
|
||||
// default-namespace one — containing one body paragraph and one table
|
||||
// with a single cell's worth of text. This exercises the append/replace
|
||||
// operations against content this package doesn't itself model (tables)
|
||||
// without needing a real binary fixture file on disk.
|
||||
func minimalWordDoc(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
documentXML := xml.Header + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||
`<w:body>` +
|
||||
`<w:p><w:r><w:t>Intro paragraph.</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tr><w:tc><w:p><w:r><w:t>cell one text</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
data, err := buildZip([]zipPart{
|
||||
{Name: "[Content_Types].xml", Body: docxContentTypes},
|
||||
{Name: "_rels/.rels", Body: docxRootRels},
|
||||
{Name: docxDocumentPart, Body: documentXML},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildZip: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestBuildDocxReadRoundTrip(t *testing.T) {
|
||||
data, err := BuildDocx([]Paragraph{
|
||||
{Text: "Title", Style: StyleHeading1},
|
||||
{Text: "First item", Style: StyleBullet},
|
||||
{Text: "Second item", Style: StyleBullet},
|
||||
{Text: "Styled body text", Bold: true, Italic: true, Underline: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDocx: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
want := []string{"Title", "- First item", "- Second item", "Styled body text"}
|
||||
got := strings.Split(text, "\n")
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d lines, got %d: %q", len(want), len(got), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line %d: got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocxRunFormattingXML(t *testing.T) {
|
||||
data, err := BuildDocx([]Paragraph{{Text: "x", Bold: true, Italic: true, Underline: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDocx: %v", err)
|
||||
}
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, want := range []string{"<b></b>", "<i></i>", `<u val="single">`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected document.xml to contain %s, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocxParagraphPreservesTable(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, err := AppendDocxParagraph(data, Paragraph{Text: "Appended line."})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendDocxParagraph: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Intro paragraph.", "cell one text", "Appended line."} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("expected read-back text to contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// The new paragraph must land before the trailing sectPr, not after
|
||||
// it — content after a body's final sectPr is invalid OOXML.
|
||||
raw, ok, err := readZipPart(out, docxDocumentPart)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
idxAppended := strings.Index(body, "Appended line.")
|
||||
idxSectPr := strings.Index(body, "<w:sectPr")
|
||||
if idxAppended < 0 || idxSectPr < 0 || idxAppended > idxSectPr {
|
||||
t.Errorf("expected appended paragraph before <w:sectPr>, got appended@%d sectPr@%d:\n%s", idxAppended, idxSectPr, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDocxTextInTable(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, n, err := ReplaceDocxText(data, "cell one text", "updated cell text")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceDocxText: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 text run changed, got %d", n)
|
||||
}
|
||||
text, err := ReadDocxText(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if strings.Contains(text, "cell one text") {
|
||||
t.Errorf("old text still present after replace: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "updated cell text") {
|
||||
t.Errorf("expected replaced text, got: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "Intro paragraph.") {
|
||||
t.Errorf("unrelated paragraph lost: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDocxTextNotFound(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
if _, _, err := ReplaceDocxText(data, "nope, not in this document", "x"); err == nil {
|
||||
t.Fatal("expected an error for text that isn't present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetXlsxCellsFormulaAndOrdering(t *testing.T) {
|
||||
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"Label", "Value"}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildXlsx: %v", err)
|
||||
}
|
||||
|
||||
// Written deliberately out of row order, including a formula, to
|
||||
// exercise the sort-before-marshal fix.
|
||||
out, err := SetXlsxCells(data, "", map[string]string{
|
||||
"A5": "fifth",
|
||||
"A2": "second",
|
||||
"B2": "=A2",
|
||||
"A1": "first",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetXlsxCells: %v", err)
|
||||
}
|
||||
|
||||
rows, _, err := ReadXlsxCells(out, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadXlsxCells: %v", err)
|
||||
}
|
||||
// Three sparse rows exist: 1 (from BuildXlsx + overwritten A1), 2, 5.
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d: %v", len(rows), rows)
|
||||
}
|
||||
if rows[0][0] != "first" {
|
||||
t.Errorf("row 1 col A: got %q, want %q", rows[0][0], "first")
|
||||
}
|
||||
if rows[1][0] != "second" {
|
||||
t.Errorf("row 2 col A: got %q, want %q", rows[1][0], "second")
|
||||
}
|
||||
if len(rows[1]) < 2 || rows[1][1] != "=A2" {
|
||||
t.Errorf("row 2 col B: got %v, want formula \"=A2\"", rows[1])
|
||||
}
|
||||
if rows[2][0] != "fifth" {
|
||||
t.Errorf("row 5 col A: got %q, want %q", rows[2][0], "fifth")
|
||||
}
|
||||
|
||||
// A real reader (Excel included) cares about the underlying
|
||||
// element order, not just what this package's own reader derives —
|
||||
// confirm the raw XML itself is in ascending row/column order.
|
||||
raw, ok, err := readZipPart(out, xlsxSheetPart(1))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart sheet1: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
idxA1 := strings.Index(body, `r="A1"`)
|
||||
idxA2 := strings.Index(body, `r="A2"`)
|
||||
idxB2 := strings.Index(body, `r="B2"`)
|
||||
idxA5 := strings.Index(body, `r="A5"`)
|
||||
if idxA1 < 0 || idxA2 < 0 || idxB2 < 0 || idxA5 < 0 {
|
||||
t.Fatalf("expected every written cell ref present in raw XML:\n%s", body)
|
||||
}
|
||||
if !(idxA1 < idxA2 && idxA2 < idxB2 && idxB2 < idxA5) {
|
||||
t.Errorf("expected ascending order A1 < A2 < B2 < A5 in raw XML, got offsets %d,%d,%d,%d:\n%s",
|
||||
idxA1, idxA2, idxB2, idxA5, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetXlsxCellsManyNewRowsNoDataLoss is a regression test for a bug
|
||||
// where SetXlsxCells indexed newly-created rows by a *pointer* taken
|
||||
// at append time. Go's append reallocates a slice's backing array
|
||||
// once its capacity is exceeded, which silently strands any
|
||||
// previously-taken pointer into the old array — a write applied
|
||||
// through it returns no error but never reaches the file. That bug
|
||||
// only showed up when (a) more than one cell landed in the same
|
||||
// brand-new row and (b) a slice reallocation happened to fall between
|
||||
// those two writes, so it passed or failed depending on Go's
|
||||
// (deliberately randomized) map iteration order. This test forces
|
||||
// many reallocations (far more new rows than any small initial slice
|
||||
// capacity) and writes two cells into every one of them, so a
|
||||
// reintroduction of the pointer bug fails deterministically rather
|
||||
// than intermittently.
|
||||
func TestSetXlsxCellsManyNewRowsNoDataLoss(t *testing.T) {
|
||||
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"seed"}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildXlsx: %v", err)
|
||||
}
|
||||
|
||||
const numRows = 200
|
||||
writes := make(map[string]string, numRows*2)
|
||||
for r := 2; r <= numRows+1; r++ {
|
||||
writes[fmt.Sprintf("A%d", r)] = fmt.Sprintf("label-%d", r)
|
||||
writes[fmt.Sprintf("B%d", r)] = fmt.Sprintf("=A%d", r)
|
||||
}
|
||||
|
||||
out, err := SetXlsxCells(data, "", writes)
|
||||
if err != nil {
|
||||
t.Fatalf("SetXlsxCells: %v", err)
|
||||
}
|
||||
|
||||
rows, _, err := ReadXlsxCells(out, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadXlsxCells: %v", err)
|
||||
}
|
||||
if len(rows) != numRows+1 { // +1 for the seeded row 1
|
||||
t.Fatalf("expected %d rows, got %d", numRows+1, len(rows))
|
||||
}
|
||||
for r := 2; r <= numRows+1; r++ {
|
||||
row := rows[r-1]
|
||||
if len(row) != 2 {
|
||||
t.Fatalf("row %d: expected 2 cells, got %d: %v", r, len(row), row)
|
||||
}
|
||||
wantA := fmt.Sprintf("label-%d", r)
|
||||
wantB := fmt.Sprintf("=A%d", r)
|
||||
if row[0] != wantA {
|
||||
t.Errorf("row %d col A: got %q, want %q", r, row[0], wantA)
|
||||
}
|
||||
if row[1] != wantB {
|
||||
t.Errorf("row %d col B: got %q, want %q", r, row[1], wantB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPptxReadRoundTrip(t *testing.T) {
|
||||
data, err := BuildPptx([]Slide{
|
||||
{Title: "Welcome"},
|
||||
{Title: "Agenda", Bullets: []string{"Point one", "Point two & three"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPptx: %v", err)
|
||||
}
|
||||
slides, err := ReadPptxSlides(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPptxSlides: %v", err)
|
||||
}
|
||||
if len(slides) != 2 {
|
||||
t.Fatalf("expected 2 slides, got %d", len(slides))
|
||||
}
|
||||
if slides[0].Title != "Welcome" {
|
||||
t.Errorf("slide 1 title: got %q", slides[0].Title)
|
||||
}
|
||||
if slides[1].Title != "Agenda" || len(slides[1].Bullets) != 2 || slides[1].Bullets[1] != "Point two & three" {
|
||||
t.Errorf("slide 2: got %+v", slides[1])
|
||||
}
|
||||
}
|
||||
206
pkg/officekit/pdf.go
Normal file
206
pkg/officekit/pdf.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// pdf.go adds best-effort PDF text extraction: read_pdf's only job is
|
||||
// "give me back the words", not full-fidelity layout, so this
|
||||
// deliberately stays a few hundred lines of pure stdlib (no PDF
|
||||
// library, no cgo, nothing to go get in an offline deployment) rather
|
||||
// than a complete PDF renderer. It walks the file's raw bytes for
|
||||
// `stream ... endstream` blocks, inflates the ones declared
|
||||
// FlateDecode (the overwhelming majority of PDFs produced by real
|
||||
// tools — Word, LibreOffice, browsers, Pandoc, Pages — all compress
|
||||
// their content streams this way), and scans the decompressed
|
||||
// PostScript-like content for the handful of text-showing operators
|
||||
// (Tj, TJ, ', ") that actually place characters on the page.
|
||||
// Everything else in the file — fonts, images, vector graphics,
|
||||
// annotations, page geometry — is ignored.
|
||||
//
|
||||
// What this can't do, and won't pretend to: scanned/image-only PDFs
|
||||
// (no embedded text at all — that needs OCR, out of scope here),
|
||||
// PDFs whose content streams use a filter other than FlateDecode
|
||||
// (rare in practice), and any PDF that encodes its text with a custom
|
||||
// font encoding and no usable mapping back to plain characters — those
|
||||
// come out as empty or as mangled text, and ReadPdfText says so rather
|
||||
// than silently returning garbage.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pdfStreamRe finds every `stream\r?\n ... \r?\nendstream` block in
|
||||
// the file, capturing the body separately from the "stream" keyword
|
||||
// that precedes it — ReadPdfText looks backward from the keyword for
|
||||
// the object dictionary's /Filter entry rather than actually parsing
|
||||
// PDF's object/dictionary syntax.
|
||||
var pdfStreamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`)
|
||||
|
||||
// pdfDictLookback is how far back from a `stream` keyword ReadPdfText
|
||||
// looks for that stream object's dictionary (and in particular its
|
||||
// /Filter entry) — generous enough for any realistic content-stream
|
||||
// dictionary (/Length, /Filter, /Length1, a handful of other keys)
|
||||
// without risking bleeding into the previous object.
|
||||
const pdfDictLookback = 600
|
||||
|
||||
// ReadPdfText extracts a PDF's plain text content, best-effort. See
|
||||
// the package doc above for exactly what it does and doesn't handle.
|
||||
// Returns an error only when the input isn't recognizable as a PDF,
|
||||
// or no extractable text was found anywhere in it — a PDF that fails
|
||||
// to extract from some streams but not others still returns whatever
|
||||
// text it did recover from the rest.
|
||||
func ReadPdfText(data []byte) (string, error) {
|
||||
head := data
|
||||
if len(head) > 1024 {
|
||||
head = head[:1024]
|
||||
}
|
||||
if !bytes.Contains(head, []byte("%PDF-")) {
|
||||
return "", fmt.Errorf("officekit: not a PDF file (missing %%PDF- header)")
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for _, loc := range pdfStreamRe.FindAllSubmatchIndex(data, -1) {
|
||||
streamStart, bodyStart, bodyEnd := loc[0], loc[2], loc[3]
|
||||
dictStart := streamStart - pdfDictLookback
|
||||
if dictStart < 0 {
|
||||
dictStart = 0
|
||||
}
|
||||
dict := data[dictStart:streamStart]
|
||||
if !bytes.Contains(dict, []byte("FlateDecode")) {
|
||||
continue // not a stream this package knows how to decompress
|
||||
}
|
||||
inflated, err := inflateZlib(data[bodyStart:bodyEnd])
|
||||
if err != nil {
|
||||
continue // FlateDecode-tagged but not plain DEFLATE (e.g. an embedded font program); skip it
|
||||
}
|
||||
if text := extractShownText(inflated); text != "" {
|
||||
if out.Len() > 0 {
|
||||
out.WriteString("\n\n")
|
||||
}
|
||||
out.WriteString(text)
|
||||
}
|
||||
}
|
||||
result := strings.TrimSpace(out.String())
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("officekit: no extractable text found (the PDF may be scanned/image-only, or use an encoding this reader doesn't support)")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func inflateZlib(b []byte) ([]byte, error) {
|
||||
r, err := zlib.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
// pdfTextOpRe matches one text-showing operator call together with
|
||||
// its operand(s): one or more PDF "literal strings" — parenthesized,
|
||||
// with their own backslash-escape syntax decoded by
|
||||
// pdfUnescapeLiteral — followed by Tj, TJ, ' or ". A TJ call's operand
|
||||
// is actually an array, e.g. `[(Hel)-20(lo)] TJ`, which is why this
|
||||
// matches "one or more" consecutive literals rather than exactly one.
|
||||
var pdfTextOpRe = regexp.MustCompile(`(?s)((?:\((?:\\.|[^()\\])*\)[^()]*?)+)(Tj|TJ|'|")`)
|
||||
|
||||
// pdfLiteralRe pulls each individual (...) literal string out of one
|
||||
// operator call's operand span.
|
||||
var pdfLiteralRe = regexp.MustCompile(`(?s)\((?:\\.|[^()\\])*\)`)
|
||||
|
||||
// pdfNewlineOpRe matches the content-stream operators that move the
|
||||
// text cursor to a new line (Td, TD, T*) — used only as a heuristic
|
||||
// for where extractShownText inserts a newline into the recovered
|
||||
// text, since the stream itself carries no notion of "paragraph" or
|
||||
// "line" beyond cursor movement.
|
||||
var pdfNewlineOpRe = regexp.MustCompile(`\bT[dD*]\b`)
|
||||
|
||||
// extractShownText walks one decompressed content stream in document
|
||||
// order and concatenates the string(s) passed to each text-showing
|
||||
// operator call, inserting a newline wherever a line-positioning
|
||||
// operator (Td/TD/T*) appeared since the previous call and a plain
|
||||
// space otherwise — an approximation of the stream's original line
|
||||
// breaks, not a layout engine. A single TJ call's several literals
|
||||
// (PDF splits one word across more than one literal to carry kerning
|
||||
// adjustments, e.g. `[(Hel)-20(lo)] TJ`) are joined with nothing
|
||||
// between them, since the gap is a spacing nudge, not a real word
|
||||
// break — any actual space in the shown text already appears as a
|
||||
// literal " " character the way it does in a Tj call.
|
||||
func extractShownText(stream []byte) string {
|
||||
var sb strings.Builder
|
||||
pos := 0
|
||||
pendingNewline := false
|
||||
for pos < len(stream) {
|
||||
loc := pdfTextOpRe.FindSubmatchIndex(stream[pos:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
matchStart, matchEnd := pos+loc[0], pos+loc[1]
|
||||
if pdfNewlineOpRe.Match(stream[pos:matchStart]) {
|
||||
pendingNewline = true
|
||||
}
|
||||
operand := stream[pos+loc[2] : pos+loc[3]]
|
||||
var call strings.Builder
|
||||
for _, lit := range pdfLiteralRe.FindAll(operand, -1) {
|
||||
call.WriteString(pdfUnescapeLiteral(lit[1 : len(lit)-1]))
|
||||
}
|
||||
if text := call.String(); text != "" {
|
||||
if sb.Len() > 0 {
|
||||
if pendingNewline {
|
||||
sb.WriteByte('\n')
|
||||
} else {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
pendingNewline = false
|
||||
sb.WriteString(text)
|
||||
}
|
||||
pos = matchEnd
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// pdfUnescapeLiteral decodes a PDF literal string's backslash escapes
|
||||
// (\n \r \t \( \) \\ and octal \ddd) and treats every other byte as
|
||||
// raw Latin-1 — correct for the common case (a simple, non-custom
|
||||
// font encoding) and the best this package can do without parsing the
|
||||
// PDF's font /Encoding and /ToUnicode tables.
|
||||
func pdfUnescapeLiteral(b []byte) string {
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if c != '\\' || i == len(b)-1 {
|
||||
sb.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch n := b[i]; {
|
||||
case n == 'n':
|
||||
sb.WriteByte('\n')
|
||||
case n == 'r':
|
||||
sb.WriteByte('\r')
|
||||
case n == 't':
|
||||
sb.WriteByte('\t')
|
||||
case n == 'b' || n == 'f':
|
||||
// backspace/form-feed: not meaningful in extracted text
|
||||
case n == '(' || n == ')' || n == '\\':
|
||||
sb.WriteByte(n)
|
||||
case n == '\n':
|
||||
// backslash-newline is a line continuation, not a literal char
|
||||
case n >= '0' && n <= '7':
|
||||
j := i
|
||||
for j < len(b) && j < i+3 && b[j] >= '0' && b[j] <= '7' {
|
||||
j++
|
||||
}
|
||||
if v, err := strconv.ParseInt(string(b[i:j]), 8, 32); err == nil {
|
||||
sb.WriteByte(byte(v))
|
||||
}
|
||||
i = j - 1
|
||||
default:
|
||||
sb.WriteByte(n)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
90
pkg/officekit/pdf_test.go
Normal file
90
pkg/officekit/pdf_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildMinimalPdf wraps one FlateDecode-compressed content stream in
|
||||
// just enough surrounding PDF syntax for ReadPdfText's regex-based
|
||||
// scanner to find it — a %PDF- header and one `N 0 obj << ... /Filter
|
||||
// /FlateDecode >> stream ... endstream endobj` object. Not a complete,
|
||||
// schema-valid PDF (no xref table, no page tree), but ReadPdfText
|
||||
// never looks at either of those, so this is a faithful enough fixture
|
||||
// for exercising it without needing a real binary PDF checked into
|
||||
// the repo.
|
||||
func buildMinimalPdf(t *testing.T, content string) []byte {
|
||||
t.Helper()
|
||||
var compressed bytes.Buffer
|
||||
zw := zlib.NewWriter(&compressed)
|
||||
if _, err := zw.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zlib write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zlib close: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("%PDF-1.4\n")
|
||||
buf.WriteString("4 0 obj\n<< /Length 999 /Filter /FlateDecode >>\nstream\n")
|
||||
buf.Write(compressed.Bytes())
|
||||
buf.WriteString("\nendstream\nendobj\n%%EOF")
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestReadPdfTextBasic(t *testing.T) {
|
||||
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td (Hello World) Tj ET")
|
||||
text, err := ReadPdfText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPdfText: %v", err)
|
||||
}
|
||||
if text != "Hello World" {
|
||||
t.Errorf("got %q, want %q", text, "Hello World")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextLineBreaksAndEscapes(t *testing.T) {
|
||||
content := "BT /F1 12 Tf 72 712 Td (Line one \\(escaped\\)) Tj\n72 700 Td (Line two) Tj ET"
|
||||
data := buildMinimalPdf(t, content)
|
||||
text, err := ReadPdfText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPdfText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "Line one (escaped)") {
|
||||
t.Errorf("expected unescaped parens in %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Line two") {
|
||||
t.Errorf("expected second Td'd line's text in %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "\n") {
|
||||
t.Errorf("expected a newline between Td-separated text runs, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextTJArrayKerning(t *testing.T) {
|
||||
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td [(Hel)-20(lo)20( world)] TJ ET")
|
||||
text, err := ReadPdfText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPdfText: %v", err)
|
||||
}
|
||||
if text != "Hello world" {
|
||||
t.Errorf("got %q, want %q (TJ kerning numbers must not become spaces)", text, "Hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextRejectsNonPdf(t *testing.T) {
|
||||
if _, err := ReadPdfText([]byte("just some ordinary bytes, not a PDF")); err == nil {
|
||||
t.Error("expected an error for input with no %PDF- header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextNoShownTextOperators(t *testing.T) {
|
||||
// A validly-structured FlateDecode stream (so the inflate step
|
||||
// succeeds) with no Tj/TJ/'/" calls at all — e.g. a pure image draw.
|
||||
data := buildMinimalPdf(t, "q 100 0 0 100 0 0 cm /Im0 Do Q")
|
||||
if _, err := ReadPdfText(data); err == nil {
|
||||
t.Error("expected an error when the stream has no text-showing operators")
|
||||
}
|
||||
}
|
||||
376
pkg/officekit/pptx.go
Normal file
376
pkg/officekit/pptx.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Slide is one slide's content, as the model supplies it to
|
||||
// create_presentation/edit_presentation: a title and a flat list of
|
||||
// bullet lines. Deliberately this simple — no layouts, no inherited
|
||||
// placeholders, no raw DrawingML — so the model never authors
|
||||
// PresentationML either; every slide is rendered as two independent,
|
||||
// absolutely-positioned text boxes (title, body) that don't need to
|
||||
// resolve against the slide layout/master's placeholder scheme.
|
||||
type Slide struct {
|
||||
Title string
|
||||
Bullets []string
|
||||
}
|
||||
|
||||
// --- read side: typed XML for parsing a slide's shape tree back out
|
||||
// (ReadPptxSlides). Every struct tag below is a plain, unprefixed
|
||||
// local name with no namespace component — PresentationML genuinely
|
||||
// needs several simultaneous namespaces (p: for presentation elements,
|
||||
// a: for drawing elements), so unlike docx's single-namespace case we
|
||||
// can't lean on one default namespace to cover everything. Instead we
|
||||
// rely on a different, equally standard encoding/xml behavior: a field
|
||||
// tag with no namespace component matches an element by local name
|
||||
// only, regardless of which namespace it actually resolved to. So
|
||||
// "cSld" matches <p:cSld> and "xfrm" matches <a:xfrm> alike, without
|
||||
// our Go types needing to know or care which prefix/namespace a given
|
||||
// element belongs to. (See the longer version of this note on
|
||||
// wDocument in docx.go — same underlying mechanism, applied here to
|
||||
// the harder, multi-namespace case.) ---
|
||||
|
||||
type pSld struct {
|
||||
XMLName xml.Name `xml:"sld"`
|
||||
CSld pCSld `xml:"cSld"`
|
||||
}
|
||||
|
||||
type pCSld struct {
|
||||
SpTree pSpTree `xml:"spTree"`
|
||||
}
|
||||
|
||||
type pSpTree struct {
|
||||
Sp []pSp `xml:"sp"`
|
||||
}
|
||||
|
||||
type pCNvPr struct {
|
||||
Id int `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
|
||||
type pSp struct {
|
||||
NvSpPr pNvSpPr `xml:"nvSpPr"`
|
||||
TxBody pTxBody `xml:"txBody"`
|
||||
}
|
||||
|
||||
type pNvSpPr struct {
|
||||
CNvPr pCNvPr `xml:"cNvPr"`
|
||||
}
|
||||
|
||||
type pTxBody struct {
|
||||
Paras []pTxPara `xml:"p"`
|
||||
}
|
||||
|
||||
type pTxPara struct {
|
||||
Run pTxRun `xml:"r"`
|
||||
}
|
||||
|
||||
type pTxRun struct {
|
||||
Text string `xml:"t"`
|
||||
}
|
||||
|
||||
// --- write side: deliberately NOT built with xml.Marshal. PresentationML's
|
||||
// several simultaneous namespaces mean a struct-tag-driven Marshal would
|
||||
// need Go to auto-assign namespace prefixes, which is both unpredictable
|
||||
// and avoidable here — we fully control this template, so we write the
|
||||
// real p:/a: prefixes directly and escape every piece of user-supplied
|
||||
// text through encoding/xml's own escaper (xmlEscapeText), which is the
|
||||
// same function Marshal itself uses internally for chardata. That keeps
|
||||
// the "well-formed, correctly-escaped, never touched by the model"
|
||||
// property from the package doc comment without fighting Go's namespace
|
||||
// prefix allocation. ---
|
||||
|
||||
// xmlEscapeText XML-escapes s for use as element chardata (title and
|
||||
// bullet text — the only user-supplied strings that end up in a slide).
|
||||
func xmlEscapeText(s string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
const titleShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
|
||||
<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="3200" b="1"/><a:t>%s</a:t></a:r></a:p></p:txBody>
|
||||
</p:sp>`
|
||||
|
||||
const bodyParaTemplate = `<a:p><a:pPr><a:buChar char="•"/></a:pPr><a:r><a:rPr lang="en-US" sz="2000"/><a:t>%s</a:t></a:r></a:p>`
|
||||
|
||||
const bodyShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Body"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
|
||||
<p:spPr><a:xfrm><a:off x="457200" y="1600200"/><a:ext cx="8229600" cy="4525963"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody>
|
||||
</p:sp>`
|
||||
|
||||
func titleShape(id int, text string) string {
|
||||
return fmt.Sprintf(titleShapeTemplate, id, xmlEscapeText(text))
|
||||
}
|
||||
|
||||
func bodyShape(id int, bullets []string) string {
|
||||
var paras strings.Builder
|
||||
for _, b := range bullets {
|
||||
fmt.Fprintf(¶s, bodyParaTemplate, xmlEscapeText(b))
|
||||
}
|
||||
return fmt.Sprintf(bodyShapeTemplate, id, paras.String())
|
||||
}
|
||||
|
||||
func slideXML(s Slide) ([]byte, error) {
|
||||
var shapes strings.Builder
|
||||
if s.Title != "" {
|
||||
shapes.WriteString(titleShape(2, s.Title))
|
||||
}
|
||||
if len(s.Bullets) > 0 {
|
||||
shapes.WriteString(bodyShape(3, s.Bullets))
|
||||
}
|
||||
out := xml.Header + `<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` + shapes.String() + `</p:spTree></p:cSld>
|
||||
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
|
||||
</p:sld>`
|
||||
return []byte(out), nil
|
||||
}
|
||||
|
||||
func slidePath(n int) string { return fmt.Sprintf("ppt/slides/slide%d.xml", n) }
|
||||
func slideRelsPath(n int) string { return fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", n) }
|
||||
|
||||
const slideRelsBody = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// BuildPptx renders slides into a complete, valid .pptx file.
|
||||
func BuildPptx(slides []Slide) ([]byte, error) {
|
||||
if len(slides) == 0 {
|
||||
slides = []Slide{{}}
|
||||
}
|
||||
|
||||
parts := []zipPart{
|
||||
{Name: "[Content_Types].xml", Body: pptxContentTypes(len(slides))},
|
||||
{Name: "_rels/.rels", Body: pptxRootRels},
|
||||
{Name: "ppt/presentation.xml", Body: pptxPresentationXML(len(slides))},
|
||||
{Name: "ppt/_rels/presentation.xml.rels", Body: pptxPresentationRels(len(slides))},
|
||||
{Name: "ppt/slideMasters/slideMaster1.xml", Body: pptxSlideMaster},
|
||||
{Name: "ppt/slideMasters/_rels/slideMaster1.xml.rels", Body: pptxSlideMasterRels},
|
||||
{Name: "ppt/slideLayouts/slideLayout1.xml", Body: pptxSlideLayout},
|
||||
{Name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels", Body: pptxSlideLayoutRels},
|
||||
{Name: "ppt/theme/theme1.xml", Body: pptxTheme},
|
||||
{Name: "docProps/core.xml", Body: officeCoreProps},
|
||||
}
|
||||
for i, s := range slides {
|
||||
n := i + 1
|
||||
sx, err := slideXML(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts,
|
||||
zipPart{Name: slidePath(n), Body: string(sx)},
|
||||
zipPart{Name: slideRelsPath(n), Body: slideRelsBody},
|
||||
)
|
||||
}
|
||||
return buildZip(parts)
|
||||
}
|
||||
|
||||
// ReadPptxSlides returns every slide's title + bullets, in order, for
|
||||
// the read_presentation tool. It re-derives slide order and count from
|
||||
// ppt/presentation.xml's sldIdLst / presentation.xml.rels rather than
|
||||
// assuming a fixed count, so a file this package wrote (or edited)
|
||||
// round-trips exactly.
|
||||
func ReadPptxSlides(data []byte) ([]Slide, error) {
|
||||
n, err := countSlides(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Slide
|
||||
for i := 1; i <= n; i++ {
|
||||
raw, ok, err := readZipPart(data, slidePath(i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var sld pSld
|
||||
if err := xml.Unmarshal(raw, &sld); err != nil {
|
||||
return nil, fmt.Errorf("officekit: parse %s: %w", slidePath(i), err)
|
||||
}
|
||||
s := Slide{}
|
||||
for _, sp := range sld.CSld.SpTree.Sp {
|
||||
switch sp.NvSpPr.CNvPr.Name {
|
||||
case "Title":
|
||||
if len(sp.TxBody.Paras) > 0 {
|
||||
s.Title = sp.TxBody.Paras[0].Run.Text
|
||||
}
|
||||
case "Body":
|
||||
for _, p := range sp.TxBody.Paras {
|
||||
s.Bullets = append(s.Bullets, p.Run.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// countSlides counts how many ppt/slides/slideN.xml parts a .pptx
|
||||
// actually contains by probing sequentially (simplest reliable
|
||||
// approach given this package writes slides as a dense 1..n sequence
|
||||
// and never deletes from the middle).
|
||||
func countSlides(data []byte) (int, error) {
|
||||
n := 0
|
||||
for {
|
||||
_, ok, err := readZipPart(data, slidePath(n+1))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AppendPptxSlide adds one new slide at the end and returns the new
|
||||
// file bytes, keeping [Content_Types].xml, presentation.xml and
|
||||
// presentation.xml.rels in sync with the new slide count.
|
||||
func AppendPptxSlide(data []byte, s Slide) ([]byte, error) {
|
||||
n, err := countSlides(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newN := n + 1
|
||||
sx, err := slideXML(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := addZipParts(data, []zipPart{
|
||||
{Name: slidePath(newN), Body: string(sx)},
|
||||
{Name: slideRelsPath(newN), Body: slideRelsBody},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = replaceZipPart(out, "[Content_Types].xml", []byte(pptxContentTypes(newN)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = replaceZipPart(out, "ppt/presentation.xml", []byte(pptxPresentationXML(newN)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return replaceZipPart(out, "ppt/_rels/presentation.xml.rels", []byte(pptxPresentationRels(newN)))
|
||||
}
|
||||
|
||||
// UpdatePptxSlide replaces one existing slide's content (1-based
|
||||
// index) in place.
|
||||
func UpdatePptxSlide(data []byte, index int, s Slide) ([]byte, error) {
|
||||
n, err := countSlides(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index < 1 || index > n {
|
||||
return nil, fmt.Errorf("officekit: slide %d out of range (presentation has %d slide(s))", index, n)
|
||||
}
|
||||
sx, err := slideXML(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return replaceZipPart(data, slidePath(index), []byte(sx))
|
||||
}
|
||||
|
||||
func pptxContentTypes(n int) string {
|
||||
var overrides strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&overrides, `<Override PartName="/%s" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`, slidePath(i))
|
||||
}
|
||||
return xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
||||
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
|
||||
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
|
||||
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
` + overrides.String() + `</Types>`
|
||||
}
|
||||
|
||||
func pptxPresentationXML(n int) string {
|
||||
var ids strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&ids, `<p:sldId id="%d" r:id="rId%d"/>`, 255+i, i+1)
|
||||
}
|
||||
return xml.Header + `<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
|
||||
<p:sldIdLst>` + ids.String() + `</p:sldIdLst>
|
||||
<p:sldSz cx="9144000" cy="6858000"/>
|
||||
<p:notesSz cx="6858000" cy="9144000"/>
|
||||
</p:presentation>`
|
||||
}
|
||||
|
||||
func pptxPresentationRels(n int) string {
|
||||
var rels strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide%d.xml"/>`, i+1, i)
|
||||
}
|
||||
return xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
|
||||
` + rels.String() + `</Relationships>`
|
||||
}
|
||||
|
||||
const pptxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const pptxSlideMasterRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const pptxSlideLayoutRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const pptxSlideMaster = xml.Header + `<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
|
||||
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
|
||||
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
|
||||
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
|
||||
</p:sldMaster>`
|
||||
|
||||
const pptxSlideLayout = xml.Header + `<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
|
||||
<p:cSld name="Blank">
|
||||
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
|
||||
</p:sldLayout>`
|
||||
|
||||
const pptxTheme = xml.Header + `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="enkly">
|
||||
<a:themeElements>
|
||||
<a:clrScheme name="enkly">
|
||||
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
|
||||
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
|
||||
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
||||
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
||||
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
|
||||
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
||||
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
||||
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
||||
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
|
||||
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
||||
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
||||
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
||||
</a:clrScheme>
|
||||
<a:fontScheme name="enkly">
|
||||
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
|
||||
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
|
||||
</a:fontScheme>
|
||||
<a:fmtScheme name="enkly">
|
||||
<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
|
||||
<a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
|
||||
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
|
||||
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
|
||||
</a:fmtScheme>
|
||||
</a:themeElements>
|
||||
</a:theme>`
|
||||
302
pkg/officekit/redline.go
Normal file
302
pkg/officekit/redline.go
Normal file
@@ -0,0 +1,302 @@
|
||||
// redline.go adds lightweight "track changes" (redlining) support on
|
||||
// top of the plain append/replace operations in docx.go: the same
|
||||
// kind of edit — change this document's text — but recorded as a
|
||||
// Word-reviewable <w:ins>/<w:del> pair instead of a silent in-place
|
||||
// rewrite, plus accept/reject to resolve those changes afterward.
|
||||
// Word, LibreOffice and Google Docs (on import) all read and display
|
||||
// these the same way: Review > Track Changes.
|
||||
//
|
||||
// Scope, deliberately: a tracked replace marks the WHOLE run that
|
||||
// contained a match as changed (old text struck through, new text
|
||||
// inserted next to it) rather than computing a minimal character-level
|
||||
// diff within the run — the same "whole unit, not a sub-span"
|
||||
// tradeoff ReplaceDocxText already makes implicitly by working at the
|
||||
// <w:t> level (see its doc comment in docx.go). A run in a real
|
||||
// document is usually already one coherent span of text in one
|
||||
// formatting (Word splits runs on formatting changes, not mid-word),
|
||||
// so in practice this reads as a natural redline rather than an
|
||||
// oversized strikethrough.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wRunRe matches one complete <w:r>...</w:r> run — its opening tag,
|
||||
// any attributes, its full inner content, and its closing tag — so a
|
||||
// tracked replace can pull the run's formatting (its leading <w:rPr>,
|
||||
// if any) out and copy it into both halves of the del/ins pair it
|
||||
// produces. Safe as a non-nested regex because OOXML's schema never
|
||||
// allows a <w:r> to contain another <w:r> (a run is the schema's
|
||||
// smallest text-formatting unit, not a container for other runs).
|
||||
var wRunRe = regexp.MustCompile(`(?s)<((?:[A-Za-z][\w.-]*:)?r)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?r>`)
|
||||
|
||||
// wRPrLeadingRe pulls a run's leading <w:rPr>...</w:rPr> block (if
|
||||
// any) off the front of its inner content.
|
||||
var wRPrLeadingRe = regexp.MustCompile(`(?s)^\s*(<(?:[A-Za-z][\w.-]*:)?rPr>.*?</(?:[A-Za-z][\w.-]*:)?rPr>)`)
|
||||
|
||||
// wInsBlockRe / wDelBlockRe match one complete <w:ins>/<w:del> block,
|
||||
// capturing its opening tag's attributes and its inner content
|
||||
// separately — AcceptAllDocxChanges, RejectAllDocxChanges and
|
||||
// ReadDocxRevisions all work by matching one or the other and either
|
||||
// keeping, dropping, or reporting on what's inside.
|
||||
var wInsBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?ins(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?ins>`)
|
||||
var wDelBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?del(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?del>`)
|
||||
|
||||
// wDelTextElementRe matches one <w:delText>...</w:delText> element,
|
||||
// the deletion-tracking equivalent of <w:t> used only inside <w:del>
|
||||
// runs — RejectAllDocxChanges turns these back into ordinary <w:t>
|
||||
// elements so the restored (un-deleted) text renders normally again.
|
||||
var wDelTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?delText(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?delText>)`)
|
||||
|
||||
// changeAuthorRe pulls the author="..." (optionally namespace-
|
||||
// prefixed) attribute value out of a <w:ins>/<w:del> opening tag's
|
||||
// captured attribute string.
|
||||
var changeAuthorRe = regexp.MustCompile(`(?:^|\s)(?:[\w.-]+:)?author="([^"]*)"`)
|
||||
|
||||
// revisionIDRe finds every existing id="N" attribute in a
|
||||
// document.xml, so new tracked changes can continue numbering from
|
||||
// the highest ID already present rather than risking a collision with
|
||||
// revisions from an earlier edit (an uploaded document, or an earlier
|
||||
// tool call in the same session).
|
||||
var revisionIDRe = regexp.MustCompile(`id="(\d+)"`)
|
||||
|
||||
func nextRevisionIDFrom(doc []byte) int {
|
||||
max := 0
|
||||
for _, m := range revisionIDRe.FindAllSubmatch(doc, -1) {
|
||||
if n, err := strconv.Atoi(string(m[1])); err == nil && n > max {
|
||||
max = n
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
// revisionDate is w:date's expected format (ISO 8601, UTC, second
|
||||
// precision) — the same convention Word itself writes.
|
||||
func revisionDate() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
func xmlEscapeAttrVal(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
return s
|
||||
}
|
||||
|
||||
// ReplaceDocxTextTracked is ReplaceDocxText's tracked-changes sibling:
|
||||
// instead of silently rewriting matching text runs in place, it turns
|
||||
// each matching run into a <w:del>/<w:ins> pair recording who changed
|
||||
// it and when, exactly as if a human reviewer had made the edit with
|
||||
// Track Changes switched on in Word. See this file's package doc for
|
||||
// the whole-run-not-sub-span scope this operates at. author defaults
|
||||
// to "enkly agent" if empty.
|
||||
func ReplaceDocxTextTracked(data []byte, find, replace, author string) ([]byte, int, error) {
|
||||
if author == "" {
|
||||
author = "enkly agent"
|
||||
}
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
|
||||
nextID := nextRevisionIDFrom(raw)
|
||||
date := revisionDate()
|
||||
changed := 0
|
||||
newDoc := wRunRe.ReplaceAllFunc(raw, func(match []byte) []byte {
|
||||
g := wRunRe.FindSubmatch(match)
|
||||
inner := g[3]
|
||||
tm := wTextElementRe.FindSubmatch(inner)
|
||||
if tm == nil {
|
||||
return match // a tab, break, or field run — no <w:t> to compare against
|
||||
}
|
||||
oldText := xmlUnescapeText(tm[2])
|
||||
if !strings.Contains(oldText, find) {
|
||||
return match
|
||||
}
|
||||
newText := strings.ReplaceAll(oldText, find, replace)
|
||||
changed++
|
||||
|
||||
rPr := ""
|
||||
if m := wRPrLeadingRe.FindSubmatch(inner); m != nil {
|
||||
rPr = string(m[1])
|
||||
}
|
||||
delID, insID := nextID, nextID+1
|
||||
nextID += 2
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, `<del id="%d" author="%s" date="%s"><r>%s<delText xml:space="preserve">%s</delText></r></del>`,
|
||||
delID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(oldText))
|
||||
fmt.Fprintf(&sb, `<ins id="%d" author="%s" date="%s"><r>%s<t xml:space="preserve">%s</t></r></ins>`,
|
||||
insID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(newText))
|
||||
return []byte(sb.String())
|
||||
})
|
||||
if changed == 0 {
|
||||
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
|
||||
}
|
||||
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
return out, changed, err
|
||||
}
|
||||
|
||||
// wIns is a <w:ins> element's content: the tracked-insertion wrapper
|
||||
// AppendDocxParagraphTracked puts around a brand-new paragraph's run,
|
||||
// mirroring ReplaceDocxTextTracked's own <ins id author date><r>...
|
||||
// shape above but built with encoding/xml (like the rest of a freshly
|
||||
// generated paragraph fragment) rather than string formatting, since
|
||||
// here — unlike a splice into arbitrary existing XML — the whole
|
||||
// fragment is this package's own output.
|
||||
type wIns struct {
|
||||
ID int `xml:"id,attr"`
|
||||
Author string `xml:"author,attr"`
|
||||
Date string `xml:"date,attr"`
|
||||
Run wRun `xml:"r"`
|
||||
}
|
||||
|
||||
// wInsParagraphFragment is wParagraphFragment's (docx.go)
|
||||
// tracked-insert counterpart: same paragraph content, but with its
|
||||
// run wrapped in <w:ins> so a reviewer sees the whole new paragraph as
|
||||
// a pending insertion rather than it silently becoming part of the
|
||||
// document.
|
||||
type wInsParagraphFragment struct {
|
||||
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
|
||||
PPr *wPPr `xml:"pPr,omitempty"`
|
||||
Ins wIns `xml:"ins"`
|
||||
}
|
||||
|
||||
// AppendDocxParagraphTracked is AppendDocxParagraph's tracked-changes
|
||||
// sibling: the new paragraph is spliced in exactly the same way (see
|
||||
// AppendDocxParagraph's doc comment in docx.go), but its run is
|
||||
// wrapped in <w:ins>, so it shows up in Word as a pending insertion
|
||||
// (Review > Track Changes) instead of silently becoming part of the
|
||||
// document. author defaults to "enkly agent" if empty.
|
||||
func AppendDocxParagraphTracked(data []byte, p Paragraph, author string) ([]byte, error) {
|
||||
if author == "" {
|
||||
author = "enkly agent"
|
||||
}
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
wp := toWParagraph(p)
|
||||
frag := wInsParagraphFragment{
|
||||
PPr: wp.PPr,
|
||||
Ins: wIns{ID: nextRevisionIDFrom(raw), Author: author, Date: revisionDate(), Run: wp.Run},
|
||||
}
|
||||
paraXML, err := xml.Marshal(frag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal tracked paragraph: %w", err)
|
||||
}
|
||||
at := bodyInsertionPoint(raw)
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := make([]byte, 0, len(raw)+len(paraXML))
|
||||
newDoc = append(newDoc, raw[:at]...)
|
||||
newDoc = append(newDoc, paraXML...)
|
||||
newDoc = append(newDoc, raw[at:]...)
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// AcceptAllDocxChanges resolves every tracked change in the document
|
||||
// the way Word's "Accept All Changes" does: deleted text (<w:del>) is
|
||||
// removed for good, and inserted text (<w:ins>) becomes ordinary,
|
||||
// no-longer-marked content.
|
||||
func AcceptAllDocxChanges(data []byte) ([]byte, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := wDelBlockRe.ReplaceAll(raw, nil)
|
||||
newDoc = wInsBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
|
||||
return wInsBlockRe.FindSubmatch(m)[2]
|
||||
})
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// RejectAllDocxChanges resolves every tracked change the way Word's
|
||||
// "Reject All Changes" does: inserted text (<w:ins>) is discarded, and
|
||||
// deleted text (<w:del>) is restored as ordinary content.
|
||||
func RejectAllDocxChanges(data []byte) ([]byte, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := wInsBlockRe.ReplaceAll(raw, nil)
|
||||
newDoc = wDelBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
|
||||
inner := wDelBlockRe.FindSubmatch(m)[2]
|
||||
// delText -> t, so the restored text renders as normal content
|
||||
// rather than staying tagged as a (now orphaned) deletion.
|
||||
return wDelTextElementRe.ReplaceAllFunc(inner, func(tm []byte) []byte {
|
||||
g := wDelTextElementRe.FindSubmatch(tm)
|
||||
open := bytes.Replace(g[1], []byte("delText"), []byte("t"), 1)
|
||||
closeTag := bytes.Replace(g[3], []byte("delText"), []byte("t"), 1)
|
||||
out := make([]byte, 0, len(open)+len(g[2])+len(closeTag))
|
||||
out = append(out, open...)
|
||||
out = append(out, g[2]...)
|
||||
out = append(out, closeTag...)
|
||||
return out
|
||||
})
|
||||
})
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// wChangeBlockRe matches one complete <w:ins>...</w:ins> or
|
||||
// <w:del>...</w:del> block, capturing which kind it is, its opening
|
||||
// tag's attributes, and its inner content — ReadDocxRevisions' single
|
||||
// pass over the document.
|
||||
var wChangeBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?(ins|del)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?(?:ins|del)>`)
|
||||
|
||||
// ReadDocxRevisions lists every tracked change (insertion or deletion)
|
||||
// currently in the document — author and the affected text, in
|
||||
// document order — so a caller can review what Track Changes has
|
||||
// recorded before deciding whether to accept or reject it. Returns
|
||||
// ("", nil) if the document has no tracked changes at all.
|
||||
func ReadDocxRevisions(data []byte) (string, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
matches := wChangeBlockRe.FindAllSubmatch(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, m := range matches {
|
||||
kind, attrs, inner := string(m[1]), m[2], m[3]
|
||||
author := "unknown"
|
||||
if am := changeAuthorRe.FindSubmatch(attrs); am != nil {
|
||||
author = string(am[1])
|
||||
}
|
||||
label, textRe := "inserted", wTextElementRe
|
||||
if kind == "del" {
|
||||
label, textRe = "deleted", wDelTextElementRe
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, tm := range textRe.FindAllSubmatch(inner, -1) {
|
||||
text.WriteString(xmlUnescapeText(tm[2]))
|
||||
}
|
||||
fmt.Fprintf(&sb, "%d. %s by %s: %q\n", i+1, label, author, text.String())
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), nil
|
||||
}
|
||||
114
pkg/officekit/redline_test.go
Normal file
114
pkg/officekit/redline_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplaceDocxTextTrackedRoundTrip(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, n, err := ReplaceDocxTextTracked(data, "cell one text", "updated cell text", "Reviewer A")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceDocxTextTracked: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 run changed, got %d", n)
|
||||
}
|
||||
|
||||
// Plain read-back should show the NEW text (deleted text is review
|
||||
// chrome, not live content) and still see the untouched paragraph.
|
||||
text, err := ReadDocxText(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "updated cell text") {
|
||||
t.Errorf("expected new text in read-back, got: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "Intro paragraph.") {
|
||||
t.Errorf("unrelated paragraph lost: %s", text)
|
||||
}
|
||||
|
||||
revisions, err := ReadDocxRevisions(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxRevisions: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Reviewer A", "cell one text", "updated cell text"} {
|
||||
if !strings.Contains(revisions, want) {
|
||||
t.Errorf("expected revisions summary to contain %q, got:\n%s", want, revisions)
|
||||
}
|
||||
}
|
||||
|
||||
raw, ok, err := readZipPart(out, docxDocumentPart)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
if !strings.Contains(body, "<del ") || !strings.Contains(body, "<ins ") {
|
||||
t.Errorf("expected <del>/<ins> elements in document.xml, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocxParagraphTrackedThenAccept(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, err := AppendDocxParagraphTracked(data, Paragraph{Text: "Pending new line."}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("AppendDocxParagraphTracked: %v", err)
|
||||
}
|
||||
revisions, err := ReadDocxRevisions(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxRevisions: %v", err)
|
||||
}
|
||||
if !strings.Contains(revisions, "enkly agent") || !strings.Contains(revisions, "Pending new line.") {
|
||||
t.Errorf("expected default author + inserted text in revisions, got:\n%s", revisions)
|
||||
}
|
||||
|
||||
accepted, err := AcceptAllDocxChanges(out)
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptAllDocxChanges: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(accepted)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "Pending new line.") {
|
||||
t.Errorf("expected accepted insertion to survive as plain text, got: %s", text)
|
||||
}
|
||||
if noRev, err := ReadDocxRevisions(accepted); err != nil || noRev != "" {
|
||||
t.Errorf("expected no revisions left after accept, got %q (err=%v)", noRev, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDocxTextTrackedThenReject(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, _, err := ReplaceDocxTextTracked(data, "Intro paragraph.", "Rewritten intro.", "Reviewer B")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceDocxTextTracked: %v", err)
|
||||
}
|
||||
rejected, err := RejectAllDocxChanges(out)
|
||||
if err != nil {
|
||||
t.Fatalf("RejectAllDocxChanges: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(rejected)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "Intro paragraph.") {
|
||||
t.Errorf("expected original text restored after reject, got: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "Rewritten intro.") {
|
||||
t.Errorf("rejected insertion should not survive, got: %s", text)
|
||||
}
|
||||
if noRev, err := ReadDocxRevisions(rejected); err != nil || noRev != "" {
|
||||
t.Errorf("expected no revisions left after reject, got %q (err=%v)", noRev, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDocxRevisionsEmptyWhenNoChanges(t *testing.T) {
|
||||
revisions, err := ReadDocxRevisions(minimalWordDoc(t))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxRevisions: %v", err)
|
||||
}
|
||||
if revisions != "" {
|
||||
t.Errorf("expected no revisions in a plain document, got: %s", revisions)
|
||||
}
|
||||
}
|
||||
505
pkg/officekit/xlsx.go
Normal file
505
pkg/officekit/xlsx.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sheet is one worksheet's data, as the model supplies it to
|
||||
// create_spreadsheet: a name and a grid of string cell values (numbers
|
||||
// are written as plain decimal strings; anything that parses as a
|
||||
// number is stored as a numeric cell so Excel treats it as one).
|
||||
type Sheet struct {
|
||||
Name string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
const xlsxWorkbookPart = "xl/workbook.xml"
|
||||
const xlsxWorkbookRelsPart = "xl/_rels/workbook.xml.rels"
|
||||
|
||||
// xlsxSheetPart returns the conventional worksheet part path for the
|
||||
// Nth (1-based) sheet BuildXlsx writes — sheet1.xml, sheet2.xml, ...
|
||||
func xlsxSheetPart(n int) string { return fmt.Sprintf("xl/worksheets/sheet%d.xml", n) }
|
||||
|
||||
// --- typed XML for the parts BuildXlsx/edit operations actually
|
||||
// generate from user-supplied content (worksheet XML, plus the small
|
||||
// workbook/rels indirection needed to support more than one sheet);
|
||||
// every other part is static boilerplate with no user content. ---
|
||||
|
||||
type sWorksheet struct {
|
||||
XMLName xml.Name `xml:"worksheet"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
SheetData sSheetData `xml:"sheetData"`
|
||||
}
|
||||
|
||||
type sSheetData struct {
|
||||
Rows []sRow `xml:"row"`
|
||||
}
|
||||
|
||||
type sRow struct {
|
||||
R int `xml:"r,attr"`
|
||||
Cells []sCell `xml:"c"`
|
||||
}
|
||||
|
||||
// sCell's field order (after the r/t attrs) follows OOXML's CT_Cell
|
||||
// schema sequence — f (formula), v (value), is (inline string) — so a
|
||||
// formula cell with a cached value (f + v both set, as real Excel
|
||||
// always writes them) round-trips as a schema-valid element.
|
||||
type sCell struct {
|
||||
R string `xml:"r,attr"`
|
||||
Type string `xml:"t,attr,omitempty"` // "inlineStr" for text; omitted (numeric/formula) otherwise
|
||||
Formula *string `xml:"f,omitempty"` // formula cells ("=SUM(...)" minus the leading "=")
|
||||
Value *string `xml:"v,omitempty"` // numeric cells, and a formula's last-known/cached result
|
||||
Is *sIs `xml:"is,omitempty"` // inline-string cells
|
||||
}
|
||||
|
||||
type sIs struct {
|
||||
T string `xml:"t"`
|
||||
}
|
||||
|
||||
// sWorkbook is the minimal shape of xl/workbook.xml this package
|
||||
// cares about: just enough to resolve a sheet name/1-based index to
|
||||
// its relationship id, so ReadXlsxCells/SetXlsxCells can find the
|
||||
// right worksheet part even in a workbook with more than one sheet
|
||||
// (including ones not authored by BuildXlsx, e.g. a user upload).
|
||||
type sWorkbook struct {
|
||||
XMLName xml.Name `xml:"workbook"`
|
||||
Sheets []sWbName `xml:"sheets>sheet"`
|
||||
}
|
||||
|
||||
type sWbName struct {
|
||||
Name string `xml:"name,attr"`
|
||||
RID string `xml:"id,attr"` // r:id — encoding/xml matches the local name regardless of the "r" namespace prefix.
|
||||
}
|
||||
|
||||
// sRelationships is the minimal shape of a .rels part this package
|
||||
// needs: a relationship id -> target path.
|
||||
type sRelationships struct {
|
||||
XMLName xml.Name `xml:"Relationships"`
|
||||
Rel []sRelationship `xml:"Relationship"`
|
||||
}
|
||||
|
||||
type sRelationship struct {
|
||||
ID string `xml:"Id,attr"`
|
||||
Target string `xml:"Target,attr"`
|
||||
}
|
||||
|
||||
// colLetter converts a 0-based column index to its spreadsheet letter
|
||||
// (0 -> A, 25 -> Z, 26 -> AA, ...).
|
||||
func colLetter(i int) string {
|
||||
s := ""
|
||||
for {
|
||||
s = string(rune('A'+(i%26))) + s
|
||||
i = i/26 - 1
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.ParseFloat(s, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func rowsToSheetXML(rows [][]string) ([]byte, error) {
|
||||
sd := sSheetData{}
|
||||
for ri, row := range rows {
|
||||
r := sRow{R: ri + 1}
|
||||
for ci, val := range row {
|
||||
cell := sCell{R: colLetter(ci) + strconv.Itoa(ri+1)}
|
||||
setCellValue(&cell, val)
|
||||
r.Cells = append(r.Cells, cell)
|
||||
}
|
||||
sd.Rows = append(sd.Rows, r)
|
||||
}
|
||||
ws := sWorksheet{Xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", SheetData: sd}
|
||||
body, err := xml.Marshal(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
|
||||
}
|
||||
return []byte(xml.Header + string(body)), nil
|
||||
}
|
||||
|
||||
// BuildXlsx renders one or more sheets into a complete, valid .xlsx
|
||||
// workbook — every sheet given is written as its own worksheet part
|
||||
// and listed in xl/workbook.xml in order, each with a matching
|
||||
// relationship in xl/_rels/workbook.xml.rels. An unnamed sheet gets
|
||||
// the conventional "SheetN" name.
|
||||
func BuildXlsx(sheets []Sheet) ([]byte, error) {
|
||||
if len(sheets) == 0 {
|
||||
sheets = []Sheet{{Name: "Sheet1"}}
|
||||
}
|
||||
|
||||
parts := []zipPart{
|
||||
{Name: "_rels/.rels", Body: xlsxRootRels},
|
||||
{Name: "xl/styles.xml", Body: xlsxStyles},
|
||||
{Name: "docProps/core.xml", Body: officeCoreProps},
|
||||
}
|
||||
|
||||
var sheetEntries, relEntries, overrides strings.Builder
|
||||
for i, sh := range sheets {
|
||||
idx := i + 1
|
||||
name := sh.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Sheet%d", idx)
|
||||
}
|
||||
sheetXML, err := rowsToSheetXML(sh.Rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, zipPart{Name: xlsxSheetPart(idx), Body: string(sheetXML)})
|
||||
fmt.Fprintf(&sheetEntries, `<sheet name="%s" sheetId="%d" r:id="rId%d"/>`, xmlEscapeAttr(name), idx, idx)
|
||||
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet%d.xml"/>`, idx, idx)
|
||||
fmt.Fprintf(&overrides, `<Override PartName="/xl/worksheets/sheet%d.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`, idx)
|
||||
}
|
||||
// The styles relationship id just needs to be distinct from every
|
||||
// sheet's rId above; one past the last sheet always is.
|
||||
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`, len(sheets)+1)
|
||||
|
||||
workbookXML := xml.Header + `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
|
||||
sheetEntries.String() + `</sheets></workbook>`
|
||||
workbookRels := xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
relEntries.String() + `</Relationships>`
|
||||
contentTypes := xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
|
||||
`<Default Extension="xml" ContentType="application/xml"/>` +
|
||||
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
|
||||
overrides.String() +
|
||||
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
|
||||
`<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`
|
||||
|
||||
parts = append(parts,
|
||||
zipPart{Name: "[Content_Types].xml", Body: contentTypes},
|
||||
zipPart{Name: xlsxWorkbookPart, Body: workbookXML},
|
||||
zipPart{Name: xlsxWorkbookRelsPart, Body: workbookRels},
|
||||
)
|
||||
return buildZip(parts)
|
||||
}
|
||||
|
||||
// xmlEscapeAttr escapes the handful of characters that matter inside
|
||||
// an XML attribute value. Used only for the places we interpolate
|
||||
// user text into a hand-written template string (sheet names in
|
||||
// workbook.xml) rather than through encoding/xml.
|
||||
func xmlEscapeAttr(s string) string {
|
||||
r := strings.NewReplacer(`&`, "&", `<`, "<", `>`, ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// ReadXlsxSheetNames returns every sheet name in the workbook, in
|
||||
// order — used by read_spreadsheet to tell the model what else is in
|
||||
// a multi-sheet file it only asked to read one sheet of.
|
||||
func ReadXlsxSheetNames(data []byte) ([]string, error) {
|
||||
wb, err := readXlsxWorkbook(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, len(wb.Sheets))
|
||||
for i, s := range wb.Sheets {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func readXlsxWorkbook(data []byte) (*sWorkbook, error) {
|
||||
raw, ok, err := readZipPart(data, xlsxWorkbookPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", xlsxWorkbookPart)
|
||||
}
|
||||
var wb sWorkbook
|
||||
if err := xml.Unmarshal(raw, &wb); err != nil {
|
||||
return nil, fmt.Errorf("officekit: parse workbook xml: %w", err)
|
||||
}
|
||||
return &wb, nil
|
||||
}
|
||||
|
||||
// resolveXlsxSheetPart maps a sheet reference (empty for the first
|
||||
// sheet, a 1-based index as a string, or a sheet name) to that sheet's
|
||||
// actual worksheet part path, by reading xl/workbook.xml and
|
||||
// xl/_rels/workbook.xml.rels — not by assuming sheet1.xml is sheet
|
||||
// one, since that isn't guaranteed for a file this package didn't
|
||||
// write itself (a user-uploaded workbook whose sheets were reordered
|
||||
// or renumbered in Excel). Falls back to the sheet1.xml convention
|
||||
// only if the workbook's own bookkeeping can't be parsed at all.
|
||||
func resolveXlsxSheetPart(data []byte, sheetRef string) (partPath, sheetName string, err error) {
|
||||
wb, err := readXlsxWorkbook(data)
|
||||
if err != nil || len(wb.Sheets) == 0 {
|
||||
return xlsxSheetPart(1), "", nil
|
||||
}
|
||||
|
||||
var target *sWbName
|
||||
switch {
|
||||
case sheetRef == "":
|
||||
target = &wb.Sheets[0]
|
||||
default:
|
||||
if n, convErr := strconv.Atoi(sheetRef); convErr == nil {
|
||||
if n < 1 || n > len(wb.Sheets) {
|
||||
return "", "", fmt.Errorf("officekit: sheet %d out of range (workbook has %d sheet(s))", n, len(wb.Sheets))
|
||||
}
|
||||
target = &wb.Sheets[n-1]
|
||||
} else {
|
||||
for i := range wb.Sheets {
|
||||
if strings.EqualFold(wb.Sheets[i].Name, sheetRef) {
|
||||
target = &wb.Sheets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return "", "", fmt.Errorf("officekit: no sheet named %q", sheetRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
relsRaw, ok, err := readZipPart(data, xlsxWorkbookRelsPart)
|
||||
if err != nil || !ok {
|
||||
return xlsxSheetPart(1), target.Name, nil
|
||||
}
|
||||
var rels sRelationships
|
||||
if err := xml.Unmarshal(relsRaw, &rels); err != nil {
|
||||
return xlsxSheetPart(1), target.Name, nil
|
||||
}
|
||||
for _, r := range rels.Rel {
|
||||
if r.ID == target.RID {
|
||||
t := strings.TrimPrefix(r.Target, "/")
|
||||
if !strings.HasPrefix(t, "xl/") {
|
||||
t = "xl/" + t
|
||||
}
|
||||
return t, target.Name, nil
|
||||
}
|
||||
}
|
||||
return xlsxSheetPart(1), target.Name, nil
|
||||
}
|
||||
|
||||
// ReadXlsxCells returns one worksheet's cells as a row-major grid of
|
||||
// display strings, for the read_spreadsheet tool. sheet selects which
|
||||
// one: empty for the first sheet, a 1-based index ("2"), or a sheet
|
||||
// name (case-insensitive); the resolved name is returned alongside so
|
||||
// callers can report it.
|
||||
func ReadXlsxCells(data []byte, sheet string) ([][]string, string, error) {
|
||||
partPath, sheetName, err := resolveXlsxSheetPart(data, sheet)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, ok, err := readZipPart(data, partPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
|
||||
}
|
||||
var ws sWorksheet
|
||||
if err := xml.Unmarshal(raw, &ws); err != nil {
|
||||
return nil, "", fmt.Errorf("officekit: parse worksheet xml: %w", err)
|
||||
}
|
||||
var out [][]string
|
||||
for _, row := range ws.SheetData.Rows {
|
||||
var line []string
|
||||
for _, c := range row.Cells {
|
||||
switch {
|
||||
case c.Is != nil:
|
||||
line = append(line, c.Is.T)
|
||||
case c.Value != nil:
|
||||
// A real Excel-authored formula cell always caches its
|
||||
// last-computed value alongside the formula itself; show
|
||||
// that computed result rather than the formula text.
|
||||
line = append(line, *c.Value)
|
||||
case c.Formula != nil:
|
||||
// A formula cell this package just wrote has no cached
|
||||
// value (nothing here evaluates formulas) — show the
|
||||
// formula itself rather than a misleading blank.
|
||||
line = append(line, "="+*c.Formula)
|
||||
default:
|
||||
line = append(line, "")
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, sheetName, nil
|
||||
}
|
||||
|
||||
// SetXlsxCells downloads-parses-modifies-reuploads: applies a sparse
|
||||
// set of cell writes (keyed by "A1"-style reference) to one worksheet
|
||||
// (selected the same way as ReadXlsxCells) and returns the new file
|
||||
// bytes. Cells outside the current grid extend it; existing rows/cells
|
||||
// are preserved otherwise.
|
||||
func SetXlsxCells(data []byte, sheet string, writes map[string]string) ([]byte, error) {
|
||||
partPath, _, err := resolveXlsxSheetPart(data, sheet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, ok, err := readZipPart(data, partPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
|
||||
}
|
||||
var ws sWorksheet
|
||||
if err := xml.Unmarshal(raw, &ws); err != nil {
|
||||
return nil, fmt.Errorf("officekit: parse worksheet xml: %w", err)
|
||||
}
|
||||
|
||||
// rowByNum maps a row number to its INDEX in ws.SheetData.Rows, not
|
||||
// a pointer into it. A pointer taken here (&ws.SheetData.Rows[i])
|
||||
// is only valid for the backing array that exists at the moment
|
||||
// it's taken; the very next `append` below that grows the slice
|
||||
// past its capacity allocates a new backing array and copies into
|
||||
// it, silently stranding any earlier-taken pointer on the old,
|
||||
// now-orphaned one. Writes applied through a stranded pointer
|
||||
// still "succeed" from the caller's point of view — no error, no
|
||||
// panic — but vanish, because they land on memory nothing else
|
||||
// reads. An index has no such failure mode: ws.SheetData.Rows[idx]
|
||||
// always re-resolves against whatever backing array the slice
|
||||
// header currently points to.
|
||||
rowByNum := map[int]int{}
|
||||
for i := range ws.SheetData.Rows {
|
||||
rowByNum[ws.SheetData.Rows[i].R] = i
|
||||
}
|
||||
|
||||
// Writes are applied in ascending (row, column) order rather than
|
||||
// map iteration order (which Go deliberately randomizes). This
|
||||
// doesn't affect correctness — sortSheetData below restores final
|
||||
// order regardless — but it keeps behavior deterministic and easy
|
||||
// to reason about/debug when a batch of cell writes is logged or
|
||||
// replayed.
|
||||
refs := make([]string, 0, len(writes))
|
||||
for ref := range writes {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
sort.Slice(refs, func(a, b int) bool {
|
||||
ra, ca, _ := parseCellRef(refs[a])
|
||||
rb, cb, _ := parseCellRef(refs[b])
|
||||
if ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
return ca < cb
|
||||
})
|
||||
|
||||
for _, ref := range refs {
|
||||
val := writes[ref]
|
||||
rowNum, _, err := parseCellRef(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx, ok := rowByNum[rowNum]
|
||||
if !ok {
|
||||
ws.SheetData.Rows = append(ws.SheetData.Rows, sRow{R: rowNum})
|
||||
idx = len(ws.SheetData.Rows) - 1
|
||||
rowByNum[rowNum] = idx
|
||||
}
|
||||
row := &ws.SheetData.Rows[idx]
|
||||
replaced := false
|
||||
for i := range row.Cells {
|
||||
if row.Cells[i].R == ref {
|
||||
setCellValue(&row.Cells[i], val)
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
c := sCell{R: ref}
|
||||
setCellValue(&c, val)
|
||||
row.Cells = append(row.Cells, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Excel is strict about row/cell order within xl/worksheets/sheetN.xml
|
||||
// (rows ascending by r, cells within a row ascending by column) —
|
||||
// writes above can append a row/cell out of order (e.g. writing "A1"
|
||||
// after "A5" already existed), which a lenient reader shrugs off but
|
||||
// Excel itself can flag as needing "repair". Restore ascending order
|
||||
// before marshaling so a sparse, arbitrarily-ordered set of writes
|
||||
// always produces a schema-valid worksheet.
|
||||
sortSheetData(&ws.SheetData)
|
||||
|
||||
body, err := xml.Marshal(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
|
||||
}
|
||||
return replaceZipPart(data, partPath, []byte(xml.Header+string(body)))
|
||||
}
|
||||
|
||||
// sortSheetData reorders rows ascending by row number and, within each
|
||||
// row, cells ascending by column — the order SetXlsxCells' sparse,
|
||||
// map-driven writes don't otherwise guarantee.
|
||||
func sortSheetData(sd *sSheetData) {
|
||||
sort.Slice(sd.Rows, func(i, j int) bool { return sd.Rows[i].R < sd.Rows[j].R })
|
||||
for i := range sd.Rows {
|
||||
cells := sd.Rows[i].Cells
|
||||
sort.Slice(cells, func(a, b int) bool { return cellColumn(cells[a].R) < cellColumn(cells[b].R) })
|
||||
}
|
||||
}
|
||||
|
||||
// cellColumn returns a cell reference's 0-based column index (e.g.
|
||||
// "B12" -> 1), or 0 if ref doesn't parse as one — used only to order
|
||||
// cells within a row for output, so a malformed ref sorts first rather
|
||||
// than failing a write this deep into the process.
|
||||
func cellColumn(ref string) int {
|
||||
_, col, err := parseCellRef(ref)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
// setCellValue classifies val and sets exactly the sCell fields that
|
||||
// representation needs, clearing the others: a leading "=" (with at
|
||||
// least one character after it) stores a formula, same convention
|
||||
// Excel itself uses to distinguish "=SUM(A1:A2)" from the literal text
|
||||
// "=SUM(A1:A2)"; otherwise a value that parses as a plain number is
|
||||
// numeric, and everything else is inline text.
|
||||
func setCellValue(c *sCell, val string) {
|
||||
switch {
|
||||
case strings.HasPrefix(val, "=") && len(val) > 1:
|
||||
f := val[1:]
|
||||
c.Formula, c.Value, c.Is, c.Type = &f, nil, nil, ""
|
||||
case isNumeric(val):
|
||||
v := val
|
||||
c.Formula, c.Value, c.Is, c.Type = nil, &v, nil, ""
|
||||
default:
|
||||
c.Formula, c.Value = nil, nil
|
||||
c.Is, c.Type = &sIs{T: val}, "inlineStr"
|
||||
}
|
||||
}
|
||||
|
||||
// parseCellRef splits "B12" into (row=12, col=1). Only used to locate
|
||||
// a row bucket for SetXlsxCells; the column stays embedded in the "r"
|
||||
// attribute string as-is.
|
||||
func parseCellRef(ref string) (row, col int, err error) {
|
||||
i := 0
|
||||
for i < len(ref) && ref[i] >= 'A' && ref[i] <= 'Z' {
|
||||
col = col*26 + int(ref[i]-'A'+1)
|
||||
i++
|
||||
}
|
||||
if i == 0 || i == len(ref) {
|
||||
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
|
||||
}
|
||||
row, err = strconv.Atoi(ref[i:])
|
||||
if err != nil || row < 1 {
|
||||
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
|
||||
}
|
||||
return row, col - 1, nil
|
||||
}
|
||||
|
||||
const xlsxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const xlsxStyles = xml.Header + `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||
</styleSheet>`
|
||||
181
pkg/officekit/zipkit.go
Normal file
181
pkg/officekit/zipkit.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Package officekit creates and edits real, native Office Open XML
|
||||
// files — .docx (Word), .xlsx (Excel), .pptx (PowerPoint) — as plain
|
||||
// Go structs and byte slices, with no dependency beyond the standard
|
||||
// library (archive/zip + encoding/xml).
|
||||
//
|
||||
// # Why this package exists
|
||||
//
|
||||
// enkly's agent talks to a Qwen model through a vLLM OpenAI-compatible
|
||||
// tool-calling API (pkg/vllmkit). Tool calls and tool results travel
|
||||
// as structured JSON on that wire (an "arguments" string vLLM's
|
||||
// tool-call parser extracts from the model's raw generation), not
|
||||
// wrapped in the XML-ish chat-template delimiters an Ollama deployment
|
||||
// of this same model would have used — so the specific "XML syntax
|
||||
// error on line N" failure mode an earlier, Ollama-backed version of
|
||||
// this service saw when a tool result contained raw angle-bracket
|
||||
// markup no longer applies verbatim. vLLM's tool-call parser can still
|
||||
// fail to extract a clean call from the model's output, surfaced
|
||||
// client-side as vllmkit.IsToolCallParseError (see
|
||||
// agentrun/agent.go's compactToolHistory doc comment for the retry
|
||||
// strategy built on top of it) — a different failure mode, but the
|
||||
// same underlying risk: anything that makes the model's raw output
|
||||
// harder for its tool-call parser to read is worth avoiding.
|
||||
//
|
||||
// The structural fix lives here regardless of transport: the model
|
||||
// NEVER authors or receives OOXML. It only ever calls a tool with
|
||||
// plain structured arguments
|
||||
// (a title, a list of paragraphs, a grid of cell values, a list of
|
||||
// slide bullets) and gets back a short plain-text summary. This
|
||||
// package does 100% of the actual XML generation and parsing,
|
||||
// server-side, using Go's typed XML encoder/decoder (which always
|
||||
// produces well-formed, correctly-escaped XML) — and the resulting
|
||||
// bytes travel to/from the workspace service over the existing
|
||||
// UploadFile/DownloadFile RPCs, which carry raw []byte, not the
|
||||
// text-oriented ReadFile/WriteFile string fields. No proto changes,
|
||||
// no binary-over-text encoding, and no XML ever touches the LLM.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// zipPart is one file inside an OOXML package (which is just a zip
|
||||
// archive with a prescribed internal layout).
|
||||
type zipPart struct {
|
||||
Name string
|
||||
Body string
|
||||
}
|
||||
|
||||
// buildZip packages parts into an OOXML container. Office is picky
|
||||
// about [Content_Types].xml being present; beyond that, part order
|
||||
// doesn't matter to any reader in practice, but we write it first
|
||||
// anyway since that's the convention most real Office-authored files
|
||||
// follow.
|
||||
func buildZip(parts []zipPart) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, p := range parts {
|
||||
w, err := zw.Create(p.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(p.Body)); err != nil {
|
||||
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("officekit: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// readZipPart extracts one named part's raw bytes from an OOXML
|
||||
// package, or returns ok=false if it isn't present.
|
||||
func readZipPart(data []byte, name string) ([]byte, bool, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("officekit: open %s: %w", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
return nil, false, fmt.Errorf("officekit: read %s: %w", name, err)
|
||||
}
|
||||
return buf.Bytes(), true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// addZipParts returns a copy of an OOXML package with every existing
|
||||
// part copied through unchanged, plus the given new parts appended
|
||||
// (used by AppendPptxSlide to add a slideN.xml/slideN.xml.rels pair
|
||||
// without disturbing anything already in the archive).
|
||||
func addZipParts(data []byte, newParts []zipPart) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, f := range zr.File {
|
||||
w, err := zw.Create(f.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
|
||||
}
|
||||
_, copyErr := io.Copy(w, rc)
|
||||
rc.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
|
||||
}
|
||||
}
|
||||
for _, p := range newParts {
|
||||
w, err := zw.Create(p.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(p.Body)); err != nil {
|
||||
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("officekit: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// replaceZipPart returns a copy of an OOXML package with one part's
|
||||
// body replaced (used by the edit_* operations: download, patch one
|
||||
// XML part, re-upload). Every other part is copied through unchanged.
|
||||
func replaceZipPart(data []byte, name string, newBody []byte) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
replaced := false
|
||||
for _, f := range zr.File {
|
||||
w, err := zw.Create(f.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == name {
|
||||
replaced = true
|
||||
if _, err := w.Write(newBody); err != nil {
|
||||
return nil, fmt.Errorf("officekit: write %s: %w", f.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
|
||||
}
|
||||
_, copyErr := io.Copy(w, rc)
|
||||
rc.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
return nil, fmt.Errorf("officekit: part %s not found in archive", name)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("officekit: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
107
pkg/pgkit/pgkit.go
Normal file
107
pkg/pgkit/pgkit.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package pgkit is the small piece of internal/db/db.go that wasn't
|
||||
// specific to any one bounded context: connecting with a startup retry
|
||||
// loop (docker compose's "healthy" and "actually accepting
|
||||
// connections" can be a beat apart) and applying an idempotent
|
||||
// schema.sql-style migration under an advisory lock so two replicas of
|
||||
// the same service starting at once don't race each other.
|
||||
//
|
||||
// Each data-owning service (iam, billing, conversation, knowledge) gets
|
||||
// its own *pgkit.DB over its own database — see each service's
|
||||
// migrations/0001_init.sql — never a shared one. That split is the
|
||||
// difference between "microservices" and "one database with several
|
||||
// front ends" (see ARCHITECTURE.md, "Database-per-service").
|
||||
package pgkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Sentinel errors every repository package in this repo returns, so
|
||||
// application-layer use cases can errors.Is against one shared set
|
||||
// instead of each service inventing its own (matching internal/db's
|
||||
// original ErrNotFound/ErrEmailTaken/ErrInvalidToken).
|
||||
var (
|
||||
ErrNotFound = errors.New("pgkit: not found")
|
||||
ErrAlreadyTaken = errors.New("pgkit: unique constraint violated")
|
||||
ErrInvalid = errors.New("pgkit: invalid or expired")
|
||||
)
|
||||
|
||||
// DB wraps *sql.DB exactly as internal/db.DB did.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open connects to Postgres and retries pinging it for up to ~30s.
|
||||
func Open(dsn string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgkit: open: %w", err)
|
||||
}
|
||||
|
||||
// Pool sizing: each microservice is still a single logical writer
|
||||
// per table, same reasoning as the monolith's own comment here —
|
||||
// a handful of connections covers concurrent gRPC calls without
|
||||
// exhausting Postgres' default max_connections (100) once you
|
||||
// multiply by four data-owning services.
|
||||
sqlDB.SetMaxOpenConns(20)
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
var lastErr error
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
lastErr = sqlDB.PingContext(ctx)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return &DB{sqlDB}, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("pgkit: ping (gave up after 30s): %w", lastErr)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// migrationLockID is an arbitrary constant used with pg_advisory_lock.
|
||||
// Shared across services since each connects to its own database (the
|
||||
// lock is scoped per-database by Postgres), so there's no cross-service
|
||||
// collision risk in reusing one constant.
|
||||
const migrationLockID = 7_402_113_915
|
||||
|
||||
// Migrate applies schema (expected to be idempotent — CREATE ... IF NOT
|
||||
// EXISTS throughout, as with the monolith's schema.sql) under an
|
||||
// advisory lock.
|
||||
func (d *DB) Migrate(ctx context.Context, schema string) error {
|
||||
conn, err := d.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: acquire connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrationLockID); err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, migrationLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUniqueViolation reports whether err is Postgres' unique_violation (23505).
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||
}
|
||||
359
pkg/renderkit/highlight.go
Normal file
359
pkg/renderkit/highlight.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A small, dependency-free syntax highlighter for fenced code blocks.
|
||||
//
|
||||
// It is a single-pass lexer, not a parser: it recognises comments, strings,
|
||||
// numbers, keywords, built-in types, constants, function-call names and (for
|
||||
// shell) $variables, and wraps each in <span class="tok-...">. That is
|
||||
// enough to make code readable without pulling in a highlighting library or
|
||||
// a CDN (this stack must work fully offline).
|
||||
//
|
||||
// Safety: input is gomarkdown's already-escaped block body. It is unescaped,
|
||||
// tokenised, and every emitted piece is re-escaped, so no model-controlled
|
||||
// text can ever become markup. The visible text is unchanged (the copy
|
||||
// button still copies the original code).
|
||||
|
||||
// maxHighlightBytes skips highlighting for huge blocks; they are rare and
|
||||
// the lexer is per-byte.
|
||||
const maxHighlightBytes = 128 * 1024
|
||||
|
||||
type langSpec struct {
|
||||
keywords, types, consts map[string]bool
|
||||
lineComments []string
|
||||
blockStart, blockEnd string
|
||||
quotes string
|
||||
tripleQuotes bool
|
||||
foldCase bool // keywords are case-insensitive (SQL)
|
||||
shellVars bool
|
||||
jsonKeys bool
|
||||
}
|
||||
|
||||
func wordSet(words string) map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for _, w := range strings.Fields(words) {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var (
|
||||
goSpec = &langSpec{
|
||||
keywords: wordSet(`break case chan const continue default defer else fallthrough for func go goto if
|
||||
import interface map package range return select struct switch type var`),
|
||||
types: wordSet(`string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte rune
|
||||
float32 float64 complex64 complex128 bool error any`),
|
||||
consts: wordSet(`true false nil iota`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'`",
|
||||
}
|
||||
jsSpec = &langSpec{
|
||||
keywords: wordSet(`async await break case catch class const continue debugger default delete do else
|
||||
export extends finally for from function if import in instanceof let new of return static super
|
||||
switch this throw try typeof var void while with yield interface type enum implements namespace
|
||||
declare readonly public private protected abstract as`),
|
||||
types: wordSet(`string number boolean any unknown never object Promise Array Map Set`),
|
||||
consts: wordSet(`true false null undefined NaN Infinity`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'`",
|
||||
}
|
||||
pySpec = &langSpec{
|
||||
keywords: wordSet(`and as assert async await break class continue def del elif else except finally for
|
||||
from global if import in is lambda nonlocal not or pass raise return try while with yield match case`),
|
||||
types: wordSet(`int str float bool list dict set tuple bytes object`),
|
||||
consts: wordSet(`True False None`),
|
||||
lineComments: []string{"#"},
|
||||
quotes: "\"'", tripleQuotes: true,
|
||||
}
|
||||
shSpec = &langSpec{
|
||||
keywords: wordSet(`if then else elif fi for while until do done case esac in function select time
|
||||
return exit break continue export local readonly declare unset source alias`),
|
||||
types: wordSet(`echo cd ls cat grep sed awk find git go make docker curl mkdir rm cp mv chmod chown
|
||||
touch sudo npm pip python gofmt`),
|
||||
lineComments: []string{"#"},
|
||||
quotes: "\"'", shellVars: true,
|
||||
}
|
||||
jsonSpec = &langSpec{
|
||||
consts: wordSet(`true false null`),
|
||||
quotes: `"`, jsonKeys: true,
|
||||
}
|
||||
sqlSpec = &langSpec{
|
||||
keywords: wordSet(`select from where insert into values update set delete create table alter drop index
|
||||
join left right inner outer full cross on group by order having limit offset as and or not in is
|
||||
like between exists union all distinct case when then else end primary key foreign references
|
||||
default unique constraint with returning begin commit rollback extension if`),
|
||||
types: wordSet(`int integer bigint smallint serial bigserial text varchar char boolean bool date
|
||||
timestamp timestamptz numeric decimal float double real jsonb json uuid vector`),
|
||||
consts: wordSet(`true false null`),
|
||||
lineComments: []string{"--"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'", foldCase: true,
|
||||
}
|
||||
cSpec = &langSpec{
|
||||
keywords: wordSet(`auto break case const continue default do else enum extern for goto if inline
|
||||
register return sizeof static struct switch typedef union volatile while class namespace
|
||||
template typename public private protected virtual new delete using try catch throw`),
|
||||
types: wordSet(`void char short int long float double signed unsigned bool size_t int8_t int16_t
|
||||
int32_t int64_t uint8_t uint16_t uint32_t uint64_t string`),
|
||||
consts: wordSet(`true false NULL nullptr`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'",
|
||||
}
|
||||
javaSpec = &langSpec{
|
||||
keywords: wordSet(`abstract assert break case catch class const continue default do else enum extends
|
||||
final finally for if implements import instanceof interface native new package private protected
|
||||
public return static super switch synchronized this throw throws try void volatile while`),
|
||||
types: wordSet(`boolean byte char short int long float double String Object List Map Set`),
|
||||
consts: wordSet(`true false null`), lineComments: []string{"//"},
|
||||
blockStart: "/*", blockEnd: "*/", quotes: "\"'",
|
||||
}
|
||||
// No ' in quotes: Rust lifetimes ('a) would swallow the rest of the line.
|
||||
rustSpec = &langSpec{
|
||||
keywords: wordSet(`as async await break const continue crate dyn else enum extern fn for if impl in
|
||||
let loop match mod move mut pub ref return self Self static struct super trait type unsafe use
|
||||
where while`),
|
||||
types: wordSet(`i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String
|
||||
Vec Option Result Box`),
|
||||
consts: wordSet(`true false None Some Ok Err`), lineComments: []string{"//"},
|
||||
blockStart: "/*", blockEnd: "*/", quotes: `"`,
|
||||
}
|
||||
|
||||
langByName = map[string]*langSpec{
|
||||
"go": goSpec, "golang": goSpec,
|
||||
"js": jsSpec, "javascript": jsSpec, "jsx": jsSpec, "ts": jsSpec, "typescript": jsSpec, "tsx": jsSpec,
|
||||
"py": pySpec, "python": pySpec,
|
||||
"sh": shSpec, "bash": shSpec, "shell": shSpec, "zsh": shSpec, "console": shSpec,
|
||||
"json": jsonSpec,
|
||||
"sql": sqlSpec, "postgres": sqlSpec, "psql": sqlSpec,
|
||||
"c": cSpec, "cpp": cSpec, "c++": cSpec, "cc": cSpec, "h": cSpec, "hpp": cSpec,
|
||||
"java": javaSpec,
|
||||
"rust": rustSpec, "rs": rustSpec,
|
||||
}
|
||||
)
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
func isIdentStart(c byte) bool { return c == '_' || c == '$' || (c|0x20 >= 'a' && c|0x20 <= 'z') }
|
||||
func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) }
|
||||
|
||||
func spanned(b *strings.Builder, class, text string) {
|
||||
if class == "" {
|
||||
b.WriteString(stdhtml.EscapeString(text))
|
||||
return
|
||||
}
|
||||
b.WriteString(`<span class="tok-`)
|
||||
b.WriteString(class)
|
||||
b.WriteString(`">`)
|
||||
b.WriteString(stdhtml.EscapeString(text))
|
||||
b.WriteString(`</span>`)
|
||||
}
|
||||
|
||||
// highlight takes a fenced block's language and its HTML-escaped body and
|
||||
// returns the body with token spans. Unknown languages come back unchanged.
|
||||
func highlight(lang, escapedBody string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if len(escapedBody) > maxHighlightBytes {
|
||||
return escapedBody
|
||||
}
|
||||
if lang == "diff" || lang == "patch" {
|
||||
return highlightDiff(stdhtml.UnescapeString(escapedBody))
|
||||
}
|
||||
spec := langByName[lang]
|
||||
if spec == nil {
|
||||
return escapedBody
|
||||
}
|
||||
return spec.lex(stdhtml.UnescapeString(escapedBody))
|
||||
}
|
||||
|
||||
func highlightDiff(src string) string {
|
||||
var b strings.Builder
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, ln := range lines {
|
||||
class := ""
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+++") || strings.HasPrefix(ln, "---"):
|
||||
class = "hunk"
|
||||
case strings.HasPrefix(ln, "@@"):
|
||||
class = "hunk"
|
||||
case strings.HasPrefix(ln, "+"):
|
||||
class = "add"
|
||||
case strings.HasPrefix(ln, "-"):
|
||||
class = "del"
|
||||
}
|
||||
spanned(&b, class, ln)
|
||||
if i < len(lines)-1 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *langSpec) lex(src string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(src) + len(src)/2)
|
||||
i := 0
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
rest := src[i:]
|
||||
|
||||
if n := s.commentLen(src, i); n > 0 {
|
||||
spanned(&b, "cmt", rest[:n])
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.IndexByte(s.quotes, c) >= 0 {
|
||||
n := s.stringLen(rest)
|
||||
class := "str"
|
||||
if s.jsonKeys {
|
||||
k := i + n
|
||||
for k < len(src) && (src[k] == ' ' || src[k] == '\t') {
|
||||
k++
|
||||
}
|
||||
if k < len(src) && src[k] == ':' {
|
||||
class = "key"
|
||||
}
|
||||
}
|
||||
spanned(&b, class, rest[:n])
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
|
||||
if isDigit(c) && (i == 0 || !isIdent(src[i-1])) {
|
||||
j := i + 1
|
||||
for j < len(src) && (isIdent(src[j]) || (src[j] == '.' && j+1 < len(src) && isDigit(src[j+1]))) {
|
||||
j++
|
||||
}
|
||||
spanned(&b, "num", src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if s.shellVars && c == '$' && i+1 < len(src) && (isIdentStart(src[i+1]) || src[i+1] == '{') {
|
||||
j := i + 1
|
||||
if src[j] == '{' {
|
||||
if k := strings.IndexAny(src[j:], "}\n"); k > 0 && src[j+k] == '}' {
|
||||
j += k + 1
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
} else {
|
||||
for j < len(src) && isIdent(src[j]) {
|
||||
j++
|
||||
}
|
||||
}
|
||||
spanned(&b, "var", src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if isIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(src) && isIdent(src[j]) {
|
||||
j++
|
||||
}
|
||||
spanned(&b, s.classify(src[i:j], src[j:]), src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
_, size := utf8.DecodeRuneInString(rest)
|
||||
spanned(&b, "", rest[:size])
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *langSpec) classify(word, after string) string {
|
||||
w := word
|
||||
if s.foldCase {
|
||||
w = strings.ToLower(word)
|
||||
}
|
||||
switch {
|
||||
case s.keywords[w]:
|
||||
return "kw"
|
||||
case s.types[w]:
|
||||
return "type"
|
||||
case s.consts[w]:
|
||||
return "const"
|
||||
}
|
||||
if len(s.types) > 0 || len(s.keywords) > 0 { // JSON has no functions
|
||||
for k := 0; k < len(after); k++ {
|
||||
if after[k] == ' ' || after[k] == '\t' {
|
||||
continue
|
||||
}
|
||||
if after[k] == '(' {
|
||||
return "fn"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commentLen returns the length of a comment starting at src[i], or 0.
|
||||
func (s *langSpec) commentLen(src string, i int) int {
|
||||
rest := src[i:]
|
||||
for _, lc := range s.lineComments {
|
||||
if !strings.HasPrefix(rest, lc) {
|
||||
continue
|
||||
}
|
||||
// A "#" only starts a comment at a word boundary, so "a#b" and
|
||||
// "$#" aren't swallowed.
|
||||
if lc == "#" && i > 0 && src[i-1] != ' ' && src[i-1] != '\t' && src[i-1] != '\n' && src[i-1] != ';' {
|
||||
continue
|
||||
}
|
||||
if k := strings.IndexByte(rest, '\n'); k >= 0 {
|
||||
return k
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
if s.blockStart != "" && strings.HasPrefix(rest, s.blockStart) {
|
||||
if k := strings.Index(rest[len(s.blockStart):], s.blockEnd); k >= 0 {
|
||||
return len(s.blockStart) + k + len(s.blockEnd)
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringLen returns the length of the string literal starting at rest[0].
|
||||
// Unterminated ' and " literals end at the newline, so one stray quote
|
||||
// can't colour the rest of the block.
|
||||
func (s *langSpec) stringLen(rest string) int {
|
||||
q := rest[0]
|
||||
if s.tripleQuotes {
|
||||
if t := strings.Repeat(string(q), 3); strings.HasPrefix(rest, t) {
|
||||
if k := strings.Index(rest[3:], t); k >= 0 {
|
||||
return 3 + k + 3
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
}
|
||||
if q == '`' { // raw / multi-line
|
||||
if k := strings.IndexByte(rest[1:], '`'); k >= 0 {
|
||||
return 1 + k + 1
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
j := 1
|
||||
for j < len(rest) {
|
||||
switch rest[j] {
|
||||
case '\\':
|
||||
j += 2
|
||||
continue
|
||||
case q:
|
||||
return j + 1
|
||||
case '\n':
|
||||
return j
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j > len(rest) {
|
||||
j = len(rest)
|
||||
}
|
||||
return j
|
||||
}
|
||||
81
pkg/renderkit/highlight_test.go
Normal file
81
pkg/renderkit/highlight_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// visibleText strips the span markup and unescapes, i.e. what the user
|
||||
// sees and what the copy button copies.
|
||||
func visibleText(s string) string { return stdhtml.UnescapeString(tagRe.ReplaceAllString(s, "")) }
|
||||
|
||||
func TestHighlightPreservesText(t *testing.T) {
|
||||
samples := map[string]string{
|
||||
"go": "package main\n\n// hi\nfunc main() {\n\tx := \"a<b>\" + `raw\nline`\n\tif x != nil { return 42 }\n}\n",
|
||||
"py": "def f(x):\n \"\"\"doc\"\"\"\n return x # c\n",
|
||||
"bash": "echo \"$HOME\" ${A} | grep 'x' # note\nfoo#bar $#\n",
|
||||
"json": "{\"a\": [1, 2.5, true, null], \"b\": \"x\"}",
|
||||
"sql": "SELECT count(*) FROM t WHERE a = 'x' -- c\n",
|
||||
"rust": "fn f<'a>(x: &'a str) -> u32 { 1 }\n",
|
||||
"diff": "@@ -1 +1 @@\n-old\n+new\n ctx\n",
|
||||
}
|
||||
for lang, src := range samples {
|
||||
out := highlight(lang, stdhtml.EscapeString(src))
|
||||
if got := visibleText(out); got != src {
|
||||
t.Errorf("%s: text changed\nwant %q\ngot %q", lang, src, got)
|
||||
}
|
||||
if !strings.Contains(out, "<span class=\"tok-") {
|
||||
t.Errorf("%s: nothing was highlighted: %s", lang, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightTokens(t *testing.T) {
|
||||
out := highlight("go", stdhtml.EscapeString("func main() { return \"a<b>\" } // done"))
|
||||
for _, want := range []string{
|
||||
`<span class="tok-kw">func</span>`,
|
||||
`<span class="tok-fn">main</span>`,
|
||||
`<span class="tok-kw">return</span>`,
|
||||
`<span class="tok-str">"a<b>"</span>`,
|
||||
`<span class="tok-cmt">// done</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %s in %s", want, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(highlight("json", `"k": 1`), `tok-key`) {
|
||||
t.Error("json key not detected")
|
||||
}
|
||||
if !strings.Contains(highlight("sql", "select 1"), `<span class="tok-kw">select</span>`) {
|
||||
t.Error("sql keywords should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightNeverInjectsMarkup(t *testing.T) {
|
||||
evil := "x := \"</span><script>alert(1)</script>\" // <img src=x onerror=1>"
|
||||
out := highlight("go", stdhtml.EscapeString(evil))
|
||||
if strings.Contains(out, "<script") || strings.Contains(out, "<img") {
|
||||
t.Fatalf("markup leaked: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightUnknownLanguageUnchanged(t *testing.T) {
|
||||
in := "a < b"
|
||||
if got := highlight("brainfuck", in); got != in {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := highlight("", in); got != in {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHighlightsFencedCode(t *testing.T) {
|
||||
out := Markdown("```go\nfunc main() {}\n```")
|
||||
if !strings.Contains(out, `<span class="tok-kw">func</span>`) || !strings.Contains(out, "copy-btn") {
|
||||
t.Fatalf("not highlighted: %s", out)
|
||||
}
|
||||
}
|
||||
94
pkg/renderkit/markdown.go
Normal file
94
pkg/renderkit/markdown.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package render turns raw assistant text into safe HTML: Markdown for
|
||||
// finished messages, and simple HTML-escaping (with <br> line breaks)
|
||||
// for the plain-text tokens shown while a reply is still streaming.
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
mdhtml "github.com/gomarkdown/markdown/html"
|
||||
mdparser "github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// gomarkdown's Parser and Renderer are single-use and stateful ("Parser is
|
||||
// not reusable" — a second Parse on the same instance panics, and sharing
|
||||
// one across goroutines would race), so both are built fresh per call.
|
||||
const (
|
||||
mdParserExtensions = mdparser.CommonExtensions | mdparser.AutoHeadingIDs | mdparser.FencedCode
|
||||
mdRendererFlags = mdhtml.CommonFlags | mdhtml.HrefTargetBlank | mdhtml.SkipHTML | mdhtml.Safelink
|
||||
)
|
||||
|
||||
// fencedCodeBlock matches gomarkdown's output for a fenced code block:
|
||||
// <pre><code class="language-xxx">...escaped content...</code></pre>
|
||||
// (the language class is only present when the fence specified one,
|
||||
// e.g. ```go). (?s) lets "." match newlines, since code blocks are
|
||||
// almost always multi-line.
|
||||
var fencedCodeBlock = regexp.MustCompile(`(?s)<pre><code(?: class="language-([\w+.-]*)")?>(.*?)</code></pre>`)
|
||||
|
||||
// Markdown renders a complete Markdown document (the agent's finished
|
||||
// reply) to HTML for embedding in a chat bubble.
|
||||
//
|
||||
// The text being rendered is model output, and the model reads files
|
||||
// and command output that may contain attacker-controlled text (prompt
|
||||
// injection), so it is treated as untrusted: raw HTML in the Markdown
|
||||
// source is dropped (SkipHTML) and only safe link schemes are linked
|
||||
// (Safelink — no javascript: URLs). Code spans and fenced blocks are
|
||||
// still HTML-escaped by gomarkdown, so showing "<div>" in code works.
|
||||
//
|
||||
// Fenced code blocks get an extra post-processing pass (addCodeBlockChrome)
|
||||
// to wrap them in a header bar with a language label and copy button,
|
||||
// matching the rest of the UI's terminal-chat styling — done as plain
|
||||
// string manipulation on gomarkdown's already-escaped output rather
|
||||
// than pulling in a syntax-highlighting dependency (see the module's
|
||||
// deliberately small go.mod). Keywords, strings, comments, numbers and so
|
||||
// on are coloured by the small built-in lexer in highlight.go.
|
||||
func Markdown(src string) string {
|
||||
return addCodeBlockChrome(rawMarkdown(src))
|
||||
}
|
||||
|
||||
// rawMarkdown is gomarkdown's output before addCodeBlockChrome. Split out so
|
||||
// Snippets (snippets.go) can enumerate fenced blocks in exactly the same
|
||||
// order Markdown renders them.
|
||||
func rawMarkdown(src string) string {
|
||||
p := mdparser.NewWithExtensions(mdParserExtensions)
|
||||
r := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdRendererFlags})
|
||||
return string(markdown.ToHTML([]byte(src), p, r))
|
||||
}
|
||||
|
||||
// addCodeBlockChrome wraps every fenced code block in a `.code-block`
|
||||
// container with a header (language label + copy button) so it reads
|
||||
// like a small file viewer instead of a bare <pre>. The copy button has
|
||||
// no inline JS handler — web/static/js/app.js listens for clicks on
|
||||
// `.copy-btn` via event delegation and reads the code from a sibling
|
||||
// <pre>, so this still degrades gracefully (the button just does
|
||||
// nothing) if that script is blocked, matching the rest of the app's
|
||||
// zero-required-JS posture.
|
||||
func addCodeBlockChrome(html string) string {
|
||||
return fencedCodeBlock.ReplaceAllStringFunc(html, func(block string) string {
|
||||
m := fencedCodeBlock.FindStringSubmatch(block)
|
||||
lang := m[1]
|
||||
body := m[2]
|
||||
label := lang
|
||||
if label == "" {
|
||||
label = "text"
|
||||
}
|
||||
return `<div class="code-block">` +
|
||||
`<div class="code-block-header">` +
|
||||
`<span class="code-block-lang">` + stdhtml.EscapeString(label) + `</span>` +
|
||||
`<button type="button" class="copy-btn" aria-label="Copy code">Copy</button>` +
|
||||
`</div>` +
|
||||
`<pre><code class="language-` + stdhtml.EscapeString(label) + `">` + highlight(lang, body) + `</code></pre>` +
|
||||
`</div>`
|
||||
})
|
||||
}
|
||||
|
||||
// PlainStream escapes raw text and converts newlines to <br>, for
|
||||
// rendering the in-progress streamed response before the final Markdown
|
||||
// pass runs on the "done" event.
|
||||
func PlainStream(src string) string {
|
||||
escaped := stdhtml.EscapeString(src)
|
||||
return strings.ReplaceAll(escaped, "\n", "<br>")
|
||||
}
|
||||
49
pkg/renderkit/markdown_test.go
Normal file
49
pkg/renderkit/markdown_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownDropsRawHTMLAndUnsafeLinks(t *testing.T) {
|
||||
out := Markdown("hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))\n\n<div onclick=\"x()\">boom</div>")
|
||||
for _, bad := range []string{"<script", "<img", "onerror", "onclick", `href="javascript:`} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Fatalf("output contains %q:\n%s", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownKeepsCodeAndFormatting(t *testing.T) {
|
||||
out := Markdown("**bold**\n\n```go\nx := \"<div>\" + `a`\n```\n\n`<b>`")
|
||||
for _, want := range []string{"<strong>bold</strong>", "code-block", "copy-btn", "<div>", "<b>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainStreamEscapes(t *testing.T) {
|
||||
if got := PlainStream("<b>a</b>\nb"); got != "<b>a</b><br>b" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: gomarkdown's Parser panics if reused, which used to make every
|
||||
// reply after the first fail. Also exercised concurrently for the race detector.
|
||||
func TestMarkdownIsReusableAndConcurrencySafe(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 8; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for j := 0; j < 20; j++ {
|
||||
if out := Markdown("# title\n\ntext"); !strings.Contains(out, "<h1") {
|
||||
t.Error(out)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user