@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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/internal/assets.d.ts +9 -0
- package/dist/internal/assets.js +50 -0
- package/dist/internal/docusaurusConfig.d.ts +9 -0
- package/dist/internal/docusaurusConfig.js +259 -0
- package/dist/internal/spellbookAssets.d.ts +39 -0
- package/dist/internal/spellbookAssets.js +68 -0
- package/dist/layer.d.ts +3 -0
- package/dist/layer.js +6 -0
- package/dist/shared.d.ts +10 -0
- package/dist/shared.js +36 -0
- package/dist/upstream.d.ts +6 -0
- package/dist/upstream.js +84 -0
- package/package.json +59 -0
- package/src/index.ts +1 -0
- package/src/internal/assets.ts +66 -0
- package/src/internal/docusaurusConfig.ts +281 -0
- package/src/internal/spellbookAssets.ts +80 -0
- package/src/layer.ts +12 -0
- package/src/shared.ts +43 -0
- package/src/upstream.ts +119 -0
- package/templates/spellbook/spellbookPlugin.ts +156 -0
- package/templates/spellbook/src/components/SpellbookChat/ChatEngine.ts +79 -0
- package/templates/spellbook/src/components/SpellbookChat/ChatErrorBoundary.tsx +65 -0
- package/templates/spellbook/src/components/SpellbookChat/Markdown.tsx +259 -0
- package/templates/spellbook/src/components/SpellbookChat/README.md +111 -0
- package/templates/spellbook/src/components/SpellbookChat/SettingsPanel.tsx +376 -0
- package/templates/spellbook/src/components/SpellbookChat/VoiceMode.tsx +867 -0
- package/templates/spellbook/src/components/SpellbookChat/index.tsx +744 -0
- package/templates/spellbook/src/components/SpellbookChat/markdown.module.css +343 -0
- package/templates/spellbook/src/components/SpellbookChat/secretStore.ts +106 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/anthropic.ts +36 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/createCloudProvider.ts +112 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/google.ts +33 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/index.ts +32 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/mapFinishReason.ts +23 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/ollama.ts +44 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/openai.ts +34 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/openaiRealtime.ts +320 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/types.ts +172 -0
- package/templates/spellbook/src/components/SpellbookChat/streamProviders/webllm.ts +214 -0
- package/templates/spellbook/src/components/SpellbookChat/styles.module.css +852 -0
- package/templates/spellbook/src/components/SpellbookChat/systemPrompt.ts +107 -0
- package/templates/spellbook/src/components/SpellbookChat/transformers-ssr-stub.ts +16 -0
- package/templates/spellbook/src/components/SpellbookChat/types.ts +52 -0
- package/templates/spellbook/src/components/SpellbookChat/useBundleLoader.ts +46 -0
- package/templates/spellbook/src/components/SpellbookChat/useChatEngine.ts +524 -0
- package/templates/spellbook/src/components/SpellbookChat/useEmbeddings.ts +147 -0
- package/templates/spellbook/src/components/SpellbookChat/useRetrieval.ts +377 -0
- package/templates/spellbook/src/components/SpellbookChat/useSileroVAD.ts +236 -0
- package/templates/spellbook/src/components/SpellbookChat/useSpeechRecognition.ts +271 -0
- package/templates/spellbook/src/components/SpellbookChat/useSpeechSynthesis.ts +229 -0
- package/templates/spellbook/src/components/SpellbookChat/useUnifiedSTT.ts +134 -0
- package/templates/spellbook/src/components/SpellbookChat/useWhisperSTT.ts +411 -0
- package/templates/spellbook/src/components/SpellbookChat/vad-ssr-stub.ts +25 -0
- package/templates/spellbook/src/components/SpellbookChat/voiceDebug.ts +60 -0
- package/templates/spellbook/src/components/SpellbookChat/voiceFsm.ts +196 -0
- package/templates/spellbook/src/components/SpellbookChat/voiceStyles.module.css +334 -0
- package/templates/spellbook/src/components/SpellbookChat/webllm-ssr-stub.ts +8 -0
- package/templates/spellbook/src/components/SpellbookChatDisabled.tsx +20 -0
- 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
|
+
}
|