@86d-app/search 0.0.4 → 0.0.13

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.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +72 -0
  3. package/README.md +171 -28
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/embedding-provider.test.d.ts +2 -0
  7. package/dist/__tests__/embedding-provider.test.d.ts.map +1 -0
  8. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  9. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  10. package/dist/__tests__/meilisearch-provider.test.d.ts +2 -0
  11. package/dist/__tests__/meilisearch-provider.test.d.ts.map +1 -0
  12. package/dist/__tests__/service-impl.test.d.ts +2 -0
  13. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  14. package/dist/admin/components/index.d.ts +2 -0
  15. package/dist/admin/components/index.d.ts.map +1 -0
  16. package/dist/admin/components/search-analytics.d.ts +2 -0
  17. package/dist/admin/components/search-analytics.d.ts.map +1 -0
  18. package/dist/admin/endpoints/analytics.d.ts +15 -0
  19. package/dist/admin/endpoints/analytics.d.ts.map +1 -0
  20. package/dist/admin/endpoints/bulk-index.d.ts +20 -0
  21. package/dist/admin/endpoints/bulk-index.d.ts.map +1 -0
  22. package/dist/admin/endpoints/click-analytics.d.ts +7 -0
  23. package/dist/admin/endpoints/click-analytics.d.ts.map +1 -0
  24. package/dist/admin/endpoints/get-settings.d.ts +17 -0
  25. package/dist/admin/endpoints/get-settings.d.ts.map +1 -0
  26. package/dist/admin/endpoints/index-manage.d.ts +26 -0
  27. package/dist/admin/endpoints/index-manage.d.ts.map +1 -0
  28. package/dist/admin/endpoints/index.d.ts +125 -0
  29. package/dist/admin/endpoints/index.d.ts.map +1 -0
  30. package/dist/admin/endpoints/popular.d.ts +10 -0
  31. package/dist/admin/endpoints/popular.d.ts.map +1 -0
  32. package/dist/admin/endpoints/synonyms.d.ts +30 -0
  33. package/dist/admin/endpoints/synonyms.d.ts.map +1 -0
  34. package/dist/admin/endpoints/zero-results.d.ts +10 -0
  35. package/dist/admin/endpoints/zero-results.d.ts.map +1 -0
  36. package/dist/embedding-provider.d.ts +28 -0
  37. package/dist/embedding-provider.d.ts.map +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/meilisearch-provider.d.ts +104 -0
  41. package/dist/meilisearch-provider.d.ts.map +1 -0
  42. package/dist/schema.d.ts +133 -0
  43. package/dist/schema.d.ts.map +1 -0
  44. package/dist/service-impl.d.ts +6 -0
  45. package/dist/service-impl.d.ts.map +1 -0
  46. package/dist/service.d.ts +127 -0
  47. package/dist/service.d.ts.map +1 -0
  48. package/dist/store/components/_hooks.d.ts +6 -0
  49. package/dist/store/components/_hooks.d.ts.map +1 -0
  50. package/dist/store/components/index.d.ts +10 -0
  51. package/dist/store/components/index.d.ts.map +1 -0
  52. package/dist/store/components/search-bar.d.ts +7 -0
  53. package/dist/store/components/search-bar.d.ts.map +1 -0
  54. package/dist/store/components/search-page.d.ts +4 -0
  55. package/dist/store/components/search-page.d.ts.map +1 -0
  56. package/dist/store/components/search-results.d.ts +9 -0
  57. package/dist/store/components/search-results.d.ts.map +1 -0
  58. package/dist/store/endpoints/click.d.ts +14 -0
  59. package/dist/store/endpoints/click.d.ts.map +1 -0
  60. package/dist/store/endpoints/index.d.ts +85 -0
  61. package/dist/store/endpoints/index.d.ts.map +1 -0
  62. package/dist/store/endpoints/recent.d.ts +15 -0
  63. package/dist/store/endpoints/recent.d.ts.map +1 -0
  64. package/dist/store/endpoints/search.d.ts +36 -0
  65. package/dist/store/endpoints/search.d.ts.map +1 -0
  66. package/dist/store/endpoints/store-search.d.ts +16 -0
  67. package/dist/store/endpoints/store-search.d.ts.map +1 -0
  68. package/dist/store/endpoints/suggest.d.ts +11 -0
  69. package/dist/store/endpoints/suggest.d.ts.map +1 -0
  70. package/package.json +3 -3
  71. package/src/__tests__/controllers.test.ts +1026 -0
  72. package/src/__tests__/embedding-provider.test.ts +195 -0
  73. package/src/__tests__/endpoint-security.test.ts +300 -0
  74. package/src/__tests__/meilisearch-provider.test.ts +400 -0
  75. package/src/__tests__/service-impl.test.ts +341 -8
  76. package/src/admin/components/search-analytics.tsx +120 -0
  77. package/src/admin/endpoints/bulk-index.ts +34 -0
  78. package/src/admin/endpoints/click-analytics.ts +16 -0
  79. package/src/admin/endpoints/get-settings.ts +56 -0
  80. package/src/admin/endpoints/index-manage.ts +4 -1
  81. package/src/admin/endpoints/index.ts +6 -0
  82. package/src/admin/endpoints/synonyms.ts +1 -1
  83. package/src/embedding-provider.ts +99 -0
  84. package/src/index.ts +60 -4
  85. package/src/meilisearch-provider.ts +239 -0
  86. package/src/schema.ts +15 -0
  87. package/src/service-impl.ts +605 -34
  88. package/src/service.ts +60 -1
  89. package/src/store/endpoints/click.ts +21 -0
  90. package/src/store/endpoints/index.ts +2 -0
  91. package/src/store/endpoints/recent.ts +1 -1
  92. package/src/store/endpoints/search.ts +38 -10
  93. package/src/store/endpoints/store-search.ts +1 -1
  94. package/src/store/endpoints/suggest.ts +2 -2
  95. package/vitest.config.ts +2 -0
