@kpritam/grimoire-output-docusaurus 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/internal/assets.d.ts +9 -0
  7. package/dist/internal/assets.js +50 -0
  8. package/dist/internal/docusaurusConfig.d.ts +9 -0
  9. package/dist/internal/docusaurusConfig.js +259 -0
  10. package/dist/internal/spellbookAssets.d.ts +39 -0
  11. package/dist/internal/spellbookAssets.js +68 -0
  12. package/dist/layer.d.ts +3 -0
  13. package/dist/layer.js +6 -0
  14. package/dist/shared.d.ts +10 -0
  15. package/dist/shared.js +36 -0
  16. package/dist/upstream.d.ts +6 -0
  17. package/dist/upstream.js +84 -0
  18. package/package.json +59 -0
  19. package/src/index.ts +1 -0
  20. package/src/internal/assets.ts +66 -0
  21. package/src/internal/docusaurusConfig.ts +281 -0
  22. package/src/internal/spellbookAssets.ts +80 -0
  23. package/src/layer.ts +12 -0
  24. package/src/shared.ts +43 -0
  25. package/src/upstream.ts +119 -0
  26. package/templates/spellbook/spellbookPlugin.ts +156 -0
  27. package/templates/spellbook/src/components/SpellbookChat/ChatEngine.ts +79 -0
  28. package/templates/spellbook/src/components/SpellbookChat/ChatErrorBoundary.tsx +65 -0
  29. package/templates/spellbook/src/components/SpellbookChat/Markdown.tsx +259 -0
  30. package/templates/spellbook/src/components/SpellbookChat/README.md +111 -0
  31. package/templates/spellbook/src/components/SpellbookChat/SettingsPanel.tsx +376 -0
  32. package/templates/spellbook/src/components/SpellbookChat/VoiceMode.tsx +867 -0
  33. package/templates/spellbook/src/components/SpellbookChat/index.tsx +744 -0
  34. package/templates/spellbook/src/components/SpellbookChat/markdown.module.css +343 -0
  35. package/templates/spellbook/src/components/SpellbookChat/secretStore.ts +106 -0
  36. package/templates/spellbook/src/components/SpellbookChat/streamProviders/anthropic.ts +36 -0
  37. package/templates/spellbook/src/components/SpellbookChat/streamProviders/createCloudProvider.ts +112 -0
  38. package/templates/spellbook/src/components/SpellbookChat/streamProviders/google.ts +33 -0
  39. package/templates/spellbook/src/components/SpellbookChat/streamProviders/index.ts +32 -0
  40. package/templates/spellbook/src/components/SpellbookChat/streamProviders/mapFinishReason.ts +23 -0
  41. package/templates/spellbook/src/components/SpellbookChat/streamProviders/ollama.ts +44 -0
  42. package/templates/spellbook/src/components/SpellbookChat/streamProviders/openai.ts +34 -0
  43. package/templates/spellbook/src/components/SpellbookChat/streamProviders/openaiRealtime.ts +320 -0
  44. package/templates/spellbook/src/components/SpellbookChat/streamProviders/types.ts +172 -0
  45. package/templates/spellbook/src/components/SpellbookChat/streamProviders/webllm.ts +214 -0
  46. package/templates/spellbook/src/components/SpellbookChat/styles.module.css +852 -0
  47. package/templates/spellbook/src/components/SpellbookChat/systemPrompt.ts +107 -0
  48. package/templates/spellbook/src/components/SpellbookChat/transformers-ssr-stub.ts +16 -0
  49. package/templates/spellbook/src/components/SpellbookChat/types.ts +52 -0
  50. package/templates/spellbook/src/components/SpellbookChat/useBundleLoader.ts +46 -0
  51. package/templates/spellbook/src/components/SpellbookChat/useChatEngine.ts +524 -0
  52. package/templates/spellbook/src/components/SpellbookChat/useEmbeddings.ts +147 -0
  53. package/templates/spellbook/src/components/SpellbookChat/useRetrieval.ts +377 -0
  54. package/templates/spellbook/src/components/SpellbookChat/useSileroVAD.ts +236 -0
  55. package/templates/spellbook/src/components/SpellbookChat/useSpeechRecognition.ts +271 -0
  56. package/templates/spellbook/src/components/SpellbookChat/useSpeechSynthesis.ts +229 -0
  57. package/templates/spellbook/src/components/SpellbookChat/useUnifiedSTT.ts +134 -0
  58. package/templates/spellbook/src/components/SpellbookChat/useWhisperSTT.ts +411 -0
  59. package/templates/spellbook/src/components/SpellbookChat/vad-ssr-stub.ts +25 -0
  60. package/templates/spellbook/src/components/SpellbookChat/voiceDebug.ts +60 -0
  61. package/templates/spellbook/src/components/SpellbookChat/voiceFsm.ts +196 -0
  62. package/templates/spellbook/src/components/SpellbookChat/voiceStyles.module.css +334 -0
  63. package/templates/spellbook/src/components/SpellbookChat/webllm-ssr-stub.ts +8 -0
  64. package/templates/spellbook/src/components/SpellbookChatDisabled.tsx +20 -0
  65. package/templates/spellbook/src/theme/Root.tsx +29 -0
