Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
845 changes: 141 additions & 704 deletions apps/web/app/(dashboard)/dashboard/playground/page.tsx

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions apps/web/app/api/dashboard/playground/documents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { forwardToCP } from "@/lib/cp-proxy";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const DEMO_API_KEY = process.env.VECTORLESS_INTERNAL_API_KEY || "";

export interface PlaygroundDoc {
doc_id: string;
title: string;
status: string;
section_count?: number;
source_type?: string;
}

/** Pull a documents array out of the CP list response (shape-tolerant). */
function normalize(data: unknown): PlaygroundDoc[] {
const arr =
(data as { documents?: unknown[] } | null)?.documents ??
(Array.isArray(data) ? (data as unknown[]) : []);
return (arr as Record<string, unknown>[])
.map((d) => ({
doc_id: String(d.doc_id ?? d.document_id ?? d.id ?? ""),
title: String(d.title ?? d.filename ?? "Untitled"),
status: String(d.status ?? "unknown"),
section_count:
typeof d.section_count === "number" ? d.section_count : undefined,
source_type:
typeof d.source_type === "string" ? d.source_type : undefined,
}))
.filter((d) => d.doc_id);
}

/**
* GET /api/dashboard/playground/documents
* Returns the shared demo corpus (via the internal key) and the user's
* own documents (via their session), so the picker can offer both.
*/
export async function GET() {
const [demo, mine] = await Promise.all([
DEMO_API_KEY
? forwardToCP("/v1/documents", { apiKey: DEMO_API_KEY })
: Promise.resolve({ ok: false, status: 503, data: null }),
forwardToCP("/v1/documents"),
]);

const onlyReady = (docs: PlaygroundDoc[]) =>
docs.filter((d) => d.status === "ready");

return Response.json({
demo: demo.ok ? onlyReady(normalize(demo.data)) : [],
mine: mine.ok ? onlyReady(normalize(mine.data)) : [],
});
}
71 changes: 71 additions & 0 deletions apps/web/app/api/dashboard/playground/grounding/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest } from "next/server";
import { forwardToCP } from "@/lib/cp-proxy";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const DEMO_API_KEY = process.env.VECTORLESS_INTERNAL_API_KEY || "";

interface CitationIn {
quote?: string;
section_ids?: string[];
}
interface GroundingBody {
source?: "demo" | "mine";
citations: CitationIn[];
}

/** Collapse whitespace + lowercase for a tolerant substring match. */
function norm(s: string): string {
return s.replace(/\s+/g, " ").trim().toLowerCase();
}

/**
* POST /api/dashboard/playground/grounding
* For each citation, fetch its cited section(s) and check the verbatim
* quote actually occurs in the source — a no-ground-truth "faithfulness"
* signal. Returns per-citation booleans + an overall groundedness ratio.
*/
export async function POST(request: NextRequest) {
let body: GroundingBody;
try {
body = (await request.json()) as GroundingBody;
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const citations = body.citations ?? [];
const useDemo = body.source !== "mine";
const auth = useDemo && DEMO_API_KEY ? { apiKey: DEMO_API_KEY } : {};

// Cache section content across citations that share a section.
const contentCache = new Map<string, string>();
const getSection = async (id: string): Promise<string> => {
if (contentCache.has(id)) return contentCache.get(id)!;
const { ok, data } = await forwardToCP(`/v1/sections/${id}`, auth);
const content = ok
? String((data as { content?: string } | null)?.content ?? "")
: "";
contentCache.set(id, content);
return content;
};

const results = await Promise.all(
citations.map(async (c) => {
const quote = c.quote?.trim();
if (!quote || !c.section_ids?.length) return { grounded: null };
const needle = norm(quote);
for (const id of c.section_ids) {
const hay = norm(await getSection(id));
if (hay && hay.includes(needle)) return { grounded: true };
}
return { grounded: false };
}),
);

const checkable = results.filter((r) => r.grounded !== null);
const grounded = checkable.filter((r) => r.grounded === true).length;
const groundedness =
checkable.length > 0 ? grounded / checkable.length : null;

return Response.json({ results, groundedness });
}
85 changes: 85 additions & 0 deletions apps/web/app/api/dashboard/playground/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextRequest } from "next/server";
import { streamFromCP } from "@/lib/cp-proxy";

// Node runtime + dynamic so we can stream the upstream SSE body straight
// through without Vercel buffering it.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const DEMO_API_KEY = process.env.VECTORLESS_INTERNAL_API_KEY || "";

interface StreamBody {
document_id: string;
query: string;
/** "demo" = shared corpus (internal key); "mine" = user's own docs. */
source?: "demo" | "mine";
model?: string;
max_hops?: number;
}

