'use client' import * as React from 'react' import { useT } from '@open-mercato/shared/lib/i18n/context' import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { Button } from '@open-mercato/ui/primitives/button' import { Label } from '@open-mercato/ui/primitives/label' import { Spinner } from '@open-mercato/ui/primitives/spinner' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@open-mercato/ui/primitives/tabs' // Types type EmbeddingProviderId = 'openai' | 'google' | 'mistral' | 'cohere' | 'bedrock' | 'ollama' type EmbeddingProviderConfig = { providerId: EmbeddingProviderId model: string dimension: number outputDimensionality?: number baseUrl?: string updatedAt: string } type EmbeddingModelInfo = { id: string name: string dimension: number configurableDimension?: boolean minDimension?: number maxDimension?: number } type EmbeddingProviderInfo = { name: string envKeyRequired: string defaultModel: string models: EmbeddingModelInfo[] } type EmbeddingSettings = { openaiConfigured: boolean autoIndexingEnabled: boolean autoIndexingLocked: boolean lockReason: string | null embeddingConfig: EmbeddingProviderConfig | null embeddingConfigSource?: 'tenant' | 'instance' | 'env' configuredProviders: EmbeddingProviderId[] providerAvailability?: { providerId: EmbeddingProviderId; available: boolean; reason?: string; models?: number }[] indexedDimension: number | null reindexRequired: boolean documentCount: number | null } type EmbeddingSettingsResponse = { settings?: EmbeddingSettings error?: string } type VectorDriverId = 'pgvector' | 'qdrant' | 'chromadb' type VectorDriverEnvVar = { name: string set: boolean hint: string } type VectorDriverStatus = { id: VectorDriverId name: string configured: boolean implemented: boolean envVars: VectorDriverEnvVar[] } type VectorStoreConfigResponse = { currentDriver: VectorDriverId configured: boolean drivers: VectorDriverStatus[] } type ReindexLock = { type: 'fulltext' | 'vector' action: string startedAt: string elapsedMinutes: number } type ActivityLog = { id: string source: string handler: string level: 'info' | 'error' | 'warn' entityType: string | null recordId: string | null message: string details: unknown occurredAt: string } const EMBEDDING_PROVIDERS: Record = { openai: { name: 'OpenAI', envKeyRequired: 'OPENAI_API_KEY', defaultModel: 'text-embedding-3-small', models: [ { id: 'text-embedding-3-small', name: 'text-embedding-3-small', dimension: 1536 }, { id: 'text-embedding-3-large', name: 'text-embedding-3-large', dimension: 3072, configurableDimension: true, minDimension: 256, maxDimension: 3072 }, { id: 'text-embedding-ada-002', name: 'text-embedding-ada-002', dimension: 1536 }, ], }, google: { name: 'Google Generative AI', envKeyRequired: 'GOOGLE_GENERATIVE_AI_API_KEY', defaultModel: 'text-embedding-004', models: [ { id: 'text-embedding-004', name: 'text-embedding-004', dimension: 768, configurableDimension: true, minDimension: 1, maxDimension: 768 }, { id: 'embedding-001', name: 'embedding-001', dimension: 768 }, ], }, mistral: { name: 'Mistral', envKeyRequired: 'MISTRAL_API_KEY', defaultModel: 'mistral-embed', models: [ { id: 'mistral-embed', name: 'mistral-embed', dimension: 1024 }, ], }, cohere: { name: 'Cohere', envKeyRequired: 'COHERE_API_KEY', defaultModel: 'embed-english-v3.0', models: [ { id: 'embed-english-v3.0', name: 'embed-english-v3.0', dimension: 1024 }, { id: 'embed-multilingual-v3.0', name: 'embed-multilingual-v3.0', dimension: 1024 }, { id: 'embed-english-light-v3.0', name: 'embed-english-light-v3.0', dimension: 384 }, { id: 'embed-multilingual-light-v3.0', name: 'embed-multilingual-light-v3.0', dimension: 384 }, ], }, bedrock: { name: 'Amazon Bedrock', envKeyRequired: 'AWS_ACCESS_KEY_ID', defaultModel: 'amazon.titan-embed-text-v2:0', models: [ { id: 'amazon.titan-embed-text-v2:0', name: 'Titan Embed Text v2', dimension: 1024, configurableDimension: true, minDimension: 256, maxDimension: 1024 }, { id: 'amazon.titan-embed-text-v1', name: 'Titan Embed Text v1', dimension: 1536 }, { id: 'cohere.embed-english-v3', name: 'Cohere Embed English v3', dimension: 1024 }, { id: 'cohere.embed-multilingual-v3', name: 'Cohere Embed Multilingual v3', dimension: 1024 }, ], }, ollama: { name: 'Ollama (Local)', envKeyRequired: 'OLLAMA_BASE_URL', defaultModel: 'nomic-embed-text', models: [ { id: 'nomic-embed-text', name: 'nomic-embed-text', dimension: 768 }, { id: 'mxbai-embed-large', name: 'mxbai-embed-large', dimension: 1024 }, { id: 'all-minilm', name: 'all-minilm', dimension: 384 }, { id: 'snowflake-arctic-embed', name: 'snowflake-arctic-embed', dimension: 1024 }, ], }, } export type VectorSearchSectionProps = { embeddingSettings: EmbeddingSettings | null embeddingLoading: boolean vectorStoreConfig: VectorStoreConfigResponse | null vectorStoreConfigLoading: boolean vectorReindexLock: ReindexLock | null onEmbeddingSettingsUpdate: (settings: EmbeddingSettings) => void onRefreshEmbeddings: () => Promise } export function VectorSearchSection({ embeddingSettings, embeddingLoading, vectorStoreConfig, vectorStoreConfigLoading, vectorReindexLock, onEmbeddingSettingsUpdate, onRefreshEmbeddings, }: VectorSearchSectionProps) { const t = useT() const [embeddingSaving, setEmbeddingSaving] = React.useState(false) const autoIndexingPreviousRef = React.useRef(true) // Staged embedding selection const [selectedProvider, setSelectedProvider] = React.useState(null) const [selectedModel, setSelectedModel] = React.useState(null) const [customModelName, setCustomModelName] = React.useState('') const [customDimension, setCustomDimension] = React.useState(768) const [pendingEmbeddingConfig, setPendingEmbeddingConfig] = React.useState(null) const [showEmbeddingConfirmDialog, setShowEmbeddingConfirmDialog] = React.useState(false) // Vector reindex state const [vectorReindexing, setVectorReindexing] = React.useState(false) const [showVectorReindexDialog, setShowVectorReindexDialog] = React.useState(false) // Activity logs state const [activityLogs, setActivityLogs] = React.useState([]) const [activityLoading, setActivityLoading] = React.useState(true) // Fetch activity logs const fetchActivityLogs = React.useCallback(async () => { setActivityLoading(true) try { const response = await fetch('/api/query_index/status') if (response.ok) { const body = await response.json() as { logs?: ActivityLog[]; errors?: ActivityLog[] } const allLogs: ActivityLog[] = [] if (body.logs) { allLogs.push(...body.logs) } if (body.errors) { allLogs.push(...body.errors.map(err => ({ ...err, level: 'error' as const }))) } // Filter for vector-related logs const vectorLogs = allLogs.filter(log => { const lowerSource = log.source?.toLowerCase() ?? '' const lowerMessage = log.message?.toLowerCase() ?? '' const lowerHandler = log.handler?.toLowerCase() ?? '' return lowerSource.includes('vector') || lowerMessage.includes('vector') || lowerMessage.includes('embedding') || lowerHandler.includes('vector') }) vectorLogs.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()) setActivityLogs(vectorLogs.slice(0, 50)) } } catch { // Silently fail } finally { setActivityLoading(false) } }, []) React.useEffect(() => { fetchActivityLogs() }, [fetchActivityLogs]) useAppEvent('progress.job.updated', () => { void fetchActivityLogs() }, [fetchActivityLogs]) useAppEvent('progress.job.completed', () => { void fetchActivityLogs() }, [fetchActivityLogs]) useAppEvent('om:bridge:reconnected', () => { void fetchActivityLogs() }, [fetchActivityLogs]) // Update auto-indexing const updateAutoIndexing = React.useCallback(async (nextValue: boolean) => { autoIndexingPreviousRef.current = embeddingSettings?.autoIndexingEnabled ?? true if (embeddingSettings) { onEmbeddingSettingsUpdate({ ...embeddingSettings, autoIndexingEnabled: nextValue }) } setEmbeddingSaving(true) try { const body = await readApiResultOrThrow( '/api/search/embeddings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ autoIndexingEnabled: nextValue }), }, { errorMessage: t('search.settings.errors.saveFailed', 'Failed to save settings'), allowNullResult: true }, ) if (body?.settings) { onEmbeddingSettingsUpdate(body.settings) autoIndexingPreviousRef.current = body.settings.autoIndexingEnabled } flash(t('search.settings.messages.saved', 'Settings saved'), 'success') } catch { if (embeddingSettings) { onEmbeddingSettingsUpdate({ ...embeddingSettings, autoIndexingEnabled: autoIndexingPreviousRef.current }) } } finally { setEmbeddingSaving(false) } }, [embeddingSettings, onEmbeddingSettingsUpdate, t]) // Provider handlers const handleProviderChange = (providerId: EmbeddingProviderId) => { setSelectedProvider(providerId) setSelectedModel(null) setCustomModelName('') setCustomDimension(768) } const handleModelChange = (modelId: string) => { setSelectedModel(modelId) } const handleApplyEmbeddingChanges = () => { const newProviderId = selectedProvider ?? embeddingSettings?.embeddingConfig?.providerId ?? 'openai' const newProviderInfo = EMBEDDING_PROVIDERS[newProviderId] const newModelId = selectedModel ?? (selectedProvider ? newProviderInfo.defaultModel : embeddingSettings?.embeddingConfig?.model ?? newProviderInfo.defaultModel) let modelName: string let dimension: number if (newModelId === 'custom') { modelName = customModelName.trim() dimension = customDimension if (!modelName) { flash(t('search.settings.errors.modelRequired', 'Please enter a model name'), 'error') return } if (dimension <= 0) { flash(t('search.settings.errors.dimensionRequired', 'Please enter a valid dimension'), 'error') return } } else { const newModel = newProviderInfo.models.find((m) => m.id === newModelId) ?? newProviderInfo.models[0] modelName = newModel.id dimension = newModel.dimension } const newConfig: EmbeddingProviderConfig = { providerId: newProviderId, model: modelName, dimension, updatedAt: new Date().toISOString(), } if (embeddingSettings?.indexedDimension || embeddingSettings?.embeddingConfig) { setPendingEmbeddingConfig(newConfig) setShowEmbeddingConfirmDialog(true) } else { applyEmbeddingConfig(newConfig) } } const handleCancelEmbeddingSelection = () => { setSelectedProvider(null) setSelectedModel(null) setCustomModelName('') setCustomDimension(768) } const applyEmbeddingConfig = async (config: EmbeddingProviderConfig) => { setEmbeddingSaving(true) setShowEmbeddingConfirmDialog(false) setPendingEmbeddingConfig(null) try { await readApiResultOrThrow( '/api/search/embeddings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ embeddingConfig: config }), }, { errorMessage: t('search.settings.errors.saveFailed', 'Failed to save settings'), allowNullResult: true }, ) setSelectedProvider(null) setSelectedModel(null) flash(t('search.settings.messages.providerSaved', 'Embedding provider saved'), 'success') await onRefreshEmbeddings() } catch { // Error handled by readApiResultOrThrow } finally { setEmbeddingSaving(false) } } const handleEmbeddingConfirmChange = () => { if (pendingEmbeddingConfig) { applyEmbeddingConfig(pendingEmbeddingConfig) } } const handleEmbeddingCancelChange = () => { setShowEmbeddingConfirmDialog(false) setPendingEmbeddingConfig(null) } // Vector reindex handlers const handleVectorReindexClick = () => { setShowVectorReindexDialog(true) } const handleVectorReindexConfirm = async () => { setShowVectorReindexDialog(false) setVectorReindexing(true) try { await readApiResultOrThrow<{ ok: boolean }>( '/api/search/embeddings/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purgeFirst: true }), }, { errorMessage: t('search.settings.errors.reindexFailed', 'Reindex failed'), allowNullResult: true }, ) flash(t('search.settings.messages.reindexStarted', 'Reindex started'), 'success') await fetchActivityLogs() } catch { // Error handled by readApiResultOrThrow } finally { setVectorReindexing(false) } } const handleVectorReindexCancel = () => { setShowVectorReindexDialog(false) } // Computed values const savedProvider = embeddingSettings?.embeddingConfig?.providerId ?? 'openai' const savedProviderInfo = EMBEDDING_PROVIDERS[savedProvider] const savedModel = embeddingSettings?.embeddingConfig?.model ?? savedProviderInfo.defaultModel const savedDimension = embeddingSettings?.embeddingConfig?.dimension ?? savedProviderInfo.models[0]?.dimension ?? 768 const savedModelIsPredefined = savedProviderInfo.models.some((m) => m.id === savedModel) const savedCustomModel = !savedModelIsPredefined && savedModel ? { id: savedModel, name: savedModel, dimension: savedDimension } : null const displayProvider = selectedProvider ?? savedProvider const displayProviderInfo = EMBEDDING_PROVIDERS[displayProvider] const displayModel = selectedModel ?? (selectedProvider ? displayProviderInfo.defaultModel : savedModel) const isCustomModel = displayModel === 'custom' const displayModelIsSavedCustom = !isCustomModel && displayProvider === savedProvider && savedCustomModel && displayModel === savedCustomModel.id const displayModelInfo = isCustomModel ? null : displayModelIsSavedCustom ? savedCustomModel : displayProviderInfo.models.find((m) => m.id === displayModel) ?? displayProviderInfo.models[0] const displayDimension = isCustomModel ? customDimension : (displayModelInfo?.dimension ?? 768) const hasUnsavedEmbeddingChanges = (selectedProvider !== null && selectedProvider !== savedProvider) || (selectedModel !== null && selectedModel !== savedModel) || (selectedProvider !== null && selectedModel === null && displayProviderInfo.defaultModel !== savedModel) || (isCustomModel && (customModelName.trim() !== '' || customDimension !== 768)) const isEmbeddingConfigured = embeddingSettings?.configuredProviders?.includes(savedProvider) const providerOptions: EmbeddingProviderId[] = ['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama'] const autoIndexingChecked = embeddingSettings ? embeddingSettings.autoIndexingEnabled : true const autoIndexingDisabled = embeddingLoading || embeddingSaving || Boolean(embeddingSettings?.autoIndexingLocked) return (