@@ -0,0 +1,744 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ memo,
4
+ type ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+
12
+ import type { Citation } from "./types";
13
+ import ChatErrorBoundary from "./ChatErrorBoundary";
14
+ import Markdown from "./Markdown";
15
+ import SettingsPanel from "./SettingsPanel";
16
+ import { useActiveProvider, useChatEngine } from "./useChatEngine";
17
+ import VoiceMode, { type VoiceModeHandle } from "./VoiceMode";
18
+
19
+ import styles from "./styles.module.css";
20
+
21
+ export {
22
+ useChatEngine,
23
+ useActiveProvider,
24
+ notifySettingsChanged,
25
+ readActiveProviderId,
26
+ } from "./useChatEngine";
27
+
28
+ type InputMode = "type" | "speak";
29
+
30
+ type UserMessage = {
31
+ readonly id: string;
32
+ readonly role: "user";
33
+ readonly text: string;
34
+ };
35
+
36
+ type AssistantMessage = {
37
+ readonly id: string;
38
+ readonly role: "assistant";
39
+ readonly text: string;
40
+ readonly citations?: readonly Citation[];
41
+ readonly error?: boolean;
42
+ /**
43
+ * `"length"` means the answer was cut off because the model hit its
44
+ * `maxTokens` ceiling. The bubble shows a small banner so users know to
45
+ * ask "continue" rather than treating the truncated text as final.
46
+ */
47
+ readonly truncated?: boolean;
48
+ };
49
+
50
+ type ChatMessage = UserMessage | AssistantMessage;
51
+
52
+ function newId(): string {
53
+ return (
54
+ globalThis.crypto?.randomUUID?.() ??
55
+ `m-${Date.now()}-${Math.random()}`
56
+ );
57
+ }
58
+
59
+ function Icon(props: { readonly children: ReactNode }): ReactNode {
60
+ return (
61
+ <svg
62
+ width="16"
63
+ height="16"
64
+ viewBox="0 0 24 24"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeWidth="1.75"
68
+ strokeLinecap="round"
69
+ strokeLinejoin="round"
70
+ aria-hidden
71
+ >
72
+ {props.children}
73
+ </svg>
74
+ );
75
+ }
76
+
77
+ function ClearIcon(): ReactNode {
78
+ return (
79
+ <Icon>
80
+ <path d="M3 6h18" />
81
+ <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
82
+ <path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
83
+ </Icon>
84
+ );
85
+ }
86
+
87
+ function ExpandIcon(): ReactNode {
88
+ return (
89
+ <Icon>
90
+ <path d="M15 3h6v6" />
91
+ <path d="M21 3l-8 8" />
92
+ <path d="M9 21H3v-6" />
93
+ <path d="M3 21l8-8" />
94
+ </Icon>
95
+ );
96
+ }
97
+
98
+ function CollapseIcon(): ReactNode {
99
+ return (
100
+ <Icon>
101
+ <path d="M21 3l-7 7" />
102
+ <path d="M14 4v6h6" />
103
+ <path d="M3 21l7-7" />
104
+ <path d="M10 20v-6H4" />
105
+ </Icon>
106
+ );
107
+ }
108
+
109
+ function SettingsIcon(): ReactNode {
110
+ return (
111
+ <Icon>
112
+ <circle cx="12" cy="12" r="3" />
113
+ <path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 1 1-4 0v-.06a1.7 1.7 0 0 0-1.04-1.57 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 1 1 0-4h.06a1.7 1.7 0 0 0 1.57-1.04 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.05A1.7 1.7 0 0 0 10 3.06V3a2 2 0 1 1 4 0v.06a1.7 1.7 0 0 0 1.04 1.57h.05a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.05a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 1 1 0 4h-.06a1.7 1.7 0 0 0-1.54 1.04Z" />
114
+ </Icon>
115
+ );
116
+ }
117
+
118
+ function CloseIcon(): ReactNode {
119
+ return (
120
+ <Icon>
121
+ <path d="M18 6 6 18" />
122
+ <path d="m6 6 12 12" />
123
+ </Icon>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Defense-in-depth: only render citations whose sourceLink is http(s) or a
129
+ * relative in-site path. The index builder only emits these schemes today,
130
+ * but a hand-edited or third-party index shouldn't be able to smuggle a
131
+ * `javascript:` href through the panel.
132
+ */
133
+ function safeCitationHref(raw: string | undefined): string | undefined {
134
+ if (!raw) return undefined;
135
+ const trimmed = raw.trim();
136
+ if (trimmed === "") return undefined;
137
+ if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("./")) {
138
+ return trimmed;
139
+ }
140
+ try {
141
+ const url = new URL(trimmed, window.location.origin);
142
+ if (url.protocol === "http:" || url.protocol === "https:") {
143
+ return url.toString();
144
+ }
145
+ } catch {
146
+ // Fall through: invalid URL → drop the link, render as a non-link card.
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function CitationList(props: {
152
+ readonly citations: readonly Citation[];
153
+ }): ReactNode {
154
+ const { citations } = props;
155
+ if (!Array.isArray(citations) || citations.length === 0) {
156
+ return null;
157
+ }
158
+ return (
159
+ <div className={styles.citations}>
160
+ <p className={styles.citationsTitle}>Sources</p>
161
+ {citations.map((c) => {
162
+ if (!c || typeof c.file !== "string") {
163
+ return null;
164
+ }
165
+ const headings: readonly string[] = Array.isArray(c.headings)
166
+ ? c.headings
167
+ : [];
168
+ const pathLabel = `${c.file}${c.anchor ? `#${c.anchor}` : ""}`;
169
+ const inner = (
170
+ <>
171
+ <div className={styles.citationPath}>{pathLabel}</div>
172
+ {headings.length > 0 ? (
173
+ <div className={styles.citationHeadings}>
174
+ {headings.join(" · ")}
175
+ </div>
176
+ ) : null}
177
+ </>
178
+ );
179
+ const href = safeCitationHref(c.sourceLink);
180
+ if (href) {
181
+ const external = href.startsWith("http");
182
+ return (
183
+ <a
184
+ key={`${c.file}-${c.anchor ?? ""}`}
185
+ className={styles.citationCard}
186
+ href={href}
187
+ target={external ? "_blank" : undefined}
188
+ rel={external ? "noreferrer noopener" : undefined}
189
+ >
190
+ {inner}
191
+ </a>
192
+ );
193
+ }
194
+ return (
195
+ <div
196
+ key={`${c.file}-${c.anchor ?? ""}`}
197
+ className={styles.citationCard}
198
+ >
199
+ {inner}
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+ );
205
+ }
206
+
207
+ const MessageBubble = memo(function MessageBubble(props: {
208
+ readonly message: ChatMessage;
209
+ }): ReactNode {
210
+ const { message: msg } = props;
211
+ if (msg.role === "user") {
212
+ return (
213
+ <div className={`${styles.bubbleRow} ${styles.bubbleRowUser}`}>
214
+ <div className={`${styles.bubble} ${styles.bubbleUser}`}>
215
+ {msg.text}
216
+ </div>
217
+ </div>
218
+ );
219
+ }
220
+ return (
221
+ <div className={styles.bubbleRow}>
222
+ <div
223
+ className={`${styles.bubble} ${styles.bubbleAssistant} ${
224
+ msg.error ? styles.bubbleAssistantError : ""
225
+ }`}
226
+ >
227
+ {msg.error ? (
228
+ <p style={{ margin: 0 }}>{msg.text}</p>
229
+ ) : msg.text ? (
230
+ <Markdown source={msg.text} />
231
+ ) : (
232
+ <span className={styles.typingDots} aria-hidden>
233
+ <span />
234
+ <span />
235
+ <span />
236
+ </span>
237
+ )}
238
+ {msg.truncated ? (
239
+ <p
240
+ className={styles.truncatedNotice}
241
+ role="status"
242
+ aria-live="polite"
243
+ >
244
+ Response was cut off (token limit). Ask “continue” for the rest.
245
+ </p>
246
+ ) : null}
247
+ {msg.citations ? <CitationList citations={msg.citations} /> : null}
248
+ </div>
249
+ </div>
250
+ );
251
+ });
252
+
253
+ export default function SpellbookChat(): ReactNode {
254
+ const engine = useChatEngine();
255
+ const activeInfo = useActiveProvider();
256
+ const titleId = useId();
257
+ const [isOpen, setIsOpen] = useState(false);
258
+ const [expanded, setExpanded] = useState(false);
259
+ const [showSettings, setShowSettings] = useState(false);
260
+ const [input, setInput] = useState("");
261
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
262
+ const [streamingText, setStreamingText] = useState("");
263
+ const [streamingId, setStreamingId] = useState<string | null>(null);
264
+ const [busy, setBusy] = useState(false);
265
+ const [mode, setMode] = useState<InputMode>("type");
266
+ const abortRef = useRef<AbortController | null>(null);
267
+ const messagesRef = useRef<HTMLDivElement | null>(null);
268
+ const streamingTextRef = useRef("");
269
+ const voiceRef = useRef<VoiceModeHandle | null>(null);
270
+
271
+ /** Stop everything noisy: text ask, voice ask, TTS. Safe to call repeatedly. */
272
+ const cancelAll = useCallback(() => {
273
+ abortRef.current?.abort();
274
+ abortRef.current = null;
275
+ voiceRef.current?.cancel();
276
+ }, []);
277
+
278
+ const handleVoiceTranscript = useCallback(
279
+ (entry: {
280
+ role: "user" | "assistant";
281
+ text: string;
282
+ partial: boolean;
283
+ }) => {
284
+ if (entry.role === "user" && entry.partial) {
285
+ return;
286
+ }
287
+ setMessages((m) => {
288
+ const last = m[m.length - 1];
289
+ if (entry.role === "user") {
290
+ return [...m, { id: newId(), role: "user", text: entry.text }];
291
+ }
292
+ if (last?.role === "assistant") {
293
+ return m.map((msg, i) =>
294
+ i === m.length - 1 && msg.role === "assistant"
295
+ ? { ...msg, text: entry.text }
296
+ : msg,
297
+ );
298
+ }
299
+ return [...m, { id: newId(), role: "assistant", text: entry.text }];
300
+ });
301
+ },
302
+ [],
303
+ );
304
+
305
+ const closePanel = useCallback(() => {
306
+ cancelAll();
307
+ setIsOpen(false);
308
+ setShowSettings(false);
309
+ }, [cancelAll]);
310
+
311
+ const clearSession = useCallback(() => {
312
+ cancelAll();
313
+ setMessages([]);
314
+ setStreamingId(null);
315
+ setStreamingText("");
316
+ streamingTextRef.current = "";
317
+ setInput("");
318
+ setBusy(false);
319
+ }, [cancelAll]);
320
+
321
+ const switchMode = useCallback((next: InputMode) => {
322
+ if (next !== "speak") {
323
+ // Mode switch unmounts VoiceMode (which cancels via unmount cleanup),
324
+ // but proactively cancel so there's zero audio gap between click and
325
+ // silence — the unmount effect runs on React's next flush.
326
+ voiceRef.current?.cancel();
327
+ }
328
+ setMode(next);
329
+ }, []);
330
+
331
+ const toggleExpand = useCallback(() => {
332
+ setExpanded((e) => !e);
333
+ }, []);
334
+
335
+ useEffect(() => {
336
+ const node = messagesRef.current;
337
+ if (!node) {
338
+ return;
339
+ }
340
+ node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
341
+ }, [messages, streamingText]);
342
+
343
+ useEffect(() => {
344
+ if (!isOpen) {
345
+ return;
346
+ }
347
+ engine.preload();
348
+ }, [isOpen, engine.preload]);
349
+
350
+ useEffect(() => {
351
+ if (!isOpen) {
352
+ return;
353
+ }
354
+ const onKey = (ev: globalThis.KeyboardEvent): void => {
355
+ if (ev.key === "Escape") {
356
+ closePanel();
357
+ }
358
+ };
359
+ window.addEventListener("keydown", onKey);
360
+ return () => window.removeEventListener("keydown", onKey);
361
+ }, [isOpen, closePanel]);
362
+
363
+ // Unmount cleanup: the chat panel itself going away (route change, theme
364
+ // hot-reload, etc.) must drain any in-flight ask and TTS.
365
+ useEffect(() => {
366
+ return () => {
367
+ abortRef.current?.abort();
368
+ abortRef.current = null;
369
+ };
370
+ }, []);
371
+
372
+ const send = useCallback(async () => {
373
+ const text = input.trim();
374
+ if (!text || busy) {
375
+ return;
376
+ }
377
+ if (engine.state !== "ready") {
378
+ return;
379
+ }
380
+
381
+ const userMsg: UserMessage = { id: newId(), role: "user", text };
382
+ const assistantId = newId();
383
+ setMessages((m) => [...m, userMsg]);
384
+ setStreamingId(assistantId);
385
+ setStreamingText("");
386
+ streamingTextRef.current = "";
387
+ setInput("");
388
+ setBusy(true);
389
+
390
+ const ac = new AbortController();
391
+ abortRef.current = ac;
392
+
393
+ // Project the visible chat into the multi-turn history the engine forwards
394
+ // to the model. Errored bubbles are dropped so a recoverable mistake
395
+ // doesn't pollute the next prompt; `(stopped)` markers stay in the UI but
396
+ // are still useful as signal to the model so we keep them.
397
+ const history = messages
398
+ .filter(
399
+ (m): m is ChatMessage =>
400
+ m.role === "user" || (m.role === "assistant" && !m.error),
401
+ )
402
+ .map((m) => ({ role: m.role, content: m.text }));
403
+
404
+ try {
405
+ const result = await engine.ask(text, {
406
+ signal: ac.signal,
407
+ history,
408
+ onToken: (ev) => {
409
+ if (ac.signal.aborted) return;
410
+ const piece = typeof ev?.text === "string" ? ev.text : "";
411
+ if (!piece) {
412
+ return;
413
+ }
414
+ streamingTextRef.current += piece;
415
+ setStreamingText(streamingTextRef.current);
416
+ },
417
+ });
418
+ const finalText =
419
+ (typeof result?.answer === "string" ? result.answer : "") ||
420
+ streamingTextRef.current;
421
+ const finalCitations: readonly Citation[] = Array.isArray(
422
+ result?.citations,
423
+ )
424
+ ? result.citations
425
+ : [];
426
+ const truncated = result?.finishReason === "length";
427
+ setMessages((m) => [
428
+ ...m,
429
+ {
430
+ id: assistantId,
431
+ role: "assistant",
432
+ text: finalText,
433
+ citations: finalCitations,
434
+ truncated,
435
+ },
436
+ ]);
437
+ setStreamingId(null);
438
+ setStreamingText("");
439
+ streamingTextRef.current = "";
440
+ } catch (e) {
441
+ if ((e as Error)?.name === "AbortError") {
442
+ const body = streamingTextRef.current
443
+ ? `${streamingTextRef.current}\n\n(stopped)`
444
+ : "(stopped)";
445
+ setMessages((m) => [
446
+ ...m,
447
+ {
448
+ id: assistantId,
449
+ role: "assistant",
450
+ text: body,
451
+ error: true,
452
+ },
453
+ ]);
454
+ } else {
455
+ const msgText =
456
+ e instanceof Error ? e.message : "The assistant couldn't answer.";
457
+ setMessages((m) => [
458
+ ...m,
459
+ {
460
+ id: assistantId,
461
+ role: "assistant",
462
+ text: msgText,
463
+ error: true,
464
+ },
465
+ ]);
466
+ }
467
+ setStreamingId(null);
468
+ setStreamingText("");
469
+ streamingTextRef.current = "";
470
+ } finally {
471
+ setBusy(false);
472
+ if (abortRef.current === ac) {
473
+ abortRef.current = null;
474
+ }
475
+ }
476
+ }, [busy, engine, input]);
477
+
478
+ const onComposerKeyDown = useCallback(
479
+ (ev: KeyboardEvent<HTMLTextAreaElement>) => {
480
+ if (ev.key === "Enter" && !ev.shiftKey) {
481
+ ev.preventDefault();
482
+ void send();
483
+ }
484
+ },
485
+ [send],
486
+ );
487
+
488
+ const loading =
489
+ engine.state === "loading-bundle" || engine.state === "loading-model";
490
+ const showProgress = loading || (isOpen && engine.state === "idle");
491
+ const degradedError = engine.state === "error";
492
+
493
+ return (
494
+ <>
495
+ <button
496
+ type="button"
497
+ className={styles.launcher}
498
+ aria-expanded={isOpen}
499
+ aria-controls="spellbook-chat-panel"
500
+ onClick={() => setIsOpen((o) => !o)}
501
+ >
502
+ <span className={styles.screenReader}>Ask the docs</span>
503
+ <span className={styles.launcherSigil} aria-hidden>
504
+ <span className={styles.launcherSigilInner} />
505
+ </span>
506
+ <span className={styles.launcherLabel}>Ask the docs</span>
507
+ </button>
508
+
509
+ <div
510
+ className={`${styles.backdrop} ${isOpen ? styles.backdropVisible : ""}`}
511
+ role="presentation"
512
+ onClick={closePanel}
513
+ tabIndex={-1}
514
+ />
515
+
516
+ <aside
517
+ id="spellbook-chat-panel"
518
+ className={`${styles.panel} ${isOpen ? styles.panelOpen : ""} ${
519
+ expanded ? styles.panelExpanded : ""
520
+ }`}
521
+ role="dialog"
522
+ aria-modal="true"
523
+ aria-labelledby={titleId}
524
+ hidden={!isOpen}
525
+ >
526
+ <ChatErrorBoundary>
527
+ <div className={styles.panelInner}>
528
+ <header className={styles.header}>
529
+ <div className={styles.headerMain}>
530
+ <span className={styles.kicker}>Docs assistant</span>
531
+ <div className={styles.titleRow}>
532
+ <h2 id={titleId} className={styles.title}>
533
+ Assistant
534
+ </h2>
535
+ <div className={styles.headerActions}>
536
+ <button
537
+ type="button"
538
+ className={styles.iconButton}
539
+ aria-label={
540
+ messages.length === 0
541
+ ? "Clear session (no messages)"
542
+ : "Clear session"
543
+ }
544
+ title="Clear session"
545
+ onClick={clearSession}
546
+ disabled={messages.length === 0 && !busy}
547
+ >
548
+ <ClearIcon />
549
+ </button>
550
+ <button
551
+ type="button"
552
+ className={styles.iconButton}
553
+ aria-label={expanded ? "Compact view" : "Expand panel"}
554
+ aria-pressed={expanded}
555
+ title={expanded ? "Compact view" : "Expand panel"}
556
+ onClick={toggleExpand}
557
+ >
558
+ {expanded ? <CollapseIcon /> : <ExpandIcon />}
559
+ </button>
560
+ <button
561
+ type="button"
562
+ className={styles.iconButton}
563
+ aria-label="Settings"
564
+ title="Settings"
565
+ onClick={() => setShowSettings((s) => !s)}
566
+ >
567
+ <SettingsIcon />
568
+ </button>
569
+ <button
570
+ type="button"
571
+ className={styles.iconButton}
572
+ aria-label="Close chat"
573
+ title="Close"
574
+ onClick={closePanel}
575
+ >
576
+ <CloseIcon />
577
+ </button>
578
+ </div>
579
+ </div>
580
+ {engine.repo ||
581
+ (engine.state === "ready" && engine.chunkCount > 0) ? (
582
+ <p className={styles.repo}>
583
+ {engine.repo ? <>{engine.repo} · </> : null}
584
+ {engine.state === "ready" && engine.chunkCount > 0 ? (
585
+ <>{engine.chunkCount} fragments indexed · </>
586
+ ) : null}
587
+ {activeInfo.providerId === "webllm"
588
+ ? `Local · ${activeInfo.modelLabel}`
589
+ : activeInfo.modelLabel}
590
+ </p>
591
+ ) : null}
592
+ </div>
593
+ </header>
594
+
595
+ {showProgress ? (
596
+ <div className={styles.statusBar}>
597
+ <p className={styles.statusText}>
598
+ {engine.state === "idle" && isOpen
599
+ ? "Loading…"
600
+ : engine.statusMessage}
601
+ </p>
602
+ <div className={styles.progressTrack}>
603
+ <div className={styles.progressFillIndeterminate} />
604
+ </div>
605
+ </div>
606
+ ) : null}
607
+
608
+ {degradedError && engine.error ? (
609
+ <div className={styles.errorBanner} role="alert">
610
+ <div>{engine.error}</div>
611
+ <div className={styles.errorActions}>
612
+ <button
613
+ type="button"
614
+ className={styles.buttonPrimary}
615
+ onClick={() => engine.preload()}
616
+ >
617
+ Try again
618
+ </button>
619
+ </div>
620
+ </div>
621
+ ) : null}
622
+
623
+ {showSettings ? (
624
+ <SettingsPanel
625
+ variant="card"
626
+ onClose={() => setShowSettings(false)}
627
+ remoteStatusMessage={
628
+ engine.state === "loading-bundle" ||
629
+ engine.state === "loading-model"
630
+ ? engine.statusMessage
631
+ : undefined
632
+ }
633
+ />
634
+ ) : null}
635
+
636
+ {!showSettings && engine.state === "missing-key" && !loading ? (
637
+ <SettingsPanel variant="inline" />
638
+ ) : null}
639
+
640
+ {!showSettings &&
641
+ !degradedError &&
642
+ engine.state !== "missing-key" ? (
643
+ <>
644
+ <div
645
+ ref={messagesRef}
646
+ className={styles.messages}
647
+ aria-live="polite"
648
+ >
649
+ {messages.length === 0 && engine.state === "ready" ? (
650
+ <p className={styles.statusText} style={{ margin: 0 }}>
651
+ Ask anything about this documentation. Answers cite only
652
+ the indexed sources.
653
+ </p>
654
+ ) : null}
655
+ {messages.map((msg) => (
656
+ <MessageBubble key={msg.id} message={msg} />
657
+ ))}
658
+ {streamingId ? (
659
+ <div key={streamingId} className={styles.bubbleRow}>
660
+ <div
661
+ className={`${styles.bubble} ${styles.bubbleAssistant}`}
662
+ >
663
+ {streamingText ? (
664
+ <Markdown source={streamingText} streaming />
665
+ ) : (
666
+ <span className={styles.typingDots} aria-hidden>
667
+ <span />
668
+ <span />
669
+ <span />
670
+ </span>
671
+ )}
672
+ </div>
673
+ </div>
674
+ ) : null}
675
+ </div>
676
+ <footer className={styles.footer}>
677
+ <div
678
+ role="tablist"
679
+ aria-label="Input mode"
680
+ className={styles.modeTabs}
681
+ >
682
+ <button
683
+ type="button"
684
+ role="tab"
685
+ aria-selected={mode === "type"}
686
+ className={`${styles.modeTab} ${
687
+ mode === "type" ? styles.modeTabActive : ""
688
+ }`}
689
+ onClick={() => switchMode("type")}
690
+ >
691
+ Type
692
+ </button>
693
+ <button
694
+ type="button"
695
+ role="tab"
696
+ aria-selected={mode === "speak"}
697
+ className={`${styles.modeTab} ${
698
+ mode === "speak" ? styles.modeTabActive : ""
699
+ }`}
700
+ onClick={() => switchMode("speak")}
701
+ >
702
+ Speak
703
+ </button>
704
+ </div>
705
+ {mode === "type" ? (
706
+ <div className={styles.composer}>
707
+ <textarea
708
+ className={styles.textarea}
709
+ placeholder={
710
+ engine.state === "ready"
711
+ ? "Ask a question…"
712
+ : "Loading…"
713
+ }
714
+ value={input}
715
+ disabled={engine.state !== "ready" || busy}
716
+ onChange={(ev) => setInput(ev.target.value)}
717
+ onKeyDown={onComposerKeyDown}
718
+ rows={2}
719
+ />
720
+ <button
721
+ type="button"
722
+ className={styles.sendButton}
723
+ disabled={engine.state !== "ready" || busy}
724
+ onClick={() => void send()}
725
+ >
726
+ Send
727
+ </button>
728
+ </div>
729
+ ) : (
730
+ <VoiceMode
731
+ ref={voiceRef}
732
+ engine={engine}
733
+ onTranscriptUpdate={handleVoiceTranscript}
734
+ />
735
+ )}
736
+ </footer>
737
+ </>
738
+ ) : null}
739
+ </div>
740
+ </ChatErrorBoundary>
741
+ </aside>
742
+ </>
743
+ );
744
+ }