From dc1e64d997e2b54fa491e0dc95f1827464deae37 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 24 May 2026 16:17:55 -0700 Subject: [PATCH] feat(embeddings): configurable embedding providers with dedicated settings panel (#2583) --- .../components/settings/panels/AIPanel.tsx | 7 - .../settings/panels/EmbeddingsPanel.tsx | 696 ++++++++++++++++++ app/src/lib/i18n/chunks/ar-1.ts | 36 + app/src/lib/i18n/chunks/ar-4.ts | 2 + app/src/lib/i18n/chunks/bn-1.ts | 36 + app/src/lib/i18n/chunks/bn-4.ts | 2 + app/src/lib/i18n/chunks/de-1.ts | 37 + app/src/lib/i18n/chunks/de-4.ts | 2 + app/src/lib/i18n/chunks/en-1.ts | 36 + app/src/lib/i18n/chunks/en-4.ts | 2 + app/src/lib/i18n/chunks/es-1.ts | 36 + app/src/lib/i18n/chunks/es-4.ts | 3 + app/src/lib/i18n/chunks/fr-1.ts | 37 + app/src/lib/i18n/chunks/fr-4.ts | 3 + app/src/lib/i18n/chunks/hi-1.ts | 37 + app/src/lib/i18n/chunks/hi-4.ts | 2 + app/src/lib/i18n/chunks/id-1.ts | 36 + app/src/lib/i18n/chunks/id-4.ts | 2 + app/src/lib/i18n/chunks/it-1.ts | 37 + app/src/lib/i18n/chunks/it-4.ts | 3 + app/src/lib/i18n/chunks/ko-1.ts | 36 + app/src/lib/i18n/chunks/ko-4.ts | 2 + app/src/lib/i18n/chunks/pt-1.ts | 37 + app/src/lib/i18n/chunks/pt-4.ts | 2 + app/src/lib/i18n/chunks/ru-1.ts | 37 + app/src/lib/i18n/chunks/ru-4.ts | 2 + app/src/lib/i18n/chunks/zh-CN-1.ts | 36 + app/src/lib/i18n/chunks/zh-CN-4.ts | 2 + app/src/lib/i18n/en.ts | 40 + app/src/pages/Settings.tsx | 9 + app/src/services/__tests__/rpcMethods.test.ts | 12 +- .../api/__tests__/aiSettingsApi.test.ts | 4 +- app/src/services/api/aiSettingsApi.ts | 3 - app/src/services/api/embeddingsApi.ts | 104 +++ app/src/services/rpcMethods.ts | 7 + src/core/all.rs | 5 +- src/core/legacy_aliases.rs | 1 + src/openhuman/embeddings/catalog.rs | 254 +++++++ src/openhuman/embeddings/cohere.rs | 179 +++++ src/openhuman/embeddings/factory.rs | 59 +- src/openhuman/embeddings/mod.rs | 114 ++- src/openhuman/embeddings/rpc.rs | 382 ++++++++++ src/openhuman/embeddings/schemas.rs | 336 +++++++++ src/openhuman/embeddings/voyage.rs | 82 +++ src/openhuman/inference/schemas.rs | 30 - src/openhuman/inference/schemas_tests.rs | 3 +- tests/embeddings_rpc_e2e.rs | 566 ++++++++++++++ 47 files changed, 3338 insertions(+), 58 deletions(-) create mode 100644 app/src/components/settings/panels/EmbeddingsPanel.tsx create mode 100644 app/src/services/api/embeddingsApi.ts create mode 100644 src/openhuman/embeddings/catalog.rs create mode 100644 src/openhuman/embeddings/cohere.rs create mode 100644 src/openhuman/embeddings/rpc.rs create mode 100644 src/openhuman/embeddings/schemas.rs create mode 100644 src/openhuman/embeddings/voyage.rs create mode 100644 tests/embeddings_rpc_e2e.rs diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 6e4bfefea..ac4c0fce0 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -76,7 +76,6 @@ type WorkloadId = | 'agentic' | 'coding' | 'memory' - | 'embeddings' | 'heartbeat' | 'learning' | 'subconscious'; @@ -207,8 +206,6 @@ const WORKLOAD_MODEL_HINTS: Record = { 'Recommended: a cheaper summarization model. It should be consistent and compact, but it does not need premium frontier-level reasoning.', heartbeat: 'Recommended: a cheap, efficient background model. This runs often between turns, so low cost matters more than maximum intelligence.', - embeddings: - 'Recommended: a dedicated embedding model. Prefer low cost, stable dimensions, and strong retrieval quality over general chat ability.', learning: 'Recommended: a stronger reflective model. This can be mid-cost or premium because it benefits from better synthesis over recent history.', subconscious: @@ -233,7 +230,6 @@ const EMPTY_ROUTING: RoutingMap = { agentic: { kind: 'default' }, coding: { kind: 'default' }, memory: { kind: 'default' }, - embeddings: { kind: 'default' }, heartbeat: { kind: 'default' }, learning: { kind: 'default' }, subconscious: { kind: 'default' }, @@ -286,7 +282,6 @@ function toPanelRoutingFromApi(api: ApiAISettings): { panel: AISettings } { agentic: liftRef(api.routing.agentic), coding: liftRef(api.routing.coding), memory: liftRef(api.routing.memory), - embeddings: liftRef(api.routing.embeddings), heartbeat: liftRef(api.routing.heartbeat), learning: liftRef(api.routing.learning), subconscious: liftRef(api.routing.subconscious), @@ -310,7 +305,6 @@ function toApiSettings(panel: AISettings): ApiAISettings { agentic: panel.routing.agentic, coding: panel.routing.coding, memory: panel.routing.memory, - embeddings: panel.routing.embeddings, heartbeat: panel.routing.heartbeat, learning: panel.routing.learning, subconscious: panel.routing.subconscious, @@ -1782,7 +1776,6 @@ function routingWithAllWorkloads(next: ProviderRef): RoutingMap { agentic: next, coding: next, memory: next, - embeddings: EMPTY_ROUTING.embeddings, heartbeat: next, learning: next, subconscious: next, diff --git a/app/src/components/settings/panels/EmbeddingsPanel.tsx b/app/src/components/settings/panels/EmbeddingsPanel.tsx new file mode 100644 index 000000000..08398f0e0 --- /dev/null +++ b/app/src/components/settings/panels/EmbeddingsPanel.tsx @@ -0,0 +1,696 @@ +/** + * Embeddings settings panel — provider selection, API keys, model + dimensions. + * + * Flow: select a provider → if it needs an API key, a setup popup appears + * to enter the key, test connection, and save. Dimension changes show a + * destructive confirm dialog since they invalidate stored vectors. + */ +import { useCallback, useEffect, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + clearEmbeddingsApiKey, + type EmbeddingProviderEntry, + type EmbeddingsSettings, + type EmbeddingsTestResult, + loadEmbeddingsSettings, + setEmbeddingsApiKey, + testEmbeddingsConnection, + updateEmbeddingsSettings, +} from '../../../services/api/embeddingsApi'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +type Status = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'saving' } + | { kind: 'saved' } + | { kind: 'error'; message: string }; + +const EmbeddingsPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState({ kind: 'loading' }); + + // Setup popup state + const [setupProvider, setSetupProvider] = useState(null); + const [setupKey, setSetupKey] = useState(''); + const [setupShowKey, setSetupShowKey] = useState(false); + const [setupTesting, setSetupTesting] = useState(false); + const [setupTestResult, setSetupTestResult] = useState(null); + const [setupSaving, setSetupSaving] = useState(false); + const [setupError, setSetupError] = useState(''); + + // Confirm wipe dialog + const [pendingWipe, setPendingWipe] = useState<{ + provider?: string; + model?: string; + dimensions?: number; + custom_endpoint?: string; + } | null>(null); + + // Custom endpoint state + const [customEndpoint, setCustomEndpoint] = useState(''); + const [customModel, setCustomModel] = useState(''); + const [customDims, setCustomDims] = useState('1024'); + + const reload = useCallback(async () => { + try { + const s = await loadEmbeddingsSettings(); + setSettings(s); + setStatus({ kind: 'idle' }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + if (!settings) { + return ( +
+ +
+
+ {status.kind === 'loading' + ? t('common.loading') + : status.kind === 'error' + ? status.message + : ''} +
+
+
+ ); + } + + const selectedProvider = normalizeProvider(settings.provider); + const currentEntry = settings.providers.find(p => p.slug === selectedProvider); + const currentModels = currentEntry?.models ?? []; + const currentModel = currentModels.find(m => m.id === settings.model) ?? currentModels[0]; + const allowedDims = currentModel?.allowed_dimensions ?? []; + + function handleProviderClick(entry: EmbeddingProviderEntry) { + if (entry.slug === selectedProvider) return; + + if (entry.slug === 'custom') { + // For custom, open setup popup to enter endpoint + setSetupProvider(entry); + setSetupKey(''); + setSetupTestResult(null); + setSetupError(''); + return; + } + + if (entry.requires_api_key && !entry.has_api_key) { + // Open the setup popup for API key entry + test + setSetupProvider(entry); + setSetupKey(''); + setSetupShowKey(false); + setSetupTestResult(null); + setSetupError(''); + return; + } + + // No key needed or already configured — switch directly + void doProviderSwitch(entry.slug); + } + + async function doProviderSwitch(slug: string, model?: string, dims?: number) { + const entry = settings!.providers.find(p => p.slug === slug); + const defaultModel = entry?.models[0]; + const newModel = model ?? defaultModel?.id ?? settings!.model; + const newDims = dims ?? defaultModel?.default_dimensions ?? settings!.dimensions; + + setStatus({ kind: 'saving' }); + try { + const result = await updateEmbeddingsSettings({ + provider: slug, + model: newModel, + dimensions: newDims, + confirm_wipe: false, + }); + if (result.error === 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE') { + setPendingWipe({ provider: slug, model: newModel, dimensions: newDims }); + setStatus({ kind: 'idle' }); + return; + } + await reload(); + setStatus({ kind: 'saved' }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + async function handleModelChange(modelId: string) { + const model = currentModels.find(m => m.id === modelId); + const newDims = model?.default_dimensions ?? settings!.dimensions; + setStatus({ kind: 'saving' }); + try { + const result = await updateEmbeddingsSettings({ + model: modelId, + dimensions: newDims, + confirm_wipe: false, + }); + if (result.error === 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE') { + setPendingWipe({ model: modelId, dimensions: newDims }); + setStatus({ kind: 'idle' }); + return; + } + await reload(); + setStatus({ kind: 'saved' }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + async function handleDimsChange(dims: number) { + setStatus({ kind: 'saving' }); + try { + const result = await updateEmbeddingsSettings({ dimensions: dims, confirm_wipe: false }); + if (result.error === 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE') { + setPendingWipe({ dimensions: dims }); + setStatus({ kind: 'idle' }); + return; + } + await reload(); + setStatus({ kind: 'saved' }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + async function confirmWipe() { + if (!pendingWipe) return; + setStatus({ kind: 'saving' }); + const wipe = pendingWipe; + setPendingWipe(null); + try { + await updateEmbeddingsSettings({ ...wipe, confirm_wipe: true }); + await reload(); + setStatus({ kind: 'saved' }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + // ── Setup popup handlers ── + + async function setupTest() { + if (!setupProvider) return; + setSetupTesting(true); + setSetupTestResult(null); + setSetupError(''); + try { + // Store the key first so the backend can use it for the test + if (setupKey.trim()) { + await setEmbeddingsApiKey(setupProvider.slug, setupKey.trim()); + } + const defaultModel = setupProvider.models[0]; + const result = await testEmbeddingsConnection({ + provider: setupProvider.slug, + model: defaultModel?.id, + dimensions: defaultModel?.default_dimensions, + }); + setSetupTestResult(result); + if (result.success) { + // Refresh settings to pick up the stored key + await reload(); + } + } catch (err) { + setSetupError(err instanceof Error ? err.message : String(err)); + } finally { + setSetupTesting(false); + } + } + + async function setupSave() { + if (!setupProvider) return; + setSetupSaving(true); + setSetupError(''); + try { + // Store key if not already stored during test + if (setupKey.trim()) { + await setEmbeddingsApiKey(setupProvider.slug, setupKey.trim()); + } + // Switch to this provider + await doProviderSwitch(setupProvider.slug); + setSetupProvider(null); + setSetupKey(''); + setSetupTestResult(null); + } catch (err) { + setSetupError(err instanceof Error ? err.message : String(err)); + } finally { + setSetupSaving(false); + } + } + + async function setupSaveCustom() { + if (!customEndpoint.trim()) return; + setSetupSaving(true); + setSetupError(''); + try { + if (setupKey.trim()) { + await setEmbeddingsApiKey('custom', setupKey.trim()); + } + setStatus({ kind: 'saving' }); + const result = await updateEmbeddingsSettings({ + provider: 'custom', + model: customModel || 'embedding', + dimensions: Number(customDims) || 1024, + custom_endpoint: customEndpoint.trim(), + confirm_wipe: false, + }); + if (result.error === 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE') { + setPendingWipe({ + provider: 'custom', + model: customModel || 'embedding', + dimensions: Number(customDims) || 1024, + custom_endpoint: customEndpoint.trim(), + }); + setStatus({ kind: 'idle' }); + } else { + await reload(); + setStatus({ kind: 'saved' }); + } + setSetupProvider(null); + } catch (err) { + setSetupError(err instanceof Error ? err.message : String(err)); + } finally { + setSetupSaving(false); + } + } + + async function handleClearKey() { + if (!currentEntry) return; + setStatus({ kind: 'saving' }); + try { + await clearEmbeddingsApiKey(selectedProvider); + await reload(); + setStatus({ kind: 'saved' }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + async function handleTestConnection() { + setStatus({ kind: 'saving' }); + try { + const result = await testEmbeddingsConnection(); + if (result.success) { + setStatus({ kind: 'saved' }); + } else { + setStatus({ kind: 'error', message: result.error ?? 'Test failed' }); + } + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + return ( +
+ + +
+

+ {t('settings.embeddings.description')} +

+ + {/* Provider selection */} +
+ {settings.providers.map((entry, idx) => { + const selected = entry.slug === selectedProvider; + return ( + + ); + })} +
+ + {/* Model & dimensions (for active provider with catalog models) */} + {currentModels.length > 0 && + selectedProvider !== 'custom' && + selectedProvider !== 'none' && ( +
+ {currentModels.length > 1 && ( +
+ + +
+ )} + + {allowedDims.length > 1 && ( +
+ + +
+ )} + + {/* Active provider info + actions */} +
+ {currentEntry?.requires_api_key && currentEntry.has_api_key && ( + + )} + +
+
+ )} + + {/* Status bar */} +
+ {status.kind === 'saving' && t('settings.embeddings.saving')} + {status.kind === 'saved' && t('settings.embeddings.saved')} + {status.kind === 'error' && ( + + {t('settings.embeddings.errorPrefix')}: {status.message} + + )} +
+
+ + {/* ── Setup popup (API key entry + test + save) ── */} + {setupProvider && ( +
{ + if (e.target === e.currentTarget) { + setSetupProvider(null); + } + }}> +
+

+ {t('settings.embeddings.setupTitle').replace('{provider}', setupProvider.label)} +

+ + {setupProvider.slug === 'custom' ? ( + /* Custom endpoint form */ +
+
+ + setCustomEndpoint(e.target.value)} + placeholder="https://your-endpoint.com/v1" + className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + autoFocus + /> +
+
+
+ + setCustomModel(e.target.value)} + placeholder="text-embedding-3-small" + className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> +
+
+ + setCustomDims(e.target.value)} + placeholder="1024" + className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> +
+
+
+ + setSetupKey(e.target.value)} + placeholder={t('settings.embeddings.placeholderKey')} + className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> +
+
+ ) : ( + /* Standard API key form */ +
+

+ {setupProvider.description} +

+
+ +
+ setSetupKey(e.target.value)} + placeholder={t('settings.embeddings.placeholderKey')} + className="flex-1 px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + autoFocus + /> + +
+

+ {t('settings.embeddings.keyStoredEncrypted')} +

