diff --git a/AGENTS.md b/AGENTS.md index ad7a2af55..20d9d498e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -526,6 +526,7 @@ Follow this order so behavior is **specified**, **proven in Rust**, **proven ove - **No dynamic imports** in production **`app/src`** code — use **static** `import` / `import type` at the top of the module. Do **not** use `import()` (async dynamic import), `React.lazy(() => import(...))`, or `await import('…')` to load app modules, Tauri APIs, or RPC clients. **Why:** predictable chunk graph, simpler static analysis, fewer surprises in Tauri + Vite, and easier code review. **If a module must not run at load time** (e.g. heavy optional path), use a static import and **guard the call site** with `try/catch` or an explicit runtime check instead of deferring module load via dynamic import. **Exceptions:** Vitest harness patterns (`vi.importActual`, dynamic imports **only** inside `*.test.ts` / `__tests__` / `test/setup.ts` when required by the runner); ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc).- **Type-only imports**: `import type` where appropriate. - **Dual socket / tool sync**: If you change realtime protocol, keep **frontend** (`socketService` / MCP transport) and **core** socket behavior aligned (see [`gitbooks/developing/architecture.md`](gitbooks/developing/architecture.md) dual-socket section). - **i18n for all UI text**: Every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, dialog copy, `aria-label`, etc.) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the new key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. **Exceptions:** developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values). +- **i18n chunk files — update ALL locales**: The source-of-truth translation files are the **chunk files** under `app/src/lib/i18n/chunks/` (`en-{1..5}.ts` plus `-{1..5}.ts` for each locale). When adding or changing keys in `en.ts`, you **must also** add them to the corresponding English chunk file (`en-N.ts`) **and** to the same chunk number for every non-English locale (use the English value as a placeholder — translators fill in later). CI enforces parity via `pnpm i18n:check`; missing keys in any locale chunk will fail the i18n coverage gate. Locales: `ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pt`, `ru`, `zh-CN`. --- diff --git a/CLAUDE.md b/CLAUDE.md index b35a41661..24c4eb2c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -322,6 +322,7 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test. - **No dynamic imports** in production `app/src` code — static `import` / `import type` only. No `import()`, `React.lazy(() => import(...))`, `await import(...)`. For heavy optional paths, use a static import and guard the call site with `try/catch` or a runtime check. *Exceptions*: Vitest harness patterns in `*.test.ts` / `__tests__` / `test/setup.ts`; ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc). - **Dual socket sync**: when changing the realtime protocol, keep `socketService` / MCP transport aligned with core socket behavior (see `gitbooks/developing/architecture.md` dual-socket section). - **i18n for all UI text**: every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, error messages, dialog copy) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. Exceptions: developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values). +- **i18n chunk files — update ALL locales**: the source-of-truth translation files are the **chunk files** under `app/src/lib/i18n/chunks/` (`en-{1..5}.ts` plus `-{1..5}.ts` for each locale). When adding or changing keys in `en.ts`, you **must also** add them to the corresponding English chunk file (`en-N.ts`) **and** to the same chunk number for every non-English locale (use the English value as a placeholder — translators fill in later). CI enforces parity via `pnpm i18n:check`; missing keys in any locale chunk will fail the i18n coverage gate. Locales: `ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pt`, `ru`, `zh-CN`. --- diff --git a/app/src/components/settings/panels/EmbeddingsPanel.tsx b/app/src/components/settings/panels/EmbeddingsPanel.tsx index 08398f0e0..0e7b4b423 100644 --- a/app/src/components/settings/panels/EmbeddingsPanel.tsx +++ b/app/src/components/settings/panels/EmbeddingsPanel.tsx @@ -28,7 +28,11 @@ type Status = | { kind: 'saved' } | { kind: 'error'; message: string }; -const EmbeddingsPanel = () => { +interface EmbeddingsPanelProps { + embedded?: boolean; +} + +const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); @@ -74,13 +78,15 @@ const EmbeddingsPanel = () => { if (!settings) { return (
- -
+ {!embedded && ( + + )} +
{status.kind === 'loading' ? t('common.loading') @@ -318,14 +324,16 @@ const EmbeddingsPanel = () => { return (
- + {!embedded && ( + + )} -
+

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

@@ -393,6 +401,15 @@ const EmbeddingsPanel = () => { })}
+ {/* Vector search disabled notice */} + {selectedProvider === 'none' && ( +
+

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

+
+ )} + {/* Model & dimensions (for active provider with catalog models) */} {currentModels.length > 0 && selectedProvider !== 'custom' && diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index e2231ea51..fe3dcf7aa 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -241,6 +241,7 @@ const ar1: TranslationMap = { 'onboarding.custom.stepperVoice': 'الصوت', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'البحث', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'الذاكرة', 'onboarding.custom.stepCounter': 'الخطوة {n} من {total}', 'onboarding.custom.defaultTitle': 'افتراضي', @@ -277,6 +278,13 @@ const ar1: TranslationMap = { 'onboarding.custom.search.defaultDesc': 'يستخدم OpenHuman خادم بحث مُدار. لا حاجة لمفاتيح.', 'onboarding.custom.search.configureDesc': 'استخدم مفتاح مزود البحث الخاص بك (Tavily أو Brave إلخ). اضبطه من الإعدادات › الأدوات.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'الذاكرة', 'onboarding.custom.memory.subtitle': 'كيف يتذكر OpenHuman سياقك وتفضيلاتك ومحادثاتك السابقة.', 'onboarding.custom.memory.defaultDesc': @@ -515,6 +523,8 @@ const ar1: TranslationMap = { 'settings.embeddings.setupTitle': 'إعداد {provider}', 'settings.embeddings.saveAndSwitch': 'حفظ والتبديل', 'settings.embeddings.optional': 'اختياري', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'مسح مفتاح API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 614ba8c59..91b075ff8 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -244,6 +244,7 @@ const bn1: TranslationMap = { 'onboarding.custom.stepperVoice': 'ভয়েস', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'সার্চ', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'মেমোরি', 'onboarding.custom.stepCounter': 'ধাপ {n} / {total}', 'onboarding.custom.defaultTitle': 'ডিফল্ট', @@ -283,6 +284,13 @@ const bn1: TranslationMap = { 'OpenHuman ম্যানেজড সার্চ ব্যাকএন্ড ব্যবহার করে। কোনো কী লাগে না।', 'onboarding.custom.search.configureDesc': 'নিজের সার্চ প্রোভাইডার কী আনুন (Tavily, Brave ইত্যাদি)। Settings › Tools-এ কনফিগার করুন।', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'মেমোরি', 'onboarding.custom.memory.subtitle': 'OpenHuman কীভাবে আপনার কন্টেক্সট, পছন্দ ও পূর্ববর্তী কথোপকথন মনে রাখে।', @@ -524,6 +532,8 @@ const bn1: TranslationMap = { 'settings.embeddings.setupTitle': '{provider} সেটআপ', 'settings.embeddings.saveAndSwitch': 'সংরক্ষণ এবং স্যুইচ', 'settings.embeddings.optional': 'ঐচ্ছিক', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'API কী মুছুন', 'mcp.alphaBadge': 'আলফা', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index 6ac2eeaf8..03a2b65eb 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -289,6 +289,7 @@ const de1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Stimme', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Suchen', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Erinnerung', 'onboarding.custom.stepCounter': 'Schritt {n} von {total}', 'onboarding.custom.defaultTitle': 'Standard', @@ -328,6 +329,13 @@ const de1: TranslationMap = { 'OpenHuman verwendet ein verwaltetes Such-Backend. Keine Schlüssel erforderlich.', 'onboarding.custom.search.configureDesc': 'Bring deinen eigenen Suchanbieterschlüssel mit (Tavily, Brave usw.). Konfiguriere unter Einstellungen › Extras.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Erinnerung', 'onboarding.custom.memory.subtitle': 'Wie OpenHuman sich deinen Kontext, deine Vorlieben und frühere Gespräche merkt.', @@ -535,6 +543,8 @@ const de1: TranslationMap = { 'settings.embeddings.setupTitle': '{provider} einrichten', 'settings.embeddings.saveAndSwitch': 'Speichern & wechseln', 'settings.embeddings.optional': 'optional', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'API-Schlüssel löschen', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index 88b8a4686..54c051686 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -533,6 +533,7 @@ const en1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Voice', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Search', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Memory', 'onboarding.custom.stepCounter': 'Step {n} of {total}', 'onboarding.custom.defaultTitle': 'Default', @@ -572,6 +573,13 @@ const en1: TranslationMap = { 'OpenHuman uses a managed search proxy by default. No search API key needed.', 'onboarding.custom.search.configureDesc': 'Bring your own search provider key (Tavily, Brave, etc.). Configure in Settings › Tools.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Memory', 'onboarding.custom.memory.subtitle': 'How OpenHuman remembers your context, preferences, and prior conversations.', @@ -1053,6 +1061,8 @@ const en1: TranslationMap = { 'settings.embeddings.setupTitle': 'Set up {provider}', 'settings.embeddings.saveAndSwitch': 'Save & switch', 'settings.embeddings.optional': 'optional', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Clear API key', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index 41da03bd7..dc09c7b2b 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -251,6 +251,7 @@ const es1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Voz', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Búsqueda', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Memoria', 'onboarding.custom.stepCounter': 'Paso {n} de {total}', 'onboarding.custom.defaultTitle': 'Por defecto', @@ -290,6 +291,13 @@ const es1: TranslationMap = { 'OpenHuman usa un backend de búsqueda gestionado. Sin claves necesarias.', 'onboarding.custom.search.configureDesc': 'Usa tu propia clave de proveedor de búsqueda (Tavily, Brave, etc.). Configura en Configuración › Herramientas.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Memoria', 'onboarding.custom.memory.subtitle': 'Cómo OpenHuman recuerda tu contexto, preferencias y conversaciones anteriores.', @@ -536,6 +544,8 @@ const es1: TranslationMap = { 'settings.embeddings.setupTitle': 'Configurar {provider}', 'settings.embeddings.saveAndSwitch': 'Guardar y cambiar', 'settings.embeddings.optional': 'opcional', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Borrar clave API', 'mcp.alphaBadge': 'Alfa', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index 0f8b556a8..399d01cb1 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -251,6 +251,7 @@ const fr1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Voix', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Recherche', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Mémoire', 'onboarding.custom.stepCounter': 'Étape {n} sur {total}', 'onboarding.custom.defaultTitle': 'Par défaut', @@ -291,6 +292,13 @@ const fr1: TranslationMap = { 'OpenHuman utilise un backend de recherche géré. Aucune clé nécessaire.', 'onboarding.custom.search.configureDesc': 'Utilise ta propre clé de fournisseur de recherche (Tavily, Brave, etc.). À configurer dans Paramètres › Outils.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Mémoire', 'onboarding.custom.memory.subtitle': 'Comment OpenHuman mémorise ton contexte, tes préférences et tes conversations passées.', @@ -539,6 +547,8 @@ const fr1: TranslationMap = { 'settings.embeddings.setupTitle': 'Configurer {provider}', 'settings.embeddings.saveAndSwitch': 'Enregistrer et changer', 'settings.embeddings.optional': 'optionnel', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Effacer la clé API', 'mcp.alphaBadge': 'Alpha', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index 70a0e9435..589f4fa8a 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -242,6 +242,7 @@ const hi1: TranslationMap = { 'onboarding.custom.stepperVoice': 'वॉइस', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'सर्च', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'मेमोरी', 'onboarding.custom.stepCounter': 'स्टेप {n} / {total}', 'onboarding.custom.defaultTitle': 'डिफ़ॉल्ट', @@ -280,6 +281,13 @@ const hi1: TranslationMap = { 'OpenHuman मैनेज्ड सर्च बैकएंड इस्तेमाल करता है। कोई key नहीं चाहिए।', 'onboarding.custom.search.configureDesc': 'अपनी सर्च प्रोवाइडर key लाएं (Tavily, Brave, आदि)। Settings › Tools में कॉन्फिगर करें।', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'मेमोरी', 'onboarding.custom.memory.subtitle': 'OpenHuman आपका कॉन्टेक्स्ट, पसंद और पुरानी बातें कैसे याद रखता है।', @@ -522,6 +530,8 @@ const hi1: TranslationMap = { 'settings.embeddings.setupTitle': '{provider} सेटअप', 'settings.embeddings.saveAndSwitch': 'सहेजें और बदलें', 'settings.embeddings.optional': 'वैकल्पिक', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'API कुंजी साफ़ करें', 'mcp.alphaBadge': 'अल्फ़ा', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index 27852c6e1..d126869ec 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -245,6 +245,7 @@ const id1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Suara', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Pencarian', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Memori', 'onboarding.custom.stepCounter': 'Langkah {n} dari {total}', 'onboarding.custom.defaultTitle': 'Bawaan', @@ -284,6 +285,13 @@ const id1: TranslationMap = { 'OpenHuman menggunakan backend pencarian terkelola. Tidak perlu key.', 'onboarding.custom.search.configureDesc': 'Bawa key penyedia pencarian Anda sendiri (Tavily, Brave, dll.). Konfigurasi di Pengaturan › Alat.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Memori', 'onboarding.custom.memory.subtitle': 'Cara OpenHuman mengingat konteks, preferensi, dan percakapan sebelumnya.', @@ -527,6 +535,8 @@ const id1: TranslationMap = { 'settings.embeddings.setupTitle': 'Siapkan {provider}', 'settings.embeddings.saveAndSwitch': 'Simpan & ganti', 'settings.embeddings.optional': 'opsional', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Hapus kunci API', 'mcp.alphaBadge': 'Alfa', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 147337b97..d2614bef6 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -248,6 +248,7 @@ const it1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Voce', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Ricerca', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Memoria', 'onboarding.custom.stepCounter': 'Passo {n} di {total}', 'onboarding.custom.defaultTitle': 'Predefinito', @@ -287,6 +288,13 @@ const it1: TranslationMap = { 'OpenHuman usa un backend di ricerca gestito. Nessuna chiave necessaria.', 'onboarding.custom.search.configureDesc': 'Porta la tua chiave di provider di ricerca (Tavily, Brave, ecc.). Configura in Impostazioni › Strumenti.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Memoria', 'onboarding.custom.memory.subtitle': 'Come OpenHuman ricorda il tuo contesto, le preferenze e le conversazioni precedenti.', @@ -532,6 +540,8 @@ const it1: TranslationMap = { 'settings.embeddings.setupTitle': 'Configura {provider}', 'settings.embeddings.saveAndSwitch': 'Salva e cambia', 'settings.embeddings.optional': 'opzionale', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Cancella chiave API', 'mcp.alphaBadge': 'Alfa', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 9a6821430..fa4fb5d2e 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -243,6 +243,7 @@ const ko1: TranslationMap = { 'onboarding.custom.stepperVoice': '음성', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': '검색', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': '메모리', 'onboarding.custom.stepCounter': '{total}단계 중 {n}단계', 'onboarding.custom.defaultTitle': '기본값', @@ -283,6 +284,13 @@ const ko1: TranslationMap = { 'OpenHuman은 관리형 검색 백엔드를 사용합니다. 키가 필요 없습니다.', 'onboarding.custom.search.configureDesc': '직접 검색 제공업체 키(Tavily, Brave 등)를 가져오세요. 설정 › 도구에서 구성할 수 있습니다.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': '메모리', 'onboarding.custom.memory.subtitle': 'OpenHuman이 사용자의 컨텍스트, 선호도, 이전 대화를 기억하는 방식입니다.', @@ -524,6 +532,8 @@ const ko1: TranslationMap = { 'settings.embeddings.setupTitle': '{provider} 설정', 'settings.embeddings.saveAndSwitch': '저장 및 전환', 'settings.embeddings.optional': '선택사항', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'API 키 삭제', 'mcp.alphaBadge': '알파', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index 9152039ac..65ba03d02 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -251,6 +251,7 @@ const pt1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Voz', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Pesquisa', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Memória', 'onboarding.custom.stepCounter': 'Etapa {n} de {total}', 'onboarding.custom.defaultTitle': 'Padrão', @@ -290,6 +291,13 @@ const pt1: TranslationMap = { 'O OpenHuman usa um backend de pesquisa gerenciado. Sem chaves necessárias.', 'onboarding.custom.search.configureDesc': 'Traga sua própria chave de provedor de pesquisa (Tavily, Brave, etc.). Configure em Configurações › Ferramentas.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Memória', 'onboarding.custom.memory.subtitle': 'Como o OpenHuman se lembra do seu contexto, preferências e conversas anteriores.', @@ -537,6 +545,8 @@ const pt1: TranslationMap = { 'settings.embeddings.setupTitle': 'Configurar {provider}', 'settings.embeddings.saveAndSwitch': 'Salvar e trocar', 'settings.embeddings.optional': 'opcional', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Limpar chave API', 'mcp.alphaBadge': 'Alfa', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index 665637453..7231ba72c 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -245,6 +245,7 @@ const ru1: TranslationMap = { 'onboarding.custom.stepperVoice': 'Голос', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Поиск', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Память', 'onboarding.custom.stepCounter': 'Шаг {n} из {total}', 'onboarding.custom.defaultTitle': 'По умолчанию', @@ -283,6 +284,13 @@ const ru1: TranslationMap = { 'OpenHuman использует управляемый поисковый бэкенд. Ключи не нужны.', 'onboarding.custom.search.configureDesc': 'Используй свой ключ поискового провайдера (Tavily, Brave и др.). Настрой в Настройки › Инструменты.', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': 'Память', 'onboarding.custom.memory.subtitle': 'Как OpenHuman запоминает контекст, предпочтения и предыдущие разговоры.', @@ -527,6 +535,8 @@ const ru1: TranslationMap = { 'settings.embeddings.setupTitle': 'Настройка {provider}', 'settings.embeddings.saveAndSwitch': 'Сохранить и переключить', 'settings.embeddings.optional': 'необязательно', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Удалить API-ключ', 'mcp.alphaBadge': 'Альфа', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 8bf413cdc..f0e43acb8 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -236,6 +236,7 @@ const zhCN1: TranslationMap = { 'onboarding.custom.stepperVoice': '语音', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': '搜索', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': '记忆', 'onboarding.custom.stepCounter': '第 {n} 步,共 {total} 步', 'onboarding.custom.defaultTitle': '默认', @@ -271,6 +272,13 @@ const zhCN1: TranslationMap = { 'onboarding.custom.search.defaultDesc': 'OpenHuman 使用托管搜索后端。无需密钥。', 'onboarding.custom.search.configureDesc': '使用你自己的搜索提供商密钥(Tavily、Brave 等)。在设置 › 工具中配置。', + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', 'onboarding.custom.memory.title': '记忆', 'onboarding.custom.memory.subtitle': 'OpenHuman 如何记住你的上下文、偏好和历史对话。', 'onboarding.custom.memory.defaultDesc': 'OpenHuman 会自动管理记忆存储和检索。无需设置。', @@ -508,6 +516,8 @@ const zhCN1: TranslationMap = { 'settings.embeddings.setupTitle': '设置 {provider}', 'settings.embeddings.saveAndSwitch': '保存并切换', 'settings.embeddings.optional': '可选', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': '清除 API 密钥', 'mcp.alphaBadge': '阿尔法', 'mcp.alphaBannerText': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index e2659d8c1..491bc15c0 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -356,6 +356,7 @@ const en: TranslationMap = { 'onboarding.custom.stepperVoice': 'Voice', 'onboarding.custom.stepperOAuth': 'OAuth', 'onboarding.custom.stepperSearch': 'Search', + 'onboarding.custom.stepperEmbeddings': 'Embeddings', 'onboarding.custom.stepperMemory': 'Memory', 'onboarding.custom.stepCounter': 'Step {n} of {total}', 'onboarding.custom.defaultTitle': 'Default', @@ -404,6 +405,15 @@ const en: TranslationMap = { 'onboarding.custom.search.configureDesc': 'Bring your own search provider key (Tavily, Brave, etc.). Configure in Settings › Tools.', + // Onboarding: Custom > Embeddings + 'onboarding.custom.embeddings.title': 'Embeddings', + 'onboarding.custom.embeddings.subtitle': + 'How OpenHuman generates vector embeddings for semantic memory search.', + 'onboarding.custom.embeddings.defaultDesc': + 'OpenHuman uses a managed embedding service. No API key needed.', + 'onboarding.custom.embeddings.configureDesc': + 'Bring your own embedding provider (OpenAI, Voyage, Ollama, etc.).', + // Onboarding: Custom > Memory 'onboarding.custom.memory.title': 'Memory', 'onboarding.custom.memory.subtitle': @@ -654,6 +664,8 @@ const en: TranslationMap = { 'settings.embeddings.setupTitle': 'Set up {provider}', 'settings.embeddings.saveAndSwitch': 'Save & switch', 'settings.embeddings.optional': 'optional', + 'settings.embeddings.vectorSearchDisabled': + 'Vector search is disabled. Memory recall will use keyword matching and recency only — no semantic ranking.', 'settings.embeddings.clearKey': 'Clear API key', 'pages.settings.ai.embeddings': 'Embeddings', 'pages.settings.ai.embeddingsDesc': 'Vector encoding model for memory retrieval', diff --git a/app/src/pages/onboarding/Onboarding.tsx b/app/src/pages/onboarding/Onboarding.tsx index 48271e584..963e20c7c 100644 --- a/app/src/pages/onboarding/Onboarding.tsx +++ b/app/src/pages/onboarding/Onboarding.tsx @@ -1,9 +1,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import OnboardingLayout from './OnboardingLayout'; +import CustomEmbeddingsPage from './pages/CustomEmbeddingsPage'; import CustomInferencePage from './pages/CustomInferencePage'; -// Search + Memory steps are hidden from the flow for now (files kept on -// disk; uncomment alongside customWizardSteps.ts to re-enable): +// Memory step is hidden from the flow for now (file kept on disk; +// uncomment alongside customWizardSteps.ts to re-enable): // import CustomMemoryPage from './pages/CustomMemoryPage'; import CustomOAuthPage from './pages/CustomOAuthPage'; import CustomSearchPage from './pages/CustomSearchPage'; @@ -16,7 +17,7 @@ import WelcomePage from './pages/WelcomePage'; * * welcome → runtime-choice * ├── cloud → /home - * └── custom → /custom/inference → voice → oauth → search → memory → /home + * └── custom → /custom/inference → voice → oauth → search → embeddings → memory → /home * * Each custom step asks Default (let OpenHuman manage it) vs Configure * (let me pick). Default is a one-click pick; Configure renders inline @@ -37,6 +38,7 @@ const Onboarding = () => { } /> } /> } /> + } /> {/* } /> */} } /> diff --git a/app/src/pages/onboarding/OnboardingContext.tsx b/app/src/pages/onboarding/OnboardingContext.tsx index 65b1b39a8..a962482d6 100644 --- a/app/src/pages/onboarding/OnboardingContext.tsx +++ b/app/src/pages/onboarding/OnboardingContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext } from 'react'; export type AiMode = 'cloud' | 'custom'; -export type CustomStepKey = 'inference' | 'voice' | 'oauth' | 'search' | 'memory'; +export type CustomStepKey = 'inference' | 'voice' | 'oauth' | 'search' | 'embeddings' | 'memory'; export type CustomStepChoice = 'default' | 'configure'; export interface OnboardingDraft { diff --git a/app/src/pages/onboarding/customWizardSteps.ts b/app/src/pages/onboarding/customWizardSteps.ts index 9dbbc7ca6..e9b09264f 100644 --- a/app/src/pages/onboarding/customWizardSteps.ts +++ b/app/src/pages/onboarding/customWizardSteps.ts @@ -8,6 +8,7 @@ export const CUSTOM_WIZARD_STEPS: CustomStepKey[] = [ 'voice', 'oauth', 'search', + 'embeddings', // 'memory', ]; @@ -16,6 +17,7 @@ export const CUSTOM_WIZARD_ROUTES: Record = { voice: '/onboarding/custom/voice', oauth: '/onboarding/custom/oauth', search: '/onboarding/custom/search', + embeddings: '/onboarding/custom/embeddings', memory: '/onboarding/custom/memory', }; @@ -26,5 +28,6 @@ export const CUSTOM_WIZARD_SETTINGS_ROUTES: Record = { voice: '/settings/voice', oauth: '/settings/composio-routing', search: '/settings/tools', + embeddings: '/settings/embeddings', memory: '/settings/memory-data', }; diff --git a/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx b/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx new file mode 100644 index 000000000..bf5f33064 --- /dev/null +++ b/app/src/pages/onboarding/pages/CustomEmbeddingsPage.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import EmbeddingsPanel from '../../../components/settings/panels/EmbeddingsPanel'; +import { useT } from '../../../lib/i18n/I18nContext'; +import { useCoreState } from '../../../providers/CoreStateProvider'; +import { trackEvent } from '../../../services/analytics'; +import { isLocalSessionToken } from '../../../utils/localSession'; +import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps'; +import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext'; +import CustomWizardStep from '../steps/CustomWizardStep'; + +const STEP_KEY = 'embeddings' as const; +const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY); +const LOCAL_DEFAULT_DISABLED_REASON = + 'Managed setup requires OpenHuman sign-in and is unavailable in local mode.'; + +const CustomEmbeddingsPage = () => { + const { t } = useT(); + const navigate = useNavigate(); + const { snapshot } = useCoreState(); + const { draft, setDraft, completeAndExit } = useOnboardingContext(); + const isLocalSession = isLocalSessionToken(snapshot.sessionToken); + + const [choice, setChoice] = useState( + draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null) + ); + + useEffect(() => { + if (!isLocalSession) { + return; + } + setChoice('configure'); + setDraft(prev => ({ + ...prev, + customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' }, + })); + }, [isLocalSession, setDraft]); + + const persistChoice = (next: CustomStepChoice) => { + setChoice(next); + setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } })); + }; + + const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1; + + return ( + } + defaultDisabled={isLocalSession} + defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined} + hideChoiceCards={isLocalSession} + choice={choice} + onChoiceChange={persistChoice} + onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])} + onContinue={async () => { + trackEvent('onboarding_step_complete', { + step_name: 'custom_embeddings', + choice: choice ?? 'default', + }); + if (isLast) { + try { + await completeAndExit(); + } catch (err) { + console.error('[onboarding:custom-embeddings] completeAndExit failed', err); + } + return; + } + navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]); + }} + continueLabel={isLast ? t('onboarding.custom.finish') : undefined} + /> + ); +}; + +export default CustomEmbeddingsPage; diff --git a/app/src/pages/onboarding/steps/CustomWizardStep.tsx b/app/src/pages/onboarding/steps/CustomWizardStep.tsx index 643047e6f..1ab95d70c 100644 --- a/app/src/pages/onboarding/steps/CustomWizardStep.tsx +++ b/app/src/pages/onboarding/steps/CustomWizardStep.tsx @@ -113,6 +113,7 @@ const CustomWizardStep = ({ t('onboarding.custom.stepperVoice'), t('onboarding.custom.stepperOAuth'), t('onboarding.custom.stepperSearch'), + t('onboarding.custom.stepperEmbeddings'), t('onboarding.custom.stepperMemory'), ].slice(0, stepCount); diff --git a/app/src/services/api/embeddingsApi.ts b/app/src/services/api/embeddingsApi.ts index bb5052ce9..a11f11771 100644 --- a/app/src/services/api/embeddingsApi.ts +++ b/app/src/services/api/embeddingsApi.ts @@ -32,6 +32,7 @@ export interface EmbeddingsSettings { dimensions: number; rate_limit_per_min: number; providers: EmbeddingProviderEntry[]; + vector_search_enabled: boolean; } export interface EmbeddingsUpdateResult { diff --git a/src/openhuman/embeddings/rpc.rs b/src/openhuman/embeddings/rpc.rs index 3bdacdd47..74deafedc 100644 --- a/src/openhuman/embeddings/rpc.rs +++ b/src/openhuman/embeddings/rpc.rs @@ -43,18 +43,29 @@ pub async fn get_settings(config: &Config) -> Result (TempDir, Config) { let tmp = TempDir::new().unwrap(); @@ -364,3 +365,135 @@ async fn full_pipeline_ingest_to_retrieval() { }) .await; } + +/// When `embeddings_provider = "none"`, the full ingest → retrieval pipeline +/// must still work end-to-end. Semantic rerank degrades to recency ordering +/// (InertEmbedder produces zero vectors → cosine similarity = 0), but chunks +/// are still written with valid zero-vector embeddings, and source-tree +/// retrieval succeeds via the recency fallback path. +/// +/// This guards against regressions where disabling embeddings causes panics, +/// schema mismatches, or silent data loss in the memory subsystem. +#[tokio::test] +async fn pipeline_works_with_embeddings_disabled() { + let (_tmp, mut cfg) = test_config(); + cfg.embeddings_provider = Some("none".into()); + + // Verify the factory returns InertEmbedder for this config. + let embedder = build_embedder_from_config(&cfg).expect("factory must succeed for 'none'"); + assert_eq!( + embedder.name(), + "inert", + "embeddings_provider=none must route to InertEmbedder" + ); + + let provider: Arc = Arc::new(StaticChatProvider::new( + r#"{"summary":"bob@example.com discussed the quarterly review.","entities":["email:bob@example.com"],"topics":["quarterly","review"]}"#, + )); + + test_override::with_provider(Arc::clone(&provider), async { + // ── Ingest a heavy batch to cross the seal threshold ──────────── + let source_id = "slack:#disabled-embed-test"; + let result = ingest_chat( + &cfg, + source_id, + "bob", + vec!["test".into()], + heavy_batch("slack", "#disabled-embed-test", 0), + ) + .await + .expect("ingest_chat must succeed with embeddings disabled"); + + assert!( + result.chunks_written >= 1, + "ingest must write at least one chunk even with embeddings disabled" + ); + + log::debug!( + "[tree_e2e_test::embeddings_disabled] ingest: chunks_written={} dropped={}", + result.chunks_written, + result.chunks_dropped + ); + + // ── Drain the async job queue ─────────────────────────────────── + drain_until_idle(&cfg) + .await + .expect("drain_until_idle must succeed with embeddings disabled"); + + // ── Source-tree retrieval without a query (recency only) ───────── + use crate::openhuman::memory_store::chunks::types::SourceKind; + let recency_resp = query_source(&cfg, None, Some(SourceKind::Chat), None, None, 20) + .await + .expect("query_source (recency) must succeed with embeddings disabled"); + + log::debug!( + "[tree_e2e_test::embeddings_disabled] query_source (recency): total={} hits={}", + recency_resp.total, + recency_resp.hits.len() + ); + + assert!( + recency_resp.total >= recency_resp.hits.len(), + "query_source total must be >= hits.len()" + ); + + // ── Source-tree retrieval WITH a query (semantic rerank path) ──── + // This exercises the codepath where build_embedder_from_config is + // called internally. With InertEmbedder, the rerank degrades to + // recency ordering but must not error. + let semantic_resp = query_source( + &cfg, + None, + Some(SourceKind::Chat), + None, + Some("quarterly review"), + 20, + ) + .await + .expect( + "query_source (semantic) must succeed with embeddings disabled — \ + InertEmbedder should degrade gracefully to recency ordering", + ); + + log::debug!( + "[tree_e2e_test::embeddings_disabled] query_source (semantic): total={} hits={}", + semantic_resp.total, + semantic_resp.hits.len() + ); + + assert!( + semantic_resp.total >= semantic_resp.hits.len(), + "query_source total must be >= hits.len()" + ); + + // ── Global digest should also succeed ─────────────────────────── + let today = Utc::now().date_naive(); + let digest_outcome = end_of_day_digest(&cfg, today) + .await + .expect("end_of_day_digest must succeed with embeddings disabled"); + + log::debug!( + "[tree_e2e_test::embeddings_disabled] digest outcome: {:?}", + digest_outcome + ); + + // ── Entity search (keyword-based, no embeddings needed) ───────── + let entity_matches = search_entities(&cfg, "bob", None, 10) + .await + .expect("search_entities must succeed with embeddings disabled"); + + log::debug!( + "[tree_e2e_test::embeddings_disabled] search_entities('bob'): {} matches", + entity_matches.len() + ); + + log::info!( + "[tree_e2e_test] pipeline_works_with_embeddings_disabled PASSED \ + recency_hits={} semantic_hits={} entity_matches={}", + recency_resp.hits.len(), + semantic_resp.hits.len(), + entity_matches.len() + ); + }) + .await; +} diff --git a/src/openhuman/memory_tree/score/embed/factory.rs b/src/openhuman/memory_tree/score/embed/factory.rs index e3fb73a96..71ea9c260 100644 --- a/src/openhuman/memory_tree/score/embed/factory.rs +++ b/src/openhuman/memory_tree/score/embed/factory.rs @@ -80,6 +80,21 @@ pub fn build_embedder_from_config(config: &Config) -> Result> ))) } _ => { + // If the user explicitly disabled embeddings, return InertEmbedder + // so semantic rerank degrades to recency-only ordering. + if config + .embeddings_provider + .as_deref() + .map(|s| s.trim()) + .is_some_and(|s| s == "none") + { + log::info!( + "[memory_tree::embed::factory] embeddings_provider=none — \ + using InertEmbedder (vector search disabled)" + ); + return Ok(Box::new(InertEmbedder::new())); + } + // Honour the unified AI settings: `embeddings_provider` is the // single source of truth. When it parses as `ollama:` we // route locally; otherwise we fall back to the cloud session. @@ -241,6 +256,15 @@ mod tests { assert_eq!(e.name(), "cloud"); } + #[test] + fn none_provider_returns_inert() { + let (_tmp, mut cfg) = test_config(); + cfg.embeddings_provider = Some("none".into()); + touch_auth_profile(&cfg); + let e = build_embedder_from_config(&cfg).expect("none should build"); + assert_eq!(e.name(), "inert"); + } + #[test] fn explicit_endpoint_override_wins_over_local_ai_flag() { // Power-user override beats the checkbox.