@tomehq/theme 0.6.4 → 0.7.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/dist/ai-api-6KVITVN6.js +14 -0
- package/dist/{chunk-XTCRYWCB.js → chunk-6AD5TJ6F.js} +426 -87
- package/dist/chunk-LNWYFDZ4.js +73 -0
- package/dist/entry.js +2 -1
- package/dist/index.d.ts +250 -4
- package/dist/index.js +2 -1
- package/package.json +3 -3
- package/src/AiChat.tsx +11 -74
- package/src/Shell.tsx +168 -15
- package/src/__tests__/ai-api.test.ts +42 -0
- package/src/__tests__/ai-search.test.tsx +205 -0
- package/src/__tests__/feedback-widget.test.tsx +132 -0
- package/src/ai-api.ts +110 -0
- package/src/entry-helpers.ts +8 -3
- package/src/entry.test.tsx +6 -0
- package/src/entry.tsx +3 -0
- package/src/presets.ts +93 -3
- package/src/routing.test.ts +2 -3
- package/src/routing.ts +12 -4
package/src/Shell.tsx
CHANGED
|
@@ -342,11 +342,13 @@ interface ShellProps {
|
|
|
342
342
|
config: {
|
|
343
343
|
name: string;
|
|
344
344
|
theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
|
|
345
|
-
search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
|
|
345
|
+
search?: { provider?: string; ai?: boolean; appId?: string; apiKey?: string; indexName?: string };
|
|
346
346
|
ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
|
|
347
347
|
toc?: { enabled?: boolean; depth?: number };
|
|
348
348
|
topNav?: Array<{ label: string; href: string }>;
|
|
349
349
|
banner?: { text: string; link?: string; dismissible?: boolean };
|
|
350
|
+
branding?: { powered?: boolean };
|
|
351
|
+
feedback?: { enabled?: boolean; textInput?: boolean };
|
|
350
352
|
socialLinks?: Array<{ platform: string; url: string; label?: string; icon?: string }>;
|
|
351
353
|
[key: string]: unknown;
|
|
352
354
|
};
|
|
@@ -366,6 +368,7 @@ interface ShellProps {
|
|
|
366
368
|
lastUpdated?: string;
|
|
367
369
|
changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
|
|
368
370
|
apiManifest?: any;
|
|
371
|
+
asyncApiManifest?: any;
|
|
369
372
|
apiBaseUrl?: string;
|
|
370
373
|
apiPlayground?: boolean;
|
|
371
374
|
apiAuth?: { type: "bearer" | "apiKey"; header?: string };
|
|
@@ -375,6 +378,7 @@ interface ShellProps {
|
|
|
375
378
|
showPlayground?: boolean;
|
|
376
379
|
playgroundAuth?: { type: "bearer" | "apiKey"; header?: string };
|
|
377
380
|
}>;
|
|
381
|
+
AsyncApiReferenceComponent?: React.ComponentType<{ manifest: any }>;
|
|
378
382
|
onNavigate: (id: string) => void;
|
|
379
383
|
allPages: Array<{ id: string; title: string; description?: string }>;
|
|
380
384
|
versioning?: VersioningInfo;
|
|
@@ -397,7 +401,7 @@ interface ShellProps {
|
|
|
397
401
|
export function Shell({
|
|
398
402
|
config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
|
|
399
403
|
pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries,
|
|
400
|
-
apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages,
|
|
404
|
+
apiManifest, asyncApiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, AsyncApiReferenceComponent, onNavigate, allPages,
|
|
401
405
|
versioning, currentVersion, i18n, currentLocale, docContext, basePath = "", isDraft, dir: dirProp, overrides,
|
|
402
406
|
}: ShellProps) {
|
|
403
407
|
// RTL support: resolve text direction from prop, i18n.localeDirs, or default to "ltr"
|
|
@@ -421,6 +425,9 @@ export function Shell({
|
|
|
421
425
|
const [localeDropdownOpen, setLocaleDropdown] = useState(false);
|
|
422
426
|
const [zoomSrc, setZoomSrc] = useState<string | null>(null);
|
|
423
427
|
const [feedbackGiven, setFeedbackGiven] = useState<Record<string, boolean>>({});
|
|
428
|
+
const [feedbackRating, setFeedbackRating] = useState<Record<string, "up" | "down">>({});
|
|
429
|
+
const [feedbackComment, setFeedbackComment] = useState("");
|
|
430
|
+
const [feedbackSubmitted, setFeedbackSubmitted] = useState<Record<string, boolean>>({});
|
|
424
431
|
const [bannerDismissed, setBannerDismissed] = useState(() => {
|
|
425
432
|
if (!config.banner?.text) return true;
|
|
426
433
|
try {
|
|
@@ -726,6 +733,12 @@ export function Shell({
|
|
|
726
733
|
onNavigate={(id) => { onNavigate(id); setSearch(false); }}
|
|
727
734
|
onClose={() => setSearch(false)}
|
|
728
735
|
mobile={mobile}
|
|
736
|
+
aiSearch={config.search?.ai && config.ai?.enabled ? {
|
|
737
|
+
provider: config.ai.provider || "anthropic",
|
|
738
|
+
model: config.ai.model,
|
|
739
|
+
apiKey: (window as any).__TOME_AI_API_KEY__ || undefined,
|
|
740
|
+
context: docContext?.map((d) => `## ${d.title}\n${d.content}`).join("\n\n"),
|
|
741
|
+
} : undefined}
|
|
729
742
|
/>
|
|
730
743
|
) : null}
|
|
731
744
|
|
|
@@ -860,7 +873,7 @@ export function Shell({
|
|
|
860
873
|
{isDark ? <SunIcon /> : <MoonIcon />}
|
|
861
874
|
</button>
|
|
862
875
|
) : <div />}
|
|
863
|
-
<span style={{ fontSize: 11, color: "var(--txM)", letterSpacing: 0.2 }}>Built with {"\u2661"} by Tome</span>
|
|
876
|
+
{config.branding?.powered !== false && <span style={{ fontSize: 11, color: "var(--txM)", letterSpacing: 0.2 }}>Built with {"\u2661"} by Tome</span>}
|
|
864
877
|
<span style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)" }}>{typeof __TOME_VERSION__ !== "undefined" && __TOME_VERSION__ ? `v${__TOME_VERSION__}` : "v0.1.0"}</span>
|
|
865
878
|
</div>
|
|
866
879
|
</aside>
|
|
@@ -1125,7 +1138,7 @@ export function Shell({
|
|
|
1125
1138
|
|
|
1126
1139
|
{/* Content + TOC */}
|
|
1127
1140
|
<div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
|
|
1128
|
-
<main style={{ flex: 1, maxWidth: mobile ? "100%" : apiManifest ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
|
|
1141
|
+
<main style={{ flex: 1, maxWidth: mobile ? "100%" : (apiManifest || asyncApiManifest) ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
|
|
1129
1142
|
{breadcrumbs.length > 0 && (
|
|
1130
1143
|
<nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
|
|
1131
1144
|
display: "flex", alignItems: "center", gap: 6,
|
|
@@ -1164,9 +1177,19 @@ export function Shell({
|
|
|
1164
1177
|
)}
|
|
1165
1178
|
{pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
|
|
1166
1179
|
<div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
|
|
1167
|
-
{/* TOM-19: API Reference page */}
|
|
1168
|
-
{apiManifest && ApiReferenceComponent ? (
|
|
1169
|
-
|
|
1180
|
+
{/* TOM-19 + TOM-66: API Reference page (OpenAPI + AsyncAPI) */}
|
|
1181
|
+
{(apiManifest || asyncApiManifest) && (ApiReferenceComponent || AsyncApiReferenceComponent) ? (
|
|
1182
|
+
<>
|
|
1183
|
+
{apiManifest && ApiReferenceComponent && (
|
|
1184
|
+
<ApiReferenceComponent manifest={apiManifest} baseUrl={apiBaseUrl} showPlayground={apiPlayground} playgroundAuth={apiAuth} />
|
|
1185
|
+
)}
|
|
1186
|
+
{asyncApiManifest && AsyncApiReferenceComponent && (
|
|
1187
|
+
<>
|
|
1188
|
+
{apiManifest && <div style={{ borderTop: "1px solid var(--bd)", margin: "40px 0" }} />}
|
|
1189
|
+
<AsyncApiReferenceComponent manifest={asyncApiManifest} />
|
|
1190
|
+
</>
|
|
1191
|
+
)}
|
|
1192
|
+
</>
|
|
1170
1193
|
) : /* TOM-49: Changelog page type */
|
|
1171
1194
|
changelogEntries && changelogEntries.length > 0 ? (
|
|
1172
1195
|
<ChangelogView entries={changelogEntries} />
|
|
@@ -1225,21 +1248,90 @@ export function Shell({
|
|
|
1225
1248
|
)}
|
|
1226
1249
|
|
|
1227
1250
|
{/* Feedback widget */}
|
|
1228
|
-
|
|
1229
|
-
|
|
1251
|
+
{config.feedback?.enabled !== false && (
|
|
1252
|
+
<div data-testid="feedback-widget" style={{ marginTop: 24, padding: "12px 0" }}>
|
|
1253
|
+
{feedbackSubmitted[currentPageId] ? (
|
|
1254
|
+
<span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
|
|
1255
|
+
) : feedbackGiven[currentPageId] && config.feedback?.textInput ? (
|
|
1256
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
1257
|
+
<span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Any additional feedback? (optional)</span>
|
|
1258
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
1259
|
+
<input
|
|
1260
|
+
data-testid="feedback-text-input"
|
|
1261
|
+
type="text"
|
|
1262
|
+
value={feedbackComment}
|
|
1263
|
+
onChange={e => setFeedbackComment(e.target.value)}
|
|
1264
|
+
placeholder="Tell us more..."
|
|
1265
|
+
style={{
|
|
1266
|
+
flex: 1, padding: "6px 10px", fontSize: 13, fontFamily: "var(--font-body)",
|
|
1267
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
1268
|
+
color: "var(--tx)", outline: "none",
|
|
1269
|
+
}}
|
|
1270
|
+
onKeyDown={e => {
|
|
1271
|
+
if (e.key === "Enter") {
|
|
1272
|
+
(window as any).__tome?.trackFeedback(currentPageId, feedbackRating[currentPageId], feedbackComment);
|
|
1273
|
+
try { localStorage.setItem(`tome-feedback-${currentPageId}`, feedbackRating[currentPageId]); } catch {}
|
|
1274
|
+
setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
|
|
1275
|
+
setFeedbackComment("");
|
|
1276
|
+
}
|
|
1277
|
+
}}
|
|
1278
|
+
/>
|
|
1279
|
+
<button
|
|
1280
|
+
data-testid="feedback-submit"
|
|
1281
|
+
onClick={() => {
|
|
1282
|
+
(window as any).__tome?.trackFeedback(currentPageId, feedbackRating[currentPageId], feedbackComment);
|
|
1283
|
+
try { localStorage.setItem(`tome-feedback-${currentPageId}`, feedbackRating[currentPageId]); } catch {}
|
|
1284
|
+
setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
|
|
1285
|
+
setFeedbackComment("");
|
|
1286
|
+
}}
|
|
1287
|
+
style={{
|
|
1288
|
+
background: "none", border: "1px solid var(--bd)", borderRadius: 2,
|
|
1289
|
+
padding: "6px 14px", cursor: "pointer", fontSize: 13, color: "var(--txM)",
|
|
1290
|
+
}}
|
|
1291
|
+
>Submit</button>
|
|
1292
|
+
<button
|
|
1293
|
+
onClick={() => {
|
|
1294
|
+
(window as any).__tome?.trackFeedback(currentPageId, feedbackRating[currentPageId]);
|
|
1295
|
+
try { localStorage.setItem(`tome-feedback-${currentPageId}`, feedbackRating[currentPageId]); } catch {}
|
|
1296
|
+
setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
|
|
1297
|
+
}}
|
|
1298
|
+
style={{
|
|
1299
|
+
background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--txM)",
|
|
1300
|
+
}}
|
|
1301
|
+
>Skip</button>
|
|
1302
|
+
</div>
|
|
1303
|
+
</div>
|
|
1304
|
+
) : feedbackGiven[currentPageId] ? (
|
|
1230
1305
|
<span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
|
|
1231
1306
|
) : (
|
|
1232
|
-
|
|
1307
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
1233
1308
|
<span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Was this helpful?</span>
|
|
1234
|
-
<button onClick={() => {
|
|
1309
|
+
<button data-testid="feedback-up" onClick={() => {
|
|
1310
|
+
setFeedbackRating(prev => ({ ...prev, [currentPageId]: "up" }));
|
|
1311
|
+
setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true }));
|
|
1312
|
+
if (!config.feedback?.textInput) {
|
|
1313
|
+
(window as any).__tome?.trackFeedback(currentPageId, "up");
|
|
1314
|
+
try { localStorage.setItem(`tome-feedback-${currentPageId}`, "up"); } catch {}
|
|
1315
|
+
setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
|
|
1316
|
+
}
|
|
1317
|
+
}} style={{
|
|
1235
1318
|
background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
|
|
1236
1319
|
}}>👍</button>
|
|
1237
|
-
<button onClick={() => {
|
|
1320
|
+
<button data-testid="feedback-down" onClick={() => {
|
|
1321
|
+
setFeedbackRating(prev => ({ ...prev, [currentPageId]: "down" }));
|
|
1322
|
+
setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true }));
|
|
1323
|
+
if (!config.feedback?.textInput) {
|
|
1324
|
+
(window as any).__tome?.trackFeedback(currentPageId, "down");
|
|
1325
|
+
try { localStorage.setItem(`tome-feedback-${currentPageId}`, "down"); } catch {}
|
|
1326
|
+
setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
|
|
1327
|
+
}
|
|
1328
|
+
}} style={{
|
|
1238
1329
|
background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
|
|
1239
1330
|
}}>👎</button>
|
|
1240
|
-
|
|
1331
|
+
</div>
|
|
1241
1332
|
)}
|
|
1242
1333
|
</div>
|
|
1334
|
+
)}
|
|
1243
1335
|
|
|
1244
1336
|
{/* Prev / Next link cards */}
|
|
1245
1337
|
<div style={{ display: "grid", gridTemplateColumns: mobile ? "1fr" : "1fr 1fr", marginTop: 24, paddingTop: 32, paddingBottom: 40, borderTop: "1px solid var(--bd)", gap: 16 }}>
|
|
@@ -1368,18 +1460,22 @@ interface SearchResult {
|
|
|
1368
1460
|
}
|
|
1369
1461
|
|
|
1370
1462
|
// ── SEARCH MODAL (TOM-15) ────────────────────────────────
|
|
1371
|
-
function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
1463
|
+
function SearchModal({ allPages, onNavigate, onClose, mobile, aiSearch }: {
|
|
1372
1464
|
allPages: Array<{ id: string; title: string; description?: string }>;
|
|
1373
1465
|
onNavigate: (id: string) => void;
|
|
1374
1466
|
onClose: () => void;
|
|
1375
1467
|
mobile?: boolean;
|
|
1468
|
+
aiSearch?: { provider: "openai" | "anthropic" | "custom"; model?: string; apiKey?: string; context?: string };
|
|
1376
1469
|
}) {
|
|
1377
1470
|
const [q, setQ] = useState("");
|
|
1378
1471
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
1379
1472
|
const [selected, setSelected] = useState(0);
|
|
1380
1473
|
const [pagefindReady, setPagefindReady] = useState<boolean | null>(null);
|
|
1474
|
+
const [aiAnswer, setAiAnswer] = useState<string | null>(null);
|
|
1475
|
+
const [aiLoading, setAiLoading] = useState(false);
|
|
1381
1476
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
1382
1477
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
1478
|
+
const aiDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
1383
1479
|
|
|
1384
1480
|
// Try to initialize Pagefind on mount
|
|
1385
1481
|
useEffect(() => {
|
|
@@ -1429,6 +1525,8 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1429
1525
|
}
|
|
1430
1526
|
setResults(items);
|
|
1431
1527
|
setSelected(0);
|
|
1528
|
+
// Track search query in analytics
|
|
1529
|
+
(window as any).__tome?.trackSearch(query, items.length);
|
|
1432
1530
|
return;
|
|
1433
1531
|
} catch {
|
|
1434
1532
|
// Pagefind search failed, fall through to fallback
|
|
@@ -1436,8 +1534,11 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1436
1534
|
}
|
|
1437
1535
|
|
|
1438
1536
|
// Fallback: client-side filtering
|
|
1439
|
-
|
|
1537
|
+
const fallbackResults = fallbackSearch(query);
|
|
1538
|
+
setResults(fallbackResults);
|
|
1440
1539
|
setSelected(0);
|
|
1540
|
+
// Track search query in analytics
|
|
1541
|
+
(window as any).__tome?.trackSearch(query, fallbackResults.length);
|
|
1441
1542
|
}, [fallbackSearch]);
|
|
1442
1543
|
|
|
1443
1544
|
// Debounced search on query change
|
|
@@ -1447,6 +1548,42 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1447
1548
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
1448
1549
|
}, [q, doSearch]);
|
|
1449
1550
|
|
|
1551
|
+
// AI-enhanced search: fire after longer debounce for synthesized answer
|
|
1552
|
+
useEffect(() => {
|
|
1553
|
+
if (!aiSearch?.apiKey || !q.trim() || q.length < 3) {
|
|
1554
|
+
setAiAnswer(null);
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (aiDebounceRef.current) clearTimeout(aiDebounceRef.current);
|
|
1558
|
+
aiDebounceRef.current = setTimeout(async () => {
|
|
1559
|
+
setAiLoading(true);
|
|
1560
|
+
try {
|
|
1561
|
+
const { callAiProvider, buildSystemPrompt, getDefaultModel } = await import("./ai-api.js");
|
|
1562
|
+
const model = aiSearch.model || getDefaultModel(aiSearch.provider);
|
|
1563
|
+
// Build context from search results + full doc context
|
|
1564
|
+
const searchContext = results.slice(0, 5).map(r => `Page: ${r.title}\n${r.excerpt || ""}`).join("\n\n");
|
|
1565
|
+
const fullContext = aiSearch.context ? `${searchContext}\n\n---\n\n${aiSearch.context}` : searchContext;
|
|
1566
|
+
const sysPrompt = buildSystemPrompt(
|
|
1567
|
+
fullContext,
|
|
1568
|
+
"You are a documentation search assistant. Answer the user's question concisely (2-3 sentences max) based on the documentation. Cite specific page names when relevant. If you can't answer from the docs, say so briefly."
|
|
1569
|
+
);
|
|
1570
|
+
const answer = await callAiProvider(
|
|
1571
|
+
aiSearch.provider,
|
|
1572
|
+
[{ role: "user", content: q }],
|
|
1573
|
+
aiSearch.apiKey || "",
|
|
1574
|
+
model,
|
|
1575
|
+
sysPrompt,
|
|
1576
|
+
);
|
|
1577
|
+
setAiAnswer(answer);
|
|
1578
|
+
} catch {
|
|
1579
|
+
setAiAnswer(null);
|
|
1580
|
+
} finally {
|
|
1581
|
+
setAiLoading(false);
|
|
1582
|
+
}
|
|
1583
|
+
}, 500);
|
|
1584
|
+
return () => { if (aiDebounceRef.current) clearTimeout(aiDebounceRef.current); };
|
|
1585
|
+
}, [q, results, aiSearch]);
|
|
1586
|
+
|
|
1450
1587
|
// Keyboard navigation
|
|
1451
1588
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
1452
1589
|
if (e.key === "ArrowDown") {
|
|
@@ -1483,6 +1620,22 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1483
1620
|
/>
|
|
1484
1621
|
<kbd style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)", background: "var(--cdBg)", padding: "2px 6px", borderRadius: 2, border: "1px solid var(--bd)" }}>ESC</kbd>
|
|
1485
1622
|
</div>
|
|
1623
|
+
{/* AI Answer Card */}
|
|
1624
|
+
{aiSearch && q.length >= 3 && (aiLoading || aiAnswer) && (
|
|
1625
|
+
<div style={{
|
|
1626
|
+
padding: "12px 18px", borderBottom: "1px solid var(--bd)",
|
|
1627
|
+
background: "var(--acT)", fontSize: 13, lineHeight: 1.5, color: "var(--tx)",
|
|
1628
|
+
}}>
|
|
1629
|
+
<div style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase" as const, letterSpacing: 0.5, color: "var(--ac)", marginBottom: 6 }}>
|
|
1630
|
+
AI Answer
|
|
1631
|
+
</div>
|
|
1632
|
+
{aiLoading ? (
|
|
1633
|
+
<div style={{ color: "var(--txM)", fontStyle: "italic" }}>Thinking...</div>
|
|
1634
|
+
) : aiAnswer ? (
|
|
1635
|
+
<div>{aiAnswer}</div>
|
|
1636
|
+
) : null}
|
|
1637
|
+
</div>
|
|
1638
|
+
)}
|
|
1486
1639
|
{results.length > 0 && <div style={{ padding: 6, maxHeight: mobile ? "none" : 360, overflow: "auto", flex: mobile ? 1 : undefined }}>
|
|
1487
1640
|
{results.map((r, i) => (
|
|
1488
1641
|
<button key={r.id + i} onClick={() => onNavigate(r.id)} style={{
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildSystemPrompt, getDefaultModel } from "../ai-api.js";
|
|
3
|
+
|
|
4
|
+
describe("buildSystemPrompt", () => {
|
|
5
|
+
it("returns base instruction when no context", () => {
|
|
6
|
+
const prompt = buildSystemPrompt();
|
|
7
|
+
expect(prompt).toContain("documentation assistant");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("includes context when provided", () => {
|
|
11
|
+
const prompt = buildSystemPrompt("Some doc content");
|
|
12
|
+
expect(prompt).toContain("Some doc content");
|
|
13
|
+
expect(prompt).toContain("Documentation:");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("truncates long context", () => {
|
|
17
|
+
const longContext = "x".repeat(200000);
|
|
18
|
+
const prompt = buildSystemPrompt(longContext);
|
|
19
|
+
expect(prompt.length).toBeLessThan(200000);
|
|
20
|
+
expect(prompt).toContain("[Documentation truncated...]");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("uses custom instruction when provided", () => {
|
|
24
|
+
const prompt = buildSystemPrompt("docs", "You are a search assistant.");
|
|
25
|
+
expect(prompt).toContain("You are a search assistant.");
|
|
26
|
+
expect(prompt).not.toContain("documentation assistant");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("getDefaultModel", () => {
|
|
31
|
+
it("returns gpt-4o-mini for openai", () => {
|
|
32
|
+
expect(getDefaultModel("openai")).toBe("gpt-4o-mini");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns claude model for anthropic", () => {
|
|
36
|
+
expect(getDefaultModel("anthropic")).toContain("claude");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns claude model for custom provider", () => {
|
|
40
|
+
expect(getDefaultModel("custom")).toContain("claude");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
4
|
+
import Shell from "../Shell.js";
|
|
5
|
+
|
|
6
|
+
// jsdom doesn't provide matchMedia
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
Object.defineProperty(window, "matchMedia", {
|
|
9
|
+
writable: true,
|
|
10
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
11
|
+
matches: query.includes("dark"),
|
|
12
|
+
media: query,
|
|
13
|
+
onchange: null,
|
|
14
|
+
addListener: vi.fn(),
|
|
15
|
+
removeListener: vi.fn(),
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ── Mock the ai-api module ──────────────────────────────
|
|
24
|
+
|
|
25
|
+
vi.mock("../ai-api.js", () => ({
|
|
26
|
+
callAiProvider: vi.fn().mockResolvedValue("The configuration page explains how to set up your project."),
|
|
27
|
+
buildSystemPrompt: vi.fn().mockReturnValue("system prompt"),
|
|
28
|
+
getDefaultModel: vi.fn().mockReturnValue("gpt-4o-mini"),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// ── Minimal Shell props ─────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const baseConfig = {
|
|
34
|
+
name: "Test Docs",
|
|
35
|
+
theme: { preset: "amber", mode: "auto" },
|
|
36
|
+
toc: { enabled: false },
|
|
37
|
+
search: { provider: "local", ai: true },
|
|
38
|
+
ai: { enabled: true, provider: "anthropic" as const },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const navigation = [{
|
|
42
|
+
section: "Docs",
|
|
43
|
+
pages: [
|
|
44
|
+
{ id: "index", title: "Introduction", urlPath: "/" },
|
|
45
|
+
{ id: "config", title: "Configuration", urlPath: "/config" },
|
|
46
|
+
{ id: "theming", title: "Theming", urlPath: "/theming" },
|
|
47
|
+
],
|
|
48
|
+
}];
|
|
49
|
+
|
|
50
|
+
const docContext = [
|
|
51
|
+
{ id: "index", title: "Introduction", content: "Welcome to the documentation." },
|
|
52
|
+
{ id: "config", title: "Configuration", content: "Configure your site with tome.config.js." },
|
|
53
|
+
{ id: "theming", title: "Theming", content: "Choose from 10 theme presets." },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const allPages = [
|
|
57
|
+
{ id: "index", title: "Introduction", description: "Welcome to the docs" },
|
|
58
|
+
{ id: "config", title: "Configuration", description: "How to configure" },
|
|
59
|
+
{ id: "theming", title: "Theming", description: "Customize your theme" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function renderShell(configOverrides = {}) {
|
|
63
|
+
(window as any).__TOME_AI_API_KEY__ = "test-key";
|
|
64
|
+
|
|
65
|
+
return render(
|
|
66
|
+
<Shell
|
|
67
|
+
config={{ ...baseConfig, ...configOverrides }}
|
|
68
|
+
navigation={navigation}
|
|
69
|
+
currentPageId="index"
|
|
70
|
+
pageHtml="<h1>Introduction</h1>"
|
|
71
|
+
pageTitle="Introduction"
|
|
72
|
+
headings={[]}
|
|
73
|
+
allPages={allPages}
|
|
74
|
+
onNavigate={() => {}}
|
|
75
|
+
docContext={docContext}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Setup ────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
localStorage.clear();
|
|
84
|
+
(window as any).__tome = {
|
|
85
|
+
trackSearch: vi.fn(),
|
|
86
|
+
trackFeedback: vi.fn(),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Tests ────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("AI Search in SearchModal", () => {
|
|
93
|
+
it("opens search modal with Cmd+K", async () => {
|
|
94
|
+
renderShell();
|
|
95
|
+
await act(async () => {
|
|
96
|
+
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
|
97
|
+
});
|
|
98
|
+
expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("shows search results for keyword queries", async () => {
|
|
102
|
+
renderShell();
|
|
103
|
+
|
|
104
|
+
// Open search
|
|
105
|
+
await act(async () => {
|
|
106
|
+
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const input = screen.getByPlaceholderText("Search documentation...");
|
|
110
|
+
|
|
111
|
+
// Type a query that matches a page title
|
|
112
|
+
await act(async () => {
|
|
113
|
+
fireEvent.change(input, { target: { value: "Configuration" } });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Wait for debounce
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await new Promise(r => setTimeout(r, 200));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Should show keyword results (fallback since Pagefind not available in tests)
|
|
122
|
+
// "Configuration" appears in sidebar too, so check that search results area has it
|
|
123
|
+
expect(screen.getAllByText("Configuration").length).toBeGreaterThanOrEqual(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not show AI answer card when search.ai is false", async () => {
|
|
127
|
+
renderShell({ search: { provider: "local", ai: false } });
|
|
128
|
+
|
|
129
|
+
await act(async () => {
|
|
130
|
+
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const input = screen.getByPlaceholderText("Search documentation...");
|
|
134
|
+
await act(async () => {
|
|
135
|
+
fireEvent.change(input, { target: { value: "how to configure" } });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await act(async () => {
|
|
139
|
+
await new Promise(r => setTimeout(r, 600));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(screen.queryByText("AI Answer")).not.toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does not show AI answer for very short queries", async () => {
|
|
146
|
+
renderShell();
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const input = screen.getByPlaceholderText("Search documentation...");
|
|
153
|
+
await act(async () => {
|
|
154
|
+
fireEvent.change(input, { target: { value: "hi" } });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await new Promise(r => setTimeout(r, 600));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Query too short (< 3 chars), no AI answer
|
|
162
|
+
expect(screen.queryByText("AI Answer")).not.toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("shows AI answer card when query is long enough and AI is enabled", async () => {
|
|
166
|
+
renderShell();
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const input = screen.getByPlaceholderText("Search documentation...");
|
|
173
|
+
await act(async () => {
|
|
174
|
+
fireEvent.change(input, { target: { value: "how to configure my project" } });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Wait for both keyword debounce (120ms) and AI debounce (500ms)
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await new Promise(r => setTimeout(r, 700));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// AI answer card should appear with the mocked response
|
|
183
|
+
expect(screen.getByText("AI Answer")).toBeInTheDocument();
|
|
184
|
+
expect(screen.getByText("The configuration page explains how to set up your project.")).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("tracks search queries in analytics", async () => {
|
|
188
|
+
renderShell();
|
|
189
|
+
|
|
190
|
+
await act(async () => {
|
|
191
|
+
fireEvent.keyDown(document, { key: "k", metaKey: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const input = screen.getByPlaceholderText("Search documentation...");
|
|
195
|
+
await act(async () => {
|
|
196
|
+
fireEvent.change(input, { target: { value: "Configuration" } });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await act(async () => {
|
|
200
|
+
await new Promise(r => setTimeout(r, 200));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect((window as any).__tome.trackSearch).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
});
|