{t('search.settings.vector.sectionTitle', 'Vector Search')}

{t('search.settings.vector.sectionDescription', 'AI-powered semantic search using embeddings.')}

{t('search.settings.tabs.configuration', 'Configuration')} {t('search.settings.tabs.indexManagement', 'Index Management')} {t('search.settings.tabs.activity', 'Activity')} {/* Configuration Tab */} {(embeddingLoading || vectorStoreConfigLoading) ? (
{t('search.settings.loadingLabel', 'Loading settings...')}
) : (
{/* Vector Store Driver Status */}

{t('search.settings.vector.store', 'Vector Store')}

{vectorStoreConfig?.drivers.map((driver) => { const isCurrent = driver.id === vectorStoreConfig.currentDriver const isReady = driver.configured && driver.implemented return (

{driver.name}

{isCurrent && ( {t('search.settings.vector.active', 'Active')} )} {!driver.implemented && ( {t('search.settings.vector.comingSoon', 'Coming soon')} )}
{driver.envVars.map((envVar) => (
{envVar.name}
))}
) })}
{/* Embedding Provider Selection */}

{t('search.settings.vector.providers', 'Embedding Provider')}

{t('search.settings.vector.providersHint', 'Select a provider to generate embeddings. Only reachable providers can be selected.')}

{embeddingSettings?.embeddingConfigSource && embeddingSettings.embeddingConfigSource !== 'tenant' && (

{t('search.settings.vector.inheritingDefault', 'This tenant is inheriting the instance/environment default. Saving a provider creates an override for this tenant.')}

)} {embeddingSettings?.embeddingConfigSource === 'tenant' && (

{t('search.settings.vector.usingTenantConfig', 'This tenant is using its own embedding settings.')}

)}
{providerOptions.map((providerId) => { const info = EMBEDDING_PROVIDERS[providerId] const availability = embeddingSettings?.providerAvailability?.find((entry) => entry.providerId === providerId) const isConfigured = availability ? availability.available : embeddingSettings?.configuredProviders?.includes(providerId) const isSelected = displayProvider === providerId const isCurrentlySaved = savedProvider === providerId return (
)}
)} ) })}
{/* Setup Instructions */}

