@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,476 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
9
+ import { GlobalSearchSection } from './sections/GlobalSearchSection'
10
+ import { FulltextSearchSection } from './sections/FulltextSearchSection'
11
+ import { VectorSearchSection } from './sections/VectorSearchSection'
12
+
13
+ // Types
14
+ type StrategyStatus = {
15
+ id: string
16
+ name: string
17
+ priority: number
18
+ available: boolean
19
+ }
20
+
21
+ type FulltextStats = {
22
+ numberOfDocuments: number
23
+ isIndexing: boolean
24
+ fieldDistribution: Record<string, number>
25
+ }
26
+
27
+ type ReindexLock = {
28
+ type: 'fulltext' | 'vector'
29
+ action: string
30
+ startedAt: string
31
+ elapsedMinutes: number
32
+ }
33
+
34
+ type SearchSettings = {
35
+ strategies: StrategyStatus[]
36
+ fulltextConfigured: boolean
37
+ fulltextStats: FulltextStats | null
38
+ vectorConfigured: boolean
39
+ tokensEnabled: boolean
40
+ defaultStrategies: string[]
41
+ reindexLock: ReindexLock | null
42
+ fulltextReindexLock: ReindexLock | null
43
+ vectorReindexLock: ReindexLock | null
44
+ }
45
+
46
+ type SettingsResponse = {
47
+ settings?: SearchSettings
48
+ error?: string
49
+ }
50
+
51
+ // Embedding types
52
+ type EmbeddingProviderId = 'openai' | 'google' | 'mistral' | 'cohere' | 'bedrock' | 'ollama'
53
+
54
+ type EmbeddingProviderConfig = {
55
+ providerId: EmbeddingProviderId
56
+ model: string
57
+ dimension: number
58
+ outputDimensionality?: number
59
+ baseUrl?: string
60
+ updatedAt: string
61
+ }
62
+
63
+ type EmbeddingSettings = {
64
+ openaiConfigured: boolean
65
+ autoIndexingEnabled: boolean
66
+ autoIndexingLocked: boolean
67
+ lockReason: string | null
68
+ embeddingConfig: EmbeddingProviderConfig | null
69
+ configuredProviders: EmbeddingProviderId[]
70
+ indexedDimension: number | null
71
+ reindexRequired: boolean
72
+ documentCount: number | null
73
+ }
74
+
75
+ type EmbeddingSettingsResponse = {
76
+ settings?: EmbeddingSettings
77
+ error?: string
78
+ }
79
+
80
+ // Full-text search config types
81
+ type FulltextEnvVarStatus = {
82
+ set: boolean
83
+ hint: string
84
+ }
85
+
86
+ type FulltextOptionalEnvVarStatus = {
87
+ set: boolean
88
+ value?: string | boolean
89
+ default?: string | boolean
90
+ hint: string
91
+ }
92
+
93
+ type FulltextConfigResponse = {
94
+ driver: 'meilisearch' | null
95
+ configured: boolean
96
+ envVars: {
97
+ MEILISEARCH_HOST: FulltextEnvVarStatus
98
+ MEILISEARCH_API_KEY: FulltextEnvVarStatus
99
+ }
100
+ optionalEnvVars: {
101
+ MEILISEARCH_INDEX_PREFIX: FulltextOptionalEnvVarStatus
102
+ SEARCH_EXCLUDE_ENCRYPTED_FIELDS: FulltextOptionalEnvVarStatus
103
+ }
104
+ }
105
+
106
+ // Vector store driver types
107
+ type VectorDriverId = 'pgvector' | 'qdrant' | 'chromadb'
108
+
109
+ type VectorDriverEnvVar = {
110
+ name: string
111
+ set: boolean
112
+ hint: string
113
+ }
114
+
115
+ type VectorDriverStatus = {
116
+ id: VectorDriverId
117
+ name: string
118
+ configured: boolean
119
+ implemented: boolean
120
+ envVars: VectorDriverEnvVar[]
121
+ }
122
+
123
+ type VectorStoreConfigResponse = {
124
+ currentDriver: VectorDriverId
125
+ configured: boolean
126
+ drivers: VectorDriverStatus[]
127
+ }
128
+
129
+ const normalizeErrorMessage = (error: unknown, fallback: string): string => {
130
+ if (typeof error === 'string' && error.trim().length) return error.trim()
131
+ if (error instanceof Error && error.message.trim().length) return error.message.trim()
132
+ return fallback
133
+ }
134
+
135
+ export function SearchSettingsPageClient() {
136
+ const t = useT()
137
+
138
+ // Main settings state
139
+ const [settings, setSettings] = React.useState<SearchSettings | null>(null)
140
+ const [loading, setLoading] = React.useState(true)
141
+ const [error, setError] = React.useState<string | null>(null)
142
+
143
+ // Embedding settings state
144
+ const [embeddingSettings, setEmbeddingSettings] = React.useState<EmbeddingSettings | null>(null)
145
+ const [embeddingLoading, setEmbeddingLoading] = React.useState(true)
146
+
147
+ // Global search settings state
148
+ const [globalSearchStrategies, setGlobalSearchStrategies] = React.useState<Set<string>>(() => new Set(['fulltext', 'vector', 'tokens']))
149
+ const [globalSearchInitial, setGlobalSearchInitial] = React.useState<Set<string>>(() => new Set(['fulltext', 'vector', 'tokens']))
150
+ const [globalSearchLoading, setGlobalSearchLoading] = React.useState(true)
151
+ const [globalSearchSaving, setGlobalSearchSaving] = React.useState(false)
152
+
153
+ // Full-text search config state
154
+ const [fulltextConfig, setFulltextConfig] = React.useState<FulltextConfigResponse | null>(null)
155
+ const [fulltextConfigLoading, setFulltextConfigLoading] = React.useState(true)
156
+
157
+ // Vector store config state
158
+ const [vectorStoreConfig, setVectorStoreConfig] = React.useState<VectorStoreConfigResponse | null>(null)
159
+ const [vectorStoreConfigLoading, setVectorStoreConfigLoading] = React.useState(true)
160
+
161
+ // Fetch main settings
162
+ const fetchSettings = React.useCallback(async () => {
163
+ setLoading(true)
164
+ setError(null)
165
+ try {
166
+ const body = await readApiResultOrThrow<SettingsResponse>(
167
+ '/api/search/settings',
168
+ undefined,
169
+ { errorMessage: t('search.settings.errorLabel', 'Failed to load settings'), allowNullResult: true },
170
+ )
171
+ if (body?.settings) {
172
+ setSettings(body.settings)
173
+ } else {
174
+ setSettings({
175
+ strategies: [],
176
+ fulltextConfigured: false,
177
+ fulltextStats: null,
178
+ vectorConfigured: false,
179
+ tokensEnabled: true,
180
+ defaultStrategies: [],
181
+ reindexLock: null,
182
+ fulltextReindexLock: null,
183
+ vectorReindexLock: null,
184
+ })
185
+ }
186
+ } catch (err) {
187
+ const message = normalizeErrorMessage(err, t('search.settings.errorLabel', 'Failed to load settings'))
188
+ setError(message)
189
+ flash(message, 'error')
190
+ } finally {
191
+ setLoading(false)
192
+ }
193
+ }, [t])
194
+
195
+ React.useEffect(() => {
196
+ fetchSettings()
197
+ }, [fetchSettings])
198
+
199
+ // Lightweight stats refresh for polling during reindex
200
+ const refreshStatsOnly = React.useCallback(async () => {
201
+ try {
202
+ const body = await readApiResultOrThrow<SettingsResponse>(
203
+ '/api/search/settings',
204
+ { cache: 'no-store' },
205
+ { errorMessage: '', allowNullResult: true },
206
+ )
207
+ if (body?.settings) {
208
+ setSettings(body.settings)
209
+ }
210
+ } catch {
211
+ // Silently ignore errors during polling
212
+ }
213
+ }, [])
214
+
215
+ // Lightweight embedding stats refresh
216
+ const refreshEmbeddingStatsOnly = React.useCallback(async () => {
217
+ try {
218
+ const body = await readApiResultOrThrow<EmbeddingSettingsResponse>(
219
+ '/api/search/embeddings',
220
+ { cache: 'no-store' },
221
+ { errorMessage: '', allowNullResult: true },
222
+ )
223
+ if (body?.settings) {
224
+ setEmbeddingSettings(body.settings)
225
+ }
226
+ } catch {
227
+ // Silently ignore errors during polling
228
+ }
229
+ }, [])
230
+
231
+ // Polling logic
232
+ const wasPollingRef = React.useRef(false)
233
+ const pollCountAfterClearRef = React.useRef(0)
234
+
235
+ React.useEffect(() => {
236
+ const hasFulltextLock = settings?.fulltextReindexLock !== null
237
+ const hasVectorLock = settings?.vectorReindexLock !== null
238
+
239
+ const shouldPoll = hasFulltextLock || hasVectorLock ||
240
+ (wasPollingRef.current && pollCountAfterClearRef.current < 3)
241
+
242
+ if (!shouldPoll) {
243
+ wasPollingRef.current = false
244
+ pollCountAfterClearRef.current = 0
245
+ return
246
+ }
247
+
248
+ if (hasFulltextLock || hasVectorLock) {
249
+ wasPollingRef.current = true
250
+ pollCountAfterClearRef.current = 0
251
+ }
252
+
253
+ const pollInterval = setInterval(() => {
254
+ if (!hasFulltextLock && !hasVectorLock) {
255
+ pollCountAfterClearRef.current += 1
256
+ }
257
+
258
+ refreshStatsOnly()
259
+ if (hasVectorLock) {
260
+ refreshEmbeddingStatsOnly()
261
+ }
262
+ }, 3000)
263
+
264
+ return () => clearInterval(pollInterval)
265
+ }, [settings?.fulltextReindexLock, settings?.vectorReindexLock, refreshStatsOnly, refreshEmbeddingStatsOnly])
266
+
267
+ // Fetch embedding settings
268
+ const fetchEmbeddingSettings = React.useCallback(async () => {
269
+ setEmbeddingLoading(true)
270
+ try {
271
+ const body = await readApiResultOrThrow<EmbeddingSettingsResponse>(
272
+ '/api/search/embeddings',
273
+ undefined,
274
+ { errorMessage: t('search.settings.errors.loadFailed', 'Failed to load settings'), allowNullResult: true },
275
+ )
276
+ if (body?.settings) {
277
+ setEmbeddingSettings(body.settings)
278
+ } else {
279
+ setEmbeddingSettings({
280
+ openaiConfigured: false,
281
+ autoIndexingEnabled: true,
282
+ autoIndexingLocked: false,
283
+ lockReason: null,
284
+ embeddingConfig: null,
285
+ configuredProviders: [],
286
+ indexedDimension: null,
287
+ reindexRequired: false,
288
+ documentCount: null,
289
+ })
290
+ }
291
+ } catch {
292
+ // Error already handled
293
+ } finally {
294
+ setEmbeddingLoading(false)
295
+ }
296
+ }, [t])
297
+
298
+ React.useEffect(() => {
299
+ fetchEmbeddingSettings()
300
+ }, [fetchEmbeddingSettings])
301
+
302
+ // Fetch global search settings
303
+ const fetchGlobalSearchSettings = React.useCallback(async () => {
304
+ setGlobalSearchLoading(true)
305
+ try {
306
+ const response = await fetch('/api/search/settings/global-search')
307
+ if (response.ok) {
308
+ const body = await response.json() as { enabledStrategies?: string[] }
309
+ if (body.enabledStrategies && Array.isArray(body.enabledStrategies) && body.enabledStrategies.length > 0) {
310
+ const strategies = new Set(body.enabledStrategies)
311
+ setGlobalSearchStrategies(strategies)
312
+ setGlobalSearchInitial(new Set(strategies))
313
+ }
314
+ }
315
+ } catch {
316
+ // Silently use defaults
317
+ } finally {
318
+ setGlobalSearchLoading(false)
319
+ }
320
+ }, [])
321
+
322
+ React.useEffect(() => {
323
+ fetchGlobalSearchSettings()
324
+ }, [fetchGlobalSearchSettings])
325
+
326
+ // Fetch fulltext config
327
+ const fetchFulltextConfig = React.useCallback(async () => {
328
+ setFulltextConfigLoading(true)
329
+ try {
330
+ const response = await fetch('/api/search/settings/fulltext')
331
+ if (response.ok) {
332
+ const body = await response.json() as FulltextConfigResponse
333
+ setFulltextConfig(body)
334
+ }
335
+ } catch {
336
+ // Silently use null
337
+ } finally {
338
+ setFulltextConfigLoading(false)
339
+ }
340
+ }, [])
341
+
342
+ React.useEffect(() => {
343
+ fetchFulltextConfig()
344
+ }, [fetchFulltextConfig])
345
+
346
+ // Fetch vector store config
347
+ const fetchVectorStoreConfig = React.useCallback(async () => {
348
+ setVectorStoreConfigLoading(true)
349
+ try {
350
+ const response = await fetch('/api/search/settings/vector-store')
351
+ if (response.ok) {
352
+ const body = await response.json() as VectorStoreConfigResponse
353
+ setVectorStoreConfig(body)
354
+ }
355
+ } catch {
356
+ // Silently use null
357
+ } finally {
358
+ setVectorStoreConfigLoading(false)
359
+ }
360
+ }, [])
361
+
362
+ React.useEffect(() => {
363
+ fetchVectorStoreConfig()
364
+ }, [fetchVectorStoreConfig])
365
+
366
+ // Global search settings handlers - auto-save on toggle
367
+ const toggleGlobalSearchStrategy = React.useCallback(async (strategyId: string) => {
368
+ const newStrategies = new Set(globalSearchStrategies)
369
+ if (newStrategies.has(strategyId)) {
370
+ if (newStrategies.size > 1) {
371
+ newStrategies.delete(strategyId)
372
+ } else {
373
+ return
374
+ }
375
+ } else {
376
+ newStrategies.add(strategyId)
377
+ }
378
+
379
+ setGlobalSearchStrategies(newStrategies)
380
+ setGlobalSearchSaving(true)
381
+
382
+ try {
383
+ const response = await fetch('/api/search/settings/global-search', {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ enabledStrategies: Array.from(newStrategies) }),
387
+ })
388
+
389
+ if (!response.ok) {
390
+ const body = await response.json().catch(() => ({})) as { error?: string }
391
+ throw new Error(body.error || t('search.settings.globalSearch.saveError', 'Failed to save settings'))
392
+ }
393
+
394
+ setGlobalSearchInitial(new Set(newStrategies))
395
+ } catch (err) {
396
+ setGlobalSearchStrategies(globalSearchInitial)
397
+ flash(normalizeErrorMessage(err, t('search.settings.globalSearch.saveError', 'Failed to save settings')), 'error')
398
+ } finally {
399
+ setGlobalSearchSaving(false)
400
+ }
401
+ }, [globalSearchStrategies, globalSearchInitial, t])
402
+
403
+ // Callbacks for section components
404
+ const handleFulltextStatsUpdate = React.useCallback((stats: FulltextStats | null) => {
405
+ setSettings(prev => prev ? { ...prev, fulltextStats: stats } : prev)
406
+ }, [])
407
+
408
+ const handleEmbeddingSettingsUpdate = React.useCallback((newSettings: EmbeddingSettings) => {
409
+ setEmbeddingSettings(newSettings)
410
+ }, [])
411
+
412
+ return (
413
+ <div className="flex flex-col gap-6">
414
+ {/* Header */}
415
+ <div className="space-y-1">
416
+ <h1 className="text-2xl font-bold">{t('search.settings.pageTitle', 'Search Settings')}</h1>
417
+ <p className="text-muted-foreground">{t('search.settings.pageDescription', 'Configure search strategies and view their availability.')}</p>
418
+ </div>
419
+
420
+ {/* Section 1: Global Search Settings */}
421
+ <GlobalSearchSection
422
+ loading={globalSearchLoading}
423
+ saving={globalSearchSaving}
424
+ strategies={globalSearchStrategies}
425
+ fulltextConfigured={settings?.fulltextConfigured ?? false}
426
+ vectorConfigured={settings?.vectorConfigured ?? false}
427
+ onToggleStrategy={toggleGlobalSearchStrategy}
428
+ />
429
+
430
+ {/* Section 2: Full-Text Search (with tabs) */}
431
+ <FulltextSearchSection
432
+ fulltextConfig={fulltextConfig}
433
+ fulltextConfigLoading={fulltextConfigLoading}
434
+ fulltextStats={settings?.fulltextStats ?? null}
435
+ fulltextReindexLock={settings?.fulltextReindexLock ?? null}
436
+ loading={loading}
437
+ onStatsUpdate={handleFulltextStatsUpdate}
438
+ onRefresh={fetchSettings}
439
+ />
440
+
441
+ {/* Section 3: Vector Search (with tabs) */}
442
+ <VectorSearchSection
443
+ embeddingSettings={embeddingSettings}
444
+ embeddingLoading={embeddingLoading}
445
+ vectorStoreConfig={vectorStoreConfig}
446
+ vectorStoreConfigLoading={vectorStoreConfigLoading}
447
+ vectorReindexLock={settings?.vectorReindexLock ?? null}
448
+ onEmbeddingSettingsUpdate={handleEmbeddingSettingsUpdate}
449
+ onRefreshEmbeddings={fetchEmbeddingSettings}
450
+ />
451
+
452
+ {/* Refresh Button */}
453
+ <div className="flex items-center gap-3">
454
+ <Button
455
+ type="button"
456
+ variant="outline"
457
+ size="sm"
458
+ onClick={() => fetchSettings()}
459
+ disabled={loading}
460
+ >
461
+ {loading ? (
462
+ <>
463
+ <Spinner size="sm" className="mr-2" />
464
+ {t('search.settings.loadingLabel', 'Loading settings...')}
465
+ </>
466
+ ) : (
467
+ t('search.settings.refreshLabel', 'Refresh')
468
+ )}
469
+ </Button>
470
+ {error && <span className="text-sm text-destructive">{error}</span>}
471
+ </div>
472
+ </div>
473
+ )
474
+ }
475
+
476
+ export default SearchSettingsPageClient