@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,343 @@
|
|
|
1
|
+
/* Markdown rendering for SpellbookChat assistant messages.
|
|
2
|
+
Theming follows the grimoire dark palette (violet + candlelight gold). */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 0.6em;
|
|
8
|
+
font-family: var(--grim-font-chat);
|
|
9
|
+
font-size: 0.92rem;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: var(--grim-text);
|
|
12
|
+
word-break: break-word;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.root > *:first-child {
|
|
16
|
+
margin-top: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.root > *:last-child {
|
|
20
|
+
margin-bottom: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.paragraph {
|
|
24
|
+
margin: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.heading {
|
|
28
|
+
margin: 0.4em 0 0.1em;
|
|
29
|
+
font-family: var(--grim-font-display);
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
font-size: 1.05rem;
|
|
32
|
+
letter-spacing: 0;
|
|
33
|
+
color: var(--grim-text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.subheading {
|
|
37
|
+
margin: 0.4em 0 0.1em;
|
|
38
|
+
font-family: var(--grim-font-display);
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
font-size: 0.98rem;
|
|
41
|
+
color: var(--grim-text);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.list {
|
|
45
|
+
margin: 0;
|
|
46
|
+
padding-left: 1.25rem;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: 0.2em;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.listItem {
|
|
53
|
+
padding-left: 0.1rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.listItem::marker {
|
|
57
|
+
color: var(--grim-violet);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.link {
|
|
61
|
+
color: var(--grim-gold);
|
|
62
|
+
text-decoration: underline;
|
|
63
|
+
text-decoration-color: oklch(0.74 0.15 70 / 0.45);
|
|
64
|
+
text-underline-offset: 2px;
|
|
65
|
+
transition: text-decoration-color 120ms ease;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.link:hover {
|
|
69
|
+
text-decoration-color: var(--grim-gold);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.blockquote {
|
|
73
|
+
margin: 0;
|
|
74
|
+
padding: 0.4em 0.9em;
|
|
75
|
+
border-left: 2px solid var(--grim-violet);
|
|
76
|
+
background: oklch(0.18 0.04 285 / 0.6);
|
|
77
|
+
border-radius: 0 var(--grim-radius-sm) var(--grim-radius-sm) 0;
|
|
78
|
+
color: var(--grim-text-muted);
|
|
79
|
+
font-style: italic;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.rule {
|
|
83
|
+
border: none;
|
|
84
|
+
border-top: 1px solid var(--grim-rule);
|
|
85
|
+
margin: 0.6em 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.tableWrap {
|
|
89
|
+
width: 100%;
|
|
90
|
+
overflow-x: auto;
|
|
91
|
+
border-radius: var(--grim-radius-sm);
|
|
92
|
+
border: 1px solid var(--grim-rule);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.table {
|
|
96
|
+
width: 100%;
|
|
97
|
+
border-collapse: collapse;
|
|
98
|
+
font-size: 0.85rem;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.table th,
|
|
102
|
+
.table td {
|
|
103
|
+
padding: 0.4em 0.7em;
|
|
104
|
+
text-align: left;
|
|
105
|
+
border-bottom: 1px solid var(--grim-rule);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.table th {
|
|
109
|
+
background: var(--grim-surface);
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
color: var(--grim-text);
|
|
112
|
+
letter-spacing: 0.02em;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.table tr:last-child td {
|
|
116
|
+
border-bottom: none;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ---------- Inline code ---------- */
|
|
120
|
+
|
|
121
|
+
.inlineCode {
|
|
122
|
+
font-family: var(--grim-font-mono);
|
|
123
|
+
font-size: 0.85em;
|
|
124
|
+
padding: 0.06em 0.4em;
|
|
125
|
+
border-radius: 4px;
|
|
126
|
+
background: oklch(0.22 0.03 285 / 0.85);
|
|
127
|
+
border: 1px solid var(--grim-rule);
|
|
128
|
+
color: var(--grim-gold);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ---------- Fenced code blocks ---------- */
|
|
132
|
+
|
|
133
|
+
.codeBlock {
|
|
134
|
+
margin: 0.1em 0;
|
|
135
|
+
border-radius: var(--grim-radius-md);
|
|
136
|
+
border: 1px solid var(--grim-rule);
|
|
137
|
+
background: oklch(0.12 0.025 282);
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.codeBlockHeader {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: space-between;
|
|
145
|
+
padding: 0.35em 0.7em;
|
|
146
|
+
background: oklch(0.16 0.028 282);
|
|
147
|
+
border-bottom: 1px solid var(--grim-rule);
|
|
148
|
+
font-family: var(--grim-font-ui);
|
|
149
|
+
font-size: 0.7rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.codeBlockLang {
|
|
153
|
+
text-transform: uppercase;
|
|
154
|
+
letter-spacing: 0.08em;
|
|
155
|
+
color: var(--grim-text-subtle);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.codeCopyButton {
|
|
159
|
+
appearance: none;
|
|
160
|
+
border: none;
|
|
161
|
+
background: transparent;
|
|
162
|
+
color: var(--grim-text-muted);
|
|
163
|
+
font-family: var(--grim-font-ui);
|
|
164
|
+
font-size: 0.7rem;
|
|
165
|
+
letter-spacing: 0.04em;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
padding: 0.15em 0.5em;
|
|
168
|
+
border-radius: 4px;
|
|
169
|
+
transition: color 120ms ease, background 120ms ease;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.codeCopyButton:hover {
|
|
173
|
+
color: var(--grim-gold);
|
|
174
|
+
background: oklch(0.22 0.03 285);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.codeBlockPre {
|
|
178
|
+
margin: 0;
|
|
179
|
+
padding: 0.75em 0.9em;
|
|
180
|
+
overflow-x: auto;
|
|
181
|
+
font-family: var(--grim-font-mono);
|
|
182
|
+
font-size: 0.82rem;
|
|
183
|
+
line-height: 1.55;
|
|
184
|
+
background: transparent;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.codeBlockCode {
|
|
188
|
+
background: transparent !important;
|
|
189
|
+
padding: 0 !important;
|
|
190
|
+
border-radius: 0 !important;
|
|
191
|
+
border: none !important;
|
|
192
|
+
color: var(--grim-text);
|
|
193
|
+
font-family: inherit;
|
|
194
|
+
font-size: inherit;
|
|
195
|
+
white-space: pre;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ---------- highlight.js dark theme overrides ---------- */
|
|
199
|
+
/* Scoped via :global so highlight.js's emitted classes apply only inside .root */
|
|
200
|
+
|
|
201
|
+
.root :global(.hljs) {
|
|
202
|
+
color: var(--grim-text);
|
|
203
|
+
background: transparent;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.root :global(.hljs-comment),
|
|
207
|
+
.root :global(.hljs-quote) {
|
|
208
|
+
color: oklch(0.55 0.025 285);
|
|
209
|
+
font-style: italic;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.root :global(.hljs-keyword),
|
|
213
|
+
.root :global(.hljs-selector-tag),
|
|
214
|
+
.root :global(.hljs-tag) {
|
|
215
|
+
color: oklch(0.78 0.16 300);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.root :global(.hljs-string),
|
|
219
|
+
.root :global(.hljs-attr),
|
|
220
|
+
.root :global(.hljs-meta-string) {
|
|
221
|
+
color: oklch(0.85 0.13 78);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.root :global(.hljs-number),
|
|
225
|
+
.root :global(.hljs-literal),
|
|
226
|
+
.root :global(.hljs-built_in),
|
|
227
|
+
.root :global(.hljs-type) {
|
|
228
|
+
color: oklch(0.82 0.16 200);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.root :global(.hljs-title),
|
|
232
|
+
.root :global(.hljs-name),
|
|
233
|
+
.root :global(.hljs-section) {
|
|
234
|
+
color: oklch(0.88 0.12 100);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.root :global(.hljs-variable),
|
|
238
|
+
.root :global(.hljs-template-variable),
|
|
239
|
+
.root :global(.hljs-property) {
|
|
240
|
+
color: oklch(0.92 0.04 290);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.root :global(.hljs-symbol),
|
|
244
|
+
.root :global(.hljs-bullet),
|
|
245
|
+
.root :global(.hljs-link) {
|
|
246
|
+
color: var(--grim-violet);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.root :global(.hljs-deletion) {
|
|
250
|
+
color: oklch(0.7 0.16 28);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.root :global(.hljs-addition) {
|
|
254
|
+
color: oklch(0.78 0.18 145);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.root :global(.hljs-emphasis) {
|
|
258
|
+
font-style: italic;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.root :global(.hljs-strong) {
|
|
262
|
+
font-weight: 600;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* =============================================================
|
|
266
|
+
LIGHT THEME OVERRIDES — surfaces and syntax colours above are
|
|
267
|
+
dark-palette only; correct them here.
|
|
268
|
+
============================================================= */
|
|
269
|
+
|
|
270
|
+
:global([data-theme="light"]) .link {
|
|
271
|
+
text-decoration-color: color-mix(in oklch, var(--grim-gold) 45%, transparent);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
:global([data-theme="light"]) .listItem::marker {
|
|
275
|
+
color: var(--grim-violet-deep);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
:global([data-theme="light"]) .blockquote {
|
|
279
|
+
background: color-mix(in oklch, var(--grim-violet) 8%, var(--grim-surface));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
:global([data-theme="light"]) .inlineCode {
|
|
283
|
+
background: var(--grim-code-bg);
|
|
284
|
+
color: var(--grim-violet-deep);
|
|
285
|
+
border-color: color-mix(in oklch, var(--grim-violet) 20%, var(--grim-rule));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
:global([data-theme="light"]) .codeBlock {
|
|
289
|
+
background: var(--grim-code-bg);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
:global([data-theme="light"]) .codeBlockHeader {
|
|
293
|
+
background: var(--grim-surface-2);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
:global([data-theme="light"]) .codeCopyButton:hover {
|
|
297
|
+
background: var(--grim-surface-3);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* Light theme: remap hljs syntax colours for a light code background */
|
|
301
|
+
:global([data-theme="light"]) .root :global(.hljs-comment),
|
|
302
|
+
:global([data-theme="light"]) .root :global(.hljs-quote) {
|
|
303
|
+
color: oklch(0.56 0.03 285);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
:global([data-theme="light"]) .root :global(.hljs-keyword),
|
|
307
|
+
:global([data-theme="light"]) .root :global(.hljs-selector-tag),
|
|
308
|
+
:global([data-theme="light"]) .root :global(.hljs-tag) {
|
|
309
|
+
color: oklch(0.45 0.22 298);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
:global([data-theme="light"]) .root :global(.hljs-string),
|
|
313
|
+
:global([data-theme="light"]) .root :global(.hljs-attr),
|
|
314
|
+
:global([data-theme="light"]) .root :global(.hljs-meta-string) {
|
|
315
|
+
color: oklch(0.5 0.15 58);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
:global([data-theme="light"]) .root :global(.hljs-number),
|
|
319
|
+
:global([data-theme="light"]) .root :global(.hljs-literal),
|
|
320
|
+
:global([data-theme="light"]) .root :global(.hljs-built_in),
|
|
321
|
+
:global([data-theme="light"]) .root :global(.hljs-type) {
|
|
322
|
+
color: oklch(0.48 0.18 210);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
:global([data-theme="light"]) .root :global(.hljs-title),
|
|
326
|
+
:global([data-theme="light"]) .root :global(.hljs-name),
|
|
327
|
+
:global([data-theme="light"]) .root :global(.hljs-section) {
|
|
328
|
+
color: oklch(0.48 0.15 88);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
:global([data-theme="light"]) .root :global(.hljs-variable),
|
|
332
|
+
:global([data-theme="light"]) .root :global(.hljs-template-variable),
|
|
333
|
+
:global([data-theme="light"]) .root :global(.hljs-property) {
|
|
334
|
+
color: oklch(0.4 0.03 285);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
:global([data-theme="light"]) .root :global(.hljs-deletion) {
|
|
338
|
+
color: oklch(0.48 0.2 25);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
:global([data-theme="light"]) .root :global(.hljs-addition) {
|
|
342
|
+
color: oklch(0.44 0.2 148);
|
|
343
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory store for chat API keys.
|
|
3
|
+
*
|
|
4
|
+
* Keys live ONLY in the running tab's memory — never in localStorage,
|
|
5
|
+
* sessionStorage, IndexedDB, cookies, or any other persistent surface.
|
|
6
|
+
* Refreshing the page, opening a new tab, or closing the browser drops
|
|
7
|
+
* every key. This is a deliberate security trade-off: users pay a one-time
|
|
8
|
+
* re-entry cost per session for a drastically smaller attack surface
|
|
9
|
+
* (no XSS-readable keys, no extensions that snoop storage, no keys left
|
|
10
|
+
* behind on a shared device).
|
|
11
|
+
*
|
|
12
|
+
* Everything non-secret (active provider id, chosen model, base URL) still
|
|
13
|
+
* lives in localStorage — those are low-risk preferences, not credentials.
|
|
14
|
+
*
|
|
15
|
+
* Subscribers can listen to `onChange(id)` to re-run `validateConfig`
|
|
16
|
+
* whenever the user types or clears a key.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ProviderId } from "./streamProviders/types";
|
|
20
|
+
|
|
21
|
+
type Listener = (id: ProviderId) => void;
|
|
22
|
+
|
|
23
|
+
const secrets = new Map<ProviderId, string>();
|
|
24
|
+
const listeners = new Set<Listener>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the secret for a provider. Returns `undefined` when the user hasn't
|
|
28
|
+
* entered one yet or after they clear it.
|
|
29
|
+
*/
|
|
30
|
+
export function getSecret(id: ProviderId): string | undefined {
|
|
31
|
+
const v = secrets.get(id);
|
|
32
|
+
return v && v.length > 0 ? v : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Store a secret in memory. Empty/whitespace values clear the secret
|
|
37
|
+
* (equivalent to `clearSecret(id)`).
|
|
38
|
+
*/
|
|
39
|
+
export function setSecret(id: ProviderId, value: string): void {
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
if (trimmed.length === 0) {
|
|
42
|
+
if (!secrets.has(id)) return;
|
|
43
|
+
secrets.delete(id);
|
|
44
|
+
} else {
|
|
45
|
+
if (secrets.get(id) === trimmed) return;
|
|
46
|
+
secrets.set(id, trimmed);
|
|
47
|
+
}
|
|
48
|
+
for (const fn of listeners) {
|
|
49
|
+
try {
|
|
50
|
+
fn(id);
|
|
51
|
+
} catch {
|
|
52
|
+
// Listener failures must not prevent other listeners firing or the
|
|
53
|
+
// secret from being updated; subsequent calls will re-notify.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function clearSecret(id: ProviderId): void {
|
|
59
|
+
setSecret(id, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hasSecret(id: ProviderId): boolean {
|
|
63
|
+
return getSecret(id) !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function onSecretChange(fn: Listener): () => void {
|
|
67
|
+
listeners.add(fn);
|
|
68
|
+
return () => {
|
|
69
|
+
listeners.delete(fn);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Migration helper: wipes any legacy API keys from localStorage that a
|
|
75
|
+
* previous version of the chat had persisted, so returning users don't
|
|
76
|
+
* carry plaintext credentials around on disk. Runs exactly once per tab
|
|
77
|
+
* (guarded by `migrated`).
|
|
78
|
+
*
|
|
79
|
+
* Call this from the chat bootstrap (useChatEngine) — it's a one-shot
|
|
80
|
+
* side-effect, not something to call on every render.
|
|
81
|
+
*/
|
|
82
|
+
let migrated = false;
|
|
83
|
+
export function purgeLegacyKeyStorage(): void {
|
|
84
|
+
if (migrated) return;
|
|
85
|
+
migrated = true;
|
|
86
|
+
if (typeof localStorage === "undefined") return;
|
|
87
|
+
// Any key whose last segment is `apiKey`, plus the pre-generic
|
|
88
|
+
// namespace Grimoire used during early previews. The list is explicit
|
|
89
|
+
// rather than a regex so we never accidentally delete user preferences.
|
|
90
|
+
const doomed = [
|
|
91
|
+
"grimoire.spellbook.anthropic-key",
|
|
92
|
+
"grimoire.spellbook.anthropic.apiKey",
|
|
93
|
+
"grimoire.spellbook.openai.apiKey",
|
|
94
|
+
"grimoire.spellbook.google.apiKey",
|
|
95
|
+
"grimoire.chat.anthropic.apiKey",
|
|
96
|
+
"grimoire.chat.openai.apiKey",
|
|
97
|
+
"grimoire.chat.google.apiKey",
|
|
98
|
+
];
|
|
99
|
+
for (const key of doomed) {
|
|
100
|
+
try {
|
|
101
|
+
localStorage.removeItem(key);
|
|
102
|
+
} catch {
|
|
103
|
+
// Private browsing / storage quota — silently skip.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
+
|
|
3
|
+
import { createCloudProvider } from "./createCloudProvider";
|
|
4
|
+
|
|
5
|
+
export const anthropicProvider = createCloudProvider({
|
|
6
|
+
id: "anthropic",
|
|
7
|
+
displayName: "Anthropic Claude",
|
|
8
|
+
tagline: "Cloud · BYOK · Claude 4.5–4.7 family",
|
|
9
|
+
models: [
|
|
10
|
+
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", note: "balanced" },
|
|
11
|
+
{ id: "claude-opus-4-7", label: "Claude Opus 4.7", note: "smart" },
|
|
12
|
+
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5", note: "fast" },
|
|
13
|
+
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
|
14
|
+
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
|
15
|
+
{ id: "claude-3-5-haiku-latest", label: "Claude 3.5 Haiku", note: "legacy" },
|
|
16
|
+
],
|
|
17
|
+
configFields: [
|
|
18
|
+
{
|
|
19
|
+
key: "apiKey",
|
|
20
|
+
label: "Anthropic API key",
|
|
21
|
+
placeholder: "sk-ant-…",
|
|
22
|
+
helpText:
|
|
23
|
+
"Get one at console.anthropic.com. Kept in this tab's memory only (cleared on refresh). Calls go directly from your browser to Anthropic (CORS header enabled).",
|
|
24
|
+
required: true,
|
|
25
|
+
secret: true,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
validateConfig: (cfg) => (!cfg.apiKey?.trim() ? "API key required" : null),
|
|
29
|
+
resolveModel: (cfg) => {
|
|
30
|
+
const client = createAnthropic({
|
|
31
|
+
apiKey: cfg.apiKey!,
|
|
32
|
+
headers: { "anthropic-dangerous-direct-browser-access": "true" },
|
|
33
|
+
});
|
|
34
|
+
return client(cfg.model);
|
|
35
|
+
},
|
|
36
|
+
});
|
package/templates/spellbook/src/components/SpellbookChat/streamProviders/createCloudProvider.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type LanguageModel, streamText } from "ai";
|
|
2
|
+
|
|
3
|
+
import { mapFinishReason } from "./mapFinishReason";
|
|
4
|
+
import type {
|
|
5
|
+
ConfigField,
|
|
6
|
+
ModelOption,
|
|
7
|
+
PreloadProgress,
|
|
8
|
+
ProviderConfig,
|
|
9
|
+
ProviderId,
|
|
10
|
+
StreamEvent,
|
|
11
|
+
StreamProvider,
|
|
12
|
+
StreamRequest,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many times the AI SDK should retry a failed network call before
|
|
17
|
+
* giving up. Cloud LLM endpoints are flaky enough — especially during
|
|
18
|
+
* model rollouts — that bumping this from the SDK default of 2 to 3 has
|
|
19
|
+
* a measurable user-experience impact and costs nothing on the happy
|
|
20
|
+
* path. The signal still aborts the whole pipeline including retries.
|
|
21
|
+
*/
|
|
22
|
+
const STREAM_MAX_RETRIES = 3;
|
|
23
|
+
|
|
24
|
+
export interface CloudProviderSpec {
|
|
25
|
+
readonly id: ProviderId;
|
|
26
|
+
readonly displayName: string;
|
|
27
|
+
readonly tagline: string;
|
|
28
|
+
readonly models: readonly ModelOption[];
|
|
29
|
+
readonly configFields: readonly ConfigField[];
|
|
30
|
+
readonly validateConfig: (cfg: ProviderConfig) => string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Build a `LanguageModel` for the given config. Provider-specific (creates
|
|
33
|
+
* the SDK client, applies headers, picks `chat` vs `chatModel`).
|
|
34
|
+
*/
|
|
35
|
+
readonly resolveModel: (cfg: ProviderConfig) => LanguageModel;
|
|
36
|
+
/** Optional warm-up. Cloud providers usually leave this undefined. */
|
|
37
|
+
readonly preload?: (
|
|
38
|
+
config: ProviderConfig,
|
|
39
|
+
onProgress?: (info: PreloadProgress) => void,
|
|
40
|
+
) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Single source of truth for the streaming loop every cloud provider runs:
|
|
45
|
+
* 1. Build a `LanguageModel` via the spec's `resolveModel`.
|
|
46
|
+
* 2. Hand it to the AI SDK's `streamText` with our shared retry, abort
|
|
47
|
+
* and decoding settings.
|
|
48
|
+
* 3. Forward `text-delta` events as they arrive, then a `finish` with
|
|
49
|
+
* mapped reason + token usage.
|
|
50
|
+
*
|
|
51
|
+
* Adding a new cloud provider is now a ~15-line metadata block instead of
|
|
52
|
+
* 70 lines of nearly-identical glue. Provider-specific quirks
|
|
53
|
+
* (`anthropic-dangerous-direct-browser-access`, OpenAI-compatible base URL,
|
|
54
|
+
* etc.) live entirely inside `resolveModel`.
|
|
55
|
+
*/
|
|
56
|
+
export function createCloudProvider(spec: CloudProviderSpec): StreamProvider {
|
|
57
|
+
return {
|
|
58
|
+
id: spec.id,
|
|
59
|
+
displayName: spec.displayName,
|
|
60
|
+
tagline: spec.tagline,
|
|
61
|
+
models: spec.models,
|
|
62
|
+
configFields: spec.configFields,
|
|
63
|
+
validateConfig: spec.validateConfig,
|
|
64
|
+
preload: spec.preload,
|
|
65
|
+
async *stream(
|
|
66
|
+
req: StreamRequest,
|
|
67
|
+
cfg: ProviderConfig,
|
|
68
|
+
): AsyncIterable<StreamEvent> {
|
|
69
|
+
const model = spec.resolveModel(cfg);
|
|
70
|
+
|
|
71
|
+
const result = streamText({
|
|
72
|
+
model,
|
|
73
|
+
system: req.system,
|
|
74
|
+
messages: req.messages.map((m) => ({
|
|
75
|
+
role: m.role,
|
|
76
|
+
content: m.content,
|
|
77
|
+
})),
|
|
78
|
+
maxOutputTokens: req.maxTokens ?? 1024,
|
|
79
|
+
temperature: req.temperature ?? 0.4,
|
|
80
|
+
abortSignal: req.signal,
|
|
81
|
+
maxRetries: STREAM_MAX_RETRIES,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
for await (const delta of result.textStream) {
|
|
86
|
+
yield { type: "text-delta", text: delta };
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// The async iterator can throw if the underlying fetch was aborted
|
|
90
|
+
// mid-flight. Surface that as a clean abort finish so the engine
|
|
91
|
+
// doesn't bubble an `AbortError` past `useChatEngine`.
|
|
92
|
+
if (
|
|
93
|
+
req.signal?.aborted ||
|
|
94
|
+
(err as Error)?.name === "AbortError"
|
|
95
|
+
) {
|
|
96
|
+
yield { type: "finish", finishReason: "abort" };
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const finishReasonRaw = await result.finishReason;
|
|
103
|
+
const usage = await result.usage;
|
|
104
|
+
yield {
|
|
105
|
+
type: "finish",
|
|
106
|
+
finishReason: mapFinishReason(finishReasonRaw),
|
|
107
|
+
inputTokens: usage?.inputTokens,
|
|
108
|
+
outputTokens: usage?.outputTokens,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
2
|
+
|
|
3
|
+
import { createCloudProvider } from "./createCloudProvider";
|
|
4
|
+
|
|
5
|
+
export const googleProvider = createCloudProvider({
|
|
6
|
+
id: "google",
|
|
7
|
+
displayName: "Google Gemini",
|
|
8
|
+
tagline: "Cloud · BYOK · Gemini 3 & 2.5",
|
|
9
|
+
models: [
|
|
10
|
+
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", note: "preview" },
|
|
11
|
+
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", note: "preview" },
|
|
12
|
+
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro", note: "smart" },
|
|
13
|
+
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash", note: "fast" },
|
|
14
|
+
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash-Lite", note: "budget" },
|
|
15
|
+
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash", note: "deprecated" },
|
|
16
|
+
],
|
|
17
|
+
configFields: [
|
|
18
|
+
{
|
|
19
|
+
key: "apiKey",
|
|
20
|
+
label: "Google AI API key",
|
|
21
|
+
placeholder: "AI…",
|
|
22
|
+
helpText:
|
|
23
|
+
"Create a key in Google AI Studio (aistudio.google.com). Kept in this tab's memory only (cleared on refresh).",
|
|
24
|
+
required: true,
|
|
25
|
+
secret: true,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
validateConfig: (cfg) => (!cfg.apiKey?.trim() ? "API key required" : null),
|
|
29
|
+
resolveModel: (cfg) => {
|
|
30
|
+
const google = createGoogleGenerativeAI({ apiKey: cfg.apiKey! });
|
|
31
|
+
return google.chat(cfg.model);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PreloadProgress,
|
|
3
|
+
ProviderConfig,
|
|
4
|
+
ProviderId,
|
|
5
|
+
ProviderRegistry,
|
|
6
|
+
StreamEvent,
|
|
7
|
+
StreamProvider,
|
|
8
|
+
StreamRequest,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export const PROVIDERS: ProviderRegistry = {
|
|
12
|
+
anthropic: () => import("./anthropic").then((m) => m.anthropicProvider),
|
|
13
|
+
openai: () => import("./openai").then((m) => m.openaiProvider),
|
|
14
|
+
"openai-realtime": () =>
|
|
15
|
+
import("./openaiRealtime").then((m) => m.openaiRealtimeProvider),
|
|
16
|
+
google: () => import("./google").then((m) => m.googleProvider),
|
|
17
|
+
ollama: () => import("./ollama").then((m) => m.ollamaProvider),
|
|
18
|
+
webllm: () => import("./webllm").then((m) => m.webllmProvider),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function loadProvider(id: ProviderId): Promise<StreamProvider> {
|
|
22
|
+
return PROVIDERS[id]();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type {
|
|
26
|
+
PreloadProgress,
|
|
27
|
+
ProviderConfig,
|
|
28
|
+
ProviderId,
|
|
29
|
+
StreamEvent,
|
|
30
|
+
StreamProvider,
|
|
31
|
+
StreamRequest,
|
|
32
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StreamEvent } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize the `ai` SDK's free-form `finishReason` string into the closed
|
|
5
|
+
* union our `StreamProvider` contract emits. Shared by every cloud provider
|
|
6
|
+
* (`anthropic`, `openai`, `google`, `ollama`) so the mapping is defined once.
|
|
7
|
+
*
|
|
8
|
+
* Unknown / undefined reasons fall through to `"stop"` since callers treat
|
|
9
|
+
* that as the "completed normally" baseline.
|
|
10
|
+
*/
|
|
11
|
+
export type FinishReason = Extract<StreamEvent, { type: "finish" }>["finishReason"];
|
|
12
|
+
|
|
13
|
+
export function mapFinishReason(r: string | undefined): FinishReason {
|
|
14
|
+
if (r === "length") return "length";
|
|
15
|
+
if (r === "error" || r === "content-filter") return "error";
|
|
16
|
+
if (r === "tool-calls") return "tool-call";
|
|
17
|
+
// The AI SDK currently emits `"unknown"` after an aborted stream. We can't
|
|
18
|
+
// distinguish that from a malformed response, but in practice the engine
|
|
19
|
+
// already detects abort via `signal.aborted` before reading the finish
|
|
20
|
+
// event so this branch is informational only.
|
|
21
|
+
if (r === "abort") return "abort";
|
|
22
|
+
return "stop";
|
|
23
|
+
}
|