Skip to content

Commit fed3751

Browse files
authored
feat(playground): observable streaming chat + retrieval inspector (#12)
Rebuilds the Playground as a ChatGPT-style streaming experience over the engine's agentic tree-walk (/v1/answer/treewalk?stream=true): - SSE proxy route pipes the stream (demo corpus via internal key, or the user's own docs via session) straight to the browser; useTreewalkStream parses events into typed state. - Split UI: chat thread (streaming answer + citation quotes) on the left, a live Retrieval Inspector (structure-reading, per-hop reasoning, sections touched) + quality ScoreCard on the right. - ScoreCard: confidence, groundedness, citations, pages read, hops, latency, cost, tokens. Groundedness verifies each citation quote occurs in the cited section (demo + own via the API-key-aware proxy). - DocumentPicker toggles shared demo corpus vs the user's documents. - Removed the old non-streaming keyword-matching action (dead endpoints); kept session persistence. Landing design tokens throughout.
1 parent 92f7c13 commit fed3751

11 files changed

Lines changed: 1309 additions & 909 deletions

File tree

apps/web/app/(dashboard)/dashboard/playground/page.tsx

Lines changed: 141 additions & 704 deletions
Large diffs are not rendered by default.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { forwardToCP } from "@/lib/cp-proxy";
2+
3+
export const runtime = "nodejs";
4+
export const dynamic = "force-dynamic";
5+
6+
const DEMO_API_KEY = process.env.VECTORLESS_INTERNAL_API_KEY || "";
7+
8+
export interface PlaygroundDoc {
9+
doc_id: string;
10+
title: string;
11+
status: string;
12+
section_count?: number;
13+
source_type?: string;
14+
}
15+
16+
/** Pull a documents array out of the CP list response (shape-tolerant). */
17+
function normalize(data: unknown): PlaygroundDoc[] {
18+
const arr =
19+
(data as { documents?: unknown[] } | null)?.documents ??
20+
(Array.isArray(data) ? (data as unknown[]) : []);
21+
return (arr as Record<string, unknown>[])
22+
.map((d) => ({
23+
doc_id: String(d.doc_id ?? d.document_id ?? d.id ?? ""),
24+
title: String(d.title ?? d.filename ?? "Untitled"),
25+
status: String(d.status ?? "unknown"),
26+
section_count:
27+
typeof d.section_count === "number" ? d.section_count : undefined,
28+
source_type:
29+
typeof d.source_type === "string" ? d.source_type : undefined,
30+
}))
31+
.filter((d) => d.doc_id);
32+
}
33+
34+
/**
35+
* GET /api/dashboard/playground/documents
36+
* Returns the shared demo corpus (via the internal key) and the user's
37+
* own documents (via their session), so the picker can offer both.
38+
*/
39+
export async function GET() {
40+
const [demo, mine] = await Promise.all([
41+
DEMO_API_KEY
42+
? forwardToCP("/v1/documents", { apiKey: DEMO_API_KEY })
43+
: Promise.resolve({ ok: false, status: 503, data: null }),
44+
forwardToCP("/v1/documents"),
45+
]);
46+
47+
const onlyReady = (docs: PlaygroundDoc[]) =>
48+
docs.filter((d) => d.status === "ready");
49+
50+
return Response.json({
51+
demo: demo.ok ? onlyReady(normalize(demo.data)) : [],
52+
mine: mine.ok ? onlyReady(normalize(mine.data)) : [],
53+
});
54+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { NextRequest } from "next/server";
2+
import { forwardToCP } from "@/lib/cp-proxy";
3+
4+
export const runtime = "nodejs";
5+
export const dynamic = "force-dynamic";
6+
7+
const DEMO_API_KEY = process.env.VECTORLESS_INTERNAL_API_KEY || "";
8+
9+
interface CitationIn {
10+
quote?: string;
11+
section_ids?: string[];
12+
}
13+
interface GroundingBody {
14+
source?: "demo" | "mine";
15+
citations: CitationIn[];
16+
}
17+
18+
/** Collapse whitespace + lowercase for a tolerant substring match. */
19+
function norm(s: string): string {
20+
return s.replace(/\s+/g, " ").trim().toLowerCase();
21+
}
22+
23+
/**
24+
* POST /api/dashboard/playground/grounding
25+
* For each citation, fetch its cited section(s) and check the verbatim
26+
* quote actually occurs in the source — a no-ground-truth "faithfulness"
27+
* signal. Returns per-citation booleans + an overall groundedness ratio.
28+
*/
29+
export async function POST(request: NextRequest) {
30+
let body: GroundingBody;
31+
try {
32+
body = (await request.json()) as GroundingBody;
33+
} catch {
34+
return Response.json({ error: "Invalid JSON" }, { status: 400 });
35+
}
36+
const citations = body.citations ?? [];
37+
const useDemo = body.source !== "mine";
38+
const auth = useDemo && DEMO_API_KEY ? { apiKey: DEMO_API_KEY } : {};
39+
40+
// Cache section content across citations that share a section.
41+
const contentCache = new Map<string, string>();
42+
const getSection = async (id: string): Promise<string> => {
43+
if (contentCache.has(id)) return contentCache.get(id)!;
44+
const { ok, data } = await forwardToCP(`/v1/sections/${id}`, auth);
45+
const content = ok
46+
? String((data as { content?: string } | null)?.content ?? "")
47+
: "";
48+
contentCache.set(id, content);
49+
return content;
50+
};
51+
52+
const results = await Promise.all(
53+
citations.map(async (c) => {
54+
const quote = c.quote?.trim();
55+
if (!quote || !c.section_ids?.length) return { grounded: null };
56+
const needle = norm(quote);
57+
for (const id of c.section_ids) {
58+
const hay = norm(await getSection(id));
59+
if (hay && hay.includes(needle)) return { grounded: true };
60+
}
61+
return { grounded: false };
62+
}),
63+
);
64+
65+
const checkable = results.filter((r) => r.grounded !== null);
66+
const grounded = checkable.filter((r) => r.grounded === true).length;
67+
const groundedness =
68+
checkable.length > 0 ? grounded / checkable.length : null;
69+
70+
return Response.json({ results, groundedness });
71+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { NextRequest } from "next/server";
2+
import { streamFromCP } from "@/lib/cp-proxy";
3+
4+
// Node runtime + dynamic so we can stream the upstream SSE body straight
5+
// through without Vercel buffering it.
6+
export const runtime = "nodejs";
7+
export const dynamic = "force-dynamic";
8+
9+
const DEMO_API_KEY = process.env.VECTORLESS_INTERNAL_API_KEY || "";
10+
11+
interface StreamBody {
12+
document_id: string;
13+
query: string;
14+
/** "demo" = shared corpus (internal key); "mine" = user's own docs. */
15+
source?: "demo" | "mine";
16+
model?: string;
17+
max_hops?: number;
18+
}
19+
20+
/**
21+
* POST /api/dashboard/playground/stream
22+
*
23+
* Opens the engine's agentic tree-walk answer stream
24+
* (/v1/answer/treewalk?stream=true) via the control plane and pipes the
25+
* SSE events (started → per-hop reasoning → answer) to the browser.
26+
*/
27+
export async function POST(request: NextRequest) {
28+
let body: StreamBody;
29+
try {
30+
body = (await request.json()) as StreamBody;
31+
} catch {
32+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
33+
}
34+
35+
if (!body.document_id || !body.query?.trim()) {
36+
return Response.json(
37+
{ error: "document_id and query are required" },
38+
{ status: 400 },
39+
);
40+
}
41+
42+
const upstreamBody = {
43+
document_id: body.document_id,
44+
query: body.query,
45+
stream: true,
46+
reasoning: true,
47+
...(body.model ? { model: body.model } : {}),
48+
...(body.max_hops ? { max_hops: body.max_hops } : {}),
49+
};
50+
51+
const useDemo = body.source !== "mine";
52+
if (useDemo && !DEMO_API_KEY) {
53+
return Response.json(
54+
{ error: "Demo corpus is not configured on this deployment." },
55+
{ status: 503 },
56+
);
57+
}
58+
59+
const upstream = await streamFromCP(
60+
"/v1/answer/treewalk?stream=true",
61+
upstreamBody,
62+
useDemo ? { apiKey: DEMO_API_KEY } : {},
63+
);
64+
65+
if (!upstream.ok || !upstream.body) {
66+
const text = await upstream.text().catch(() => "");
67+
return new Response(
68+
text || JSON.stringify({ error: "Upstream stream error" }),
69+
{
70+
status: upstream.status || 502,
71+
headers: { "Content-Type": "application/json" },
72+
},
73+
);
74+
}
75+
76+
return new Response(upstream.body, {
77+
status: 200,
78+
headers: {
79+
"Content-Type": "text/event-stream; charset=utf-8",
80+
"Cache-Control": "no-cache, no-transform",
81+
Connection: "keep-alive",
82+
"X-Accel-Buffering": "no",
83+
},
84+
});
85+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import type {
5+
Message,
6+
AssistantMessage,
7+
Citation,
8+
} from "@/lib/hooks/useTreewalkStream";
9+
import { Markdown } from "@/components/ui/markdown";
10+
import { GroundedPill } from "./ScoreCard";
11+
import { cn } from "@/lib/utils";
12+
import { AlertCircle, Loader2, Quote, Sparkles } from "lucide-react";
13+
14+
function CitationItem({ c }: { c: Citation }) {
15+
const pages =
16+
c.startPage != null && c.endPage != null
17+
? c.startPage === c.endPage
18+
? `p.${c.startPage}`
19+
: `pp.${c.startPage}${c.endPage}`
20+
: null;
21+
return (
22+
<div className="rounded-[14px] border border-border-light bg-muted/40 p-3">
23+
<div className="mb-1.5 flex items-center gap-2">
24+
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-[11px] font-medium text-white">
25+
{c.index}
26+
</span>
27+
{pages && (
28+
<span className="font-mono text-[11px] text-muted-foreground">
29+
{pages}
30+
</span>
31+
)}
32+
<GroundedPill grounded={c.grounded} />
33+
</div>
34+
{c.quote ? (
35+
<p className="border-l-2 border-brand-pink/40 pl-3 text-[13px] italic leading-relaxed text-foreground/80">
36+
{c.quote}
37+
</p>
38+
) : (
39+
<p className="text-[13px] text-muted-foreground">
40+
Cited {c.sectionIds?.length ?? 0} section(s).
41+
</p>
42+
)}
43+
</div>
44+
);
45+
}
46+
47+
function AssistantBubble({ m }: { m: AssistantMessage }) {
48+
const empty = !m.answer && m.steps.length === 0 && m.status === "streaming";
49+
return (
50+
<div className="flex gap-3">
51+
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
52+
<Sparkles className="h-3.5 w-3.5 text-brand-blue" />
53+
</span>
54+
<div className="min-w-0 flex-1 space-y-3">
55+
{empty && (
56+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
57+
<Loader2 className="h-4 w-4 animate-spin" />
58+
Reading the document…
59+
</div>
60+
)}
61+
62+
{m.answer && <Markdown content={m.answer} />}
63+
64+
{m.status === "error" && (
65+
<div className="flex items-start gap-2 rounded-[14px] border border-red-200 bg-red-50 p-3 text-sm text-red-700">
66+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
67+
<span>{m.error ?? "Something went wrong."}</span>
68+
</div>
69+
)}
70+
71+
{m.citations.length > 0 && (
72+
<div className="space-y-2">
73+
<div className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
74+
<Quote className="h-3 w-3" />
75+
{m.citations.length} citation
76+
{m.citations.length === 1 ? "" : "s"}
77+
</div>
78+
{m.citations.map((c) => (
79+
<CitationItem key={c.index} c={c} />
80+
))}
81+
</div>
82+
)}
83+
</div>
84+
</div>
85+
);
86+
}
87+
88+
function Bubble({ m }: { m: Message }) {
89+
if (m.role === "user") {
90+
return (
91+
<div className="flex justify-end">
92+
<div className="max-w-[85%] rounded-[18px] rounded-br-md bg-foreground px-4 py-2.5 text-sm text-white">
93+
{m.content}
94+
</div>
95+
</div>
96+
);
97+
}
98+
return <AssistantBubble m={m} />;
99+
}
100+
101+
export function ChatThread({ messages }: { messages: Message[] }) {
102+
const endRef = useRef<HTMLDivElement>(null);
103+
useEffect(() => {
104+
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
105+
}, [messages]);
106+
107+
if (messages.length === 0) {
108+
return (
109+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
110+
<span className="flex h-11 w-11 items-center justify-center rounded-[16px] bg-brand-blue/10">
111+
<Sparkles className="h-5 w-5 text-brand-blue" />
112+
</span>
113+
<h2 className="text-lg font-medium text-foreground">
114+
Ask the document anything
115+
</h2>
116+
<p className="max-w-sm text-sm text-muted-foreground">
117+
Vectorless navigates the document&apos;s structure and answers with
118+
grounded citations — and you watch every step it takes on the right.
119+
</p>
120+
</div>
121+
);
122+
}
123+
124+
return (
125+
<div className={cn("flex-1 space-y-6 overflow-y-auto px-1 py-4")}>
126+
{messages.map((m, i) => (
127+
<Bubble key={i} m={m} />
128+
))}
129+
<div ref={endRef} />
130+
</div>
131+
);
132+
}

0 commit comments

Comments
 (0)