From 377f21acd7a57f119452dab8eda27d8a72fae54a Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:22:45 -0400 Subject: [PATCH] docs: add serving-phase diagram to the introduction page (#264) * feat(docs): add serving-phase diagram to the introduction page The introduction's "How ktx works" section described both the ingest and serve sides but only rendered the ingestion diagram. Add a live, theme-aware React Flow diagram for the serving phase (agent <-> ktx via MCP -> context layer + database) so both phases are shown, with a matching content test. Co-Authored-By: Claude Opus 4.8 * docs(diagram-studio): relabel context edge and use right-angle routing The hub->context edge searches and reads definitions, not just searches; relabel it "search + read". Route the serving search/read-only edges with smoothstep (right angles) to match the docs diagram. (The README PNG is a baked export and is unchanged until re-exported from the studio.) Co-Authored-By: Claude Opus 4.8 * test(docs): point product-mechanics assertions at the FlowCanvas wrapper product-mechanics renders via the shared FlowCanvas wrapper, so the ReactFlow config (nodesDraggable, zoomOnScroll, etc.) lives there now. Update the stale assertions that still expected those literals inline, fixing a pre-existing test failure. Co-Authored-By: Claude Opus 4.8 * docs(serving-diagram): shrink the boxes and drop OpenCode from the agent list Reduce node dimensions, font sizes, padding, and the canvas height so the serving diagram renders ~25% smaller and more compact. Remove OpenCode from the agent's listed clients. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docs-site/components/diagram-studio/flows.ts | 6 +- docs-site/components/product-runtime.tsx | 576 ++++++++++++++++++ .../docs/getting-started/introduction.mdx | 3 + .../tests/product-mechanics-content.test.mjs | 28 +- .../tests/product-runtime-content.test.mjs | 74 +++ 5 files changed, 670 insertions(+), 17 deletions(-) create mode 100644 docs-site/components/product-runtime.tsx create mode 100644 docs-site/tests/product-runtime-content.test.mjs diff --git a/docs-site/components/diagram-studio/flows.ts b/docs-site/components/diagram-studio/flows.ts index cddf75cb..e63cc512 100644 --- a/docs-site/components/diagram-studio/flows.ts +++ b/docs-site/components/diagram-studio/flows.ts @@ -305,8 +305,8 @@ export const runtimeEdges: Edge[] = [ sourceHandle: "to-context", target: "context", targetHandle: "in", - type: "default", - label: "search", + type: "smoothstep", + label: "search + read", ...labelBg, style: edgeStyle, markerStart: marker, @@ -318,7 +318,7 @@ export const runtimeEdges: Edge[] = [ sourceHandle: "to-warehouse", target: "warehouse", targetHandle: "in", - type: "default", + type: "smoothstep", label: "read-only", ...labelBg, style: edgeStyle, diff --git a/docs-site/components/product-runtime.tsx b/docs-site/components/product-runtime.tsx new file mode 100644 index 00000000..bfe7d64a --- /dev/null +++ b/docs-site/components/product-runtime.tsx @@ -0,0 +1,576 @@ +"use client"; + +import { + type Edge, + type EdgeProps, + getSmoothStepPath, + Handle, + MarkerType, + type Node, + type NodeProps, + Position, +} from "@xyflow/react"; + +import { FlowCanvas } from "./flow-canvas"; + +type AgentNodeData = { + title: string; + items: string[]; +}; + +type HubNodeData = { + title: string; + badge: string; + rows: string[]; +}; + +type TargetNodeData = { + accent: string; + title: string; + body: string; + rows: { text: string; color?: string; mono?: boolean }[]; + badge?: string; +}; + +type AgentNode = Node; +type HubNode = Node; +type TargetNode = Node; +type FlowNode = AgentNode | HubNode | TargetNode; + +const AGENT_W = 252; +const AGENT_H = 96; +const HUB_W = 306; +const HUB_H = 190; +const TARGET_W = 268; +const TARGET_H = 148; + +const CENTER_X = 470; +const ROW_AGENT_Y = 0; +const ROW_HUB_Y = 196; +const ROW_TARGET_Y = 488; + +const AGENT_X = CENTER_X - AGENT_W / 2; +const HUB_X = CENTER_X - HUB_W / 2; + +const TARGET_GAP_X = 38; +const TARGETS_TOTAL = TARGET_W * 2 + TARGET_GAP_X; +const TARGETS_START_X = CENTER_X - TARGETS_TOTAL / 2; +const CONTEXT_X = TARGETS_START_X; +const WAREHOUSE_X = TARGETS_START_X + TARGET_W + TARGET_GAP_X; + +const EDGE_STROKE = "#94a3b8"; +const CYCLE_STROKE = "#0e7490"; +const EMERALD = "#059669"; +const TEAL = "#0e7490"; + +const nodes: FlowNode[] = [ + { + id: "agent", + type: "agent", + position: { x: AGENT_X, y: ROW_AGENT_Y }, + data: { + title: "Your agent", + items: ["Claude Code", "Cursor", "Codex"], + }, + draggable: false, + selectable: false, + }, + { + id: "hub", + type: "hub", + position: { x: HUB_X, y: ROW_HUB_Y }, + data: { + title: "ktx", + badge: "MCP + CLI", + rows: [ + "Search wiki + semantic layer", + "Return approved metrics", + "Compile metrics → SQL", + ], + }, + draggable: false, + selectable: false, + }, + { + id: "context", + type: "target", + position: { x: CONTEXT_X, y: ROW_TARGET_Y }, + data: { + accent: TEAL, + title: "Context layer", + body: "Approved definitions agents search before they answer.", + rows: [ + { text: "wiki/*.md", color: EMERALD, mono: true }, + { text: "semantic-layer/*.yaml", color: TEAL, mono: true }, + ], + }, + draggable: false, + selectable: false, + }, + { + id: "warehouse", + type: "target", + position: { x: WAREHOUSE_X, y: ROW_TARGET_Y }, + data: { + accent: "#334155", + title: "Database", + badge: "read-only", + body: "Runs the compiled SQL. ktx never writes to it.", + rows: [], + }, + draggable: false, + selectable: false, + }, +]; + +const labelBg = { + labelBgPadding: [6, 3] as [number, number], + labelBgBorderRadius: 4, + labelStyle: { + fontSize: 13, + fontWeight: 600, + fill: "var(--color-fd-muted-foreground)", + }, + labelBgStyle: { + fill: "var(--color-fd-background)", + stroke: "var(--color-fd-border)", + strokeWidth: 1, + }, +}; + +const requestMarker = { + type: MarkerType.ArrowClosed, + color: EDGE_STROKE, + width: 16, + height: 16, +}; + +const flowEdges: Edge[] = [ + { + id: "e-ask", + source: "agent", + sourceHandle: "ask", + target: "hub", + targetHandle: "ask", + type: "straight", + label: "ask", + ...labelBg, + style: { stroke: EDGE_STROKE, strokeWidth: 1.5 }, + markerEnd: requestMarker, + }, + { + id: "e-answer", + source: "hub", + sourceHandle: "answer", + target: "agent", + targetHandle: "answer", + type: "straight", + label: "answer", + ...labelBg, + style: { stroke: EDGE_STROKE, strokeWidth: 1.5 }, + markerEnd: requestMarker, + }, + { + id: "e-search", + source: "hub", + sourceHandle: "to-context", + target: "context", + targetHandle: "in", + type: "smoothstep", + label: "search + read", + ...labelBg, + style: { stroke: CYCLE_STROKE, strokeWidth: 1.5 }, + markerStart: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + markerEnd: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + }, + { + id: "e-readonly", + source: "hub", + sourceHandle: "to-warehouse", + target: "warehouse", + targetHandle: "in", + type: "smoothstep", + label: "read-only", + ...labelBg, + style: { stroke: CYCLE_STROKE, strokeWidth: 1.5 }, + markerStart: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + markerEnd: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + }, +]; + +function AgentNodeView({ data }: NodeProps) { + return ( +
+ + +
+ + + +

+ {data.title} +

+
+
+ {data.items.map((item) => ( + + {item} + + ))} +
+
+ ); +} + +function HubNodeView({ data }: NodeProps) { + return ( +
+ + + + +
+ + k + + + {data.title} + + + {data.badge} + +
+
+ {data.rows.map((row) => ( +
+ + + {row} + +
+ ))} +
+
+ ); +} + +function TargetNodeView({ data }: NodeProps) { + return ( +
+ +
+

+ {data.title} +

+ {data.badge ? ( + + {data.badge} + + ) : null} +
+ {data.rows.length > 0 ? ( +
+ {data.rows.map((row) => ( + + {row.text} + + ))} +
+ ) : null} +

+ {data.body} +

+
+ ); +} + +/* ------------------------------- Particles ------------------------------- */ + +const PARTICLE_SPEED_PX_PER_SEC = 150; +const PARTICLE_MIN_DURATION_SEC = 5; + +type Leg = { + sx: number; + sy: number; + sPos: Position; + tx: number; + ty: number; + tPos: Position; +}; + +const AGENT_ASK_X = AGENT_X + AGENT_W * 0.35; +const AGENT_ANSWER_X = AGENT_X + AGENT_W * 0.65; +const AGENT_BOTTOM_Y = ROW_AGENT_Y + AGENT_H; +const HUB_ASK_X = HUB_X + HUB_W * 0.375; +const HUB_ANSWER_X = HUB_X + HUB_W * 0.625; +const HUB_TO_CONTEXT_X = HUB_X + HUB_W * 0.44; +const HUB_TO_WAREHOUSE_X = HUB_X + HUB_W * 0.56; +const HUB_BOTTOM_Y = ROW_HUB_Y + HUB_H; +const CONTEXT_TOP_X = CONTEXT_X + TARGET_W / 2; +const WAREHOUSE_TOP_X = WAREHOUSE_X + TARGET_W / 2; + +function buildCyclePath(spokeX: number, targetX: number): { + d: string; + length: number; +} { + const legs: Leg[] = [ + // agent → hub (ask, down) + { sx: AGENT_ASK_X, sy: AGENT_BOTTOM_Y, sPos: Position.Bottom, tx: HUB_ASK_X, ty: ROW_HUB_Y, tPos: Position.Top }, + // through the hub to its spoke handle (down, drawn behind the hub) + { sx: HUB_ASK_X, sy: ROW_HUB_Y, sPos: Position.Bottom, tx: spokeX, ty: HUB_BOTTOM_Y, tPos: Position.Top }, + // hub → target (down) + { sx: spokeX, sy: HUB_BOTTOM_Y, sPos: Position.Bottom, tx: targetX, ty: ROW_TARGET_Y, tPos: Position.Top }, + // target → hub (up) + { sx: targetX, sy: ROW_TARGET_Y, sPos: Position.Top, tx: spokeX, ty: HUB_BOTTOM_Y, tPos: Position.Bottom }, + // through the hub to its answer handle (up, drawn behind the hub) + { sx: spokeX, sy: HUB_BOTTOM_Y, sPos: Position.Top, tx: HUB_ANSWER_X, ty: ROW_HUB_Y, tPos: Position.Bottom }, + // hub → agent (answer, up) + { sx: HUB_ANSWER_X, sy: ROW_HUB_Y, sPos: Position.Top, tx: AGENT_ANSWER_X, ty: AGENT_BOTTOM_Y, tPos: Position.Bottom }, + ]; + + const segments = legs.map((leg) => { + const [segment] = getSmoothStepPath({ + sourceX: leg.sx, + sourceY: leg.sy, + sourcePosition: leg.sPos, + targetX: leg.tx, + targetY: leg.ty, + targetPosition: leg.tPos, + }); + return segment; + }); + + let d = segments[0]; + for (let i = 1; i < segments.length; i += 1) { + d += ` ${segments[i].replace(/^M/, "L")}`; + } + + const length = legs.reduce( + (sum, leg) => sum + Math.abs(leg.tx - leg.sx) + Math.abs(leg.ty - leg.sy), + 0, + ); + + return { d, length }; +} + +type ParticleEdgeData = { + d: string; + duration: number; + beginOffset: number; + color: string; +}; + +type ParticleEdge = Edge; + +function ParticleEdgeView({ id, data }: EdgeProps) { + if (!data) return null; + const pathId = `runtime-particle-path-${id}`; + return ( + <> + + + + + + + + + + + ); +} + +function makeCycleEdge( + id: string, + source: string, + spokeX: number, + targetX: number, + beginFraction: number, +): ParticleEdge { + const { d, length } = buildCyclePath(spokeX, targetX); + const duration = Math.max( + PARTICLE_MIN_DURATION_SEC, + length / PARTICLE_SPEED_PX_PER_SEC, + ); + return { + id, + source, + target: source, + type: "particle", + data: { d, duration, beginOffset: duration * beginFraction, color: CYCLE_STROKE }, + }; +} + +const particleEdges: ParticleEdge[] = [ + makeCycleEdge("p-context", "context", HUB_TO_CONTEXT_X, CONTEXT_TOP_X, 0), + makeCycleEdge("p-warehouse", "warehouse", HUB_TO_WAREHOUSE_X, WAREHOUSE_TOP_X, 0.5), +]; + +const nodeTypes = { + agent: AgentNodeView, + hub: HubNodeView, + target: TargetNodeView, +}; + +const edgeTypes = { + particle: ParticleEdgeView, +}; + +const edges = [...flowEdges, ...particleEdges]; + +export function ProductRuntime() { + return ( +
+
+

+ How serving works +

+

+ At runtime, agents reach ktx through MCP. ktx searches the context + layer, returns approved metrics, and compiles them into read-only SQL + the warehouse runs. +

+
+ +
+
+

+ Serving flow +

+

+ From an agent request to a governed answer +

+

+ The agent asks in plain language. ktx is the only thing that touches + the context layer and the warehouse, and every database connection + is read-only. +

+
+ + +
+ +
+ ); +} diff --git a/docs-site/content/docs/getting-started/introduction.mdx b/docs-site/content/docs/getting-started/introduction.mdx index cc3b0ca8..50ffe20d 100644 --- a/docs-site/content/docs/getting-started/introduction.mdx +++ b/docs-site/content/docs/getting-started/introduction.mdx @@ -4,6 +4,7 @@ description: ktx is an open-source, self-improving context layer for data agents --- import { ProductMechanics } from "@/components/product-mechanics"; +import { ProductRuntime } from "@/components/product-runtime";
@@ -59,6 +60,8 @@ serves that context to agents at runtime. + + ## Use it for Use **ktx** when agents need more than raw database access. Agents can search wiki diff --git a/docs-site/tests/product-mechanics-content.test.mjs b/docs-site/tests/product-mechanics-content.test.mjs index 5cce9001..d0c9471c 100644 --- a/docs-site/tests/product-mechanics-content.test.mjs +++ b/docs-site/tests/product-mechanics-content.test.mjs @@ -85,7 +85,7 @@ test("product mechanics component explains ingestion outputs", async () => { "compile into SQL", '"use client"', "@xyflow/react", - " { ); } - assert.match( - component, + // The ReactFlow canvas config lives in the shared FlowCanvas wrapper, which + // product-mechanics renders. Assert the static read-only behavior there. + const flowCanvas = await readDocsFile("components/flow-canvas.tsx"); + for (const guard of [ /nodesDraggable=\{false\}/, - "ReactFlow canvas should disable node dragging", - ); - assert.match( - component, - /panOnDrag=\{false\}/, - "ReactFlow canvas should disable panning", - ); - assert.match( - component, + /nodesConnectable=\{false\}/, /zoomOnScroll=\{false\}/, - "ReactFlow canvas should disable scroll zoom", - ); + /elementsSelectable=\{false\}/, + ]) { + assert.match( + flowCanvas, + guard, + `shared FlowCanvas should enforce static read-only behavior: ${guard}`, + ); + } assert.doesNotMatch(component, /raw-sources/); assert.doesNotMatch(component, /\.ktx/); diff --git a/docs-site/tests/product-runtime-content.test.mjs b/docs-site/tests/product-runtime-content.test.mjs new file mode 100644 index 00000000..ac643faa --- /dev/null +++ b/docs-site/tests/product-runtime-content.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const docsSiteDir = join(dirname(fileURLToPath(import.meta.url)), ".."); + +async function readDocsFile(path) { + return readFile(join(docsSiteDir, path), "utf8"); +} + +test("docs introduction renders the serving phase after ingestion", async () => { + const introduction = await readDocsFile( + "content/docs/getting-started/introduction.mdx", + ); + + assert.match( + introduction, + /import\s+\{\s*ProductRuntime\s*\}\s+from\s+"@\/components\/product-runtime";/, + ); + assert.match(introduction, //); + + const mechanicsIndex = introduction.indexOf(""); + const runtimeIndex = introduction.indexOf(""); + const useCaseIndex = introduction.indexOf("## Use it for"); + + assert.ok( + runtimeIndex > mechanicsIndex, + "serving diagram should appear after the ingestion diagram", + ); + assert.ok( + runtimeIndex < useCaseIndex, + "serving diagram should appear before use-case sections", + ); +}); + +test("product runtime component explains the serving cycle", async () => { + const component = await readDocsFile("components/product-runtime.tsx"); + + for (const expectedText of [ + "How serving works", + "Serving flow", + "From an agent request to a governed answer", + "Your agent", + "Claude Code", + "Cursor", + "Codex", + "Search wiki + semantic layer", + "Return approved metrics", + "Compile metrics → SQL", + "Context layer", + "Database", + "search + read", + "read-only", + "wiki/*.md", + "semantic-layer/*.yaml", + '"use client"', + "@xyflow/react", + "FlowCanvas", + "getSmoothStepPath", + "animateMotion", + "runtime-particle", + "buildCyclePath", + ]) { + assert.ok( + component.includes(expectedText), + `component should include: ${expectedText}`, + ); + } + + assert.doesNotMatch(component, /raw-sources/); + assert.doesNotMatch(component, /