+
+
+ )} + + {/* Test result */} + {setupTestResult && ( +
+ {setupTestResult.success + ? t('settings.embeddings.testSuccess').replace( + '{dims}', + String(setupTestResult.actual_dimensions ?? '?') + ) + : t('settings.embeddings.testFailed').replace( + '{error}', + setupTestResult.error ?? '' + )} +
+ )} + + {setupError && ( +
+ {setupError} +
+ )} + + {/* Popup actions */} +
+ + +
+ + +
+
+
+
+ )} + + {/* ── Confirm wipe dialog ── */} + {pendingWipe && ( +
+
+

+ {t('settings.embeddings.wipeTitle')} +

+

+ {t('settings.embeddings.wipeBody')} +

+
+ + +
+
+
+ )} +
+ ); +}; + +function normalizeProvider(raw: string): string { + if (raw === 'cloud') return 'managed'; + if (raw.startsWith('custom:')) return 'custom'; + return raw; +} + +export default EmbeddingsPanel; diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index ae45d140b..cba846f06 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -480,6 +480,42 @@ const ar1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'التضمينات', + 'settings.embeddings.description': + 'اختر مزود التضمينات الذي يحول الذاكرة إلى متجهات للبحث الدلالي. تغيير المزود أو النموذج أو الأبعاد يبطل المتجهات المخزنة ويتطلب إعادة تعيين كاملة للذاكرة.', + 'settings.embeddings.providerAria': 'مزود التضمينات', + 'settings.embeddings.statusConfigured': 'تم التهيئة', + 'settings.embeddings.statusNeedsKey': 'يحتاج مفتاح API', + 'settings.embeddings.apiKeyLabel': 'مفتاح API لـ {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'الصق مفتاح API الخاص بك…', + 'settings.embeddings.keyStoredEncrypted': 'يتم تخزين مفتاح API الخاص بك مشفرًا على هذا الجهاز.', + 'settings.embeddings.show': 'إظهار', + 'settings.embeddings.hide': 'إخفاء', + 'settings.embeddings.save': 'حفظ', + 'settings.embeddings.clear': 'مسح', + 'settings.embeddings.model': 'النموذج', + 'settings.embeddings.dimensions': 'الأبعاد', + 'settings.embeddings.customEndpoint': 'نقطة نهاية مخصصة', + 'settings.embeddings.customModelPlaceholder': 'اسم النموذج', + 'settings.embeddings.customDimsPlaceholder': 'الأبعاد', + 'settings.embeddings.applyCustom': 'تطبيق', + 'settings.embeddings.testConnection': 'اختبار الاتصال', + 'settings.embeddings.testing': 'جارٍ الاختبار…', + 'settings.embeddings.testSuccess': 'متصل — {dims} بُعد', + 'settings.embeddings.testFailed': 'فشل: {error}', + 'settings.embeddings.saving': 'جارٍ الحفظ…', + 'settings.embeddings.saved': 'تم الحفظ.', + 'settings.embeddings.errorPrefix': 'فشل', + 'settings.embeddings.wipeTitle': 'إعادة تعيين متجهات الذاكرة؟', + 'settings.embeddings.wipeBody': + 'سيؤدي تغيير مزود التضمينات أو النموذج أو الأبعاد إلى مسح جميع متجهات الذاكرة المخزنة. يجب إعادة بناء الذاكرة قبل أن يعمل الاسترجاع مرة أخرى. لا يمكن التراجع عن هذا.', + 'settings.embeddings.cancel': 'إلغاء', + 'settings.embeddings.confirmWipe': 'مسح وتطبيق', + 'settings.embeddings.setupTitle': 'إعداد {provider}', + 'settings.embeddings.saveAndSwitch': 'حفظ والتبديل', + 'settings.embeddings.optional': 'اختياري', + 'settings.embeddings.clearKey': 'مسح مفتاح API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index 40d65c787..b976c9d56 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -186,6 +186,8 @@ const ar4: TranslationMap = { 'pages.settings.ai.llmDesc': 'وصف LLM', 'pages.settings.ai.voice': 'الصوت', 'pages.settings.ai.voiceDesc': 'وصف الصوت', + 'pages.settings.ai.embeddings': 'التضمينات', + 'pages.settings.ai.embeddingsDesc': 'نموذج ترميز المتجهات لاسترجاع الذاكرة', 'pages.settings.aiSection.description': 'مزودو نماذج اللغة وOllama المحلي والصوت (STT / TTS).', 'pages.settings.aiSection.title': 'الذكاء الاصطناعي', 'pages.settings.features.desktopCompanion': 'الرفيق المكتبي', diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index caad72ca3..9f5b3ad7a 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -489,6 +489,42 @@ const bn1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'এমবেডিংস', + 'settings.embeddings.description': + 'কোন এমবেডিং প্রদানকারী মেমরিকে সিমান্টিক সার্চের জন্য ভেক্টরে রূপান্তর করে তা চয়ন করুন। প্রদানকারী, মডেল বা মাত্রা পরিবর্তন করলে সংরক্ষিত ভেক্টর অবৈধ হয়ে যায় এবং সম্পূর্ণ মেমরি রিসেট প্রয়োজন।', + 'settings.embeddings.providerAria': 'এমবেডিং প্রদানকারী', + 'settings.embeddings.statusConfigured': 'কনফিগার করা হয়েছে', + 'settings.embeddings.statusNeedsKey': 'API কী প্রয়োজন', + 'settings.embeddings.apiKeyLabel': '{provider} API কী', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'আপনার API কী পেস্ট করুন…', + 'settings.embeddings.keyStoredEncrypted': 'আপনার API কী এই ডিভাইসে এনক্রিপ্ট করে সংরক্ষিত আছে।', + 'settings.embeddings.show': 'দেখান', + 'settings.embeddings.hide': 'লুকান', + 'settings.embeddings.save': 'সংরক্ষণ', + 'settings.embeddings.clear': 'মুছুন', + 'settings.embeddings.model': 'মডেল', + 'settings.embeddings.dimensions': 'মাত্রা', + 'settings.embeddings.customEndpoint': 'কাস্টম এন্ডপয়েন্ট', + 'settings.embeddings.customModelPlaceholder': 'মডেলের নাম', + 'settings.embeddings.customDimsPlaceholder': 'মাত্রা', + 'settings.embeddings.applyCustom': 'প্রয়োগ', + 'settings.embeddings.testConnection': 'সংযোগ পরীক্ষা', + 'settings.embeddings.testing': 'পরীক্ষা হচ্ছে…', + 'settings.embeddings.testSuccess': 'সংযুক্ত — {dims} মাত্রা', + 'settings.embeddings.testFailed': 'ব্যর্থ: {error}', + 'settings.embeddings.saving': 'সংরক্ষণ হচ্ছে…', + 'settings.embeddings.saved': 'সংরক্ষিত।', + 'settings.embeddings.errorPrefix': 'ব্যর্থ', + 'settings.embeddings.wipeTitle': 'মেমরি ভেক্টর রিসেট করবেন?', + 'settings.embeddings.wipeBody': + 'এমবেডিং প্রদানকারী, মডেল বা মাত্রা পরিবর্তন করলে সমস্ত সংরক্ষিত মেমরি ভেক্টর মুছে যাবে। পুনরুদ্ধার কাজ করার আগে মেমরি পুনর্নির্মাণ করতে হবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।', + 'settings.embeddings.cancel': 'বাতিল', + 'settings.embeddings.confirmWipe': 'মুছুন এবং প্রয়োগ করুন', + 'settings.embeddings.setupTitle': '{provider} সেটআপ', + 'settings.embeddings.saveAndSwitch': 'সংরক্ষণ এবং স্যুইচ', + 'settings.embeddings.optional': 'ঐচ্ছিক', + 'settings.embeddings.clearKey': 'API কী মুছুন', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 6f2995f90..da105d21d 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -186,6 +186,8 @@ const bn4: TranslationMap = { 'pages.settings.ai.llmDesc': 'LLM বিবরণ', 'pages.settings.ai.voice': 'ভয়েস', 'pages.settings.ai.voiceDesc': 'ভয়েস বিবরণ', + 'pages.settings.ai.embeddings': 'এমবেডিংস', + 'pages.settings.ai.embeddingsDesc': 'মেমরি পুনরুদ্ধারের জন্য ভেক্টর এনকোডিং মডেল', 'pages.settings.aiSection.description': 'ল্যাঙ্গুয়েজ মডেল প্রোভাইডার, লোকাল Ollama, এবং ভয়েস (STT / TTS)।', 'pages.settings.aiSection.title': 'AI', diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index bc1d65960..0543c1f8d 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -499,6 +499,43 @@ const de1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Wählen Sie den Embedding-Anbieter, der Erinnerungen in Vektoren für die semantische Suche umwandelt. Das Ändern des Anbieters, Modells oder der Dimensionen macht gespeicherte Vektoren ungültig und erfordert einen vollständigen Speicher-Reset.', + 'settings.embeddings.providerAria': 'Embedding-Anbieter', + 'settings.embeddings.statusConfigured': 'Konfiguriert', + 'settings.embeddings.statusNeedsKey': 'API-Schlüssel benötigt', + 'settings.embeddings.apiKeyLabel': '{provider} API-Schlüssel', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'API-Schlüssel einfügen…', + 'settings.embeddings.keyStoredEncrypted': + 'Ihr API-Schlüssel wird verschlüsselt auf diesem Gerät gespeichert.', + 'settings.embeddings.show': 'Anzeigen', + 'settings.embeddings.hide': 'Verbergen', + 'settings.embeddings.save': 'Speichern', + 'settings.embeddings.clear': 'Löschen', + 'settings.embeddings.model': 'Modell', + 'settings.embeddings.dimensions': 'Dimensionen', + 'settings.embeddings.customEndpoint': 'Benutzerdefinierter Endpunkt', + 'settings.embeddings.customModelPlaceholder': 'Modellname', + 'settings.embeddings.customDimsPlaceholder': 'Dim.', + 'settings.embeddings.applyCustom': 'Anwenden', + 'settings.embeddings.testConnection': 'Verbindung testen', + 'settings.embeddings.testing': 'Wird getestet…', + 'settings.embeddings.testSuccess': 'Verbunden — {dims} Dimensionen', + 'settings.embeddings.testFailed': 'Fehlgeschlagen: {error}', + 'settings.embeddings.saving': 'Wird gespeichert…', + 'settings.embeddings.saved': 'Gespeichert.', + 'settings.embeddings.errorPrefix': 'Fehlgeschlagen', + 'settings.embeddings.wipeTitle': 'Speichervektoren zurücksetzen?', + 'settings.embeddings.wipeBody': + 'Das Ändern des Embedding-Anbieters, Modells oder der Dimensionen löscht alle gespeicherten Speichervektoren. Der Speicher muss neu aufgebaut werden, bevor die Abfrage wieder funktioniert. Dies kann nicht rückgängig gemacht werden.', + 'settings.embeddings.cancel': 'Abbrechen', + 'settings.embeddings.confirmWipe': 'Löschen & anwenden', + 'settings.embeddings.setupTitle': '{provider} einrichten', + 'settings.embeddings.saveAndSwitch': 'Speichern & wechseln', + 'settings.embeddings.optional': 'optional', + 'settings.embeddings.clearKey': 'API-Schlüssel löschen', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/de-4.ts b/app/src/lib/i18n/chunks/de-4.ts index 1a3ec4690..24ed94463 100644 --- a/app/src/lib/i18n/chunks/de-4.ts +++ b/app/src/lib/i18n/chunks/de-4.ts @@ -193,6 +193,8 @@ const de4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Llm absch', 'pages.settings.ai.voice': 'Stimme', 'pages.settings.ai.voiceDesc': 'Sprachbeschreibung', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': 'Vektorkodierungsmodell für den Speicherabruf', 'pages.settings.aiSection.description': 'Sprachmodellanbieter, lokal Ollama und Sprache (STT / TTS).', 'pages.settings.aiSection.title': 'AI', diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index ff664c76b..4099bcfb8 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -971,6 +971,42 @@ const en1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Choose which embedding provider converts memory into vectors for semantic search. Changing the provider, model, or dimensions invalidates stored vectors and requires a full memory reset.', + 'settings.embeddings.providerAria': 'Embedding provider', + 'settings.embeddings.statusConfigured': 'Configured', + 'settings.embeddings.statusNeedsKey': 'Needs API key', + 'settings.embeddings.apiKeyLabel': '{provider} API key', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Paste your API key…', + 'settings.embeddings.keyStoredEncrypted': 'Your API key is stored encrypted on this device.', + 'settings.embeddings.show': 'Show', + 'settings.embeddings.hide': 'Hide', + 'settings.embeddings.save': 'Save', + 'settings.embeddings.clear': 'Clear', + 'settings.embeddings.model': 'Model', + 'settings.embeddings.dimensions': 'Dimensions', + 'settings.embeddings.customEndpoint': 'Custom endpoint', + 'settings.embeddings.customModelPlaceholder': 'Model name', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Apply', + 'settings.embeddings.testConnection': 'Test connection', + 'settings.embeddings.testing': 'Testing…', + 'settings.embeddings.testSuccess': 'Connected — {dims} dimensions', + 'settings.embeddings.testFailed': 'Failed: {error}', + 'settings.embeddings.saving': 'Saving…', + 'settings.embeddings.saved': 'Saved.', + 'settings.embeddings.errorPrefix': 'Failed', + 'settings.embeddings.wipeTitle': 'Reset memory vectors?', + 'settings.embeddings.wipeBody': + 'Switching embedding provider, model, or dimensions will erase all stored memory vectors. Memory must be rebuilt before recall works again. This cannot be undone.', + 'settings.embeddings.cancel': 'Cancel', + 'settings.embeddings.confirmWipe': 'Wipe & apply', + 'settings.embeddings.setupTitle': 'Set up {provider}', + 'settings.embeddings.saveAndSwitch': 'Save & switch', + 'settings.embeddings.optional': 'optional', + 'settings.embeddings.clearKey': 'Clear API key', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index b1c058b21..3f1eb85f4 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -199,6 +199,8 @@ const en4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Llm desc', 'pages.settings.ai.voice': 'Voice', 'pages.settings.ai.voiceDesc': 'Voice desc', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': 'Vector encoding model for memory retrieval', 'pages.settings.aiSection.description': 'Language model providers, local Ollama, and voice (STT / TTS).', 'pages.settings.aiSection.title': 'AI', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index b835e6944..c8ff1192b 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -501,6 +501,42 @@ const es1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Elige qué proveedor de embeddings convierte la memoria en vectores para la búsqueda semántica. Cambiar el proveedor, modelo o dimensiones invalida los vectores almacenados y requiere un reinicio completo de la memoria.', + 'settings.embeddings.providerAria': 'Proveedor de embeddings', + 'settings.embeddings.statusConfigured': 'Configurado', + 'settings.embeddings.statusNeedsKey': 'Necesita clave API', + 'settings.embeddings.apiKeyLabel': 'Clave API de {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Pega tu clave API…', + 'settings.embeddings.keyStoredEncrypted': 'Tu clave API se almacena cifrada en este dispositivo.', + 'settings.embeddings.show': 'Mostrar', + 'settings.embeddings.hide': 'Ocultar', + 'settings.embeddings.save': 'Guardar', + 'settings.embeddings.clear': 'Borrar', + 'settings.embeddings.model': 'Modelo', + 'settings.embeddings.dimensions': 'Dimensiones', + 'settings.embeddings.customEndpoint': 'Endpoint personalizado', + 'settings.embeddings.customModelPlaceholder': 'Nombre del modelo', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Aplicar', + 'settings.embeddings.testConnection': 'Probar conexión', + 'settings.embeddings.testing': 'Probando…', + 'settings.embeddings.testSuccess': 'Conectado — {dims} dimensiones', + 'settings.embeddings.testFailed': 'Fallido: {error}', + 'settings.embeddings.saving': 'Guardando…', + 'settings.embeddings.saved': 'Guardado.', + 'settings.embeddings.errorPrefix': 'Fallido', + 'settings.embeddings.wipeTitle': '¿Reiniciar vectores de memoria?', + 'settings.embeddings.wipeBody': + 'Cambiar el proveedor de embeddings, modelo o dimensiones borrará todos los vectores de memoria almacenados. La memoria debe reconstruirse antes de que la recuperación funcione de nuevo. Esto no se puede deshacer.', + 'settings.embeddings.cancel': 'Cancelar', + 'settings.embeddings.confirmWipe': 'Borrar y aplicar', + 'settings.embeddings.setupTitle': 'Configurar {provider}', + 'settings.embeddings.saveAndSwitch': 'Guardar y cambiar', + 'settings.embeddings.optional': 'opcional', + 'settings.embeddings.clearKey': 'Borrar clave API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index 8dfd5fc6e..dc6da1165 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -188,6 +188,9 @@ const es4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Descripción de LLM', 'pages.settings.ai.voice': 'Voz', 'pages.settings.ai.voiceDesc': 'Descripción de voz', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': + 'Modelo de codificación vectorial para recuperación de memoria', 'pages.settings.aiSection.description': 'Proveedores de modelos de lenguaje, Ollama local y voz (STT / TTS).', 'pages.settings.aiSection.title': 'IA', diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index 21ceb8d4f..268a9d9b5 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -503,6 +503,43 @@ const fr1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + "Choisissez le fournisseur d'embeddings qui convertit la mémoire en vecteurs pour la recherche sémantique. Changer le fournisseur, le modèle ou les dimensions invalide les vecteurs stockés et nécessite une réinitialisation complète de la mémoire.", + 'settings.embeddings.providerAria': "Fournisseur d'embeddings", + 'settings.embeddings.statusConfigured': 'Configuré', + 'settings.embeddings.statusNeedsKey': 'Clé API requise', + 'settings.embeddings.apiKeyLabel': 'Clé API {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Collez votre clé API…', + 'settings.embeddings.keyStoredEncrypted': + 'Votre clé API est stockée de manière chiffrée sur cet appareil.', + 'settings.embeddings.show': 'Afficher', + 'settings.embeddings.hide': 'Masquer', + 'settings.embeddings.save': 'Enregistrer', + 'settings.embeddings.clear': 'Effacer', + 'settings.embeddings.model': 'Modèle', + 'settings.embeddings.dimensions': 'Dimensions', + 'settings.embeddings.customEndpoint': 'Point de terminaison personnalisé', + 'settings.embeddings.customModelPlaceholder': 'Nom du modèle', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Appliquer', + 'settings.embeddings.testConnection': 'Tester la connexion', + 'settings.embeddings.testing': 'Test en cours…', + 'settings.embeddings.testSuccess': 'Connecté — {dims} dimensions', + 'settings.embeddings.testFailed': 'Échec : {error}', + 'settings.embeddings.saving': 'Enregistrement…', + 'settings.embeddings.saved': 'Enregistré.', + 'settings.embeddings.errorPrefix': 'Échec', + 'settings.embeddings.wipeTitle': 'Réinitialiser les vecteurs mémoire ?', + 'settings.embeddings.wipeBody': + "Changer le fournisseur d'embeddings, le modèle ou les dimensions effacera tous les vecteurs mémoire stockés. La mémoire doit être reconstruite avant que la récupération ne fonctionne à nouveau. Cette action est irréversible.", + 'settings.embeddings.cancel': 'Annuler', + 'settings.embeddings.confirmWipe': 'Effacer et appliquer', + 'settings.embeddings.setupTitle': 'Configurer {provider}', + 'settings.embeddings.saveAndSwitch': 'Enregistrer et changer', + 'settings.embeddings.optional': 'optionnel', + 'settings.embeddings.clearKey': 'Effacer la clé API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index 2c514c271..a7dfc4061 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -188,6 +188,9 @@ const fr4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Description du LLM', 'pages.settings.ai.voice': 'Voix', 'pages.settings.ai.voiceDesc': 'Description de la voix', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': + "Modèle d'encodage vectoriel pour la récupération de la mémoire", 'pages.settings.aiSection.description': 'Fournisseurs de modèles de langage, Ollama local et voix (STT / TTS).', 'pages.settings.aiSection.title': 'IA', diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index e92dd8645..45b2938e1 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -486,6 +486,43 @@ const hi1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'एम्बेडिंग्स', + 'settings.embeddings.description': + 'चुनें कि कौन सा एम्बेडिंग प्रदाता मेमोरी को सिमेंटिक सर्च के लिए वेक्टर में बदलता है। प्रदाता, मॉडल या आयाम बदलने से संग्रहीत वेक्टर अमान्य हो जाते हैं और पूर्ण मेमरी रीसेट की आवश्यकता होती है।', + 'settings.embeddings.providerAria': 'एम्बेडिंग प्रदाता', + 'settings.embeddings.statusConfigured': 'कॉन्फ़िगर किया गया', + 'settings.embeddings.statusNeedsKey': 'API कुंजी चाहिए', + 'settings.embeddings.apiKeyLabel': '{provider} API कुंजी', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'अपनी API कुंजी पेस्ट करें…', + 'settings.embeddings.keyStoredEncrypted': + 'आपकी API कुंजी इस डिवाइस पर एन्क्रिप्टेड स्टोर की गई है।', + 'settings.embeddings.show': 'दिखाएँ', + 'settings.embeddings.hide': 'छिपाएँ', + 'settings.embeddings.save': 'सहेजें', + 'settings.embeddings.clear': 'साफ़ करें', + 'settings.embeddings.model': 'मॉडल', + 'settings.embeddings.dimensions': 'आयाम', + 'settings.embeddings.customEndpoint': 'कस्टम एंडपॉइंट', + 'settings.embeddings.customModelPlaceholder': 'मॉडल का नाम', + 'settings.embeddings.customDimsPlaceholder': 'आयाम', + 'settings.embeddings.applyCustom': 'लागू करें', + 'settings.embeddings.testConnection': 'कनेक्शन परीक्षण', + 'settings.embeddings.testing': 'परीक्षण हो रहा है…', + 'settings.embeddings.testSuccess': 'कनेक्ट — {dims} आयाम', + 'settings.embeddings.testFailed': 'विफल: {error}', + 'settings.embeddings.saving': 'सहेजा जा रहा है…', + 'settings.embeddings.saved': 'सहेजा गया।', + 'settings.embeddings.errorPrefix': 'विफल', + 'settings.embeddings.wipeTitle': 'मेमोरी वेक्टर रीसेट करें?', + 'settings.embeddings.wipeBody': + 'एम्बेडिंग प्रदाता, मॉडल या आयाम बदलने से सभी संग्रहीत मेमोरी वेक्टर मिट जाएंगे। पुनर्प्राप्ति फिर से काम करने से पहले मेमोरी को फिर से बनाना होगा। यह पूर्ववत नहीं किया जा सकता।', + 'settings.embeddings.cancel': 'रद्द करें', + 'settings.embeddings.confirmWipe': 'मिटाएँ और लागू करें', + 'settings.embeddings.setupTitle': '{provider} सेटअप', + 'settings.embeddings.saveAndSwitch': 'सहेजें और बदलें', + 'settings.embeddings.optional': 'वैकल्पिक', + 'settings.embeddings.clearKey': 'API कुंजी साफ़ करें', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 3448385c4..edda02c97 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -187,6 +187,8 @@ const hi4: TranslationMap = { 'pages.settings.ai.llmDesc': 'LLM विवरण', 'pages.settings.ai.voice': 'वॉइस', 'pages.settings.ai.voiceDesc': 'वॉइस विवरण', + 'pages.settings.ai.embeddings': 'एम्बेडिंग्स', + 'pages.settings.ai.embeddingsDesc': 'मेमोरी पुनर्प्राप्ति के लिए वेक्टर एन्कोडिंग मॉडल', 'pages.settings.aiSection.description': 'लैंग्वेज मॉडल प्रोवाइडर, लोकल Ollama और वॉइस (STT / TTS)।', 'pages.settings.aiSection.title': 'AI', diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index 391cb32e0..62ad90bbc 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -492,6 +492,42 @@ const id1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Pilih penyedia embedding yang mengubah memori menjadi vektor untuk pencarian semantik. Mengubah penyedia, model, atau dimensi membatalkan vektor yang tersimpan dan memerlukan reset memori penuh.', + 'settings.embeddings.providerAria': 'Penyedia embedding', + 'settings.embeddings.statusConfigured': 'Dikonfigurasi', + 'settings.embeddings.statusNeedsKey': 'Perlu kunci API', + 'settings.embeddings.apiKeyLabel': 'Kunci API {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Tempel kunci API Anda…', + 'settings.embeddings.keyStoredEncrypted': 'Kunci API Anda disimpan terenkripsi di perangkat ini.', + 'settings.embeddings.show': 'Tampilkan', + 'settings.embeddings.hide': 'Sembunyikan', + 'settings.embeddings.save': 'Simpan', + 'settings.embeddings.clear': 'Hapus', + 'settings.embeddings.model': 'Model', + 'settings.embeddings.dimensions': 'Dimensi', + 'settings.embeddings.customEndpoint': 'Endpoint kustom', + 'settings.embeddings.customModelPlaceholder': 'Nama model', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Terapkan', + 'settings.embeddings.testConnection': 'Uji koneksi', + 'settings.embeddings.testing': 'Menguji…', + 'settings.embeddings.testSuccess': 'Terhubung — {dims} dimensi', + 'settings.embeddings.testFailed': 'Gagal: {error}', + 'settings.embeddings.saving': 'Menyimpan…', + 'settings.embeddings.saved': 'Tersimpan.', + 'settings.embeddings.errorPrefix': 'Gagal', + 'settings.embeddings.wipeTitle': 'Reset vektor memori?', + 'settings.embeddings.wipeBody': + 'Mengubah penyedia embedding, model, atau dimensi akan menghapus semua vektor memori yang tersimpan. Memori harus dibangun ulang sebelum pencarian berfungsi kembali. Ini tidak dapat dibatalkan.', + 'settings.embeddings.cancel': 'Batal', + 'settings.embeddings.confirmWipe': 'Hapus & terapkan', + 'settings.embeddings.setupTitle': 'Siapkan {provider}', + 'settings.embeddings.saveAndSwitch': 'Simpan & ganti', + 'settings.embeddings.optional': 'opsional', + 'settings.embeddings.clearKey': 'Hapus kunci API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index 0d54339ff..b4c1af7b7 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -188,6 +188,8 @@ const id4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Deskripsi LLM', 'pages.settings.ai.voice': 'Suara', 'pages.settings.ai.voiceDesc': 'Deskripsi suara', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': 'Model encoding vektor untuk pengambilan memori', 'pages.settings.aiSection.description': 'Penyedia model bahasa, Ollama lokal, dan suara (STT / TTS).', 'pages.settings.aiSection.title': 'AI', diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index e90953786..e765e158d 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -496,6 +496,43 @@ const it1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Scegli il fornitore di embeddings che converte la memoria in vettori per la ricerca semantica. Cambiare fornitore, modello o dimensioni invalida i vettori memorizzati e richiede un reset completo della memoria.', + 'settings.embeddings.providerAria': 'Fornitore di embeddings', + 'settings.embeddings.statusConfigured': 'Configurato', + 'settings.embeddings.statusNeedsKey': 'Chiave API necessaria', + 'settings.embeddings.apiKeyLabel': 'Chiave API {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Incolla la tua chiave API…', + 'settings.embeddings.keyStoredEncrypted': + 'La tua chiave API è memorizzata crittografata su questo dispositivo.', + 'settings.embeddings.show': 'Mostra', + 'settings.embeddings.hide': 'Nascondi', + 'settings.embeddings.save': 'Salva', + 'settings.embeddings.clear': 'Cancella', + 'settings.embeddings.model': 'Modello', + 'settings.embeddings.dimensions': 'Dimensioni', + 'settings.embeddings.customEndpoint': 'Endpoint personalizzato', + 'settings.embeddings.customModelPlaceholder': 'Nome modello', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Applica', + 'settings.embeddings.testConnection': 'Testa connessione', + 'settings.embeddings.testing': 'Test in corso…', + 'settings.embeddings.testSuccess': 'Connesso — {dims} dimensioni', + 'settings.embeddings.testFailed': 'Fallito: {error}', + 'settings.embeddings.saving': 'Salvataggio…', + 'settings.embeddings.saved': 'Salvato.', + 'settings.embeddings.errorPrefix': 'Fallito', + 'settings.embeddings.wipeTitle': 'Resettare i vettori della memoria?', + 'settings.embeddings.wipeBody': + 'Cambiare il fornitore di embeddings, il modello o le dimensioni cancellerà tutti i vettori della memoria. La memoria deve essere ricostruita prima che il recupero funzioni di nuovo. Questa operazione non può essere annullata.', + 'settings.embeddings.cancel': 'Annulla', + 'settings.embeddings.confirmWipe': 'Cancella e applica', + 'settings.embeddings.setupTitle': 'Configura {provider}', + 'settings.embeddings.saveAndSwitch': 'Salva e cambia', + 'settings.embeddings.optional': 'opzionale', + 'settings.embeddings.clearKey': 'Cancella chiave API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index 119919e2e..1f4671580 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -188,6 +188,9 @@ const it4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Descrizione LLM', 'pages.settings.ai.voice': 'Voce', 'pages.settings.ai.voiceDesc': 'Descrizione voce', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': + 'Modello di codifica vettoriale per il recupero della memoria', 'pages.settings.aiSection.description': 'Provider di modelli linguistici, Ollama locale e voce (STT / TTS).', 'pages.settings.aiSection.title': 'AI', diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 215a5f59d..ddc3448b0 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -489,6 +489,42 @@ const ko1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': '임베딩', + 'settings.embeddings.description': + '시맨틱 검색을 위해 메모리를 벡터로 변환할 임베딩 제공자를 선택하세요. 제공자, 모델 또는 차원을 변경하면 저장된 벡터가 무효화되며 전체 메모리 초기화가 필요합니다.', + 'settings.embeddings.providerAria': '임베딩 제공자', + 'settings.embeddings.statusConfigured': '구성됨', + 'settings.embeddings.statusNeedsKey': 'API 키 필요', + 'settings.embeddings.apiKeyLabel': '{provider} API 키', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'API 키를 붙여넣으세요…', + 'settings.embeddings.keyStoredEncrypted': 'API 키는 이 기기에 암호화되어 저장됩니다.', + 'settings.embeddings.show': '표시', + 'settings.embeddings.hide': '숨기기', + 'settings.embeddings.save': '저장', + 'settings.embeddings.clear': '지우기', + 'settings.embeddings.model': '모델', + 'settings.embeddings.dimensions': '차원', + 'settings.embeddings.customEndpoint': '사용자 정의 엔드포인트', + 'settings.embeddings.customModelPlaceholder': '모델 이름', + 'settings.embeddings.customDimsPlaceholder': '차원', + 'settings.embeddings.applyCustom': '적용', + 'settings.embeddings.testConnection': '연결 테스트', + 'settings.embeddings.testing': '테스트 중…', + 'settings.embeddings.testSuccess': '연결됨 — {dims} 차원', + 'settings.embeddings.testFailed': '실패: {error}', + 'settings.embeddings.saving': '저장 중…', + 'settings.embeddings.saved': '저장됨.', + 'settings.embeddings.errorPrefix': '실패', + 'settings.embeddings.wipeTitle': '메모리 벡터를 초기화하시겠습니까?', + 'settings.embeddings.wipeBody': + '임베딩 제공자, 모델 또는 차원을 변경하면 저장된 모든 메모리 벡터가 삭제됩니다. 검색이 다시 작동하려면 메모리를 재구축해야 합니다. 이 작업은 취소할 수 없습니다.', + 'settings.embeddings.cancel': '취소', + 'settings.embeddings.confirmWipe': '삭제 및 적용', + 'settings.embeddings.setupTitle': '{provider} 설정', + 'settings.embeddings.saveAndSwitch': '저장 및 전환', + 'settings.embeddings.optional': '선택사항', + 'settings.embeddings.clearKey': 'API 키 삭제', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/ko-4.ts b/app/src/lib/i18n/chunks/ko-4.ts index 47cb91a41..6569a30f6 100644 --- a/app/src/lib/i18n/chunks/ko-4.ts +++ b/app/src/lib/i18n/chunks/ko-4.ts @@ -165,6 +165,8 @@ const ko4: TranslationMap = { 'pages.settings.ai.llmDesc': 'LLM 설명', 'pages.settings.ai.voice': '음성', 'pages.settings.ai.voiceDesc': '음성 설명', + 'pages.settings.ai.embeddings': '임베딩', + 'pages.settings.ai.embeddingsDesc': '메모리 검색을 위한 벡터 인코딩 모델', 'pages.settings.aiSection.description': '언어 모델 제공업체, 로컬 Ollama 및 음성(STT / TTS).', 'pages.settings.aiSection.title': 'AI', 'pages.settings.features.messagingChannels': '메시징 채널', diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index 242109a74..612691191 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -501,6 +501,43 @@ const pt1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Escolha qual provedor de embeddings converte memória em vetores para busca semântica. Alterar o provedor, modelo ou dimensões invalida vetores armazenados e requer uma redefinição completa da memória.', + 'settings.embeddings.providerAria': 'Provedor de embeddings', + 'settings.embeddings.statusConfigured': 'Configurado', + 'settings.embeddings.statusNeedsKey': 'Precisa de chave API', + 'settings.embeddings.apiKeyLabel': 'Chave API {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Cole sua chave API…', + 'settings.embeddings.keyStoredEncrypted': + 'Sua chave API é armazenada criptografada neste dispositivo.', + 'settings.embeddings.show': 'Mostrar', + 'settings.embeddings.hide': 'Ocultar', + 'settings.embeddings.save': 'Salvar', + 'settings.embeddings.clear': 'Limpar', + 'settings.embeddings.model': 'Modelo', + 'settings.embeddings.dimensions': 'Dimensões', + 'settings.embeddings.customEndpoint': 'Endpoint personalizado', + 'settings.embeddings.customModelPlaceholder': 'Nome do modelo', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Aplicar', + 'settings.embeddings.testConnection': 'Testar conexão', + 'settings.embeddings.testing': 'Testando…', + 'settings.embeddings.testSuccess': 'Conectado — {dims} dimensões', + 'settings.embeddings.testFailed': 'Falhou: {error}', + 'settings.embeddings.saving': 'Salvando…', + 'settings.embeddings.saved': 'Salvo.', + 'settings.embeddings.errorPrefix': 'Falhou', + 'settings.embeddings.wipeTitle': 'Redefinir vetores de memória?', + 'settings.embeddings.wipeBody': + 'Alterar o provedor de embeddings, modelo ou dimensões apagará todos os vetores de memória armazenados. A memória deve ser reconstruída antes que a recuperação funcione novamente. Isso não pode ser desfeito.', + 'settings.embeddings.cancel': 'Cancelar', + 'settings.embeddings.confirmWipe': 'Limpar e aplicar', + 'settings.embeddings.setupTitle': 'Configurar {provider}', + 'settings.embeddings.saveAndSwitch': 'Salvar e trocar', + 'settings.embeddings.optional': 'opcional', + 'settings.embeddings.clearKey': 'Limpar chave API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index c2b46b565..8a0cb3997 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -189,6 +189,8 @@ const pt4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Descrição do LLM', 'pages.settings.ai.voice': 'Voz', 'pages.settings.ai.voiceDesc': 'Descrição de voz', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': 'Modelo de codificação vetorial para recuperação de memória', 'pages.settings.aiSection.description': 'Provedores de modelos de linguagem, Ollama local e voz (STT / TTS).', 'pages.settings.aiSection.title': 'IA', diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index 6b6b02fd2..3f4c9d158 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -491,6 +491,43 @@ const ru1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': 'Эмбеддинги', + 'settings.embeddings.description': + 'Выберите провайдера эмбеддингов, который преобразует память в векторы для семантического поиска. Изменение провайдера, модели или размерности делает сохранённые векторы недействительными и требует полного сброса памяти.', + 'settings.embeddings.providerAria': 'Провайдер эмбеддингов', + 'settings.embeddings.statusConfigured': 'Настроено', + 'settings.embeddings.statusNeedsKey': 'Нужен API-ключ', + 'settings.embeddings.apiKeyLabel': 'API-ключ {provider}', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Вставьте API-ключ…', + 'settings.embeddings.keyStoredEncrypted': + 'Ваш API-ключ хранится в зашифрованном виде на этом устройстве.', + 'settings.embeddings.show': 'Показать', + 'settings.embeddings.hide': 'Скрыть', + 'settings.embeddings.save': 'Сохранить', + 'settings.embeddings.clear': 'Очистить', + 'settings.embeddings.model': 'Модель', + 'settings.embeddings.dimensions': 'Размерность', + 'settings.embeddings.customEndpoint': 'Пользовательский эндпоинт', + 'settings.embeddings.customModelPlaceholder': 'Название модели', + 'settings.embeddings.customDimsPlaceholder': 'Разм.', + 'settings.embeddings.applyCustom': 'Применить', + 'settings.embeddings.testConnection': 'Проверить подключение', + 'settings.embeddings.testing': 'Проверка…', + 'settings.embeddings.testSuccess': 'Подключено — {dims} измерений', + 'settings.embeddings.testFailed': 'Ошибка: {error}', + 'settings.embeddings.saving': 'Сохранение…', + 'settings.embeddings.saved': 'Сохранено.', + 'settings.embeddings.errorPrefix': 'Ошибка', + 'settings.embeddings.wipeTitle': 'Сбросить векторы памяти?', + 'settings.embeddings.wipeBody': + 'Изменение провайдера эмбеддингов, модели или размерности удалит все сохранённые векторы памяти. Память должна быть перестроена, прежде чем поиск снова заработает. Это действие нельзя отменить.', + 'settings.embeddings.cancel': 'Отмена', + 'settings.embeddings.confirmWipe': 'Очистить и применить', + 'settings.embeddings.setupTitle': 'Настройка {provider}', + 'settings.embeddings.saveAndSwitch': 'Сохранить и переключить', + 'settings.embeddings.optional': 'необязательно', + 'settings.embeddings.clearKey': 'Удалить API-ключ', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index 244997a7d..6a0e8649a 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -187,6 +187,8 @@ const ru4: TranslationMap = { 'pages.settings.ai.llmDesc': 'Описание LLM', 'pages.settings.ai.voice': 'Голос', 'pages.settings.ai.voiceDesc': 'Описание голоса', + 'pages.settings.ai.embeddings': 'Эмбеддинги', + 'pages.settings.ai.embeddingsDesc': 'Модель векторного кодирования для извлечения из памяти', 'pages.settings.aiSection.description': 'Языковые модели, локальный Ollama и голос (STT / TTS).', 'pages.settings.aiSection.title': 'ИИ', 'pages.settings.features.desktopCompanion': 'Десктоп-спутник', diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 2b8e769a5..12946fa33 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -473,6 +473,42 @@ const zhCN1: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + 'settings.embeddings.title': '向量嵌入', + 'settings.embeddings.description': + '选择将记忆转换为语义搜索向量的嵌入提供商。更改提供商、模型或维度会使已存储的向量无效,需要完全重置记忆。', + 'settings.embeddings.providerAria': '嵌入提供商', + 'settings.embeddings.statusConfigured': '已配置', + 'settings.embeddings.statusNeedsKey': '需要 API 密钥', + 'settings.embeddings.apiKeyLabel': '{provider} API 密钥', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': '粘贴您的 API 密钥…', + 'settings.embeddings.keyStoredEncrypted': '您的 API 密钥已加密存储在此设备上。', + 'settings.embeddings.show': '显示', + 'settings.embeddings.hide': '隐藏', + 'settings.embeddings.save': '保存', + 'settings.embeddings.clear': '清除', + 'settings.embeddings.model': '模型', + 'settings.embeddings.dimensions': '维度', + 'settings.embeddings.customEndpoint': '自定义端点', + 'settings.embeddings.customModelPlaceholder': '模型名称', + 'settings.embeddings.customDimsPlaceholder': '维度', + 'settings.embeddings.applyCustom': '应用', + 'settings.embeddings.testConnection': '测试连接', + 'settings.embeddings.testing': '测试中…', + 'settings.embeddings.testSuccess': '已连接 — {dims} 维度', + 'settings.embeddings.testFailed': '失败:{error}', + 'settings.embeddings.saving': '保存中…', + 'settings.embeddings.saved': '已保存。', + 'settings.embeddings.errorPrefix': '失败', + 'settings.embeddings.wipeTitle': '重置记忆向量?', + 'settings.embeddings.wipeBody': + '更改嵌入提供商、模型或维度将删除所有已存储的记忆向量。记忆必须重新构建后检索才能再次工作。此操作无法撤消。', + 'settings.embeddings.cancel': '取消', + 'settings.embeddings.confirmWipe': '清除并应用', + 'settings.embeddings.setupTitle': '设置 {provider}', + 'settings.embeddings.saveAndSwitch': '保存并切换', + 'settings.embeddings.optional': '可选', + 'settings.embeddings.clearKey': '清除 API 密钥', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index dc51816ba..6b77130f7 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -182,6 +182,8 @@ const zhCN4: TranslationMap = { 'pages.settings.ai.llmDesc': '选择并配置语言模型提供商', 'pages.settings.ai.voice': '语音', 'pages.settings.ai.voiceDesc': '配置语音输入和输出', + 'pages.settings.ai.embeddings': '向量嵌入', + 'pages.settings.ai.embeddingsDesc': '用于记忆检索的向量编码模型', 'pages.settings.aiSection.description': '语言模型提供商、本地 Ollama 以及语音(STT / TTS)。', 'pages.settings.aiSection.title': 'AI', 'pages.settings.features.desktopCompanion': '桌面伴侣', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 05b1e01c2..fc998ee96 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -618,6 +618,46 @@ const en: TranslationMap = { 'settings.search.placeholderStored': '•••••••• (stored)', 'settings.search.placeholderParallel': 'pk_...', 'settings.search.placeholderBrave': 'BSA...', + // ─── Embeddings settings ─────────────────────────────────── + 'settings.embeddings.title': 'Embeddings', + 'settings.embeddings.description': + 'Choose which embedding provider converts memory into vectors for semantic search. Changing the provider, model, or dimensions invalidates stored vectors and requires a full memory reset.', + 'settings.embeddings.providerAria': 'Embedding provider', + 'settings.embeddings.statusConfigured': 'Configured', + 'settings.embeddings.statusNeedsKey': 'Needs API key', + 'settings.embeddings.apiKeyLabel': '{provider} API key', + 'settings.embeddings.placeholderStored': '•••••••• (stored)', + 'settings.embeddings.placeholderKey': 'Paste your API key…', + 'settings.embeddings.keyStoredEncrypted': 'Your API key is stored encrypted on this device.', + 'settings.embeddings.show': 'Show', + 'settings.embeddings.hide': 'Hide', + 'settings.embeddings.save': 'Save', + 'settings.embeddings.clear': 'Clear', + 'settings.embeddings.model': 'Model', + 'settings.embeddings.dimensions': 'Dimensions', + 'settings.embeddings.customEndpoint': 'Custom endpoint', + 'settings.embeddings.customModelPlaceholder': 'Model name', + 'settings.embeddings.customDimsPlaceholder': 'Dims', + 'settings.embeddings.applyCustom': 'Apply', + 'settings.embeddings.testConnection': 'Test connection', + 'settings.embeddings.testing': 'Testing…', + 'settings.embeddings.testSuccess': 'Connected — {dims} dimensions', + 'settings.embeddings.testFailed': 'Failed: {error}', + 'settings.embeddings.saving': 'Saving…', + 'settings.embeddings.saved': 'Saved.', + 'settings.embeddings.errorPrefix': 'Failed', + 'settings.embeddings.wipeTitle': 'Reset memory vectors?', + 'settings.embeddings.wipeBody': + 'Switching embedding provider, model, or dimensions will erase all stored memory vectors. Memory must be rebuilt before recall works again. This cannot be undone.', + 'settings.embeddings.cancel': 'Cancel', + 'settings.embeddings.confirmWipe': 'Wipe & apply', + 'settings.embeddings.setupTitle': 'Set up {provider}', + 'settings.embeddings.saveAndSwitch': 'Save & switch', + 'settings.embeddings.optional': 'optional', + 'settings.embeddings.clearKey': 'Clear API key', + 'pages.settings.ai.embeddings': 'Embeddings', + 'pages.settings.ai.embeddingsDesc': 'Vector encoding model for memory retrieval', + 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': 'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.', diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index bc944d4a8..f8a756a40 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -16,6 +16,7 @@ import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePan import CronJobsPanel from '../components/settings/panels/CronJobsPanel'; import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel'; import DevicesComingSoonPanel from '../components/settings/panels/DevicesComingSoonPanel'; +import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel'; import HeartbeatPanel from '../components/settings/panels/HeartbeatPanel'; import LedgerUsagePanel from '../components/settings/panels/LedgerUsagePanel'; import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel'; @@ -270,6 +271,13 @@ const Settings = () => { route: 'llm', icon: LlmIcon, }, + { + id: 'embeddings', + title: t('pages.settings.ai.embeddings'), + description: t('pages.settings.ai.embeddingsDesc'), + route: 'embeddings', + icon: LlmIcon, + }, { id: 'voice', title: t('pages.settings.ai.voice'), @@ -415,6 +423,7 @@ const Settings = () => { element={} /> , { maxWidthClass: 'max-w-4xl' })} /> + )} /> , { maxWidthClass: 'max-w-4xl' })} diff --git a/app/src/services/__tests__/rpcMethods.test.ts b/app/src/services/__tests__/rpcMethods.test.ts index 581cf5855..b9f03846c 100644 --- a/app/src/services/__tests__/rpcMethods.test.ts +++ b/app/src/services/__tests__/rpcMethods.test.ts @@ -71,6 +71,10 @@ describe('rpcMethods catalog', () => { path.resolve(__dirname, '../../../../src/openhuman/inference/schemas.rs'), 'utf8' ), + fs.readFileSync( + path.resolve(__dirname, '../../../../src/openhuman/embeddings/schemas.rs'), + 'utf8' + ), ].join('\n'); for (const method of Object.values(CORE_RPC_METHODS)) { @@ -81,9 +85,11 @@ describe('rpcMethods catalog', () => { ? 'screen_intelligence' : methodRoot.startsWith('inference_') ? 'inference' - : methodRoot.startsWith('providers_') - ? 'providers' - : 'config'; + : methodRoot.startsWith('embeddings_') + ? 'embeddings' + : methodRoot.startsWith('providers_') + ? 'providers' + : 'config'; const fnName = methodRoot.slice(`${namespace}_`.length); expect(schemaSources).toContain(`namespace: "${namespace}"`); expect(schemaSources).toContain(`function: "${fnName}"`); diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index 845eb15d3..7094c8520 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -554,7 +554,7 @@ describe('saveAISettings', () => { agentic: { kind: 'openhuman' }, coding: { kind: 'openhuman' }, memory: { kind: 'openhuman' }, - embeddings: { kind: 'openhuman' }, + heartbeat: { kind: 'openhuman' }, learning: { kind: 'openhuman' }, subconscious: { kind: 'openhuman' }, @@ -614,7 +614,7 @@ describe('saveAISettings', () => { agentic: { kind: 'openhuman' }, coding: { kind: 'openhuman' }, memory: { kind: 'openhuman' }, - embeddings: { kind: 'openhuman' }, + heartbeat: { kind: 'openhuman' }, learning: { kind: 'openhuman' }, subconscious: { kind: 'openhuman' }, diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index b80b7bbdc..0c65599e2 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -50,7 +50,6 @@ export type WorkloadId = | 'agentic' | 'coding' | 'memory' - | 'embeddings' | 'heartbeat' | 'learning' | 'subconscious'; @@ -58,7 +57,6 @@ export type WorkloadId = export const CHAT_WORKLOADS: WorkloadId[] = ['chat', 'reasoning', 'agentic', 'coding']; export const BACKGROUND_WORKLOADS: WorkloadId[] = [ 'memory', - 'embeddings', 'heartbeat', 'learning', 'subconscious', @@ -223,7 +221,6 @@ export async function loadAISettings(): Promise { agentic: parseProviderString(config.agentic_provider), coding: parseProviderString(config.coding_provider), memory: parseProviderString(config.memory_provider), - embeddings: parseProviderString(config.embeddings_provider), heartbeat: parseProviderString(config.heartbeat_provider), learning: parseProviderString(config.learning_provider), subconscious: parseProviderString(config.subconscious_provider), diff --git a/app/src/services/api/embeddingsApi.ts b/app/src/services/api/embeddingsApi.ts new file mode 100644 index 000000000..bb5052ce9 --- /dev/null +++ b/app/src/services/api/embeddingsApi.ts @@ -0,0 +1,104 @@ +/** + * Embeddings settings API — facade for the Settings → Embeddings panel. + * + * Wraps the `openhuman.embeddings_*` RPC methods. The panel never imports + * `coreRpcClient` directly — every call goes through this file. + */ +import { callCoreRpc } from '../coreRpcClient'; +import { CORE_RPC_METHODS } from '../rpcMethods'; + +// ─── Domain types ──────────────────────────────────────────────────────────── + +export interface EmbeddingModelPreset { + id: string; + label: string; + default_dimensions: number; + allowed_dimensions: number[]; +} + +export interface EmbeddingProviderEntry { + slug: string; + label: string; + description: string; + requires_api_key: boolean; + requires_endpoint: boolean; + has_api_key: boolean; + models: EmbeddingModelPreset[]; +} + +export interface EmbeddingsSettings { + provider: string; + model: string; + dimensions: number; + rate_limit_per_min: number; + providers: EmbeddingProviderEntry[]; +} + +export interface EmbeddingsUpdateResult { + provider?: string; + model?: string; + dimensions?: number; + signature_changed?: boolean; + new_signature?: string; + /** Present when confirm_wipe was required but not supplied */ + error?: string; + message?: string; + old_signature?: string; +} + +export interface EmbeddingsTestResult { + success: boolean; + provider: string; + model: string; + requested_dimensions?: number; + actual_dimensions?: number; + error?: string; +} + +// ─── API calls ─────────────────────────────────────────────────────────────── + +export async function loadEmbeddingsSettings(): Promise { + const raw = await callCoreRpc({ + method: CORE_RPC_METHODS.embeddingsGetSettings, + params: {}, + }); + return 'result' in raw ? raw.result : raw; +} + +export async function updateEmbeddingsSettings(params: { + provider?: string; + model?: string; + dimensions?: number; + custom_endpoint?: string; + rate_limit_per_min?: number; + confirm_wipe?: boolean; +}): Promise { + const raw = await callCoreRpc({ + method: CORE_RPC_METHODS.embeddingsUpdateSettings, + params, + }); + return 'result' in raw ? raw.result : raw; +} + +export async function setEmbeddingsApiKey(provider: string, apiKey: string): Promise { + await callCoreRpc({ + method: CORE_RPC_METHODS.embeddingsSetApiKey, + params: { provider, api_key: apiKey }, + }); +} + +export async function clearEmbeddingsApiKey(provider: string): Promise { + await callCoreRpc({ method: CORE_RPC_METHODS.embeddingsClearApiKey, params: { provider } }); +} + +export async function testEmbeddingsConnection(params?: { + provider?: string; + model?: string; + dimensions?: number; +}): Promise { + const raw = await callCoreRpc({ + method: CORE_RPC_METHODS.embeddingsTestConnection, + params: params ?? {}, + }); + return 'result' in raw ? raw.result : raw; +} diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index f84300f33..faca15fb8 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -29,6 +29,12 @@ export const CORE_RPC_METHODS = { inferenceUpdateModelSettings: 'openhuman.inference_update_model_settings', providersListModels: 'openhuman.inference_list_models', screenIntelligenceStatus: 'openhuman.screen_intelligence_status', + embeddingsGetSettings: 'openhuman.embeddings_get_settings', + embeddingsUpdateSettings: 'openhuman.embeddings_update_settings', + embeddingsSetApiKey: 'openhuman.embeddings_set_api_key', + embeddingsClearApiKey: 'openhuman.embeddings_clear_api_key', + embeddingsEmbed: 'openhuman.embeddings_embed', + embeddingsTestConnection: 'openhuman.embeddings_test_connection', } as const; export type CoreRpcMethod = (typeof CORE_RPC_METHODS)[keyof typeof CORE_RPC_METHODS]; @@ -58,6 +64,7 @@ export const LEGACY_METHOD_ALIASES: Record = { 'openhuman.local_ai_diagnostics': CORE_RPC_METHODS.inferenceDiagnostics, 'openhuman.local_ai_presets': CORE_RPC_METHODS.inferencePresets, 'openhuman.providers_list_models': CORE_RPC_METHODS.inferenceListModels, + 'openhuman.inference_embed': CORE_RPC_METHODS.embeddingsEmbed, }; export function normalizeRpcMethod(method: string): string { diff --git a/src/core/all.rs b/src/core/all.rs index e6cbebf68..aad3225be 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -158,10 +158,12 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::service::all_service_registered_controllers()); // Data migration utilities controllers.extend(crate::openhuman::migration::all_migration_registered_controllers()); - // Unified inference domain: text / vision / embedding / local runtime / cloud providers. + // Unified inference domain: text / vision / local runtime / cloud providers. // (Formerly split across inference, local_ai, and providers namespaces.) controllers.extend(crate::openhuman::inference::all_inference_registered_controllers()); controllers.extend(crate::openhuman::inference::all_local_ai_registered_controllers()); + // Embedding provider configuration and embed RPC. + controllers.extend(crate::openhuman::embeddings::all_embeddings_registered_controllers()); // People resolution and interaction scoring controllers.extend(crate::openhuman::people::all_people_registered_controllers()); // Screen capture and UI analysis @@ -301,6 +303,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::migration::all_migration_controller_schemas()); schemas.extend(crate::openhuman::inference::all_inference_controller_schemas()); schemas.extend(crate::openhuman::inference::all_local_ai_controller_schemas()); + schemas.extend(crate::openhuman::embeddings::all_embeddings_controller_schemas()); schemas.extend(crate::openhuman::people::all_people_controller_schemas()); schemas.extend( crate::openhuman::screen_intelligence::all_screen_intelligence_controller_schemas(), diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index 32fbe5e40..2f13d1f20 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -91,6 +91,7 @@ const LEGACY_ALIASES: &[(&str, &str)] = &[ "openhuman.local_ai_diagnostics", "openhuman.inference_diagnostics", ), + ("openhuman.inference_embed", "openhuman.embeddings_embed"), ("openhuman.local_ai_presets", "openhuman.inference_presets"), ( "openhuman.providers_list_models", diff --git a/src/openhuman/embeddings/catalog.rs b/src/openhuman/embeddings/catalog.rs new file mode 100644 index 000000000..fab34071e --- /dev/null +++ b/src/openhuman/embeddings/catalog.rs @@ -0,0 +1,254 @@ +//! Static catalog of supported embedding providers. +//! +//! Each entry declares its slug, display label, whether it requires an API key, +//! and the models + dimension presets it supports. The frontend reads this via +//! `openhuman.embeddings_get_settings` to populate the provider picker. + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct EmbeddingModelPreset { + pub id: &'static str, + pub label: &'static str, + pub default_dimensions: usize, + pub allowed_dimensions: &'static [usize], +} + +#[derive(Debug, Clone, Serialize)] +pub struct EmbeddingProviderEntry { + pub slug: &'static str, + pub label: &'static str, + pub description: &'static str, + pub requires_api_key: bool, + pub requires_endpoint: bool, + pub models: &'static [EmbeddingModelPreset], +} + +pub const PROVIDER_MANAGED: &str = "managed"; +pub const PROVIDER_VOYAGE: &str = "voyage"; +pub const PROVIDER_OPENAI: &str = "openai"; +pub const PROVIDER_COHERE: &str = "cohere"; +pub const PROVIDER_OLLAMA: &str = "ollama"; +pub const PROVIDER_CUSTOM: &str = "custom"; +pub const PROVIDER_NONE: &str = "none"; + +static MANAGED_MODELS: &[EmbeddingModelPreset] = &[EmbeddingModelPreset { + id: "embedding-v1", + label: "Embedding v1 (Voyage-backed)", + default_dimensions: 1024, + allowed_dimensions: &[1024], +}]; + +static VOYAGE_MODELS: &[EmbeddingModelPreset] = &[ + EmbeddingModelPreset { + id: "voyage-3-large", + label: "Voyage 3 Large", + default_dimensions: 1024, + allowed_dimensions: &[256, 512, 1024, 2048], + }, + EmbeddingModelPreset { + id: "voyage-3", + label: "Voyage 3", + default_dimensions: 1024, + allowed_dimensions: &[1024], + }, + EmbeddingModelPreset { + id: "voyage-code-3", + label: "Voyage Code 3", + default_dimensions: 1024, + allowed_dimensions: &[1024], + }, +]; + +static OPENAI_MODELS: &[EmbeddingModelPreset] = &[ + EmbeddingModelPreset { + id: "text-embedding-3-small", + label: "Embedding 3 Small", + default_dimensions: 1536, + allowed_dimensions: &[512, 1536], + }, + EmbeddingModelPreset { + id: "text-embedding-3-large", + label: "Embedding 3 Large", + default_dimensions: 3072, + allowed_dimensions: &[256, 1024, 3072], + }, +]; + +static COHERE_MODELS: &[EmbeddingModelPreset] = &[ + EmbeddingModelPreset { + id: "embed-english-v3.0", + label: "Embed English v3", + default_dimensions: 1024, + allowed_dimensions: &[1024], + }, + EmbeddingModelPreset { + id: "embed-multilingual-v3.0", + label: "Embed Multilingual v3", + default_dimensions: 1024, + allowed_dimensions: &[1024], + }, +]; + +static OLLAMA_MODELS: &[EmbeddingModelPreset] = &[EmbeddingModelPreset { + id: "bge-m3", + label: "BGE-M3", + default_dimensions: 1024, + allowed_dimensions: &[1024], +}]; + +static CATALOG: &[EmbeddingProviderEntry] = &[ + EmbeddingProviderEntry { + slug: PROVIDER_MANAGED, + label: "Managed (OpenHuman)", + description: "Routes through the OpenHuman backend. No API key needed.", + requires_api_key: false, + requires_endpoint: false, + models: MANAGED_MODELS, + }, + EmbeddingProviderEntry { + slug: PROVIDER_VOYAGE, + label: "Voyage AI", + description: "Direct Voyage AI API with your own key.", + requires_api_key: true, + requires_endpoint: false, + models: VOYAGE_MODELS, + }, + EmbeddingProviderEntry { + slug: PROVIDER_OPENAI, + label: "OpenAI", + description: "OpenAI embeddings API with your own key.", + requires_api_key: true, + requires_endpoint: false, + models: OPENAI_MODELS, + }, + EmbeddingProviderEntry { + slug: PROVIDER_COHERE, + label: "Cohere", + description: "Cohere embed API with your own key.", + requires_api_key: true, + requires_endpoint: false, + models: COHERE_MODELS, + }, + EmbeddingProviderEntry { + slug: PROVIDER_OLLAMA, + label: "Ollama (Local)", + description: "Local Ollama server. No API key needed.", + requires_api_key: false, + requires_endpoint: false, + models: OLLAMA_MODELS, + }, + EmbeddingProviderEntry { + slug: PROVIDER_CUSTOM, + label: "Custom (OpenAI-compatible)", + description: "Any OpenAI-compatible embedding endpoint.", + requires_api_key: true, + requires_endpoint: true, + models: &[], + }, + EmbeddingProviderEntry { + slug: PROVIDER_NONE, + label: "Disabled", + description: "Disable semantic search. Keyword search only.", + requires_api_key: false, + requires_endpoint: false, + models: &[], + }, +]; + +pub fn all_providers() -> &'static [EmbeddingProviderEntry] { + CATALOG +} + +pub fn find_provider(slug: &str) -> Option<&'static EmbeddingProviderEntry> { + CATALOG.iter().find(|e| e.slug == slug) +} + +pub fn find_model(provider_slug: &str, model_id: &str) -> Option<&'static EmbeddingModelPreset> { + find_provider(provider_slug).and_then(|p| p.models.iter().find(|m| m.id == model_id)) +} + +pub fn default_model_for(provider_slug: &str) -> Option<&'static EmbeddingModelPreset> { + find_provider(provider_slug).and_then(|p| p.models.first()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_is_non_empty() { + assert!(!all_providers().is_empty()); + } + + #[test] + fn managed_is_first() { + assert_eq!(all_providers()[0].slug, PROVIDER_MANAGED); + } + + #[test] + fn find_voyage_model() { + let m = find_model(PROVIDER_VOYAGE, "voyage-3-large").unwrap(); + assert!(m.allowed_dimensions.contains(&1024)); + } + + #[test] + fn default_model_for_openai() { + let m = default_model_for(PROVIDER_OPENAI).unwrap(); + assert_eq!(m.id, "text-embedding-3-small"); + } + + #[test] + fn none_has_no_models() { + let p = find_provider(PROVIDER_NONE).unwrap(); + assert!(p.models.is_empty()); + } + + #[test] + fn unknown_provider_returns_none() { + assert!(find_provider("unknown").is_none()); + } + + #[test] + fn all_providers_have_unique_slugs() { + let providers = all_providers(); + let mut seen = std::collections::HashSet::new(); + for entry in providers { + assert!( + seen.insert(entry.slug), + "duplicate slug in CATALOG: \"{}\"", + entry.slug + ); + } + } + + #[test] + fn all_models_have_valid_dimensions() { + for entry in all_providers() { + for model in entry.models { + assert!( + model.allowed_dimensions.contains(&model.default_dimensions), + "provider \"{}\" model \"{}\" has default_dimensions {} not in allowed_dimensions {:?}", + entry.slug, + model.id, + model.default_dimensions, + model.allowed_dimensions + ); + } + } + } + + #[test] + fn default_model_for_all_providers_with_models() { + for entry in all_providers() { + if !entry.models.is_empty() { + assert!( + default_model_for(entry.slug).is_some(), + "default_model_for({:?}) returned None but provider has {} models", + entry.slug, + entry.models.len() + ); + } + } + } +} diff --git a/src/openhuman/embeddings/cohere.rs b/src/openhuman/embeddings/cohere.rs new file mode 100644 index 000000000..9621046be --- /dev/null +++ b/src/openhuman/embeddings/cohere.rs @@ -0,0 +1,179 @@ +//! Cohere embedding provider — direct API access with user's own key. +//! +//! Cohere's `/v2/embed` endpoint uses a slightly different contract than +//! OpenAI: `texts` instead of `input`, `embedding_types` instead of +//! `encoding_format`, and the response nests embeddings inside +//! `embeddings.float`. This module implements the Cohere-native wire +//! format. + +use async_trait::async_trait; + +use super::EmbeddingProvider; + +pub const COHERE_API_BASE: &str = "https://api.cohere.com"; +pub const COHERE_DEFAULT_MODEL: &str = "embed-english-v3.0"; +pub const COHERE_DEFAULT_DIMS: usize = 1024; + +pub struct CohereEmbedding { + api_key: String, + model: String, + dims: usize, +} + +impl CohereEmbedding { + pub fn new(api_key: &str, model: &str, dims: usize) -> Self { + let model = if model.is_empty() { + COHERE_DEFAULT_MODEL.to_string() + } else { + model.to_string() + }; + let dims = if dims == 0 { COHERE_DEFAULT_DIMS } else { dims }; + + Self { + api_key: api_key.to_string(), + model, + dims, + } + } + + fn http_client(&self) -> reqwest::Client { + crate::openhuman::config::build_runtime_proxy_client("embeddings.cohere") + } +} + +#[derive(serde::Deserialize)] +struct CohereEmbedResponse { + embeddings: CohereEmbeddings, +} + +#[derive(serde::Deserialize)] +struct CohereEmbeddings { + float: Vec>, +} + +#[async_trait] +impl EmbeddingProvider for CohereEmbedding { + fn name(&self) -> &str { + "cohere" + } + + fn model_id(&self) -> &str { + &self.model + } + + fn dimensions(&self) -> usize { + self.dims + } + + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + super::rate_limit::acquire_embedding_slot(COHERE_API_BASE).await; + + let url = format!("{COHERE_API_BASE}/v2/embed"); + + tracing::debug!( + target: "embeddings.cohere", + "[cohere] embed: model={}, count={}", self.model, texts.len() + ); + + let body = serde_json::json!({ + "model": self.model, + "texts": texts, + "input_type": "search_document", + "embedding_types": ["float"], + }); + + let resp = self + .http_client() + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let message = format!("Cohere embed API error ({status}): {text}"); + crate::core::observability::report_error_or_expected( + &message, + "embeddings", + "cohere_embed", + &[("model", self.model.as_str()), ("failure", "non_2xx")], + ); + anyhow::bail!(message); + } + + let payload: CohereEmbedResponse = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("Cohere embed response parse failed: {e}"))?; + + let embeddings = payload.embeddings.float; + + if embeddings.len() != texts.len() { + anyhow::bail!( + "Cohere embed count mismatch: sent {} texts, got {} embeddings", + texts.len(), + embeddings.len() + ); + } + + for (i, vec) in embeddings.iter().enumerate() { + if self.dims > 0 && vec.len() != self.dims { + anyhow::bail!( + "Cohere embed dimension mismatch at index {i}: expected {}, got {}", + self.dims, + vec.len() + ); + } + } + + tracing::debug!( + target: "embeddings.cohere", + "[cohere] embed success: model={}, count={}, dims={}", + self.model, embeddings.len(), + embeddings.first().map(|v| v.len()).unwrap_or(0) + ); + + Ok(embeddings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_defaults() { + let p = CohereEmbedding::new("test-key", "", 0); + assert_eq!(p.name(), "cohere"); + assert_eq!(p.model_id(), COHERE_DEFAULT_MODEL); + assert_eq!(p.dimensions(), COHERE_DEFAULT_DIMS); + } + + #[test] + fn custom_model() { + let p = CohereEmbedding::new("k", "embed-multilingual-v3.0", 1024); + assert_eq!(p.model_id(), "embed-multilingual-v3.0"); + } + + #[test] + fn signature_format() { + let p = CohereEmbedding::new("k", "embed-english-v3.0", 1024); + assert_eq!( + p.signature(), + "provider=cohere;model=embed-english-v3.0;dims=1024" + ); + } + + #[tokio::test] + async fn embed_empty_returns_empty() { + let p = CohereEmbedding::new("k", "", 0); + assert!(p.embed(&[]).await.unwrap().is_empty()); + } +} diff --git a/src/openhuman/embeddings/factory.rs b/src/openhuman/embeddings/factory.rs index 00bcb69c7..6a2726a3e 100644 --- a/src/openhuman/embeddings/factory.rs +++ b/src/openhuman/embeddings/factory.rs @@ -5,15 +5,19 @@ use std::sync::Arc; use super::cloud::{ OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, }; +use super::cohere::CohereEmbedding; use super::provider_trait::EmbeddingProvider; +use super::voyage::VoyageEmbedding; use super::{NoopEmbedding, OllamaEmbedding, OpenAiEmbedding}; /// Creates an embedding provider based on the specified name and configuration. /// /// Supported provider names: -/// - `"cloud"` → OpenHuman backend (Voyage-backed) — default, preferred +/// - `"managed"` / `"cloud"` → OpenHuman backend (Voyage-backed) — default +/// - `"voyage"` → direct Voyage AI API (user's own key) +/// - `"openai"` → OpenAI API (user's own key) +/// - `"cohere"` → Cohere API (user's own key) /// - `"ollama"` → local Ollama server (opt-in for offline-only installs) -/// - `"openai"` → OpenAI API /// - `"custom:"` → OpenAI-compatible endpoint /// - `"none"` → no-op (keyword-only search, no embeddings) /// @@ -26,9 +30,10 @@ pub fn create_embedding_provider( dims: usize, ) -> anyhow::Result> { match provider { - "cloud" => Ok(Box::new(OpenHumanCloudEmbedding::new( + "cloud" | "managed" => Ok(Box::new(OpenHumanCloudEmbedding::new( None, None, true, model, dims, ))), + "voyage" => Ok(Box::new(VoyageEmbedding::new("", model, dims))), "ollama" => { let base_url = crate::openhuman::inference::local::ollama_base_url(); Ok(Box::new(OllamaEmbedding::try_new(&base_url, model, dims)?)) @@ -39,6 +44,7 @@ pub fn create_embedding_provider( model, dims, ))), + "cohere" => Ok(Box::new(CohereEmbedding::new("", model, dims))), name if name.starts_with("custom:") => { let base_url = name.strip_prefix("custom:").unwrap_or(""); Ok(Box::new(OpenAiEmbedding::new(base_url, "", model, dims))) @@ -46,7 +52,52 @@ pub fn create_embedding_provider( "none" => Ok(Box::new(NoopEmbedding)), unknown => Err(anyhow::anyhow!( "unknown embedding provider: \"{unknown}\". \ - Supported: \"cloud\", \"ollama\", \"openai\", \"custom:\", \"none\"" + Supported: \"managed\", \"voyage\", \"openai\", \"cohere\", \ + \"ollama\", \"custom:\", \"none\"" + )), + } +} + +/// Creates an embedding provider with explicit API key and endpoint. +/// +/// Used by the RPC layer when credentials are loaded from the credential +/// store. +pub fn create_embedding_provider_with_credentials( + provider: &str, + model: &str, + dims: usize, + api_key: &str, + custom_endpoint: Option<&str>, +) -> anyhow::Result> { + match provider { + "cloud" | "managed" => Ok(Box::new(OpenHumanCloudEmbedding::new( + None, None, true, model, dims, + ))), + "voyage" => Ok(Box::new(VoyageEmbedding::new(api_key, model, dims))), + "ollama" => { + let base_url = crate::openhuman::inference::local::ollama_base_url(); + Ok(Box::new(OllamaEmbedding::try_new(&base_url, model, dims)?)) + } + "openai" => Ok(Box::new(OpenAiEmbedding::new( + "https://api.openai.com", + api_key, + model, + dims, + ))), + "cohere" => Ok(Box::new(CohereEmbedding::new(api_key, model, dims))), + "custom" => { + let url = custom_endpoint.unwrap_or(""); + Ok(Box::new(OpenAiEmbedding::new(url, api_key, model, dims))) + } + name if name.starts_with("custom:") => { + let url = custom_endpoint.unwrap_or_else(|| name.strip_prefix("custom:").unwrap_or("")); + Ok(Box::new(OpenAiEmbedding::new(url, api_key, model, dims))) + } + "none" => Ok(Box::new(NoopEmbedding)), + unknown => Err(anyhow::anyhow!( + "unknown embedding provider: \"{unknown}\". \ + Supported: \"managed\", \"voyage\", \"openai\", \"cohere\", \ + \"ollama\", \"custom\", \"none\"" )), } } diff --git a/src/openhuman/embeddings/mod.rs b/src/openhuman/embeddings/mod.rs index a31929347..5e69e8d40 100644 --- a/src/openhuman/embeddings/mod.rs +++ b/src/openhuman/embeddings/mod.rs @@ -2,22 +2,28 @@ //! //! Converts text into numerical vectors for semantic search. Providers: //! -//! - **Cloud** (default): Routes through the OpenHuman backend's +//! - **Managed** (default): Routes through the OpenHuman backend's //! `POST /openai/v1/embeddings` (Voyage-backed). The recommended path — //! works on a fresh install without requiring a local Ollama daemon. -//! - **Ollama**: Local Ollama server. Opt-in for offline-only setups -//! (set `memory.embedding_provider = "ollama"` or enable -//! `local_ai.usage.embeddings`). -//! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints. +//! - **Voyage**: Direct Voyage AI API with the user's own key. +//! - **OpenAI**: Cloud-based embeddings via the OpenAI API. +//! - **Cohere**: Cohere embed API with the user's own key. +//! - **Ollama**: Local Ollama server. Opt-in for offline-only setups. +//! - **Custom**: Any OpenAI-compatible endpoint. //! - **Noop**: A fallback provider for keyword-only search. +pub mod catalog; pub mod cloud; +pub mod cohere; mod factory; pub mod noop; pub mod ollama; pub mod openai; mod provider_trait; pub mod rate_limit; +mod rpc; +mod schemas; +pub mod voyage; // VectorStore has moved to memory_store::vectors; re-exported for callers. pub use crate::openhuman::memory_store::vectors::store; @@ -35,6 +41,10 @@ pub use noop::NoopEmbedding; pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL}; pub use openai::OpenAiEmbedding; pub use provider_trait::{format_embedding_signature, EmbeddingProvider}; +pub use schemas::{ + all_controller_schemas as all_embeddings_controller_schemas, + all_registered_controllers as all_embeddings_registered_controllers, +}; #[cfg(test)] mod tests { @@ -112,14 +122,28 @@ mod tests { assert_eq!(p.dimensions(), 0); } + #[test] + fn factory_voyage() { + let p = create_embedding_provider("voyage", "voyage-3-large", 1024).unwrap(); + assert_eq!(p.name(), "voyage"); + assert_eq!(p.dimensions(), 1024); + } + + #[test] + fn factory_cohere() { + let p = create_embedding_provider("cohere", "embed-english-v3.0", 1024).unwrap(); + assert_eq!(p.name(), "cohere"); + assert_eq!(p.dimensions(), 1024); + } + // ── Factory — errors ───────────────────────────────────── #[test] fn factory_unknown_provider_errors() { - let result = create_embedding_provider("cohere", "model", 1536); + let result = create_embedding_provider("deepseek", "model", 1536); let msg = result.err().expect("should be an error").to_string(); assert!( - msg.contains("cohere"), + msg.contains("deepseek"), "should include provider name: {msg}" ); assert!(msg.contains("unknown"), "should say unknown: {msg}"); @@ -157,6 +181,18 @@ mod tests { assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); } + #[test] + fn factory_managed() { + let p = create_embedding_provider( + "managed", + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ) + .unwrap(); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); + } + // ── Default provider ───────────────────────────────────── #[test] @@ -172,4 +208,68 @@ mod tests { assert_eq!(p.name(), "ollama"); assert_eq!(p.dimensions(), DEFAULT_OLLAMA_DIMENSIONS); } + + // ── create_embedding_provider_with_credentials ─────────── + + #[test] + fn factory_with_credentials_voyage() { + let p = factory::create_embedding_provider_with_credentials( + "voyage", + "voyage-3-large", + 1024, + "voyage-test-key", + None, + ) + .expect("voyage with key"); + assert_eq!(p.name(), "voyage"); + assert_eq!(p.model_id(), "voyage-3-large"); + assert_eq!(p.dimensions(), 1024); + } + + #[test] + fn factory_with_credentials_cohere() { + let p = factory::create_embedding_provider_with_credentials( + "cohere", + "embed-english-v3.0", + 1024, + "cohere-test-key", + None, + ) + .expect("cohere with key"); + assert_eq!(p.name(), "cohere"); + assert_eq!(p.model_id(), "embed-english-v3.0"); + assert_eq!(p.dimensions(), 1024); + } + + #[test] + fn factory_with_credentials_custom() { + let p = factory::create_embedding_provider_with_credentials( + "custom", + "custom-model", + 768, + "custom-key", + Some("http://localhost:9999"), + ) + .expect("custom provider with endpoint"); + // Custom is backed by OpenAiEmbedding + assert_eq!(p.name(), "openai"); + assert_eq!(p.dimensions(), 768); + } + + #[test] + fn factory_with_credentials_managed_ignores_key() { + // Managed/cloud provider does not use the API key — it routes through + // the OpenHuman backend. Creating it with an arbitrary key must succeed + // and produce the cloud provider. + let p = factory::create_embedding_provider_with_credentials( + "managed", + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + "should-be-ignored", + None, + ) + .expect("managed ignores key"); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); + } } diff --git a/src/openhuman/embeddings/rpc.rs b/src/openhuman/embeddings/rpc.rs new file mode 100644 index 000000000..3bdacdd47 --- /dev/null +++ b/src/openhuman/embeddings/rpc.rs @@ -0,0 +1,382 @@ +//! RPC handlers for the embeddings domain. + +use std::collections::HashMap; + +use crate::openhuman::config::Config; +use crate::openhuman::credentials::AuthService; +use crate::rpc::RpcOutcome; + +use super::catalog; +use super::factory::create_embedding_provider_with_credentials; + +const LOG_PREFIX: &str = "[embeddings::rpc]"; + +/// Returns the current embedding settings plus the provider catalog. +pub async fn get_settings(config: &Config) -> Result, String> { + let provider = &config.memory.embedding_provider; + let model = &config.memory.embedding_model; + let dimensions = config.memory.embedding_dimensions; + let rate_limit = config.memory.embedding_rate_limit_per_min; + + let auth = AuthService::from_config(config); + let providers: Vec = catalog::all_providers() + .iter() + .map(|entry| { + let has_key = if entry.requires_api_key { + let cred_provider = format!("embeddings:{}", entry.slug); + auth.get_provider_bearer_token(&cred_provider, None) + .ok() + .flatten() + .is_some() + } else { + false + }; + serde_json::json!({ + "slug": entry.slug, + "label": entry.label, + "description": entry.description, + "requires_api_key": entry.requires_api_key, + "requires_endpoint": entry.requires_endpoint, + "has_api_key": has_key, + "models": entry.models, + }) + }) + .collect(); + + let payload = serde_json::json!({ + "provider": provider, + "model": model, + "dimensions": dimensions, + "rate_limit_per_min": rate_limit, + "providers": providers, + }); + + tracing::debug!( + provider = provider.as_str(), + model = model.as_str(), + dimensions, + "{LOG_PREFIX} get_settings" + ); + + Ok(RpcOutcome::new( + payload, + vec!["embeddings settings loaded".into()], + )) +} + +/// Updates embedding provider/model/dimensions. If the embedding signature +/// changes, requires `confirm_wipe = true` and wipes memory. +pub async fn update_settings( + provider: Option, + model: Option, + dimensions: Option, + custom_endpoint: Option, + rate_limit_per_min: Option, + confirm_wipe: bool, +) -> Result, String> { + use crate::openhuman::config::ops as config_rpc; + use crate::openhuman::embeddings::format_embedding_signature; + + let mut config = config_rpc::load_config_with_timeout().await?; + + let old_sig = format_embedding_signature( + &config.memory.embedding_provider, + &config.memory.embedding_model, + config.memory.embedding_dimensions, + ); + + let new_provider = provider + .clone() + .unwrap_or_else(|| config.memory.embedding_provider.clone()); + let new_model = model + .clone() + .unwrap_or_else(|| config.memory.embedding_model.clone()); + let new_dims = dimensions.unwrap_or(config.memory.embedding_dimensions); + let new_sig = format_embedding_signature(&new_provider, &new_model, new_dims); + + let old_dims = config.memory.embedding_dimensions; + let dims_changed = new_dims != old_dims; + let sig_changed = new_sig != old_sig; + + // Only require a wipe when dimensions actually change — switching + // provider/model at the same dimensionality keeps vectors comparable. + if dims_changed && !confirm_wipe { + let payload = serde_json::json!({ + "error": "EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE", + "old_dimensions": old_dims, + "new_dimensions": new_dims, + "old_signature": old_sig, + "new_signature": new_sig, + "message": "Changing embedding dimensions invalidates all stored vectors. \ + Pass confirm_wipe=true to wipe memory and apply.", + }); + return Ok(RpcOutcome::new( + payload, + vec!["embedding dimension change requires wipe confirmation".into()], + )); + } + + if dims_changed { + tracing::warn!( + old_dims, + new_dims, + "{LOG_PREFIX} embedding dimensions changing — wiping memory" + ); + crate::openhuman::memory::read_rpc::wipe_all_rpc(&config) + .await + .map_err(|e| format!("memory wipe failed: {e}"))?; + } + + // Apply provider + if let Some(p) = &provider { + config.memory.embedding_provider = p.clone(); + // Also update the workload routing to keep them in sync + config.embeddings_provider = Some(match p.as_str() { + "managed" | "cloud" => "openhuman".to_string(), + "ollama" => format!("ollama:{new_model}"), + other => other.to_string(), + }); + } + if let Some(m) = &model { + config.memory.embedding_model = m.clone(); + } + if let Some(d) = dimensions { + config.memory.embedding_dimensions = d; + } + if let Some(rl) = rate_limit_per_min { + config.memory.embedding_rate_limit_per_min = rl; + } + // Store custom endpoint in a convention field if provided + if let Some(ep) = &custom_endpoint { + if new_provider == "custom" || new_provider.starts_with("custom:") { + config.memory.embedding_provider = format!("custom:{ep}"); + } + } + + config.save().await.map_err(|e| e.to_string())?; + + if sig_changed { + crate::openhuman::memory_queue::ensure_reembed_backfill(&config); + } + + tracing::info!( + provider = config.memory.embedding_provider.as_str(), + model = config.memory.embedding_model.as_str(), + dimensions = config.memory.embedding_dimensions, + sig_changed, + "{LOG_PREFIX} update_settings applied" + ); + + let payload = serde_json::json!({ + "provider": config.memory.embedding_provider, + "model": config.memory.embedding_model, + "dimensions": config.memory.embedding_dimensions, + "signature_changed": sig_changed, + "new_signature": new_sig, + }); + + Ok(RpcOutcome::new( + payload, + vec![format!( + "embeddings settings updated (sig_changed={sig_changed})" + )], + )) +} + +/// Stores an API key for a specific embedding provider. +pub async fn set_api_key( + config: &Config, + provider_slug: &str, + api_key: &str, +) -> Result, String> { + if provider_slug.is_empty() { + return Err("provider slug is required".into()); + } + if api_key.trim().is_empty() { + return Err("api_key cannot be empty".into()); + } + + let cred_provider = format!("embeddings:{provider_slug}"); + let auth = AuthService::from_config(config); + auth.store_provider_token(&cred_provider, "default", api_key, HashMap::new(), true) + .map_err(|e| format!("failed to store embedding API key: {e}"))?; + + tracing::info!(provider = provider_slug, "{LOG_PREFIX} set_api_key stored"); + + Ok(RpcOutcome::new( + serde_json::json!({ "stored": true, "provider": provider_slug }), + vec![format!("embedding API key stored for {provider_slug}")], + )) +} + +/// Removes the API key for a specific embedding provider. +pub async fn clear_api_key( + config: &Config, + provider_slug: &str, +) -> Result, String> { + if provider_slug.is_empty() { + return Err("provider slug is required".into()); + } + + let cred_provider = format!("embeddings:{provider_slug}"); + let auth = AuthService::from_config(config); + let removed = auth + .remove_profile(&cred_provider, "default") + .map_err(|e| format!("failed to clear embedding API key: {e}"))?; + + tracing::info!( + provider = provider_slug, + removed, + "{LOG_PREFIX} clear_api_key" + ); + + Ok(RpcOutcome::new( + serde_json::json!({ "cleared": removed, "provider": provider_slug }), + vec![format!("embedding API key cleared for {provider_slug}")], + )) +} + +/// Generates embeddings for the given input texts using the currently +/// configured provider. +pub async fn embed( + config: &Config, + inputs: &[String], +) -> Result, String> { + let provider_name = &config.memory.embedding_provider; + let model = &config.memory.embedding_model; + let dims = config.memory.embedding_dimensions; + + let api_key = resolve_api_key(config, provider_name); + + let custom_endpoint = if provider_name.starts_with("custom:") { + provider_name + .strip_prefix("custom:") + .map(|s: &str| s.to_string()) + } else { + None + }; + + let provider_slug = if provider_name.starts_with("custom:") { + "custom" + } else { + provider_name.as_str() + }; + + let embedder = create_embedding_provider_with_credentials( + provider_slug, + model, + dims, + &api_key, + custom_endpoint.as_deref(), + ) + .map_err(|e| e.to_string())?; + + let refs: Vec<&str> = inputs.iter().map(|s| s.as_str()).collect(); + let vectors = embedder.embed(&refs).await.map_err(|e| e.to_string())?; + + let actual_dims = vectors.first().map(|v| v.len()).unwrap_or(0); + + tracing::debug!( + provider = provider_slug, + model, + input_count = inputs.len(), + vector_count = vectors.len(), + dims = actual_dims, + "{LOG_PREFIX} embed completed" + ); + + let payload = serde_json::json!({ + "vectors": vectors, + "dimensions": actual_dims, + "count": vectors.len(), + "provider": provider_slug, + "model": model, + }); + + Ok(RpcOutcome::new(payload, vec!["embedding completed".into()])) +} + +/// Tests connectivity to the configured (or specified) embedding provider. +pub async fn test_connection( + config: &Config, + provider_slug: Option<&str>, + model: Option<&str>, + dims: Option, +) -> Result, String> { + let slug = provider_slug.unwrap_or(&config.memory.embedding_provider); + let model = model.unwrap_or(&config.memory.embedding_model); + let dims = dims.unwrap_or(config.memory.embedding_dimensions); + + let api_key = resolve_api_key(config, slug); + + let custom_endpoint = if slug.starts_with("custom:") { + slug.strip_prefix("custom:").map(|s| s.to_string()) + } else { + None + }; + + let provider_tag = if slug.starts_with("custom:") { + "custom" + } else { + slug + }; + + let embedder = create_embedding_provider_with_credentials( + provider_tag, + model, + dims, + &api_key, + custom_endpoint.as_deref(), + ) + .map_err(|e| e.to_string())?; + + tracing::debug!( + provider = provider_tag, + model, + dims, + "{LOG_PREFIX} test_connection starting" + ); + + match embedder.embed(&["connection test"]).await { + Ok(vectors) => { + let actual_dims = vectors.first().map(|v| v.len()).unwrap_or(0); + let payload = serde_json::json!({ + "success": true, + "provider": provider_tag, + "model": model, + "requested_dimensions": dims, + "actual_dimensions": actual_dims, + }); + Ok(RpcOutcome::new( + payload, + vec!["connection test passed".into()], + )) + } + Err(e) => { + let payload = serde_json::json!({ + "success": false, + "provider": provider_tag, + "model": model, + "error": e.to_string(), + }); + Ok(RpcOutcome::new( + payload, + vec![format!("connection test failed: {e}")], + )) + } + } +} + +fn resolve_api_key(config: &Config, provider_name: &str) -> String { + let slug = if provider_name.starts_with("custom:") { + "custom" + } else { + provider_name + }; + let cred_provider = format!("embeddings:{slug}"); + let auth = AuthService::from_config(config); + auth.get_provider_bearer_token(&cred_provider, None) + .ok() + .flatten() + .unwrap_or_default() +} diff --git a/src/openhuman/embeddings/schemas.rs b/src/openhuman/embeddings/schemas.rs new file mode 100644 index 000000000..d5072a51d --- /dev/null +++ b/src/openhuman/embeddings/schemas.rs @@ -0,0 +1,336 @@ +//! Controller schemas and handler registrations for the embeddings domain. + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::config::ops as config_rpc; +use crate::rpc::RpcOutcome; + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("get_settings"), + schemas("update_settings"), + schemas("set_api_key"), + schemas("clear_api_key"), + schemas("embed"), + schemas("test_connection"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("get_settings"), + handler: handle_get_settings, + }, + RegisteredController { + schema: schemas("update_settings"), + handler: handle_update_settings, + }, + RegisteredController { + schema: schemas("set_api_key"), + handler: handle_set_api_key, + }, + RegisteredController { + schema: schemas("clear_api_key"), + handler: handle_clear_api_key, + }, + RegisteredController { + schema: schemas("embed"), + handler: handle_embed, + }, + RegisteredController { + schema: schemas("test_connection"), + handler: handle_test_connection, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "get_settings" => ControllerSchema { + namespace: "embeddings", + function: "get_settings", + description: "Get current embedding settings and provider catalog.", + inputs: vec![], + outputs: vec![json_output("settings", "Embedding settings and provider catalog.")], + }, + "update_settings" => ControllerSchema { + namespace: "embeddings", + function: "update_settings", + description: "Update embedding provider, model, or dimensions. Requires confirm_wipe when signature changes.", + inputs: vec![ + optional_string("provider", "Embedding provider slug."), + optional_string("model", "Model identifier."), + optional_u64("dimensions", "Output vector dimensions."), + optional_string("custom_endpoint", "Custom endpoint URL (for custom provider)."), + optional_u64("rate_limit_per_min", "Rate limit in requests per minute."), + optional_bool("confirm_wipe", "Confirm memory wipe on signature change."), + ], + outputs: vec![json_output("result", "Updated settings.")], + }, + "set_api_key" => ControllerSchema { + namespace: "embeddings", + function: "set_api_key", + description: "Store an API key for an embedding provider.", + inputs: vec![ + required_string("provider", "Provider slug (e.g. voyage, openai, cohere, custom)."), + required_string("api_key", "The API key to store."), + ], + outputs: vec![json_output("result", "Storage confirmation.")], + }, + "clear_api_key" => ControllerSchema { + namespace: "embeddings", + function: "clear_api_key", + description: "Remove the stored API key for an embedding provider.", + inputs: vec![ + required_string("provider", "Provider slug."), + ], + outputs: vec![json_output("result", "Removal confirmation.")], + }, + "embed" => ControllerSchema { + namespace: "embeddings", + function: "embed", + description: "Generate embeddings for text inputs using the configured provider.", + inputs: vec![FieldSchema { + name: "inputs", + ty: TypeSchema::Array(Box::new(TypeSchema::String)), + comment: "Texts to embed.", + required: true, + }], + outputs: vec![json_output("result", "Embedding vectors and metadata.")], + }, + "test_connection" => ControllerSchema { + namespace: "embeddings", + function: "test_connection", + description: "Test connectivity to the configured or specified embedding provider.", + inputs: vec![ + optional_string("provider", "Provider slug to test (defaults to current)."), + optional_string("model", "Model to test (defaults to current)."), + optional_u64("dimensions", "Dimensions to test (defaults to current)."), + ], + outputs: vec![json_output("result", "Connection test result.")], + }, + _ => ControllerSchema { + namespace: "embeddings", + function: "unknown", + description: "Unknown embeddings controller function.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +// ── Param structs ────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct UpdateSettingsParams { + provider: Option, + model: Option, + dimensions: Option, + custom_endpoint: Option, + rate_limit_per_min: Option, + #[serde(default)] + confirm_wipe: bool, +} + +#[derive(Debug, Deserialize)] +struct SetApiKeyParams { + provider: String, + api_key: String, +} + +#[derive(Debug, Deserialize)] +struct ClearApiKeyParams { + provider: String, +} + +#[derive(Debug, Deserialize)] +struct EmbedParams { + inputs: Vec, +} + +#[derive(Debug, Deserialize)] +struct TestConnectionParams { + provider: Option, + model: Option, + dimensions: Option, +} + +// ── Handlers ─────────────────────────────────────────────── + +fn handle_get_settings(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(super::rpc::get_settings(&config).await?) + }) +} + +fn handle_update_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + to_json( + super::rpc::update_settings( + p.provider, + p.model, + p.dimensions, + p.custom_endpoint, + p.rate_limit_per_min, + p.confirm_wipe, + ) + .await?, + ) + }) +} + +fn handle_set_api_key(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let config = config_rpc::load_config_with_timeout().await?; + to_json(super::rpc::set_api_key(&config, &p.provider, &p.api_key).await?) + }) +} + +fn handle_clear_api_key(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let config = config_rpc::load_config_with_timeout().await?; + to_json(super::rpc::clear_api_key(&config, &p.provider).await?) + }) +} + +fn handle_embed(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let config = config_rpc::load_config_with_timeout().await?; + to_json(super::rpc::embed(&config, &p.inputs).await?) + }) +} + +fn handle_test_connection(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let config = config_rpc::load_config_with_timeout().await?; + to_json( + super::rpc::test_connection( + &config, + p.provider.as_deref(), + p.model.as_deref(), + p.dimensions, + ) + .await?, + ) + }) +} + +// ── Helpers ──────────────────────────────────────────────── + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +fn deserialize_params( + params: Map, +) -> Result { + serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) +} + +fn required_string(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::String, + comment, + required: true, + } +} + +fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment, + required: false, + } +} + +fn optional_bool(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), + comment, + required: false, + } +} + +fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment, + required: false, + } +} + +fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Json, + comment, + required: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_count() { + assert_eq!(all_controller_schemas().len(), 6); + } + + #[test] + fn all_controllers_count() { + assert_eq!(all_registered_controllers().len(), 6); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + assert_eq!(schema.namespace, "embeddings"); + } + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("bad"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "embeddings"); + } + + #[tokio::test] + async fn all_handlers_accept_empty_params_without_panic() { + // Every handler should return a result (Ok or Err) when called with + // empty params — it must not panic. Handlers that require params will + // return an error, which is also acceptable here. + let controllers = all_registered_controllers(); + for ctrl in controllers { + let params = serde_json::Map::new(); + // The handler is an async fn pointer; calling it with empty params + // must not panic. We don't assert Ok because mandatory-param + // handlers legitimately return Err("invalid params: ..."). + let _result = (ctrl.handler)(params).await; + // If we reach here the handler did not panic. + } + } +} diff --git a/src/openhuman/embeddings/voyage.rs b/src/openhuman/embeddings/voyage.rs new file mode 100644 index 000000000..22d4f3add --- /dev/null +++ b/src/openhuman/embeddings/voyage.rs @@ -0,0 +1,82 @@ +//! Voyage AI embedding provider — direct API access with user's own key. +//! +//! Voyage's `/v1/embeddings` endpoint speaks a superset of the OpenAI +//! embeddings contract (same request/response shape, plus extra fields +//! like `input_type` and `truncation`). We delegate to `OpenAiEmbedding` +//! for the HTTP plumbing and just set the correct base URL + auth. + +use async_trait::async_trait; + +use super::openai::OpenAiEmbedding; +use super::EmbeddingProvider; + +pub const VOYAGE_API_BASE: &str = "https://api.voyageai.com"; +pub const VOYAGE_DEFAULT_MODEL: &str = "voyage-3-large"; +pub const VOYAGE_DEFAULT_DIMS: usize = 1024; + +pub struct VoyageEmbedding { + inner: OpenAiEmbedding, +} + +impl VoyageEmbedding { + pub fn new(api_key: &str, model: &str, dims: usize) -> Self { + let model = if model.is_empty() { + VOYAGE_DEFAULT_MODEL + } else { + model + }; + let dims = if dims == 0 { VOYAGE_DEFAULT_DIMS } else { dims }; + + Self { + inner: OpenAiEmbedding::new(VOYAGE_API_BASE, api_key, model, dims), + } + } +} + +#[async_trait] +impl EmbeddingProvider for VoyageEmbedding { + fn name(&self) -> &str { + "voyage" + } + + fn model_id(&self) -> &str { + self.inner.model_id() + } + + fn dimensions(&self) -> usize { + self.inner.dimensions() + } + + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + self.inner.embed(texts).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_and_defaults() { + let p = VoyageEmbedding::new("test-key", "", 0); + assert_eq!(p.name(), "voyage"); + assert_eq!(p.model_id(), VOYAGE_DEFAULT_MODEL); + assert_eq!(p.dimensions(), VOYAGE_DEFAULT_DIMS); + } + + #[test] + fn custom_model_and_dims() { + let p = VoyageEmbedding::new("test-key", "voyage-code-3", 1024); + assert_eq!(p.model_id(), "voyage-code-3"); + assert_eq!(p.dimensions(), 1024); + } + + #[test] + fn signature_format() { + let p = VoyageEmbedding::new("k", "voyage-3-large", 1024); + assert_eq!( + p.signature(), + "provider=voyage;model=voyage-3-large;dims=1024" + ); + } +} diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index 3367dc1b2..15cfb94bb 100644 --- a/src/openhuman/inference/schemas.rs +++ b/src/openhuman/inference/schemas.rs @@ -27,11 +27,6 @@ struct InferenceVisionPromptParams { max_tokens: Option, } -#[derive(Debug, Deserialize)] -struct InferenceEmbedParams { - inputs: Vec, -} - #[derive(Debug, Deserialize)] struct InferenceTestProviderModelParams { workload: String, @@ -140,7 +135,6 @@ pub fn all_controller_schemas() -> Vec { schemas("summarize"), schemas("prompt"), schemas("vision_prompt"), - schemas("embed"), schemas("test_provider_model"), schemas("should_react"), schemas("analyze_sentiment"), @@ -213,10 +207,6 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("vision_prompt"), handler: handle_inference_vision_prompt, }, - RegisteredController { - schema: schemas("embed"), - handler: handle_inference_embed, - }, RegisteredController { schema: schemas("test_provider_model"), handler: handle_inference_test_provider_model, @@ -402,18 +392,6 @@ pub fn schemas(function: &str) -> ControllerSchema { ], outputs: vec![json_output("output", "Prompt output text.")], }, - "embed" => ControllerSchema { - namespace: "inference", - function: "embed", - description: "Generate embeddings for text inputs.", - inputs: vec![FieldSchema { - name: "inputs", - ty: TypeSchema::Array(Box::new(TypeSchema::String)), - comment: "Texts to embed.", - required: true, - }], - outputs: vec![json_output("embedding", "Embedding result payload.")], - }, "test_provider_model" => ControllerSchema { namespace: "inference", function: "test_provider_model", @@ -766,14 +744,6 @@ fn handle_inference_vision_prompt(params: Map) -> ControllerFutur }) } -fn handle_inference_embed(params: Map) -> ControllerFuture { - Box::pin(async move { - let p = deserialize_params::(params)?; - let config = config_rpc::load_config_with_timeout().await?; - to_json(crate::openhuman::inference::rpc::inference_embed(&config, &p.inputs).await?) - }) -} - fn handle_inference_test_provider_model(params: Map) -> ControllerFuture { Box::pin(async move { let p = deserialize_params::(params)?; diff --git a/src/openhuman/inference/schemas_tests.rs b/src/openhuman/inference/schemas_tests.rs index 38aa13bb3..eb391f878 100644 --- a/src/openhuman/inference/schemas_tests.rs +++ b/src/openhuman/inference/schemas_tests.rs @@ -42,7 +42,8 @@ fn inference_schema_function_names_are_stable() { assert!(functions.contains(&"openai_oauth_disconnect")); assert!(functions.contains(&"prompt")); assert!(functions.contains(&"vision_prompt")); - assert!(functions.contains(&"embed")); + // embed moved to the embeddings domain (openhuman.embeddings_embed) + assert!(!functions.contains(&"embed")); assert!(!functions.contains(&"should_send_gif")); assert!(!functions.contains(&"tenor_search")); } diff --git a/tests/embeddings_rpc_e2e.rs b/tests/embeddings_rpc_e2e.rs new file mode 100644 index 000000000..32c8edadf --- /dev/null +++ b/tests/embeddings_rpc_e2e.rs @@ -0,0 +1,566 @@ +//! JSON-RPC E2E tests for the embeddings domain. +//! +//! Spins up the core HTTP router against a temp workspace and exercises the +//! `openhuman.embeddings_*` controller surface end-to-end. No real Voyage / +//! OpenAI / Cohere calls are made — tests either use the "none" noop provider +//! or assert error shapes from providers that require live credentials. +//! +//! Run with: `cargo test --test embeddings_rpc_e2e` + +use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use axum::http::header::AUTHORIZATION; +use serde_json::{json, Value}; +use tempfile::tempdir; + +use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; +use openhuman_core::core::jsonrpc::build_core_http_router; + +// ── Auth / token setup ──────────────────────────────────────────────────────── + +const TEST_RPC_TOKEN: &str = "embeddings-e2e-test-token"; +static E2E_AUTH_INIT: OnceLock<()> = OnceLock::new(); + +/// Serialises tests: env-var mutations (`HOME`, `OPENHUMAN_WORKSPACE`, +/// `OPENHUMAN_APP_ENV`) are process-global. The OnceLock+Mutex pattern mirrors +/// `json_rpc_e2e.rs` so tests don't race each other. +static EMBEDDINGS_E2E_ENV_LOCK: OnceLock> = OnceLock::new(); + +fn embeddings_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> { + let mutex = EMBEDDINGS_E2E_ENV_LOCK.get_or_init(|| Mutex::new(())); + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn ensure_test_rpc_auth() { + E2E_AUTH_INIT.get_or_init(|| { + // SAFETY: runs exactly once inside OnceLock before any concurrent env + // reads occur. Rust 1.81+ requires unsafe for set_var in multi-threaded + // contexts; the OnceLock guard limits the mutation to a single call. + unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) }; + let token_dir = std::env::temp_dir().join("openhuman-embeddings-e2e-auth"); + init_rpc_token(&token_dir).expect("init rpc auth token for embeddings_rpc_e2e"); + }); +} + +// ── Env-var guard (RAII restore) ────────────────────────────────────────────── + +struct EnvVarGuard { + key: &'static str, + old: Option, +} + +impl EnvVarGuard { + fn set_to_path(key: &'static str, path: &Path) -> Self { + let old = std::env::var(key).ok(); + unsafe { std::env::set_var(key, path.as_os_str()) }; + Self { key, old } + } + + #[allow(dead_code)] + fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var(key).ok(); + unsafe { std::env::set_var(key, value) }; + Self { key, old } + } + + fn unset(key: &'static str) -> Self { + let old = std::env::var(key).ok(); + unsafe { std::env::remove_var(key) }; + Self { key, old } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.old { + Some(v) => unsafe { std::env::set_var(self.key, v) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } +} + +// ── Minimal config writer ───────────────────────────────────────────────────── + +fn write_min_config(config_dir: &Path) { + std::fs::create_dir_all(config_dir).expect("mkdir for embeddings e2e config"); + let cfg = r#"api_url = "http://127.0.0.1:1" +default_model = "e2e-mock-model" +default_temperature = 0.7 +chat_onboarding_completed = true + +[secrets] +encrypt = false +"#; + std::fs::write(config_dir.join("config.toml"), cfg).expect("write embeddings e2e config.toml"); +} + +// ── HTTP server helpers ─────────────────────────────────────────────────────── + +async fn serve_on_ephemeral() -> ( + SocketAddr, + tokio::task::JoinHandle>, +) { + ensure_test_rpc_auth(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local_addr"); + let app = build_core_http_router(false); + let handle = tokio::spawn(async move { axum::serve(listener, app).await }); + (addr, handle) +} + +async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("build reqwest client"); + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }); + let url = format!("{}/rpc", rpc_base.trim_end_matches('/')); + let resp = client + .post(&url) + .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) + .json(&body) + .send() + .await + .unwrap_or_else(|e| panic!("POST {url}: {e}")); + assert!( + resp.status().is_success(), + "HTTP error {} calling {}", + resp.status(), + method + ); + resp.json::() + .await + .unwrap_or_else(|e| panic!("deserialize json for {method}: {e}")) +} + +// ── Assertion helpers ───────────────────────────────────────────────────────── + +fn assert_no_rpc_error<'a>(v: &'a Value, ctx: &str) -> &'a Value { + if let Some(err) = v.get("error") { + panic!("{ctx}: unexpected JSON-RPC error: {err}"); + } + v.get("result") + .unwrap_or_else(|| panic!("{ctx}: missing 'result' field in: {v}")) +} + +// ── Test scaffolding: set up HOME + OPENHUMAN_WORKSPACE then start RPC server ─ + +/// Returns `(rpc_base, tempdir, guards)`. The `guards` tuple keeps all +/// `EnvVarGuard` values alive for the duration of the test. +async fn setup_embeddings_test() -> ( + String, + tempfile::TempDir, + (EnvVarGuard, EnvVarGuard, EnvVarGuard, EnvVarGuard), + tokio::task::JoinHandle>, +) { + let tmp = tempdir().expect("tempdir"); + let home = tmp.path().to_path_buf(); + let openhuman_home = home.join(".openhuman"); + + write_min_config(&openhuman_home); + // Also write user-local config so that post-login config loads succeed. + write_min_config(&openhuman_home.join("users").join("local")); + + let home_guard = EnvVarGuard::set_to_path("HOME", &home); + let workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let backend_guard = EnvVarGuard::unset("BACKEND_URL"); + let vite_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (addr, join) = serve_on_ephemeral().await; + let rpc_base = format!("http://{addr}"); + + ( + rpc_base, + tmp, + (home_guard, workspace_guard, backend_guard, vite_guard), + join, + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn embeddings_get_settings_returns_catalog() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + let resp = post_json_rpc(&rpc_base, 1, "openhuman.embeddings_get_settings", json!({})).await; + let result = assert_no_rpc_error(&resp, "embeddings_get_settings"); + + // Unwrap one more layer if the controller wraps in {result: ...} + let inner = result.get("result").unwrap_or(result); + + // Required top-level fields + assert!( + inner.get("provider").is_some(), + "get_settings result missing 'provider': {inner}" + ); + assert!( + inner.get("model").is_some(), + "get_settings result missing 'model': {inner}" + ); + assert!( + inner.get("dimensions").is_some(), + "get_settings result missing 'dimensions': {inner}" + ); + + // Provider catalog + let providers = inner + .get("providers") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("get_settings result missing 'providers' array: {inner}")); + assert!( + providers.len() >= 5, + "expected at least 5 providers in catalog, got {}: {providers:?}", + providers.len() + ); + + // Each entry has the required fields + for entry in providers.iter() { + for field in &["slug", "label", "requires_api_key", "models"] { + assert!( + entry.get(field).is_some(), + "provider entry missing '{field}': {entry}" + ); + } + } + + // Managed provider should not require an API key + let managed = providers + .iter() + .find(|e| e.get("slug").and_then(Value::as_str) == Some("managed")) + .expect("catalog must include 'managed' provider"); + assert_eq!( + managed.get("requires_api_key").and_then(Value::as_bool), + Some(false), + "managed provider must not require an API key" + ); + + // Voyage provider requires a key + let voyage = providers + .iter() + .find(|e| e.get("slug").and_then(Value::as_str) == Some("voyage")) + .expect("catalog must include 'voyage' provider"); + assert_eq!( + voyage.get("requires_api_key").and_then(Value::as_bool), + Some(true), + "voyage provider must require an API key" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn embeddings_update_settings_switches_provider() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + // Switch to "none" (noop) which has 0 dimensions — dimension change from + // the default requires confirm_wipe. We pass it so the update goes through. + let update = post_json_rpc( + &rpc_base, + 2, + "openhuman.embeddings_update_settings", + json!({ "provider": "none", "confirm_wipe": true }), + ) + .await; + let update_result = assert_no_rpc_error(&update, "embeddings_update_settings"); + let inner = update_result.get("result").unwrap_or(update_result); + + assert_eq!( + inner.get("provider").and_then(Value::as_str), + Some("none"), + "update_settings should return provider=none: {inner}" + ); + + // Subsequent get_settings should reflect the change + let get = post_json_rpc(&rpc_base, 3, "openhuman.embeddings_get_settings", json!({})).await; + let get_result = assert_no_rpc_error(&get, "embeddings_get_settings after update"); + let get_inner = get_result.get("result").unwrap_or(get_result); + + assert_eq!( + get_inner.get("provider").and_then(Value::as_str), + Some("none"), + "get_settings after update should show provider=none: {get_inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn embeddings_update_settings_dimension_change_requires_wipe() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + // First set provider to voyage (a provider that supports multiple dims) + let _ = post_json_rpc( + &rpc_base, + 10, + "openhuman.embeddings_update_settings", + json!({ "provider": "voyage", "model": "voyage-3-large", "dimensions": 1024, "confirm_wipe": true }), + ) + .await; + + // Now try to change only dimensions without confirm_wipe — should get the + // EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE sentinel in the result body. + let resp = post_json_rpc( + &rpc_base, + 11, + "openhuman.embeddings_update_settings", + json!({ "dimensions": 512 }), + ) + .await; + let result = assert_no_rpc_error(&resp, "update_settings no confirm_wipe"); + let inner = result.get("result").unwrap_or(result); + + assert_eq!( + inner.get("error").and_then(Value::as_str), + Some("EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE"), + "expected EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE sentinel in response body: {inner}" + ); + assert!( + inner.get("old_dimensions").is_some(), + "response should include old_dimensions: {inner}" + ); + assert!( + inner.get("new_dimensions").is_some(), + "response should include new_dimensions: {inner}" + ); + + // With confirm_wipe=true the change should succeed + let confirmed = post_json_rpc( + &rpc_base, + 12, + "openhuman.embeddings_update_settings", + json!({ "dimensions": 512, "confirm_wipe": true }), + ) + .await; + let confirmed_result = assert_no_rpc_error(&confirmed, "update_settings with confirm_wipe"); + let confirmed_inner = confirmed_result.get("result").unwrap_or(confirmed_result); + + // The confirmed update should NOT carry the error sentinel + assert!( + confirmed_inner.get("error").is_none(), + "confirmed update must not return an error sentinel: {confirmed_inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn embeddings_set_and_clear_api_key() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + // Store a key for voyage + let set_resp = post_json_rpc( + &rpc_base, + 20, + "openhuman.embeddings_set_api_key", + json!({ "provider": "voyage", "api_key": "voy-test-1234" }), + ) + .await; + let set_result = assert_no_rpc_error(&set_resp, "embeddings_set_api_key"); + let set_inner = set_result.get("result").unwrap_or(set_result); + assert_eq!( + set_inner.get("stored").and_then(Value::as_bool), + Some(true), + "set_api_key should report stored=true: {set_inner}" + ); + + // get_settings should now show has_api_key=true for voyage + let get_resp = post_json_rpc( + &rpc_base, + 21, + "openhuman.embeddings_get_settings", + json!({}), + ) + .await; + let get_result = assert_no_rpc_error(&get_resp, "get_settings after set_api_key"); + let get_inner = get_result.get("result").unwrap_or(get_result); + let providers = get_inner + .get("providers") + .and_then(Value::as_array) + .expect("providers array missing"); + let voyage = providers + .iter() + .find(|e| e.get("slug").and_then(Value::as_str) == Some("voyage")) + .expect("voyage provider missing from catalog"); + assert_eq!( + voyage.get("has_api_key").and_then(Value::as_bool), + Some(true), + "voyage should report has_api_key=true after storing key: {voyage}" + ); + + // Clear the key + let clear_resp = post_json_rpc( + &rpc_base, + 22, + "openhuman.embeddings_clear_api_key", + json!({ "provider": "voyage" }), + ) + .await; + let clear_result = assert_no_rpc_error(&clear_resp, "embeddings_clear_api_key"); + let clear_inner = clear_result.get("result").unwrap_or(clear_result); + assert_eq!( + clear_inner.get("cleared").and_then(Value::as_bool), + Some(true), + "clear_api_key should report cleared=true: {clear_inner}" + ); + + // has_api_key should be false again + let get2_resp = post_json_rpc( + &rpc_base, + 23, + "openhuman.embeddings_get_settings", + json!({}), + ) + .await; + let get2_result = assert_no_rpc_error(&get2_resp, "get_settings after clear_api_key"); + let get2_inner = get2_result.get("result").unwrap_or(get2_result); + let providers2 = get2_inner + .get("providers") + .and_then(Value::as_array) + .expect("providers array missing after clear"); + let voyage2 = providers2 + .iter() + .find(|e| e.get("slug").and_then(Value::as_str) == Some("voyage")) + .expect("voyage missing after clear"); + assert_eq!( + voyage2.get("has_api_key").and_then(Value::as_bool), + Some(false), + "voyage should report has_api_key=false after clearing key: {voyage2}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn embeddings_test_connection_with_none_provider() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + // Switch to "none" so test_connection uses the noop provider (no network). + let _ = post_json_rpc( + &rpc_base, + 30, + "openhuman.embeddings_update_settings", + json!({ "provider": "none", "confirm_wipe": true }), + ) + .await; + + let resp = post_json_rpc( + &rpc_base, + 31, + "openhuman.embeddings_test_connection", + json!({}), + ) + .await; + let result = assert_no_rpc_error(&resp, "embeddings_test_connection none"); + let inner = result.get("result").unwrap_or(result); + + // The noop provider's embed() returns an empty vec, which causes the + // test_connection result to show success=false with an "Empty embedding + // result" error — that's acceptable. What we assert is that the call + // completes without a JSON-RPC level error and returns a structured body. + assert!( + inner.get("success").is_some(), + "test_connection should return a 'success' field: {inner}" + ); + assert!( + inner.get("provider").is_some(), + "test_connection should return a 'provider' field: {inner}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn embeddings_embed_with_none_returns_empty_vectors() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + // Switch to noop so embed() doesn't require network. + let _ = post_json_rpc( + &rpc_base, + 40, + "openhuman.embeddings_update_settings", + json!({ "provider": "none", "confirm_wipe": true }), + ) + .await; + + let resp = post_json_rpc( + &rpc_base, + 41, + "openhuman.embeddings_embed", + json!({ "inputs": ["hello", "world"] }), + ) + .await; + let result = assert_no_rpc_error(&resp, "embeddings_embed none"); + let inner = result.get("result").unwrap_or(result); + + // NoopEmbedding.embed() returns an empty vec — count and dimensions should both be 0. + assert_eq!( + inner.get("count").and_then(Value::as_u64), + Some(0), + "noop provider should return count=0: {inner}" + ); + assert_eq!( + inner.get("dimensions").and_then(Value::as_u64), + Some(0), + "noop provider should return dimensions=0: {inner}" + ); + let vectors = inner + .get("vectors") + .and_then(Value::as_array) + .expect("embed result must include 'vectors' array: {inner}"); + assert!( + vectors.is_empty(), + "noop provider must return empty vectors array: {vectors:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn legacy_alias_inference_embed_resolves() { + let _lock = embeddings_e2e_env_lock(); + let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await; + + // Set provider to none so the embed call itself doesn't fail on missing keys. + let _ = post_json_rpc( + &rpc_base, + 50, + "openhuman.embeddings_update_settings", + json!({ "provider": "none", "confirm_wipe": true }), + ) + .await; + + // Call via the legacy alias — must NOT return an "unknown method" JSON-RPC + // error; the alias table must rewrite it to openhuman.embeddings_embed. + let resp = post_json_rpc( + &rpc_base, + 51, + "openhuman.inference_embed", + json!({ "inputs": [] }), + ) + .await; + + // If the alias resolution failed we'd get a JSON-RPC error with code -32601. + if let Some(err) = resp.get("error") { + let code = err.get("code").and_then(Value::as_i64).unwrap_or(0); + assert_ne!( + code, -32601, + "legacy alias openhuman.inference_embed resolved to 'method not found' — alias table may be broken: {err}" + ); + } + + // The resolved call should succeed (no JSON-RPC error) and return a result. + let result = assert_no_rpc_error(&resp, "legacy inference_embed alias"); + let inner = result.get("result").unwrap_or(result); + assert!( + inner.get("vectors").is_some() || inner.get("count").is_some(), + "legacy alias should resolve to embeddings_embed and return vector data: {inner}" + ); +}