From 58f5fa730950b8dc9dba9efebb2ccae18107e81a Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Sat, 4 Jul 2026 04:19:28 +0100 Subject: [PATCH] =?UTF-8?q?Playground:=20Collection=20mode=20=E2=80=94=20a?= =?UTF-8?q?sk=20across=20a=20whole=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../(dashboard)/dashboard/playground/page.tsx | 38 +++ .../playground/store-answer/route.ts | 100 +++++++ .../components/playground/CollectionAsk.tsx | 249 ++++++++++++++++++ .../components/playground/DocumentPicker.tsx | 4 + 4 files changed, 391 insertions(+) create mode 100644 apps/web/app/api/dashboard/playground/store-answer/route.ts create mode 100644 apps/web/components/playground/CollectionAsk.tsx 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 }) => ( + + ))} +
{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) && ( +
+
+ {asked} +
+
+ )} + + {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 */} +
+
+