From b6812da308a7c2390c7ad79688fc3dbfbc64327f Mon Sep 17 00:00:00 2001 From: SecurID Date: Thu, 9 Jul 2026 11:00:20 +0200 Subject: [PATCH 1/3] feat(deploy): one-click Render blueprint + one-command Fly.io deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render.yaml backs a 'Deploy to Render' button: middleware + admin UI from the published GHCR images, managed Postgres 17 (pgvector), a persistent /data disk, and a generated VAULT_KEY — the /setup wizard collects the LLM key at runtime, so a deploy needs zero pasted secrets. MIDDLEWARE_URL reaches the web-ui via fromService/envVarKey RENDER_EXTERNAL_URL. Fly has no blueprint-style button, so fly/deploy.sh is the one-command equivalent: three apps (pgvector/pgvector:pg17 private-only, middleware with /data volume, web-ui), volumes, and generated secrets. Fly's own Postgres offerings don't fit (postgres-flex ships without pgvector, Managed Postgres gates the vector extension behind a dashboard toggle), hence the same pgvector image the compose stack uses. Smoke-tested end to end on a live Fly org. Co-Authored-By: Claude Fable 5 --- README.md | 22 +++++++++++ fly/deploy.sh | 85 +++++++++++++++++++++++++++++++++++++++++ fly/middleware.fly.toml | 50 ++++++++++++++++++++++++ fly/pg.fly.toml | 37 ++++++++++++++++++ fly/web-ui.fly.toml | 25 ++++++++++++ render.yaml | 80 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+) create mode 100755 fly/deploy.sh create mode 100644 fly/middleware.fly.toml create mode 100644 fly/pg.fly.toml create mode 100644 fly/web-ui.fly.toml create mode 100644 render.yaml diff --git a/README.md b/README.md index c86c8559..ceb1b76d 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,28 @@ the differentiating logic, and verifying with the smoke runner before install. ## Deployment - **Local / single-tenant**: `docker compose up`, see Quickstart above +- **One-click cloud**: deploy the minimal core into your own Render + workspace — [`render.yaml`](render.yaml) provisions the middleware, + admin UI, and Postgres (pgvector), generates `VAULT_KEY`, and the + `/setup` wizard collects your LLM key on first boot. Runs on paid + instance types (the middleware needs a persistent disk). + + [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/byte5ai/omadia) + +- **One-command Fly.io**: Fly has no blueprint-style deploy button, so the + equivalent is one command. [`fly/deploy.sh`](fly/deploy.sh) provisions + three apps in your Fly org — middleware (persistent `/data` volume), + admin UI, and a private [`pgvector/pgvector`](https://hub.docker.com/r/pgvector/pgvector) + Postgres (the same image the compose stack uses; Fly's own Postgres + offerings either lack pgvector or gate it behind a dashboard toggle) — + generates `VAULT_KEY` and the database password, and deploys the GHCR + images. Needs a logged-in `flyctl`; roughly $10/month: + + ```bash + git clone https://github.com/byte5ai/omadia.git && cd omadia + ./fly/deploy.sh + ``` + - **Bring-your-own**: the runtime is a stock Node + Postgres app; any host that can run both works (Kubernetes, ECS, plain VM). diff --git a/fly/deploy.sh b/fly/deploy.sh new file mode 100755 index 00000000..8b02eb38 --- /dev/null +++ b/fly/deploy.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# One-command Fly.io deploy of the omadia minimal core — the Fly +# counterpart to the repo-root render.yaml blueprint. +# +# ./fly/deploy.sh +# +# Provisions three Fly apps in YOUR Fly org (your account, your bill): +# - omadia-postgres- pgvector/pgvector:pg17, private-only +# - omadia-middleware- kernel API, persistent /data volume +# - omadia-web-ui- admin UI, public entrypoint +# +# Secrets are generated here (VAULT_KEY, Postgres password); the LLM key +# is collected by the /setup wizard on first boot — nothing to paste. +# +# Prerequisites: flyctl installed and logged in (fly auth login), and +# openssl on PATH. Cost: three shared-cpu machines + two 1 GB volumes, +# roughly $10/month depending on usage. +# +# Overridable via environment: +# FLY_ORG Fly org slug (default: personal) +# FLY_REGION region for volumes/machines (default: fra — +# if you change it, also change primary_region in the +# three fly/*.fly.toml files) +# OMADIA_SUFFIX app-name suffix; Fly app names (default: random) +# are globally unique, hence one +# +# This is a one-shot installer, not an upgrade tool. To upgrade later: +# fly deploy --app --config fly/middleware.fly.toml +# fly deploy --app --config fly/web-ui.fly.toml +# To tear everything down: fly apps destroy . + +set -euo pipefail + +FLY="$(command -v fly || command -v flyctl || true)" +if [ -z "$FLY" ]; then + echo "flyctl not found — install it first: https://fly.io/docs/flyctl/install/" >&2 + exit 1 +fi + +ORG="${FLY_ORG:-personal}" +REGION="${FLY_REGION:-fra}" +SUFFIX="${OMADIA_SUFFIX:-$(openssl rand -hex 3)}" +PG_APP="omadia-postgres-${SUFFIX}" +MW_APP="omadia-middleware-${SUFFIX}" +UI_APP="omadia-web-ui-${SUFFIX}" + +cd "$(dirname "$0")" + +echo "==> Creating apps in org '${ORG}' (suffix: ${SUFFIX})" +"$FLY" apps create "$PG_APP" --org "$ORG" +"$FLY" apps create "$MW_APP" --org "$ORG" +"$FLY" apps create "$UI_APP" --org "$ORG" + +echo "==> Creating volumes in ${REGION}" +"$FLY" volumes create omadia_pgdata --app "$PG_APP" --region "$REGION" --size 1 --yes +"$FLY" volumes create omadia_data --app "$MW_APP" --region "$REGION" --size 1 --yes + +echo "==> Setting secrets" +PG_PASSWORD="$(openssl rand -hex 16)" +"$FLY" secrets set --app "$PG_APP" \ + POSTGRES_PASSWORD="$PG_PASSWORD" +"$FLY" secrets set --app "$MW_APP" \ + VAULT_KEY="$(openssl rand -base64 32)" \ + DATABASE_URL="postgresql://omadia:${PG_PASSWORD}@${PG_APP}.internal:5432/omadia" +"$FLY" secrets set --app "$UI_APP" \ + MIDDLEWARE_URL="http://${MW_APP}.internal:8080" + +echo "==> Deploying Postgres (${PG_APP})" +"$FLY" deploy --app "$PG_APP" --config pg.fly.toml --ha=false + +echo "==> Deploying middleware (${MW_APP}) — first boot runs migrations, allow a minute" +# If the middleware wins the race against initdb on the very first boot, +# its machine exits and Fly's restart policy retries until Postgres is up. +"$FLY" deploy --app "$MW_APP" --config middleware.fly.toml --ha=false + +echo "==> Deploying admin UI (${UI_APP})" +"$FLY" deploy --app "$UI_APP" --config web-ui.fly.toml --ha=false + +echo +echo "Done. Open https://${UI_APP}.fly.dev and finish the /setup wizard" +echo "(first admin + LLM key, stored encrypted in the vault)." +echo +echo "The middleware is public at https://${MW_APP}.fly.dev (needed for" +echo "channel webhooks). VAULT_KEY lives only as a Fly secret on ${MW_APP};" +echo "losing it makes vault entries unrecoverable." diff --git a/fly/middleware.fly.toml b/fly/middleware.fly.toml new file mode 100644 index 00000000..d7c24105 --- /dev/null +++ b/fly/middleware.fly.toml @@ -0,0 +1,50 @@ +# Fly config — omadia middleware (kernel API + plugin runtime). +# Deployed by fly/deploy.sh (app name is passed via --app). +# +# Secrets set by deploy.sh: DATABASE_URL (points at the pg app over the +# private network), VAULT_KEY (generated; the GHCR image bakes +# NODE_ENV=production, which makes the key mandatory at boot — losing it +# makes vault entries unrecoverable). The LLM key is collected by the +# first-admin /setup wizard at runtime, so nothing else is required. +# +# DEV_ENDPOINTS_ENABLED stays unset on purpose: this app is publicly +# reachable, and the dev mounts expose raw KG + memory state without auth. + +primary_region = "fra" + +[build] + image = "ghcr.io/byte5ai/omadia-middleware:latest" + +[env] + # Persist vault, installed.json, builder drafts.db, and uploaded plugin + # packages on the volume below instead of the image layer. + PLATFORM_DATA_DIR = "/data" + +[mounts] + source = "omadia_data" + destination = "/data" + +[http_service] + internal_port = 8080 + force_https = true + # Keep the kernel always-on: cron routines and channel webhooks + # (Teams / Telegram) must not wait for a cold start. + auto_stop_machines = "off" + auto_start_machines = true + min_machines_running = 1 + + [[http_service.checks]] + interval = "10s" + timeout = "5s" + # First boot runs plugin-catalog load + KG migrations + bootstrap + # installs. Fly caps HTTP-check grace periods at 1 minute (longer + # values are lowered with a deploy warning), so this is the max — + # in practice the restart policy covers a slower first boot. + grace_period = "60s" + method = "GET" + path = "/health" + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "1gb" diff --git a/fly/pg.fly.toml b/fly/pg.fly.toml new file mode 100644 index 00000000..796b70ed --- /dev/null +++ b/fly/pg.fly.toml @@ -0,0 +1,37 @@ +# Fly config — Postgres + pgvector for the omadia minimal core. +# Deployed by fly/deploy.sh (app name is passed via --app). +# +# Same image as docker-compose.yaml, so migrations behave identically. +# Fly's own Postgres offerings don't fit here: the unmanaged postgres-flex +# image ships without pgvector, and Managed Postgres gates the vector +# extension behind a dashboard toggle (and starts at a much higher price +# point). Running the pgvector image as a plain app keeps the stack +# scriptable end to end. +# +# No [http_service] / [[services]]: the database is reachable only over +# the org's private 6PN network at .internal:5432 — never public. +# Fly takes daily snapshots of the volume; for managed backups/HA, +# migrate to Fly Managed Postgres and enable the vector extension there. + +primary_region = "fra" + +[build] + image = "pgvector/pgvector:pg17" + +[env] + # Subdirectory below the mount: the official postgres image refuses to + # initdb into a directory that already contains lost+found (which every + # fresh Fly volume does). + PGDATA = "/var/lib/postgresql/data/pgdata" + POSTGRES_USER = "omadia" + POSTGRES_DB = "omadia" + # POSTGRES_PASSWORD comes in as a Fly secret (set by deploy.sh). + +[mounts] + source = "omadia_pgdata" + destination = "/var/lib/postgresql/data" + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "1gb" diff --git a/fly/web-ui.fly.toml b/fly/web-ui.fly.toml new file mode 100644 index 00000000..88fc12b4 --- /dev/null +++ b/fly/web-ui.fly.toml @@ -0,0 +1,25 @@ +# Fly config — omadia admin UI (Next.js). +# Deployed by fly/deploy.sh (app name is passed via --app). +# +# MIDDLEWARE_URL comes in as a Fly secret (set by deploy.sh) and points at +# the middleware app over the org's private 6PN network — the same +# topology as the compose stack's http://middleware:8080. + +primary_region = "fra" + +[build] + image = "ghcr.io/byte5ai/omadia-web-ui:latest" + +[http_service] + internal_port = 3000 + force_https = true + # The UI may scale to zero between visits; the proxy cold-starts it. + # The middleware underneath stays always-on. + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "512mb" diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..fe96b856 --- /dev/null +++ b/render.yaml @@ -0,0 +1,80 @@ +# Render Blueprint — one-click deploy of the omadia minimal core +# (middleware + admin UI + Postgres/pgvector) into the deployer's OWN +# Render workspace. This file backs the "Deploy to Render" button: +# +# https://render.com/deploy?repo=https://github.com/byte5ai/omadia +# +# Mirrors docker-compose.yaml's minimal core; the optional overlays +# (MinIO, Kroki, Ollama) are not part of the blueprint. +# +# No secrets to paste: VAULT_KEY is generated by Render on first sync +# (the GHCR image bakes NODE_ENV=production, which makes the key +# mandatory at boot — see README "Deployment"), and the LLM key is +# collected by the first-admin /setup wizard at runtime and stored +# encrypted in the vault. DEV_ENDPOINTS_ENABLED stays unset: these +# services are publicly reachable, and the dev mounts expose raw KG + +# memory state without auth. +# +# Costs: the middleware needs a persistent disk, which requires paid +# instance types — `starter` for both services plus `basic-256mb` +# Postgres. Everything is pinned to one region (frankfurt) so the +# database connection string resolves over the private network. + +services: + # --- Omadia middleware (TypeScript kernel + plugin runtime) --------------- + - type: web + name: omadia-middleware + runtime: image + image: + url: ghcr.io/byte5ai/omadia-middleware:latest + plan: starter + region: frankfurt + healthCheckPath: /health + envVars: + - key: DATABASE_URL + fromDatabase: + name: omadia-postgres + property: connectionString + # Vault master key. Generated once by Render; losing it makes vault + # entries unrecoverable, so never rotate it casually. Rotating LLM / + # plugin credentials happens inside the app (Admin → Runtime → + # Secrets), not by regenerating this key. + - key: VAULT_KEY + generateValue: true + # Persist vault, installed.json, builder drafts.db, and uploaded + # plugin packages on the disk below instead of the image layer. + - key: PLATFORM_DATA_DIR + value: /data + disk: + name: omadia-data + mountPath: /data + sizeGB: 1 + + # --- Admin UI (Next.js, talks to middleware over /bot-api) ---------------- + - type: web + name: omadia-web-ui + runtime: image + image: + url: ghcr.io/byte5ai/omadia-web-ui:latest + plan: starter + region: frankfurt + envVars: + # Server-side base URL the admin UI uses to reach the middleware. + # RENDER_EXTERNAL_URL is the middleware's public https URL, injected + # by Render — a full URL with scheme, which MIDDLEWARE_URL requires. + - key: MIDDLEWARE_URL + fromService: + name: omadia-middleware + type: web + envVarKey: RENDER_EXTERNAL_URL + +databases: + # --- Postgres + pgvector (knowledge graph, routines, verifier store) ----- + # Migrations run automatically on the middleware's first boot, including + # CREATE EXTENSION IF NOT EXISTS vector (supported on Render Postgres). + - name: omadia-postgres + plan: basic-256mb + region: frankfurt + postgresMajorVersion: "17" + databaseName: omadia + user: omadia From 9c5eba9e0144bdf6d5d7facae9f4c319f5a78faa Mon Sep 17 00:00:00 2001 From: SecurID Date: Thu, 9 Jul 2026 11:00:37 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(web-ui):=20resolve=20MIDDLEWARE=5FURL?= =?UTF-8?q?=20at=20request=20time=20=E2=80=94=20route=20handlers=20replace?= =?UTF-8?q?=20baked=20rewrites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next evaluates rewrites() at build time and freezes the destinations into routes-manifest.json. The published web-ui image was built with ARG MIDDLEWARE_URL=http://middleware:8080, so every browser call through /bot-api proxied to a hostname that only exists on the compose network — on Fly/Render/any other host the proxy died with ENOTFOUND and every operator page showed 'GET … failed: 500'. Runtime MIDDLEWARE_URL was silently ignored (only server-side fetches honored it, which is why page shells still rendered). /bot-api/* and /p/* are now catch-all route handlers backed by app/_lib/middlewareProxy.ts, resolving MIDDLEWARE_URL per request: streaming both directions (SSE, plugin-ZIP uploads), multiple Set-Cookie preserved, redirects passed through, hop-by-hop headers stripped. One image now runs anywhere; the compose default still works because compose sets the same value as runtime env. Consequences swept: Dockerfile build ARG removed (with a warning against reintroducing it), CI workflows no longer pass the inert build-arg, the compose build overlay drops it, and desktop-apps.yml stops pretending to bake the kernel URL — the desktop supervisor already injects it at spawn (this also fixes the latent wrong-port proxy when the kernel doesn't get its preferred port). Verified: 6 regression tests (core assertion: same handler, changed env, changed target), plus a live Fly deployment where /bot-api/v1/auth/providers went 500 → 200 and login started working. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 8 +- .github/workflows/desktop-apps.yml | 8 +- .github/workflows/publish-images.yml | 6 +- docker-compose.build.yaml | 8 +- web-ui/Dockerfile | 11 +- .../_lib/__tests__/middlewareProxy.test.ts | 156 ++++++++++++++++++ web-ui/app/_lib/middlewareProxy.ts | 92 +++++++++++ web-ui/app/bot-api/[[...path]]/route.ts | 18 ++ web-ui/app/p/[[...path]]/route.ts | 20 +++ web-ui/next.config.ts | 33 +--- 10 files changed, 315 insertions(+), 45 deletions(-) create mode 100644 web-ui/app/_lib/__tests__/middlewareProxy.test.ts create mode 100644 web-ui/app/_lib/middlewareProxy.ts create mode 100644 web-ui/app/bot-api/[[...path]]/route.ts create mode 100644 web-ui/app/p/[[...path]]/route.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5945b448..7570da5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,11 +83,9 @@ jobs: push: false load: true tags: omadia-web-ui:ci-${{ github.sha }} - # Same MIDDLEWARE_URL placeholder the production fly.toml uses; - # only baked into the standalone bundle, not used at runtime - # against this image. - build-args: | - MIDDLEWARE_URL=http://middleware:8080 + # No MIDDLEWARE_URL build-arg: it is a runtime setting since the + # /bot-api and /p proxies became route handlers + # (app/_lib/middlewareProxy.ts). cache-from: type=gha,scope=web-ui # See the middleware job: a cache-export error is non-fatal. cache-to: type=gha,scope=web-ui,mode=max,ignore-error=true diff --git a/.github/workflows/desktop-apps.yml b/.github/workflows/desktop-apps.yml index 638412bd..17b1f466 100644 --- a/.github/workflows/desktop-apps.yml +++ b/.github/workflows/desktop-apps.yml @@ -119,10 +119,12 @@ jobs: npm ci npm run build - - name: Build web-ui (bake the fixed kernel URL into the standalone build) + # No MIDDLEWARE_URL at build time: the /bot-api and /p proxies are route + # handlers that read it per request (web-ui/app/_lib/middlewareProxy.ts). + # The desktop supervisor injects the real kernel URL when it spawns the + # standalone server (desktop/src/supervisor.ts, uiEnv). + - name: Build web-ui working-directory: web-ui - env: - MIDDLEWARE_URL: ${{ env.KERNEL_URL }} run: | npm ci npm run build diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 7d079ec7..d16bcc83 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -66,8 +66,10 @@ jobs: image: ghcr.io/byte5ai/omadia-web-ui context: ./web-ui dockerfile: ./web-ui/Dockerfile - build_args: | - MIDDLEWARE_URL=http://middleware:8080 + # MIDDLEWARE_URL is a runtime setting since the /bot-api and /p + # proxies became route handlers (app/_lib/middlewareProxy.ts) — + # nothing middleware-related is baked at build time anymore. + build_args: '' steps: - uses: actions/checkout@v7 with: diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml index c551a6fc..38eda467 100644 --- a/docker-compose.build.yaml +++ b/docker-compose.build.yaml @@ -17,8 +17,6 @@ services: build: context: ./web-ui dockerfile: Dockerfile - args: - # Build-arg: the rewrite target baked into the Next.js build. Use the - # in-network service name so the browser-loaded UI reaches the - # middleware through Docker's DNS, not the host's localhost. - MIDDLEWARE_URL: http://middleware:8080 + # No MIDDLEWARE_URL build-arg: the /bot-api and /p proxies read it at + # request time (web-ui/app/_lib/middlewareProxy.ts), and the runtime + # value comes from the web-ui service env in docker-compose.yaml. diff --git a/web-ui/Dockerfile b/web-ui/Dockerfile index e50b62bc..dce95b19 100644 --- a/web-ui/Dockerfile +++ b/web-ui/Dockerfile @@ -17,12 +17,11 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # `output: 'standalone'` (next.config.ts) emits .next/standalone + .next/static ENV NEXT_TELEMETRY_DISABLED=1 -# Next.js bakes the /bot-api/* rewrite destination into routes-manifest.json -# at build time, so MIDDLEWARE_URL has to be known here — not at runtime. -# Override from fly.toml [build.args] or `fly deploy --build-arg` when moving -# the middleware onto a different internal hostname. -ARG MIDDLEWARE_URL=http://middleware:8080 -ENV MIDDLEWARE_URL=${MIDDLEWARE_URL} +# MIDDLEWARE_URL is a pure RUNTIME setting: /bot-api/* and /p/* are proxied +# by route handlers that read it per request (app/_lib/middlewareProxy.ts), +# so the same image runs against any middleware host. Do not reintroduce a +# build ARG for it — that was the bug that pinned the published image to +# the compose hostname. RUN npm run build # --- runtime ---------------------------------------------------------------- diff --git a/web-ui/app/_lib/__tests__/middlewareProxy.test.ts b/web-ui/app/_lib/__tests__/middlewareProxy.test.ts new file mode 100644 index 00000000..f6a16ea1 --- /dev/null +++ b/web-ui/app/_lib/__tests__/middlewareProxy.test.ts @@ -0,0 +1,156 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { NextRequest } from 'next/server'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { createMiddlewareProxy } from '../middlewareProxy'; + +/** + * Regression tests for the /bot-api and /p runtime proxies. + * + * The bug pattern being locked down: the proxy target froze at build time + * (rewrites in next.config.ts), so the published image ignored the runtime + * MIDDLEWARE_URL and every browser call 500'd on any host that wasn't the + * compose network. The core assertion here is that the handler resolves + * MIDDLEWARE_URL **per request** — see "re-reads MIDDLEWARE_URL on every + * request" below. + */ + +type Captured = { + method: string | undefined; + url: string | undefined; + headers: IncomingMessage['headers']; + body: string; +}; + +let server: Server; +let baseUrl: string; +let captured: Captured | undefined; +let respond: (res: ServerResponse) => void; +const savedEnv = process.env.MIDDLEWARE_URL; + +function ctx(path: string[]) { + return { params: Promise.resolve({ path }) }; +} + +beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf8'), + }; + respond(res); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + process.env.MIDDLEWARE_URL = savedEnv; +}); + +afterEach(() => { + captured = undefined; + respond = (res) => { + res.statusCode = 200; + res.end('{}'); + }; +}); + +describe('createMiddlewareProxy', () => { + it('forwards method, mapped path, query string, and headers to ${MIDDLEWARE_URL}/api', async () => { + process.env.MIDDLEWARE_URL = baseUrl; + respond = (res) => { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end('{"providers":[]}'); + }; + + const proxy = createMiddlewareProxy('/api'); + const req = new NextRequest('http://web-ui.local/bot-api/v1/auth/providers?verbose=1', { + headers: { cookie: 'session=abc', 'x-custom': 'kept' }, + }); + const res = await proxy(req, ctx(['v1', 'auth', 'providers'])); + + expect(captured?.method).toBe('GET'); + expect(captured?.url).toBe('/api/v1/auth/providers?verbose=1'); + expect(captured?.headers.cookie).toBe('session=abc'); + expect(captured?.headers['x-custom']).toBe('kept'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ providers: [] }); + }); + + it('re-reads MIDDLEWARE_URL on every request (the frozen-at-build regression)', async () => { + const proxy = createMiddlewareProxy('/api'); + + process.env.MIDDLEWARE_URL = baseUrl; + await proxy(new NextRequest('http://web-ui.local/bot-api/v1/ping'), ctx(['v1', 'ping'])); + expect(captured?.url).toBe('/api/v1/ping'); + + // Same handler instance, new env — the target must follow. With the old + // build-time rewrite this was impossible: the destination was frozen. + process.env.MIDDLEWARE_URL = `${baseUrl}/tenant-b`; + await proxy(new NextRequest('http://web-ui.local/bot-api/v1/ping'), ctx(['v1', 'ping'])); + expect(captured?.url).toBe('/tenant-b/api/v1/ping'); + }); + + it('forwards POST bodies', async () => { + process.env.MIDDLEWARE_URL = baseUrl; + const proxy = createMiddlewareProxy('/api'); + const req = new NextRequest('http://web-ui.local/bot-api/v1/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ user: 'admin' }), + }); + await proxy(req, ctx(['v1', 'auth', 'login'])); + + expect(captured?.method).toBe('POST'); + expect(captured?.body).toBe('{"user":"admin"}'); + expect(captured?.headers['content-type']).toBe('application/json'); + }); + + it('preserves multiple Set-Cookie headers (login sets the JWT this way)', async () => { + process.env.MIDDLEWARE_URL = baseUrl; + respond = (res) => { + res.statusCode = 200; + res.setHeader('Set-Cookie', ['auth=jwt; HttpOnly', 'flags=1; Path=/']); + res.end('{}'); + }; + const proxy = createMiddlewareProxy('/api'); + const res = await proxy( + new NextRequest('http://web-ui.local/bot-api/v1/auth/login', { method: 'POST', body: '{}' }), + ctx(['v1', 'auth', 'login']), + ); + + expect(res.headers.getSetCookie()).toEqual(['auth=jwt; HttpOnly', 'flags=1; Path=/']); + }); + + it('maps /p/* without the /api prefix', async () => { + process.env.MIDDLEWARE_URL = baseUrl; + const proxy = createMiddlewareProxy('/p'); + await proxy( + new NextRequest('http://web-ui.local/p/my-plugin/tab'), + ctx(['my-plugin', 'tab']), + ); + expect(captured?.url).toBe('/p/my-plugin/tab'); + }); + + it('passes upstream error statuses through untouched', async () => { + process.env.MIDDLEWARE_URL = baseUrl; + respond = (res) => { + res.statusCode = 503; + res.end('busy'); + }; + const proxy = createMiddlewareProxy('/api'); + const res = await proxy(new NextRequest('http://web-ui.local/bot-api/v1/chat'), ctx(['v1', 'chat'])); + expect(res.status).toBe(503); + expect(await res.text()).toBe('busy'); + }); +}); diff --git a/web-ui/app/_lib/middlewareProxy.ts b/web-ui/app/_lib/middlewareProxy.ts new file mode 100644 index 00000000..0c9e5dec --- /dev/null +++ b/web-ui/app/_lib/middlewareProxy.ts @@ -0,0 +1,92 @@ +import type { NextRequest } from 'next/server'; + +/** + * Runtime reverse proxy to the middleware — the server half of the + * same-origin API surface (browser → /bot-api/* → middleware /api/*). + * + * /bot-api/* and /p/* used to be next.config.ts rewrites. Next evaluates + * rewrites() at BUILD time and freezes the destinations into + * routes-manifest.json, so the published Docker image had the compose + * hostname (http://middleware:8080) baked in and silently ignored the + * runtime MIDDLEWARE_URL on every other platform (Fly, Render, bare VMs): + * every browser call through the proxy failed with ENOTFOUND → 500. + * These handlers resolve MIDDLEWARE_URL per request instead, so one image + * runs anywhere. + * + * Streaming passes through untouched in both directions (SSE spec-event + * streams, multi-megabyte plugin-ZIP uploads). WebSockets are NOT proxied + * here — route handlers cannot upgrade a connection; nothing in the web-ui + * connects via WS on its own origin (the canvas WS goes to the middleware + * directly). + */ + +/** Hop-by-hop headers never travel through a proxy (RFC 9110 §7.6.1). */ +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +type RouteContext = { params: Promise<{ path?: string[] }> }; + +export function createMiddlewareProxy( + targetPrefix: '/api' | '/p', +): (req: NextRequest, ctx: RouteContext) => Promise { + return async function proxy(req, ctx) { + // Resolved per request, never at module scope — module scope would + // re-freeze the value at boot and reintroduce a flavour of the bug. + const base = process.env['MIDDLEWARE_URL'] ?? 'http://localhost:3979'; + const { path = [] } = await ctx.params; + const target = + base + + targetPrefix + + path.map((segment) => `/${encodeURIComponent(segment)}`).join('') + + req.nextUrl.search; + + const headers = new Headers(); + req.headers.forEach((value, key) => { + // `host` must be the upstream's own; fetch derives it from the URL. + if (HOP_BY_HOP.has(key) || key === 'host') return; + headers.set(key, value); + }); + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const upstream = await fetch(target, { + method: req.method, + headers, + body: hasBody ? req.body : undefined, + // Redirects (e.g. the OAuth broker) pass through to the browser + // unchanged, mirroring the old rewrite behaviour. + redirect: 'manual', + cache: 'no-store', + signal: req.signal, + // Node requires the half-duplex opt-in to stream request bodies. + ...(hasBody ? { duplex: 'half' } : {}), + } as RequestInit); + + const resHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (HOP_BY_HOP.has(key)) return; + // fetch already decoded the body; the upstream's encoding headers + // would corrupt the re-served response. + if (key === 'content-encoding' || key === 'content-length') return; + // Multi-valued; folded by forEach. Re-added individually below. + if (key === 'set-cookie') return; + resHeaders.set(key, value); + }); + for (const cookie of upstream.headers.getSetCookie()) { + resHeaders.append('set-cookie', cookie); + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: resHeaders, + }); + }; +} diff --git a/web-ui/app/bot-api/[[...path]]/route.ts b/web-ui/app/bot-api/[[...path]]/route.ts new file mode 100644 index 00000000..93aeb2da --- /dev/null +++ b/web-ui/app/bot-api/[[...path]]/route.ts @@ -0,0 +1,18 @@ +import { createMiddlewareProxy } from '@/app/_lib/middlewareProxy'; + +// Same-origin browser surface for the middleware Admin API: /bot-api/* is +// forwarded to `${MIDDLEWARE_URL}/api/*` at request time. See +// middlewareProxy.ts for why this is a route handler and not a +// next.config.ts rewrite. + +const proxy = createMiddlewareProxy('/api'); + +export { + proxy as GET, + proxy as POST, + proxy as PUT, + proxy as PATCH, + proxy as DELETE, + proxy as HEAD, + proxy as OPTIONS, +}; diff --git a/web-ui/app/p/[[...path]]/route.ts b/web-ui/app/p/[[...path]]/route.ts new file mode 100644 index 00000000..b0bad4f3 --- /dev/null +++ b/web-ui/app/p/[[...path]]/route.ts @@ -0,0 +1,20 @@ +import { createMiddlewareProxy } from '@/app/_lib/middlewareProxy'; + +// Plugin-served UI surfaces: plugins register Express routers under +// /p//... via ctx.routes.register; iframes embedded in Teams +// Tabs hit this proxy so the browser only ever sees the web-ui origin. +// Forwarded to `${MIDDLEWARE_URL}/p/*` at request time — see +// middlewareProxy.ts for why this is a route handler and not a +// next.config.ts rewrite. + +const proxy = createMiddlewareProxy('/p'); + +export { + proxy as GET, + proxy as POST, + proxy as PUT, + proxy as PATCH, + proxy as DELETE, + proxy as HEAD, + proxy as OPTIONS, +}; diff --git a/web-ui/next.config.ts b/web-ui/next.config.ts index 6771594c..2f0fac7a 100644 --- a/web-ui/next.config.ts +++ b/web-ui/next.config.ts @@ -2,8 +2,6 @@ import path from 'node:path'; import type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; -const middlewareUrl = process.env.MIDDLEWARE_URL ?? 'http://localhost:3979'; - // next-intl plugin: wires `i18n/request.ts` into the Next compile so that // `getRequestConfig` is invoked on every RSC request and the resolved // messages reach ``. @@ -29,38 +27,25 @@ const nextConfig: NextConfig = { '/**': ['./node_modules/@swc/helpers/**'], }, - // Rewrite /bot-api/* on this Next server to the middleware's /api/* so the - // browser only ever sees same-origin requests. No CORS dance, no separate - // API-route proxy. The prefix is /bot-api intentionally — /api/* is reserved - // for Next's own route handlers, which we don't use here but shouldn't shadow. - // - // MIDDLEWARE_URL is environment-dependent: - // - dev: http://localhost:3979 (middleware on same machine) - // - on Fly: http://odoo-bot-middleware.internal:8080 - // (flycast — private network, no public edge hop) + // NB: /bot-api/* and /p/* are deliberately NOT rewrites. Next evaluates + // rewrites() at build time and freezes the destination into + // routes-manifest.json, which baked the compose hostname into the + // published Docker image and broke MIDDLEWARE_URL as a runtime setting + // on every other platform. They are route handlers now + // (app/bot-api/[[...path]]/route.ts, app/p/[[...path]]/route.ts) that + // resolve MIDDLEWARE_URL per request — see app/_lib/middlewareProxy.ts. async rewrites() { return [ - { - source: '/bot-api/:path*', - destination: `${middlewareUrl}/api/:path*`, - }, // Friction-free pairing discovery (#293). `.well-known` segments are // ignored by the App Router file system, so the canonical public path is // served by the `/pairing-discovery` route handler via this rewrite. The // desktop app GETs the operator URL it already knows and gets back a - // connect-ready descriptor (absolute wsUrl + auth). + // connect-ready descriptor (absolute wsUrl + auth). Static destination, + // so the build-time freeze is harmless here. { source: '/.well-known/omadia-ui', destination: '/pairing-discovery', }, - // Plugin-served UI surfaces. Plugins register Express routers under - // /p//... via ctx.routes.register; iframes embedded in - // Teams Tabs hit this rewrite so the browser only ever sees the - // web-ui origin. - { - source: '/p/:path*', - destination: `${middlewareUrl}/p/:path*`, - }, ]; }, }; From b3f4591e095a2c3d7efd0295564e878111d984e8 Mon Sep 17 00:00:00 2001 From: SecurID Date: Thu, 9 Jul 2026 11:00:52 +0200 Subject: [PATCH 3/3] feat(web-ui): first-run readiness banner; fix 17 broken t.rich placeholders; unbreak next dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three first-run findings from smoke-testing the one-click deploy: 1. Readiness banner. A fresh install has no LLM key, so the orchestrator never publishes chatAgent@1/orchestratorRegistry@1 and every operator surface answers 503 multi_orchestrator_unavailable — the UI showed raw 'GET … failed: 503' strings with no hint at the cause. RuntimeReadinessBanner (mounted next to SessionWatcher) probes one operator route, and on the structured 503 shows a card naming the cause with a link to /admin/settings. Re-probes on focus + heartbeat while visible, so it clears itself the moment the key is saved. 2. t.rich placeholder syntax (regression from #447). 17 placeholders across 12 message keys used ICU argument syntax ({toolName}) while the component passes chunk functions — next-intl splices the raw function into the render output and React throws ('Functions cannot be passed to Client Components'), hard-crashing the routines page, login no-providers state, onboarding modal, builder preview, and four admin pages whenever those paths render. The locale-parity check couldn't catch it because both locales were consistently wrong. All converted to tag syntax () in en+de; audited to zero via a t.rich/message cross-check sweep. 3. next dev was 500ing on every page: Tailwind v4 scans comments as class candidates, and two test-file comments containing a literal bracket-class with var(...) compiled into invalid CSS (dev-only; prod builds tolerate it). Reworded the comments. Co-Authored-By: Claude Fable 5 --- .../_components/RuntimeReadinessBanner.tsx | 141 ++++++++++++++++++ .../__tests__/LocaleSwitcher.test.tsx | 6 +- .../__tests__/RuntimeReadinessBanner.test.tsx | 108 ++++++++++++++ .../__tests__/ThemeControls.test.tsx | 4 +- web-ui/app/layout.tsx | 2 + web-ui/messages/de.json | 30 ++-- web-ui/messages/en.json | 30 ++-- 7 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 web-ui/app/_components/RuntimeReadinessBanner.tsx create mode 100644 web-ui/app/_components/__tests__/RuntimeReadinessBanner.test.tsx diff --git a/web-ui/app/_components/RuntimeReadinessBanner.tsx b/web-ui/app/_components/RuntimeReadinessBanner.tsx new file mode 100644 index 00000000..11d0758d --- /dev/null +++ b/web-ui/app/_components/RuntimeReadinessBanner.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { motion } from 'framer-motion'; +import { KeyRound } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +/** + * RuntimeReadinessBanner — turns the fresh-install "everything 503s" state + * into a visible, actionable hint. + * + * On a fresh install the orchestrator plugin has no LLM API key, so it never + * publishes chatAgent@1 / orchestratorRegistry@1: every operator surface + * (agents, channels, skills, chat) answers 503 + * `multi_orchestrator_unavailable`, and routines aren't mounted at all. The + * individual pages then surface raw "GET … failed: 503" strings with no hint + * at the cause. This card names the cause and links to the fix. + * + * Detection is a probe of one representative operator route, looking for the + * structured 503. It re-probes on tab focus, and on a heartbeat while the + * card is visible, so it clears itself the moment the key is saved (the + * operator routes go live without a restart). + * + * Mounted once in the root layout, next to SessionWatcher. Renders nothing + * on /login + /setup. + */ + +/** Heartbeat cadence while the card is visible — catches the key being saved. */ +const HEARTBEAT_MS = 60 * 1000; + +function isAuthPage(pathname: string): boolean { + return pathname === '/login' || pathname === '/setup'; +} + +export function RuntimeReadinessBanner(): React.ReactElement | null { + const pathname = usePathname(); + const onAuthPage = isAuthPage(pathname); + + const [unavailable, setUnavailable] = useState(false); + const [dismissed, setDismissed] = useState(false); + + // ── Initial probe + focus re-check + heartbeat-while-visible ──────────── + // One effect à la SessionWatcher: the probe lives inside so every + // setState happens after an await (no sync-setState-in-effect). The + // heartbeat is only armed while the card shows — its job is to clear the + // card once the key lands and the operator routes come up. + useEffect(() => { + if (onAuthPage) return; + let cancelled = false; + + const probe = async (): Promise => { + try { + const res = await fetch('/bot-api/v1/operator/agents', { + credentials: 'include', + }); + if (cancelled) return; + if (res.status !== 503) { + // 200 = runtime is up; 401/403 = not this card's concern. + setUnavailable(false); + return; + } + const body = (await res.json().catch(() => null)) as { + error?: string; + } | null; + if (cancelled) return; + setUnavailable(body?.error === 'multi_orchestrator_unavailable'); + } catch { + // Network blip — leave state intact; the next probe retries. + } + }; + + void probe(); + const heartbeat = + unavailable && !dismissed + ? window.setInterval(() => void probe(), HEARTBEAT_MS) + : undefined; + const onVisibility = (): void => { + if (document.visibilityState === 'visible') void probe(); + }; + document.addEventListener('visibilitychange', onVisibility); + + return () => { + cancelled = true; + if (heartbeat !== undefined) window.clearInterval(heartbeat); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [onAuthPage, unavailable, dismissed]); + + if (onAuthPage || !unavailable || dismissed) return null; + + // No AnimatePresence exit animation on purpose: the card leaves when the + // runtime comes up — an instant disappearance is fine, and it keeps the + // clear-on-heartbeat path deterministic under fake timers in tests. + return setDismissed(true)} />; +} + +/** Non-blocking bottom-right card, styled after SessionWarningCard. */ +function ReadinessCard({ + onDismiss, +}: { + onDismiss: () => void; +}): React.ReactElement { + const t = useTranslations('runtimeReadiness'); + + return ( + +
+ + {t('title')} +
+