/**
* POST /api/dashboard/playground/stream
*
* Opens the engine's agentic tree-walk answer stream
* (/v1/answer/treewalk?stream=true) via the control plane and pipes the
* SSE events (started → per-hop reasoning → answer) to the browser.
*/
export async function POST(request: NextRequest) {
let body: StreamBody;
try {
body = (await request.json()) as StreamBody;
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}

if (!body.document_id || !body.query?.trim()) {
return Response.json(
{ error: "document_id and query are required" },
{ status: 400 },
);
}

const upstreamBody = {
document_id: body.document_id,
query: body.query,
stream: true,
reasoning: true,
...(body.model ? { model: body.model } : {}),
...(body.max_hops ? { max_hops: body.max_hops } : {}),
};

const useDemo = body.source !== "mine";
if (useDemo && !DEMO_API_KEY) {
return Response.json(
{ error: "Demo corpus is not configured on this deployment." },
{ status: 503 },
);
}

const upstream = await streamFromCP(
"/v1/answer/treewalk?stream=true",
upstreamBody,
useDemo ? { apiKey: DEMO_API_KEY } : {},
);

if (!upstream.ok || !upstream.body) {
const text = await upstream.text().catch(() => "");
return new Response(
text || JSON.stringify({ error: "Upstream stream error" }),
{
status: upstream.status || 502,
headers: { "Content-Type": "application/json" },
},
);
}

return new Response(upstream.body, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
132 changes: 132 additions & 0 deletions apps/web/components/playground/ChatThread.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useEffect, useRef } from "react";
import type {
Message,
AssistantMessage,
Citation,
} from "@/lib/hooks/useTreewalkStream";
import { Markdown } from "@/components/ui/markdown";
import { GroundedPill } from "./ScoreCard";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, Quote, Sparkles } from "lucide-react";

function CitationItem({ c }: { c: Citation }) {
const pages =
c.startPage != null && c.endPage != null
? c.startPage === c.endPage
? `p.${c.startPage}`
: `pp.${c.startPage}–${c.endPage}`
: null;
return (
<div className="rounded-[14px] border border-border-light bg-muted/40 p-3">
<div className="mb-1.5 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-[11px] font-medium text-white">
{c.index}
</span>
{pages && (
<span className="font-mono text-[11px] text-muted-foreground">
{pages}
</span>
)}
<GroundedPill grounded={c.grounded} />
</div>
{c.quote ? (
<p className="border-l-2 border-brand-pink/40 pl-3 text-[13px] italic leading-relaxed text-foreground/80">
“{c.quote}”
</p>
) : (
<p className="text-[13px] text-muted-foreground">
Cited {c.sectionIds?.length ?? 0} section(s).
</p>
)}
</div>
);
}

function AssistantBubble({ m }: { m: AssistantMessage }) {
const empty = !m.answer && m.steps.length === 0 && m.status === "streaming";
return (
<div className="flex gap-3">
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
<Sparkles className="h-3.5 w-3.5 text-brand-blue" />
</span>
<div className="min-w-0 flex-1 space-y-3">
{empty && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Reading the document…
</div>
)}

{m.answer && <Markdown content={m.answer} />}

{m.status === "error" && (
<div className="flex items-start gap-2 rounded-[14px] border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{m.error ?? "Something went wrong."}</span>
</div>
)}

{m.citations.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
<Quote className="h-3 w-3" />
{m.citations.length} citation
{m.citations.length === 1 ? "" : "s"}
</div>
{m.citations.map((c) => (
<CitationItem key={c.index} c={c} />
))}
</div>
)}
</div>
</div>
);
}

function Bubble({ m }: { m: Message }) {
if (m.role === "user") {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-[18px] rounded-br-md bg-foreground px-4 py-2.5 text-sm text-white">
{m.content}
</div>
</div>
);
}
return <AssistantBubble m={m} />;
}

export function ChatThread({ messages }: { messages: Message[] }) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);

if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-[16px] bg-brand-blue/10">
<Sparkles className="h-5 w-5 text-brand-blue" />
</span>
<h2 className="text-lg font-medium text-foreground">
Ask the document anything
</h2>
<p className="max-w-sm text-sm text-muted-foreground">
Vectorless navigates the document&apos;s structure and answers with
grounded citations — and you watch every step it takes on the right.
</p>
</div>
);
}

return (
<div className={cn("flex-1 space-y-6 overflow-y-auto px-1 py-4")}>
{messages.map((m, i) => (
<Bubble key={i} m={m} />
))}
<div ref={endRef} />
</div>
);
}
Loading
Loading