feat(embeddings): onboarding step + graceful degradation when disabled (#2591)

This commit is contained in:
Steven Enamakel
2026-05-24 20:13:07 -07:00
committed by GitHub
parent 45a1502629
commit 8f2e68db0d
26 changed files with 438 additions and 19 deletions
+1
View File
@@ -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 `<locale>-{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`.
---
+1
View File
@@ -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 `<locale>-{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`.
---
@@ -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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.embeddings.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4">
{!embedded && (
<SettingsHeader
title={t('settings.embeddings.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className={embedded ? '' : 'p-4'}>
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-xs text-stone-500 dark:text-neutral-400">
{status.kind === 'loading'
? t('common.loading')
@@ -318,14 +324,16 @@ const EmbeddingsPanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.embeddings.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{!embedded && (
<SettingsHeader
title={t('settings.embeddings.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className="p-4 space-y-4">
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('settings.embeddings.description')}
</p>
@@ -393,6 +401,15 @@ const EmbeddingsPanel = () => {
})}
</div>
{/* Vector search disabled notice */}
{selectedProvider === 'none' && (
<div className="rounded-xl border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-900/10 p-3">
<p className="text-xs text-amber-800 dark:text-amber-200 leading-relaxed">
{t('settings.embeddings.vectorSearchDisabled')}
</p>
</div>
)}
{/* Model & dimensions (for active provider with catalog models) */}
{currentModels.length > 0 &&
selectedProvider !== 'custom' &&
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+10
View File
@@ -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':
+12
View File
@@ -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',
+5 -3
View File
@@ -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 = () => {
<Route path="custom/voice" element={<CustomVoicePage />} />
<Route path="custom/oauth" element={<CustomOAuthPage />} />
<Route path="custom/search" element={<CustomSearchPage />} />
<Route path="custom/embeddings" element={<CustomEmbeddingsPage />} />
{/* <Route path="custom/memory" element={<CustomMemoryPage />} /> */}
<Route path="*" element={<Navigate to="welcome" replace />} />
</Route>
@@ -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 {
@@ -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<CustomStepKey, string> = {
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<CustomStepKey, string> = {
voice: '/settings/voice',
oauth: '/settings/composio-routing',
search: '/settings/tools',
embeddings: '/settings/embeddings',
memory: '/settings/memory-data',
};
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-embeddings-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.embeddings.title')}
subtitle={t('onboarding.custom.embeddings.subtitle')}
defaultDescription={t('onboarding.custom.embeddings.defaultDesc')}
configureDescription={t('onboarding.custom.embeddings.configureDesc')}
configureContent={<EmbeddingsPanel embedded />}
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;
@@ -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);
+1
View File
@@ -32,6 +32,7 @@ export interface EmbeddingsSettings {
dimensions: number;
rate_limit_per_min: number;
providers: EmbeddingProviderEntry[];
vector_search_enabled: boolean;
}
export interface EmbeddingsUpdateResult {
+11
View File
@@ -43,18 +43,29 @@ pub async fn get_settings(config: &Config) -> Result<RpcOutcome<serde_json::Valu
})
.collect();
let vector_search_enabled = {
let slug = if provider.starts_with("custom:") {
"custom"
} else {
provider.as_str()
};
slug != "none"
};
let payload = serde_json::json!({
"provider": provider,
"model": model,
"dimensions": dimensions,
"rate_limit_per_min": rate_limit,
"providers": providers,
"vector_search_enabled": vector_search_enabled,
});
tracing::debug!(
provider = provider.as_str(),
model = model.as_str(),
dimensions,
vector_search_enabled,
"{LOG_PREFIX} get_settings"
);
+133
View File
@@ -28,6 +28,7 @@ use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_tree::retrieval::{
query_global, query_source, query_topic, search_entities,
};
use crate::openhuman::memory_tree::score::embed::build_embedder_from_config;
fn test_config() -> (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<dyn ChatProvider> = 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;
}
@@ -80,6 +80,21 @@ pub fn build_embedder_from_config(config: &Config) -> Result<Box<dyn Embedder>>
)))
}
_ => {
// 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:<model>` 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.