@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,943 @@
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 { Label } from '@open-mercato/ui/primitives/label'
9
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
10
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@open-mercato/ui/primitives/tabs'
11
+
12
+ // Types
13
+ type EmbeddingProviderId = 'openai' | 'google' | 'mistral' | 'cohere' | 'bedrock' | 'ollama'
14
+
15
+ type EmbeddingProviderConfig = {
16
+ providerId: EmbeddingProviderId
17
+ model: string
18
+ dimension: number
19
+ outputDimensionality?: number
20
+ baseUrl?: string
21
+ updatedAt: string
22
+ }
23
+
24
+ type EmbeddingModelInfo = {
25
+ id: string
26
+ name: string
27
+ dimension: number
28
+ configurableDimension?: boolean
29
+ minDimension?: number
30
+ maxDimension?: number
31
+ }
32
+
33
+ type EmbeddingProviderInfo = {
34
+ name: string
35
+ envKeyRequired: string
36
+ defaultModel: string
37
+ models: EmbeddingModelInfo[]
38
+ }
39
+
40
+ type EmbeddingSettings = {
41
+ openaiConfigured: boolean
42
+ autoIndexingEnabled: boolean
43
+ autoIndexingLocked: boolean
44
+ lockReason: string | null
45
+ embeddingConfig: EmbeddingProviderConfig | null
46
+ configuredProviders: EmbeddingProviderId[]
47
+ indexedDimension: number | null
48
+ reindexRequired: boolean
49
+ documentCount: number | null
50
+ }
51
+
52
+ type EmbeddingSettingsResponse = {
53
+ settings?: EmbeddingSettings
54
+ error?: string
55
+ }
56
+
57
+ type VectorDriverId = 'pgvector' | 'qdrant' | 'chromadb'
58
+
59
+ type VectorDriverEnvVar = {
60
+ name: string
61
+ set: boolean
62
+ hint: string
63
+ }
64
+
65
+ type VectorDriverStatus = {
66
+ id: VectorDriverId
67
+ name: string
68
+ configured: boolean
69
+ implemented: boolean
70
+ envVars: VectorDriverEnvVar[]
71
+ }
72
+
73
+ type VectorStoreConfigResponse = {
74
+ currentDriver: VectorDriverId
75
+ configured: boolean
76
+ drivers: VectorDriverStatus[]
77
+ }
78
+
79
+ type ReindexLock = {
80
+ type: 'fulltext' | 'vector'
81
+ action: string
82
+ startedAt: string
83
+ elapsedMinutes: number
84
+ }
85
+
86
+ type ActivityLog = {
87
+ id: string
88
+ source: string
89
+ handler: string
90
+ level: 'info' | 'error' | 'warn'
91
+ entityType: string | null
92
+ recordId: string | null
93
+ message: string
94
+ details: unknown
95
+ occurredAt: string
96
+ }
97
+
98
+ const EMBEDDING_PROVIDERS: Record<EmbeddingProviderId, EmbeddingProviderInfo> = {
99
+ openai: {
100
+ name: 'OpenAI',
101
+ envKeyRequired: 'OPENAI_API_KEY',
102
+ defaultModel: 'text-embedding-3-small',
103
+ models: [
104
+ { id: 'text-embedding-3-small', name: 'text-embedding-3-small', dimension: 1536 },
105
+ { id: 'text-embedding-3-large', name: 'text-embedding-3-large', dimension: 3072, configurableDimension: true, minDimension: 256, maxDimension: 3072 },
106
+ { id: 'text-embedding-ada-002', name: 'text-embedding-ada-002', dimension: 1536 },
107
+ ],
108
+ },
109
+ google: {
110
+ name: 'Google Generative AI',
111
+ envKeyRequired: 'GOOGLE_GENERATIVE_AI_API_KEY',
112
+ defaultModel: 'text-embedding-004',
113
+ models: [
114
+ { id: 'text-embedding-004', name: 'text-embedding-004', dimension: 768, configurableDimension: true, minDimension: 1, maxDimension: 768 },
115
+ { id: 'embedding-001', name: 'embedding-001', dimension: 768 },
116
+ ],
117
+ },
118
+ mistral: {
119
+ name: 'Mistral',
120
+ envKeyRequired: 'MISTRAL_API_KEY',
121
+ defaultModel: 'mistral-embed',
122
+ models: [
123
+ { id: 'mistral-embed', name: 'mistral-embed', dimension: 1024 },
124
+ ],
125
+ },
126
+ cohere: {
127
+ name: 'Cohere',
128
+ envKeyRequired: 'COHERE_API_KEY',
129
+ defaultModel: 'embed-english-v3.0',
130
+ models: [
131
+ { id: 'embed-english-v3.0', name: 'embed-english-v3.0', dimension: 1024 },
132
+ { id: 'embed-multilingual-v3.0', name: 'embed-multilingual-v3.0', dimension: 1024 },
133
+ { id: 'embed-english-light-v3.0', name: 'embed-english-light-v3.0', dimension: 384 },
134
+ { id: 'embed-multilingual-light-v3.0', name: 'embed-multilingual-light-v3.0', dimension: 384 },
135
+ ],
136
+ },
137
+ bedrock: {
138
+ name: 'Amazon Bedrock',
139
+ envKeyRequired: 'AWS_ACCESS_KEY_ID',
140
+ defaultModel: 'amazon.titan-embed-text-v2:0',
141
+ models: [
142
+ { id: 'amazon.titan-embed-text-v2:0', name: 'Titan Embed Text v2', dimension: 1024, configurableDimension: true, minDimension: 256, maxDimension: 1024 },
143
+ { id: 'amazon.titan-embed-text-v1', name: 'Titan Embed Text v1', dimension: 1536 },
144
+ { id: 'cohere.embed-english-v3', name: 'Cohere Embed English v3', dimension: 1024 },
145
+ { id: 'cohere.embed-multilingual-v3', name: 'Cohere Embed Multilingual v3', dimension: 1024 },
146
+ ],
147
+ },
148
+ ollama: {
149
+ name: 'Ollama (Local)',
150
+ envKeyRequired: 'OLLAMA_BASE_URL',
151
+ defaultModel: 'nomic-embed-text',
152
+ models: [
153
+ { id: 'nomic-embed-text', name: 'nomic-embed-text', dimension: 768 },
154
+ { id: 'mxbai-embed-large', name: 'mxbai-embed-large', dimension: 1024 },
155
+ { id: 'all-minilm', name: 'all-minilm', dimension: 384 },
156
+ { id: 'snowflake-arctic-embed', name: 'snowflake-arctic-embed', dimension: 1024 },
157
+ ],
158
+ },
159
+ }
160
+
161
+ export type VectorSearchSectionProps = {
162
+ embeddingSettings: EmbeddingSettings | null
163
+ embeddingLoading: boolean
164
+ vectorStoreConfig: VectorStoreConfigResponse | null
165
+ vectorStoreConfigLoading: boolean
166
+ vectorReindexLock: ReindexLock | null
167
+ onEmbeddingSettingsUpdate: (settings: EmbeddingSettings) => void
168
+ onRefreshEmbeddings: () => Promise<void>
169
+ }
170
+
171
+ export function VectorSearchSection({
172
+ embeddingSettings,
173
+ embeddingLoading,
174
+ vectorStoreConfig,
175
+ vectorStoreConfigLoading,
176
+ vectorReindexLock,
177
+ onEmbeddingSettingsUpdate,
178
+ onRefreshEmbeddings,
179
+ }: VectorSearchSectionProps) {
180
+ const t = useT()
181
+ const [embeddingSaving, setEmbeddingSaving] = React.useState(false)
182
+ const autoIndexingPreviousRef = React.useRef<boolean>(true)
183
+
184
+ // Staged embedding selection
185
+ const [selectedProvider, setSelectedProvider] = React.useState<EmbeddingProviderId | null>(null)
186
+ const [selectedModel, setSelectedModel] = React.useState<string | null>(null)
187
+ const [customModelName, setCustomModelName] = React.useState<string>('')
188
+ const [customDimension, setCustomDimension] = React.useState<number>(768)
189
+
190
+ const [pendingEmbeddingConfig, setPendingEmbeddingConfig] = React.useState<EmbeddingProviderConfig | null>(null)
191
+ const [showEmbeddingConfirmDialog, setShowEmbeddingConfirmDialog] = React.useState(false)
192
+
193
+ // Vector reindex state
194
+ const [vectorReindexing, setVectorReindexing] = React.useState(false)
195
+ const [showVectorReindexDialog, setShowVectorReindexDialog] = React.useState(false)
196
+
197
+ // Activity logs state
198
+ const [activityLogs, setActivityLogs] = React.useState<ActivityLog[]>([])
199
+ const [activityLoading, setActivityLoading] = React.useState(true)
200
+
201
+ // Fetch activity logs
202
+ const fetchActivityLogs = React.useCallback(async () => {
203
+ setActivityLoading(true)
204
+ try {
205
+ const response = await fetch('/api/query_index/status')
206
+ if (response.ok) {
207
+ const body = await response.json() as { logs?: ActivityLog[]; errors?: ActivityLog[] }
208
+ const allLogs: ActivityLog[] = []
209
+ if (body.logs) {
210
+ allLogs.push(...body.logs)
211
+ }
212
+ if (body.errors) {
213
+ allLogs.push(...body.errors.map(err => ({ ...err, level: 'error' as const })))
214
+ }
215
+ // Filter for vector-related logs
216
+ const vectorLogs = allLogs.filter(log => {
217
+ const lowerSource = log.source?.toLowerCase() ?? ''
218
+ const lowerMessage = log.message?.toLowerCase() ?? ''
219
+ const lowerHandler = log.handler?.toLowerCase() ?? ''
220
+ return lowerSource.includes('vector') || lowerMessage.includes('vector') ||
221
+ lowerMessage.includes('embedding') || lowerHandler.includes('vector')
222
+ })
223
+ vectorLogs.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())
224
+ setActivityLogs(vectorLogs.slice(0, 50))
225
+ }
226
+ } catch {
227
+ // Silently fail
228
+ } finally {
229
+ setActivityLoading(false)
230
+ }
231
+ }, [])
232
+
233
+ React.useEffect(() => {
234
+ fetchActivityLogs()
235
+ }, [fetchActivityLogs])
236
+
237
+ // Poll for activity when reindexing
238
+ React.useEffect(() => {
239
+ if (vectorReindexLock || vectorReindexing) {
240
+ const interval = setInterval(fetchActivityLogs, 5000)
241
+ return () => clearInterval(interval)
242
+ }
243
+ }, [vectorReindexLock, vectorReindexing, fetchActivityLogs])
244
+
245
+ // Update auto-indexing
246
+ const updateAutoIndexing = React.useCallback(async (nextValue: boolean) => {
247
+ autoIndexingPreviousRef.current = embeddingSettings?.autoIndexingEnabled ?? true
248
+ if (embeddingSettings) {
249
+ onEmbeddingSettingsUpdate({ ...embeddingSettings, autoIndexingEnabled: nextValue })
250
+ }
251
+ setEmbeddingSaving(true)
252
+ try {
253
+ const body = await readApiResultOrThrow<EmbeddingSettingsResponse>(
254
+ '/api/search/embeddings',
255
+ {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ autoIndexingEnabled: nextValue }),
259
+ },
260
+ { errorMessage: t('search.settings.errors.saveFailed', 'Failed to save settings'), allowNullResult: true },
261
+ )
262
+ if (body?.settings) {
263
+ onEmbeddingSettingsUpdate(body.settings)
264
+ autoIndexingPreviousRef.current = body.settings.autoIndexingEnabled
265
+ }
266
+ flash(t('search.settings.messages.saved', 'Settings saved'), 'success')
267
+ } catch {
268
+ if (embeddingSettings) {
269
+ onEmbeddingSettingsUpdate({ ...embeddingSettings, autoIndexingEnabled: autoIndexingPreviousRef.current })
270
+ }
271
+ } finally {
272
+ setEmbeddingSaving(false)
273
+ }
274
+ }, [embeddingSettings, onEmbeddingSettingsUpdate, t])
275
+
276
+ // Provider handlers
277
+ const handleProviderChange = (providerId: EmbeddingProviderId) => {
278
+ setSelectedProvider(providerId)
279
+ setSelectedModel(null)
280
+ setCustomModelName('')
281
+ setCustomDimension(768)
282
+ }
283
+
284
+ const handleModelChange = (modelId: string) => {
285
+ setSelectedModel(modelId)
286
+ }
287
+
288
+ const handleApplyEmbeddingChanges = () => {
289
+ const newProviderId = selectedProvider ?? embeddingSettings?.embeddingConfig?.providerId ?? 'openai'
290
+ const newProviderInfo = EMBEDDING_PROVIDERS[newProviderId]
291
+ const newModelId = selectedModel ?? (selectedProvider ? newProviderInfo.defaultModel : embeddingSettings?.embeddingConfig?.model ?? newProviderInfo.defaultModel)
292
+
293
+ let modelName: string
294
+ let dimension: number
295
+
296
+ if (newModelId === 'custom') {
297
+ modelName = customModelName.trim()
298
+ dimension = customDimension
299
+ if (!modelName) {
300
+ flash(t('search.settings.errors.modelRequired', 'Please enter a model name'), 'error')
301
+ return
302
+ }
303
+ if (dimension <= 0) {
304
+ flash(t('search.settings.errors.dimensionRequired', 'Please enter a valid dimension'), 'error')
305
+ return
306
+ }
307
+ } else {
308
+ const newModel = newProviderInfo.models.find((m) => m.id === newModelId) ?? newProviderInfo.models[0]
309
+ modelName = newModel.id
310
+ dimension = newModel.dimension
311
+ }
312
+
313
+ const newConfig: EmbeddingProviderConfig = {
314
+ providerId: newProviderId,
315
+ model: modelName,
316
+ dimension,
317
+ updatedAt: new Date().toISOString(),
318
+ }
319
+
320
+ if (embeddingSettings?.indexedDimension || embeddingSettings?.embeddingConfig) {
321
+ setPendingEmbeddingConfig(newConfig)
322
+ setShowEmbeddingConfirmDialog(true)
323
+ } else {
324
+ applyEmbeddingConfig(newConfig)
325
+ }
326
+ }
327
+
328
+ const handleCancelEmbeddingSelection = () => {
329
+ setSelectedProvider(null)
330
+ setSelectedModel(null)
331
+ setCustomModelName('')
332
+ setCustomDimension(768)
333
+ }
334
+
335
+ const applyEmbeddingConfig = async (config: EmbeddingProviderConfig) => {
336
+ setEmbeddingSaving(true)
337
+ setShowEmbeddingConfirmDialog(false)
338
+ setPendingEmbeddingConfig(null)
339
+
340
+ try {
341
+ await readApiResultOrThrow<EmbeddingSettingsResponse>(
342
+ '/api/search/embeddings',
343
+ {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify({ embeddingConfig: config }),
347
+ },
348
+ { errorMessage: t('search.settings.errors.saveFailed', 'Failed to save settings'), allowNullResult: true },
349
+ )
350
+ setSelectedProvider(null)
351
+ setSelectedModel(null)
352
+ flash(t('search.settings.messages.providerSaved', 'Embedding provider saved'), 'success')
353
+ await onRefreshEmbeddings()
354
+ } catch {
355
+ // Error handled by readApiResultOrThrow
356
+ } finally {
357
+ setEmbeddingSaving(false)
358
+ }
359
+ }
360
+
361
+ const handleEmbeddingConfirmChange = () => {
362
+ if (pendingEmbeddingConfig) {
363
+ applyEmbeddingConfig(pendingEmbeddingConfig)
364
+ }
365
+ }
366
+
367
+ const handleEmbeddingCancelChange = () => {
368
+ setShowEmbeddingConfirmDialog(false)
369
+ setPendingEmbeddingConfig(null)
370
+ }
371
+
372
+ // Vector reindex handlers
373
+ const handleVectorReindexClick = () => {
374
+ setShowVectorReindexDialog(true)
375
+ }
376
+
377
+ const handleVectorReindexConfirm = async () => {
378
+ setShowVectorReindexDialog(false)
379
+ setVectorReindexing(true)
380
+ try {
381
+ await readApiResultOrThrow<{ ok: boolean }>(
382
+ '/api/search/embeddings/reindex',
383
+ {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ purgeFirst: true }),
387
+ },
388
+ { errorMessage: t('search.settings.errors.reindexFailed', 'Reindex failed'), allowNullResult: true },
389
+ )
390
+ flash(t('search.settings.messages.reindexStarted', 'Reindex started'), 'success')
391
+ await fetchActivityLogs()
392
+ } catch {
393
+ // Error handled by readApiResultOrThrow
394
+ } finally {
395
+ setVectorReindexing(false)
396
+ }
397
+ }
398
+
399
+ const handleVectorReindexCancel = () => {
400
+ setShowVectorReindexDialog(false)
401
+ }
402
+
403
+ // Computed values
404
+ const savedProvider = embeddingSettings?.embeddingConfig?.providerId ?? 'openai'
405
+ const savedProviderInfo = EMBEDDING_PROVIDERS[savedProvider]
406
+ const savedModel = embeddingSettings?.embeddingConfig?.model ?? savedProviderInfo.defaultModel
407
+ const savedDimension = embeddingSettings?.embeddingConfig?.dimension ?? savedProviderInfo.models[0]?.dimension ?? 768
408
+
409
+ const savedModelIsPredefined = savedProviderInfo.models.some((m) => m.id === savedModel)
410
+ const savedCustomModel = !savedModelIsPredefined && savedModel ? { id: savedModel, name: savedModel, dimension: savedDimension } : null
411
+
412
+ const displayProvider = selectedProvider ?? savedProvider
413
+ const displayProviderInfo = EMBEDDING_PROVIDERS[displayProvider]
414
+ const displayModel = selectedModel ?? (selectedProvider ? displayProviderInfo.defaultModel : savedModel)
415
+ const isCustomModel = displayModel === 'custom'
416
+
417
+ const displayModelIsSavedCustom = !isCustomModel && displayProvider === savedProvider && savedCustomModel && displayModel === savedCustomModel.id
418
+
419
+ const displayModelInfo = isCustomModel
420
+ ? null
421
+ : displayModelIsSavedCustom
422
+ ? savedCustomModel
423
+ : displayProviderInfo.models.find((m) => m.id === displayModel) ?? displayProviderInfo.models[0]
424
+ const displayDimension = isCustomModel ? customDimension : (displayModelInfo?.dimension ?? 768)
425
+
426
+ const hasUnsavedEmbeddingChanges = (selectedProvider !== null && selectedProvider !== savedProvider) ||
427
+ (selectedModel !== null && selectedModel !== savedModel) ||
428
+ (selectedProvider !== null && selectedModel === null && displayProviderInfo.defaultModel !== savedModel) ||
429
+ (isCustomModel && (customModelName.trim() !== '' || customDimension !== 768))
430
+
431
+ const isEmbeddingConfigured = embeddingSettings?.configuredProviders?.includes(savedProvider)
432
+ const providerOptions: EmbeddingProviderId[] = ['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama']
433
+
434
+ const autoIndexingChecked = embeddingSettings ? embeddingSettings.autoIndexingEnabled : true
435
+ const autoIndexingDisabled = embeddingLoading || embeddingSaving || Boolean(embeddingSettings?.autoIndexingLocked)
436
+
437
+ return (
438
+ <div className="rounded-lg border border-border bg-card p-5 shadow-sm">
439
+ <h2 className="text-lg font-semibold mb-2">
440
+ {t('search.settings.vector.sectionTitle', 'Vector Search')}
441
+ </h2>
442
+ <p className="text-sm text-muted-foreground mb-4">
443
+ {t('search.settings.vector.sectionDescription', 'AI-powered semantic search using embeddings.')}
444
+ </p>
445
+
446
+ <Tabs defaultValue="configuration">
447
+ <TabsList className="mb-4">
448
+ <TabsTrigger value="configuration">
449
+ {t('search.settings.tabs.configuration', 'Configuration')}
450
+ </TabsTrigger>
451
+ <TabsTrigger value="index">
452
+ {t('search.settings.tabs.indexManagement', 'Index Management')}
453
+ </TabsTrigger>
454
+ <TabsTrigger value="activity">
455
+ {t('search.settings.tabs.activity', 'Activity')}
456
+ </TabsTrigger>
457
+ </TabsList>
458
+
459
+ {/* Configuration Tab */}
460
+ <TabsContent value="configuration">
461
+ {(embeddingLoading || vectorStoreConfigLoading) ? (
462
+ <div className="flex items-center gap-2 text-muted-foreground">
463
+ <Spinner size="sm" />
464
+ <span>{t('search.settings.loadingLabel', 'Loading settings...')}</span>
465
+ </div>
466
+ ) : (
467
+ <div className="space-y-4">
468
+ {/* Vector Store Driver Status */}
469
+ <div>
470
+ <h3 className="text-sm font-semibold mb-2">{t('search.settings.vector.store', 'Vector Store')}</h3>
471
+ <div className="grid gap-2 sm:grid-cols-3">
472
+ {vectorStoreConfig?.drivers.map((driver) => {
473
+ const isCurrent = driver.id === vectorStoreConfig.currentDriver
474
+ const isReady = driver.configured && driver.implemented
475
+ return (
476
+ <div
477
+ key={driver.id}
478
+ className={`flex items-start gap-3 p-3 rounded-md border ${
479
+ isCurrent && isReady
480
+ ? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20'
481
+ : !driver.implemented
482
+ ? 'border-border bg-muted/20 opacity-60'
483
+ : 'border-border bg-muted/30'
484
+ }`}
485
+ >
486
+ <div className={`flex h-8 w-8 items-center justify-center rounded-full flex-shrink-0 ${
487
+ isCurrent && isReady
488
+ ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400'
489
+ : 'bg-muted text-muted-foreground'
490
+ }`}>
491
+ <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
492
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
493
+ </svg>
494
+ </div>
495
+ <div className="flex-1 min-w-0">
496
+ <div className="flex items-center gap-2">
497
+ <p className={`text-sm font-medium ${isCurrent && isReady ? 'text-emerald-700 dark:text-emerald-300' : ''}`}>
498
+ {driver.name}
499
+ </p>
500
+ {isCurrent && (
501
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
502
+ {t('search.settings.vector.active', 'Active')}
503
+ </span>
504
+ )}
505
+ {!driver.implemented && (
506
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
507
+ {t('search.settings.vector.comingSoon', 'Coming soon')}
508
+ </span>
509
+ )}
510
+ </div>
511
+ <div className="mt-1 space-y-0.5">
512
+ {driver.envVars.map((envVar) => (
513
+ <div key={envVar.name} className="flex items-center gap-1.5">
514
+ <div className={`h-1.5 w-1.5 rounded-full ${envVar.set ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`} />
515
+ <code className="text-[10px] text-muted-foreground font-mono">{envVar.name}</code>
516
+ </div>
517
+ ))}
518
+ </div>
519
+ </div>
520
+ </div>
521
+ )
522
+ })}
523
+ </div>
524
+ </div>
525
+
526
+ {/* Embedding Provider Selection */}
527
+ <div>
528
+ <h3 className="text-sm font-semibold mb-2">{t('search.settings.vector.providers', 'Embedding Provider')}</h3>
529
+ <p className="text-xs text-muted-foreground mb-3">{t('search.settings.vector.providersHint', 'Select a provider to generate embeddings. Only providers with configured API keys can be selected.')}</p>
530
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 items-start">
531
+ {providerOptions.map((providerId) => {
532
+ const info = EMBEDDING_PROVIDERS[providerId]
533
+ const isConfigured = embeddingSettings?.configuredProviders?.includes(providerId)
534
+ const isSelected = displayProvider === providerId
535
+ const isCurrentlySaved = savedProvider === providerId
536
+ return (
537
+ <button
538
+ key={providerId}
539
+ type="button"
540
+ onClick={() => isConfigured && handleProviderChange(providerId)}
541
+ disabled={!isConfigured || embeddingLoading || embeddingSaving}
542
+ className={`text-left p-3 rounded-lg border-2 transition-all ${
543
+ isSelected
544
+ ? 'border-primary bg-primary/5 ring-1 ring-primary/20'
545
+ : isConfigured
546
+ ? 'border-border hover:border-primary/50 hover:bg-muted/50 cursor-pointer'
547
+ : 'border-border bg-muted/20 opacity-50 cursor-not-allowed'
548
+ }`}
549
+ >
550
+ <div className="flex items-start justify-between gap-2">
551
+ <div className="flex-1 min-w-0">
552
+ <div className="flex items-center gap-2">
553
+ <p className={`text-sm font-medium ${isSelected ? 'text-primary' : isConfigured ? '' : 'text-muted-foreground'}`}>
554
+ {info.name}
555
+ </p>
556
+ {isCurrentlySaved && isConfigured && (
557
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
558
+ {t('search.settings.vector.active', 'Active')}
559
+ </span>
560
+ )}
561
+ </div>
562
+ {isConfigured ? (
563
+ <p className="text-xs text-muted-foreground mt-1">
564
+ {info.models.length} {t('search.settings.vector.modelsAvailable', 'models available')}
565
+ </p>
566
+ ) : (
567
+ <p className="text-xs text-muted-foreground mt-1">
568
+ {t('search.settings.vector.setEnvVar', 'Set')} <code className="font-mono text-[10px] bg-muted px-1 rounded">{info.envKeyRequired}</code>
569
+ </p>
570
+ )}
571
+ </div>
572
+ <div className={`flex h-5 w-5 items-center justify-center rounded-full flex-shrink-0 ${
573
+ isSelected
574
+ ? 'bg-primary text-primary-foreground'
575
+ : isConfigured
576
+ ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400'
577
+ : 'bg-muted text-muted-foreground'
578
+ }`}>
579
+ {isSelected ? (
580
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
581
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
582
+ </svg>
583
+ ) : isConfigured ? (
584
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
585
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
586
+ </svg>
587
+ ) : (
588
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
589
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
590
+ </svg>
591
+ )}
592
+ </div>
593
+ </div>
594
+
595
+ {/* Model Selection */}
596
+ {isSelected && isConfigured && (
597
+ <div className="mt-3 pt-3 border-t border-border space-y-2" onClick={(e) => e.stopPropagation()}>
598
+ <div className="space-y-1">
599
+ <Label htmlFor={`model-${providerId}`} className="text-xs font-medium">
600
+ {t('search.settings.model.label', 'Model')}
601
+ </Label>
602
+ <select
603
+ id={`model-${providerId}`}
604
+ className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
605
+ value={displayModel}
606
+ onChange={(e) => handleModelChange(e.target.value)}
607
+ disabled={embeddingLoading || embeddingSaving}
608
+ >
609
+ {savedCustomModel && displayProvider === savedProvider && (
610
+ <option key={savedCustomModel.id} value={savedCustomModel.id}>
611
+ {savedCustomModel.name} ({savedCustomModel.dimension}d)
612
+ </option>
613
+ )}
614
+ {displayProviderInfo.models.map((model) => (
615
+ <option key={model.id} value={model.id}>
616
+ {model.name} ({model.dimension}d)
617
+ </option>
618
+ ))}
619
+ <option value="custom">{t('search.settings.model.custom', 'Custom...')}</option>
620
+ </select>
621
+ </div>
622
+
623
+ {isCustomModel && (
624
+ <div className="space-y-2 p-2 rounded border border-input bg-muted/30">
625
+ <input
626
+ type="text"
627
+ className="w-full rounded border border-input bg-background px-2 py-1 text-sm"
628
+ value={customModelName}
629
+ onChange={(e) => setCustomModelName(e.target.value)}
630
+ placeholder={t('search.settings.model.namePlaceholder', 'Model name')}
631
+ disabled={embeddingLoading || embeddingSaving}
632
+ />
633
+ <input
634
+ type="number"
635
+ className="w-full rounded border border-input bg-background px-2 py-1 text-sm"
636
+ value={customDimension}
637
+ onChange={(e) => setCustomDimension(Number(e.target.value) || 768)}
638
+ placeholder="768"
639
+ min={1}
640
+ disabled={embeddingLoading || embeddingSaving}
641
+ />
642
+ </div>
643
+ )}
644
+
645
+ <div className="flex items-center justify-between text-xs">
646
+ <span className="text-muted-foreground">
647
+ {t('search.settings.dimension.label', 'Dimensions')}: {displayDimension}
648
+ </span>
649
+ {embeddingSettings?.indexedDimension && embeddingSettings.indexedDimension !== displayDimension && (
650
+ <span className="text-amber-600 dark:text-amber-400">
651
+ {t('search.settings.dimension.mismatch', 'mismatch')}: {embeddingSettings.indexedDimension}
652
+ </span>
653
+ )}
654
+ </div>
655
+
656
+ {hasUnsavedEmbeddingChanges && (
657
+ <div className="flex gap-2 pt-1">
658
+ <Button type="button" variant="default" size="sm" className="flex-1" onClick={handleApplyEmbeddingChanges} disabled={embeddingLoading || embeddingSaving}>
659
+ {embeddingSaving ? <Spinner size="sm" className="mr-1" /> : null}
660
+ {t('search.settings.actions.apply', 'Apply')}
661
+ </Button>
662
+ <Button type="button" variant="outline" size="sm" onClick={handleCancelEmbeddingSelection} disabled={embeddingLoading || embeddingSaving}>
663
+ {t('search.settings.actions.cancel', 'Cancel')}
664
+ </Button>
665
+ </div>
666
+ )}
667
+ </div>
668
+ )}
669
+ </button>
670
+ )
671
+ })}
672
+ </div>
673
+ </div>
674
+
675
+ {/* Setup Instructions */}
676
+ <div className="p-3 rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
677
+ <div className="flex items-start gap-2">
678
+ <svg className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
679
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
680
+ </svg>
681
+ <div className="text-sm text-blue-800 dark:text-blue-200">
682
+ <p className="font-medium mb-1">{t('search.settings.vector.howTo', 'How to set up')}</p>
683
+ <p className="text-xs">{t('search.settings.vector.howToDescription', 'Add the API key for your preferred provider to your .env file. Only providers with configured API keys can be selected.')}</p>
684
+ </div>
685
+ </div>
686
+ </div>
687
+ </div>
688
+ )}
689
+ </TabsContent>
690
+
691
+ {/* Index Management Tab */}
692
+ <TabsContent value="index">
693
+ {embeddingLoading ? (
694
+ <div className="flex items-center gap-2 text-muted-foreground">
695
+ <Spinner size="sm" />
696
+ <span>{t('search.settings.loadingLabel', 'Loading settings...')}</span>
697
+ </div>
698
+ ) : !isEmbeddingConfigured ? (
699
+ <div className="p-4 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
700
+ <div className="flex items-start gap-3">
701
+ <svg className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
702
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
703
+ </svg>
704
+ <div>
705
+ <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
706
+ {t('search.settings.vectorNotConfigured', 'No embedding provider configured')}
707
+ </p>
708
+ <p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
709
+ {t('search.settings.vectorNotConfiguredHint', 'Configure an embedding provider in the Configuration tab to enable indexing.')}
710
+ </p>
711
+ </div>
712
+ </div>
713
+ </div>
714
+ ) : (
715
+ <div className="space-y-4">
716
+ {/* Document Count */}
717
+ {embeddingSettings?.documentCount !== null && embeddingSettings?.documentCount !== undefined && (
718
+ <div className="rounded-md border border-border p-4 max-w-xs">
719
+ <p className="text-sm text-muted-foreground">{t('search.settings.vectorDocumentsLabel', 'Embeddings')}</p>
720
+ <p className="text-2xl font-bold">{embeddingSettings.documentCount.toLocaleString()}</p>
721
+ </div>
722
+ )}
723
+
724
+ {/* Auto-Indexing Toggle */}
725
+ <div className="flex items-start gap-4 p-4 rounded-md border border-border">
726
+ <div className="flex-1">
727
+ <div className="flex items-center gap-2">
728
+ <input
729
+ id="search-auto-indexing"
730
+ type="checkbox"
731
+ className="h-4 w-4 rounded border-muted-foreground/40"
732
+ checked={autoIndexingChecked}
733
+ onChange={(event) => updateAutoIndexing(event.target.checked)}
734
+ disabled={autoIndexingDisabled}
735
+ />
736
+ <Label htmlFor="search-auto-indexing" className="text-sm font-medium">
737
+ {t('search.settings.autoIndexing.label', 'Enable auto-indexing')}
738
+ </Label>
739
+ {embeddingSaving ? <Spinner size="sm" className="text-muted-foreground" /> : null}
740
+ </div>
741
+ <p className="text-xs text-muted-foreground mt-1 ml-6">
742
+ {t('search.settings.autoIndexing.description', 'Automatically index new and updated records for vector search.')}
743
+ </p>
744
+ {embeddingSettings?.autoIndexingLocked && (
745
+ <p className="text-xs text-destructive mt-1 ml-6">
746
+ {t('search.settings.autoIndexing.locked', 'Disabled via environment variable.')}
747
+ </p>
748
+ )}
749
+ </div>
750
+ </div>
751
+
752
+ {/* Reindex Actions */}
753
+ <div className="space-y-3">
754
+ <h3 className="text-sm font-semibold">{t('search.settings.vectorReindex.title', 'Reindex Data')}</h3>
755
+ <p className="text-xs text-muted-foreground">
756
+ {t('search.settings.vectorReindex.description', 'Rebuild vector embeddings for all indexed entities. This will purge existing data and regenerate all embeddings.')}
757
+ </p>
758
+
759
+ {/* Active reindex lock banner */}
760
+ {vectorReindexLock && (
761
+ <div className="p-3 rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
762
+ <div className="flex items-start gap-3">
763
+ <Spinner size="sm" className="flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
764
+ <div className="flex-1">
765
+ <p className="text-sm font-medium text-blue-800 dark:text-blue-200">
766
+ {t('search.settings.reindexInProgress', 'Reindex operation in progress')}
767
+ </p>
768
+ <p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
769
+ {t('search.settings.reindexInProgressDetails', 'Action: {{action}} | Started {{minutes}} minutes ago', {
770
+ action: vectorReindexLock.action,
771
+ minutes: vectorReindexLock.elapsedMinutes,
772
+ })}
773
+ </p>
774
+ </div>
775
+ </div>
776
+ </div>
777
+ )}
778
+
779
+ <div className="flex items-center gap-2 p-2 rounded bg-amber-50 dark:bg-amber-900/20">
780
+ <svg className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
781
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
782
+ </svg>
783
+ <p className="text-xs text-amber-800 dark:text-amber-200">
784
+ {t('search.settings.vectorReindex.warning', 'This may take a while for large datasets and will consume API credits.')}
785
+ </p>
786
+ </div>
787
+ <Button
788
+ type="button"
789
+ variant="default"
790
+ size="sm"
791
+ onClick={handleVectorReindexClick}
792
+ disabled={embeddingLoading || embeddingSaving || vectorReindexing || vectorReindexLock !== null}
793
+ >
794
+ {vectorReindexing || vectorReindexLock !== null ? (
795
+ <>
796
+ <Spinner size="sm" className="mr-2" />
797
+ {t('search.settings.vectorReindex.running', 'Reindexing...')}
798
+ </>
799
+ ) : (
800
+ t('search.settings.vectorReindex.button', 'Full Reindex')
801
+ )}
802
+ </Button>
803
+ </div>
804
+ </div>
805
+ )}
806
+ </TabsContent>
807
+
808
+ {/* Activity Tab */}
809
+ <TabsContent value="activity">
810
+ {activityLoading ? (
811
+ <div className="flex items-center gap-2 text-muted-foreground">
812
+ <Spinner size="sm" />
813
+ <span>{t('search.settings.loadingLabel', 'Loading...')}</span>
814
+ </div>
815
+ ) : activityLogs.length === 0 ? (
816
+ <div className="p-4 rounded-md bg-muted/50 text-center">
817
+ <p className="text-sm text-muted-foreground">
818
+ {t('search.settings.activity.noLogs', 'No recent indexing activity')}
819
+ </p>
820
+ </div>
821
+ ) : (
822
+ <div className="space-y-2 max-h-80 overflow-y-auto">
823
+ {activityLogs.map((log) => (
824
+ <div
825
+ key={log.id}
826
+ className={`p-2 rounded-md text-sm ${
827
+ log.level === 'error'
828
+ ? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
829
+ : 'bg-muted/50'
830
+ }`}
831
+ >
832
+ <div className="flex items-start gap-2">
833
+ {log.level === 'error' && (
834
+ <svg className="h-4 w-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
835
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
836
+ </svg>
837
+ )}
838
+ <div className="flex-1 min-w-0">
839
+ <p className={`text-xs ${log.level === 'error' ? 'text-red-800 dark:text-red-200' : 'text-foreground'}`}>
840
+ {log.message}
841
+ </p>
842
+ <p className="text-xs text-muted-foreground mt-0.5">
843
+ {(() => {
844
+ const d = new Date(log.occurredAt)
845
+ const pad = (n: number) => n.toString().padStart(2, '0')
846
+ return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
847
+ })()}
848
+ {log.entityType && ` · ${log.entityType}`}
849
+ </p>
850
+ </div>
851
+ </div>
852
+ </div>
853
+ ))}
854
+ </div>
855
+ )}
856
+ <div className="mt-3">
857
+ <Button
858
+ type="button"
859
+ variant="outline"
860
+ size="sm"
861
+ onClick={fetchActivityLogs}
862
+ disabled={activityLoading}
863
+ >
864
+ {activityLoading ? (
865
+ <>
866
+ <Spinner size="sm" className="mr-2" />
867
+ {t('search.settings.loadingLabel', 'Loading...')}
868
+ </>
869
+ ) : (
870
+ t('search.settings.refreshLabel', 'Refresh')
871
+ )}
872
+ </Button>
873
+ </div>
874
+ </TabsContent>
875
+ </Tabs>
876
+
877
+ {/* Vector Reindex Confirmation Dialog */}
878
+ {showVectorReindexDialog && (
879
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
880
+ <div className="mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
881
+ <h3 className="text-lg font-semibold mb-2">{t('search.settings.reindex.confirmTitle', 'Confirm Reindex')}</h3>
882
+ <p className="text-sm text-muted-foreground mb-4">
883
+ {t('search.settings.reindex.confirmDescription', 'This will rebuild all vector embeddings. Existing data will be purged first.')}
884
+ </p>
885
+ <div className="flex justify-end gap-3">
886
+ <Button type="button" variant="outline" onClick={handleVectorReindexCancel}>
887
+ {t('search.settings.actions.cancel', 'Cancel')}
888
+ </Button>
889
+ <Button type="button" variant="default" onClick={handleVectorReindexConfirm}>
890
+ {t('search.settings.reindex.confirmButton', 'Start Reindex')}
891
+ </Button>
892
+ </div>
893
+ </div>
894
+ </div>
895
+ )}
896
+
897
+ {/* Embedding Provider Change Confirmation Dialog */}
898
+ {showEmbeddingConfirmDialog && pendingEmbeddingConfig && (
899
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
900
+ <div className="mx-4 max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg">
901
+ <h3 className="text-lg font-semibold mb-2">{t('search.settings.change.title', 'Confirm Provider Change')}</h3>
902
+ <p className="text-sm text-muted-foreground mb-4">
903
+ {t('search.settings.change.description', 'Changing the embedding provider will require reindexing all data.')}
904
+ </p>
905
+ <div className="mb-4 p-3 rounded-md bg-muted/50 text-sm">
906
+ <p className="font-medium">
907
+ {embeddingSettings?.embeddingConfig
908
+ ? `${EMBEDDING_PROVIDERS[embeddingSettings.embeddingConfig.providerId].name} (${embeddingSettings.embeddingConfig.model})`
909
+ : 'Default'}
910
+ {' → '}
911
+ {EMBEDDING_PROVIDERS[pendingEmbeddingConfig.providerId].name} ({pendingEmbeddingConfig.model})
912
+ </p>
913
+ <p className="text-muted-foreground">
914
+ {embeddingSettings?.indexedDimension ?? 'N/A'} → {pendingEmbeddingConfig.dimension} dimensions
915
+ </p>
916
+ </div>
917
+ <ul className="mb-4 space-y-1 text-sm">
918
+ <li className="flex items-start gap-2">
919
+ <span className="text-destructive">•</span>
920
+ <span>{t('search.settings.change.bullet1', 'Existing vector data will be cleared')}</span>
921
+ </li>
922
+ <li className="flex items-start gap-2">
923
+ <span className="text-destructive">•</span>
924
+ <span>{t('search.settings.change.bullet2', 'Vector search will be unavailable during reindex')}</span>
925
+ </li>
926
+ </ul>
927
+ <div className="flex justify-end gap-3">
928
+ <Button type="button" variant="outline" onClick={handleEmbeddingCancelChange} disabled={embeddingSaving}>
929
+ {t('search.settings.actions.cancel', 'Cancel')}
930
+ </Button>
931
+ <Button type="button" variant="destructive" onClick={handleEmbeddingConfirmChange} disabled={embeddingSaving}>
932
+ {embeddingSaving ? <Spinner size="sm" className="mr-2" /> : null}
933
+ {t('search.settings.actions.confirm', 'Confirm')}
934
+ </Button>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ )}
939
+ </div>
940
+ )
941
+ }
942
+
943
+ export default VectorSearchSection