Skip to content

Commit f89d610

Browse files
authored
Playground: Collection mode — ask across a whole store (#16)
Adds a Document/Collection toggle to the playground. In Collection mode a single question is answered across every ready document in the active store (the demo corpus or the caller's selected store) and rendered as one synthesized answer with citations grouped by document. - New API route /api/dashboard/playground/store-answer resolves the store to its ready document_ids, calls the engine POST /v1/answer/store, and maps each citation document_id back to its title for display. - CollectionAsk component: composer + synthesized answer (Markdown) + per-document citation groups with page ranges and verbatim quotes, plus a "synthesized from N of M documents" provenance line. - DocumentPicker gains hideDocSelect (single-doc dropdown hidden in collection mode; the Demo/Mine source switch stays). The single-document streaming tree-walk playground is unchanged.
1 parent 1b9733a commit f89d610

4 files changed

Lines changed: 391 additions & 0 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { Send, Square, RotateCcw } from "lucide-react";
77
import { DocumentPicker } from "@/components/playground/DocumentPicker";
88
import { ChatThread } from "@/components/playground/ChatThread";
99
import { RetrievalInspector } from "@/components/playground/RetrievalInspector";
10+
import { CollectionAsk } from "@/components/playground/CollectionAsk";
11+
import { FileText, Library } from "lucide-react";
12+
import { cn } from "@/lib/utils";
1013
import {
1114
useTreewalkStream,
1215
type AssistantMessage,
@@ -20,6 +23,7 @@ export default function PlaygroundPage() {
2023
const [source, setSource] = useState<"demo" | "mine">("demo");
2124
const [docId, setDocId] = useState("");
2225
const [input, setInput] = useState("");
26+
const [mode, setMode] = useState<"document" | "collection">("document");
2327

2428
const { messages, isStreaming, send, stop, reset } = useTreewalkStream();
2529

@@ -82,6 +86,29 @@ export default function PlaygroundPage() {
8286
</h1>
8387
</div>
8488
<div className="flex items-center gap-2">
89+
{/* Mode: single document vs whole collection */}
90+
<div className="flex items-center rounded-full border border-border-light bg-muted/40 p-0.5">
91+
{(
92+
[
93+
{ m: "document", label: "Document", Icon: FileText },
94+
{ m: "collection", label: "Collection", Icon: Library },
95+
] as const
96+
).map(({ m, label, Icon }) => (
97+
<button
98+
key={m}
99+
onClick={() => setMode(m)}
100+
className={cn(
101+
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12.5px] font-medium transition-colors",
102+
mode === m
103+
? "bg-background text-foreground shadow-sm"
104+
: "text-muted-foreground hover:text-foreground",
105+
)}
106+
>
107+
<Icon className="h-3.5 w-3.5" />
108+
{label}
109+
</button>
110+
))}
111+
</div>
85112
<DocumentPicker
86113
demo={demo}
87114
mine={mine}
@@ -90,6 +117,7 @@ export default function PlaygroundPage() {
90117
docId={docId}
91118
onDocChange={setDocId}
92119
loading={loadingDocs}
120+
hideDocSelect={mode === "collection"}
93121
/>
94122
{messages.length > 0 && (
95123
<Button
@@ -106,7 +134,16 @@ export default function PlaygroundPage() {
106134
</div>
107135
</div>
108136

137+
{/* Collection mode: one synthesized answer across the whole store */}
138+
{mode === "collection" && (
139+
<CollectionAsk
140+
source={source}
141+
docCount={(source === "demo" ? demo : mine).length}
142+
/>
143+
)}
144+
109145
{/* Split: chat (left) + inspector (right) */}
146+
{mode === "document" && (
110147
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-5">
111148
{/* Chat column */}
112149
<div className="flex min-h-0 flex-col rounded-[20px] border border-border-light bg-card shadow-[rgba(0,0,0,0.04)_0px_2px_8px] lg:col-span-3">
@@ -164,6 +201,7 @@ export default function PlaygroundPage() {
164201
<RetrievalInspector message={activeAssistant} />
165202
</div>
166203
</div>
204+
)}
167205
</div>
168206
);
169207
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { NextRequest, NextResponse } 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 StoreAnswerBody {
10+
source?: "demo" | "mine";
11+
query?: string;
12+
}
13+
14+
interface DocLite {
15+
doc_id: string;
16+
title: string;
17+
status: string;
18+
}
19+
20+
function readyDocs(data: unknown): DocLite[] {
21+
const arr =
22+
(data as { documents?: unknown[] } | null)?.documents ??
23+
(Array.isArray(data) ? (data as unknown[]) : []);
24+
return (arr as Record<string, unknown>[])
25+
.map((d) => ({
26+
doc_id: String(d.doc_id ?? d.document_id ?? d.id ?? ""),
27+
title: String(d.title ?? d.filename ?? "Untitled"),
28+
status: String(d.status ?? "unknown"),
29+
}))
30+
.filter((d) => d.doc_id && d.status === "ready");
31+
}
32+
33+
/**
34+
* POST /api/dashboard/playground/store-answer
35+
* Ask one question across an entire collection (store) and return a
36+
* single synthesised answer with per-document citations.
37+
*
38+
* source "demo" → the shared demo store (internal key); "mine" → the
39+
* caller's selected store (session + store cookie). We resolve the
40+
* store's ready document_ids, hand them to the engine's /v1/answer/store,
41+
* then map each citation's document_id back to its title for display.
42+
*/
43+
export async function POST(request: NextRequest) {
44+
let body: StoreAnswerBody;
45+
try {
46+
body = (await request.json()) as StoreAnswerBody;
47+
} catch {
48+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
49+
}
50+
const query = (body.query ?? "").trim();
51+
if (!query) {
52+
return NextResponse.json({ error: "query is required" }, { status: 400 });
53+
}
54+
const useDemo = body.source !== "mine";
55+
const auth = useDemo && DEMO_API_KEY ? { apiKey: DEMO_API_KEY } : {};
56+
57+
// 1. Resolve the collection's ready documents.
58+
const list = await forwardToCP("/v1/documents?limit=100", auth);
59+
if (!list.ok) {
60+
return NextResponse.json(
61+
list.data ?? { error: "Failed to list collection" },
62+
{ status: list.status },
63+
);
64+
}
65+
const docs = readyDocs(list.data);
66+
if (docs.length === 0) {
67+
return NextResponse.json(
68+
{ error: "This collection has no ready documents yet." },
69+
{ status: 400 },
70+
);
71+
}
72+
73+
// 2. Ask across the whole collection.
74+
const ans = await forwardToCP("/v1/answer/store", {
75+
method: "POST",
76+
body: { document_ids: docs.map((d) => d.doc_id), query },
77+
...auth,
78+
});
79+
if (!ans.ok) {
80+
return NextResponse.json(
81+
ans.data ?? { error: "Store answer failed" },
82+
{ status: ans.status },
83+
);
84+
}
85+
86+
// 3. Map citation document_id → title for display.
87+
const titleById = Object.fromEntries(docs.map((d) => [d.doc_id, d.title]));
88+
const data = (ans.data ?? {}) as {
89+
citations?: { document_id?: string; document_title?: string }[];
90+
};
91+
if (Array.isArray(data.citations)) {
92+
data.citations = data.citations.map((c) => ({
93+
...c,
94+
document_title:
95+
c.document_title || titleById[c.document_id ?? ""] || c.document_id,
96+
}));
97+
}
98+
99+
return NextResponse.json({ ...data, collection_size: docs.length });
100+
}

0 commit comments

Comments
 (0)