feat: streaming + multi-turn + view source + doc-scoped + UX fixes
Browse files- Streaming: SSE consumer hits /ask_smart with stream=true, tokens
fill the answer incrementally; spinner clears as soon as first
token arrives
- Multi-turn: last 5 successful Q→A pairs sent as `history` so the
model can resolve "and the third one?" / pronoun references
- View source modal: clicking a source chip opens a drawer with the
full markdown of the doc (rendered via react-markdown with the
same atelier styles)
- Doc-scoped: pinnedDocId on conversation; pinning an entry from the
Documents page opens a new scoped conversation, follow-ups go to
/ask (retrieval bypass). Composer shows a removable "📌 scoped"
chip and adapts its placeholder
- Inline citations: [1] [2] markers in the answer become clickable
badges that open the source drawer
- Reject UX: in `rejected_low_similarity` the source list is
re-labelled "Closest matches · all below similarity X" and
collapsed by default
- Layout: AppShell locked to viewport (h-screen + overflow-hidden +
min-h-0), Thread bottom-anchored to remove the empty scroll gap
- TopBar: removed Live · Xms and Saved Xs ago chrome
- app/documents/page.tsx +18 -1
- app/page.tsx +147 -35
- components/chat/AppShell.tsx +3 -3
- components/chat/Composer.tsx +56 -16
- components/chat/Message.tsx +75 -19
- components/chat/Sidebar.tsx +2 -2
- components/chat/SourceChips.tsx +170 -110
- components/chat/SourceDrawer.tsx +262 -0
- components/chat/Thread.tsx +23 -8
- components/chat/TopBar.tsx +1 -135
- lib/api.ts +74 -2
- lib/chatStore.ts +56 -1
- lib/types.ts +36 -0
|
@@ -8,18 +8,27 @@ import {
|
|
| 8 |
useQueryClient,
|
| 9 |
} from "@tanstack/react-query";
|
| 10 |
import { useMemo, useState } from "react";
|
|
|
|
|
|
|
| 11 |
import clsx from "clsx";
|
| 12 |
|
| 13 |
const PAGE_SIZE = 50;
|
| 14 |
|
| 15 |
export default function DocumentsPage() {
|
| 16 |
const qc = useQueryClient();
|
|
|
|
|
|
|
| 17 |
const [filter, setFilter] = useState("");
|
| 18 |
const [page, setPage] = useState(0);
|
| 19 |
const [confirmDelete, setConfirmDelete] = useState<DocumentMeta | null>(null);
|
| 20 |
const [reindexResult, setReindexResult] = useState<ReindexResponse | null>(null);
|
| 21 |
const [showAdd, setShowAdd] = useState(false);
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const { data, isLoading, error } = useQuery({
|
| 24 |
queryKey: ["documents"],
|
| 25 |
queryFn: () => api.listDocuments(),
|
|
@@ -193,7 +202,7 @@ export default function DocumentsPage() {
|
|
| 193 |
>
|
| 194 |
<div className="min-w-0 flex-1">
|
| 195 |
<div className="text-body-strong text-ink truncate">
|
| 196 |
-
{d.name}
|
| 197 |
</div>
|
| 198 |
<div className="flex items-center gap-2.5 mt-0.5 text-micro text-ink-50 font-mono uppercase tracking-[0.10em]">
|
| 199 |
<span className="truncate">{d.doc_id}</span>
|
|
@@ -205,6 +214,14 @@ export default function DocumentsPage() {
|
|
| 205 |
</span>
|
| 206 |
</div>
|
| 207 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
<button
|
| 209 |
onClick={() => setConfirmDelete(d)}
|
| 210 |
className="opacity-0 group-hover:opacity-100 transition-opacity text-caption text-status-err hover:bg-status-err-glow px-2.5 py-1 rounded-md"
|
|
|
|
| 8 |
useQueryClient,
|
| 9 |
} from "@tanstack/react-query";
|
| 10 |
import { useMemo, useState } from "react";
|
| 11 |
+
import { useRouter } from "next/navigation";
|
| 12 |
+
import { useChatStore } from "@/lib/chatStore";
|
| 13 |
import clsx from "clsx";
|
| 14 |
|
| 15 |
const PAGE_SIZE = 50;
|
| 16 |
|
| 17 |
export default function DocumentsPage() {
|
| 18 |
const qc = useQueryClient();
|
| 19 |
+
const router = useRouter();
|
| 20 |
+
const newConversationPinned = useChatStore((s) => s.newConversationPinned);
|
| 21 |
const [filter, setFilter] = useState("");
|
| 22 |
const [page, setPage] = useState(0);
|
| 23 |
const [confirmDelete, setConfirmDelete] = useState<DocumentMeta | null>(null);
|
| 24 |
const [reindexResult, setReindexResult] = useState<ReindexResponse | null>(null);
|
| 25 |
const [showAdd, setShowAdd] = useState(false);
|
| 26 |
|
| 27 |
+
const handleAskAbout = (d: DocumentMeta) => {
|
| 28 |
+
newConversationPinned(d.doc_id, d.name);
|
| 29 |
+
router.push("/");
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
const { data, isLoading, error } = useQuery({
|
| 33 |
queryKey: ["documents"],
|
| 34 |
queryFn: () => api.listDocuments(),
|
|
|
|
| 202 |
>
|
| 203 |
<div className="min-w-0 flex-1">
|
| 204 |
<div className="text-body-strong text-ink truncate">
|
| 205 |
+
{d.name.replace(/_\d{6,}$/, "")}
|
| 206 |
</div>
|
| 207 |
<div className="flex items-center gap-2.5 mt-0.5 text-micro text-ink-50 font-mono uppercase tracking-[0.10em]">
|
| 208 |
<span className="truncate">{d.doc_id}</span>
|
|
|
|
| 214 |
</span>
|
| 215 |
</div>
|
| 216 |
</div>
|
| 217 |
+
<button
|
| 218 |
+
onClick={() => handleAskAbout(d)}
|
| 219 |
+
className="hidden sm:inline-flex text-caption text-amber hover:bg-amber/10 px-3 py-1.5 rounded-pill transition-colors"
|
| 220 |
+
style={{ boxShadow: "0 0 0 1px rgba(255,181,69,0.32)" }}
|
| 221 |
+
title="Open a new conversation scoped to this doc"
|
| 222 |
+
>
|
| 223 |
+
Ask about this →
|
| 224 |
+
</button>
|
| 225 |
<button
|
| 226 |
onClick={() => setConfirmDelete(d)}
|
| 227 |
className="opacity-0 group-hover:opacity-100 transition-opacity text-caption text-status-err hover:bg-status-err-glow px-2.5 py-1 rounded-md"
|
|
@@ -1,18 +1,22 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useRef, useState } from "react";
|
| 4 |
-
import { useMutation } from "@tanstack/react-query";
|
| 5 |
import { Composer, type ComposerHandle } from "@/components/chat/Composer";
|
| 6 |
import { Thread } from "@/components/chat/Thread";
|
| 7 |
import { ChatEmpty } from "@/components/chat/Empty";
|
| 8 |
import { SettingsDrawer } from "@/components/chat/SettingsDrawer";
|
|
|
|
| 9 |
import { api, ApiError } from "@/lib/api";
|
| 10 |
-
import { useChatStore } from "@/lib/chatStore";
|
| 11 |
import { useDocumentTitle, useHotkeys } from "@/lib/hooks";
|
| 12 |
-
import type { AskSmartResponse } from "@/lib/types";
|
| 13 |
|
| 14 |
export default function HomePage() {
|
| 15 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const composerRef = useRef<ComposerHandle | null>(null);
|
| 17 |
const abortRef = useRef<AbortController | null>(null);
|
| 18 |
|
|
@@ -25,37 +29,11 @@ export default function HomePage() {
|
|
| 25 |
const conv = useChatStore((s) =>
|
| 26 |
s.activeId ? s.conversations[s.activeId] : null
|
| 27 |
);
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
useDocumentTitle(
|
| 31 |
-
conv && conv.turns.length > 0 ? conv.title : null
|
| 32 |
-
);
|
| 33 |
|
| 34 |
-
const
|
| 35 |
-
AskSmartResponse,
|
| 36 |
-
unknown,
|
| 37 |
-
{ id: string; question: string; signal: AbortSignal }
|
| 38 |
-
>({
|
| 39 |
-
mutationFn: ({ question, signal }) =>
|
| 40 |
-
api.askSmart({ ...settings, question }, signal),
|
| 41 |
-
onSuccess: (resp, vars) =>
|
| 42 |
-
patchTurn(vars.id, { response: resp, pending: false }),
|
| 43 |
-
onError: (err, vars) => {
|
| 44 |
-
// If we explicitly aborted, surface a soft refusal instead of a server error
|
| 45 |
-
if ((err as { name?: string })?.name === "AbortError") {
|
| 46 |
-
patchTurn(vars.id, {
|
| 47 |
-
error: { status: 0, message: "Request stopped." },
|
| 48 |
-
pending: false,
|
| 49 |
-
});
|
| 50 |
-
return;
|
| 51 |
-
}
|
| 52 |
-
const status = err instanceof ApiError ? err.status : 0;
|
| 53 |
-
const message = err instanceof Error ? err.message : String(err);
|
| 54 |
-
patchTurn(vars.id, { error: { status, message }, pending: false });
|
| 55 |
-
},
|
| 56 |
-
});
|
| 57 |
-
|
| 58 |
-
const handleAsk = (q: string) => {
|
| 59 |
const id =
|
| 60 |
typeof crypto !== "undefined" && crypto.randomUUID
|
| 61 |
? crypto.randomUUID()
|
|
@@ -68,12 +46,132 @@ export default function HomePage() {
|
|
| 68 |
});
|
| 69 |
const ctrl = new AbortController();
|
| 70 |
abortRef.current = ctrl;
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
};
|
| 73 |
|
| 74 |
const handleStop = () => {
|
| 75 |
abortRef.current?.abort();
|
| 76 |
abortRef.current = null;
|
|
|
|
| 77 |
};
|
| 78 |
|
| 79 |
const handleClear = () => {
|
|
@@ -81,6 +179,10 @@ export default function HomePage() {
|
|
| 81 |
if (confirm("Clear this conversation?")) clearActive();
|
| 82 |
};
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
// Global keyboard shortcuts
|
| 85 |
useHotkeys({
|
| 86 |
"mod+k": (e) => {
|
|
@@ -101,13 +203,17 @@ export default function HomePage() {
|
|
| 101 |
|
| 102 |
return (
|
| 103 |
<div className="flex-1 flex flex-col min-h-0 relative">
|
| 104 |
-
{isEmpty ?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
<Composer
|
| 107 |
ref={composerRef}
|
| 108 |
onSubmit={handleAsk}
|
| 109 |
onStop={handleStop}
|
| 110 |
-
pending={
|
| 111 |
onOpenSettings={() => setSettingsOpen(true)}
|
| 112 |
onClear={handleClear}
|
| 113 |
/>
|
|
@@ -116,6 +222,12 @@ export default function HomePage() {
|
|
| 116 |
open={settingsOpen}
|
| 117 |
onClose={() => setSettingsOpen(false)}
|
| 118 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
</div>
|
| 120 |
);
|
| 121 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useRef, useState } from "react";
|
|
|
|
| 4 |
import { Composer, type ComposerHandle } from "@/components/chat/Composer";
|
| 5 |
import { Thread } from "@/components/chat/Thread";
|
| 6 |
import { ChatEmpty } from "@/components/chat/Empty";
|
| 7 |
import { SettingsDrawer } from "@/components/chat/SettingsDrawer";
|
| 8 |
+
import { SourceDrawer } from "@/components/chat/SourceDrawer";
|
| 9 |
import { api, ApiError } from "@/lib/api";
|
| 10 |
+
import { buildHistory, useChatStore } from "@/lib/chatStore";
|
| 11 |
import { useDocumentTitle, useHotkeys } from "@/lib/hooks";
|
| 12 |
+
import type { AskSmartResponse, SourceDoc } from "@/lib/types";
|
| 13 |
|
| 14 |
export default function HomePage() {
|
| 15 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 16 |
+
const [sourceDrawer, setSourceDrawer] = useState<{
|
| 17 |
+
doc_id: string;
|
| 18 |
+
doc_name?: string | null;
|
| 19 |
+
} | null>(null);
|
| 20 |
const composerRef = useRef<ComposerHandle | null>(null);
|
| 21 |
const abortRef = useRef<AbortController | null>(null);
|
| 22 |
|
|
|
|
| 29 |
const conv = useChatStore((s) =>
|
| 30 |
s.activeId ? s.conversations[s.activeId] : null
|
| 31 |
);
|
| 32 |
+
const [pending, setPending] = useState(false);
|
| 33 |
|
| 34 |
+
useDocumentTitle(conv && conv.turns.length > 0 ? conv.title : null);
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
const handleAsk = async (q: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const id =
|
| 38 |
typeof crypto !== "undefined" && crypto.randomUUID
|
| 39 |
? crypto.randomUUID()
|
|
|
|
| 46 |
});
|
| 47 |
const ctrl = new AbortController();
|
| 48 |
abortRef.current = ctrl;
|
| 49 |
+
setPending(true);
|
| 50 |
+
|
| 51 |
+
// Pinned-doc path: classic /ask, single-shot (retrieval bypass).
|
| 52 |
+
// The backend /ask doesn't return source_docs, so we synthesize one from
|
| 53 |
+
// the pinned doc to keep the UI consistent.
|
| 54 |
+
const pinnedDocId = conv?.pinnedDocId ?? null;
|
| 55 |
+
const pinnedDocName = conv?.pinnedDocName ?? null;
|
| 56 |
+
|
| 57 |
+
try {
|
| 58 |
+
if (pinnedDocId) {
|
| 59 |
+
const res = await api.ask(
|
| 60 |
+
{
|
| 61 |
+
doc_id: pinnedDocId,
|
| 62 |
+
question: q,
|
| 63 |
+
scaler: settings.scaler,
|
| 64 |
+
bias_scaler: settings.bias_scaler,
|
| 65 |
+
max_new_tokens: settings.max_new_tokens,
|
| 66 |
+
},
|
| 67 |
+
ctrl.signal
|
| 68 |
+
);
|
| 69 |
+
const synthetic: AskSmartResponse = {
|
| 70 |
+
answer: res.answer,
|
| 71 |
+
source_docs: [
|
| 72 |
+
{
|
| 73 |
+
doc_id: pinnedDocId,
|
| 74 |
+
name: pinnedDocName ?? pinnedDocId,
|
| 75 |
+
similarity: 1.0,
|
| 76 |
+
},
|
| 77 |
+
],
|
| 78 |
+
_grounding_status: "answered",
|
| 79 |
+
_top_similarity: 1.0,
|
| 80 |
+
_threshold: 0,
|
| 81 |
+
retrieve_seconds: 0,
|
| 82 |
+
inference_seconds: res.elapsed_seconds,
|
| 83 |
+
total_seconds: res.elapsed_seconds,
|
| 84 |
+
};
|
| 85 |
+
patchTurn(id, { response: synthetic, pending: false });
|
| 86 |
+
} else {
|
| 87 |
+
// Streaming path via SSE — incrementally fill in the response.
|
| 88 |
+
const acc: AskSmartResponse = {
|
| 89 |
+
answer: "",
|
| 90 |
+
source_docs: [],
|
| 91 |
+
_grounding_status: "answered",
|
| 92 |
+
_top_similarity: 0,
|
| 93 |
+
_threshold: settings.similarity_threshold,
|
| 94 |
+
retrieve_seconds: 0,
|
| 95 |
+
inference_seconds: 0,
|
| 96 |
+
total_seconds: 0,
|
| 97 |
+
};
|
| 98 |
+
// First, expose an empty response object so streamed tokens have a
|
| 99 |
+
// place to land (and the spinner can hide as soon as first token arrives).
|
| 100 |
+
patchTurn(id, { response: { ...acc }, pending: true });
|
| 101 |
+
|
| 102 |
+
await api.askSmartStream(
|
| 103 |
+
{
|
| 104 |
+
...settings,
|
| 105 |
+
question: q,
|
| 106 |
+
history: buildHistory(conv),
|
| 107 |
+
},
|
| 108 |
+
(evt) => {
|
| 109 |
+
if (evt.event === "status") {
|
| 110 |
+
// We currently don't surface status text; could add later.
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
if (evt.event === "sources") {
|
| 114 |
+
acc.source_docs = evt.data.source_docs ?? [];
|
| 115 |
+
if (typeof evt.data._top_similarity === "number")
|
| 116 |
+
acc._top_similarity = evt.data._top_similarity;
|
| 117 |
+
if (typeof evt.data._top_rerank_score === "number")
|
| 118 |
+
acc._top_rerank_score = evt.data._top_rerank_score;
|
| 119 |
+
if (typeof evt.data._anchor_score === "number")
|
| 120 |
+
acc._anchor_score = evt.data._anchor_score;
|
| 121 |
+
if (typeof evt.data._corpus_relevance === "number")
|
| 122 |
+
acc._corpus_relevance = evt.data._corpus_relevance;
|
| 123 |
+
if (typeof evt.data._threshold === "number")
|
| 124 |
+
acc._threshold = evt.data._threshold;
|
| 125 |
+
patchTurn(id, { response: { ...acc } });
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
if (evt.event === "token") {
|
| 129 |
+
acc.answer += evt.data.text;
|
| 130 |
+
patchTurn(id, { response: { ...acc } });
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
if (evt.event === "rejected") {
|
| 134 |
+
patchTurn(id, { response: evt.data, pending: false });
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
if (evt.event === "done") {
|
| 138 |
+
patchTurn(id, { response: evt.data, pending: false });
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
if (evt.event === "error") {
|
| 142 |
+
patchTurn(id, {
|
| 143 |
+
error: { status: evt.data.status, message: evt.data.message },
|
| 144 |
+
pending: false,
|
| 145 |
+
});
|
| 146 |
+
return;
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
ctrl.signal
|
| 150 |
+
);
|
| 151 |
+
// If stream closed without explicit done/error, mark not pending.
|
| 152 |
+
patchTurn(id, { pending: false });
|
| 153 |
+
}
|
| 154 |
+
} catch (err: unknown) {
|
| 155 |
+
if ((err as { name?: string })?.name === "AbortError") {
|
| 156 |
+
patchTurn(id, {
|
| 157 |
+
error: { status: 0, message: "Request stopped." },
|
| 158 |
+
pending: false,
|
| 159 |
+
});
|
| 160 |
+
} else {
|
| 161 |
+
const status = err instanceof ApiError ? err.status : 0;
|
| 162 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 163 |
+
patchTurn(id, { error: { status, message }, pending: false });
|
| 164 |
+
}
|
| 165 |
+
} finally {
|
| 166 |
+
abortRef.current = null;
|
| 167 |
+
setPending(false);
|
| 168 |
+
}
|
| 169 |
};
|
| 170 |
|
| 171 |
const handleStop = () => {
|
| 172 |
abortRef.current?.abort();
|
| 173 |
abortRef.current = null;
|
| 174 |
+
setPending(false);
|
| 175 |
};
|
| 176 |
|
| 177 |
const handleClear = () => {
|
|
|
|
| 179 |
if (confirm("Clear this conversation?")) clearActive();
|
| 180 |
};
|
| 181 |
|
| 182 |
+
const openSource = (doc: SourceDoc) => {
|
| 183 |
+
setSourceDrawer({ doc_id: doc.doc_id, doc_name: doc.name });
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
// Global keyboard shortcuts
|
| 187 |
useHotkeys({
|
| 188 |
"mod+k": (e) => {
|
|
|
|
| 203 |
|
| 204 |
return (
|
| 205 |
<div className="flex-1 flex flex-col min-h-0 relative">
|
| 206 |
+
{isEmpty ? (
|
| 207 |
+
<ChatEmpty onAsk={handleAsk} />
|
| 208 |
+
) : (
|
| 209 |
+
<Thread onOpenSource={openSource} />
|
| 210 |
+
)}
|
| 211 |
|
| 212 |
<Composer
|
| 213 |
ref={composerRef}
|
| 214 |
onSubmit={handleAsk}
|
| 215 |
onStop={handleStop}
|
| 216 |
+
pending={pending}
|
| 217 |
onOpenSettings={() => setSettingsOpen(true)}
|
| 218 |
onClear={handleClear}
|
| 219 |
/>
|
|
|
|
| 222 |
open={settingsOpen}
|
| 223 |
onClose={() => setSettingsOpen(false)}
|
| 224 |
/>
|
| 225 |
+
<SourceDrawer
|
| 226 |
+
open={!!sourceDrawer}
|
| 227 |
+
docId={sourceDrawer?.doc_id ?? null}
|
| 228 |
+
docName={sourceDrawer?.doc_name ?? null}
|
| 229 |
+
onClose={() => setSourceDrawer(null)}
|
| 230 |
+
/>
|
| 231 |
</div>
|
| 232 |
);
|
| 233 |
}
|
|
@@ -32,17 +32,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|
| 32 |
}, [pathname, hydrated, conversations, activeId, newConversation]);
|
| 33 |
|
| 34 |
return (
|
| 35 |
-
<div className="relative
|
| 36 |
{/* Atmospheric background — fixed, behind everything */}
|
| 37 |
<Aurora />
|
| 38 |
|
| 39 |
<TopBar />
|
| 40 |
|
| 41 |
-
<div className="relative z-10 flex-1 flex overflow-hidden">
|
| 42 |
<Sidebar />
|
| 43 |
<main
|
| 44 |
className={clsx(
|
| 45 |
-
"flex-1 flex flex-col min-w-0 transition-[margin] duration-300 ease-atelier",
|
| 46 |
sidebarOpen ? "md:ml-0" : "md:ml-0"
|
| 47 |
)}
|
| 48 |
>
|
|
|
|
| 32 |
}, [pathname, hydrated, conversations, activeId, newConversation]);
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div className="relative h-screen flex flex-col overflow-hidden">
|
| 36 |
{/* Atmospheric background — fixed, behind everything */}
|
| 37 |
<Aurora />
|
| 38 |
|
| 39 |
<TopBar />
|
| 40 |
|
| 41 |
+
<div className="relative z-10 flex-1 flex overflow-hidden min-h-0">
|
| 42 |
<Sidebar />
|
| 43 |
<main
|
| 44 |
className={clsx(
|
| 45 |
+
"flex-1 flex flex-col min-w-0 min-h-0 transition-[margin] duration-300 ease-atelier",
|
| 46 |
sidebarOpen ? "md:ml-0" : "md:ml-0"
|
| 47 |
)}
|
| 48 |
>
|
|
@@ -24,6 +24,10 @@ export const Composer = forwardRef<
|
|
| 24 |
const [value, setValue] = useState("");
|
| 25 |
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
| 26 |
const settings = useChatStore((s) => s.settings);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
useImperativeHandle(ref, () => ({
|
| 29 |
focus: () => taRef.current?.focus(),
|
|
@@ -56,21 +60,53 @@ export const Composer = forwardRef<
|
|
| 56 |
return (
|
| 57 |
<div className="sticky bottom-0 z-20 px-4 sm:px-6 pt-4 pb-5 bg-gradient-to-t from-canvas-deep via-canvas-deep/85 to-transparent">
|
| 58 |
<div className="max-w-[920px] mx-auto">
|
| 59 |
-
{/*
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
<div
|
| 76 |
className="relative rounded-2xl glass-strong overflow-hidden transition-all duration-200"
|
|
@@ -83,7 +119,11 @@ export const Composer = forwardRef<
|
|
| 83 |
onKeyDown={onKey}
|
| 84 |
disabled={pending}
|
| 85 |
rows={1}
|
| 86 |
-
placeholder=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
className="w-full bg-transparent outline-none resize-none px-3 py-2.5 text-body text-ink placeholder:text-ink-30 leading-relaxed scrollbar-none disabled:opacity-60"
|
| 88 |
style={{ maxHeight: 200 }}
|
| 89 |
aria-label="Question"
|
|
|
|
| 24 |
const [value, setValue] = useState("");
|
| 25 |
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
| 26 |
const settings = useChatStore((s) => s.settings);
|
| 27 |
+
const conv = useChatStore((s) => (s.activeId ? s.conversations[s.activeId] : null));
|
| 28 |
+
const pinDoc = useChatStore((s) => s.pinDoc);
|
| 29 |
+
const pinnedDocId = conv?.pinnedDocId ?? null;
|
| 30 |
+
const pinnedDocName = conv?.pinnedDocName ?? null;
|
| 31 |
|
| 32 |
useImperativeHandle(ref, () => ({
|
| 33 |
focus: () => taRef.current?.focus(),
|
|
|
|
| 60 |
return (
|
| 61 |
<div className="sticky bottom-0 z-20 px-4 sm:px-6 pt-4 pb-5 bg-gradient-to-t from-canvas-deep via-canvas-deep/85 to-transparent">
|
| 62 |
<div className="max-w-[920px] mx-auto">
|
| 63 |
+
{/* Active chips: scope (pinned doc) + non-default settings */}
|
| 64 |
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
| 65 |
+
{pinnedDocId && (
|
| 66 |
+
<span
|
| 67 |
+
className="inline-flex items-center gap-1.5 pl-3 pr-1.5 py-1 rounded-pill text-micro font-mono text-amber animate-fade-in"
|
| 68 |
+
style={{
|
| 69 |
+
background: "rgba(255,181,69,0.10)",
|
| 70 |
+
boxShadow: "0 0 0 1px rgba(255,181,69,0.42)",
|
| 71 |
+
}}
|
| 72 |
+
title={`Scoped to: ${pinnedDocName ?? pinnedDocId}`}
|
| 73 |
+
>
|
| 74 |
+
<span>📌 scoped:</span>
|
| 75 |
+
<span className="text-ink truncate max-w-[280px] normal-case">
|
| 76 |
+
{(pinnedDocName ?? pinnedDocId).replace(/_\d{6,}$/, "")}
|
| 77 |
+
</span>
|
| 78 |
+
<button
|
| 79 |
+
onClick={() => conv && pinDoc(conv.id, null, null)}
|
| 80 |
+
className="ml-1 inline-flex items-center justify-center w-5 h-5 rounded-full text-amber hover:bg-amber hover:text-canvas transition-colors"
|
| 81 |
+
aria-label="Unpin"
|
| 82 |
+
title="Unpin — return to RAG retrieval"
|
| 83 |
+
>
|
| 84 |
+
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
|
| 85 |
+
<path
|
| 86 |
+
d="m2 2 5 5M7 2l-5 5"
|
| 87 |
+
stroke="currentColor"
|
| 88 |
+
strokeWidth="1.4"
|
| 89 |
+
strokeLinecap="round"
|
| 90 |
+
/>
|
| 91 |
+
</svg>
|
| 92 |
+
</button>
|
| 93 |
+
</span>
|
| 94 |
+
)}
|
| 95 |
+
{diff.length > 0 && (
|
| 96 |
+
<button
|
| 97 |
+
onClick={onOpenSettings}
|
| 98 |
+
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-pill text-micro font-mono text-amber hover:bg-amber/10 transition-colors animate-fade-in"
|
| 99 |
+
style={{ boxShadow: "0 0 0 1px rgba(255,181,69,0.32)" }}
|
| 100 |
+
title="Tuned settings — click to edit"
|
| 101 |
+
>
|
| 102 |
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
| 103 |
+
<circle cx="5" cy="5" r="1.4" fill="currentColor" />
|
| 104 |
+
<circle cx="5" cy="5" r="3.4" stroke="currentColor" strokeWidth="0.9" />
|
| 105 |
+
</svg>
|
| 106 |
+
<span>{diff.join(" · ")}</span>
|
| 107 |
+
</button>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
|
| 111 |
<div
|
| 112 |
className="relative rounded-2xl glass-strong overflow-hidden transition-all duration-200"
|
|
|
|
| 119 |
onKeyDown={onKey}
|
| 120 |
disabled={pending}
|
| 121 |
rows={1}
|
| 122 |
+
placeholder={
|
| 123 |
+
pinnedDocId
|
| 124 |
+
? `Ask about ${(pinnedDocName ?? "this doc").replace(/_\d{6,}$/, "")}…`
|
| 125 |
+
: "Ask anything about Etiya BSS…"
|
| 126 |
+
}
|
| 127 |
className="w-full bg-transparent outline-none resize-none px-3 py-2.5 text-body text-ink placeholder:text-ink-30 leading-relaxed scrollbar-none disabled:opacity-60"
|
| 128 |
style={{ maxHeight: 200 }}
|
| 129 |
aria-label="Question"
|
|
@@ -1,9 +1,10 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import type { ChatTurn } from "@/lib/chatStore";
|
|
|
|
| 4 |
import { GroundingPill } from "./GroundingPill";
|
| 5 |
import { SourceChips } from "./SourceChips";
|
| 6 |
-
import { useState } from "react";
|
| 7 |
import clsx from "clsx";
|
| 8 |
import ReactMarkdown from "react-markdown";
|
| 9 |
|
|
@@ -17,10 +18,16 @@ export function UserMessage({ text }: { text: string }) {
|
|
| 17 |
);
|
| 18 |
}
|
| 19 |
|
| 20 |
-
export function AssistantMessage({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const r = turn.response;
|
| 22 |
-
if (!r) return null;
|
| 23 |
const [copied, setCopied] = useState(false);
|
|
|
|
| 24 |
|
| 25 |
const copy = async () => {
|
| 26 |
try {
|
|
@@ -30,6 +37,54 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 30 |
} catch {}
|
| 31 |
};
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return (
|
| 34 |
<div className="flex items-start gap-3 animate-fade-up">
|
| 35 |
<Avatar />
|
|
@@ -46,15 +101,15 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 46 |
{copied ? (
|
| 47 |
<>
|
| 48 |
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
| 49 |
-
<path d="M2 6 4.5 8.5 9 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
| 50 |
</svg>
|
| 51 |
copied
|
| 52 |
</>
|
| 53 |
) : (
|
| 54 |
<>
|
| 55 |
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
| 56 |
-
<rect x="2" y="2" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2"/>
|
| 57 |
-
<rect x="3.5" y="3.5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2"/>
|
| 58 |
</svg>
|
| 59 |
copy
|
| 60 |
</>
|
|
@@ -68,7 +123,7 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 68 |
<ReactMarkdown
|
| 69 |
components={{
|
| 70 |
p: ({ children }) => (
|
| 71 |
-
<p className="mb-3 last:mb-0">{children}</p>
|
| 72 |
),
|
| 73 |
ul: ({ children }) => (
|
| 74 |
<ul className="list-disc pl-5 mb-3 last:mb-0 space-y-1 marker:text-amber/70">
|
|
@@ -80,7 +135,7 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 80 |
{children}
|
| 81 |
</ol>
|
| 82 |
),
|
| 83 |
-
li: ({ children }) => <li className="pl-1">{children}</li>,
|
| 84 |
strong: ({ children }) => (
|
| 85 |
<strong className="font-semibold text-ink">{children}</strong>
|
| 86 |
),
|
|
@@ -110,14 +165,10 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 110 |
<h3 className="text-display-sm mt-4 mb-2">{children}</h3>
|
| 111 |
),
|
| 112 |
h2: ({ children }) => (
|
| 113 |
-
<h3 className="text-body-strong text-ink mt-3 mb-1.5">
|
| 114 |
-
{children}
|
| 115 |
-
</h3>
|
| 116 |
),
|
| 117 |
h3: ({ children }) => (
|
| 118 |
-
<h4 className="text-body-strong text-ink mt-3 mb-1.5">
|
| 119 |
-
{children}
|
| 120 |
-
</h4>
|
| 121 |
),
|
| 122 |
a: ({ href, children }) => (
|
| 123 |
<a
|
|
@@ -137,9 +188,7 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 137 |
{children}
|
| 138 |
</blockquote>
|
| 139 |
),
|
| 140 |
-
hr: () =>
|
| 141 |
-
<hr className="my-4 border-0 h-px bg-glass-border" />
|
| 142 |
-
),
|
| 143 |
}}
|
| 144 |
>
|
| 145 |
{r.answer}
|
|
@@ -147,9 +196,16 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
|
|
| 147 |
</div>
|
| 148 |
|
| 149 |
{/* Sources */}
|
| 150 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
{/* Spec sheet — collapsed by default
|
| 153 |
<SpecSheet response={r} />
|
| 154 |
</article>
|
| 155 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import type { ChatTurn } from "@/lib/chatStore";
|
| 4 |
+
import type { SourceDoc } from "@/lib/types";
|
| 5 |
import { GroundingPill } from "./GroundingPill";
|
| 6 |
import { SourceChips } from "./SourceChips";
|
| 7 |
+
import { useState, type ReactNode } from "react";
|
| 8 |
import clsx from "clsx";
|
| 9 |
import ReactMarkdown from "react-markdown";
|
| 10 |
|
|
|
|
| 18 |
);
|
| 19 |
}
|
| 20 |
|
| 21 |
+
export function AssistantMessage({
|
| 22 |
+
turn,
|
| 23 |
+
onOpenSource,
|
| 24 |
+
}: {
|
| 25 |
+
turn: ChatTurn;
|
| 26 |
+
onOpenSource?: (doc: SourceDoc, index: number) => void;
|
| 27 |
+
}) {
|
| 28 |
const r = turn.response;
|
|
|
|
| 29 |
const [copied, setCopied] = useState(false);
|
| 30 |
+
if (!r) return null;
|
| 31 |
|
| 32 |
const copy = async () => {
|
| 33 |
try {
|
|
|
|
| 37 |
} catch {}
|
| 38 |
};
|
| 39 |
|
| 40 |
+
const sources = r.source_docs ?? [];
|
| 41 |
+
const rejected = r._grounding_status === "rejected_low_similarity";
|
| 42 |
+
|
| 43 |
+
// Inline citation renderer: replaces [1] [2] markers with clickable badges.
|
| 44 |
+
// Numbers are 1-indexed against `sources` (clamped to length).
|
| 45 |
+
const renderWithCitations = (text: string): ReactNode[] => {
|
| 46 |
+
if (!sources.length || !text) return [text];
|
| 47 |
+
const out: ReactNode[] = [];
|
| 48 |
+
const re = /\[(\d{1,3})\]/g;
|
| 49 |
+
let last = 0;
|
| 50 |
+
let m: RegExpExecArray | null;
|
| 51 |
+
let key = 0;
|
| 52 |
+
while ((m = re.exec(text)) !== null) {
|
| 53 |
+
const idx = parseInt(m[1], 10);
|
| 54 |
+
if (idx >= 1 && idx <= sources.length) {
|
| 55 |
+
if (m.index > last) out.push(text.slice(last, m.index));
|
| 56 |
+
const doc = sources[idx - 1];
|
| 57 |
+
out.push(
|
| 58 |
+
<button
|
| 59 |
+
key={`cite-${key++}`}
|
| 60 |
+
onClick={() => onOpenSource?.(doc, idx - 1)}
|
| 61 |
+
className="inline-flex items-center justify-center align-baseline mx-0.5 px-1.5 min-w-[18px] h-[18px] rounded-full bg-amber/15 text-amber text-[11px] font-mono font-semibold hover:bg-amber hover:text-canvas transition-colors"
|
| 62 |
+
title={`View source: ${doc.name}`}
|
| 63 |
+
>
|
| 64 |
+
{idx}
|
| 65 |
+
</button>
|
| 66 |
+
);
|
| 67 |
+
last = m.index + m[0].length;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
if (last < text.length) out.push(text.slice(last));
|
| 71 |
+
return out.length ? out : [text];
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
// Wrap a markdown children array, expanding string nodes through citation regex.
|
| 75 |
+
const transformChildren = (children: ReactNode): ReactNode => {
|
| 76 |
+
if (typeof children === "string") return renderWithCitations(children);
|
| 77 |
+
if (Array.isArray(children))
|
| 78 |
+
return children.map((c, i) =>
|
| 79 |
+
typeof c === "string" ? (
|
| 80 |
+
<span key={i}>{renderWithCitations(c)}</span>
|
| 81 |
+
) : (
|
| 82 |
+
c
|
| 83 |
+
)
|
| 84 |
+
);
|
| 85 |
+
return children;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
return (
|
| 89 |
<div className="flex items-start gap-3 animate-fade-up">
|
| 90 |
<Avatar />
|
|
|
|
| 101 |
{copied ? (
|
| 102 |
<>
|
| 103 |
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
| 104 |
+
<path d="M2 6 4.5 8.5 9 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
| 105 |
</svg>
|
| 106 |
copied
|
| 107 |
</>
|
| 108 |
) : (
|
| 109 |
<>
|
| 110 |
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
| 111 |
+
<rect x="2" y="2" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2" />
|
| 112 |
+
<rect x="3.5" y="3.5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2" />
|
| 113 |
</svg>
|
| 114 |
copy
|
| 115 |
</>
|
|
|
|
| 123 |
<ReactMarkdown
|
| 124 |
components={{
|
| 125 |
p: ({ children }) => (
|
| 126 |
+
<p className="mb-3 last:mb-0">{transformChildren(children)}</p>
|
| 127 |
),
|
| 128 |
ul: ({ children }) => (
|
| 129 |
<ul className="list-disc pl-5 mb-3 last:mb-0 space-y-1 marker:text-amber/70">
|
|
|
|
| 135 |
{children}
|
| 136 |
</ol>
|
| 137 |
),
|
| 138 |
+
li: ({ children }) => <li className="pl-1">{transformChildren(children)}</li>,
|
| 139 |
strong: ({ children }) => (
|
| 140 |
<strong className="font-semibold text-ink">{children}</strong>
|
| 141 |
),
|
|
|
|
| 165 |
<h3 className="text-display-sm mt-4 mb-2">{children}</h3>
|
| 166 |
),
|
| 167 |
h2: ({ children }) => (
|
| 168 |
+
<h3 className="text-body-strong text-ink mt-3 mb-1.5">{children}</h3>
|
|
|
|
|
|
|
| 169 |
),
|
| 170 |
h3: ({ children }) => (
|
| 171 |
+
<h4 className="text-body-strong text-ink mt-3 mb-1.5">{children}</h4>
|
|
|
|
|
|
|
| 172 |
),
|
| 173 |
a: ({ href, children }) => (
|
| 174 |
<a
|
|
|
|
| 188 |
{children}
|
| 189 |
</blockquote>
|
| 190 |
),
|
| 191 |
+
hr: () => <hr className="my-4 border-0 h-px bg-glass-border" />,
|
|
|
|
|
|
|
| 192 |
}}
|
| 193 |
>
|
| 194 |
{r.answer}
|
|
|
|
| 196 |
</div>
|
| 197 |
|
| 198 |
{/* Sources */}
|
| 199 |
+
{sources.length > 0 && (
|
| 200 |
+
<SourceChips
|
| 201 |
+
docs={sources}
|
| 202 |
+
onOpenSource={onOpenSource}
|
| 203 |
+
rejected={rejected}
|
| 204 |
+
threshold={r._threshold}
|
| 205 |
+
/>
|
| 206 |
+
)}
|
| 207 |
|
| 208 |
+
{/* Spec sheet — collapsed by default */}
|
| 209 |
<SpecSheet response={r} />
|
| 210 |
</article>
|
| 211 |
</div>
|
|
@@ -58,8 +58,8 @@ export function Sidebar() {
|
|
| 58 |
"fixed top-14 bottom-0 left-0 w-[280px]",
|
| 59 |
"transition-transform duration-300 ease-atelier",
|
| 60 |
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
| 61 |
-
// Desktop: in flow,
|
| 62 |
-
"md:
|
| 63 |
"md:translate-x-0 md:transition-[width,opacity] md:duration-300",
|
| 64 |
sidebarOpen
|
| 65 |
? "md:w-[280px] md:opacity-100"
|
|
|
|
| 58 |
"fixed top-14 bottom-0 left-0 w-[280px]",
|
| 59 |
"transition-transform duration-300 ease-atelier",
|
| 60 |
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
| 61 |
+
// Desktop: in flow, fills row (which is locked to viewport - topbar)
|
| 62 |
+
"md:relative md:top-auto md:bottom-auto md:h-full md:self-stretch",
|
| 63 |
"md:translate-x-0 md:transition-[width,opacity] md:duration-300",
|
| 64 |
sidebarOpen
|
| 65 |
? "md:w-[280px] md:opacity-100"
|
|
@@ -4,75 +4,186 @@ import type { SourceDoc } from "@/lib/types";
|
|
| 4 |
import { useState } from "react";
|
| 5 |
import clsx from "clsx";
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
if (!docs?.length) return null;
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
return (
|
| 12 |
<div className="mt-4 flex flex-col gap-2">
|
| 13 |
-
<
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
>
|
| 48 |
-
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
| 49 |
-
<path
|
| 50 |
-
d="m3 4 2.5 2.5L8 4"
|
| 51 |
-
stroke="currentColor"
|
| 52 |
-
strokeWidth="1.4"
|
| 53 |
-
strokeLinecap="round"
|
| 54 |
-
/>
|
| 55 |
-
</svg>
|
| 56 |
-
</span>
|
| 57 |
-
</button>
|
| 58 |
-
{isOpen && (
|
| 59 |
<div
|
| 60 |
-
className="
|
| 61 |
-
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.
|
| 62 |
>
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
);
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
function Metric({ label, value }: { label: string; value: string }) {
|
| 77 |
return (
|
| 78 |
<div>
|
|
@@ -89,58 +200,7 @@ function fmt(n: number | undefined | null, digits = 3): string {
|
|
| 89 |
return n.toFixed(digits);
|
| 90 |
}
|
| 91 |
|
| 92 |
-
/**
|
| 93 |
-
* Strip the trailing `_<hash>` that the backend appends when files share a name.
|
| 94 |
-
* Display the human-readable title; the full hash is preserved in doc_id below.
|
| 95 |
-
*/
|
| 96 |
function cleanDocName(name: string): string {
|
| 97 |
return name.replace(/_\d{6,}$/, "");
|
| 98 |
}
|
| 99 |
-
|
| 100 |
-
function SimilarityRing({ value }: { value: number }) {
|
| 101 |
-
const v = Math.max(0, Math.min(1, value || 0));
|
| 102 |
-
const tone =
|
| 103 |
-
v >= 0.7
|
| 104 |
-
? "rgb(93, 214, 168)"
|
| 105 |
-
: v >= 0.45
|
| 106 |
-
? "rgb(255, 181, 69)"
|
| 107 |
-
: "rgba(245, 243, 236, 0.4)";
|
| 108 |
-
const r = 9;
|
| 109 |
-
const C = 2 * Math.PI * r;
|
| 110 |
-
const dash = C * v;
|
| 111 |
-
return (
|
| 112 |
-
<svg width="22" height="22" viewBox="0 0 22 22" className="shrink-0">
|
| 113 |
-
<circle
|
| 114 |
-
cx="11"
|
| 115 |
-
cy="11"
|
| 116 |
-
r={r}
|
| 117 |
-
stroke="rgba(255,255,255,0.10)"
|
| 118 |
-
strokeWidth="2"
|
| 119 |
-
fill="none"
|
| 120 |
-
/>
|
| 121 |
-
<circle
|
| 122 |
-
cx="11"
|
| 123 |
-
cy="11"
|
| 124 |
-
r={r}
|
| 125 |
-
stroke={tone}
|
| 126 |
-
strokeWidth="2"
|
| 127 |
-
fill="none"
|
| 128 |
-
strokeLinecap="round"
|
| 129 |
-
strokeDasharray={`${dash} ${C - dash}`}
|
| 130 |
-
transform="rotate(-90 11 11)"
|
| 131 |
-
style={{ transition: "stroke-dasharray 0.4s cubic-bezier(0.16,1,0.3,1)" }}
|
| 132 |
-
/>
|
| 133 |
-
<text
|
| 134 |
-
x="11"
|
| 135 |
-
y="13.5"
|
| 136 |
-
textAnchor="middle"
|
| 137 |
-
fontSize="7"
|
| 138 |
-
fill="rgba(245, 243, 236, 0.85)"
|
| 139 |
-
fontFamily="var(--font-mono), monospace"
|
| 140 |
-
fontWeight="600"
|
| 141 |
-
>
|
| 142 |
-
{Math.round(v * 100)}
|
| 143 |
-
</text>
|
| 144 |
-
</svg>
|
| 145 |
-
);
|
| 146 |
-
}
|
|
|
|
| 4 |
import { useState } from "react";
|
| 5 |
import clsx from "clsx";
|
| 6 |
|
| 7 |
+
/**
|
| 8 |
+
* Click a chip → opens View source drawer (via `onOpenSource`).
|
| 9 |
+
* In rejected state, the heading switches to "Closest matches (below threshold)"
|
| 10 |
+
* and the list collapses by default.
|
| 11 |
+
*/
|
| 12 |
+
export function SourceChips({
|
| 13 |
+
docs,
|
| 14 |
+
onOpenSource,
|
| 15 |
+
rejected = false,
|
| 16 |
+
threshold,
|
| 17 |
+
}: {
|
| 18 |
+
docs: SourceDoc[];
|
| 19 |
+
onOpenSource?: (doc: SourceDoc, index: number) => void;
|
| 20 |
+
rejected?: boolean;
|
| 21 |
+
threshold?: number;
|
| 22 |
+
}) {
|
| 23 |
+
const [showDetails, setShowDetails] = useState<string | null>(null);
|
| 24 |
+
const [open, setOpen] = useState(!rejected); // collapsed by default in reject path
|
| 25 |
if (!docs?.length) return null;
|
| 26 |
|
| 27 |
+
const heading = rejected ? "Closest matches" : "Sources";
|
| 28 |
+
const tail = rejected
|
| 29 |
+
? threshold !== undefined
|
| 30 |
+
? ` · all below similarity ${threshold.toFixed(2)}`
|
| 31 |
+
: " · below threshold"
|
| 32 |
+
: "";
|
| 33 |
+
|
| 34 |
return (
|
| 35 |
<div className="mt-4 flex flex-col gap-2">
|
| 36 |
+
<button
|
| 37 |
+
onClick={() => setOpen((o) => !o)}
|
| 38 |
+
className="w-full flex items-center justify-between text-micro uppercase tracking-[0.12em] text-ink-50 font-mono hover:text-ink transition-colors"
|
| 39 |
+
aria-expanded={open}
|
| 40 |
+
>
|
| 41 |
+
<span>
|
| 42 |
+
{heading} · {docs.length}
|
| 43 |
+
<span className="text-ink-30">{tail}</span>
|
| 44 |
+
</span>
|
| 45 |
+
<span
|
| 46 |
+
className={clsx(
|
| 47 |
+
"transition-transform inline-block",
|
| 48 |
+
open && "rotate-180"
|
| 49 |
+
)}
|
| 50 |
+
aria-hidden
|
| 51 |
+
>
|
| 52 |
+
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
|
| 53 |
+
<path
|
| 54 |
+
d="m2 3 2.5 2.5L7 3"
|
| 55 |
+
stroke="currentColor"
|
| 56 |
+
strokeWidth="1.4"
|
| 57 |
+
strokeLinecap="round"
|
| 58 |
+
/>
|
| 59 |
+
</svg>
|
| 60 |
+
</span>
|
| 61 |
+
</button>
|
| 62 |
+
|
| 63 |
+
{open && (
|
| 64 |
+
<ul className="flex flex-col gap-1.5 animate-fade-in">
|
| 65 |
+
{docs.map((d, i) => {
|
| 66 |
+
const showsScores = showDetails === d.doc_id;
|
| 67 |
+
const sim = d.similarity ?? d.dense_similarity ?? 0;
|
| 68 |
+
return (
|
| 69 |
+
<li key={d.doc_id} className="flex flex-col gap-1.5">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
<div
|
| 71 |
+
className="rounded-md transition-all flex items-stretch overflow-hidden"
|
| 72 |
+
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.07)" }}
|
| 73 |
>
|
| 74 |
+
<button
|
| 75 |
+
onClick={() => onOpenSource?.(d, i)}
|
| 76 |
+
className="flex items-center gap-3 flex-1 min-w-0 px-3 py-2 text-left bg-glass hover:bg-glass-stronger transition-colors"
|
| 77 |
+
title="View source"
|
| 78 |
+
>
|
| 79 |
+
<CitationBadge index={i + 1} sim={sim} />
|
| 80 |
+
<div className="min-w-0 flex-1">
|
| 81 |
+
<div className="text-caption-strong text-ink truncate">
|
| 82 |
+
{cleanDocName(d.name)}
|
| 83 |
+
</div>
|
| 84 |
+
<div className="text-micro font-mono text-ink-50 truncate">
|
| 85 |
+
{d.doc_id}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</button>
|
| 89 |
+
<button
|
| 90 |
+
onClick={() =>
|
| 91 |
+
setShowDetails(showsScores ? null : d.doc_id)
|
| 92 |
+
}
|
| 93 |
+
className={clsx(
|
| 94 |
+
"px-2.5 transition-colors border-l",
|
| 95 |
+
"border-glass-border",
|
| 96 |
+
showsScores
|
| 97 |
+
? "bg-glass-stronger text-ink"
|
| 98 |
+
: "bg-glass text-ink-50 hover:text-ink hover:bg-glass-stronger"
|
| 99 |
+
)}
|
| 100 |
+
title={showsScores ? "Hide scores" : "Show scores"}
|
| 101 |
+
aria-label="Toggle scores"
|
| 102 |
+
>
|
| 103 |
+
<span
|
| 104 |
+
className={clsx(
|
| 105 |
+
"inline-block transition-transform",
|
| 106 |
+
showsScores && "rotate-180"
|
| 107 |
+
)}
|
| 108 |
+
aria-hidden
|
| 109 |
+
>
|
| 110 |
+
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
|
| 111 |
+
<path
|
| 112 |
+
d="m3 4 2.5 2.5L8 4"
|
| 113 |
+
stroke="currentColor"
|
| 114 |
+
strokeWidth="1.4"
|
| 115 |
+
strokeLinecap="round"
|
| 116 |
+
/>
|
| 117 |
+
</svg>
|
| 118 |
+
</span>
|
| 119 |
+
</button>
|
| 120 |
</div>
|
| 121 |
+
{showsScores && (
|
| 122 |
+
<div
|
| 123 |
+
className="px-3 py-3 rounded-md bg-canvas-deep/70 grid grid-cols-3 gap-3 animate-fade-in"
|
| 124 |
+
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.06)" }}
|
| 125 |
+
>
|
| 126 |
+
<Metric label="Cosine" value={fmt(d.similarity)} />
|
| 127 |
+
<Metric label="Dense" value={fmt(d.dense_similarity)} />
|
| 128 |
+
<Metric label="Rerank" value={fmt(d.rerank_score, 2)} />
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
</li>
|
| 132 |
+
);
|
| 133 |
+
})}
|
| 134 |
+
</ul>
|
| 135 |
+
)}
|
| 136 |
</div>
|
| 137 |
);
|
| 138 |
}
|
| 139 |
|
| 140 |
+
function CitationBadge({
|
| 141 |
+
index,
|
| 142 |
+
sim,
|
| 143 |
+
}: {
|
| 144 |
+
index: number;
|
| 145 |
+
sim: number;
|
| 146 |
+
}) {
|
| 147 |
+
const v = Math.max(0, Math.min(1, sim || 0));
|
| 148 |
+
const tone =
|
| 149 |
+
v >= 0.7 ? "rgb(93, 214, 168)" : v >= 0.45 ? "rgb(255, 181, 69)" : "rgba(245, 243, 236, 0.4)";
|
| 150 |
+
const r = 9;
|
| 151 |
+
const C = 2 * Math.PI * r;
|
| 152 |
+
const dash = C * v;
|
| 153 |
+
return (
|
| 154 |
+
<span className="relative shrink-0 inline-flex items-center justify-center">
|
| 155 |
+
<svg width="22" height="22" viewBox="0 0 22 22">
|
| 156 |
+
<circle
|
| 157 |
+
cx="11"
|
| 158 |
+
cy="11"
|
| 159 |
+
r={r}
|
| 160 |
+
stroke="rgba(255,255,255,0.10)"
|
| 161 |
+
strokeWidth="2"
|
| 162 |
+
fill="none"
|
| 163 |
+
/>
|
| 164 |
+
<circle
|
| 165 |
+
cx="11"
|
| 166 |
+
cy="11"
|
| 167 |
+
r={r}
|
| 168 |
+
stroke={tone}
|
| 169 |
+
strokeWidth="2"
|
| 170 |
+
fill="none"
|
| 171 |
+
strokeLinecap="round"
|
| 172 |
+
strokeDasharray={`${dash} ${C - dash}`}
|
| 173 |
+
transform="rotate(-90 11 11)"
|
| 174 |
+
style={{ transition: "stroke-dasharray 0.4s cubic-bezier(0.16,1,0.3,1)" }}
|
| 175 |
+
/>
|
| 176 |
+
</svg>
|
| 177 |
+
<span
|
| 178 |
+
className="absolute inset-0 flex items-center justify-center text-[10px] font-mono font-semibold text-ink"
|
| 179 |
+
aria-label={`Source ${index}`}
|
| 180 |
+
>
|
| 181 |
+
{index}
|
| 182 |
+
</span>
|
| 183 |
+
</span>
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
function Metric({ label, value }: { label: string; value: string }) {
|
| 188 |
return (
|
| 189 |
<div>
|
|
|
|
| 200 |
return n.toFixed(digits);
|
| 201 |
}
|
| 202 |
|
| 203 |
+
/** Strip backend's `_<hash>` suffix on doc names. */
|
|
|
|
|
|
|
|
|
|
| 204 |
function cleanDocName(name: string): string {
|
| 205 |
return name.replace(/_\d{6,}$/, "");
|
| 206 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { api } from "@/lib/api";
|
| 4 |
+
import { useChatStore } from "@/lib/chatStore";
|
| 5 |
+
import { useQuery } from "@tanstack/react-query";
|
| 6 |
+
import clsx from "clsx";
|
| 7 |
+
import { useEffect } from "react";
|
| 8 |
+
import ReactMarkdown from "react-markdown";
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* View-source drawer. Shown when the user clicks a source chip or an inline
|
| 12 |
+
* citation. Renders the full markdown of a doc, plus the option to "Pin this
|
| 13 |
+
* source" — which scopes follow-up questions to that doc via /ask.
|
| 14 |
+
*/
|
| 15 |
+
export function SourceDrawer({
|
| 16 |
+
open,
|
| 17 |
+
docId,
|
| 18 |
+
docName,
|
| 19 |
+
onClose,
|
| 20 |
+
}: {
|
| 21 |
+
open: boolean;
|
| 22 |
+
docId: string | null;
|
| 23 |
+
docName?: string | null;
|
| 24 |
+
onClose: () => void;
|
| 25 |
+
}) {
|
| 26 |
+
const activeId = useChatStore((s) => s.activeId);
|
| 27 |
+
const conv = useChatStore((s) => (s.activeId ? s.conversations[s.activeId] : null));
|
| 28 |
+
const pinDoc = useChatStore((s) => s.pinDoc);
|
| 29 |
+
const newConversationPinned = useChatStore((s) => s.newConversationPinned);
|
| 30 |
+
|
| 31 |
+
const { data, isLoading, error } = useQuery({
|
| 32 |
+
queryKey: ["doc-content", docId],
|
| 33 |
+
queryFn: () => api.getDocumentContent(docId as string),
|
| 34 |
+
enabled: open && !!docId,
|
| 35 |
+
staleTime: 60_000,
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
// ESC to close
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
if (!open) return;
|
| 41 |
+
const onKey = (e: KeyboardEvent) => {
|
| 42 |
+
if (e.key === "Escape") onClose();
|
| 43 |
+
};
|
| 44 |
+
window.addEventListener("keydown", onKey);
|
| 45 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 46 |
+
}, [open, onClose]);
|
| 47 |
+
|
| 48 |
+
const isPinned = conv?.pinnedDocId === docId;
|
| 49 |
+
const cleanName = (n: string | null | undefined) =>
|
| 50 |
+
(n ?? "").replace(/_\d{6,}$/, "");
|
| 51 |
+
const displayName = data?.name ? cleanName(data.name) : cleanName(docName);
|
| 52 |
+
|
| 53 |
+
const handlePin = () => {
|
| 54 |
+
if (!docId || !activeId) return;
|
| 55 |
+
if (isPinned) pinDoc(activeId, null, null);
|
| 56 |
+
else pinDoc(activeId, docId, data?.name ?? docName ?? null);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const handleNewScoped = () => {
|
| 60 |
+
if (!docId) return;
|
| 61 |
+
newConversationPinned(docId, data?.name ?? docName ?? "");
|
| 62 |
+
onClose();
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<>
|
| 67 |
+
{/* Scrim */}
|
| 68 |
+
<div
|
| 69 |
+
className={clsx(
|
| 70 |
+
"fixed inset-0 z-40 bg-canvas-deep/60 backdrop-blur-sm transition-opacity duration-300",
|
| 71 |
+
open ? "opacity-100" : "opacity-0 pointer-events-none"
|
| 72 |
+
)}
|
| 73 |
+
onClick={onClose}
|
| 74 |
+
aria-hidden
|
| 75 |
+
/>
|
| 76 |
+
<aside
|
| 77 |
+
className={clsx(
|
| 78 |
+
"fixed top-0 right-0 z-50 h-screen w-full sm:w-[560px] glass-strong",
|
| 79 |
+
"flex flex-col transition-transform duration-300 ease-atelier",
|
| 80 |
+
open ? "translate-x-0" : "translate-x-full"
|
| 81 |
+
)}
|
| 82 |
+
role="dialog"
|
| 83 |
+
aria-modal="true"
|
| 84 |
+
aria-label="Source document"
|
| 85 |
+
>
|
| 86 |
+
{/* Header */}
|
| 87 |
+
<header className="px-6 py-5 border-b border-glass-border">
|
| 88 |
+
<div className="flex items-start justify-between gap-3">
|
| 89 |
+
<div className="min-w-0 flex-1">
|
| 90 |
+
<div className="text-micro uppercase tracking-[0.16em] text-ink-50 font-mono mb-1">
|
| 91 |
+
Source · view
|
| 92 |
+
</div>
|
| 93 |
+
<h2
|
| 94 |
+
className="text-display-sm text-ink truncate"
|
| 95 |
+
title={displayName ?? ""}
|
| 96 |
+
>
|
| 97 |
+
{displayName || "Loading…"}
|
| 98 |
+
</h2>
|
| 99 |
+
{docId && (
|
| 100 |
+
<div className="text-micro font-mono text-ink-50 mt-0.5 truncate">
|
| 101 |
+
{docId}
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
<button
|
| 106 |
+
onClick={onClose}
|
| 107 |
+
className="shrink-0 p-2 rounded-md text-ink-50 hover:text-ink hover:bg-glass transition-colors"
|
| 108 |
+
aria-label="Close"
|
| 109 |
+
>
|
| 110 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
| 111 |
+
<path
|
| 112 |
+
d="m3 3 8 8M11 3l-8 8"
|
| 113 |
+
stroke="currentColor"
|
| 114 |
+
strokeWidth="1.5"
|
| 115 |
+
strokeLinecap="round"
|
| 116 |
+
/>
|
| 117 |
+
</svg>
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{/* Pin / scope actions */}
|
| 122 |
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
| 123 |
+
<button
|
| 124 |
+
onClick={handlePin}
|
| 125 |
+
disabled={!activeId || !docId}
|
| 126 |
+
className={clsx(
|
| 127 |
+
"text-caption px-3 py-1.5 rounded-pill transition-colors disabled:opacity-40",
|
| 128 |
+
isPinned
|
| 129 |
+
? "bg-amber text-canvas hover:bg-amber-soft"
|
| 130 |
+
: "bg-glass text-ink hover:bg-glass-stronger"
|
| 131 |
+
)}
|
| 132 |
+
style={
|
| 133 |
+
isPinned
|
| 134 |
+
? undefined
|
| 135 |
+
: { boxShadow: "0 0 0 1px rgba(255,255,255,0.10)" }
|
| 136 |
+
}
|
| 137 |
+
title={
|
| 138 |
+
isPinned
|
| 139 |
+
? "Currently pinned — click to unpin"
|
| 140 |
+
: "Pin: future questions in this conversation will use only this doc"
|
| 141 |
+
}
|
| 142 |
+
>
|
| 143 |
+
{isPinned ? "✓ Pinned to this conversation" : "Pin to this conversation"}
|
| 144 |
+
</button>
|
| 145 |
+
<button
|
| 146 |
+
onClick={handleNewScoped}
|
| 147 |
+
disabled={!docId}
|
| 148 |
+
className="text-caption px-3 py-1.5 rounded-pill bg-glass text-ink hover:bg-glass-stronger transition-colors disabled:opacity-40"
|
| 149 |
+
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.10)" }}
|
| 150 |
+
title="Open a new conversation scoped to this doc"
|
| 151 |
+
>
|
| 152 |
+
New scoped conversation →
|
| 153 |
+
</button>
|
| 154 |
+
</div>
|
| 155 |
+
</header>
|
| 156 |
+
|
| 157 |
+
{/* Body */}
|
| 158 |
+
<div className="flex-1 overflow-y-auto px-6 py-5">
|
| 159 |
+
{isLoading && (
|
| 160 |
+
<div className="text-ink-50 text-shimmer">Loading source…</div>
|
| 161 |
+
)}
|
| 162 |
+
{error && (
|
| 163 |
+
<div
|
| 164 |
+
className="p-4 rounded-md"
|
| 165 |
+
style={{
|
| 166 |
+
background: "rgba(255, 122, 122, 0.06)",
|
| 167 |
+
boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)",
|
| 168 |
+
}}
|
| 169 |
+
>
|
| 170 |
+
<div className="text-caption-strong text-status-err">
|
| 171 |
+
Couldn't load source
|
| 172 |
+
</div>
|
| 173 |
+
<div className="text-caption text-ink-70 mt-1">
|
| 174 |
+
{error instanceof Error ? error.message : String(error)}
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
{data && (
|
| 179 |
+
<article className="markdown-body text-ink leading-[1.7]">
|
| 180 |
+
<ReactMarkdown
|
| 181 |
+
components={{
|
| 182 |
+
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
|
| 183 |
+
h1: ({ children }) => (
|
| 184 |
+
<h2 className="text-display-md text-ink mt-6 mb-3">{children}</h2>
|
| 185 |
+
),
|
| 186 |
+
h2: ({ children }) => (
|
| 187 |
+
<h3 className="text-display-sm text-ink mt-5 mb-2.5">{children}</h3>
|
| 188 |
+
),
|
| 189 |
+
h3: ({ children }) => (
|
| 190 |
+
<h4 className="text-body-strong text-ink mt-4 mb-2">{children}</h4>
|
| 191 |
+
),
|
| 192 |
+
ul: ({ children }) => (
|
| 193 |
+
<ul className="list-disc pl-5 mb-3 space-y-1 marker:text-amber/70">
|
| 194 |
+
{children}
|
| 195 |
+
</ul>
|
| 196 |
+
),
|
| 197 |
+
ol: ({ children }) => (
|
| 198 |
+
<ol className="list-decimal pl-5 mb-3 space-y-1 marker:text-amber/70 marker:font-mono">
|
| 199 |
+
{children}
|
| 200 |
+
</ol>
|
| 201 |
+
),
|
| 202 |
+
strong: ({ children }) => (
|
| 203 |
+
<strong className="font-semibold text-ink">{children}</strong>
|
| 204 |
+
),
|
| 205 |
+
em: ({ children }) => (
|
| 206 |
+
<em className="serif-italic text-amber-soft">{children}</em>
|
| 207 |
+
),
|
| 208 |
+
code: ({ children }) => (
|
| 209 |
+
<code
|
| 210 |
+
className="font-mono text-amber px-1.5 py-0.5 rounded text-[0.92em]"
|
| 211 |
+
style={{ background: "rgba(255,181,69,0.10)" }}
|
| 212 |
+
>
|
| 213 |
+
{children}
|
| 214 |
+
</code>
|
| 215 |
+
),
|
| 216 |
+
pre: ({ children }) => (
|
| 217 |
+
<pre
|
| 218 |
+
className="font-mono text-caption p-3 rounded-md overflow-x-auto my-3"
|
| 219 |
+
style={{
|
| 220 |
+
background: "rgba(0,0,0,0.28)",
|
| 221 |
+
boxShadow: "0 0 0 1px rgba(255,255,255,0.06)",
|
| 222 |
+
}}
|
| 223 |
+
>
|
| 224 |
+
{children}
|
| 225 |
+
</pre>
|
| 226 |
+
),
|
| 227 |
+
a: ({ href, children }) => (
|
| 228 |
+
<a
|
| 229 |
+
href={href}
|
| 230 |
+
className="text-amber underline decoration-amber/40 hover:decoration-amber"
|
| 231 |
+
target="_blank"
|
| 232 |
+
rel="noopener noreferrer"
|
| 233 |
+
>
|
| 234 |
+
{children}
|
| 235 |
+
</a>
|
| 236 |
+
),
|
| 237 |
+
hr: () => <hr className="my-5 border-0 h-px bg-glass-border" />,
|
| 238 |
+
blockquote: ({ children }) => (
|
| 239 |
+
<blockquote className="border-l-2 border-amber/50 pl-4 my-3 text-ink-70 serif-italic">
|
| 240 |
+
{children}
|
| 241 |
+
</blockquote>
|
| 242 |
+
),
|
| 243 |
+
}}
|
| 244 |
+
>
|
| 245 |
+
{data.text}
|
| 246 |
+
</ReactMarkdown>
|
| 247 |
+
</article>
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{data && (
|
| 252 |
+
<footer className="px-6 py-3 border-t border-glass-border text-micro font-mono uppercase tracking-[0.12em] text-ink-50">
|
| 253 |
+
{data.length_chars.toLocaleString()} chars
|
| 254 |
+
{data.created_at
|
| 255 |
+
? ` · ${new Date(data.created_at * 1000).toLocaleDateString()}`
|
| 256 |
+
: ""}
|
| 257 |
+
</footer>
|
| 258 |
+
)}
|
| 259 |
+
</aside>
|
| 260 |
+
</>
|
| 261 |
+
);
|
| 262 |
+
}
|
|
@@ -2,31 +2,46 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useRef } from "react";
|
| 4 |
import { useChatStore } from "@/lib/chatStore";
|
|
|
|
| 5 |
import { AssistantMessage, ErrorMessage, UserMessage } from "./Message";
|
| 6 |
import { Thinking } from "./Thinking";
|
| 7 |
|
| 8 |
-
export function Thread(
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const ref = useRef<HTMLDivElement | null>(null);
|
| 11 |
|
| 12 |
-
// Auto-scroll to bottom on new turns
|
|
|
|
|
|
|
| 13 |
useEffect(() => {
|
| 14 |
const el = ref.current;
|
| 15 |
if (!el) return;
|
| 16 |
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
| 17 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
if (!conv || conv.turns.length === 0) return null;
|
| 20 |
|
| 21 |
return (
|
| 22 |
-
<div ref={ref} className="flex-1 overflow-y-auto">
|
| 23 |
-
<div className="max-w-[920px] mx-auto px-4 sm:px-6 py-8 space-y-6">
|
| 24 |
{conv.turns.map((t) => (
|
| 25 |
<div key={t.id} className="space-y-4">
|
| 26 |
<UserMessage text={t.question} />
|
| 27 |
-
{t.pending && <Thinking />}
|
| 28 |
{t.error && <ErrorMessage turn={t} />}
|
| 29 |
-
{t.response &&
|
|
|
|
|
|
|
| 30 |
</div>
|
| 31 |
))}
|
| 32 |
</div>
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useRef } from "react";
|
| 4 |
import { useChatStore } from "@/lib/chatStore";
|
| 5 |
+
import type { SourceDoc } from "@/lib/types";
|
| 6 |
import { AssistantMessage, ErrorMessage, UserMessage } from "./Message";
|
| 7 |
import { Thinking } from "./Thinking";
|
| 8 |
|
| 9 |
+
export function Thread({
|
| 10 |
+
onOpenSource,
|
| 11 |
+
}: {
|
| 12 |
+
onOpenSource?: (doc: SourceDoc, index: number) => void;
|
| 13 |
+
}) {
|
| 14 |
+
const conv = useChatStore((s) =>
|
| 15 |
+
s.activeId ? s.conversations[s.activeId] : null
|
| 16 |
+
);
|
| 17 |
const ref = useRef<HTMLDivElement | null>(null);
|
| 18 |
|
| 19 |
+
// Auto-scroll to bottom on new turns / when streaming tokens arrive
|
| 20 |
+
const turns = conv?.turns ?? [];
|
| 21 |
+
const lastTurn = turns[turns.length - 1];
|
| 22 |
useEffect(() => {
|
| 23 |
const el = ref.current;
|
| 24 |
if (!el) return;
|
| 25 |
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
| 26 |
+
}, [
|
| 27 |
+
turns.length,
|
| 28 |
+
lastTurn?.pending,
|
| 29 |
+
lastTurn?.response?.answer?.length,
|
| 30 |
+
]);
|
| 31 |
|
| 32 |
if (!conv || conv.turns.length === 0) return null;
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div ref={ref} className="flex-1 overflow-y-auto min-h-0">
|
| 36 |
+
<div className="min-h-full flex flex-col justify-end max-w-[920px] mx-auto px-4 sm:px-6 py-8 space-y-6">
|
| 37 |
{conv.turns.map((t) => (
|
| 38 |
<div key={t.id} className="space-y-4">
|
| 39 |
<UserMessage text={t.question} />
|
| 40 |
+
{t.pending && !t.response?.answer && <Thinking />}
|
| 41 |
{t.error && <ErrorMessage turn={t} />}
|
| 42 |
+
{t.response && (
|
| 43 |
+
<AssistantMessage turn={t} onOpenSource={onOpenSource} />
|
| 44 |
+
)}
|
| 45 |
</div>
|
| 46 |
))}
|
| 47 |
</div>
|
|
@@ -1,51 +1,15 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
-
import { useEffect, useState } from "react";
|
| 5 |
-
import { api } from "@/lib/api";
|
| 6 |
import { useChatStore } from "@/lib/chatStore";
|
| 7 |
-
import { relativeTime, useTick } from "@/lib/hooks";
|
| 8 |
-
import clsx from "clsx";
|
| 9 |
|
| 10 |
export function TopBar() {
|
| 11 |
-
const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
|
| 12 |
-
const [pingMs, setPingMs] = useState<number | null>(null);
|
| 13 |
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
|
| 14 |
-
const syncStatus = useChatStore((s) => s.syncStatus);
|
| 15 |
-
const hydrated = useChatStore((s) => s.hydrated);
|
| 16 |
-
const lastSavedAt = useChatStore((s) => s.lastSavedAt);
|
| 17 |
-
useTick(15_000);
|
| 18 |
-
|
| 19 |
-
useEffect(() => {
|
| 20 |
-
let alive = true;
|
| 21 |
-
const tick = async () => {
|
| 22 |
-
const t0 = performance.now();
|
| 23 |
-
try {
|
| 24 |
-
const r = await api.ping();
|
| 25 |
-
const dt = performance.now() - t0;
|
| 26 |
-
if (alive) {
|
| 27 |
-
setPulse(r.status === "alive" ? "alive" : "down");
|
| 28 |
-
setPingMs(dt);
|
| 29 |
-
}
|
| 30 |
-
} catch {
|
| 31 |
-
if (alive) {
|
| 32 |
-
setPulse("down");
|
| 33 |
-
setPingMs(null);
|
| 34 |
-
}
|
| 35 |
-
}
|
| 36 |
-
};
|
| 37 |
-
tick();
|
| 38 |
-
const id = setInterval(tick, 30_000);
|
| 39 |
-
return () => {
|
| 40 |
-
alive = false;
|
| 41 |
-
clearInterval(id);
|
| 42 |
-
};
|
| 43 |
-
}, []);
|
| 44 |
|
| 45 |
return (
|
| 46 |
<header className="sticky top-0 z-30 h-14 flex items-center backdrop-blur-nav bg-canvas-deep/60 border-b border-glass-border">
|
| 47 |
<div className="container-app flex items-center justify-between w-full">
|
| 48 |
-
{/* Left: brand + sidebar toggle
|
| 49 |
<div className="flex items-center gap-3">
|
| 50 |
<button
|
| 51 |
onClick={toggleSidebar}
|
|
@@ -75,109 +39,11 @@ export function TopBar() {
|
|
| 75 |
</div>
|
| 76 |
</Link>
|
| 77 |
</div>
|
| 78 |
-
|
| 79 |
-
{/* Right: sync + status pulse */}
|
| 80 |
-
<div className="flex items-center gap-2">
|
| 81 |
-
<SyncBadge
|
| 82 |
-
status={syncStatus}
|
| 83 |
-
hydrated={hydrated}
|
| 84 |
-
lastSavedAt={lastSavedAt}
|
| 85 |
-
/>
|
| 86 |
-
<span
|
| 87 |
-
className="hidden sm:flex items-center gap-2 rounded-pill px-3 py-1.5 bg-glass"
|
| 88 |
-
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
|
| 89 |
-
aria-live="polite"
|
| 90 |
-
>
|
| 91 |
-
<span
|
| 92 |
-
className={clsx(
|
| 93 |
-
"inline-block w-1.5 h-1.5 rounded-full transition-colors",
|
| 94 |
-
pulse === "alive"
|
| 95 |
-
? "bg-status-ok shadow-[0_0_8px_rgba(93,214,168,0.8)] animate-pulse-soft"
|
| 96 |
-
: pulse === "down"
|
| 97 |
-
? "bg-status-err"
|
| 98 |
-
: "bg-ink-50"
|
| 99 |
-
)}
|
| 100 |
-
/>
|
| 101 |
-
<span className="text-micro text-ink-70 font-mono uppercase tracking-[0.15em]">
|
| 102 |
-
{pulse === "alive"
|
| 103 |
-
? `live · ${pingMs?.toFixed(0) ?? "—"}ms`
|
| 104 |
-
: pulse === "down"
|
| 105 |
-
? "down"
|
| 106 |
-
: "···"}
|
| 107 |
-
</span>
|
| 108 |
-
</span>
|
| 109 |
-
</div>
|
| 110 |
</div>
|
| 111 |
</header>
|
| 112 |
);
|
| 113 |
}
|
| 114 |
|
| 115 |
-
function SyncBadge({
|
| 116 |
-
status,
|
| 117 |
-
hydrated,
|
| 118 |
-
lastSavedAt,
|
| 119 |
-
}: {
|
| 120 |
-
status: "idle" | "saving" | "error";
|
| 121 |
-
hydrated: boolean;
|
| 122 |
-
lastSavedAt: number;
|
| 123 |
-
}) {
|
| 124 |
-
// Compose label/dot based on state. Idle + recent save = "saved Xs ago"
|
| 125 |
-
// for ~30s, then collapse to invisible.
|
| 126 |
-
const showSavedAgo =
|
| 127 |
-
hydrated &&
|
| 128 |
-
status === "idle" &&
|
| 129 |
-
lastSavedAt > 0 &&
|
| 130 |
-
Date.now() - lastSavedAt < 5 * 60_000;
|
| 131 |
-
|
| 132 |
-
const visible =
|
| 133 |
-
!hydrated || status === "saving" || status === "error" || showSavedAgo;
|
| 134 |
-
if (!visible) return null;
|
| 135 |
-
|
| 136 |
-
const tone =
|
| 137 |
-
status === "error"
|
| 138 |
-
? "text-status-err"
|
| 139 |
-
: status === "saving" || !hydrated
|
| 140 |
-
? "text-amber"
|
| 141 |
-
: "text-ink-50";
|
| 142 |
-
|
| 143 |
-
const dotCls =
|
| 144 |
-
status === "error"
|
| 145 |
-
? "bg-status-err"
|
| 146 |
-
: status === "saving" || !hydrated
|
| 147 |
-
? "bg-amber animate-pulse-soft"
|
| 148 |
-
: "bg-status-ok";
|
| 149 |
-
|
| 150 |
-
const label = !hydrated
|
| 151 |
-
? "Loading"
|
| 152 |
-
: status === "saving"
|
| 153 |
-
? "Saving"
|
| 154 |
-
: status === "error"
|
| 155 |
-
? "Sync error"
|
| 156 |
-
: `Saved ${relativeTime(lastSavedAt)}`;
|
| 157 |
-
|
| 158 |
-
return (
|
| 159 |
-
<span
|
| 160 |
-
className={clsx(
|
| 161 |
-
"hidden sm:inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1",
|
| 162 |
-
"text-micro font-mono uppercase tracking-[0.14em] transition-opacity",
|
| 163 |
-
tone
|
| 164 |
-
)}
|
| 165 |
-
title={
|
| 166 |
-
status === "error"
|
| 167 |
-
? "Couldn't save chat history to backend"
|
| 168 |
-
: status === "saving"
|
| 169 |
-
? "Saving chat history…"
|
| 170 |
-
: !hydrated
|
| 171 |
-
? "Loading chat history…"
|
| 172 |
-
: `Last save: ${new Date(lastSavedAt).toLocaleTimeString()}`
|
| 173 |
-
}
|
| 174 |
-
>
|
| 175 |
-
<span className={clsx("inline-block w-1 h-1 rounded-full", dotCls)} />
|
| 176 |
-
{label}
|
| 177 |
-
</span>
|
| 178 |
-
);
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
function Mark() {
|
| 182 |
return (
|
| 183 |
<span
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import Link from "next/link";
|
|
|
|
|
|
|
| 4 |
import { useChatStore } from "@/lib/chatStore";
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export function TopBar() {
|
|
|
|
|
|
|
| 7 |
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
return (
|
| 10 |
<header className="sticky top-0 z-30 h-14 flex items-center backdrop-blur-nav bg-canvas-deep/60 border-b border-glass-border">
|
| 11 |
<div className="container-app flex items-center justify-between w-full">
|
| 12 |
+
{/* Left: brand + sidebar toggle */}
|
| 13 |
<div className="flex items-center gap-3">
|
| 14 |
<button
|
| 15 |
onClick={toggleSidebar}
|
|
|
|
| 39 |
</div>
|
| 40 |
</Link>
|
| 41 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
</header>
|
| 44 |
);
|
| 45 |
}
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
function Mark() {
|
| 48 |
return (
|
| 49 |
<span
|
|
@@ -4,9 +4,13 @@
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import type {
|
|
|
|
|
|
|
| 7 |
AskSmartRequest,
|
| 8 |
AskSmartResponse,
|
|
|
|
| 9 |
CreateDocumentRequest,
|
|
|
|
| 10 |
DocumentMeta,
|
| 11 |
DocumentsListResponse,
|
| 12 |
HealthResponse,
|
|
@@ -27,8 +31,6 @@ class ApiError extends Error {
|
|
| 27 |
}
|
| 28 |
|
| 29 |
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
| 30 |
-
// Empty path = upstream root (`/` health). Avoid `/api/proxy/` which Next 15
|
| 31 |
-
// 308-redirects to `/api/proxy`; hit the no-slash form directly.
|
| 32 |
const url = path ? `${PROXY}/${path}` : PROXY;
|
| 33 |
const res = await fetch(url, {
|
| 34 |
...init,
|
|
@@ -70,6 +72,8 @@ export const api = {
|
|
| 70 |
// ─── Documents ───────────────────────────────────────────────────
|
| 71 |
listDocuments: () => request<DocumentsListResponse>("documents"),
|
| 72 |
getDocument: (doc_id: string) => request<DocumentMeta>(`documents/${doc_id}`),
|
|
|
|
|
|
|
| 73 |
createDocument: (req: CreateDocumentRequest) =>
|
| 74 |
request<DocumentMeta>("documents", {
|
| 75 |
method: "POST",
|
|
@@ -87,4 +91,72 @@ export const api = {
|
|
| 87 |
body: JSON.stringify(req),
|
| 88 |
signal,
|
| 89 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import type {
|
| 7 |
+
AskRequest,
|
| 8 |
+
AskResponse,
|
| 9 |
AskSmartRequest,
|
| 10 |
AskSmartResponse,
|
| 11 |
+
AskSmartStreamEvent,
|
| 12 |
CreateDocumentRequest,
|
| 13 |
+
DocumentContentResponse,
|
| 14 |
DocumentMeta,
|
| 15 |
DocumentsListResponse,
|
| 16 |
HealthResponse,
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
|
|
|
|
|
| 34 |
const url = path ? `${PROXY}/${path}` : PROXY;
|
| 35 |
const res = await fetch(url, {
|
| 36 |
...init,
|
|
|
|
| 72 |
// ─── Documents ───────────────────────────────────────────────────
|
| 73 |
listDocuments: () => request<DocumentsListResponse>("documents"),
|
| 74 |
getDocument: (doc_id: string) => request<DocumentMeta>(`documents/${doc_id}`),
|
| 75 |
+
getDocumentContent: (doc_id: string) =>
|
| 76 |
+
request<DocumentContentResponse>(`documents/${doc_id}/content`),
|
| 77 |
createDocument: (req: CreateDocumentRequest) =>
|
| 78 |
request<DocumentMeta>("documents", {
|
| 79 |
method: "POST",
|
|
|
|
| 91 |
body: JSON.stringify(req),
|
| 92 |
signal,
|
| 93 |
}),
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Doc-scoped inference (retrieval bypass) — used when the user pins a
|
| 97 |
+
* conversation to a single doc.
|
| 98 |
+
*/
|
| 99 |
+
ask: (req: AskRequest, signal?: AbortSignal) =>
|
| 100 |
+
request<AskResponse>("ask", {
|
| 101 |
+
method: "POST",
|
| 102 |
+
body: JSON.stringify(req),
|
| 103 |
+
signal,
|
| 104 |
+
}),
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* Streaming variant of /ask_smart. Emits SSE events; caller receives a
|
| 108 |
+
* sequence of typed events via the `onEvent` callback. Resolves when the
|
| 109 |
+
* stream closes (after `done`/`rejected`/`error`).
|
| 110 |
+
*/
|
| 111 |
+
askSmartStream: async (
|
| 112 |
+
req: AskSmartRequest,
|
| 113 |
+
onEvent: (e: AskSmartStreamEvent) => void,
|
| 114 |
+
signal?: AbortSignal
|
| 115 |
+
): Promise<void> => {
|
| 116 |
+
const res = await fetch(`${PROXY}/ask_smart`, {
|
| 117 |
+
method: "POST",
|
| 118 |
+
headers: {
|
| 119 |
+
"Content-Type": "application/json",
|
| 120 |
+
Accept: "text/event-stream",
|
| 121 |
+
},
|
| 122 |
+
body: JSON.stringify({ ...req, stream: true }),
|
| 123 |
+
signal,
|
| 124 |
+
});
|
| 125 |
+
if (!res.ok || !res.body) {
|
| 126 |
+
const text = await res.text().catch(() => "");
|
| 127 |
+
throw new ApiError(res.status, `stream HTTP ${res.status}`, text);
|
| 128 |
+
}
|
| 129 |
+
const reader = res.body.getReader();
|
| 130 |
+
const decoder = new TextDecoder("utf-8");
|
| 131 |
+
let buffer = "";
|
| 132 |
+
while (true) {
|
| 133 |
+
const { done, value } = await reader.read();
|
| 134 |
+
if (done) break;
|
| 135 |
+
buffer += decoder.decode(value, { stream: true });
|
| 136 |
+
// SSE frames are separated by blank lines
|
| 137 |
+
let sep: number;
|
| 138 |
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
| 139 |
+
const frame = buffer.slice(0, sep);
|
| 140 |
+
buffer = buffer.slice(sep + 2);
|
| 141 |
+
const parsed = parseSSE(frame);
|
| 142 |
+
if (parsed) onEvent(parsed);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
},
|
| 146 |
};
|
| 147 |
+
|
| 148 |
+
function parseSSE(frame: string): AskSmartStreamEvent | null {
|
| 149 |
+
let event = "message";
|
| 150 |
+
let dataLines: string[] = [];
|
| 151 |
+
for (const line of frame.split("\n")) {
|
| 152 |
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
| 153 |
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
| 154 |
+
}
|
| 155 |
+
if (dataLines.length === 0) return null;
|
| 156 |
+
try {
|
| 157 |
+
const data = JSON.parse(dataLines.join("\n"));
|
| 158 |
+
return { event, data } as AskSmartStreamEvent;
|
| 159 |
+
} catch {
|
| 160 |
+
return null;
|
| 161 |
+
}
|
| 162 |
+
}
|
|
@@ -19,9 +19,14 @@ export type Conversation = {
|
|
| 19 |
turns: ChatTurn[];
|
| 20 |
createdAt: number;
|
| 21 |
updatedAt: number;
|
|
|
|
|
|
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
-
export type Settings = Required<
|
|
|
|
|
|
|
| 25 |
|
| 26 |
export const DEFAULT_SETTINGS: Settings = {
|
| 27 |
top_k: 1,
|
|
@@ -53,9 +58,11 @@ type ChatStore = {
|
|
| 53 |
|
| 54 |
// mutations
|
| 55 |
newConversation: () => string;
|
|
|
|
| 56 |
openConversation: (id: string) => void;
|
| 57 |
deleteConversation: (id: string) => void;
|
| 58 |
renameConversation: (id: string, title: string) => void;
|
|
|
|
| 59 |
appendTurn: (turn: ChatTurn) => void;
|
| 60 |
patchTurn: (id: string, patch: Partial<ChatTurn>) => void;
|
| 61 |
clearActive: () => void;
|
|
@@ -79,6 +86,23 @@ const newId = () =>
|
|
| 79 |
? crypto.randomUUID()
|
| 80 |
: Math.random().toString(36).slice(2, 12));
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const seedConversation = (): Conversation => ({
|
| 83 |
id: newId(),
|
| 84 |
title: "New conversation",
|
|
@@ -117,6 +141,37 @@ export const useChatStore = create<ChatStore>()(
|
|
| 117 |
return c.id;
|
| 118 |
},
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
openConversation: (id) => set({ activeId: id }),
|
| 121 |
|
| 122 |
deleteConversation: (id) =>
|
|
|
|
| 19 |
turns: ChatTurn[];
|
| 20 |
createdAt: number;
|
| 21 |
updatedAt: number;
|
| 22 |
+
/** When set, follow-up questions go to /ask scoped to this doc instead of /ask_smart. */
|
| 23 |
+
pinnedDocId?: string | null;
|
| 24 |
+
pinnedDocName?: string | null;
|
| 25 |
};
|
| 26 |
|
| 27 |
+
export type Settings = Required<
|
| 28 |
+
Omit<AskSmartRequest, "question" | "history" | "stream">
|
| 29 |
+
>;
|
| 30 |
|
| 31 |
export const DEFAULT_SETTINGS: Settings = {
|
| 32 |
top_k: 1,
|
|
|
|
| 58 |
|
| 59 |
// mutations
|
| 60 |
newConversation: () => string;
|
| 61 |
+
newConversationPinned: (doc_id: string, doc_name: string) => string;
|
| 62 |
openConversation: (id: string) => void;
|
| 63 |
deleteConversation: (id: string) => void;
|
| 64 |
renameConversation: (id: string, title: string) => void;
|
| 65 |
+
pinDoc: (id: string, doc_id: string | null, doc_name?: string | null) => void;
|
| 66 |
appendTurn: (turn: ChatTurn) => void;
|
| 67 |
patchTurn: (id: string, patch: Partial<ChatTurn>) => void;
|
| 68 |
clearActive: () => void;
|
|
|
|
| 86 |
? crypto.randomUUID()
|
| 87 |
: Math.random().toString(36).slice(2, 12));
|
| 88 |
|
| 89 |
+
/**
|
| 90 |
+
* Build a multi-turn history payload for the backend from a conversation.
|
| 91 |
+
* Excludes pending and errored turns; caps to last `max` Q→A pairs.
|
| 92 |
+
*/
|
| 93 |
+
export function buildHistory(
|
| 94 |
+
conv: Conversation | null | undefined,
|
| 95 |
+
max = 5
|
| 96 |
+
): { question: string; answer: string }[] {
|
| 97 |
+
if (!conv) return [];
|
| 98 |
+
const pairs: { question: string; answer: string }[] = [];
|
| 99 |
+
for (const t of conv.turns) {
|
| 100 |
+
if (t.pending || t.error || !t.response) continue;
|
| 101 |
+
pairs.push({ question: t.question, answer: t.response.answer });
|
| 102 |
+
}
|
| 103 |
+
return pairs.slice(-max);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
const seedConversation = (): Conversation => ({
|
| 107 |
id: newId(),
|
| 108 |
title: "New conversation",
|
|
|
|
| 141 |
return c.id;
|
| 142 |
},
|
| 143 |
|
| 144 |
+
newConversationPinned: (doc_id, doc_name) => {
|
| 145 |
+
const c: Conversation = {
|
| 146 |
+
...seedConversation(),
|
| 147 |
+
title: doc_name ? `Scoped: ${doc_name}` : "Scoped conversation",
|
| 148 |
+
pinnedDocId: doc_id,
|
| 149 |
+
pinnedDocName: doc_name ?? null,
|
| 150 |
+
};
|
| 151 |
+
set((s) => ({
|
| 152 |
+
conversations: { ...s.conversations, [c.id]: c },
|
| 153 |
+
activeId: c.id,
|
| 154 |
+
}));
|
| 155 |
+
return c.id;
|
| 156 |
+
},
|
| 157 |
+
|
| 158 |
+
pinDoc: (id, doc_id, doc_name) =>
|
| 159 |
+
set((s) => {
|
| 160 |
+
const conv = s.conversations[id];
|
| 161 |
+
if (!conv) return s;
|
| 162 |
+
return {
|
| 163 |
+
conversations: {
|
| 164 |
+
...s.conversations,
|
| 165 |
+
[id]: {
|
| 166 |
+
...conv,
|
| 167 |
+
pinnedDocId: doc_id,
|
| 168 |
+
pinnedDocName: doc_name ?? conv.pinnedDocName ?? null,
|
| 169 |
+
updatedAt: Date.now(),
|
| 170 |
+
},
|
| 171 |
+
},
|
| 172 |
+
};
|
| 173 |
+
}),
|
| 174 |
+
|
| 175 |
openConversation: (id) => set({ activeId: id }),
|
| 176 |
|
| 177 |
deleteConversation: (id) =>
|
|
@@ -74,6 +74,8 @@ export type AskSmartRequest = {
|
|
| 74 |
no_repeat_ngram_size?: number;
|
| 75 |
scaler?: number;
|
| 76 |
bias_scaler?: number;
|
|
|
|
|
|
|
| 77 |
};
|
| 78 |
|
| 79 |
export type AskSmartResponse = {
|
|
@@ -86,7 +88,41 @@ export type AskSmartResponse = {
|
|
| 86 |
_anchor_score?: number;
|
| 87 |
_corpus_relevance?: number;
|
| 88 |
_threshold: number;
|
|
|
|
| 89 |
retrieve_seconds: number;
|
| 90 |
inference_seconds: number;
|
| 91 |
total_seconds: number;
|
| 92 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
no_repeat_ngram_size?: number;
|
| 75 |
scaler?: number;
|
| 76 |
bias_scaler?: number;
|
| 77 |
+
history?: { question: string; answer: string }[];
|
| 78 |
+
stream?: boolean;
|
| 79 |
};
|
| 80 |
|
| 81 |
export type AskSmartResponse = {
|
|
|
|
| 88 |
_anchor_score?: number;
|
| 89 |
_corpus_relevance?: number;
|
| 90 |
_threshold: number;
|
| 91 |
+
_reject_reason?: string;
|
| 92 |
retrieve_seconds: number;
|
| 93 |
inference_seconds: number;
|
| 94 |
total_seconds: number;
|
| 95 |
};
|
| 96 |
+
|
| 97 |
+
export type AskRequest = {
|
| 98 |
+
doc_id: string;
|
| 99 |
+
question: string;
|
| 100 |
+
scaler?: number;
|
| 101 |
+
bias_scaler?: number;
|
| 102 |
+
max_new_tokens?: number;
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export type AskResponse = {
|
| 106 |
+
answer: string;
|
| 107 |
+
doc_id: string;
|
| 108 |
+
elapsed_seconds: number;
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
export type DocumentContentResponse = {
|
| 112 |
+
doc_id: string;
|
| 113 |
+
name: string;
|
| 114 |
+
length_chars: number;
|
| 115 |
+
created_at?: number;
|
| 116 |
+
text: string;
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* SSE events emitted by /ask_smart when stream=true.
|
| 121 |
+
*/
|
| 122 |
+
export type AskSmartStreamEvent =
|
| 123 |
+
| { event: "status"; data: { phase: string; retrieve_seconds?: number } }
|
| 124 |
+
| { event: "sources"; data: Partial<AskSmartResponse> & { source_docs: SourceDoc[] } }
|
| 125 |
+
| { event: "rejected"; data: AskSmartResponse }
|
| 126 |
+
| { event: "token"; data: { text: string } }
|
| 127 |
+
| { event: "done"; data: AskSmartResponse }
|
| 128 |
+
| { event: "error"; data: { status: number; message: string } };
|