@open-mercato/search 0.4.2-canary-c02407ff85

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 (237) hide show
  1. package/AGENTS.md +678 -0
  2. package/build.mjs +92 -0
  3. package/dist/di.js +157 -0
  4. package/dist/di.js.map +7 -0
  5. package/dist/fulltext/drivers/index.js +21 -0
  6. package/dist/fulltext/drivers/index.js.map +7 -0
  7. package/dist/fulltext/drivers/meilisearch/index.js +320 -0
  8. package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
  9. package/dist/fulltext/index.js +7 -0
  10. package/dist/fulltext/index.js.map +7 -0
  11. package/dist/fulltext/types.js +1 -0
  12. package/dist/fulltext/types.js.map +7 -0
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/indexer/index.js +8 -0
  16. package/dist/indexer/index.js.map +7 -0
  17. package/dist/indexer/search-indexer.js +848 -0
  18. package/dist/indexer/search-indexer.js.map +7 -0
  19. package/dist/indexer/subscribers/delete.js +41 -0
  20. package/dist/indexer/subscribers/delete.js.map +7 -0
  21. package/dist/lib/debug.js +34 -0
  22. package/dist/lib/debug.js.map +7 -0
  23. package/dist/lib/fallback-presenter.js +107 -0
  24. package/dist/lib/fallback-presenter.js.map +7 -0
  25. package/dist/lib/field-policy.js +75 -0
  26. package/dist/lib/field-policy.js.map +7 -0
  27. package/dist/lib/index.js +19 -0
  28. package/dist/lib/index.js.map +7 -0
  29. package/dist/lib/merger.js +93 -0
  30. package/dist/lib/merger.js.map +7 -0
  31. package/dist/lib/presenter-enricher.js +192 -0
  32. package/dist/lib/presenter-enricher.js.map +7 -0
  33. package/dist/modules/search/acl.js +14 -0
  34. package/dist/modules/search/acl.js.map +7 -0
  35. package/dist/modules/search/ai-tools.js +284 -0
  36. package/dist/modules/search/ai-tools.js.map +7 -0
  37. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
  38. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
  39. package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
  40. package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
  41. package/dist/modules/search/api/embeddings/route.js +246 -0
  42. package/dist/modules/search/api/embeddings/route.js.map +7 -0
  43. package/dist/modules/search/api/index/route.js +245 -0
  44. package/dist/modules/search/api/index/route.js.map +7 -0
  45. package/dist/modules/search/api/reindex/cancel/route.js +65 -0
  46. package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
  47. package/dist/modules/search/api/reindex/route.js +332 -0
  48. package/dist/modules/search/api/reindex/route.js.map +7 -0
  49. package/dist/modules/search/api/search/global/route.js +100 -0
  50. package/dist/modules/search/api/search/global/route.js.map +7 -0
  51. package/dist/modules/search/api/search/route.js +101 -0
  52. package/dist/modules/search/api/search/route.js.map +7 -0
  53. package/dist/modules/search/api/settings/fulltext/route.js +55 -0
  54. package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
  55. package/dist/modules/search/api/settings/global-search/route.js +80 -0
  56. package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
  57. package/dist/modules/search/api/settings/route.js +118 -0
  58. package/dist/modules/search/api/settings/route.js.map +7 -0
  59. package/dist/modules/search/api/settings/vector-store/route.js +77 -0
  60. package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
  61. package/dist/modules/search/backend/config/search/page.js +10 -0
  62. package/dist/modules/search/backend/config/search/page.js.map +7 -0
  63. package/dist/modules/search/backend/config/search/page.meta.js +24 -0
  64. package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
  65. package/dist/modules/search/cli.js +698 -0
  66. package/dist/modules/search/cli.js.map +7 -0
  67. package/dist/modules/search/di.js +32 -0
  68. package/dist/modules/search/di.js.map +7 -0
  69. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
  70. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
  71. package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
  72. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
  73. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
  74. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
  75. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
  76. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
  77. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
  78. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
  79. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
  80. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
  81. package/dist/modules/search/frontend/index.js +9 -0
  82. package/dist/modules/search/frontend/index.js.map +7 -0
  83. package/dist/modules/search/frontend/utils.js +41 -0
  84. package/dist/modules/search/frontend/utils.js.map +7 -0
  85. package/dist/modules/search/i18n/de.json +61 -0
  86. package/dist/modules/search/i18n/en.json +72 -0
  87. package/dist/modules/search/i18n/es.json +61 -0
  88. package/dist/modules/search/i18n/pl.json +61 -0
  89. package/dist/modules/search/index.js +11 -0
  90. package/dist/modules/search/index.js.map +7 -0
  91. package/dist/modules/search/lib/auto-indexing.js +29 -0
  92. package/dist/modules/search/lib/auto-indexing.js.map +7 -0
  93. package/dist/modules/search/lib/embedding-config.js +131 -0
  94. package/dist/modules/search/lib/embedding-config.js.map +7 -0
  95. package/dist/modules/search/lib/global-search-config.js +45 -0
  96. package/dist/modules/search/lib/global-search-config.js.map +7 -0
  97. package/dist/modules/search/lib/reindex-lock.js +99 -0
  98. package/dist/modules/search/lib/reindex-lock.js.map +7 -0
  99. package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
  100. package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
  101. package/dist/modules/search/subscribers/vector_delete.js +58 -0
  102. package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
  103. package/dist/modules/search/subscribers/vector_purge.js +142 -0
  104. package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
  105. package/dist/modules/search/subscribers/vector_upsert.js +58 -0
  106. package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
  107. package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
  108. package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
  109. package/dist/modules/search/workers/vector-index.worker.js +234 -0
  110. package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
  111. package/dist/queue/fulltext-indexing.js +15 -0
  112. package/dist/queue/fulltext-indexing.js.map +7 -0
  113. package/dist/queue/index.js +3 -0
  114. package/dist/queue/index.js.map +7 -0
  115. package/dist/queue/vector-indexing.js +15 -0
  116. package/dist/queue/vector-indexing.js.map +7 -0
  117. package/dist/service.js +286 -0
  118. package/dist/service.js.map +7 -0
  119. package/dist/strategies/fulltext.strategy.js +116 -0
  120. package/dist/strategies/fulltext.strategy.js.map +7 -0
  121. package/dist/strategies/index.js +12 -0
  122. package/dist/strategies/index.js.map +7 -0
  123. package/dist/strategies/token.strategy.js +80 -0
  124. package/dist/strategies/token.strategy.js.map +7 -0
  125. package/dist/strategies/vector.strategy.js +137 -0
  126. package/dist/strategies/vector.strategy.js.map +7 -0
  127. package/dist/types.js +1 -0
  128. package/dist/types.js.map +7 -0
  129. package/dist/vector/drivers/chromadb/index.js +44 -0
  130. package/dist/vector/drivers/chromadb/index.js.map +7 -0
  131. package/dist/vector/drivers/index.js +9 -0
  132. package/dist/vector/drivers/index.js.map +7 -0
  133. package/dist/vector/drivers/pgvector/index.js +509 -0
  134. package/dist/vector/drivers/pgvector/index.js.map +7 -0
  135. package/dist/vector/drivers/qdrant/index.js +44 -0
  136. package/dist/vector/drivers/qdrant/index.js.map +7 -0
  137. package/dist/vector/index.js +4 -0
  138. package/dist/vector/index.js.map +7 -0
  139. package/dist/vector/lib/vector-logs.js +33 -0
  140. package/dist/vector/lib/vector-logs.js.map +7 -0
  141. package/dist/vector/services/checksum.js +20 -0
  142. package/dist/vector/services/checksum.js.map +7 -0
  143. package/dist/vector/services/embedding.js +222 -0
  144. package/dist/vector/services/embedding.js.map +7 -0
  145. package/dist/vector/services/index.js +4 -0
  146. package/dist/vector/services/index.js.map +7 -0
  147. package/dist/vector/services/vector-index.service.js +960 -0
  148. package/dist/vector/services/vector-index.service.js.map +7 -0
  149. package/dist/vector/types/pg.d.js +1 -0
  150. package/dist/vector/types/pg.d.js.map +7 -0
  151. package/dist/vector/types.js +75 -0
  152. package/dist/vector/types.js.map +7 -0
  153. package/jest.config.cjs +19 -0
  154. package/package.json +142 -0
  155. package/src/__tests__/queue.test.ts +148 -0
  156. package/src/__tests__/service.test.ts +345 -0
  157. package/src/__tests__/workers.test.ts +319 -0
  158. package/src/di.ts +291 -0
  159. package/src/fulltext/drivers/index.ts +41 -0
  160. package/src/fulltext/drivers/meilisearch/index.ts +410 -0
  161. package/src/fulltext/index.ts +13 -0
  162. package/src/fulltext/types.ts +115 -0
  163. package/src/index.ts +36 -0
  164. package/src/indexer/index.ts +13 -0
  165. package/src/indexer/search-indexer.ts +1141 -0
  166. package/src/indexer/subscribers/delete.ts +49 -0
  167. package/src/lib/debug.ts +46 -0
  168. package/src/lib/fallback-presenter.ts +106 -0
  169. package/src/lib/field-policy.ts +169 -0
  170. package/src/lib/index.ts +13 -0
  171. package/src/lib/merger.ts +159 -0
  172. package/src/lib/presenter-enricher.ts +323 -0
  173. package/src/modules/search/README.md +694 -0
  174. package/src/modules/search/acl.ts +10 -0
  175. package/src/modules/search/ai-tools.ts +467 -0
  176. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
  177. package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
  178. package/src/modules/search/api/embeddings/route.ts +304 -0
  179. package/src/modules/search/api/index/route.ts +297 -0
  180. package/src/modules/search/api/reindex/cancel/route.ts +77 -0
  181. package/src/modules/search/api/reindex/route.ts +419 -0
  182. package/src/modules/search/api/search/global/route.ts +120 -0
  183. package/src/modules/search/api/search/route.ts +121 -0
  184. package/src/modules/search/api/settings/fulltext/route.ts +82 -0
  185. package/src/modules/search/api/settings/global-search/route.ts +91 -0
  186. package/src/modules/search/api/settings/route.ts +187 -0
  187. package/src/modules/search/api/settings/vector-store/route.ts +105 -0
  188. package/src/modules/search/backend/config/search/page.meta.ts +22 -0
  189. package/src/modules/search/backend/config/search/page.tsx +12 -0
  190. package/src/modules/search/cli.ts +818 -0
  191. package/src/modules/search/di.ts +50 -0
  192. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
  193. package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
  194. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
  195. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
  196. package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
  197. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
  198. package/src/modules/search/frontend/index.ts +3 -0
  199. package/src/modules/search/frontend/utils.ts +82 -0
  200. package/src/modules/search/i18n/de.json +61 -0
  201. package/src/modules/search/i18n/en.json +72 -0
  202. package/src/modules/search/i18n/es.json +61 -0
  203. package/src/modules/search/i18n/pl.json +61 -0
  204. package/src/modules/search/index.ts +9 -0
  205. package/src/modules/search/lib/auto-indexing.ts +35 -0
  206. package/src/modules/search/lib/embedding-config.ts +161 -0
  207. package/src/modules/search/lib/global-search-config.ts +69 -0
  208. package/src/modules/search/lib/reindex-lock.ts +201 -0
  209. package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
  210. package/src/modules/search/subscribers/vector_delete.ts +75 -0
  211. package/src/modules/search/subscribers/vector_purge.ts +161 -0
  212. package/src/modules/search/subscribers/vector_upsert.ts +75 -0
  213. package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
  214. package/src/modules/search/workers/vector-index.worker.ts +292 -0
  215. package/src/queue/fulltext-indexing.ts +87 -0
  216. package/src/queue/index.ts +2 -0
  217. package/src/queue/vector-indexing.ts +66 -0
  218. package/src/service.ts +397 -0
  219. package/src/strategies/fulltext.strategy.ts +155 -0
  220. package/src/strategies/index.ts +17 -0
  221. package/src/strategies/token.strategy.ts +153 -0
  222. package/src/strategies/vector.strategy.ts +234 -0
  223. package/src/types.ts +38 -0
  224. package/src/vector/drivers/chromadb/index.ts +49 -0
  225. package/src/vector/drivers/index.ts +4 -0
  226. package/src/vector/drivers/pgvector/index.ts +627 -0
  227. package/src/vector/drivers/qdrant/index.ts +49 -0
  228. package/src/vector/index.ts +3 -0
  229. package/src/vector/lib/vector-logs.ts +46 -0
  230. package/src/vector/services/checksum.ts +18 -0
  231. package/src/vector/services/embedding.ts +275 -0
  232. package/src/vector/services/index.ts +3 -0
  233. package/src/vector/services/vector-index.service.ts +1234 -0
  234. package/src/vector/types/pg.d.ts +1 -0
  235. package/src/vector/types.ts +220 -0
  236. package/tsconfig.json +9 -0
  237. package/watch.mjs +6 -0