@@ -63,6 +63,64 @@ describe("createSearchController", () => {
63
63
  });
64
64
  });
65
65
 
66
+ // ── bulkIndex ────────────────────────────────────────────────────────
67
+
68
+ describe("bulkIndex", () => {
69
+ it("indexes multiple items at once", async () => {
70
+ const result = await controller.bulkIndex([
71
+ {
72
+ entityType: "product",
73
+ entityId: "p1",
74
+ title: "Item 1",
75
+ url: "/p1",
76
+ },
77
+ {
78
+ entityType: "product",
79
+ entityId: "p2",
80
+ title: "Item 2",
81
+ url: "/p2",
82
+ },
83
+ {
84
+ entityType: "product",
85
+ entityId: "p3",
86
+ title: "Item 3",
87
+ url: "/p3",
88
+ },
89
+ ]);
90
+ expect(result.indexed).toBe(3);
91
+ expect(result.errors).toBe(0);
92
+ const count = await controller.getIndexCount();
93
+ expect(count).toBe(3);
94
+ });
95
+
96
+ it("updates existing items in bulk", async () => {
97
+ await controller.indexItem({
98
+ entityType: "product",
99
+ entityId: "p1",
100
+ title: "Old Title",
101
+ url: "/p1",
102
+ });
103
+ const result = await controller.bulkIndex([
104
+ {
105
+ entityType: "product",
106
+ entityId: "p1",
107
+ title: "New Title",
108
+ url: "/p1",
109
+ },
110
+ {
111
+ entityType: "product",
112
+ entityId: "p2",
113
+ title: "Item 2",
114
+ url: "/p2",
115
+ },
116
+ ]);
117
+ expect(result.indexed).toBe(2);
118
+ expect(result.errors).toBe(0);
119
+ const count = await controller.getIndexCount();
120
+ expect(count).toBe(2);
121
+ });
122
+ });
123
+
66
124
  // ── removeFromIndex ─────────────────────────────────────────────────
67
125
 
