@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,44 @@
|
|
|
1
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
2
|
+
|
|
3
|
+
import { createCloudProvider } from "./createCloudProvider";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE = "http://localhost:11434/v1";
|
|
6
|
+
|
|
7
|
+
function normalizeBaseUrl(raw?: string): string {
|
|
8
|
+
const s = raw?.trim() || DEFAULT_BASE;
|
|
9
|
+
return s.replace(/\/+$/, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ollamaProvider = createCloudProvider({
|
|
13
|
+
id: "ollama",
|
|
14
|
+
displayName: "Ollama / compatible",
|
|
15
|
+
tagline: "Local server · Ollama / LM Studio / OpenAI-compatible",
|
|
16
|
+
models: [
|
|
17
|
+
{ id: "llama3.2", label: "llama3.2" },
|
|
18
|
+
{ id: "llama3.2:3b", label: "llama3.2:3b" },
|
|
19
|
+
{ id: "qwen2.5:3b", label: "qwen2.5:3b" },
|
|
20
|
+
{ id: "phi4:latest", label: "phi4:latest" },
|
|
21
|
+
],
|
|
22
|
+
configFields: [
|
|
23
|
+
{
|
|
24
|
+
key: "baseUrl",
|
|
25
|
+
label: "OpenAI-compatible base URL",
|
|
26
|
+
placeholder: DEFAULT_BASE,
|
|
27
|
+
helpText:
|
|
28
|
+
"Ollama defaults to http://localhost:11434/v1. For browser access you must allow CORS on the server (e.g. OLLAMA_ORIGINS='https://yoursite.example' ollama serve).",
|
|
29
|
+
required: false,
|
|
30
|
+
secret: false,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
validateConfig: (cfg) => (!cfg.model?.trim() ? "Model name required" : null),
|
|
34
|
+
resolveModel: (cfg) => {
|
|
35
|
+
const baseURL = normalizeBaseUrl(cfg.baseUrl);
|
|
36
|
+
const ollama = createOpenAICompatible({
|
|
37
|
+
name: "ollama",
|
|
38
|
+
baseURL,
|
|
39
|
+
apiKey: "ollama",
|
|
40
|
+
includeUsage: true,
|
|
41
|
+
});
|
|
42
|
+
return ollama.chatModel(cfg.model);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
2
|
+
|
|
3
|
+
import { createCloudProvider } from "./createCloudProvider";
|
|
4
|
+
|
|
5
|
+
export const openaiProvider = createCloudProvider({
|
|
6
|
+
id: "openai",
|
|
7
|
+
displayName: "OpenAI",
|
|
8
|
+
tagline: "Cloud · BYOK · GPT-5 family",
|
|
9
|
+
models: [
|
|
10
|
+
{ id: "gpt-5.5", label: "GPT-5.5", note: "flagship" },
|
|
11
|
+
{ id: "gpt-5.4", label: "GPT-5.4" },
|
|
12
|
+
{ id: "gpt-5.4-mini", label: "GPT-5.4 mini", note: "fast" },
|
|
13
|
+
{ id: "gpt-5.4-nano", label: "GPT-5.4 nano", note: "budget" },
|
|
14
|
+
{ id: "gpt-4o", label: "GPT-4o", note: "legacy" },
|
|
15
|
+
{ id: "gpt-4o-mini", label: "GPT-4o mini" },
|
|
16
|
+
{ id: "o3-mini", label: "o3-mini", note: "reasoning" },
|
|
17
|
+
],
|
|
18
|
+
configFields: [
|
|
19
|
+
{
|
|
20
|
+
key: "apiKey",
|
|
21
|
+
label: "OpenAI API key",
|
|
22
|
+
placeholder: "sk-…",
|
|
23
|
+
helpText:
|
|
24
|
+
"Get one at platform.openai.com. Kept in this tab's memory only (cleared on refresh). Requests are made directly from the browser.",
|
|
25
|
+
required: true,
|
|
26
|
+
secret: true,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
validateConfig: (cfg) => (!cfg.apiKey?.trim() ? "API key required" : null),
|
|
30
|
+
resolveModel: (cfg) => {
|
|
31
|
+
const openai = createOpenAI({ apiKey: cfg.apiKey! });
|
|
32
|
+
return openai.chat(cfg.model);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Realtime API provider — text-mode streaming.
|
|
3
|
+
*
|
|
4
|
+
* Realtime's headline feature is bidirectional voice (audio in / audio
|
|
5
|
+
* out via WebRTC). This provider uses Realtime's WebSocket text mode so
|
|
6
|
+
* the regular text-chat panel can benefit from the API's persistent
|
|
7
|
+
* session model and lower per-turn latency. Voice integration is a
|
|
8
|
+
* future-work item and would replace the entire VoiceMode pipeline
|
|
9
|
+
* (browser STT + native TTS go away in favour of one WebRTC peer
|
|
10
|
+
* connection); see `voiceFsm.ts` for the FSM the future voice variant
|
|
11
|
+
* would dispatch into.
|
|
12
|
+
*
|
|
13
|
+
* Auth model:
|
|
14
|
+
*
|
|
15
|
+
* Browsers MUST NOT hit the Realtime endpoint with a long-lived API
|
|
16
|
+
* key. The user (or the docs site operator) needs to stand up a small
|
|
17
|
+
* server endpoint that returns a short-lived `client_secret` minted
|
|
18
|
+
* via OpenAI's REST API. The expected response shape is:
|
|
19
|
+
*
|
|
20
|
+
* POST <tokenEndpoint>
|
|
21
|
+
* → { client_secret: { value: "ek_…", expires_at: <unix ts> } }
|
|
22
|
+
*
|
|
23
|
+
* We POST with no body (the endpoint can attach the model + voice it
|
|
24
|
+
* wants behind the scenes). The endpoint should rate-limit and
|
|
25
|
+
* authenticate its callers.
|
|
26
|
+
*
|
|
27
|
+
* For prototyping, the user can paste a raw OpenAI API key in the
|
|
28
|
+
* `apiKey` field instead — the provider will pass it as the
|
|
29
|
+
* `Authorization: Bearer …` header. This is INSECURE for production
|
|
30
|
+
* (anyone visiting the page sees the key in the network tab) but
|
|
31
|
+
* matches the existing escape hatch we expose for direct Anthropic
|
|
32
|
+
* browser access; the help text makes the trade-off explicit.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { mapFinishReason } from "./mapFinishReason";
|
|
36
|
+
import type {
|
|
37
|
+
ProviderConfig,
|
|
38
|
+
StreamEvent,
|
|
39
|
+
StreamProvider,
|
|
40
|
+
StreamRequest,
|
|
41
|
+
} from "./types";
|
|
42
|
+
|
|
43
|
+
const REALTIME_WS = "wss://api.openai.com/v1/realtime";
|
|
44
|
+
|
|
45
|
+
interface ClientSecretResponse {
|
|
46
|
+
readonly client_secret?: {
|
|
47
|
+
readonly value: string;
|
|
48
|
+
readonly expires_at?: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchEphemeralKey(endpoint: string): Promise<string> {
|
|
53
|
+
const res = await fetch(endpoint, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Token endpoint ${endpoint} returned ${res.status}. Configure it to mint OpenAI Realtime ephemeral keys.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const body = (await res.json()) as ClientSecretResponse;
|
|
63
|
+
const value = body.client_secret?.value;
|
|
64
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Token endpoint response missing `client_secret.value`. See OpenAI Realtime ephemeral keys docs.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface RealtimeServerEvent {
|
|
73
|
+
readonly type: string;
|
|
74
|
+
readonly response?: {
|
|
75
|
+
readonly status_details?: { readonly type?: string };
|
|
76
|
+
readonly usage?: {
|
|
77
|
+
readonly input_tokens?: number;
|
|
78
|
+
readonly output_tokens?: number;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
readonly delta?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Stream a text response using OpenAI's Realtime WebSocket. Yields
|
|
86
|
+
* `text-delta` events as `response.text.delta` server events arrive,
|
|
87
|
+
* then a single `finish` once `response.done` lands.
|
|
88
|
+
*/
|
|
89
|
+
async function* streamRealtimeText(
|
|
90
|
+
req: StreamRequest,
|
|
91
|
+
cfg: ProviderConfig,
|
|
92
|
+
): AsyncIterable<StreamEvent> {
|
|
93
|
+
if (typeof WebSocket === "undefined") {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"OpenAI Realtime requires a browser with WebSocket support.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let token: string;
|
|
100
|
+
if (cfg.tokenEndpoint?.trim()) {
|
|
101
|
+
token = await fetchEphemeralKey(cfg.tokenEndpoint.trim());
|
|
102
|
+
} else if (cfg.apiKey?.trim()) {
|
|
103
|
+
token = cfg.apiKey.trim();
|
|
104
|
+
} else {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"OpenAI Realtime needs either a token endpoint or an API key. Configure one in Settings → OpenAI Realtime.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const url = `${REALTIME_WS}?model=${encodeURIComponent(cfg.model)}`;
|
|
111
|
+
const ws = new WebSocket(url, ["realtime", `openai-insecure-api-key.${token}`, "openai-beta.realtime-v1"]);
|
|
112
|
+
|
|
113
|
+
// Pipeline: events → channel queue → consumer.
|
|
114
|
+
const queue: StreamEvent[] = [];
|
|
115
|
+
let resolveNext: (() => void) | null = null;
|
|
116
|
+
let closed = false;
|
|
117
|
+
let error: Error | null = null;
|
|
118
|
+
|
|
119
|
+
const push = (ev: StreamEvent): void => {
|
|
120
|
+
queue.push(ev);
|
|
121
|
+
if (resolveNext) {
|
|
122
|
+
const r = resolveNext;
|
|
123
|
+
resolveNext = null;
|
|
124
|
+
r();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const finishWithError = (err: Error): void => {
|
|
129
|
+
error = err;
|
|
130
|
+
closed = true;
|
|
131
|
+
if (resolveNext) {
|
|
132
|
+
const r = resolveNext;
|
|
133
|
+
resolveNext = null;
|
|
134
|
+
r();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onAbort = (): void => {
|
|
139
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
140
|
+
try {
|
|
141
|
+
ws.close();
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
push({ type: "finish", finishReason: "abort" });
|
|
147
|
+
closed = true;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (req.signal) {
|
|
151
|
+
if (req.signal.aborted) {
|
|
152
|
+
onAbort();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ws.addEventListener("open", () => {
|
|
159
|
+
// Configure the session for text-only mode. Modalities = ["text"]
|
|
160
|
+
// turns off audio entirely so we don't pay for streaming audio
|
|
161
|
+
// tokens we don't use.
|
|
162
|
+
ws.send(
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
type: "session.update",
|
|
165
|
+
session: {
|
|
166
|
+
modalities: ["text"],
|
|
167
|
+
instructions: req.system,
|
|
168
|
+
temperature: req.temperature ?? 0.4,
|
|
169
|
+
max_response_output_tokens: req.maxTokens ?? 1024,
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
// Stream the chat history as conversation items.
|
|
174
|
+
for (const m of req.messages) {
|
|
175
|
+
ws.send(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
type: "conversation.item.create",
|
|
178
|
+
item: {
|
|
179
|
+
type: "message",
|
|
180
|
+
role: m.role,
|
|
181
|
+
content: [{ type: "input_text", text: m.content }],
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
ws.send(
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
type: "response.create",
|
|
189
|
+
response: { modalities: ["text"] },
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
ws.addEventListener("message", (ev: MessageEvent<string>) => {
|
|
195
|
+
let msg: RealtimeServerEvent;
|
|
196
|
+
try {
|
|
197
|
+
msg = JSON.parse(ev.data) as RealtimeServerEvent;
|
|
198
|
+
} catch {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
switch (msg.type) {
|
|
202
|
+
case "response.text.delta":
|
|
203
|
+
case "response.output_text.delta": {
|
|
204
|
+
const delta = msg.delta;
|
|
205
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
206
|
+
push({ type: "text-delta", text: delta });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case "response.done": {
|
|
211
|
+
const usage = msg.response?.usage;
|
|
212
|
+
const stopType = msg.response?.status_details?.type;
|
|
213
|
+
push({
|
|
214
|
+
type: "finish",
|
|
215
|
+
finishReason: mapFinishReason(stopType),
|
|
216
|
+
inputTokens: usage?.input_tokens,
|
|
217
|
+
outputTokens: usage?.output_tokens,
|
|
218
|
+
});
|
|
219
|
+
try {
|
|
220
|
+
ws.close();
|
|
221
|
+
} catch {
|
|
222
|
+
// ignore
|
|
223
|
+
}
|
|
224
|
+
closed = true;
|
|
225
|
+
if (resolveNext) {
|
|
226
|
+
const r = resolveNext;
|
|
227
|
+
resolveNext = null;
|
|
228
|
+
r();
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
case "error": {
|
|
233
|
+
finishWithError(
|
|
234
|
+
new Error(`OpenAI Realtime error: ${ev.data.slice(0, 280)}`),
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
ws.addEventListener("error", () => {
|
|
244
|
+
finishWithError(new Error("OpenAI Realtime websocket error"));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
ws.addEventListener("close", () => {
|
|
248
|
+
closed = true;
|
|
249
|
+
if (resolveNext) {
|
|
250
|
+
const r = resolveNext;
|
|
251
|
+
resolveNext = null;
|
|
252
|
+
r();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
while (true) {
|
|
257
|
+
if (queue.length > 0) {
|
|
258
|
+
const ev = queue.shift()!;
|
|
259
|
+
yield ev;
|
|
260
|
+
if (ev.type === "finish") return;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (error) throw error;
|
|
264
|
+
if (closed) return;
|
|
265
|
+
await new Promise<void>((resolve) => {
|
|
266
|
+
resolveNext = resolve;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const openaiRealtimeProvider: StreamProvider = {
|
|
272
|
+
id: "openai-realtime",
|
|
273
|
+
displayName: "OpenAI Realtime (preview)",
|
|
274
|
+
tagline: "Cloud · WebSocket · text mode (voice integration coming)",
|
|
275
|
+
models: [
|
|
276
|
+
{
|
|
277
|
+
id: "gpt-realtime-preview",
|
|
278
|
+
label: "gpt-realtime-preview",
|
|
279
|
+
note: "voice/text",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "gpt-4o-realtime-preview",
|
|
283
|
+
label: "gpt-4o-realtime-preview",
|
|
284
|
+
note: "legacy",
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
configFields: [
|
|
288
|
+
{
|
|
289
|
+
key: "tokenEndpoint",
|
|
290
|
+
label: "Ephemeral token endpoint",
|
|
291
|
+
placeholder: "https://your-backend.example/realtime-token",
|
|
292
|
+
helpText:
|
|
293
|
+
"URL of a small server endpoint that returns OpenAI Realtime client secrets. Recommended for production. See the file header in `streamProviders/openaiRealtime.ts` for the expected response shape.",
|
|
294
|
+
required: false,
|
|
295
|
+
secret: false,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
key: "apiKey",
|
|
299
|
+
label: "OpenAI API key (insecure direct access)",
|
|
300
|
+
placeholder: "sk-…",
|
|
301
|
+
helpText:
|
|
302
|
+
"Prototyping only — the key is visible to anyone using this page. Prefer the token endpoint above for any deployment.",
|
|
303
|
+
required: false,
|
|
304
|
+
secret: true,
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
validateConfig: (cfg) => {
|
|
308
|
+
if (!cfg.tokenEndpoint?.trim() && !cfg.apiKey?.trim()) {
|
|
309
|
+
return "Configure either a token endpoint or an API key";
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
cfg.tokenEndpoint?.trim() &&
|
|
313
|
+
!/^https?:\/\//i.test(cfg.tokenEndpoint.trim())
|
|
314
|
+
) {
|
|
315
|
+
return "Token endpoint must be an http(s) URL";
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
},
|
|
319
|
+
stream: (req, cfg) => streamRealtimeText(req, cfg),
|
|
320
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-provider streaming contract for the in-browser documentation chat.
|
|
3
|
+
*
|
|
4
|
+
* All providers — cloud (Anthropic, OpenAI, Google, Ollama) and local
|
|
5
|
+
* (WebLLM via WebGPU) — implement this interface. The chat engine selects
|
|
6
|
+
* one at runtime based on user settings; secrets live in memory only
|
|
7
|
+
* (`secretStore.ts`), non-secret fields (provider id, chosen model,
|
|
8
|
+
* optional base URL) live in `localStorage` under `grimoire.chat.*`.
|
|
9
|
+
*
|
|
10
|
+
* Providers are loaded on-demand (dynamic `import()`) so the WebLLM runtime
|
|
11
|
+
* never enters the bundle of users who pick a cloud provider, and cloud
|
|
12
|
+
* SDKs never load for local-only users.
|
|
13
|
+
*
|
|
14
|
+
* DO NOT modify this file without coordinating with both ends:
|
|
15
|
+
* - Provider impls live in `./anthropic.ts`, `./openai.ts`, `./google.ts`,
|
|
16
|
+
* `./ollama.ts`, `./webllm.ts`.
|
|
17
|
+
* - Consumer is `useChatEngine.ts`, which dispatches on `ProviderId`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ProviderId =
|
|
21
|
+
| "anthropic"
|
|
22
|
+
| "openai"
|
|
23
|
+
| "openai-realtime"
|
|
24
|
+
| "google"
|
|
25
|
+
| "ollama"
|
|
26
|
+
| "webllm";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Auth + connection settings for a single provider.
|
|
30
|
+
*
|
|
31
|
+
* - `id`, `model`, `baseUrl` are read from `localStorage` under
|
|
32
|
+
* `grimoire.chat.<id>.*` (see `STORAGE_KEYS`).
|
|
33
|
+
* - `apiKey` is read from the in-memory `secretStore` only. Cleared on
|
|
34
|
+
* tab refresh — deliberate; do not persist.
|
|
35
|
+
*/
|
|
36
|
+
export interface ProviderConfig {
|
|
37
|
+
readonly id: ProviderId;
|
|
38
|
+
readonly model: string;
|
|
39
|
+
/** Cloud providers (anthropic, openai, google) — required. In-memory only. */
|
|
40
|
+
readonly apiKey?: string;
|
|
41
|
+
/** Ollama — defaults to `http://localhost:11434`. */
|
|
42
|
+
readonly baseUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* OpenAI Realtime — URL of a server endpoint that mints ephemeral
|
|
45
|
+
* client_secrets via the OpenAI REST API. Browsers cannot safely use
|
|
46
|
+
* a long-lived API key against the Realtime endpoint, so the user
|
|
47
|
+
* must provide a small backend that returns `{ client_secret: { value, expires_at } }`
|
|
48
|
+
* on each call. See `streamProviders/openaiRealtime.ts` for the
|
|
49
|
+
* expected response shape.
|
|
50
|
+
*/
|
|
51
|
+
readonly tokenEndpoint?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Single user/assistant exchange in the rolling chat history. The system
|
|
56
|
+
* prompt + RAG context is passed separately via `StreamRequest.system`.
|
|
57
|
+
*/
|
|
58
|
+
export interface ChatTurn {
|
|
59
|
+
readonly role: "user" | "assistant";
|
|
60
|
+
readonly content: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StreamRequest {
|
|
64
|
+
/** Concatenated system prompt: identity + persona + RAG context block. */
|
|
65
|
+
readonly system: string;
|
|
66
|
+
/** Multi-turn chat history. The current user question is the last entry. */
|
|
67
|
+
readonly messages: readonly ChatTurn[];
|
|
68
|
+
/** Soft cap on output tokens. Provider may clamp to its own limits. */
|
|
69
|
+
readonly maxTokens?: number;
|
|
70
|
+
/** Sampling temperature. Default 0.4 if omitted. */
|
|
71
|
+
readonly temperature?: number;
|
|
72
|
+
/** Cancel the in-flight request. */
|
|
73
|
+
readonly signal?: AbortSignal;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A streamed event from `StreamProvider.stream`. The async iterable yields
|
|
78
|
+
* zero-or-more `text-delta` events, then exactly one `finish` event.
|
|
79
|
+
*/
|
|
80
|
+
export type StreamEvent =
|
|
81
|
+
| { readonly type: "text-delta"; readonly text: string }
|
|
82
|
+
| {
|
|
83
|
+
readonly type: "finish";
|
|
84
|
+
readonly finishReason: "stop" | "length" | "tool-call" | "error" | "abort";
|
|
85
|
+
readonly inputTokens?: number;
|
|
86
|
+
readonly outputTokens?: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Progress info for slow init steps (e.g. WebLLM model download). */
|
|
90
|
+
export interface PreloadProgress {
|
|
91
|
+
readonly phase: string;
|
|
92
|
+
readonly message: string;
|
|
93
|
+
readonly loaded?: number;
|
|
94
|
+
readonly total?: number;
|
|
95
|
+
readonly fraction?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A single configurable model option a provider exposes in the UI. */
|
|
99
|
+
export interface ModelOption {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly label: string;
|
|
102
|
+
/** Optional hint shown next to the model name (e.g. "fast", "smart"). */
|
|
103
|
+
readonly note?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Required config field schema (rendered in SettingsPanel per provider). */
|
|
107
|
+
export interface ConfigField {
|
|
108
|
+
readonly key: "apiKey" | "baseUrl" | "tokenEndpoint";
|
|
109
|
+
readonly label: string;
|
|
110
|
+
readonly placeholder?: string;
|
|
111
|
+
readonly helpText?: string;
|
|
112
|
+
readonly required: boolean;
|
|
113
|
+
/** UI hint: should the value be masked (passwords)? */
|
|
114
|
+
readonly secret: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A single AI provider plugged into the chat engine. */
|
|
118
|
+
export interface StreamProvider {
|
|
119
|
+
readonly id: ProviderId;
|
|
120
|
+
readonly displayName: string;
|
|
121
|
+
/** One-line tagline shown in the provider picker. */
|
|
122
|
+
readonly tagline: string;
|
|
123
|
+
/** Models the user can pick from. The first entry is the default. */
|
|
124
|
+
readonly models: readonly ModelOption[];
|
|
125
|
+
/** Config fields (api key, base URL, etc.) the SettingsPanel renders. */
|
|
126
|
+
readonly configFields: readonly ConfigField[];
|
|
127
|
+
/** Returns null if config is valid; otherwise a human-readable error. */
|
|
128
|
+
readonly validateConfig: (config: ProviderConfig) => string | null;
|
|
129
|
+
/** Optional warm-up (download model, open WS, etc.). Cloud providers may resolve immediately. */
|
|
130
|
+
readonly preload?: (
|
|
131
|
+
config: ProviderConfig,
|
|
132
|
+
onProgress?: (info: PreloadProgress) => void,
|
|
133
|
+
) => Promise<void>;
|
|
134
|
+
/**
|
|
135
|
+
* Stream a chat completion. Yields text-delta events as tokens arrive,
|
|
136
|
+
* then exactly one finish event. Abort via `request.signal`.
|
|
137
|
+
*/
|
|
138
|
+
readonly stream: (
|
|
139
|
+
request: StreamRequest,
|
|
140
|
+
config: ProviderConfig,
|
|
141
|
+
) => AsyncIterable<StreamEvent>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Provider registry: maps id to a lazy loader that returns the impl.
|
|
146
|
+
* Implementations live in sibling files and are imported on demand.
|
|
147
|
+
*/
|
|
148
|
+
export type ProviderRegistry = Readonly<
|
|
149
|
+
Record<ProviderId, () => Promise<StreamProvider>>
|
|
150
|
+
>;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Storage key helpers for non-secret preferences (provider id, model,
|
|
154
|
+
* base URL). API keys live in `secretStore.ts` — never in localStorage.
|
|
155
|
+
*/
|
|
156
|
+
export const STORAGE_KEYS = {
|
|
157
|
+
activeProvider: "grimoire.chat.provider",
|
|
158
|
+
field: (
|
|
159
|
+
id: ProviderId,
|
|
160
|
+
key: Exclude<ConfigField["key"], "apiKey"> | "model",
|
|
161
|
+
): string => `grimoire.chat.${id}.${key}`,
|
|
162
|
+
} as const;
|
|
163
|
+
|
|
164
|
+
/** Default order shown in the provider picker dropdown. */
|
|
165
|
+
export const PROVIDER_ORDER: readonly ProviderId[] = [
|
|
166
|
+
"anthropic",
|
|
167
|
+
"openai",
|
|
168
|
+
"openai-realtime",
|
|
169
|
+
"google",
|
|
170
|
+
"ollama",
|
|
171
|
+
"webllm",
|
|
172
|
+
] as const;
|