diff --git a/apps/web/app/(dashboard)/dashboard/playground/page.tsx b/apps/web/app/(dashboard)/dashboard/playground/page.tsx
index 36424b7..8048735 100644
--- a/apps/web/app/(dashboard)/dashboard/playground/page.tsx
+++ b/apps/web/app/(dashboard)/dashboard/playground/page.tsx
@@ -7,6 +7,9 @@ import { Send, Square, RotateCcw } from "lucide-react";
import { DocumentPicker } from "@/components/playground/DocumentPicker";
import { ChatThread } from "@/components/playground/ChatThread";
import { RetrievalInspector } from "@/components/playground/RetrievalInspector";
+import { CollectionAsk } from "@/components/playground/CollectionAsk";
+import { FileText, Library } from "lucide-react";
+import { cn } from "@/lib/utils";
import {
useTreewalkStream,
type AssistantMessage,
@@ -20,6 +23,7 @@ export default function PlaygroundPage() {
const [source, setSource] = useState<"demo" | "mine">("demo");
const [docId, setDocId] = useState("");
const [input, setInput] = useState("");
+ const [mode, setMode] = useState<"document" | "collection">("document");
const { messages, isStreaming, send, stop, reset } = useTreewalkStream();
@@ -82,6 +86,29 @@ export default function PlaygroundPage() {
+ {/* Mode: single document vs whole collection */}
+
+ {(
+ [
+ { m: "document", label: "Document", Icon: FileText },
+ { m: "collection", label: "Collection", Icon: Library },
+ ] as const
+ ).map(({ m, label, Icon }) => (
+ setMode(m)}
+ className={cn(
+ "flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12.5px] font-medium transition-colors",
+ mode === m
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+
+ {label}
+
+ ))}
+
{messages.length > 0 && (
+ {/* Collection mode: one synthesized answer across the whole store */}
+ {mode === "collection" && (
+
+ )}
+
{/* Split: chat (left) + inspector (right) */}
+ {mode === "document" && (
{/* Chat column */}
@@ -164,6 +201,7 @@ export default function PlaygroundPage() {
+ )}
);
}
diff --git a/apps/web/app/api/dashboard/playground/store-answer/route.ts b/apps/web/app/api/dashboard/playground/store-answer/route.ts
new file mode 100644
index 0000000..d500211
--- /dev/null
+++ b/apps/web/app/api/dashboard/playground/store-answer/route.ts
@@ -0,0 +1,100 @@
+import { NextRequest, NextResponse } 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 StoreAnswerBody {
+ source?: "demo" | "mine";
+ query?: string;
+}
+
+interface DocLite {
+ doc_id: string;
+ title: string;
+ status: string;
+}
+
+function readyDocs(data: unknown): DocLite[] {
+ const arr =
+ (data as { documents?: unknown[] } | null)?.documents ??
+ (Array.isArray(data) ? (data as unknown[]) : []);
+ return (arr as Record[])
+ .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"),
+ }))
+ .filter((d) => d.doc_id && d.status === "ready");
+}
+
+/**
+ * POST /api/dashboard/playground/store-answer
+ * Ask one question across an entire collection (store) and return a
+ * single synthesised answer with per-document citations.
+ *
+ * source "demo" → the shared demo store (internal key); "mine" → the
+ * caller's selected store (session + store cookie). We resolve the
+ * store's ready document_ids, hand them to the engine's /v1/answer/store,
+ * then map each citation's document_id back to its title for display.
+ */
+export async function POST(request: NextRequest) {
+ let body: StoreAnswerBody;
+ try {
+ body = (await request.json()) as StoreAnswerBody;
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+ const query = (body.query ?? "").trim();
+ if (!query) {
+ return NextResponse.json({ error: "query is required" }, { status: 400 });
+ }
+ const useDemo = body.source !== "mine";
+ const auth = useDemo && DEMO_API_KEY ? { apiKey: DEMO_API_KEY } : {};
+
+ // 1. Resolve the collection's ready documents.
+ const list = await forwardToCP("/v1/documents?limit=100", auth);
+ if (!list.ok) {
+ return NextResponse.json(
+ list.data ?? { error: "Failed to list collection" },
+ { status: list.status },
+ );
+ }
+ const docs = readyDocs(list.data);
+ if (docs.length === 0) {
+ return NextResponse.json(
+ { error: "This collection has no ready documents yet." },
+ { status: 400 },
+ );
+ }
+
+ // 2. Ask across the whole collection.
+ const ans = await forwardToCP("/v1/answer/store", {
+ method: "POST",
+ body: { document_ids: docs.map((d) => d.doc_id), query },
+ ...auth,
+ });
+ if (!ans.ok) {
+ return NextResponse.json(
+ ans.data ?? { error: "Store answer failed" },
+ { status: ans.status },
+ );
+ }
+
+ // 3. Map citation document_id → title for display.
+ const titleById = Object.fromEntries(docs.map((d) => [d.doc_id, d.title]));
+ const data = (ans.data ?? {}) as {
+ citations?: { document_id?: string; document_title?: string }[];
+ };
+ if (Array.isArray(data.citations)) {
+ data.citations = data.citations.map((c) => ({
+ ...c,
+ document_title:
+ c.document_title || titleById[c.document_id ?? ""] || c.document_id,
+ }));
+ }
+
+ return NextResponse.json({ ...data, collection_size: docs.length });
+}
diff --git a/apps/web/components/playground/CollectionAsk.tsx b/apps/web/components/playground/CollectionAsk.tsx
new file mode 100644
index 0000000..f6225ae
--- /dev/null
+++ b/apps/web/components/playground/CollectionAsk.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { useState } from "react";
+import { Markdown } from "@/components/ui/markdown";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Send,
+ Loader2,
+ Library,
+ Quote,
+ FileText,
+ Sparkles,
+} from "lucide-react";
+
+interface Citation {
+ index: number;
+ document_id: string;
+ document_title?: string;
+ start_page?: number;
+ end_page?: number;
+ section_ids?: string[];
+ quote?: string;
+}
+
+interface StoreAnswer {
+ answer: string;
+ citations: Citation[];
+ documents_searched: number;
+ documents_with_matches: number;
+ collection_size?: number;
+ elapsed_ms?: number;
+}
+
+function pageLabel(c: Citation): string | null {
+ if (c.start_page == null || c.start_page <= 0) return null;
+ if (c.end_page == null || c.end_page === c.start_page) return `p.${c.start_page}`;
+ return `pp.${c.start_page}–${c.end_page}`;
+}
+
+/** Group citations by document, preserving first-cited order. */
+function groupByDoc(cites: Citation[]) {
+ const order: string[] = [];
+ const map = new Map();
+ for (const c of cites) {
+ const key = c.document_id;
+ if (!map.has(key)) {
+ map.set(key, { title: c.document_title || c.document_id, items: [] });
+ order.push(key);
+ }
+ map.get(key)!.items.push(c);
+ }
+ return order.map((k) => ({ id: k, ...map.get(k)! }));
+}
+
+export function CollectionAsk({
+ source,
+ docCount,
+}: {
+ source: "demo" | "mine";
+ docCount: number;
+}) {
+ const [q, setQ] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [res, setRes] = useState(null);
+ const [asked, setAsked] = useState("");
+ const [err, setErr] = useState(null);
+
+ const ask = async () => {
+ if (!q.trim() || loading) return;
+ setLoading(true);
+ setErr(null);
+ setRes(null);
+ setAsked(q.trim());
+ try {
+ const r = await fetch("/api/dashboard/playground/store-answer", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ source, query: q.trim() }),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d?.error ?? "Failed to answer");
+ setRes(d as StoreAnswer);
+ setQ("");
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "Failed to answer");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const grouped = res ? groupByDoc(res.citations ?? []) : [];
+
+ return (
+
+ {/* Scrollable answer area */}
+
+ {!res && !loading && !err && (
+
+
+
+
+
+ Ask the whole collection
+
+
+ Vectorless reads across{" "}
+
+ {docCount} document{docCount === 1 ? "" : "s"}
+ {" "}
+ and synthesizes one grounded answer — with a citation for every
+ document it drew on.
+
+
+ )}
+
+ {asked && (res || loading) && (
+
+ )}
+
+ {loading && (
+
+
+ Reading across the collection…
+
+ )}
+
+ {err && (
+
+ {err}
+
+ )}
+
+ {res && (
+
+ {/* Answer */}
+
+
+ {/* Provenance line */}
+
+ Synthesized from {res.documents_with_matches} of{" "}
+ {res.documents_searched} documents
+ {res.elapsed_ms ? ` · ${(res.elapsed_ms / 1000).toFixed(1)}s` : ""}
+
+
+ {/* Citations grouped by document */}
+ {grouped.length > 0 && (
+
+
+
+ Sources
+
+ {grouped.map((g) => (
+
+
+
+
+ {g.title}
+
+
+
+ {g.items.map((c) => {
+ const pages = pageLabel(c);
+ return (
+
+
+ {c.index}
+
+
+ {pages && (
+
+ {pages}
+
+ )}
+ {c.quote && (
+
+ “{c.quote}”
+
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {/* Composer */}
+
+
+ );
+}
diff --git a/apps/web/components/playground/DocumentPicker.tsx b/apps/web/components/playground/DocumentPicker.tsx
index 133f9bd..e216c13 100644
--- a/apps/web/components/playground/DocumentPicker.tsx
+++ b/apps/web/components/playground/DocumentPicker.tsx
@@ -19,6 +19,7 @@ export function DocumentPicker({
docId,
onDocChange,
loading,
+ hideDocSelect = false,
}: {
demo: PlaygroundDoc[];
mine: PlaygroundDoc[];
@@ -27,6 +28,7 @@ export function DocumentPicker({
docId: string;
onDocChange: (id: string) => void;
loading: boolean;
+ hideDocSelect?: boolean;
}) {
const docs = source === "demo" ? demo : mine;
@@ -48,6 +50,7 @@ export function DocumentPicker({
+ {!hideDocSelect && (
{loading ? (
@@ -81,6 +84,7 @@ export function DocumentPicker({
))}
+ )}
);
}