Berkkirik commited on
Commit
df790cc
·
1 Parent(s): d784651

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 CHANGED
@@ -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"
app/page.tsx CHANGED
@@ -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
- // Reflect active conversation in the browser tab title
30
- useDocumentTitle(
31
- conv && conv.turns.length > 0 ? conv.title : null
32
- );
33
 
34
- const ask = useMutation<
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
- ask.mutate({ id, question: q, signal: ctrl.signal });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ? <ChatEmpty onAsk={handleAsk} /> : <Thread />}
 
 
 
 
105
 
106
  <Composer
107
  ref={composerRef}
108
  onSubmit={handleAsk}
109
  onStop={handleStop}
110
- pending={ask.isPending}
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
  }
components/chat/AppShell.tsx CHANGED
@@ -32,17 +32,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
32
  }, [pathname, hydrated, conversations, activeId, newConversation]);
33
 
34
  return (
35
- <div className="relative min-h-screen flex flex-col">
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
  >
components/chat/Composer.tsx CHANGED
@@ -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
- {/* Settings diff chip surfaces non-default params */}
60
- {diff.length > 0 && (
61
- <button
62
- onClick={onOpenSettings}
63
- className="mb-2 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"
64
- style={{ boxShadow: "0 0 0 1px rgba(255,181,69,0.32)" }}
65
- title="Tuned settings — click to edit"
66
- >
67
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
68
- <circle cx="5" cy="5" r="1.4" fill="currentColor" />
69
- <circle cx="5" cy="5" r="3.4" stroke="currentColor" strokeWidth="0.9" />
70
- </svg>
71
- <span>{diff.join(" · ")}</span>
72
- </button>
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="Ask anything about Etiya BSS…"
 
 
 
 
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"
components/chat/Message.tsx CHANGED
@@ -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({ turn }: { turn: ChatTurn }) {
 
 
 
 
 
 
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
- {r.source_docs?.length > 0 && <SourceChips docs={r.source_docs} />}
 
 
 
 
 
 
 
151
 
152
- {/* Spec sheet — collapsed by default, only `total` visible */}
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>
components/chat/Sidebar.tsx CHANGED
@@ -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, width animates
62
- "md:sticky md:top-14 md:h-[calc(100vh-3.5rem)] md:bottom-auto",
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"
components/chat/SourceChips.tsx CHANGED
@@ -4,75 +4,186 @@ import type { SourceDoc } from "@/lib/types";
4
  import { useState } from "react";
5
  import clsx from "clsx";
6
 
7
- export function SourceChips({ docs }: { docs: SourceDoc[] }) {
8
- const [expanded, setExpanded] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  if (!docs?.length) return null;
10
 
 
 
 
 
 
 
 
11
  return (
12
  <div className="mt-4 flex flex-col gap-2">
13
- <div className="text-micro uppercase tracking-[0.12em] text-ink-50 font-mono">
14
- Sources · {docs.length}
15
- </div>
16
- <ul className="flex flex-col gap-1.5">
17
- {docs.map((d) => {
18
- const isOpen = expanded === d.doc_id;
19
- const sim = d.similarity ?? d.dense_similarity ?? 0;
20
- return (
21
- <li key={d.doc_id}>
22
- <button
23
- onClick={() => setExpanded(isOpen ? null : d.doc_id)}
24
- className={clsx(
25
- "w-full text-left rounded-md px-3 py-2 transition-all",
26
- "flex items-center gap-3",
27
- isOpen
28
- ? "bg-glass-stronger"
29
- : "bg-glass hover:bg-glass-stronger"
30
- )}
31
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.07)" }}
32
- >
33
- <SimilarityRing value={sim} />
34
- <div className="min-w-0 flex-1">
35
- <div className="text-caption-strong text-ink truncate">
36
- {cleanDocName(d.name)}
37
- </div>
38
- <div className="text-micro font-mono text-ink-50 truncate">
39
- {d.doc_id}
40
- </div>
41
- </div>
42
- <span
43
- className={clsx(
44
- "text-ink-50 transition-transform shrink-0",
45
- isOpen && "rotate-180"
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="mt-1.5 px-3 py-3 rounded-md bg-canvas-deep/70 grid grid-cols-3 gap-3 animate-fade-in"
61
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.06)" }}
62
  >
63
- <Metric label="Cosine" value={fmt(d.similarity)} />
64
- <Metric label="Dense" value={fmt(d.dense_similarity)} />
65
- <Metric label="Rerank" value={fmt(d.rerank_score, 2)} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
- )}
68
- </li>
69
- );
70
- })}
71
- </ul>
 
 
 
 
 
 
 
 
 
 
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
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/SourceDrawer.tsx ADDED
@@ -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&apos;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
+ }
components/chat/Thread.tsx CHANGED
@@ -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
- const conv = useChatStore((s) => (s.activeId ? s.conversations[s.activeId] : null));
 
 
 
 
 
 
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
- }, [conv?.turns.length, conv?.turns[conv.turns.length - 1]?.pending]);
 
 
 
 
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 && <AssistantMessage turn={t} />}
 
 
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>
components/chat/TopBar.tsx CHANGED
@@ -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 (always visible) */}
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
lib/api.ts CHANGED
@@ -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
+ }
lib/chatStore.ts CHANGED
@@ -19,9 +19,14 @@ export type Conversation = {
19
  turns: ChatTurn[];
20
  createdAt: number;
21
  updatedAt: number;
 
 
 
22
  };
23
 
24
- export type Settings = Required<Omit<AskSmartRequest, "question">>;
 
 
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) =>
lib/types.ts CHANGED
@@ -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 } };