@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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract between the text-chat panel and the voice-mode wrapper —
|
|
3
|
+
* both are React-rendered, in-browser, BYOK. `useChatEngine()` (in this
|
|
4
|
+
* folder) provides the canonical implementation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Citation } from "./types";
|
|
8
|
+
|
|
9
|
+
export type EngineLoadingState =
|
|
10
|
+
| "idle"
|
|
11
|
+
| "loading-bundle"
|
|
12
|
+
| "loading-model"
|
|
13
|
+
| "ready"
|
|
14
|
+
| "missing-key"
|
|
15
|
+
| "error";
|
|
16
|
+
|
|
17
|
+
export interface StreamChunkEvent {
|
|
18
|
+
readonly text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Why the stream ended. Surfaced on `AskResult` so the UI can distinguish
|
|
23
|
+
* "the model said its piece" from "we ran out of tokens" or "the request
|
|
24
|
+
* was cut off mid-flight". Maps 1:1 onto the AI SDK `finishReason` plus
|
|
25
|
+
* an explicit `"abort"` for client-side cancellation.
|
|
26
|
+
*/
|
|
27
|
+
export type AskFinishReason =
|
|
28
|
+
| "stop"
|
|
29
|
+
| "length"
|
|
30
|
+
| "tool-call"
|
|
31
|
+
| "error"
|
|
32
|
+
| "abort";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One turn of the conversation as it should be replayed to the model on
|
|
36
|
+
* the next call. The current pending question is NOT part of this — it's
|
|
37
|
+
* passed separately to `ask`. This shape mirrors `ChatTurn` in the
|
|
38
|
+
* provider-stream contract so the engine can hand it through verbatim.
|
|
39
|
+
*/
|
|
40
|
+
export interface AskHistoryEntry {
|
|
41
|
+
readonly role: "user" | "assistant";
|
|
42
|
+
readonly content: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AskResult {
|
|
46
|
+
readonly answer: string;
|
|
47
|
+
readonly citations: readonly Citation[];
|
|
48
|
+
readonly inputTokensApprox: number;
|
|
49
|
+
readonly outputTokensApprox: number;
|
|
50
|
+
readonly durationMs: number;
|
|
51
|
+
/** Why the stream stopped. Defaults to `"stop"` for clean completions. */
|
|
52
|
+
readonly finishReason: AskFinishReason;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AskOptions {
|
|
56
|
+
readonly onToken?: (event: StreamChunkEvent) => void;
|
|
57
|
+
readonly signal?: AbortSignal;
|
|
58
|
+
/**
|
|
59
|
+
* Prior turns of the conversation, oldest first. Excludes the current
|
|
60
|
+
* pending `question`. The engine forwards this to the provider so the
|
|
61
|
+
* model can see context like "the second one" or "what about that file?".
|
|
62
|
+
*/
|
|
63
|
+
readonly history?: readonly AskHistoryEntry[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ChatEngine {
|
|
67
|
+
readonly state: EngineLoadingState;
|
|
68
|
+
readonly statusMessage: string;
|
|
69
|
+
readonly error?: string;
|
|
70
|
+
readonly chunkCount: number;
|
|
71
|
+
readonly repo?: string;
|
|
72
|
+
readonly hasApiKey: boolean;
|
|
73
|
+
/** Streams tokens via `opts.onToken`. Rejects if not ready or call fails. */
|
|
74
|
+
readonly ask: (question: string, opts?: AskOptions) => Promise<AskResult>;
|
|
75
|
+
/** Idempotent lazy-load of bundle + model. */
|
|
76
|
+
readonly preload: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type UseChatEngine = () => ChatEngine;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import styles from "./styles.module.css";
|
|
4
|
+
|
|
5
|
+
interface ChatErrorBoundaryProps {
|
|
6
|
+
readonly children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ChatErrorBoundaryState {
|
|
10
|
+
readonly error: Error | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Isolates chat crashes so they don't take down the surrounding Docusaurus
|
|
15
|
+
* page. Renders a small recoverable error card inside the panel instead
|
|
16
|
+
* of letting React unwind to the outer ErrorBoundary.
|
|
17
|
+
*
|
|
18
|
+
* In dev we log the full error + stack to the console for debugging.
|
|
19
|
+
*/
|
|
20
|
+
export default class ChatErrorBoundary extends Component<
|
|
21
|
+
ChatErrorBoundaryProps,
|
|
22
|
+
ChatErrorBoundaryState
|
|
23
|
+
> {
|
|
24
|
+
state: ChatErrorBoundaryState = { error: null };
|
|
25
|
+
|
|
26
|
+
static getDerivedStateFromError(error: Error): ChatErrorBoundaryState {
|
|
27
|
+
return { error };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
31
|
+
if (typeof console !== "undefined") {
|
|
32
|
+
console.error("[chat] Panel crashed:", error, info.componentStack);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private reset = (): void => {
|
|
37
|
+
this.setState({ error: null });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
render(): ReactNode {
|
|
41
|
+
if (!this.state.error) {
|
|
42
|
+
return this.props.children;
|
|
43
|
+
}
|
|
44
|
+
const message =
|
|
45
|
+
this.state.error.message || "Unknown error in the chat.";
|
|
46
|
+
return (
|
|
47
|
+
<div className={styles.errorBanner} role="alert">
|
|
48
|
+
<div>
|
|
49
|
+
<strong>The assistant stumbled.</strong>
|
|
50
|
+
<br />
|
|
51
|
+
{message}
|
|
52
|
+
</div>
|
|
53
|
+
<div className={styles.errorActions}>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className={styles.buttonPrimary}
|
|
57
|
+
onClick={this.reset}
|
|
58
|
+
>
|
|
59
|
+
Try again
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { useCallback, useState, type ReactNode } from "react";
|
|
2
|
+
import rehypeHighlight from "rehype-highlight";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import { Streamdown, type Components } from "streamdown";
|
|
5
|
+
|
|
6
|
+
import styles from "./markdown.module.css";
|
|
7
|
+
|
|
8
|
+
interface MarkdownProps {
|
|
9
|
+
readonly source: string;
|
|
10
|
+
/**
|
|
11
|
+
* `true` while tokens are still arriving from the model. Streamdown uses
|
|
12
|
+
* this to enable incomplete-block parsing (so a half-emitted code fence
|
|
13
|
+
* doesn't render as broken markdown) and shows a subtle caret at the
|
|
14
|
+
* streaming edge. Cheap to leave `false` once the assistant finishes —
|
|
15
|
+
* the rendered output is identical once the markdown is complete.
|
|
16
|
+
*/
|
|
17
|
+
readonly streaming?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CodeBlockProps {
|
|
21
|
+
readonly className?: string;
|
|
22
|
+
readonly children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Standalone fenced code block with language tag + copy-to-clipboard button.
|
|
27
|
+
* Styling lives in `markdown.module.css`; theming overrides live alongside.
|
|
28
|
+
*/
|
|
29
|
+
function CodeBlock(props: CodeBlockProps): ReactNode {
|
|
30
|
+
const { className = "", children } = props;
|
|
31
|
+
const langMatch = /language-(\w+)/.exec(className);
|
|
32
|
+
const language = langMatch?.[1] ?? "";
|
|
33
|
+
const [copied, setCopied] = useState(false);
|
|
34
|
+
|
|
35
|
+
const onCopy = useCallback(() => {
|
|
36
|
+
const text = extractText(children);
|
|
37
|
+
if (!text) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
navigator.clipboard
|
|
41
|
+
?.writeText(text)
|
|
42
|
+
.then(() => {
|
|
43
|
+
setCopied(true);
|
|
44
|
+
window.setTimeout(() => setCopied(false), 1400);
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
// ignore — likely insecure context
|
|
48
|
+
});
|
|
49
|
+
}, [children]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={styles.codeBlock}>
|
|
53
|
+
<div className={styles.codeBlockHeader}>
|
|
54
|
+
<span className={styles.codeBlockLang}>{language || "code"}</span>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className={styles.codeCopyButton}
|
|
58
|
+
onClick={onCopy}
|
|
59
|
+
aria-label="Copy code"
|
|
60
|
+
>
|
|
61
|
+
{copied ? "Copied" : "Copy"}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
<pre className={styles.codeBlockPre}>
|
|
65
|
+
<code className={`${className} ${styles.codeBlockCode}`}>
|
|
66
|
+
{children}
|
|
67
|
+
</code>
|
|
68
|
+
</pre>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractText(node: ReactNode): string {
|
|
74
|
+
if (typeof node === "string") {
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
77
|
+
if (typeof node === "number") {
|
|
78
|
+
return String(node);
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(node)) {
|
|
81
|
+
return node.map(extractText).join("");
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
node &&
|
|
85
|
+
typeof node === "object" &&
|
|
86
|
+
"props" in node &&
|
|
87
|
+
(node as { props?: { children?: ReactNode } }).props
|
|
88
|
+
) {
|
|
89
|
+
return extractText(
|
|
90
|
+
(node as { props: { children?: ReactNode } }).props.children,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const componentMap: Components = {
|
|
97
|
+
code(props) {
|
|
98
|
+
const { className, children } = props as {
|
|
99
|
+
className?: string;
|
|
100
|
+
children?: ReactNode;
|
|
101
|
+
};
|
|
102
|
+
const isInline = !/language-/.test(className ?? "");
|
|
103
|
+
if (isInline) {
|
|
104
|
+
return <code className={styles.inlineCode}>{children}</code>;
|
|
105
|
+
}
|
|
106
|
+
return <CodeBlock className={className}>{children}</CodeBlock>;
|
|
107
|
+
},
|
|
108
|
+
pre(props) {
|
|
109
|
+
return <>{(props as { children?: ReactNode }).children}</>;
|
|
110
|
+
},
|
|
111
|
+
a(props) {
|
|
112
|
+
const { href = "", children, ...rest } = props as {
|
|
113
|
+
href?: string;
|
|
114
|
+
children?: ReactNode;
|
|
115
|
+
} & Record<string, unknown>;
|
|
116
|
+
// Defense-in-depth: answers are markdown from a third-party LLM and
|
|
117
|
+
// must never execute as JavaScript. Allow only http(s), anchors, and
|
|
118
|
+
// relative paths; anything else (javascript:, data:, vbscript:, etc.)
|
|
119
|
+
// is stripped to a non-link span.
|
|
120
|
+
const safeHref = (() => {
|
|
121
|
+
const t = href.trim();
|
|
122
|
+
if (t === "") return undefined;
|
|
123
|
+
if (t.startsWith("#") || t.startsWith("/") || t.startsWith("./")) {
|
|
124
|
+
return t;
|
|
125
|
+
}
|
|
126
|
+
if (/^https?:\/\//i.test(t)) {
|
|
127
|
+
return t;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
})();
|
|
131
|
+
if (!safeHref) {
|
|
132
|
+
return <span className={styles.link}>{children}</span>;
|
|
133
|
+
}
|
|
134
|
+
const external = /^https?:\/\//i.test(safeHref);
|
|
135
|
+
return (
|
|
136
|
+
<a
|
|
137
|
+
{...(rest as Record<string, unknown>)}
|
|
138
|
+
href={safeHref}
|
|
139
|
+
className={styles.link}
|
|
140
|
+
target={external ? "_blank" : undefined}
|
|
141
|
+
rel={external ? "noreferrer noopener" : undefined}
|
|
142
|
+
>
|
|
143
|
+
{children}
|
|
144
|
+
</a>
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
table(props) {
|
|
148
|
+
return (
|
|
149
|
+
<div className={styles.tableWrap}>
|
|
150
|
+
<table className={styles.table}>
|
|
151
|
+
{(props as { children?: ReactNode }).children}
|
|
152
|
+
</table>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
blockquote(props) {
|
|
157
|
+
return (
|
|
158
|
+
<blockquote className={styles.blockquote}>
|
|
159
|
+
{(props as { children?: ReactNode }).children}
|
|
160
|
+
</blockquote>
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
ul(props) {
|
|
164
|
+
return (
|
|
165
|
+
<ul className={styles.list}>
|
|
166
|
+
{(props as { children?: ReactNode }).children}
|
|
167
|
+
</ul>
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
ol(props) {
|
|
171
|
+
return (
|
|
172
|
+
<ol className={styles.list}>
|
|
173
|
+
{(props as { children?: ReactNode }).children}
|
|
174
|
+
</ol>
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
li(props) {
|
|
178
|
+
return (
|
|
179
|
+
<li className={styles.listItem}>
|
|
180
|
+
{(props as { children?: ReactNode }).children}
|
|
181
|
+
</li>
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
h1(props) {
|
|
185
|
+
return (
|
|
186
|
+
<h3 className={styles.heading}>
|
|
187
|
+
{(props as { children?: ReactNode }).children}
|
|
188
|
+
</h3>
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
h2(props) {
|
|
192
|
+
return (
|
|
193
|
+
<h3 className={styles.heading}>
|
|
194
|
+
{(props as { children?: ReactNode }).children}
|
|
195
|
+
</h3>
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
h3(props) {
|
|
199
|
+
return (
|
|
200
|
+
<h4 className={styles.subheading}>
|
|
201
|
+
{(props as { children?: ReactNode }).children}
|
|
202
|
+
</h4>
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
h4(props) {
|
|
206
|
+
return (
|
|
207
|
+
<h4 className={styles.subheading}>
|
|
208
|
+
{(props as { children?: ReactNode }).children}
|
|
209
|
+
</h4>
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
hr() {
|
|
213
|
+
return <hr className={styles.rule} />;
|
|
214
|
+
},
|
|
215
|
+
p(props) {
|
|
216
|
+
return (
|
|
217
|
+
<p className={styles.paragraph}>
|
|
218
|
+
{(props as { children?: ReactNode }).children}
|
|
219
|
+
</p>
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Streaming-aware markdown renderer.
|
|
226
|
+
*
|
|
227
|
+
* Built on Vercel's `streamdown`, which handles **incomplete markdown blocks**
|
|
228
|
+
* gracefully (so a half-emitted code fence won't render as broken HTML
|
|
229
|
+
* mid-stream) and ships `rehype-harden` for safer rendering of LLM output.
|
|
230
|
+
* We override the default elements via `components` to keep the chat panel's
|
|
231
|
+
* visual style consistent with the rest of the docs.
|
|
232
|
+
*
|
|
233
|
+
* `streaming={true}` enables the unterminated-block handling and the
|
|
234
|
+
* subtle caret indicator at the live edge. Pass `false` for finalised
|
|
235
|
+
* messages — the output is identical, just without the streaming hints.
|
|
236
|
+
*/
|
|
237
|
+
export default function Markdown({
|
|
238
|
+
source,
|
|
239
|
+
streaming = false,
|
|
240
|
+
}: MarkdownProps): ReactNode {
|
|
241
|
+
if (!source) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return (
|
|
245
|
+
<div className={styles.root}>
|
|
246
|
+
<Streamdown
|
|
247
|
+
mode={streaming ? "streaming" : "static"}
|
|
248
|
+
parseIncompleteMarkdown={streaming}
|
|
249
|
+
remarkPlugins={[remarkGfm]}
|
|
250
|
+
rehypePlugins={[
|
|
251
|
+
[rehypeHighlight, { detect: true, ignoreMissing: true }],
|
|
252
|
+
]}
|
|
253
|
+
components={componentMap}
|
|
254
|
+
>
|
|
255
|
+
{source}
|
|
256
|
+
</Streamdown>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Chat — in-browser RAG over the documentation
|
|
2
|
+
|
|
3
|
+
A static-site chat panel that runs **entirely in the user's browser**:
|
|
4
|
+
|
|
5
|
+
- no backend, no edge function, no infra cost to the project owner
|
|
6
|
+
- **BYOK** — users bring their own API keys for cloud models; keys live in
|
|
7
|
+
tab memory only (cleared on refresh, never written to disk)
|
|
8
|
+
- optional **fully local** inference with **WebLLM** (WebGPU, no keys)
|
|
9
|
+
- private by default — cloud calls go straight from the user's browser to
|
|
10
|
+
the provider they chose
|
|
11
|
+
|
|
12
|
+
## Architecture
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
16
|
+
│ Build time — packages/core/IndexBuilder │
|
|
17
|
+
│ • walks tome/**/*.md, chunks by heading + ~512 tokens │
|
|
18
|
+
│ • embeds chunks with @huggingface/transformers (onnx-community MiniLM-L6-v2, 384-dim) │
|
|
19
|
+
│ • writes the static bundle under │
|
|
20
|
+
│ tome/site/static/grimoire-index/ │
|
|
21
|
+
│ chunks.json (array<ChunkRecord>) │
|
|
22
|
+
│ vectors.bin (Float32Array, packed little-endian) │
|
|
23
|
+
│ manifest.json (BundleManifest — also carries siteName, │
|
|
24
|
+
│ siteTagline, repo so the chat persona is │
|
|
25
|
+
│ project-aware) │
|
|
26
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
27
|
+
|
|
28
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
29
|
+
│ Browser runtime — SpellbookChat panel + ChatEngine │
|
|
30
|
+
│ 1. User picks a provider + model in Settings │
|
|
31
|
+
│ • Provider id, model, base URL → localStorage │
|
|
32
|
+
│ • API key → secretStore.ts (memory only; never on disk) │
|
|
33
|
+
│ 2. lazy-load chunks.json + vectors.bin + embedding model (WASM) │
|
|
34
|
+
│ 3. Optional: WebLLM preloads model weights (IndexedDB cache) │
|
|
35
|
+
│ 4. on each question: embed query locally → top-k chunks → │
|
|
36
|
+
│ stream completion via the active StreamProvider │
|
|
37
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
38
|
+
|
|
39
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
40
|
+
│ Voice mode — VoiceMode wrapper │
|
|
41
|
+
│ • mic button → unified STT (Web Speech API, Whisper fallback) │
|
|
42
|
+
│ • call ChatEngine.ask(text, { signal, onToken }) │
|
|
43
|
+
│ • each sentence is streamed through SpeechSynthesis as it arrives │
|
|
44
|
+
│ • cancel() via ref kills the ask + TTS together; the panel calls │
|
|
45
|
+
│ it on Close, Clear, and mode-switch to Type │
|
|
46
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## LLM providers (`streamProviders/`)
|
|
50
|
+
|
|
51
|
+
All implement the shared `StreamProvider` contract (`streamProviders/types.ts`).
|
|
52
|
+
|
|
53
|
+
| Provider | How it runs | What the user configures |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| **Anthropic** | Browser → Anthropic API (CORS header set) | API key; model from built-in list |
|
|
56
|
+
| **OpenAI** | Browser → OpenAI API | API key; model from built-in list |
|
|
57
|
+
| **Google Gemini** | Browser → Generative Language API | API key; model from built-in list |
|
|
58
|
+
| **Ollama / OpenAI-compatible** | Browser → user's server (e.g. `http://localhost:11434/v1`) | Base URL (optional); model name (presets + **custom**). Requires CORS on the server (e.g. `OLLAMA_ORIGINS`). |
|
|
59
|
+
| **WebLLM** | Model runs in the tab (WebGPU); weights cached locally | Model choice only. Needs a **WebGPU**-capable browser. SSR builds alias `@mlc-ai/web-llm` to a stub so Docusaurus can prerender. |
|
|
60
|
+
|
|
61
|
+
## Storage layout
|
|
62
|
+
|
|
63
|
+
| What | Where | Why |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| Active provider id | `localStorage["grimoire.chat.provider"]` | Non-secret preference |
|
|
66
|
+
| Model / baseUrl per provider | `localStorage["grimoire.chat.<id>.{model,baseUrl}"]` | Non-secret preferences |
|
|
67
|
+
| **API key** | in-memory (`secretStore.ts`) | Cleared on tab refresh — no plaintext secret on disk |
|
|
68
|
+
|
|
69
|
+
On first load `secretStore.purgeLegacyKeyStorage()` deletes any plaintext
|
|
70
|
+
API keys a previous build persisted, so returning users don't carry
|
|
71
|
+
credentials forward on disk.
|
|
72
|
+
|
|
73
|
+
## Files in this directory
|
|
74
|
+
|
|
75
|
+
| File | Purpose |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `types.ts` | Bundle schema (`ChunkRecord`, `BundleManifest`, `RetrievedChunk`) |
|
|
78
|
+
| `ChatEngine.ts` | Engine interface shared by panel + voice mode |
|
|
79
|
+
| `streamProviders/*.ts` | Per-provider streaming (Vercel AI SDK + WebLLM) |
|
|
80
|
+
| `index.tsx` | Main chat panel React component |
|
|
81
|
+
| `useChatEngine.ts` | React hook that implements the engine |
|
|
82
|
+
| `systemPrompt.ts` | Project-aware persona + grounded RAG prompt |
|
|
83
|
+
| `secretStore.ts` | In-memory API key store (no persistence) |
|
|
84
|
+
| `useEmbeddings.ts` | Loads + runs the in-browser embedding model |
|
|
85
|
+
| `useRetrieval.ts` | Cosine-sim against shipped vectors |
|
|
86
|
+
| `SettingsPanel.tsx` | Provider + model + key settings |
|
|
87
|
+
| `VoiceMode.tsx` | Mic button + STT/TTS wrapper with imperative cancel |
|
|
88
|
+
| `useSpeechRecognition.ts` | Web Speech API STT hook |
|
|
89
|
+
| `useSpeechSynthesis.ts` | Web Speech API TTS hook (cancels on unmount) |
|
|
90
|
+
| `useUnifiedSTT.ts` | Unified native + Whisper-fallback STT |
|
|
91
|
+
| `useWhisperSTT.ts` | In-browser Whisper STT hook |
|
|
92
|
+
| `voiceDebug.ts` | Opt-in voice pipeline logger (silent by default) |
|
|
93
|
+
| `styles.module.css` | Panel styles |
|
|
94
|
+
| `voiceStyles.module.css` | Voice-mode styles |
|
|
95
|
+
| `webllm-ssr-stub.ts` | SSR stub for `@mlc-ai/web-llm` |
|
|
96
|
+
| `transformers-ssr-stub.ts` | SSR stub for `@huggingface/transformers` |
|
|
97
|
+
|
|
98
|
+
`types.ts` and `ChatEngine.ts` are the only cross-cutting contracts.
|
|
99
|
+
|
|
100
|
+
## Debug logging
|
|
101
|
+
|
|
102
|
+
The voice pipeline is chatty during development. To enable transcript-level
|
|
103
|
+
traces in production:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
localStorage.setItem("grimoire.chat.debug", "1");
|
|
107
|
+
location.reload();
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The flag is read once on module load. Logs include a truncated transcript
|
|
111
|
+
preview (first 80 chars) but never the raw API key or full answer.
|