68
126
  describe("removeFromIndex", () => {
@@ -123,7 +181,9 @@ describe("createSearchController", () => {
123
181
  });
124
182
 
125
183
  it("returns empty results for non-matching query", async () => {
126
- const { results, total } = await controller.search("nonexistent");
184
+ const { results, total } = await controller.search("nonexistent", {
185
+ fuzzy: false,
186
+ });
127
187
  expect(results).toHaveLength(0);
128
188
  expect(total).toBe(0);
129
189
  });
@@ -171,11 +231,215 @@ describe("createSearchController", () => {
171
231
  });
172
232
 
173
233
  it("ranks title matches higher than body matches", async () => {
174
- // "Red T-Shirt" has "red" in title; "Blue Jeans" does not
175
234
  const { results } = await controller.search("red");
176
235
  expect(results.length).toBeGreaterThan(0);
177
236
  expect(results[0].item.entityId).toBe("prod_1");
178
237
  });
238
+
239
+ it("returns facets with results", async () => {
240
+ const { facets } = await controller.search("clothing");
241
+ expect(facets.entityTypes.length).toBeGreaterThan(0);
242
+ expect(facets.entityTypes[0].type).toBe("product");
243
+ expect(facets.tags.length).toBeGreaterThan(0);
244
+ });
245
+
246
+ it("returns highlights in results", async () => {
247
+ const { results } = await controller.search("red");
248
+ expect(results.length).toBeGreaterThan(0);
249
+ const highlight = results[0].highlights;
250
+ expect(highlight?.title).toContain("<mark>");
251
+ });
252
+ });
253
+
254
+ // ── fuzzy search ───────────────────────────────────────────────────
255
+
256
+ describe("fuzzy search", () => {
257
+ beforeEach(async () => {
258
+ await controller.indexItem({
259
+ entityType: "product",
260
+ entityId: "prod_1",
261
+ title: "Running Shoes",
262
+ tags: ["footwear", "athletic"],
263
+ url: "/products/running-shoes",
264
+ });
265
+ await controller.indexItem({
266
+ entityType: "product",
267
+ entityId: "prod_2",
268
+ title: "Leather Boots",
269
+ tags: ["footwear", "winter"],
270
+ url: "/products/leather-boots",
271
+ });
272
+ });
273
+
274
+ it("finds results with typos when fuzzy is enabled", async () => {
275
+ const { results } = await controller.search("runnign", {
276
+ fuzzy: true,
277
+ });
278
+ expect(results.length).toBeGreaterThan(0);
279
+ expect(results[0].item.entityId).toBe("prod_1");
280
+ });
281
+
282
+ it("does not find typo results when fuzzy is disabled", async () => {
283
+ const { results } = await controller.search("runnign", {
284
+ fuzzy: false,
285
+ });
286
+ expect(results).toHaveLength(0);
287
+ });
288
+
289
+ it("fuzzy matches short words only with exact match", async () => {
290
+ // Words <= 3 chars get no fuzzy tolerance
291
+ const { results } = await controller.search("ren", {
292
+ fuzzy: true,
293
+ });
294
+ // "ren" is too short for fuzzy to match "run" (distance 2)
295
+ expect(results).toHaveLength(0);
296
+ });
297
+
298
+ it("fuzzy matches medium words with 1 edit distance", async () => {
299
+ const { results } = await controller.search("shoez", {
300
+ fuzzy: true,
301
+ });
302
+ expect(results.length).toBeGreaterThan(0);
303
+ });
304
+
305
+ it("fuzzy matches longer words with 2 edit distance", async () => {
306
+ const { results } = await controller.search("leathor", {
307
+ fuzzy: true,
308
+ });
309
+ expect(results.length).toBeGreaterThan(0);
310
+ });
311
+ });
312
+
313
+ // ── tag filtering ──────────────────────────────────────────────────
314
+
315
+ describe("tag filtering", () => {
316
+ beforeEach(async () => {
317
+ await controller.indexItem({
318
+ entityType: "product",
319
+ entityId: "p1",
320
+ title: "Red Sneakers",
321
+ tags: ["shoes", "red", "sport"],
322
+ url: "/p1",
323
+ });
324
+ await controller.indexItem({
325
+ entityType: "product",
326
+ entityId: "p2",
327
+ title: "Red Jacket",
328
+ tags: ["outerwear", "red", "winter"],
329
+ url: "/p2",
330
+ });
331
+ await controller.indexItem({
332
+ entityType: "product",
333
+ entityId: "p3",
334
+ title: "Blue Sneakers",
335
+ tags: ["shoes", "blue", "sport"],
336
+ url: "/p3",
337
+ });
338
+ });
339
+
340
+ it("filters results by tags", async () => {
341
+ const { results } = await controller.search("sneakers", {
342
+ tags: ["red"],
343
+ });
344
+ expect(results).toHaveLength(1);
345
+ expect(results[0].item.entityId).toBe("p1");
346
+ });
347
+
348
+ it("returns all matching results without tag filter", async () => {
349
+ const { results } = await controller.search("red");
350
+ expect(results.length).toBeGreaterThanOrEqual(2);
351
+ });
352
+
353
+ it("returns empty when no items match tag filter", async () => {
354
+ const { results } = await controller.search("sneakers", {
355
+ tags: ["winter"],
356
+ });
357
+ expect(results).toHaveLength(0);
358
+ });
359
+ });
360
+
361
+ // ── sorting ────────────────────────────────────────────────────────
362
+
363
+ describe("sorting", () => {
364
+ beforeEach(async () => {
365
+ await controller.indexItem({
366
+ entityType: "product",
367
+ entityId: "p1",
368
+ title: "Alpha Widget",
369
+ tags: ["widget"],
370
+ url: "/p1",
371
+ });
372
+ // Add a small delay to ensure different timestamps
373
+ await controller.indexItem({
374
+ entityType: "product",
375
+ entityId: "p2",
376
+ title: "Beta Widget",
377
+ tags: ["widget"],
378
+ url: "/p2",
379
+ });
380
+ await controller.indexItem({
381
+ entityType: "product",
382
+ entityId: "p3",
383
+ title: "Charlie Widget",
384
+ tags: ["widget"],
385
+ url: "/p3",
386
+ });
387
+ });
388
+
389
+ it("sorts by title ascending", async () => {
390
+ const { results } = await controller.search("widget", {
391
+ sort: "title_asc",
392
+ });
393
+ expect(results[0].item.title).toBe("Alpha Widget");
394
+ expect(results[results.length - 1].item.title).toBe("Charlie Widget");
395
+ });
396
+
397
+ it("sorts by title descending", async () => {
398
+ const { results } = await controller.search("widget", {
399
+ sort: "title_desc",
400
+ });
401
+ expect(results[0].item.title).toBe("Charlie Widget");
402
+ expect(results[results.length - 1].item.title).toBe("Alpha Widget");
403
+ });
404
+
405
+ it("defaults to relevance sorting", async () => {
406
+ const { results } = await controller.search("widget");
407
+ // All have same relevance, so order is stable
408
+ expect(results.length).toBe(3);
409
+ });
410
+ });
411
+
412
+ // ── did-you-mean ───────────────────────────────────────────────────
413
+
414
+ describe("did-you-mean", () => {
415
+ beforeEach(async () => {
416
+ await controller.indexItem({
417
+ entityType: "product",
418
+ entityId: "p1",
419
+ title: "Running Shoes",
420
+ url: "/p1",
421
+ });
422
+ await controller.indexItem({
423
+ entityType: "product",
424
+ entityId: "p2",
425
+ title: "Leather Boots",
426
+ url: "/p2",
427
+ });
428
+ });
429
+
430
+ it("suggests correction for misspelled query with zero results", async () => {
431
+ // "bootes" is close to "boots" (dist 1), won't substring-match "Running Shoes"
432
+ const { didYouMean } = await controller.search("bootes", {
433
+ fuzzy: false,
434
+ });
435
+ expect(didYouMean).toBeDefined();
436
+ expect(didYouMean).toBe("boots");
437
+ });
438
+
439
+ it("does not suggest correction when results are found", async () => {
440
+ const { didYouMean } = await controller.search("running");
441
+ expect(didYouMean).toBeUndefined();
442
+ });
179
443
  });