+ {t('body')} +

+
+ + {t('cta')} + + +
+
+ ); +} diff --git a/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx b/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx index 76ef92c7..2ab7ee4e 100644 --- a/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx +++ b/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx @@ -11,8 +11,10 @@ vi.mock('next/navigation', () => ({ /** * Regression guard for issue #360 — same rationale as ThemeControls.test.tsx: * the Windows native combobox widget silently ignores per-`
diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 1befd352..8de133d0 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -597,7 +597,7 @@ "shellLoading": "Lade Login…", "loadingProviders": "Lade Login…", "errorPrefix": "Login-Provider konnten nicht geladen werden:", - "noProviders": "Es sind keine Auth-Provider konfiguriert. Setze {envVar} im Middleware-Environment und starte neu.", + "noProviders": "Es sind keine Auth-Provider konfiguriert. Setze im Middleware-Environment und starte neu.", "emailLabel": "E-Mail", "passwordLabel": "Passwort", "submit": "Anmelden", @@ -644,13 +644,13 @@ "apply": "Anwenden", "applying": "Installiere…", "pluginCount": "{count, plural, one {# Plugin} other {# Plugins}}", - "outcomeAppliedTitle": "Profil {id} angewendet", + "outcomeAppliedTitle": "Profil angewendet", "outcomeStats": "{installed} installiert, {skipped} übersprungen, {errored} fehlgeschlagen", "sectionInstalled": "Neu installiert", "sectionSkipped": "Bereits installiert (übersprungen)", "sectionErrored": "Fehler — bitte manuell prüfen", - "secretsHint": "Plugins, die operator-Secrets benötigen (z.B. anthropic_api_key, telegram_bot_token), starten im Status {erroredTag} bis du die Secrets über den Wizard auf der jeweiligen Detail-Seite setzt. Aktiviere danach via {reactivateTag}.", - "errorTitle": "Profil {id} konnte nicht angewendet werden", + "secretsHint": "Plugins, die operator-Secrets benötigen (z.B. anthropic_api_key, telegram_bot_token), starten im Status bis du die Secrets über den Wizard auf der jeweiligen Detail-Seite setzt. Aktiviere danach via .", + "errorTitle": "Profil konnte nicht angewendet werden", "tryAnother": "Anderes Profil wählen" }, "chatTabs": { @@ -685,6 +685,12 @@ "expiredBody": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an, um weiterzuarbeiten.", "expiredCta": "Neu anmelden" }, + "runtimeReadiness": { + "title": "LLM-API-Key fehlt", + "body": "Die Agent-Runtime ist offline — meistens, weil noch kein LLM-API-Key hinterlegt ist. Hinterlege deinen Key in den Einstellungen; Chat, Agenten, Channels und Skills sind danach sofort verfügbar. Routinen brauchen zusätzlich einen Neustart der Middleware.", + "cta": "Einstellungen öffnen", + "dismiss": "Später" + }, "agentDetailsModal": { "ariaLabelTitle": "{label} Tool-Aufrufe", "ariaCloseBackdrop": "Schließen", @@ -1431,7 +1437,7 @@ }, "externalReadsHint": "Preview führt Cross-Integration-Lookups (spec.external_reads) nicht aus — der Agent erkennt den fehlenden Service korrekt und wirft. Stelle den Agent in deiner Instanz bereit, um den echten Datenpfad zu testen.", "agentStuck": { - "title": "Builder-Agent kommt bei Slot {slot} nicht weiter ({attempts, plural, one {# Versuch} other {# Versuche}})", + "title": "Builder-Agent kommt bei Slot nicht weiter ({attempts, plural, one {# Versuch} other {# Versuche}})", "body": "{summary}. Öffne den Slot-Editor, prüf die Errors manuell und korrigier den Code — der Agent versucht es nicht von alleine erneut.", "dismiss": "Schließen", "dismissTooltip": "Banner schließen" @@ -2158,7 +2164,7 @@ } }, "title": "Konfiguration", - "intro": "Alle {envFile}-Werte, die beim Start in den Config-Store bzw. den Secret-Vault geschrieben werden. Änderungen werden automatisch gespeichert und das betroffene Plugin neu aktiviert — sie wirken sofort, ohne Neustart. Secrets zeigen nur, ob ein Wert hinterlegt ist; der Wert selbst wird nie angezeigt.", + "intro": "Alle -Werte, die beim Start in den Config-Store bzw. den Secret-Vault geschrieben werden. Änderungen werden automatisch gespeichert und das betroffene Plugin neu aktiviert — sie wirken sofort, ohne Neustart. Secrets zeigen nur, ob ein Wert hinterlegt ist; der Wert selbst wird nie angezeigt.", "loading": "Lädt …", "loadError": "Fehler beim Laden: {message}", "vaultUnavailable": "Vault nicht verfügbar — Secrets können nicht bearbeitet werden.", @@ -2571,7 +2577,7 @@ }, "adminAuth": { "title": "Authentifizierungs-Provider", - "intro": "Aktiviere oder deaktiviere die Anmelde-Verfahren. Die env-Variable {envVar} definiert die zulässigen Provider; hier wählst du, welche davon aktuell aktiv sind.", + "intro": "Aktiviere oder deaktiviere die Anmelde-Verfahren. Die env-Variable definiert die zulässigen Provider; hier wählst du, welche davon aktuell aktiv sind.", "loading": "Lädt …", "loadError": "Fehler beim Laden: {message}", "statusActive": "aktiv", @@ -2588,7 +2594,7 @@ }, "adminRegistries": { "title": "Plugin-Registries", - "intro": "Quellen, aus denen Plugins in den Store gezogen werden. Neue Instanzen starten mit {hub}. Der Token wird verschlüsselt gespeichert und nie wieder angezeigt — nur, ob einer hinterlegt ist. Änderungen wirken ohne Neustart.", + "intro": "Quellen, aus denen Plugins in den Store gezogen werden. Neue Instanzen starten mit . Der Token wird verschlüsselt gespeichert und nie wieder angezeigt — nur, ob einer hinterlegt ist. Änderungen wirken ohne Neustart.", "addHeading": "Registry hinzufügen", "fields": { "name": "Name", @@ -2626,7 +2632,7 @@ }, "adminDomains": { "title": "Plugin-Domains", - "intro": "Übersicht aller registrierten Plugins, gruppiert nach ihrer in der {manifest} deklarierten {domain}. Domains werden vom Phase-8 Nudge-Pipeline-Multi-Domain-Trigger gelesen und vom Operator-UI für Cross-Agent-Gruppierung verwendet. Curation (Umbenennen / Mergen / Hierarchien) folgt mit OB-78 (Phase 9 Agent-Profile).", + "intro": "Übersicht aller registrierten Plugins, gruppiert nach ihrer in der deklarierten . Domains werden vom Phase-8 Nudge-Pipeline-Multi-Domain-Trigger gelesen und vom Operator-UI für Cross-Agent-Gruppierung verwendet. Curation (Umbenennen / Mergen / Hierarchien) folgt mit OB-78 (Phase 9 Agent-Profile).", "loading": "Lade …", "loadError": "Fehler beim Laden: {message}", "noData": "Keine Daten.", @@ -2637,7 +2643,7 @@ "fallbacks": "Fallbacks" }, "pluginCount": "{count, plural, one {# Plugin} other {# Plugins}}", - "fallbackWarning": "{count, plural, one {# Plugin} other {# Plugins}} ohne deklarierte Domain — auto-fallback auf {fallbackDomain}. Füge dem Manifest eine {manifestKey}-Zeile hinzu (z.B. {exampleOne}, {exampleTwo}) oder lade den Agent über den Builder neu hoch." + "fallbackWarning": "{count, plural, one {# Plugin} other {# Plugins}} ohne deklarierte Domain — auto-fallback auf . Füge dem Manifest eine -Zeile hinzu (z.B. , ) oder lade den Agent über den Builder neu hoch." }, "adminUsage": { "title": "Kosten", @@ -3182,7 +3188,7 @@ "columnLastRun": "Letzter Lauf", "columnActions": "Aktionen", "emptyTitle": "Noch keine Routinen.", - "emptyBody": "Routinen entstehen, wenn ein User im Chat etwas wie „erinnere mich jeden Montag um 9 Uhr an X“ sagt — der Agent ruft das {toolName} Tool und legt sie an." + "emptyBody": "Routinen entstehen, wenn ein User im Chat etwas wie „erinnere mich jeden Montag um 9 Uhr an X“ sagt — der Agent ruft das Tool und legt sie an." }, "row": { "neverRan": "noch nie", @@ -3208,7 +3214,7 @@ }, "templateEditor": { "badgeActive": "aktiv · {format}", - "description": "JSON-Schema: {schema}. Beim Trigger rendert der Server alle data- + static-Sektionen selbst; der LLM füllt nur die narrative-slot-Strings. Leerer Editor = Template entfernen, Routine läuft wieder im Legacy-Pfad.", + "description": "JSON-Schema: . Beim Trigger rendert der Server alle data- + static-Sektionen selbst; der LLM füllt nur die narrative-slot-Strings. Leerer Editor = Template entfernen, Routine läuft wieder im Legacy-Pfad.", "templatePlaceholder": "'{ \"format\": \"markdown\", \"sections\": [ { \"kind\": \"narrative-slot\", \"id\": \"intro\", \"hint\": \"Ein Satz Einleitung.\" } ] }'", "saveFailedLabel": "Save fehlgeschlagen:", "saved": "Gespeichert.", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 2428859a..4ba3917c 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -597,7 +597,7 @@ "shellLoading": "Loading login…", "loadingProviders": "Loading login…", "errorPrefix": "Failed to load login providers:", - "noProviders": "No auth providers are configured. Set {envVar} in the middleware environment and restart.", + "noProviders": "No auth providers are configured. Set in the middleware environment and restart.", "emailLabel": "Email", "passwordLabel": "Password", "submit": "Sign in", @@ -644,13 +644,13 @@ "apply": "Apply", "applying": "Installing…", "pluginCount": "{count, plural, one {# plugin} other {# plugins}}", - "outcomeAppliedTitle": "Profile {id} applied", + "outcomeAppliedTitle": "Profile applied", "outcomeStats": "{installed} installed, {skipped} skipped, {errored} failed", "sectionInstalled": "Newly installed", "sectionSkipped": "Already installed (skipped)", "sectionErrored": "Errors — please check manually", - "secretsHint": "Plugins that need operator secrets (e.g. anthropic_api_key, telegram_bot_token) start in status {erroredTag} until you set the secrets via the wizard on the respective detail page. Then activate via {reactivateTag}.", - "errorTitle": "Profile {id} could not be applied", + "secretsHint": "Plugins that need operator secrets (e.g. anthropic_api_key, telegram_bot_token) start in status until you set the secrets via the wizard on the respective detail page. Then activate via .", + "errorTitle": "Profile could not be applied", "tryAnother": "Pick another profile" }, "chatTabs": { @@ -685,6 +685,12 @@ "expiredBody": "Your session has expired. Please sign in again to continue working.", "expiredCta": "Sign in again" }, + "runtimeReadiness": { + "title": "LLM API key missing", + "body": "The agent runtime is offline — usually because no LLM API key is configured yet. Add your key in the settings; chat, agents, channels and skills come online right away. Routines additionally need one middleware restart.", + "cta": "Open settings", + "dismiss": "Later" + }, "agentDetailsModal": { "ariaLabelTitle": "{label} tool calls", "ariaCloseBackdrop": "Close", @@ -1431,7 +1437,7 @@ }, "externalReadsHint": "Preview does not run cross-integration lookups (spec.external_reads) — the agent correctly detects the missing service and throws. Publish the agent to the platform store to test the real data path.", "agentStuck": { - "title": "Builder agent is stuck on slot {slot} ({attempts, plural, one {# attempt} other {# attempts}})", + "title": "Builder agent is stuck on slot ({attempts, plural, one {# attempt} other {# attempts}})", "body": "{summary}. Open the slot editor, review the errors manually and fix the code — the agent won't retry on its own.", "dismiss": "Dismiss", "dismissTooltip": "Dismiss banner" @@ -2158,7 +2164,7 @@ } }, "title": "Configuration", - "intro": "All {envFile} values that are written to the config store or the secret vault at startup. Changes are saved automatically and the affected plugin is re-activated — they take effect immediately, without a restart. Secrets only show whether a value is set; the value itself is never displayed.", + "intro": "All values that are written to the config store or the secret vault at startup. Changes are saved automatically and the affected plugin is re-activated — they take effect immediately, without a restart. Secrets only show whether a value is set; the value itself is never displayed.", "loading": "Loading …", "loadError": "Failed to load: {message}", "vaultUnavailable": "Vault unavailable — secrets cannot be edited.", @@ -2571,7 +2577,7 @@ }, "adminAuth": { "title": "Authentication Providers", - "intro": "Enable or disable the sign-in methods. The {envVar} env variable defines the permitted providers; here you choose which of them are currently active.", + "intro": "Enable or disable the sign-in methods. The env variable defines the permitted providers; here you choose which of them are currently active.", "loading": "Loading …", "loadError": "Failed to load: {message}", "statusActive": "active", @@ -2588,7 +2594,7 @@ }, "adminRegistries": { "title": "Plugin Registries", - "intro": "Sources from which plugins are pulled into the store. New instances start with {hub}. The token is stored encrypted and never shown again — only whether one is set. Changes take effect without a restart.", + "intro": "Sources from which plugins are pulled into the store. New instances start with . The token is stored encrypted and never shown again — only whether one is set. Changes take effect without a restart.", "addHeading": "Add registry", "fields": { "name": "Name", @@ -2626,7 +2632,7 @@ }, "adminDomains": { "title": "Plugin Domains", - "intro": "Overview of all registered plugins, grouped by the {domain} declared in their {manifest}. Domains are read by the Phase 8 nudge-pipeline multi-domain trigger and used by the operator UI for cross-agent grouping. Curation (renaming / merging / hierarchies) follows with OB-78 (Phase 9 agent profiles).", + "intro": "Overview of all registered plugins, grouped by the declared in their . Domains are read by the Phase 8 nudge-pipeline multi-domain trigger and used by the operator UI for cross-agent grouping. Curation (renaming / merging / hierarchies) follows with OB-78 (Phase 9 agent profiles).", "loading": "Loading …", "loadError": "Failed to load: {message}", "noData": "No data.", @@ -2637,7 +2643,7 @@ "fallbacks": "Fallbacks" }, "pluginCount": "{count, plural, one {# plugin} other {# plugins}}", - "fallbackWarning": "{count, plural, one {# plugin} other {# plugins}} without a declared domain — auto-fallback to {fallbackDomain}. Add an {manifestKey} line to the manifest (e.g. {exampleOne}, {exampleTwo}) or re-upload the agent via the Builder." + "fallbackWarning": "{count, plural, one {# plugin} other {# plugins}} without a declared domain — auto-fallback to . Add an line to the manifest (e.g. , ) or re-upload the agent via the Builder." }, "adminUsage": { "title": "Costs", @@ -3182,7 +3188,7 @@ "columnLastRun": "Last run", "columnActions": "Actions", "emptyTitle": "No routines yet.", - "emptyBody": "Routines are created when a user says something like “remind me about X every Monday at 9 am” in the chat — the agent calls the {toolName} tool and creates it." + "emptyBody": "Routines are created when a user says something like “remind me about X every Monday at 9 am” in the chat — the agent calls the tool and creates it." }, "row": { "neverRan": "never", @@ -3208,7 +3214,7 @@ }, "templateEditor": { "badgeActive": "active · {format}", - "description": "JSON schema: {schema}. On trigger the server renders all data + static sections itself; the LLM only fills the narrative-slot strings. Empty editor = remove the template, the routine runs on the legacy path again.", + "description": "JSON schema: . On trigger the server renders all data + static sections itself; the LLM only fills the narrative-slot strings. Empty editor = remove the template, the routine runs on the legacy path again.", "templatePlaceholder": "'{ \"format\": \"markdown\", \"sections\": [ { \"kind\": \"narrative-slot\", \"id\": \"intro\", \"hint\": \"One sentence of introduction.\" } ] }'", "saveFailedLabel": "Save failed:", "saved": "Saved.",