@tomehq/theme 0.1.0
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/dist/chunk-BZGWSKT2.js +573 -0
- package/dist/chunk-FWBTK5TL.js +1444 -0
- package/dist/chunk-JZRT4WNC.js +1441 -0
- package/dist/chunk-LIMYFTPC.js +1468 -0
- package/dist/chunk-MEP7P6A7.js +1500 -0
- package/dist/chunk-QCWZYABW.js +1468 -0
- package/dist/chunk-RKTT3ZEX.js +1500 -0
- package/dist/chunk-UKYFJSUA.js +509 -0
- package/dist/entry.d.ts +5 -0
- package/dist/entry.js +6 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +12 -0
- package/package.json +52 -0
- package/src/AiChat.test.tsx +308 -0
- package/src/AiChat.tsx +439 -0
- package/src/Shell.test.tsx +565 -0
- package/src/Shell.tsx +827 -0
- package/src/entry.tsx +191 -0
- package/src/global.d.ts +22 -0
- package/src/index.tsx +6 -0
- package/src/presets.test.ts +59 -0
- package/src/presets.ts +51 -0
- package/src/test-setup.ts +5 -0
- package/src/virtual.d.ts +14 -0
- package/tsconfig.json +6 -0
- package/vitest.config.ts +21 -0
package/src/AiChat.tsx
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
// ── TYPES ────────────────────────────────────────────────
|
|
4
|
+
export interface AiChatProps {
|
|
5
|
+
provider: "openai" | "anthropic" | "custom";
|
|
6
|
+
model?: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
context?: string; // serialized doc context for RAG
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Message {
|
|
12
|
+
role: "user" | "assistant";
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── ICONS ────────────────────────────────────────────────
|
|
17
|
+
const ChatIcon = () => (
|
|
18
|
+
<svg width={22} height={22} viewBox="0 0 24 24" fill="none"
|
|
19
|
+
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
20
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const CloseIcon = () => (
|
|
25
|
+
<svg width={18} height={18} viewBox="0 0 24 24" fill="none"
|
|
26
|
+
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
27
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const SendIcon = () => (
|
|
32
|
+
<svg width={16} height={16} viewBox="0 0 24 24" fill="none"
|
|
33
|
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
34
|
+
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4z" />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// ── API HELPERS ──────────────────────────────────────────
|
|
39
|
+
function buildSystemPrompt(context?: string): string {
|
|
40
|
+
let prompt = "You are a helpful documentation assistant. Answer questions accurately based on the documentation provided below. If the answer isn't in the documentation, say so clearly. Keep answers concise and reference specific sections when possible.";
|
|
41
|
+
if (context) {
|
|
42
|
+
// Truncate context to stay within token limits (~100K chars ≈ 25K tokens)
|
|
43
|
+
const trimmed = context.length > 100000 ? context.slice(0, 100000) + "\n\n[Documentation truncated...]" : context;
|
|
44
|
+
prompt += `\n\nDocumentation:\n${trimmed}`;
|
|
45
|
+
}
|
|
46
|
+
return prompt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function callOpenAI(
|
|
50
|
+
messages: Message[],
|
|
51
|
+
apiKey: string,
|
|
52
|
+
model: string,
|
|
53
|
+
context?: string,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
messages: [
|
|
64
|
+
{ role: "system", content: buildSystemPrompt(context) },
|
|
65
|
+
...messages.map((m) => ({ role: m.role, content: m.content })),
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const err = await res.text();
|
|
71
|
+
throw new Error(`OpenAI API error (${res.status}): ${err}`);
|
|
72
|
+
}
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
return data.choices?.[0]?.message?.content || "No response.";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function callAnthropic(
|
|
78
|
+
messages: Message[],
|
|
79
|
+
apiKey: string,
|
|
80
|
+
model: string,
|
|
81
|
+
context?: string,
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"x-api-key": apiKey,
|
|
88
|
+
"anthropic-version": "2023-06-01",
|
|
89
|
+
"anthropic-dangerous-direct-browser-access": "true",
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model,
|
|
93
|
+
max_tokens: 1024,
|
|
94
|
+
system: buildSystemPrompt(context),
|
|
95
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
const err = await res.text();
|
|
100
|
+
throw new Error(`Anthropic API error (${res.status}): ${err}`);
|
|
101
|
+
}
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
return data.content?.[0]?.text || "No response.";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getDefaultModel(provider: string): string {
|
|
107
|
+
if (provider === "openai") return "gpt-4o-mini";
|
|
108
|
+
return "claude-sonnet-4-20250514";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── COMPONENT ───────────────────────────────────────────
|
|
112
|
+
export function AiChat({ provider, model, apiKey, context }: AiChatProps) {
|
|
113
|
+
const [open, setOpen] = useState(false);
|
|
114
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
115
|
+
const [input, setInput] = useState("");
|
|
116
|
+
const [loading, setLoading] = useState(false);
|
|
117
|
+
const [error, setError] = useState<string | null>(null);
|
|
118
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
119
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
120
|
+
|
|
121
|
+
const resolvedKey = apiKey || (typeof window !== "undefined" ? (window as any).__TOME_AI_KEY__ : undefined);
|
|
122
|
+
const resolvedModel = model || getDefaultModel(provider);
|
|
123
|
+
|
|
124
|
+
// Scroll to bottom on new messages
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
127
|
+
}, [messages]);
|
|
128
|
+
|
|
129
|
+
// Focus input when panel opens
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (open) {
|
|
132
|
+
setTimeout(() => inputRef.current?.focus(), 100);
|
|
133
|
+
}
|
|
134
|
+
}, [open]);
|
|
135
|
+
|
|
136
|
+
const sendMessage = useCallback(async () => {
|
|
137
|
+
const text = input.trim();
|
|
138
|
+
if (!text || loading) return;
|
|
139
|
+
if (!resolvedKey) return;
|
|
140
|
+
|
|
141
|
+
const userMsg: Message = { role: "user", content: text };
|
|
142
|
+
const updatedMessages = [...messages, userMsg];
|
|
143
|
+
setMessages(updatedMessages);
|
|
144
|
+
setInput("");
|
|
145
|
+
setLoading(true);
|
|
146
|
+
setError(null);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
let response: string;
|
|
150
|
+
if (provider === "openai") {
|
|
151
|
+
response = await callOpenAI(updatedMessages, resolvedKey, resolvedModel, context);
|
|
152
|
+
} else {
|
|
153
|
+
response = await callAnthropic(updatedMessages, resolvedKey, resolvedModel, context);
|
|
154
|
+
}
|
|
155
|
+
setMessages((prev) => [...prev, { role: "assistant", content: response }]);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
setError(err instanceof Error ? err.message : "Failed to get response");
|
|
158
|
+
} finally {
|
|
159
|
+
setLoading(false);
|
|
160
|
+
}
|
|
161
|
+
}, [input, loading, messages, provider, resolvedKey, resolvedModel, context]);
|
|
162
|
+
|
|
163
|
+
const handleKeyDown = useCallback(
|
|
164
|
+
(e: React.KeyboardEvent) => {
|
|
165
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
sendMessage();
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[sendMessage],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// ── Floating button (closed state) ─────────────────────
|
|
174
|
+
if (!open) {
|
|
175
|
+
return (
|
|
176
|
+
<button
|
|
177
|
+
data-testid="ai-chat-button"
|
|
178
|
+
onClick={() => setOpen(true)}
|
|
179
|
+
aria-label="Open AI chat"
|
|
180
|
+
style={{
|
|
181
|
+
position: "fixed",
|
|
182
|
+
bottom: 24,
|
|
183
|
+
right: 24,
|
|
184
|
+
zIndex: 900,
|
|
185
|
+
width: 48,
|
|
186
|
+
height: 48,
|
|
187
|
+
borderRadius: "50%",
|
|
188
|
+
background: "var(--ac)",
|
|
189
|
+
color: "#fff",
|
|
190
|
+
border: "none",
|
|
191
|
+
cursor: "pointer",
|
|
192
|
+
display: "flex",
|
|
193
|
+
alignItems: "center",
|
|
194
|
+
justifyContent: "center",
|
|
195
|
+
boxShadow: "0 4px 16px rgba(0,0,0,0.25)",
|
|
196
|
+
transition: "transform 0.15s",
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<ChatIcon />
|
|
200
|
+
</button>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Chat panel (open state) ────────────────────────────
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
data-testid="ai-chat-panel"
|
|
208
|
+
style={{
|
|
209
|
+
position: "fixed",
|
|
210
|
+
bottom: 24,
|
|
211
|
+
right: 24,
|
|
212
|
+
zIndex: 900,
|
|
213
|
+
width: 380,
|
|
214
|
+
maxWidth: "calc(100vw - 48px)",
|
|
215
|
+
height: 520,
|
|
216
|
+
maxHeight: "calc(100vh - 48px)",
|
|
217
|
+
background: "var(--sf)",
|
|
218
|
+
border: "1px solid var(--bd)",
|
|
219
|
+
borderRadius: 12,
|
|
220
|
+
boxShadow: "0 16px 64px rgba(0,0,0,0.3)",
|
|
221
|
+
display: "flex",
|
|
222
|
+
flexDirection: "column",
|
|
223
|
+
overflow: "hidden",
|
|
224
|
+
fontFamily: "var(--font-body)",
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{/* Header */}
|
|
228
|
+
<div
|
|
229
|
+
style={{
|
|
230
|
+
display: "flex",
|
|
231
|
+
alignItems: "center",
|
|
232
|
+
justifyContent: "space-between",
|
|
233
|
+
padding: "12px 16px",
|
|
234
|
+
borderBottom: "1px solid var(--bd)",
|
|
235
|
+
flexShrink: 0,
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<span
|
|
239
|
+
style={{
|
|
240
|
+
fontSize: 14,
|
|
241
|
+
fontWeight: 600,
|
|
242
|
+
color: "var(--tx)",
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
Ask AI
|
|
246
|
+
</span>
|
|
247
|
+
<button
|
|
248
|
+
data-testid="ai-chat-close"
|
|
249
|
+
onClick={() => setOpen(false)}
|
|
250
|
+
aria-label="Close AI chat"
|
|
251
|
+
style={{
|
|
252
|
+
background: "none",
|
|
253
|
+
border: "none",
|
|
254
|
+
color: "var(--txM)",
|
|
255
|
+
cursor: "pointer",
|
|
256
|
+
display: "flex",
|
|
257
|
+
padding: 4,
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<CloseIcon />
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{/* Messages */}
|
|
265
|
+
<div
|
|
266
|
+
style={{
|
|
267
|
+
flex: 1,
|
|
268
|
+
overflow: "auto",
|
|
269
|
+
padding: "12px 16px",
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{!resolvedKey && (
|
|
273
|
+
<div
|
|
274
|
+
data-testid="ai-chat-no-key"
|
|
275
|
+
style={{
|
|
276
|
+
textAlign: "center",
|
|
277
|
+
color: "var(--txM)",
|
|
278
|
+
fontSize: 13,
|
|
279
|
+
padding: "24px 8px",
|
|
280
|
+
lineHeight: 1.6,
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<p style={{ marginBottom: 8, fontWeight: 500, color: "var(--tx)" }}>AI not configured</p>
|
|
284
|
+
<p style={{ marginBottom: 8 }}>
|
|
285
|
+
To enable AI chat, set the <code style={{
|
|
286
|
+
fontFamily: "var(--font-code)",
|
|
287
|
+
fontSize: "0.88em",
|
|
288
|
+
background: "var(--cdBg)",
|
|
289
|
+
padding: "0.15em 0.4em",
|
|
290
|
+
borderRadius: 4,
|
|
291
|
+
}}>apiKeyEnv</code> in <code style={{
|
|
292
|
+
fontFamily: "var(--font-code)",
|
|
293
|
+
fontSize: "0.88em",
|
|
294
|
+
background: "var(--cdBg)",
|
|
295
|
+
padding: "0.15em 0.4em",
|
|
296
|
+
borderRadius: 4,
|
|
297
|
+
}}>tome.config.js</code> and provide the environment variable at build time.
|
|
298
|
+
</p>
|
|
299
|
+
<p style={{ fontSize: 11.5, color: "var(--txM)" }}>
|
|
300
|
+
Example: <code style={{
|
|
301
|
+
fontFamily: "var(--font-code)",
|
|
302
|
+
fontSize: "0.88em",
|
|
303
|
+
background: "var(--cdBg)",
|
|
304
|
+
padding: "0.15em 0.4em",
|
|
305
|
+
borderRadius: 4,
|
|
306
|
+
}}>TOME_AI_KEY=sk-... tome build</code>
|
|
307
|
+
</p>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
|
|
311
|
+
{messages.map((msg, i) => (
|
|
312
|
+
<div
|
|
313
|
+
key={i}
|
|
314
|
+
data-testid={`ai-chat-message-${msg.role}`}
|
|
315
|
+
style={{
|
|
316
|
+
marginBottom: 12,
|
|
317
|
+
display: "flex",
|
|
318
|
+
justifyContent: msg.role === "user" ? "flex-end" : "flex-start",
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<div
|
|
322
|
+
style={{
|
|
323
|
+
maxWidth: "85%",
|
|
324
|
+
padding: "8px 12px",
|
|
325
|
+
borderRadius: 10,
|
|
326
|
+
fontSize: 13,
|
|
327
|
+
lineHeight: 1.55,
|
|
328
|
+
whiteSpace: "pre-wrap",
|
|
329
|
+
wordBreak: "break-word",
|
|
330
|
+
background:
|
|
331
|
+
msg.role === "user" ? "var(--ac)" : "var(--cdBg)",
|
|
332
|
+
color: msg.role === "user" ? "#fff" : "var(--tx)",
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
{msg.content}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
))}
|
|
339
|
+
|
|
340
|
+
{loading && (
|
|
341
|
+
<div
|
|
342
|
+
data-testid="ai-chat-loading"
|
|
343
|
+
style={{
|
|
344
|
+
display: "flex",
|
|
345
|
+
justifyContent: "flex-start",
|
|
346
|
+
marginBottom: 12,
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<div
|
|
350
|
+
style={{
|
|
351
|
+
padding: "8px 12px",
|
|
352
|
+
borderRadius: 10,
|
|
353
|
+
fontSize: 13,
|
|
354
|
+
background: "var(--cdBg)",
|
|
355
|
+
color: "var(--txM)",
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
Thinking...
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{error && (
|
|
364
|
+
<div
|
|
365
|
+
data-testid="ai-chat-error"
|
|
366
|
+
style={{
|
|
367
|
+
padding: "8px 12px",
|
|
368
|
+
borderRadius: 8,
|
|
369
|
+
fontSize: 12,
|
|
370
|
+
background: "rgba(220,50,50,0.1)",
|
|
371
|
+
color: "#d44",
|
|
372
|
+
marginBottom: 12,
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
{error}
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
<div ref={messagesEndRef} />
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{/* Input */}
|
|
383
|
+
<div
|
|
384
|
+
style={{
|
|
385
|
+
display: "flex",
|
|
386
|
+
alignItems: "center",
|
|
387
|
+
gap: 8,
|
|
388
|
+
padding: "10px 12px",
|
|
389
|
+
borderTop: "1px solid var(--bd)",
|
|
390
|
+
flexShrink: 0,
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
<input
|
|
394
|
+
ref={inputRef}
|
|
395
|
+
data-testid="ai-chat-input"
|
|
396
|
+
value={input}
|
|
397
|
+
onChange={(e) => setInput(e.target.value)}
|
|
398
|
+
onKeyDown={handleKeyDown}
|
|
399
|
+
placeholder={resolvedKey ? "Ask a question..." : "API key required"}
|
|
400
|
+
disabled={!resolvedKey}
|
|
401
|
+
style={{
|
|
402
|
+
flex: 1,
|
|
403
|
+
background: "var(--cdBg)",
|
|
404
|
+
border: "1px solid var(--bd)",
|
|
405
|
+
borderRadius: 8,
|
|
406
|
+
padding: "8px 12px",
|
|
407
|
+
color: "var(--tx)",
|
|
408
|
+
fontSize: 13,
|
|
409
|
+
fontFamily: "var(--font-body)",
|
|
410
|
+
outline: "none",
|
|
411
|
+
}}
|
|
412
|
+
/>
|
|
413
|
+
<button
|
|
414
|
+
data-testid="ai-chat-send"
|
|
415
|
+
onClick={sendMessage}
|
|
416
|
+
disabled={!resolvedKey || !input.trim() || loading}
|
|
417
|
+
aria-label="Send message"
|
|
418
|
+
style={{
|
|
419
|
+
width: 34,
|
|
420
|
+
height: 34,
|
|
421
|
+
borderRadius: 8,
|
|
422
|
+
background: resolvedKey && input.trim() ? "var(--ac)" : "var(--cdBg)",
|
|
423
|
+
color: resolvedKey && input.trim() ? "#fff" : "var(--txM)",
|
|
424
|
+
border: "none",
|
|
425
|
+
cursor: resolvedKey && input.trim() ? "pointer" : "default",
|
|
426
|
+
display: "flex",
|
|
427
|
+
alignItems: "center",
|
|
428
|
+
justifyContent: "center",
|
|
429
|
+
flexShrink: 0,
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
<SendIcon />
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export default AiChat;
|