180
444
 
181
445
  // ── synonym expansion ───────────────────────────────────────────────
@@ -205,7 +469,6 @@ describe("createSearchController", () => {
205
469
  url: "/products/tee-collection",
206
470
  });
207
471
  const { results } = await controller.search("tshirt");
208
- // "tshirt" is a synonym of "tee", so "tee" should also be searched
209
472
  expect(results.length).toBeGreaterThan(0);
210
473
  });
211
474
  });
@@ -226,7 +489,6 @@ describe("createSearchController", () => {
226
489
  title: "Red Sneakers",
227
490
  url: "/products/red-sneakers",
228
491
  });
229
- // Record some queries
230
492
  await controller.recordQuery("red t-shirt", 5);
231
493
  await controller.recordQuery("red t-shirt", 3);
232
494
  await controller.recordQuery("red sneakers", 2);
@@ -242,7 +504,6 @@ describe("createSearchController", () => {
242
504
 
243
505
  it("prioritizes popular queries over title matches", async () => {
244
506
  const suggestions = await controller.suggest("red");
245
- // "red t-shirt" was searched twice, should appear before "red sneakers"
246
507
  expect(suggestions[0].toLowerCase()).toContain("red t-shirt");
247
508
  });
248
509
 
@@ -276,6 +537,47 @@ describe("createSearchController", () => {
276
537
  });
277
538
  });
