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 */} +
+
+