@tomehq/theme 0.6.3 → 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-26CZLWVW.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 -17
- 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 -22
- package/src/entry.tsx +3 -1
- package/src/presets.ts +93 -3
- package/src/routing.test.ts +2 -3
- package/src/routing.ts +12 -4
package/src/Shell.tsx
CHANGED
|
@@ -36,8 +36,6 @@ const Icon = ({ d, size = 16 }: { d: string; size?: number }) => (
|
|
|
36
36
|
const SearchIcon = () => <Icon d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.3-4.3" />;
|
|
37
37
|
const ChevRight = () => <Icon d="M9 18l6-6-6-6" size={14} />;
|
|
38
38
|
const ChevDown = () => <Icon d="M6 9l6 6 6-6" size={14} />;
|
|
39
|
-
const CopyIcon = () => <Icon d="M9 9h13v13H9zM5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" size={14} />;
|
|
40
|
-
const CheckIcon = () => <Icon d="M20 6L9 17l-5-5" size={14} />;
|
|
41
39
|
const MenuIcon = () => <Icon d="M3 12h18M3 6h18M3 18h18" size={20} />;
|
|
42
40
|
const XIcon = () => <Icon d="M18 6L6 18M6 6l12 12" size={18} />;
|
|
43
41
|
const MoonIcon = () => <Icon d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />;
|
|
@@ -344,11 +342,13 @@ interface ShellProps {
|
|
|
344
342
|
config: {
|
|
345
343
|
name: string;
|
|
346
344
|
theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
|
|
347
|
-
search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
|
|
345
|
+
search?: { provider?: string; ai?: boolean; appId?: string; apiKey?: string; indexName?: string };
|
|
348
346
|
ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
|
|
349
347
|
toc?: { enabled?: boolean; depth?: number };
|
|
350
348
|
topNav?: Array<{ label: string; href: string }>;
|
|
351
349
|
banner?: { text: string; link?: string; dismissible?: boolean };
|
|
350
|
+
branding?: { powered?: boolean };
|
|
351
|
+
feedback?: { enabled?: boolean; textInput?: boolean };
|
|
352
352
|
socialLinks?: Array<{ platform: string; url: string; label?: string; icon?: string }>;
|
|
353
353
|
[key: string]: unknown;
|
|
354
354
|
};
|
|
@@ -368,6 +368,7 @@ interface ShellProps {
|
|
|
368
368
|
lastUpdated?: string;
|
|
369
369
|
changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
|
|
370
370
|
apiManifest?: any;
|
|
371
|
+
asyncApiManifest?: any;
|
|
371
372
|
apiBaseUrl?: string;
|
|
372
373
|
apiPlayground?: boolean;
|
|
373
374
|
apiAuth?: { type: "bearer" | "apiKey"; header?: string };
|
|
@@ -377,6 +378,7 @@ interface ShellProps {
|
|
|
377
378
|
showPlayground?: boolean;
|
|
378
379
|
playgroundAuth?: { type: "bearer" | "apiKey"; header?: string };
|
|
379
380
|
}>;
|
|
381
|
+
AsyncApiReferenceComponent?: React.ComponentType<{ manifest: any }>;
|
|
380
382
|
onNavigate: (id: string) => void;
|
|
381
383
|
allPages: Array<{ id: string; title: string; description?: string }>;
|
|
382
384
|
versioning?: VersioningInfo;
|
|
@@ -399,7 +401,7 @@ interface ShellProps {
|
|
|
399
401
|
export function Shell({
|
|
400
402
|
config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
|
|
401
403
|
pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries,
|
|
402
|
-
apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages,
|
|
404
|
+
apiManifest, asyncApiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, AsyncApiReferenceComponent, onNavigate, allPages,
|
|
403
405
|
versioning, currentVersion, i18n, currentLocale, docContext, basePath = "", isDraft, dir: dirProp, overrides,
|
|
404
406
|
}: ShellProps) {
|
|
405
407
|
// RTL support: resolve text direction from prop, i18n.localeDirs, or default to "ltr"
|
|
@@ -423,6 +425,9 @@ export function Shell({
|
|
|
423
425
|
const [localeDropdownOpen, setLocaleDropdown] = useState(false);
|
|
424
426
|
const [zoomSrc, setZoomSrc] = useState<string | null>(null);
|
|
425
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>>({});
|
|
426
431
|
const [bannerDismissed, setBannerDismissed] = useState(() => {
|
|
427
432
|
if (!config.banner?.text) return true;
|
|
428
433
|
try {
|
|
@@ -728,6 +733,12 @@ export function Shell({
|
|
|
728
733
|
onNavigate={(id) => { onNavigate(id); setSearch(false); }}
|
|
729
734
|
onClose={() => setSearch(false)}
|
|
730
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}
|
|
731
742
|
/>
|
|
732
743
|
) : null}
|
|
733
744
|
|
|
@@ -862,7 +873,7 @@ export function Shell({
|
|
|
862
873
|
{isDark ? <SunIcon /> : <MoonIcon />}
|
|
863
874
|
</button>
|
|
864
875
|
) : <div />}
|
|
865
|
-
<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>}
|
|
866
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>
|
|
867
878
|
</div>
|
|
868
879
|
</aside>
|
|
@@ -1127,7 +1138,7 @@ export function Shell({
|
|
|
1127
1138
|
|
|
1128
1139
|
{/* Content + TOC */}
|
|
1129
1140
|
<div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
|
|
1130
|
-
<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 }}>
|
|
1131
1142
|
{breadcrumbs.length > 0 && (
|
|
1132
1143
|
<nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
|
|
1133
1144
|
display: "flex", alignItems: "center", gap: 6,
|
|
@@ -1166,9 +1177,19 @@ export function Shell({
|
|
|
1166
1177
|
)}
|
|
1167
1178
|
{pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
|
|
1168
1179
|
<div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
|
|
1169
|
-
{/* TOM-19: API Reference page */}
|
|
1170
|
-
{apiManifest && ApiReferenceComponent ? (
|
|
1171
|
-
|
|
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
|
+
</>
|
|
1172
1193
|
) : /* TOM-49: Changelog page type */
|
|
1173
1194
|
changelogEntries && changelogEntries.length > 0 ? (
|
|
1174
1195
|
<ChangelogView entries={changelogEntries} />
|
|
@@ -1227,21 +1248,90 @@ export function Shell({
|
|
|
1227
1248
|
)}
|
|
1228
1249
|
|
|
1229
1250
|
{/* Feedback widget */}
|
|
1230
|
-
|
|
1231
|
-
|
|
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] ? (
|
|
1232
1305
|
<span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
|
|
1233
1306
|
) : (
|
|
1234
|
-
|
|
1307
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
1235
1308
|
<span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Was this helpful?</span>
|
|
1236
|
-
<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={{
|
|
1237
1318
|
background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
|
|
1238
1319
|
}}>👍</button>
|
|
1239
|
-
<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={{
|
|
1240
1329
|
background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
|
|
1241
1330
|
}}>👎</button>
|
|
1242
|
-
|
|
1331
|
+
</div>
|
|
1243
1332
|
)}
|
|
1244
1333
|
</div>
|
|
1334
|
+
)}
|
|
1245
1335
|
|
|
1246
1336
|
{/* Prev / Next link cards */}
|
|
1247
1337
|
<div style={{ display: "grid", gridTemplateColumns: mobile ? "1fr" : "1fr 1fr", marginTop: 24, paddingTop: 32, paddingBottom: 40, borderTop: "1px solid var(--bd)", gap: 16 }}>
|
|
@@ -1370,18 +1460,22 @@ interface SearchResult {
|
|
|
1370
1460
|
}
|
|
1371
1461
|
|
|
1372
1462
|
// ── SEARCH MODAL (TOM-15) ────────────────────────────────
|
|
1373
|
-
function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
1463
|
+
function SearchModal({ allPages, onNavigate, onClose, mobile, aiSearch }: {
|
|
1374
1464
|
allPages: Array<{ id: string; title: string; description?: string }>;
|
|
1375
1465
|
onNavigate: (id: string) => void;
|
|
1376
1466
|
onClose: () => void;
|
|
1377
1467
|
mobile?: boolean;
|
|
1468
|
+
aiSearch?: { provider: "openai" | "anthropic" | "custom"; model?: string; apiKey?: string; context?: string };
|
|
1378
1469
|
}) {
|
|
1379
1470
|
const [q, setQ] = useState("");
|
|
1380
1471
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
1381
1472
|
const [selected, setSelected] = useState(0);
|
|
1382
1473
|
const [pagefindReady, setPagefindReady] = useState<boolean | null>(null);
|
|
1474
|
+
const [aiAnswer, setAiAnswer] = useState<string | null>(null);
|
|
1475
|
+
const [aiLoading, setAiLoading] = useState(false);
|
|
1383
1476
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
1384
1477
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
1478
|
+
const aiDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
1385
1479
|
|
|
1386
1480
|
// Try to initialize Pagefind on mount
|
|
1387
1481
|
useEffect(() => {
|
|
@@ -1431,6 +1525,8 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1431
1525
|
}
|
|
1432
1526
|
setResults(items);
|
|
1433
1527
|
setSelected(0);
|
|
1528
|
+
// Track search query in analytics
|
|
1529
|
+
(window as any).__tome?.trackSearch(query, items.length);
|
|
1434
1530
|
return;
|
|
1435
1531
|
} catch {
|
|
1436
1532
|
// Pagefind search failed, fall through to fallback
|
|
@@ -1438,8 +1534,11 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1438
1534
|
}
|
|
1439
1535
|
|
|
1440
1536
|
// Fallback: client-side filtering
|
|
1441
|
-
|
|
1537
|
+
const fallbackResults = fallbackSearch(query);
|
|
1538
|
+
setResults(fallbackResults);
|
|
1442
1539
|
setSelected(0);
|
|
1540
|
+
// Track search query in analytics
|
|
1541
|
+
(window as any).__tome?.trackSearch(query, fallbackResults.length);
|
|
1443
1542
|
}, [fallbackSearch]);
|
|
1444
1543
|
|
|
1445
1544
|
// Debounced search on query change
|
|
@@ -1449,6 +1548,42 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1449
1548
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
1450
1549
|
}, [q, doSearch]);
|
|
1451
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
|
+
|
|
1452
1587
|
// Keyboard navigation
|
|
1453
1588
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
1454
1589
|
if (e.key === "ArrowDown") {
|
|
@@ -1485,6 +1620,22 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
|
|
|
1485
1620
|
/>
|
|
1486
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>
|
|
1487
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
|
+
)}
|
|
1488
1639
|
{results.length > 0 && <div style={{ padding: 6, maxHeight: mobile ? "none" : 360, overflow: "auto", flex: mobile ? 1 : undefined }}>
|
|
1489
1640
|
{results.map((r, i) => (
|
|
1490
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
|
+
});
|