@@ -0,0 +1,418 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import * as LucideIcons from 'lucide-react'
7
+ import type { LucideIcon } from 'lucide-react'
8
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
9
+ import { RowActions } from '@open-mercato/ui/backend/RowActions'
10
+ import type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'
11
+ import { cn } from '@open-mercato/shared/lib/utils'
12
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+ import { fetchHybridSearchResults } from '../utils'
15
+
16
+ type Row = {
17
+ entityId: string
18
+ recordId: string
19
+ source: string
20
+ score: number | null
21
+ url: string | null
22
+ presenter: SearchResult['presenter'] | null
23
+ links: SearchResult['links'] | null
24
+ metadata: Record<string, unknown> | null
25
+ }
26
+
27
+ const MIN_QUERY_LENGTH = 2
28
+ const ALL_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']
29
+
30
+ type Translator = (
31
+ key: string,
32
+ fallbackOrParams?: string | Record<string, string | number>,
33
+ params?: Record<string, string | number>
34
+ ) => string
35
+
36
+ function createColumns(t: Translator): ColumnDef<Row>[] {
37
+ return [
38
+ {
39
+ id: 'title',
40
+ header: () => t('search.table.columns.result', 'Result'),
41
+ cell: ({ row }) => {
42
+ const item = row.original
43
+ const title = resolveRowTitle(item)
44
+ const iconName = item.presenter?.icon
45
+ const Icon = iconName ? resolveIcon(iconName) : null
46
+ const typeLabel = formatEntityId(item.entityId)
47
+ const snapshot = item.presenter?.subtitle ?? extractSnapshot(item.metadata)
48
+ const links = normalizeLinks(item.links)
49
+ return (
50
+ <div className="flex flex-col">
51
+ <div className="flex items-start gap-3">
52
+ {Icon ? <Icon className="mt-0.5 h-5 w-5 text-muted-foreground" /> : null}
53
+ <div className="flex flex-col gap-1">
54
+ <div className="flex flex-wrap items-center gap-2">
55
+ <span className="font-medium whitespace-normal break-all">{title}</span>
56
+ <span className="rounded border border-muted-foreground/40 px-2 py-0.5 text-xs text-muted-foreground">
57
+ {typeLabel}
58
+ </span>
59
+ </div>
60
+ {snapshot ? (
61
+ <span className="text-sm text-muted-foreground whitespace-normal break-words">{snapshot}</span>
62
+ ) : null}
63
+ {links.length ? (
64
+ <div className="mt-1 flex flex-wrap items-center gap-2">
65
+ {links.map((link) => (
66
+ <span
67
+ key={`${item.entityId}:${item.recordId}:${link.href}`}
68
+ className={cn(
69
+ 'rounded-full border px-2 py-0.5 text-xs',
70
+ link.kind === 'primary'
71
+ ? 'border-primary text-primary'
72
+ : 'border-muted-foreground/40 text-muted-foreground'
73
+ )}
74
+ >
75
+ {link.label ?? link.href}
76
+ </span>
77
+ ))}
78
+ </div>
79
+ ) : null}
80
+ </div>
81
+ </div>
82
+ </div>
83
+ )
84
+ },
85
+ meta: { priority: 1 },
86
+ },
87
+ {
88
+ id: 'source',
89
+ header: () => t('search.table.columns.source', 'Source'),
90
+ cell: ({ row }) => {
91
+ const source = row.original.source
92
+ const colorClass = getStrategyColorClass(source)
93
+ return (
94
+ <span className={cn('rounded px-2 py-0.5 text-xs font-medium', colorClass)}>
95
+ {source}
96
+ </span>
97
+ )
98
+ },
99
+ meta: { priority: 2 },
100
+ },
101
+ {
102
+ id: 'score',
103
+ header: () => t('search.table.columns.score', 'Score'),
104
+ cell: ({ row }) => <span>{row.original.score != null ? row.original.score.toFixed(2) : '—'}</span>,
105
+ meta: { priority: 2 },
106
+ },
107
+ ]
108
+ }
109
+
110
+ function getStrategyColorClass(strategy: string): string {
111
+ switch (strategy) {
112
+ case 'fulltext':
113
+ return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
114
+ case 'vector':
115
+ return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
116
+ case 'tokens':
117
+ return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
118
+ default:
119
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
120
+ }
121
+ }
122
+
123
+ function normalizeLinks(links?: Row['links']): { href: string; label?: string; kind?: string }[] {
124
+ if (!Array.isArray(links)) return []
125
+ return links.filter((link) => typeof link?.href === 'string') as Array<{ href: string; label?: string; kind?: string }>
126
+ }
127
+
128
+ function toPascalCase(input: string): string {
129
+ return input
130
+ .split(/[-_ ]+/)
131
+ .filter(Boolean)
132
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
133
+ .join('')
134
+ }
135
+
136
+ function resolveIcon(name?: string): LucideIcon | null {
137
+ if (!name) return null
138
+ const key = toPascalCase(name)
139
+ const candidate = (LucideIcons as Record<string, unknown>)[key]
140
+ if (typeof candidate === 'function') {
141
+ return candidate as LucideIcon
142
+ }
143
+ return null
144
+ }
145
+
146
+ function humanizeSegment(segment: string): string {
147
+ return segment
148
+ .split(/[_-]+/)
149
+ .filter(Boolean)
150
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
151
+ .join(' ')
152
+ }
153
+
154
+ function formatEntityId(entityId: string): string {
155
+ if (!entityId.includes(':')) return humanizeSegment(entityId)
156
+ const [module, entity] = entityId.split(':')
157
+ const moduleLabel = humanizeSegment(module)
158
+ const entityLabel = humanizeSegment(entity)
159
+ return `${moduleLabel} · ${entityLabel}`
160
+ }
161
+
162
+ function resolveRowTitle(row: Row): string {
163
+ const presenterTitle = row.presenter?.title
164
+ if (typeof presenterTitle === 'string') {
165
+ const trimmed = presenterTitle.trim()
166
+ if (trimmed.length) return trimmed
167
+ }
168
+ return row.recordId
169
+ }
170
+
171
+ function extractSnapshot(metadata: Record<string, unknown> | null): string | null {
172
+ if (!metadata) return null
173
+ const candidateKeys = ['snapshot', 'summary', 'description', 'body', 'content', 'note']
174
+ for (const key of candidateKeys) {
175
+ const value = metadata[key]
176
+ if (typeof value === 'string') {
177
+ const trimmed = value.trim()
178
+ if (trimmed.length) return trimmed
179
+ }
180
+ }
181
+ return null
182
+ }
183
+
184
+ function pickPrimaryLink(row: Row): string | null {
185
+ if (row.url) return row.url
186
+ const links = normalizeLinks(row.links)
187
+ if (!links.length) return null
188
+ const primary = links.find((link) => link.kind === 'primary')
189
+ return (primary ?? links[0]).href
190
+ }
191
+
192
+ function normalizeErrorMessage(input: unknown, fallback?: string): string | null {
193
+ const fallbackMessage = typeof fallback === 'string' && fallback.trim().length ? fallback.trim() : null
194
+ let message: string | null = null
195
+ if (typeof input === 'string') {
196
+ message = input
197
+ } else if (input instanceof Error && typeof input.message === 'string') {
198
+ message = input.message
199
+ }
200
+ if (message) {
201
+ const trimmed = message.trim()
202
+ if (trimmed.length) {
203
+ const sanitized = trimmed.replace(/^\[[^\]]+\]\s*/, '').trim()
204
+ if (sanitized.length) return sanitized
205
+ }
206
+ }
207
+ return fallbackMessage
208
+ }
209
+
210
+ type HybridSearchTableProps = {
211
+ /** Show strategy selector checkboxes (default: false - hidden from regular users) */
212
+ showStrategySelector?: boolean
213
+ /** Show source column in results (default: false - hidden from regular users) */
214
+ showSourceColumn?: boolean
215
+ }
216
+
217
+ export function HybridSearchTable({
218
+ showStrategySelector = false,
219
+ showSourceColumn = false,
220
+ }: HybridSearchTableProps = {}) {
221
+ const router = useRouter()
222
+ const t = useT()
223
+ const [searchValue, setSearchValue] = React.useState('')
224
+ const [rows, setRows] = React.useState<Row[]>([])
225
+ const [page, setPage] = React.useState(1)
226
+ const [loading, setLoading] = React.useState(false)
227
+ const [error, setError] = React.useState<string | null>(null)
228
+ const [timing, setTiming] = React.useState<number | null>(null)
229
+ const [strategiesUsed, setStrategiesUsed] = React.useState<string[]>([])
230
+ const [enabledStrategies, setEnabledStrategies] = React.useState<Set<SearchStrategyId>>(
231
+ new Set(ALL_STRATEGIES)
232
+ )
233
+ const debounceRef = React.useRef<number | null>(null)
234
+ const abortRef = React.useRef<AbortController | null>(null)
235
+ const columns = React.useMemo(() => {
236
+ const allColumns = createColumns(t)
237
+ if (!showSourceColumn) {
238
+ return allColumns.filter((col) => col.id !== 'source')
239
+ }
240
+ return allColumns
241
+ }, [t, showSourceColumn])
242
+
243
+ const toggleStrategy = React.useCallback((strategy: SearchStrategyId) => {
244
+ setEnabledStrategies((prev) => {
245
+ const next = new Set(prev)
246
+ if (next.has(strategy)) {
247
+ next.delete(strategy)
248
+ } else {
249
+ next.add(strategy)
250
+ }
251
+ return next
252
+ })
253
+ }, [])
254
+
255
+ const openRow = React.useCallback(
256
+ (row: Row) => {
257
+ const href = pickPrimaryLink(row)
258
+ if (!href) return
259
+ router.push(href)
260
+ },
261
+ [router]
262
+ )
263
+
264
+ React.useEffect(() => {
265
+ const trimmed = searchValue.trim()
266
+ abortRef.current?.abort()
267
+ if (debounceRef.current) {
268
+ window.clearTimeout(debounceRef.current)
269
+ debounceRef.current = null
270
+ }
271
+
272
+ if (trimmed.length < MIN_QUERY_LENGTH) {
273
+ setRows([])
274
+ setTiming(null)
275
+ setStrategiesUsed([])
276
+ setError(null)
277
+ setLoading(false)
278
+ return
279
+ }
280
+
281
+ if (enabledStrategies.size === 0) {
282
+ setRows([])
283
+ setTiming(null)
284
+ setStrategiesUsed([])
285
+ setError(t('search.table.errors.noSources', 'Select at least one search source'))
286
+ setLoading(false)
287
+ return
288
+ }
289
+
290
+ const controller = new AbortController()
291
+ abortRef.current = controller
292
+ setLoading(true)
293
+
294
+ debounceRef.current = window.setTimeout(async () => {
295
+ try {
296
+ const data = await fetchHybridSearchResults(trimmed, {
297
+ limit: 50,
298
+ strategies: Array.from(enabledStrategies),
299
+ signal: controller.signal,
300
+ })
301
+ const mapped = data.results.map<Row>((item) => ({
302
+ entityId: item.entityId,
303
+ recordId: item.recordId,
304
+ source: item.source,
305
+ score: typeof item.score === 'number' ? item.score : null,
306
+ url: item.url ?? null,
307
+ presenter: item.presenter ?? null,
308
+ links: item.links ?? null,
309
+ metadata: (item.metadata as Record<string, unknown> | null) ?? null,
310
+ }))
311
+ setRows(mapped)
312
+ setTiming(data.timing)
313
+ setStrategiesUsed(data.strategiesUsed)
314
+ const message = data.error ? normalizeErrorMessage(data.error, t('search.table.errors.searchFailed', 'Search failed')) : null
315
+ setError(message ?? null)
316
+ setPage(1)
317
+ } catch (err: unknown) {
318
+ if (controller.signal.aborted) return
319
+ if ((err as { name?: string })?.name === 'AbortError') return
320
+ setError(normalizeErrorMessage(err, t('search.table.errors.searchFailed', 'Search failed')))
321
+ setRows([])
322
+ setTiming(null)
323
+ setStrategiesUsed([])
324
+ } finally {
325
+ if (!controller.signal.aborted) setLoading(false)
326
+ }
327
+ }, 250)
328
+
329
+ return () => {
330
+ controller.abort()
331
+ if (debounceRef.current) window.clearTimeout(debounceRef.current)
332
+ }
333
+ }, [searchValue, enabledStrategies, t])
334
+
335
+ React.useEffect(() => {
336
+ if (!error) return
337
+ flash(error, 'error')
338
+ }, [error])
339
+
340
+ return (
341
+ <div className="flex w-full flex-col gap-4">
342
+ {/* Source Selector - only shown when showStrategySelector is true */}
343
+ {showStrategySelector && (
344
+ <div className="flex flex-wrap items-center gap-4 rounded-lg border bg-muted/50 p-3">
345
+ <span className="text-sm font-medium text-muted-foreground">
346
+ {t('search.table.sources', 'Sources:')}
347
+ </span>
348
+ {ALL_STRATEGIES.map((strategy) => (
349
+ <label key={strategy} className="flex cursor-pointer items-center gap-2">
350
+ <input
351
+ type="checkbox"
352
+ className="size-4 rounded border-gray-300"
353
+ checked={enabledStrategies.has(strategy)}
354
+ onChange={() => toggleStrategy(strategy)}
355
+ />
356
+ <span
357
+ className={cn(
358
+ 'rounded px-2 py-0.5 text-xs font-medium',
359
+ getStrategyColorClass(strategy)
360
+ )}
361
+ >
362
+ {strategy}
363
+ </span>
364
+ </label>
365
+ ))}
366
+ </div>
367
+ )}
368
+
369
+ {/* Stats Bar */}
370
+ {timing !== null && rows.length > 0 && (
371
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
372
+ <span>{rows.length} {t('search.table.stats.results', 'results')}</span>
373
+ <span>{timing}ms</span>
374
+ {/* Only show sources when strategy selector is visible */}
375
+ {showStrategySelector && strategiesUsed.length > 0 && (
376
+ <span>
377
+ {t('search.table.stats.sources', 'Sources:')} {strategiesUsed.join(', ')}
378
+ </span>
379
+ )}
380
+ </div>
381
+ )}
382
+
383
+ {/* Error Alert */}
384
+ {error ? (
385
+ <div
386
+ role="alert"
387
+ className="w-full rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
388
+ >
389
+ {error}
390
+ </div>
391
+ ) : null}
392
+
393
+ {/* Data Table */}
394
+ <DataTable<Row>
395
+ title={t('search.table.title', 'Search')}
396
+ columns={columns}
397
+ data={rows}
398
+ searchValue={searchValue}
399
+ onSearchChange={(value) => {
400
+ setSearchValue(value)
401
+ setPage(1)
402
+ }}
403
+ searchPlaceholder={t('search.table.searchPlaceholder', 'Search across all strategies...')}
404
+ isLoading={loading}
405
+ pagination={{ page, pageSize: rows.length || 1, total: rows.length, totalPages: 1, onPageChange: setPage }}
406
+ onRowClick={(row) => openRow(row)}
407
+ rowActions={(row) => {
408
+ const primaryHref = pickPrimaryLink(row)
409
+ if (!primaryHref) return null
410
+ return <RowActions items={[{ label: t('search.table.actions.open', 'Open'), href: primaryHref }]} />
411
+ }}
412
+ embedded
413
+ />
414
+ </div>
415
+ )
416
+ }
417
+
418
+ export default HybridSearchTable