278
539
 
540
+ // ── recordClick ─────────────────────────────────────────────────────
541
+
542
+ describe("recordClick", () => {
543
+ it("records a search result click", async () => {
544
+ const query = await controller.recordQuery("shoes", 10, "sess_1");
545
+ const click = await controller.recordClick({
546
+ queryId: query.id,
547
+ term: "shoes",
548
+ entityType: "product",
549
+ entityId: "prod_1",
550
+ position: 0,
551
+ });
552
+ expect(click.id).toBeDefined();
553
+ expect(click.queryId).toBe(query.id);
554
+ expect(click.term).toBe("shoes");
555
+ expect(click.position).toBe(0);
556
+ expect(click.clickedAt).toBeInstanceOf(Date);
557
+ });
558
+
559
+ it("records multiple clicks for different positions", async () => {
560
+ const query = await controller.recordQuery("shoes", 10);
561
+ const click1 = await controller.recordClick({
562
+ queryId: query.id,
563
+ term: "shoes",
564
+ entityType: "product",
565
+ entityId: "prod_1",
566
+ position: 0,
567
+ });
568
+ const click2 = await controller.recordClick({
569
+ queryId: query.id,
570
+ term: "shoes",
571
+ entityType: "product",
572
+ entityId: "prod_2",
573
+ position: 1,
574
+ });
575
+ expect(click1.id).not.toBe(click2.id);
576
+ expect(click1.position).toBe(0);
577
+ expect(click2.position).toBe(1);
578
+ });
579
+ });
580
+
279
581
  // ── getRecentQueries ────────────────────────────────────────────────
280
582
 
