@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,50 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'
4
+ import { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'
5
+ import { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'
6
+ import type { Queue } from '@open-mercato/queue'
7
+
8
+ /**
9
+ * Register search module dependencies.
10
+ *
11
+ * This registers:
12
+ * - vectorEmbeddingService: EmbeddingService for creating embeddings
13
+ * - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant)
14
+ * - vectorIndexQueue: Queue for vector indexing jobs
15
+ * - fulltextIndexQueue: Queue for fulltext indexing jobs
16
+ *
17
+ * Note: VectorIndexService is no longer registered here. Use SearchIndexer instead,
18
+ * which is registered in the main search module DI (packages/search/src/di.ts).
19
+ */
20
+ export function register(container: AppContainer) {
21
+ const embeddingService = new EmbeddingService()
22
+ const drivers = [
23
+ createPgVectorDriver(),
24
+ createChromaDbDriver(),
25
+ createQdrantDriver(),
26
+ ]
27
+
28
+ // Create queues based on environment strategy
29
+ const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
30
+ const queueConnection = queueStrategy === 'async'
31
+ ? { connection: { url: process.env.REDIS_URL || process.env.QUEUE_REDIS_URL } }
32
+ : undefined
33
+
34
+ const vectorIndexQueue: Queue<VectorIndexJobPayload> = createVectorIndexingQueue(
35
+ queueStrategy,
36
+ queueConnection,
37
+ )
38
+
39
+ const fulltextIndexQueue: Queue<FulltextIndexJobPayload> = createFulltextIndexingQueue(
40
+ queueStrategy,
41
+ queueConnection,
42
+ )
43
+
44
+ container.register({
45
+ vectorEmbeddingService: asValue(embeddingService),
46
+ vectorDrivers: asValue(drivers),
47
+ vectorIndexQueue: asValue(vectorIndexQueue),
48
+ fulltextIndexQueue: asValue(fulltextIndexQueue),
49
+ })
50
+ }
@@ -0,0 +1,436 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import {
6
+ Search,
7
+ Loader2,
8
+ Zap,
9
+ User,
10
+ Users,
11
+ Building,
12
+ StickyNote,
13
+ Briefcase,
14
+ CheckSquare,
15
+ FileText,
16
+ Mail,
17
+ Phone,
18
+ Calendar,
19
+ Clock,
20
+ Star,
21
+ Tag,
22
+ Flag,
23
+ Heart,
24
+ Bookmark,
25
+ Package,
26
+ Truck,
27
+ ShoppingCart,
28
+ CreditCard,
29
+ DollarSign,
30
+ Target,
31
+ Award,
32
+ Trophy,
33
+ Rocket,
34
+ Lightbulb,
35
+ MessageSquare,
36
+ Bell,
37
+ Settings,
38
+ Globe,
39
+ MapPin,
40
+ Link,
41
+ Folder,
42
+ Database,
43
+ Activity,
44
+ } from 'lucide-react'
45
+ import type { LucideIcon } from 'lucide-react'
46
+ import { Dialog, DialogContent } from '@open-mercato/ui/primitives/dialog'
47
+ import { Input } from '@open-mercato/ui/primitives/input'
48
+ import { Button } from '@open-mercato/ui/primitives/button'
49
+ import { cn } from '@open-mercato/shared/lib/utils'
50
+ import type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search'
51
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
52
+ import { fetchGlobalSearchResults } from '../utils'
53
+
54
+ const MIN_QUERY_LENGTH = 2
55
+
56
+ /** Default strategies used when none are configured */
57
+ const DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']
58
+
59
+ function normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {
60
+ if (!Array.isArray(links)) return []
61
+ return links.filter((link) => typeof link?.href === 'string')
62
+ }
63
+
64
+ function pickPrimaryLink(result: SearchResult): string | null {
65
+ if (result.url) return result.url
66
+ const links = normalizeLinks(result.links)
67
+ if (!links.length) return null
68
+ const primary = links.find((link) => link.kind === 'primary')
69
+ return (primary ?? links[0]).href
70
+ }
71
+
72
+ function humanizeSegment(segment: string): string {
73
+ return segment
74
+ .split(/[_-]+/)
75
+ .filter(Boolean)
76
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
77
+ .join(' ')
78
+ }
79
+
80
+ const ICON_MAP: Record<string, LucideIcon> = {
81
+ bolt: Zap,
82
+ zap: Zap,
83
+ user: User,
84
+ users: Users,
85
+ building: Building,
86
+ 'sticky-note': StickyNote,
87
+ briefcase: Briefcase,
88
+ 'check-square': CheckSquare,
89
+ 'file-text': FileText,
90
+ mail: Mail,
91
+ phone: Phone,
92
+ calendar: Calendar,
93
+ clock: Clock,
94
+ star: Star,
95
+ tag: Tag,
96
+ flag: Flag,
97
+ heart: Heart,
98
+ bookmark: Bookmark,
99
+ package: Package,
100
+ truck: Truck,
101
+ 'shopping-cart': ShoppingCart,
102
+ 'credit-card': CreditCard,
103
+ 'dollar-sign': DollarSign,
104
+ target: Target,
105
+ award: Award,
106
+ trophy: Trophy,
107
+ rocket: Rocket,
108
+ lightbulb: Lightbulb,
109
+ 'message-square': MessageSquare,
110
+ bell: Bell,
111
+ settings: Settings,
112
+ globe: Globe,
113
+ 'map-pin': MapPin,
114
+ link: Link,
115
+ folder: Folder,
116
+ database: Database,
117
+ activity: Activity,
118
+ }
119
+
120
+ function resolveIcon(name?: string): LucideIcon | null {
121
+ if (!name) return null
122
+ return ICON_MAP[name.toLowerCase()] ?? null
123
+ }
124
+
125
+ function formatEntityId(entityId: string): string {
126
+ if (!entityId.includes(':')) return humanizeSegment(entityId)
127
+ const [module, entity] = entityId.split(':')
128
+ return `${humanizeSegment(module)} · ${humanizeSegment(entity)}`
129
+ }
130
+
131
+ export type GlobalSearchDialogProps = {
132
+ /** Whether embedding provider is configured for vector search */
133
+ embeddingConfigured: boolean
134
+ /** Message to show when embedding is not configured */
135
+ missingConfigMessage: string
136
+ /** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */
137
+ enabledStrategies?: SearchStrategyId[]
138
+ }
139
+
140
+ export function GlobalSearchDialog({
141
+ embeddingConfigured,
142
+ missingConfigMessage,
143
+ enabledStrategies: propStrategies,
144
+ }: GlobalSearchDialogProps) {
145
+ const router = useRouter()
146
+ const [open, setOpen] = React.useState(false)
147
+ const [query, setQuery] = React.useState('')
148
+ const [results, setResults] = React.useState<SearchResult[]>([])
149
+ const [loading, setLoading] = React.useState(false)
150
+ const [error, setError] = React.useState<string | null>(null)
151
+ const [selectedIndex, setSelectedIndex] = React.useState(0)
152
+ const inputRef = React.useRef<HTMLInputElement | null>(null)
153
+ const abortRef = React.useRef<AbortController | null>(null)
154
+ const t = useT()
155
+
156
+ // Use configured strategies or fall back to defaults
157
+ const enabledStrategies = React.useMemo(() => {
158
+ if (propStrategies && propStrategies.length > 0) {
159
+ return propStrategies
160
+ }
161
+ return DEFAULT_STRATEGIES
162
+ }, [propStrategies])
163
+
164
+ const resetState = React.useCallback(() => {
165
+ setQuery('')
166
+ setResults([])
167
+ setError(null)
168
+ setSelectedIndex(0)
169
+ setLoading(false)
170
+ }, [])
171
+
172
+ React.useEffect(() => {
173
+ if (!open) {
174
+ resetState()
175
+ return
176
+ }
177
+ const handler = (event: KeyboardEvent) => {
178
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
179
+ event.preventDefault()
180
+ }
181
+ }
182
+ window.addEventListener('keydown', handler)
183
+ return () => window.removeEventListener('keydown', handler)
184
+ }, [open, resetState])
185
+
186
+ React.useEffect(() => {
187
+ const shortcut = (event: KeyboardEvent) => {
188
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
189
+ event.preventDefault()
190
+ setOpen((prev) => !prev)
191
+ }
192
+ }
193
+ window.addEventListener('keydown', shortcut)
194
+ return () => window.removeEventListener('keydown', shortcut)
195
+ }, [])
196
+
197
+ React.useEffect(() => {
198
+ if (!open) return
199
+ const focusTimer = setTimeout(() => inputRef.current?.focus(), 50)
200
+ return () => clearTimeout(focusTimer)
201
+ }, [open])
202
+
203
+ React.useEffect(() => {
204
+ if (!open) return
205
+
206
+ abortRef.current?.abort()
207
+ if (query.trim().length < MIN_QUERY_LENGTH) {
208
+ setResults([])
209
+ setError(null)
210
+ setLoading(false)
211
+ return
212
+ }
213
+
214
+ const controller = new AbortController()
215
+ abortRef.current = controller
216
+ setLoading(true)
217
+
218
+ const handle = setTimeout(async () => {
219
+ try {
220
+ const data = await fetchGlobalSearchResults(query, {
221
+ limit: 10,
222
+ signal: controller.signal,
223
+ })
224
+ setResults(data.results)
225
+ setError(data.error ?? null)
226
+ setSelectedIndex(0)
227
+ } catch (err: unknown) {
228
+ if (controller.signal.aborted) return
229
+ const abortError = err as { name?: string }
230
+ if (abortError?.name === 'AbortError') return
231
+ setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))
232
+ setResults([])
233
+ } finally {
234
+ if (!controller.signal.aborted) setLoading(false)
235
+ }
236
+ }, 220)
237
+
238
+ return () => {
239
+ clearTimeout(handle)
240
+ controller.abort()
241
+ }
242
+ }, [open, query, enabledStrategies, t])
243
+
244
+ const openResult = React.useCallback((result: SearchResult | undefined) => {
245
+ if (!result) return
246
+ const href = pickPrimaryLink(result)
247
+ if (!href) return
248
+ router.push(href)
249
+ setOpen(false)
250
+ }, [router])
251
+
252
+ const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
253
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
254
+ event.preventDefault()
255
+ openResult(results[selectedIndex])
256
+ return
257
+ }
258
+ if (event.key === 'ArrowDown') {
259
+ event.preventDefault()
260
+ setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1))
261
+ return
262
+ }
263
+ if (event.key === 'ArrowUp') {
264
+ event.preventDefault()
265
+ setSelectedIndex((prev) => {
266
+ if (!results.length) return 0
267
+ return prev <= 0 ? results.length - 1 : prev - 1
268
+ })
269
+ return
270
+ }
271
+ if (event.key === 'Escape') {
272
+ event.preventDefault()
273
+ setOpen(false)
274
+ return
275
+ }
276
+ if (event.key === 'Enter') {
277
+ event.preventDefault()
278
+ const target = results[selectedIndex]
279
+ openResult(target)
280
+ return
281
+ }
282
+ }, [results, selectedIndex, openResult])
283
+
284
+ // Check if vector search is enabled but not configured
285
+ const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error
286
+
287
+ // Check if selected result has a navigable link
288
+ const selectedResult = results[selectedIndex]
289
+ const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false
290
+
291
+ return (
292
+ <>
293
+ <Button type="button" variant="ghost" size="sm" onClick={() => setOpen(true)} className="hidden sm:inline-flex items-center gap-2">
294
+ <Search className="h-4 w-4" />
295
+ <span>{t('search.dialog.actions.search')}</span>
296
+ <span className="ml-2 rounded border px-1 text-xs text-muted-foreground">⌘K</span>
297
+ </Button>
298
+ <Button
299
+ type="button"
300
+ variant="ghost"
301
+ size="icon"
302
+ className="sm:hidden"
303
+ onClick={() => setOpen(true)}
304
+ aria-label={t('search.dialog.actions.openGlobalSearch')}
305
+ >
306
+ <Search className="h-4 w-4" />
307
+ </Button>
308
+ <Dialog open={open} onOpenChange={setOpen}>
309
+ <DialogContent className="max-w-xl p-0" aria-describedby="global-search-description">
310
+ <span id="global-search-description" className="sr-only">
311
+ {t('search.dialog.instructions')}
312
+ </span>
313
+ <div className="flex flex-col gap-3 border-b px-4 pb-3 pt-4">
314
+ <div className="flex items-center gap-2 rounded border bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring">
315
+ <Search className="h-4 w-4 text-muted-foreground" />
316
+ <TypedInput
317
+ ref={inputRef}
318
+ value={query}
319
+ onChange={(event) => setQuery(event.target.value)}
320
+ onKeyDown={handleKeyDown}
321
+ placeholder={t('search.dialog.input.placeholder')}
322
+ className="border-none px-0 shadow-none focus-visible:ring-0"
323
+ autoFocus
324
+ />
325
+ {loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
326
+ </div>
327
+
328
+ {error ? (
329
+ <p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
330
+ ) : null}
331
+ {showVectorWarning ? (
332
+ <p className="rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200">{missingConfigMessage}</p>
333
+ ) : null}
334
+ </div>
335
+ <div className="max-h-96 overflow-y-auto px-2 pb-3">
336
+ {results.length === 0 && !loading && !error ? (
337
+ <div className="px-4 py-6 text-sm text-muted-foreground">
338
+ {query.trim().length < MIN_QUERY_LENGTH
339
+ ? t('search.dialog.empty.hint')
340
+ : t('search.dialog.empty.none')}
341
+ </div>
342
+ ) : null}
343
+ <ul className="flex flex-col">
344
+ {results.map((result, index) => {
345
+ const presenter = result.presenter
346
+ const isActive = index === selectedIndex
347
+ const hasLink = pickPrimaryLink(result) !== null
348
+ const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null
349
+ return (
350
+ <li key={`${result.entityId}:${result.recordId}`}>
351
+ <button
352
+ type="button"
353
+ onClick={() => openResult(result)}
354
+ onMouseEnter={() => setSelectedIndex(index)}
355
+ className={cn(
356
+ 'w-full rounded-lg px-4 py-3 text-left transition border',
357
+ isActive
358
+ ? 'border-primary bg-primary/10 text-foreground shadow-sm'
359
+ : 'border-transparent hover:border-muted-foreground/30 hover:bg-muted/60',
360
+ !hasLink && 'opacity-60'
361
+ )}
362
+ >
363
+ <div className="flex items-start justify-between gap-4">
364
+ <div className="flex flex-col gap-1">
365
+ <div className="flex flex-wrap items-center gap-2">
366
+ <span className={cn('font-medium text-base whitespace-normal break-all', !hasLink && 'text-muted-foreground')}>{presenter?.title ?? result.recordId}</span>
367
+ <span className="rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground">
368
+ {formatEntityId(result.entityId)}
369
+ </span>
370
+ {!hasLink && (
371
+ <span className="rounded-full border border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400">
372
+ {t('search.dialog.noLink')}
373
+ </span>
374
+ )}
375
+ </div>
376
+ {presenter?.subtitle ? (
377
+ <div className="text-sm text-muted-foreground whitespace-normal break-words">{presenter.subtitle}</div>
378
+ ) : null}
379
+ {normalizeLinks(result.links).length ? (
380
+ <div className="mt-1 flex flex-wrap items-center gap-2">
381
+ {normalizeLinks(result.links).map((link) => (
382
+ <span
383
+ key={`${link.href}`}
384
+ className={cn(
385
+ 'rounded-full border px-2 py-0.5 text-xs',
386
+ link.kind === 'primary'
387
+ ? 'border-primary text-primary'
388
+ : 'border-muted-foreground/40 text-muted-foreground'
389
+ )}
390
+ >
391
+ {link.label ?? link.href}
392
+ </span>
393
+ ))}
394
+ </div>
395
+ ) : null}
396
+ </div>
397
+ {Icon ? (
398
+ <div className="flex flex-col items-end gap-2">
399
+ <Icon className="h-5 w-5 text-muted-foreground" />
400
+ </div>
401
+ ) : null}
402
+ </div>
403
+ </button>
404
+ </li>
405
+ )
406
+ })}
407
+ </ul>
408
+ </div>
409
+ <div className="flex items-center justify-between border-t px-4 py-3">
410
+ <span className="text-xs text-muted-foreground">
411
+ {selectedResult && !selectedHasLink
412
+ ? t('search.dialog.noLinkHint')
413
+ : t('search.dialog.shortcuts.hint')}
414
+ </span>
415
+ <div className="flex items-center gap-2">
416
+ <Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
417
+ {t('search.dialog.actions.cancel')}
418
+ </Button>
419
+ <Button
420
+ type="button"
421
+ size="sm"
422
+ onClick={() => openResult(results[selectedIndex])}
423
+ disabled={!results.length || !selectedHasLink}
424
+ >
425
+ {t('search.dialog.actions.openSelected')}
426
+ </Button>
427
+ </div>
428
+ </div>
429
+ </DialogContent>
430
+ </Dialog>
431
+ </>
432
+ )
433
+ }
434
+
435
+ export default GlobalSearchDialog
436
+ const TypedInput = Input as React.ForwardRefExoticComponent<React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>