{t('search.settings.vector.howTo', 'How to set up')}

{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.')}

)} {/* Index Management Tab */} {embeddingLoading ? (
{t('search.settings.loadingLabel', 'Loading settings...')}
) : !isEmbeddingConfigured ? (

{t('search.settings.vectorNotConfigured', 'No embedding provider configured')}

{t('search.settings.vectorNotConfiguredHint', 'Configure an embedding provider in the Configuration tab to enable indexing.')}

) : (
{/* Document Count */} {embeddingSettings?.documentCount !== null && embeddingSettings?.documentCount !== undefined && (

{t('search.settings.vectorDocumentsLabel', 'Embeddings')}

{embeddingSettings.documentCount.toLocaleString()}

)} {/* Auto-Indexing Toggle */}
updateAutoIndexing(event.target.checked)} disabled={autoIndexingDisabled} /> {embeddingSaving ? : null}

{t('search.settings.autoIndexing.description', 'Automatically index new and updated records for vector search.')}

{embeddingSettings?.autoIndexingLocked && (

{t('search.settings.autoIndexing.locked', 'Disabled via environment variable.')}

)}
{/* Reindex Actions */}

{t('search.settings.vectorReindex.title', 'Reindex Data')}

{t('search.settings.vectorReindex.description', 'Rebuild vector embeddings for all indexed entities. This will purge existing data and regenerate all embeddings.')}

{/* Active reindex lock banner */} {vectorReindexLock && (

{t('search.settings.reindexInProgress', 'Reindex operation in progress')}

{t('search.settings.reindexInProgressDetails', 'Action: {{action}} | Started {{minutes}} minutes ago', { action: vectorReindexLock.action, minutes: vectorReindexLock.elapsedMinutes, })}

)}

{t('search.settings.vectorReindex.warning', 'This may take a while for large datasets and will consume API credits.')}

)}
{/* Activity Tab */} {activityLoading ? (
{t('search.settings.loadingLabel', 'Loading...')}
) : activityLogs.length === 0 ? (

{t('search.settings.activity.noLogs', 'No recent indexing activity')}

) : (
{activityLogs.map((log) => (
{log.level === 'error' && ( )}

{log.message}

{(() => { const d = new Date(log.occurredAt) const pad = (n: number) => n.toString().padStart(2, '0') return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}` })()} {log.entityType && ` · ${log.entityType}`}

))}
)}
{/* Vector Reindex Confirmation Dialog */} {showVectorReindexDialog && (

{t('search.settings.reindex.confirmTitle', 'Confirm Reindex')}

{t('search.settings.reindex.confirmDescription', 'This will rebuild all vector embeddings. Existing data will be purged first.')}

)} {/* Embedding Provider Change Confirmation Dialog */} {showEmbeddingConfirmDialog && pendingEmbeddingConfig && (

{t('search.settings.change.title', 'Confirm Provider Change')}

{t('search.settings.change.description', 'Changing the embedding provider will require reindexing all data.')}

{embeddingSettings?.embeddingConfig ? `${EMBEDDING_PROVIDERS[embeddingSettings.embeddingConfig.providerId].name} (${embeddingSettings.embeddingConfig.model})` : 'Default'} {' → '} {EMBEDDING_PROVIDERS[pendingEmbeddingConfig.providerId].name} ({pendingEmbeddingConfig.model})

{embeddingSettings?.indexedDimension ?? 'N/A'} → {pendingEmbeddingConfig.dimension} dimensions

  • {t('search.settings.change.bullet1', 'Existing vector data will be cleared')}
  • {t('search.settings.change.bullet2', 'Vector search will be unavailable during reindex')}
)} ) } export default VectorSearchSection