281
583
  describe("getRecentQueries", () => {
@@ -324,7 +626,7 @@ describe("createSearchController", () => {
324
626
  const popular = await controller.getPopularTerms();
325
627
  expect(popular[0].term).toBe("shoes");
326
628
  expect(popular[0].count).toBe(3);
327
- expect(popular[0].avgResultCount).toBe(10); // (10+8+12)/3 = 10
629
+ expect(popular[0].avgResultCount).toBe(10);
328
630
  expect(popular[1].term).toBe("hats");
329
631
  expect(popular[1].count).toBe(2);
330
632
  });
@@ -377,9 +679,11 @@ describe("createSearchController", () => {
377
679
  const analytics = await controller.getAnalytics();
378
680
  expect(analytics.totalQueries).toBe(3);
379
681
  expect(analytics.uniqueTerms).toBe(2);
380
- expect(analytics.avgResultCount).toBe(6); // (10+8+0)/3 = 6
682
+ expect(analytics.avgResultCount).toBe(6);
381
683
  expect(analytics.zeroResultCount).toBe(1);
382
- expect(analytics.zeroResultRate).toBe(33); // 1/3 = 33%
684
+ expect(analytics.zeroResultRate).toBe(33);
685
+ expect(analytics.clickThroughRate).toBe(0);
686
+ expect(analytics.avgClickPosition).toBe(0);
383
687
  });
384
688
 
385
689
  it("returns zeros when no queries", async () => {
@@ -389,6 +693,35 @@ describe("createSearchController", () => {
389
693
  expect(analytics.avgResultCount).toBe(0);
390
694
  expect(analytics.zeroResultCount).toBe(0);
391
695
  expect(analytics.zeroResultRate).toBe(0);
696
+ expect(analytics.clickThroughRate).toBe(0);
697
+ expect(analytics.avgClickPosition).toBe(0);
698
+ });
699
+
700
+ it("computes click-through rate and avg position", async () => {
701
+ const q1 = await controller.recordQuery("shoes", 10);
702
+ const q2 = await controller.recordQuery("hats", 5);
703
+ await controller.recordQuery("bags", 3);
704
+
705
+ await controller.recordClick({
706
+ queryId: q1.id,
707
+ term: "shoes",
708
+ entityType: "product",
709
+ entityId: "p1",
710
+ position: 0,
711
+ });
712
+ await controller.recordClick({
713
+ queryId: q2.id,
714
+ term: "hats",
715
+ entityType: "product",
716
+ entityId: "p2",
717
+ position: 2,
718
+ });
719
+
720
+ const analytics = await controller.getAnalytics();
721
+ // 2 queries with clicks out of 3 queries with results = 67%
722
+ expect(analytics.clickThroughRate).toBe(67);
723
+ // avg position: (0 + 2) / 2 = 1
724
+ expect(analytics.avgClickPosition).toBe(1);
392
725
  });
393
726
  });
394
727
 
@@ -25,9 +25,25 @@ interface Synonym {
25
25
  createdAt: string;
26
26
  }
27
27
 
28
+ interface SearchSettings {
29
+ meilisearch: {
30
+ configured: boolean;
31
+ host: string | null;
32
+ apiKey: string | null;
33
+ indexUid: string;
34
+ };
35
+ embeddings: {
36
+ configured: boolean;
37
+ provider: "openai" | "openrouter" | null;
38
+ model: string;
39
+ };
40
+ indexCount: number;
41
+ }
42
+
28
43
  function useSearchAdminApi() {
29
44
  const client = useModuleClient();
30
45
  return {
46
+ settings: client.module("search").admin["/admin/search/settings"],
31
47
  analytics: client.module("search").admin["/admin/search/analytics"],
32
48
  popular: client.module("search").admin["/admin/search/popular"],
33
49
  zeroResults: client.module("search").admin["/admin/search/zero-results"],
@@ -49,12 +65,41 @@ function StatCard({ label, value }: { label: string; value: string | number }) {
49
65
  );
50
66
  }
51
67
 
68
+ function ConfigBadge({
69
+ configured,
70
+ label,
71
+ }: {
72
+ configured: boolean;
73
+ label: string;
74
+ }) {
75
+ return (
76
+ <span
77
+ className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 font-medium text-xs ${
78
+ configured
79
+ ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
80
+ : "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
81
+ }`}
82
+ >
83
+ <span
84
+ className={`inline-block size-1.5 rounded-full ${
85
+ configured ? "bg-emerald-500" : "bg-amber-500"
86
+ }`}
87
+ />
88
+ {label}
89
+ </span>
90
+ );
91
+ }
92
+
52
93
  export function SearchAnalytics() {
53
94
  const api = useSearchAdminApi();
54
95
  const [newTerm, setNewTerm] = useState("");
55
96
  const [newSynonyms, setNewSynonyms] = useState("");
56
97
  const [error, setError] = useState("");
57
98
 
99
+ const { data: settingsData } = api.settings.useQuery({}) as {
100
+ data: SearchSettings | undefined;
101
+ };
102
+
58
103
  const { data: analyticsData, isLoading: analyticsLoading } =
59
104
  api.analytics.useQuery({}) as {
60
105
  data: { analytics: AnalyticsData } | undefined;
@@ -132,6 +177,81 @@ export function SearchAnalytics() {
132
177
 
133
178
  return (
134
179
  <div className="space-y-8">
180
+ {/* Search engine configuration */}
181
+ {settingsData && (
182
+ <div className="rounded-lg border border-border bg-background p-5">
183
+ <h3 className="mb-4 font-medium text-foreground text-sm">
184
+ Search Configuration
185
+ </h3>
186
+ <div className="grid gap-4 sm:grid-cols-2">
187
+ <div className="flex flex-col gap-2">
188
+ <div className="flex items-center justify-between">
189
+ <span className="text-muted-foreground text-sm">
190
+ MeiliSearch
191
+ </span>
192
+ <ConfigBadge
193
+ configured={settingsData.meilisearch.configured}
194
+ label={
195
+ settingsData.meilisearch.configured
196
+ ? "Connected"
197
+ : "Not configured"
198
+ }
199
+ />
200
+ </div>
201
+ {settingsData.meilisearch.configured ? (
202
+ <div className="text-muted-foreground text-xs">
203
+ <p>Host: {settingsData.meilisearch.host}</p>
204
+ <p>Index: {settingsData.meilisearch.indexUid}</p>
205
+ <p>Key: {settingsData.meilisearch.apiKey}</p>
206
+ </div>
207
+ ) : (
208
+ <p className="text-muted-foreground text-xs">
209
+ Using local search engine. Configure{" "}
210
+ <code className="rounded bg-muted px-1 text-[11px]">
211
+ MEILISEARCH_HOST
212
+ </code>{" "}
213
+ and{" "}
214
+ <code className="rounded bg-muted px-1 text-[11px]">
215
+ MEILISEARCH_API_KEY
216
+ </code>{" "}
217
+ for dedicated search.
218
+ </p>
219
+ )}
220
+ </div>
221
+ <div className="flex flex-col gap-2">
222
+ <div className="flex items-center justify-between">
223
+ <span className="text-muted-foreground text-sm">
224
+ AI Embeddings
225
+ </span>
226
+ <ConfigBadge
227
+ configured={settingsData.embeddings.configured}
228
+ label={
229
+ settingsData.embeddings.configured
230
+ ? settingsData.embeddings.provider === "openai"
231
+ ? "OpenAI"
232
+ : "OpenRouter"
233
+ : "Not configured"
234
+ }
235
+ />
236
+ </div>
237
+ {settingsData.embeddings.configured ? (
238
+ <p className="text-muted-foreground text-xs">
239
+ Model: {settingsData.embeddings.model}
240
+ </p>
241
+ ) : (
242
+ <p className="text-muted-foreground text-xs">
243
+ Semantic search disabled. Configure{" "}
244
+ <code className="rounded bg-muted px-1 text-[11px]">
245
+ OPENAI_API_KEY
246
+ </code>{" "}
247
+ for AI-powered results.
248
+ </p>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ )}
254
+
135
255
  {/* Stats overview */}
136
256
  {analytics && (
137
257
  <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
@@ -0,0 +1,34 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const bulkIndex = createAdminEndpoint(
5
+ "/admin/search/index/bulk",
6
+ {
7
+ method: "POST",
8
+ body: z.object({
9
+ items: z
10
+ .array(
11
+ z.object({
12
+ entityType: z.string().min(1).max(100),
13
+ entityId: z.string().min(1).max(200),
14
+ title: z.string().min(1).max(500),
15
+ body: z.string().max(10000).optional(),
16
+ tags: z.array(z.string().max(100)).max(50).optional(),
17
+ url: z.string().min(1).max(500),
18
+ image: z.string().max(500).optional(),
19
+ metadata: z
20
+ .record(z.string().max(100), z.unknown())
21
+ .refine((r) => Object.keys(r).length <= 50, "Too many keys")
22
+ .optional(),
23
+ }),
24
+ )
25
+ .min(1)
26
+ .max(500),
27
+ }),
28
+ },
29
+ async (ctx) => {
30
+ const controller = ctx.context.controllers.search as SearchController;
31
+ const result = await controller.bulkIndex(ctx.body.items);
32
+ return result;
33
+ },
34
+ );
@@ -0,0 +1,16 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ export const clickAnalyticsEndpoint = createAdminEndpoint(
5
+ "/admin/search/clicks",
6
+ { method: "GET" },
7
+ async (ctx) => {
8
+ const controller = ctx.context.controllers.search as SearchController;
9
+ const analytics = await controller.getAnalytics();
10
+
11
+ return {
12
+ clickThroughRate: analytics.clickThroughRate,
13
+ avgClickPosition: analytics.avgClickPosition,
14
+ };
15
+ },
16
+ );
@@ -0,0 +1,56 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ import type { SearchController } from "../../service";
3
+
4
+ function maskKey(key: string): string {
5
+ if (key.length <= 8) return `${key.slice(0, 2)}••••••`;
6
+ return `${key.slice(0, 4)}${"•".repeat(Math.min(key.length - 4, 20))}`;
7
+ }
8
+
9
+ interface SearchModuleOptions {
10
+ meilisearchHost?: string;
11
+ meilisearchApiKey?: string;
12
+ meilisearchIndexUid?: string;
13
+ openaiApiKey?: string;
14
+ openrouterApiKey?: string;
15
+ embeddingModel?: string;
16
+ }
17
+
18
+ export const getSettings = createAdminEndpoint(
19
+ "/admin/search/settings",
20
+ { method: "GET" },
21
+ async (ctx) => {
22
+ const controller = ctx.context.controllers.search as SearchController;
23
+ const options = ctx.context.options as SearchModuleOptions | undefined;
24
+
25
+ const meilisearchConfigured = Boolean(
26
+ options?.meilisearchHost && options?.meilisearchApiKey,
27
+ );
28
+
29
+ const embeddingConfigured = Boolean(
30
+ options?.openaiApiKey || options?.openrouterApiKey,
31
+ );
32
+
33
+ const indexCount = await controller.getIndexCount();
34
+
35
+ return {
36
+ meilisearch: {
37
+ configured: meilisearchConfigured,
38
+ host: options?.meilisearchHost ?? null,
39
+ apiKey: options?.meilisearchApiKey
40
+ ? maskKey(options.meilisearchApiKey)
41
+ : null,
42
+ indexUid: options?.meilisearchIndexUid ?? "search",
43
+ },
44
+ embeddings: {
45
+ configured: embeddingConfigured,
46
+ provider: options?.openaiApiKey
47
+ ? "openai"
48
+ : options?.openrouterApiKey
49
+ ? "openrouter"
50
+ : null,
51
+ model: options?.embeddingModel ?? "text-embedding-3-small",
52
+ },
53
+ indexCount,
54
+ };
55
+ },
56
+ );
@@ -13,7 +13,10 @@ export const indexItem = createAdminEndpoint(
13
13
  tags: z.array(z.string().max(100)).max(50).optional(),
14
14
  url: z.string().min(1).max(500),
15
15
  image: z.string().max(500).optional(),
16
- metadata: z.record(z.string(), z.unknown()).optional(),
16
+ metadata: z
17
+ .record(z.string().max(100), z.unknown())
18
+ .refine((r) => Object.keys(r).length <= 50, "Too many keys")
19
+ .optional(),
17
20
  }),
18
21
  },
19
22
  async (ctx) => {