From 5b4cc5660ee6864835a333a858e9265d149c9ce4 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Thu, 11 Jun 2026 13:19:50 +0530 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20Operate=20pages=20=E2=80=94?= =?UTF-8?q?=20Query=20Playground,=20Ingest,=20Chat=20(7.9b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console can now drive the request path, not just observe it. Closes the three High-priority no-UI gaps from the v2 API-coverage analysis, built to the Operate design brief (apps/admin-ui/design/claude-design-prompt-operate.md), 1:1 on the real wire types (rag_core.gateway_types / rag_core.openai_types). - Query Playground (/playground, new Operate nav group): full QueryRequest control rail (corpus multiselect, top_k, rerank/pack/generate, advanced pack_budget / generate_max_tokens / generate_temperature / filters editor); Run query → POST /v1/query, Retrieve only → POST /v1/retrieve. Results: decision strip (shape, bm25 fallback, corpus strategy, packed tokens, experiment tag), answer with clickable [n] citation chips + guard verdict, citations list, ranked-chunks table (trust badges, detail sheet), stacked per-stage timings with skipped-stage markers, and a feedback bar (thumbs / 1-5 stars / copy / regenerate / PII-redacted comment → POST /v1/feedback, exact signal enum). "View full trace" deep-links to /trace?rid=… (trace page now reads the rid param; seeded runs reuse DEMO_REQUEST_ID so the demo resolves end-to-end). - Ingest (/ingest, Knowledge group): drag-and-drop multipart POST /v1/ingest/document; staged queue with progress, IngestResult chunk/embedding badges + View-in-Corpora on success, error + Retry on failure; parse → chunk → enrich → PII-redact → embed → index explainer. - Chat (/chat): OpenAI-compatible playground — streaming SSE thread with the non-standard rag annotations per reply (shape, chunks_n, citations, guard, finish_reason, usage; RAG on by default), Embeddings tab (one input per line, float/base64, dim + L2 norm + sparkline), Models tab (GET /v1/models). Live→seed hybrid as everywhere: action surfaces simulate without a gateway (deterministic worked query, token-streamed replies, prefers-reduced-motion honoured). New Slider + Tabs primitives; CorpusMultiSelect / GuardBadge / CitedText shared components; api.ts gains runQuery / retrieveOnly / submitFeedback / ingestDocument / streamChat (SSE reader) / chatComplete / createEmbeddings / fetchModels. Identity body-carried on /v1/query, /v1/retrieve, /v1/feedback; header-driven elsewhere. Documentation: docs/reference/admin-ui.md (Operate pages + hybrid + config). Gates: tsc clean, eslint clean, 46/46 vitest (12 new), next build 17 static routes; browser-verified light + dark, all three pages + trace deep-link. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 3 +- apps/admin-ui/src/app/chat/page.tsx | 701 +++++++++++++++ apps/admin-ui/src/app/ingest/page.tsx | 377 ++++++++ apps/admin-ui/src/app/playground/page.tsx | 823 ++++++++++++++++++ apps/admin-ui/src/app/trace/page.tsx | 95 +- apps/admin-ui/src/components/shared.tsx | 118 ++- apps/admin-ui/src/components/shell/nav.ts | 11 + .../admin-ui/src/components/ui/primitives.tsx | 72 ++ apps/admin-ui/src/lib/api.ts | 214 ++++- apps/admin-ui/src/lib/config.ts | 3 + apps/admin-ui/src/lib/mock.ts | 403 ++++++++- apps/admin-ui/src/lib/types.ts | 242 +++++ apps/admin-ui/test/chat.test.tsx | 60 ++ apps/admin-ui/test/ingest.test.tsx | 51 ++ apps/admin-ui/test/playground.test.tsx | 65 ++ docs/reference/admin-ui.md | 63 +- 16 files changed, 3249 insertions(+), 52 deletions(-) create mode 100644 apps/admin-ui/src/app/chat/page.tsx create mode 100644 apps/admin-ui/src/app/ingest/page.tsx create mode 100644 apps/admin-ui/src/app/playground/page.tsx create mode 100644 apps/admin-ui/test/chat.test.tsx create mode 100644 apps/admin-ui/test/ingest.test.tsx create mode 100644 apps/admin-ui/test/playground.test.tsx diff --git a/TRACKER.md b/TRACKER.md index d15c09d..e96115f 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -13,13 +13,14 @@ | | | |---|---| -| **Last updated** | 2026-06-10 | +| **Last updated** | 2026-06-11 | | **Current phase** | Phase 7 — Pilot, Harden, GA (**10 / 10 steps ✅**) — **all phases complete** | | **Overall** | **84 / 84 steps ✅ — v1.0.0 GA** | | **Next action** | **🎉 GA reached.** Engineering scope (Phases 0–7) complete; `v1.0.0` cut. Remaining items are external/process (external pentest, SOC 2 audit, registry first-publish, signed referenceable customers, on-call staffing + status page, launch assets) — tracked in the [GA readiness checklist](docs/ga/ga-readiness-checklist.md). Post-GA: cut the **v1.0.1** patch tag (serve-path fix below; note: GitHub Actions runs are currently blocked by an account **billing/spending-limit** failure — every `docker.yml` run since GA failed at job start, so the GHCR image was never actually published), then the V1.1 backlog. | **Recently shipped** +- **7.9b** ✅ Admin-ui **Operate pages** — the console can now *drive* the request path, not just observe it (closes the three High-priority gaps from the [v2 API-coverage analysis](apps/admin-ui/design/claude-design-prompt-v2-api-coverage.md); built to the [Operate design brief](apps/admin-ui/design/claude-design-prompt-operate.md), 1:1 on the real wire types in `rag_core.gateway_types` / `rag_core.openai_types`). New **Operate** nav group + three pages: **Query Playground** (`/playground` — full `QueryRequest` control rail: corpus multiselect / `top_k` / rerank·pack·generate switches / advanced `pack_budget`·`generate_max_tokens`·`generate_temperature`·`filters`; **Run query** `POST /v1/query` vs **Retrieve only** `POST /v1/retrieve`; results render the decision strip (shape · bm25-fallback · corpus strategy · packed tokens · experiment tag), the answer with clickable `[n]` citation chips + guard verdict, citations, the ranked-chunks table (trust badges, detail sheet), a stacked per-stage timings bar with skipped-stage markers, and a **feedback bar** (👍/👎/1–5★/copy/regenerate/PII-redacted comment → `POST /v1/feedback`, exact signal enum); **View full trace** deep-links to `/trace?rid=…` — the seeded run reuses `DEMO_REQUEST_ID` so the demo resolves end-to-end); **Ingest** (`/ingest`, Knowledge — drag-and-drop multipart `POST /v1/ingest/document`, staged queue with progress → `IngestResult` chunk/embedding badges or error+Retry, the *parse → chunk → enrich → PII-redact → embed → index* explainer); **Chat** (`/chat` — OpenAI-compatible: streaming SSE thread with the non-standard `rag` annotations surfaced per reply (shape/chunks/citations/guard/finish_reason/usage; RAG **on by default**), an **Embeddings** tab (one input per line, float/base64, dim + L2 norm + sparkline), and a **Models** tab (`GET /v1/models`)). All follow the live→seed hybrid: action surfaces *simulate* without a gateway (deterministic worked query, token-streamed replies honouring `prefers-reduced-motion`). New `Slider`/`Tabs` primitives + `CorpusMultiSelect`/`GuardBadge`/`CitedText` shared components; identity body-carried on `/v1/query`·`/v1/retrieve`·`/v1/feedback`, header-driven elsewhere. Gates: tsc / eslint / **46 vitest** (12 new) / `next build` (17 static routes); browser-verified light+dark, all three pages + the trace deep-link. [reference/admin-ui.md](docs/reference/admin-ui.md) - **v1.0.1** ✅ *(patch)* — **production serve path fixed end-to-end.** The image's default CMD (a Phase-0 placeholder that printed the version and exited) now serves via the new config-driven **`rag_gateway.serve:create_app`** uvicorn-factory entrypoint: `RAG_CONFIG_PATH` set → `build_app_from_config(load(path))`, unset → noop `build_app()`, set-but-broken → **fail fast** (`ConfigError`), never a silent noop fallback. The image **build** itself was also broken — the full `uv sync` needs every workspace member on disk but `sdks/python` was never copied (`Distribution not found`); both stages now copy it. New public **`GET /readyz`** backs the Helm chart's readiness probe (liveness stays `/healthz`). The chart's ConfigMap previously rendered an *invalid* `rag.yaml` (`env:/log_level:/service:` against the strict `extra='forbid'` schema → guaranteed CrashLoop); it now renders a minimal valid document with a **`config.ragYaml`** passthrough for full platform configs (`${VAR}` env interpolation keeps secrets out of the ConfigMap), chart `0.2.0` / `appVersion` `1.0.1` (the old `0.1.0` appVersion pointed the default image tag at a tag that never existed), `values.dev.yaml` `env: development` → `local`. Docs: image tags are published *without* the `v` prefix — `:v1.0.0`-style pulls in README/release-notes never matched. [release notes](docs/release-notes/v1.0.1.md) [#185](https://github.com/officialCodeWork/AgentContextOS/pull/185) - **7.10** ✅ **GA cutover — v1.0.0** 🎉 — the close of an 8-phase, 84-step build. Cut **`v1.0.0`** with **Semantic Versioning** over the drift-gated public contracts (REST/OpenAPI · gRPC proto · `rag.yaml` · SDKs · `ragctl`) + a deprecation policy ([ADR-0052](docs/adr/ADR-0052-ga-cutover.md)); the `rag-platform` meta bumped to 1.0.0. GA **release notes** ([v1.0.0](docs/release-notes/v1.0.0.md)) + a **GA readiness checklist** ([ga-readiness-checklist](docs/ga/ga-readiness-checklist.md)) that maps every Phase 7 exit gate to a CI gate / in-repo artifact, with inherently-external items (external pentest, SOC 2 audit, registry first-publish, signed customers, on-call staffing, launch assets) marked **(external)** — the same in-repo-machine vs external-deliverable split used across Phase 7. The `v1.0.0` tag fans out to every channel via `docker.yml` + `release.yml` + `release-airgap.yml`. - **7.9** ✅ Billing & metering — per-tenant usage accounting, **mirroring the 5.6c cost pattern** (dataclasses in `rag-observability`, the gateway wraps a Pydantic response → **no `dist/schemas` churn**). New `rag_observability.billing`: a `UsageMeter` (observe-only per-tenant, per-dimension counters: queries / docs / storage / reranker / LLM tokens) fed O(1) from `record_request_usage` independent of quotas; `GET /v1/billing/usage` serves the per-tenant dashboard; pure `generate_invoice` + `PricingModel`/`load_pricing` price an invoice **from `marketplace/pricing.yaml`** (a price change is config) and **reconcile with metered usage ±0.5% by construction**; a `BillingProvider` Protocol seam for Stripe / marketplace metering (degrade-open). Inert by default (`cfg.billing`); `ragctl billing` drives meter→invoice→reconcile; **no governed SPI call** (policy-coverage linter unchanged); only `dist/openapi` + `dist/rag.schema` regenerate. Deferred: the concrete Stripe/marketplace adapters, the docs/reranker/storage feed points, an admin usage card. [ADR-0051](docs/adr/ADR-0051-billing-metering.md) [#182](https://github.com/officialCodeWork/AgentContextOS/pull/182) diff --git a/apps/admin-ui/src/app/chat/page.tsx b/apps/admin-ui/src/app/chat/page.tsx new file mode 100644 index 0000000..7c067a9 --- /dev/null +++ b/apps/admin-ui/src/app/chat/page.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { Binary, MessageSquare, Send } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useApp } from "@/components/providers"; +import { + CitedText, + CorpusMultiSelect, + DataPanel, + GuardBadge, + PageHeader, + useResolvedState, +} from "@/components/shared"; +import { Sparkline } from "@/components/ui/charts"; +import { + Badge, + Button, + Card, + CardBar, + CopyButton, + DemoDataBadge, + EmptyState, + ErrorState, + Field, + Input, + Label, + Mono, + Select, + Skeleton, + Slider, + Switch, + TBody, + TD, + TH, + THead, + TR, + Table, + Tabs, + Textarea, +} from "@/components/ui/primitives"; +import { + GatewayError, + chatComplete, + createEmbeddings, + fetchCorpora, + fetchModels, + streamChat, + type ChatRequestInput, +} from "@/lib/api"; +import { cn } from "@/lib/cn"; +import { GATEWAY_URL } from "@/lib/config"; +import { + CORPORA, + embeddingToBase64, + seedChatReply, + seedEmbedding, + seedModels, +} from "@/lib/mock"; +import type { + ChatUsage, + Citation, + Corpus, + DataSource, + EmbeddingsUsage, + ModelInfo, + RagAnnotations, +} from "@/lib/types"; +import { useLive } from "@/lib/use-live"; + +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// --------------------------------------------------------------------------- +// Chat tab +// --------------------------------------------------------------------------- +interface ThreadMessage { + id: number; + role: "user" | "assistant"; + content: string; + streaming: boolean; + rag: RagAnnotations | null; + usage: ChatUsage | null; + finish_reason: string | null; + error: string | null; +} + +let msgSeq = 0; + +const STARTERS = ["What is our incident escalation policy?", "How do severities map to response SLAs?"]; + +function FinishReasonBadge({ reason }: { reason: string | null }) { + if (!reason) return null; + const variant = reason === "content_filter" ? "warning" : reason === "error" ? "error" : "outline"; + return {reason}; +} + +function RagSummary({ msg }: { msg: ThreadMessage }) { + if (msg.error) return null; + if (msg.rag && !msg.rag.enabled) { + return

RAG off — model-only answer

; + } + return ( +
+ {msg.rag?.shape && {msg.rag.shape}} + {msg.rag && {msg.rag.chunks_n} chunks} + {msg.rag?.citations.map((c: Citation, i: number) => ( + + [{i + 1}] + + ))} + {msg.rag?.bm25_fallback && bm25 fallback} + {msg.rag && } + + {msg.usage && ( + + {msg.usage.prompt_tokens}+{msg.usage.completion_tokens}={msg.usage.total_tokens} tok + + )} +
+ ); +} + +function ChatTab({ models }: { models: ModelInfo[] }) { + const { tenant } = useApp(); + const { rows: corpora } = useLive( + (tid) => CORPORA.filter((c) => c.tenant_id === tid), + fetchCorpora, + ); + + const [messages, setMessages] = useState([]); + const [composer, setComposer] = useState(""); + const [busy, setBusy] = useState(false); + + // request controls (ChatCompletionRequest + RagOptions) + const [model, setModel] = useState("rag-gateway"); + const [temperature, setTemperature] = useState(0); + const [maxTokens, setMaxTokens] = useState(1024); + const [stream, setStream] = useState(true); + const [ragEnabled, setRagEnabled] = useState(true); + const [ragCorpusIds, setRagCorpusIds] = useState([]); + const [ragTopK, setRagTopK] = useState(10); + const [ragRerank, setRagRerank] = useState(true); + const [ragPack, setRagPack] = useState(true); + + const threadRef = useRef(null); + const streamTimer = useRef | null>(null); + + useEffect(() => { + setMessages([]); + setRagCorpusIds([]); + }, [tenant.id]); + useEffect( + () => () => { + if (streamTimer.current) clearInterval(streamTimer.current); + }, + [], + ); + useEffect(() => { + const el = threadRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages]); + + function patchMsg(id: number, fields: Partial) { + setMessages((cur) => cur.map((m) => (m.id === id ? { ...m, ...fields } : m))); + } + + const send = useCallback( + async (text: string) => { + const q = text.trim(); + if (!q || busy) return; + setComposer(""); + setBusy(true); + const history = [...messages, { role: "user" as const, content: q }]; + setMessages((cur) => [ + ...cur, + { id: ++msgSeq, role: "user", content: q, streaming: false, rag: null, usage: null, finish_reason: null, error: null }, + ]); + const asstId = ++msgSeq; + setMessages((cur) => [ + ...cur, + { id: asstId, role: "assistant", content: "", streaming: true, rag: null, usage: null, finish_reason: null, error: null }, + ]); + + const input: ChatRequestInput = { + model, + messages: history.map((m) => ({ role: m.role, content: m.content })), + temperature, + max_tokens: maxTokens, + rag: { enabled: ragEnabled, corpus_ids: ragCorpusIds, top_k: ragTopK, rerank: ragRerank, pack: ragPack }, + }; + + if (!GATEWAY_URL) { + await delay(420); + const reply = seedChatReply(tenant.id, { enabled: ragEnabled, top_k: ragTopK }); + const finish = () => { + patchMsg(asstId, { + content: reply.text, + streaming: false, + rag: reply.rag, + usage: reply.usage, + finish_reason: reply.finish_reason, + }); + setBusy(false); + }; + if (!stream || prefersReducedMotion()) { + await delay(300); + finish(); + return; + } + const words = reply.text.split(" "); + let i = 0; + streamTimer.current = setInterval(() => { + i += 1; + patchMsg(asstId, { content: words.slice(0, i).join(" ") }); + if (i >= words.length) { + if (streamTimer.current) clearInterval(streamTimer.current); + streamTimer.current = null; + finish(); + } + }, 24); + return; + } + + try { + const final = stream + ? await streamChat(tenant.id, input, (delta) => + setMessages((cur) => cur.map((m) => (m.id === asstId ? { ...m, content: m.content + delta } : m))), + ) + : await chatComplete(tenant.id, input); + patchMsg(asstId, { + content: final.content, + streaming: false, + rag: final.rag, + usage: final.usage, + finish_reason: final.finish_reason, + }); + } catch (err) { + patchMsg(asstId, { + streaming: false, + finish_reason: "error", + error: err instanceof GatewayError ? err.message : String(err), + }); + } finally { + setBusy(false); + } + }, + [busy, messages, model, temperature, maxTokens, stream, ragEnabled, ragCorpusIds, ragTopK, ragRerank, ragPack, tenant.id], + ); + + return ( +
+ {/* thread */} + +
+ {messages.length === 0 ? ( +
+
+ +
+

Ask a question grounded in your corpora

+

+ Retrieval runs over the last user turn and the context is injected before the LLM call. +

+
+ {STARTERS.map((s) => ( + + ))} +
+
+ ) : ( + messages.map((m) => ( +
+
+
+ {m.error ? ( + + {`{ "error": { "message": "${m.error}", "type": "server_error" } }`} + + ) : ( + <> + + {m.streaming && ( + + )} + + )} +
+ {m.role === "assistant" && !m.streaming && } +
+
+ )) + )} +
+
+