diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx index a13e13d76d..530063fbae 100644 --- a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx @@ -39,6 +39,7 @@ vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch })); vi.mock('../analytics', () => ({ trackAnalyticsEvent: (...args: unknown[]) => mockTrackAnalyticsEvent(...args), })); +const mockSetCloudSummarization = vi.fn(); vi.mock('../../utils/tauriCommands', async importOriginal => { // Inherit everything else (types, sibling wrappers) verbatim so the panel @@ -52,6 +53,7 @@ vi.mock('../../utils/tauriCommands', async importOriginal => { memorySyncStatusList: (...args: unknown[]) => mockSyncStatusList(...args), memoryTreeRetryFailed: (...args: unknown[]) => mockRetryFailed(...args), memoryNamespaceSummaries: (...args: unknown[]) => mockNamespaceSummaries(...args), + memoryTreeSetCloudSummarization: (...args: unknown[]) => mockSetCloudSummarization(...args), }; }); @@ -87,6 +89,8 @@ describe('', () => { mockRetryFailed.mockReset(); mockNamespaceSummaries.mockReset(); mockTrackAnalyticsEvent.mockReset(); + mockSetCloudSummarization.mockReset(); + mockSetCloudSummarization.mockResolvedValue(undefined); mockSyncStatusList.mockResolvedValue([]); // default: empty, harmless to existing tests // Same default the inline stub used to hard-code, now re-programmable // per test so the failure branch can be driven. @@ -182,6 +186,90 @@ describe('', () => { expect(toggle.getAttribute('aria-checked')).toBe('false'); }); + it('reflects stored cloud-summarization consent and can withdraw it', async () => { + // The remediation for `summarizer_unavailable` names this flag, and until + // now nothing in the app could set it. The control must show the STORED + // value — a default-rendered switch would misreport whether memory + // summaries are allowed to leave the machine. + mockPipelineStatus + .mockResolvedValueOnce(payload({ cloud_summarization_opt_in: true })) + .mockResolvedValue(payload({ cloud_summarization_opt_in: false })); + + render(); + + const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle'); + await waitFor(() => { + expect(toggle.getAttribute('aria-checked')).toBe('true'); + }); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(mockSetCloudSummarization).toHaveBeenCalledWith(false); + }); + await waitFor(() => { + expect(toggle.getAttribute('aria-checked')).toBe('false'); + }); + }); + + it('offers the cloud-summarization toggle even when the summarizer is fine', async () => { + // A control that only appeared alongside the error could grant consent but + // never withdraw it: fixing the problem would take the switch away. + mockPipelineStatus.mockResolvedValue( + payload({ cloud_summarization_opt_in: false, first_blocking_cause: null }) + ); + + render(); + + const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle'); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + + fireEvent.click(toggle); + await waitFor(() => { + expect(mockSetCloudSummarization).toHaveBeenCalledWith(true); + }); + }); + + it('surfaces a failed consent change instead of leaving the switch lying', async () => { + // The toggle is optimistic about nothing: on failure the panel re-reads the + // stored value on the next poll, so the switch must not be left showing a + // consent state the core never recorded. The toast is how the user learns + // that — silently swallowing the rejection would show "off" for a machine + // still summarising in the cloud. + mockPipelineStatus.mockResolvedValue(payload({ cloud_summarization_opt_in: false })); + mockSetCloudSummarization.mockRejectedValueOnce(new Error('core unreachable')); + const onToast = vi.fn(); + + render(); + + const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle'); + fireEvent.click(toggle); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'core unreachable' }) + ); + }); + + // And the control comes back — a toggle stuck disabled after one failure + // cannot be used to withdraw consent later. + await waitFor(() => { + expect(toggle.getAttribute('aria-checked')).toBe('false'); + expect((toggle as HTMLButtonElement).disabled).toBe(false); + }); + }); + + it('treats a core that predates the field as not opted in', async () => { + // `cloud_summarization_opt_in` is `#[serde(default)]` on the wire, so an + // older core omits it. Absent must read as "no consent", never as "on". + mockPipelineStatus.mockResolvedValue(payload()); + + render(); + + const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle'); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + }); + it('renders a paused pill with the reason from the wire payload', async () => { mockPipelineStatus.mockResolvedValueOnce( payload({ status: 'paused', is_paused: true, reason: 'scheduler gate mode = off' }) diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx index e6214dbd3d..455c83bea0 100644 --- a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx @@ -26,7 +26,11 @@ import { useT } from '../../lib/i18n/I18nContext'; import { reportMemoryPipelineFailure, reportMemoryQuarantine } from '../../lib/userErrors/report'; import { useAppDispatch } from '../../store/hooks'; import type { ToastNotification } from '../../types/intelligence'; -import { memoryTreeRetryFailed, memoryTreeSetEnabled } from '../../utils/tauriCommands'; +import { + memoryTreeRetryFailed, + memoryTreeSetCloudSummarization, + memoryTreeSetEnabled, +} from '../../utils/tauriCommands'; import { trackAnalyticsEvent } from '../analytics'; import { Card } from '../ui'; import Button from '../ui/Button'; @@ -90,6 +94,7 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { reportMemoryQuarantine(dispatch, quarantine); // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the fields that matter }, [dispatch, quarantineKey]); + const [cloudBusy, setCloudBusy] = useState(false); const handleToggle = useCallback(async () => { if (!status || toggleBusy) return; @@ -147,6 +152,27 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { setRetryBusy(false); } }, [retryBusy, refresh, onToast, t]); + const handleCloudSummarizationToggle = useCallback(async () => { + if (!status || cloudBusy) return; + const next = !(status.cloud_summarization_opt_in ?? false); + console.debug('[ui-flow][memory-tree-status] cloud-summarization toggle: entry next=%s', next); + setCloudBusy(true); + try { + await memoryTreeSetCloudSummarization(next); + trackAnalyticsEvent('memory_tree_cloud_summarization_changed', { opted_in: next }); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[ui-flow][memory-tree-status] cloud-summarization toggle: error %s', message); + onToast?.({ + type: 'error', + title: t('memoryTree.status.cloudSummarizationToggleFailed'), + message, + }); + } finally { + setCloudBusy(false); + } + }, [status, cloudBusy, refresh, onToast, t]); const statusKind = status?.status ?? 'idle'; // #5324: "Error — 936 unrecoverable failures need action" told the user @@ -192,6 +218,7 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { const failedJobs = status?.pipeline_jobs.failed ?? 0; const checked = !(status?.is_paused ?? false); + const cloudSummarizationOn = status?.cloud_summarization_opt_in ?? false; const labelClass = 'text-[11px] uppercase tracking-wide text-content-muted mb-1'; const valueClass = 'text-xl font-semibold text-content'; @@ -408,6 +435,44 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { data-testid="memory-tree-status-toggle" /> + + {/* Cloud-summarization consent. Rendered unconditionally, next to the + auto-sync toggle, rather than only while `summarizer_unavailable` is + live: a control that appears with the error and vanishes once it is + fixed cannot be used to withdraw the consent it granted. */} +
+
+
+ {t('memoryTree.status.cloudSummarizationLabel')} +
+
+ {t('memoryTree.status.cloudSummarizationDescription')} +
+
+ +
); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index fe1c7ff736..7fda1c24a3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -925,6 +925,10 @@ const messages: TranslationMap = { 'memoryTree.status.title': 'شجرة الذاكرة', 'memoryTree.status.autoSyncLabel': 'النظام الآلي', 'memoryTree.status.autoSyncDescription': 'توقف عن الابتلاع (ويكي) الحالي يبقى قابلاً للتساؤل', + 'memoryTree.status.cloudSummarizationLabel': 'التلخيص السحابي', + 'memoryTree.status.cloudSummarizationDescription': + 'يتيح إنشاء أشجار التلخيص دون ذكاء اصطناعي محلي. تُرسَل ملخصات الذاكرة إلى مزوّد السحابة الذي أعددته.', + 'memoryTree.status.cloudSummarizationToggleFailed': 'تعذّر تغيير التلخيص السحابي', 'memoryTree.status.statusTile': 'الحالة', 'memoryTree.status.lastSyncTile': 'آخر تزامن', 'memoryTree.status.totalChunksTile': 'أوراق شجرة الملخصات', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 6ae2a65470..182e04068d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -952,6 +952,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'স্বয়ংক্রিয়-sync', 'memoryTree.status.autoSyncDescription': 'নতুন অভিযান থামাতে বিরতি দাও। এখানে বিদ্যমান উইকি অনুসন্ধানের জন্য অপেক্ষা করছে।', + 'memoryTree.status.cloudSummarizationLabel': 'ক্লাউড সারাংশ', + 'memoryTree.status.cloudSummarizationDescription': + 'স্থানীয় AI ছাড়াই সারাংশ ট্রি তৈরি করতে দেয়। মেমরি সারাংশ আপনার কনফিগার করা ক্লাউড প্রদানকারীর কাছে পাঠানো হয়।', + 'memoryTree.status.cloudSummarizationToggleFailed': 'ক্লাউড সারাংশ পরিবর্তন করা যায়নি', 'memoryTree.status.statusTile': 'অবস্থা', 'memoryTree.status.lastSyncTile': 'সর্বশেষ সুসংগতি', 'memoryTree.status.totalChunksTile': 'সারাংশ-বৃক্ষের পাতা', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 8f5fafdc15..38002b378a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -986,6 +986,11 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Auto Sync', 'memoryTree.status.autoSyncDescription': 'Pausieren Sie, um die erneute Einnahme zu stoppen. Vorhandenes Wiki bleibt abfragbar.', + 'memoryTree.status.cloudSummarizationLabel': 'Cloud-Zusammenfassung', + 'memoryTree.status.cloudSummarizationDescription': + 'Ermöglicht Zusammenfassungsbäume ohne lokale KI. Speicher-Zusammenfassungen werden an Ihren konfigurierten Cloud-Anbieter gesendet.', + 'memoryTree.status.cloudSummarizationToggleFailed': + 'Cloud-Zusammenfassung konnte nicht geändert werden', 'memoryTree.status.statusTile': 'Status', 'memoryTree.status.lastSyncTile': 'Letzte Synchronisierung', 'memoryTree.status.totalChunksTile': 'Blätter des Zusammenfassungsbaums', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8e816cab95..654acfb48e 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -909,6 +909,10 @@ const en: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Auto-sync', 'memoryTree.status.autoSyncDescription': 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.cloudSummarizationLabel': 'Cloud summarization', + 'memoryTree.status.cloudSummarizationDescription': + 'Lets summary trees build without local AI. Memory summaries are sent to your configured cloud provider.', + 'memoryTree.status.cloudSummarizationToggleFailed': "Couldn't change cloud summarization", 'memoryTree.status.statusTile': 'Status', 'memoryTree.status.lastSyncTile': 'Last sync', 'memoryTree.status.totalChunksTile': 'Summary-tree leaves', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 13a9bb57c8..030ad6bb3d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -973,6 +973,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Auto-sincronización', 'memoryTree.status.autoSyncDescription': 'Pausa para detener la nueva ingestión. El wiki existente sigue siendo consultable.', + 'memoryTree.status.cloudSummarizationLabel': 'Resumen en la nube', + 'memoryTree.status.cloudSummarizationDescription': + 'Permite crear árboles de resumen sin IA local. Los resúmenes de memoria se envían a tu proveedor de nube configurado.', + 'memoryTree.status.cloudSummarizationToggleFailed': 'No se pudo cambiar el resumen en la nube', 'memoryTree.status.statusTile': 'Estado', 'memoryTree.status.lastSyncTile': 'Última sincronización', 'memoryTree.status.totalChunksTile': 'Hojas del árbol de resúmenes', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 557ff3bdbe..72ac214be6 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -985,6 +985,11 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Auto-synchronisation', 'memoryTree.status.autoSyncDescription': 'Pause pour arrêter la nouvelle ingestion. Le wiki existant reste consultable.', + 'memoryTree.status.cloudSummarizationLabel': 'Résumé dans le cloud', + 'memoryTree.status.cloudSummarizationDescription': + 'Permet de créer des arbres de résumé sans IA locale. Les résumés de mémoire sont envoyés à votre fournisseur cloud configuré.', + 'memoryTree.status.cloudSummarizationToggleFailed': + 'Impossible de modifier le résumé dans le cloud', 'memoryTree.status.statusTile': 'Statut', 'memoryTree.status.lastSyncTile': 'Dernière synchronisation', 'memoryTree.status.totalChunksTile': "Feuilles de l'arbre de synthèse", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index f2cb6c40f2..11bd853a88 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -951,6 +951,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'ऑटो सिंक', 'memoryTree.status.autoSyncDescription': 'नए अंतर्ग्रहण को रोकने के लिए रोकें। मौजूदा विकि क्वेरी योग्य रहता है।', + 'memoryTree.status.cloudSummarizationLabel': 'क्लाउड सारांश', + 'memoryTree.status.cloudSummarizationDescription': + 'स्थानीय AI के बिना सारांश ट्री बनाने देता है। मेमोरी सारांश आपके कॉन्फ़िगर किए गए क्लाउड प्रदाता को भेजे जाते हैं।', + 'memoryTree.status.cloudSummarizationToggleFailed': 'क्लाउड सारांश नहीं बदला जा सका', 'memoryTree.status.statusTile': 'स्थिति', 'memoryTree.status.lastSyncTile': 'अंतिम सिंक', 'memoryTree.status.totalChunksTile': 'सारांश-वृक्ष की पत्तियाँ', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 6a29e24434..b56d5398b4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -962,6 +962,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Sinkronisasi otomatis', 'memoryTree.status.autoSyncDescription': 'Jeda untuk menghentikan ingest baru. Wiki yang ada tetap dapat dikueri.', + 'memoryTree.status.cloudSummarizationLabel': 'Ringkasan cloud', + 'memoryTree.status.cloudSummarizationDescription': + 'Memungkinkan pohon ringkasan dibuat tanpa AI lokal. Ringkasan memori dikirim ke penyedia cloud yang Anda konfigurasikan.', + 'memoryTree.status.cloudSummarizationToggleFailed': 'Tidak dapat mengubah ringkasan cloud', 'memoryTree.status.statusTile': 'Status', 'memoryTree.status.lastSyncTile': 'Sinkronisasi terakhir', 'memoryTree.status.totalChunksTile': 'Daun pohon ringkasan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3f9a9e9487..3d2b612e3b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -977,6 +977,11 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Sincronizzazione automatica', 'memoryTree.status.autoSyncDescription': 'Pausa per interrompere la nuova ingestione. La wiki esistente rimane interrogabile.', + 'memoryTree.status.cloudSummarizationLabel': 'Riepilogo nel cloud', + 'memoryTree.status.cloudSummarizationDescription': + 'Consente di creare alberi di riepilogo senza IA locale. I riepiloghi della memoria vengono inviati al provider cloud configurato.', + 'memoryTree.status.cloudSummarizationToggleFailed': + 'Impossibile modificare il riepilogo nel cloud', 'memoryTree.status.statusTile': 'Stato', 'memoryTree.status.lastSyncTile': 'Ultima sincronizzazione', 'memoryTree.status.totalChunksTile': "Foglie dell'albero dei riassunti", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 2a8e01ce65..558e156157 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -941,6 +941,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': '자동 동기화', 'memoryTree.status.autoSyncDescription': '새 수집을 중지하려면 일시 중지하세요. 기존 위키는 계속 쿼리할 수 있습니다.', + 'memoryTree.status.cloudSummarizationLabel': '클라우드 요약', + 'memoryTree.status.cloudSummarizationDescription': + '로컬 AI 없이도 요약 트리를 만듭니다. 메모리 요약이 설정된 클라우드 제공자로 전송됩니다.', + 'memoryTree.status.cloudSummarizationToggleFailed': '클라우드 요약 설정을 변경할 수 없습니다.', 'memoryTree.status.statusTile': '상태', 'memoryTree.status.lastSyncTile': '마지막 동기화', 'memoryTree.status.totalChunksTile': '요약 트리 리프', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9dfb955250..09b4e445c6 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -967,6 +967,11 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Automatyczna synchronizacja', 'memoryTree.status.autoSyncDescription': 'Wstrzymaj, aby zatrzymać nowe pobieranie. Istniejąca wiki pozostanie dostępna do zapytań.', + 'memoryTree.status.cloudSummarizationLabel': 'Podsumowanie w chmurze', + 'memoryTree.status.cloudSummarizationDescription': + 'Pozwala tworzyć drzewa podsumowań bez lokalnej AI. Podsumowania pamięci są wysyłane do skonfigurowanego dostawcy chmury.', + 'memoryTree.status.cloudSummarizationToggleFailed': + 'Nie udało się zmienić podsumowania w chmurze', 'memoryTree.status.statusTile': 'Stan', 'memoryTree.status.lastSyncTile': 'Ostatnia synchronizacja', 'memoryTree.status.totalChunksTile': 'Liście drzewa podsumowań', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 968177ecbc..8d1686e068 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -972,6 +972,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Auto-sincronização', 'memoryTree.status.autoSyncDescription': 'Pausa para parar a nova ingestão. O wiki existente permanece consultável.', + 'memoryTree.status.cloudSummarizationLabel': 'Resumo na nuvem', + 'memoryTree.status.cloudSummarizationDescription': + 'Permite criar árvores de resumo sem IA local. Os resumos de memória são enviados ao provedor de nuvem configurado.', + 'memoryTree.status.cloudSummarizationToggleFailed': 'Não foi possível alterar o resumo na nuvem', 'memoryTree.status.statusTile': 'Status', 'memoryTree.status.lastSyncTile': 'Última sincronização', 'memoryTree.status.totalChunksTile': 'Folhas da árvore de resumos', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index a93515fe9b..2b4910f61d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -961,6 +961,10 @@ const messages: TranslationMap = { 'memoryTree.status.autoSyncLabel': 'Автосинхронизация', 'memoryTree.status.autoSyncDescription': 'Пауза, чтобы остановить новый прием. Существующая вики остается доступной для запросов.', + 'memoryTree.status.cloudSummarizationLabel': 'Облачное резюмирование', + 'memoryTree.status.cloudSummarizationDescription': + 'Позволяет строить деревья сводок без локального ИИ. Сводки памяти отправляются вашему настроенному облачному провайдеру.', + 'memoryTree.status.cloudSummarizationToggleFailed': 'Не удалось изменить облачное резюмирование', 'memoryTree.status.statusTile': 'Статус', 'memoryTree.status.lastSyncTile': 'Последняя синхронизация', 'memoryTree.status.totalChunksTile': 'Листья дерева сводок', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index ed2129d007..fac87bb1d7 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -893,6 +893,10 @@ const messages: TranslationMap = { 'memoryTree.status.title': '记忆树', 'memoryTree.status.autoSyncLabel': '自动同步', 'memoryTree.status.autoSyncDescription': '暂停后将停止新的摄取。现有 wiki 仍可查询。', + 'memoryTree.status.cloudSummarizationLabel': '云端摘要', + 'memoryTree.status.cloudSummarizationDescription': + '无需本地 AI 也能构建摘要树。记忆摘要会发送到你配置的云提供方。', + 'memoryTree.status.cloudSummarizationToggleFailed': '无法更改云端摘要', 'memoryTree.status.statusTile': '状态', 'memoryTree.status.lastSyncTile': '上次同步', 'memoryTree.status.totalChunksTile': '摘要树叶子节点', diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index b00c9b1c0d..2ac0ea2ef6 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -106,6 +106,7 @@ const ALLOWED_EVENT_NAMES = [ 'automation_run_cancelled', 'memory_repair_succeeded', 'memory_tree_retry_succeeded', + 'memory_tree_cloud_summarization_changed', 'skill_install', 'skill_uninstall', 'tab_bar_change', diff --git a/app/src/utils/tauriCommands/memoryTree.test.ts b/app/src/utils/tauriCommands/memoryTree.test.ts index a7b6a0300c..7c7c2fd3b1 100644 --- a/app/src/utils/tauriCommands/memoryTree.test.ts +++ b/app/src/utils/tauriCommands/memoryTree.test.ts @@ -25,6 +25,7 @@ import { memoryTreeResetTree, memoryTreeRetryFailed, memoryTreeSearch, + memoryTreeSetCloudSummarization, memoryTreeSetLlm, memoryTreeTopEntities, memoryTreeWipeAll, @@ -574,3 +575,43 @@ describe('memoryTreeBackfillConnectorTrees', () => { expect(out.more_pending).toBe(true); }); }); + +describe('memoryTreeSetCloudSummarization', () => { + test('patches only the consent field, so sibling memory settings survive', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: {}, logs: [] }); + + await memoryTreeSetCloudSummarization(true); + + // The method is the shared memory-settings mutator, and it applies only the + // fields it is given. Sending the whole settings object here would let this + // toggle silently rewrite the embedder or the memory window, so the params + // are asserted exactly rather than with `objectContaining`. + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_update_memory_settings', + params: { cloud_summarization_opt_in: true }, + }); + }); + + test('sends an explicit false rather than omitting the field', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: {}, logs: [] }); + + await memoryTreeSetCloudSummarization(false); + + // Withdrawal has to travel as `false`. An omitted field means "leave this + // alone" to the core, so dropping it would make the off position of the + // toggle a no-op — consent that cannot be taken back. + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_update_memory_settings', + params: { cloud_summarization_opt_in: false }, + }); + }); + + test('propagates a transport failure instead of resolving', async () => { + mockCallCoreRpc.mockRejectedValueOnce(new Error('core unreachable')); + + // The caller renders an error toast and re-reads the stored value off the + // next status poll. Swallowing the rejection here would leave the switch + // showing a consent state the core never recorded. + await expect(memoryTreeSetCloudSummarization(true)).rejects.toThrow('core unreachable'); + }); +}); diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts index 3393468e31..4e87233d18 100644 --- a/app/src/utils/tauriCommands/memoryTree.ts +++ b/app/src/utils/tauriCommands/memoryTree.ts @@ -948,6 +948,14 @@ export interface MemoryTreePipelineStatus { * not connected when it happened. Absent when nothing was quarantined. */ quarantine?: MemoryTreeQuarantine | null; + /** + * Current `memory_tree.cloud_summarization_opt_in`. The panel owns the + * toggle for it — the remediation for `summarizer_unavailable` names this + * flag — so the control must render the stored value rather than a default. + * Optional for back-compat with cores that predate the field; absent ⇒ + * treat as not opted in. + */ + cloud_summarization_opt_in?: boolean; } /** A corrupt-store quarantine as `memory_tree_pipeline_status` reports it. */ @@ -1053,6 +1061,26 @@ export async function memoryTreeSetEnabled( return out; } +/** + * Set `memory_tree.cloud_summarization_opt_in`. + * + * Consent to summarize workspace memory through the configured cloud provider + * when local AI is off. Without it (and without local AI) "Build Summary + * Trees" has no summarizer, the tree stops growing, and the memory-health + * panel reports `summarizer_unavailable` — whose remediation names this flag. + * + * Backed by `openhuman.config_update_memory_settings`, which patches only the + * fields it is given, so the other memory settings are untouched. + */ +export async function memoryTreeSetCloudSummarization(optIn: boolean): Promise { + console.debug('[memory-tree-rpc] memoryTreeSetCloudSummarization: entry opt_in=%s', optIn); + await callCoreRpc({ + method: 'openhuman.config_update_memory_settings', + params: { cloud_summarization_opt_in: optIn }, + }); + console.debug('[memory-tree-rpc] memoryTreeSetCloudSummarization: exit opt_in=%s', optIn); +} + // ── Sync Audit Log ───────────────────────────────────────────────── export interface SyncAuditEntry { diff --git a/src/openhuman/config/ops/model.rs b/src/openhuman/config/ops/model.rs index 41089503cf..c55605dd62 100644 --- a/src/openhuman/config/ops/model.rs +++ b/src/openhuman/config/ops/model.rs @@ -58,6 +58,14 @@ pub struct MemorySettingsPatch { /// Unknown values are silently ignored so old clients can keep /// posting partial patches. pub memory_window: Option, + /// Consent to summarize workspace memory through the configured cloud + /// provider when local AI is off (`memory_tree.cloud_summarization_opt_in`). + /// + /// Exposed here because the memory-health remediation names this flag as + /// the fix for `summarizer_unavailable`, and until now nothing in the app + /// could set it — the only routes were an env var or hand-editing + /// `config.toml` on the host. + pub cloud_summarization_opt_in: Option, } #[derive(Debug, Clone, Default)] @@ -329,6 +337,9 @@ pub async fn apply_memory_settings( if let Some(dimensions) = update.embedding_dimensions { config.memory.embedding_dimensions = dimensions; } + if let Some(opt_in) = update.cloud_summarization_opt_in { + config.memory_tree.cloud_summarization_opt_in = opt_in; + } if let Some(window_label) = update.memory_window.as_deref() { if let Some(window) = crate::openhuman::config::schema::MemoryContextWindow::from_str_opt(window_label) diff --git a/src/openhuman/config/ops_tests_part_02_tests.rs b/src/openhuman/config/ops_tests_part_02_tests.rs index 535d360726..2f8d514843 100644 --- a/src/openhuman/config/ops_tests_part_02_tests.rs +++ b/src/openhuman/config/ops_tests_part_02_tests.rs @@ -326,9 +326,11 @@ async fn apply_memory_settings_updates_all_provided_fields() { embedding_model: Some("nomic".into()), embedding_dimensions: Some(768), memory_window: Some("extended".into()), + cloud_summarization_opt_in: Some(true), }; let _ = apply_memory_settings(&mut cfg, patch).await.expect("apply"); assert_eq!(cfg.memory.backend, "sqlite"); + assert!(cfg.memory_tree.cloud_summarization_opt_in); assert!(cfg.memory.auto_save); assert_eq!(cfg.memory.embedding_provider, "ollama"); assert_eq!(cfg.memory.embedding_model, "nomic"); @@ -639,3 +641,50 @@ async fn apply_analytics_settings_updates_enabled() { .expect("apply"); assert!(!cfg.observability.analytics_enabled); } + +/// The consent flag has to be settable *off* as well as on. A patch that only +/// ever turned it on would leave the user unable to withdraw consent from the +/// same control that granted it — which is the whole point of an opt-in. +#[tokio::test] +async fn apply_memory_settings_can_withdraw_cloud_summarization_consent() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.memory_tree.cloud_summarization_opt_in = true; + + let _ = apply_memory_settings( + &mut cfg, + MemorySettingsPatch { + cloud_summarization_opt_in: Some(false), + ..MemorySettingsPatch::default() + }, + ) + .await + .expect("apply"); + + assert!(!cfg.memory_tree.cloud_summarization_opt_in); +} + +/// An absent field must not be read as `false`. Older clients post partial +/// patches, and one that omits this must not silently revoke consent the user +/// granted elsewhere. +#[tokio::test] +async fn apply_memory_settings_leaves_cloud_summarization_alone_when_absent() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.memory_tree.cloud_summarization_opt_in = true; + + let _ = apply_memory_settings( + &mut cfg, + MemorySettingsPatch { + backend: Some("sqlite".into()), + ..MemorySettingsPatch::default() + }, + ) + .await + .expect("apply"); + + assert!( + cfg.memory_tree.cloud_summarization_opt_in, + "a patch that does not mention the flag must not clear it" + ); +} diff --git a/src/openhuman/config/schemas/controllers_part_01.rs b/src/openhuman/config/schemas/controllers_part_01.rs index b26c516357..ccfcafbcdc 100644 --- a/src/openhuman/config/schemas/controllers_part_01.rs +++ b/src/openhuman/config/schemas/controllers_part_01.rs @@ -384,6 +384,7 @@ fn handle_update_memory_settings(params: Map) -> ControllerFuture embedding_model: update.embedding_model, embedding_dimensions: update.embedding_dimensions, memory_window: update.memory_window, + cloud_summarization_opt_in: update.cloud_summarization_opt_in, }; to_json(config_rpc::load_and_apply_memory_settings(patch).await?) }) diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index 301b73cc80..e361b8b43f 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -83,6 +83,9 @@ pub(super) struct MemorySettingsUpdate { pub(super) embedding_dimensions: Option, /// One of `"minimal" | "balanced" | "extended" | "maximum"`. pub(super) memory_window: Option, + /// `memory_tree.cloud_summarization_opt_in` — consent to summarize + /// workspace memory through the configured cloud provider. + pub(super) cloud_summarization_opt_in: Option, } #[derive(Debug, Deserialize)] diff --git a/src/openhuman/config/schemas/schemas_schema_part_01.rs b/src/openhuman/config/schemas/schemas_schema_part_01.rs index 52e150bcc2..a29d24258d 100644 --- a/src/openhuman/config/schemas/schemas_schema_part_01.rs +++ b/src/openhuman/config/schemas/schemas_schema_part_01.rs @@ -123,6 +123,12 @@ pub(super) fn lookup(function: &str) -> Option { "memory_window", "Stepped long-term memory window preset: minimal | balanced | extended | maximum.", ), + FieldSchema { + name: "cloud_summarization_opt_in", + ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), + comment: "Summarize workspace memory through the configured cloud provider when local AI is off.", + required: false, + }, ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }), diff --git a/src/openhuman/memory/tree/tree/rpc_part_02.rs b/src/openhuman/memory/tree/tree/rpc_part_02.rs index e59322c66f..fa8196d307 100644 --- a/src/openhuman/memory/tree/tree/rpc_part_02.rs +++ b/src/openhuman/memory/tree/tree/rpc_part_02.rs @@ -224,6 +224,13 @@ pub struct PipelineStatusResponse { /// Reported until the rebuilt store holds a chunk again (`resynced`). #[serde(default, skip_serializing_if = "Option::is_none")] pub quarantine: Option, + /// Current `memory_tree.cloud_summarization_opt_in`. The status panel owns + /// the toggle for it (the remediation for `summarizer_unavailable` names + /// this flag), and a toggle that renders a default instead of the stored + /// value would misreport the machine's consent state. Additive + /// (`#[serde(default)]` → `false` for older clients). + #[serde(default)] + pub cloud_summarization_opt_in: bool, } /// `memory_tree_pipeline_status` RPC handler (#1856 Part 1). @@ -439,6 +446,7 @@ pub async fn pipeline_status_rpc( degraded, first_blocking_cause, extraction_coverage, + cloud_summarization_opt_in: config.memory_tree.cloud_summarization_opt_in, quarantine, }; diff --git a/src/openhuman/modules/memory_host.rs b/src/openhuman/modules/memory_host.rs index 7470fa3b0c..fd7f215c11 100644 --- a/src/openhuman/modules/memory_host.rs +++ b/src/openhuman/modules/memory_host.rs @@ -89,11 +89,77 @@ impl ChatCallbacks { role: String, request: ModelRequest, ) -> tinybus::Result { - let model = resolve_chat_model(&role, &self.0).map_err(method_error)?; + let config = self.config_for_role(&role).await?; + let model = resolve_chat_model(&role, config.as_ref()).map_err(method_error)?; model.invoke(&(), request).await.map_err(method_error) } } +impl ChatCallbacks { + /// The config a role's route should be resolved against. + /// + /// `ChatCallbacks` is built **once**, at module-serve time, from an + /// `Arc` that is never refreshed. For most roles that is only a + /// staleness wart. For `"summarization"` it is a consent bug: the ladder in + /// [`resolve_chat_model`] refuses the cloud route unless + /// `memory_tree.cloud_summarization_opt_in` is set, and reading that flag + /// off the boot snapshot means a user who *withdraws* consent keeps having + /// workspace memory summarised through their cloud provider until the core + /// restarts. The withdrawal is durable on disk and ignored at the point of + /// use, which is the worst shape a consent control can have. + /// + /// So the summarization role — and only it — re-reads from disk, the way + /// [`ComposioCallbacks::live_config`] already does in this file. Every + /// other role keeps the snapshot: they carry no consent decision, and + /// re-reading for all of them would put a file read in front of every + /// module-side model call. + /// + /// # Why a read failure blocks only the cloud route + /// + /// A config that cannot be read is not evidence that consent still holds, + /// so continuing on the snapshot could route memory out on a permission + /// the user has since revoked. But refusing outright would break + /// summarization for local-AI users, who never owed consent at all and + /// whose route the ladder resolves without consulting the flag. + /// + /// So a failed re-read refuses **only** when the held snapshot says the + /// route would be the cloud one (local AI off and the opt-in granted) — + /// exactly the case where the answer might have changed to "no". Local AI + /// on, or consent never granted, falls through to the snapshot and lets + /// the ladder decide as it always has. + async fn config_for_role(&self, role: &str) -> tinybus::Result> { + if role != "summarization" { + return Ok(Arc::clone(&self.0)); + } + match crate::openhuman::config::rpc::reload_config_snapshot_with_timeout(&self.0).await { + Ok(fresh) => { + log::debug!( + "[memory_tree::summarise] consent re-read role={role} local_ai={} opted_in={}", + fresh.local_ai.runtime_enabled, + fresh.memory_tree.cloud_summarization_opt_in + ); + Ok(Arc::new(fresh)) + } + Err(error) => { + let would_route_to_cloud = !self.0.local_ai.runtime_enabled + && self.0.memory_tree.cloud_summarization_opt_in; + log::warn!( + "[memory_tree::summarise] consent re-read failed role={role} \ + snapshot_routes_to_cloud={would_route_to_cloud}: {error}" + ); + if would_route_to_cloud { + return Err(method_error( + "cloud summarization refused: the consent setting \ + (memory_tree.cloud_summarization_opt_in) could not be re-read, and a \ + stale grant is not consent", + )); + } + Ok(Arc::clone(&self.0)) + } + } + } +} + /// Resolve the model a module-side chat call runs on, by role. /// /// The `"summarization"` role is special-cased through the tree summarizer's diff --git a/src/openhuman/modules/memory_host_tests.rs b/src/openhuman/modules/memory_host_tests.rs index c01b9abf23..f702af145e 100644 --- a/src/openhuman/modules/memory_host_tests.rs +++ b/src/openhuman/modules/memory_host_tests.rs @@ -17,8 +17,8 @@ //! can be honest, in `tinymemory`'s own module E2E against a real module. use super::{ - serve_interfaces, ComposioCallbacks, EmbeddingCallbacks, CHAT_NAME, CHAT_PATH, COMPOSIO_NAME, - COMPOSIO_PATH, EMBEDDING_NAME, EMBEDDING_PATH, RUNTIME_NAME, RUNTIME_PATH, + serve_interfaces, ChatCallbacks, ComposioCallbacks, EmbeddingCallbacks, CHAT_NAME, CHAT_PATH, + COMPOSIO_NAME, COMPOSIO_PATH, EMBEDDING_NAME, EMBEDDING_PATH, RUNTIME_NAME, RUNTIME_PATH, }; use crate::openhuman::config::Config; use crate::openhuman::integrations::composio::client::create_composio_client; @@ -393,3 +393,114 @@ fn non_summarization_roles_keep_the_role_factory() { super::resolve_chat_model("chat", &config) .expect("a non-summarization role resolves through the factory (test override)"); } + +/// Write a `config.toml` under `dir` carrying just the consent flag, so a +/// re-read has something on disk that can disagree with the held snapshot. +fn write_consent_config(dir: &Path, opt_in: bool) { + std::fs::create_dir_all(dir).expect("config dir"); + std::fs::write( + dir.join("config.toml"), + format!("[memory_tree]\ncloud_summarization_opt_in = {opt_in}\n"), + ) + .expect("write config"); +} + +/// The consent flag is read at the point of use, not at boot. +/// +/// `ChatCallbacks` holds one `Arc` for the life of the process, so +/// without this a user who withdraws consent keeps having workspace memory +/// summarised through their cloud provider until the core restarts — the +/// withdrawal durable on disk and ignored where it matters. +#[tokio::test] +async fn summarization_role_re_reads_withdrawn_consent_from_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + write_consent_config(dir.path(), false); + + // The boot snapshot still carries the grant the user has since withdrawn. + let mut snapshot = Config::default(); + snapshot.workspace_dir = dir.path().join("workspace"); + snapshot.config_path = dir.path().join("config.toml"); + snapshot.memory_tree.cloud_summarization_opt_in = true; + + let callbacks = ChatCallbacks(Arc::new(snapshot)); + let fresh = callbacks + .config_for_role("summarization") + .await + .expect("a readable config must resolve"); + + assert!( + !fresh.memory_tree.cloud_summarization_opt_in, + "the withdrawal on disk must win over the boot snapshot" + ); +} + +/// Every other role keeps the snapshot. They carry no consent decision, and +/// re-reading for all of them would put a file read in front of every +/// module-side model call. +#[tokio::test] +async fn other_roles_keep_the_boot_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + write_consent_config(dir.path(), false); + + let mut snapshot = Config::default(); + snapshot.workspace_dir = dir.path().join("workspace"); + snapshot.config_path = dir.path().join("config.toml"); + snapshot.memory_tree.cloud_summarization_opt_in = true; + let held = Arc::new(snapshot); + + let callbacks = ChatCallbacks(Arc::clone(&held)); + let resolved = callbacks.config_for_role("chat").await.expect("no refusal"); + + assert!( + Arc::ptr_eq(&held, &resolved), + "a non-summarization role must not pay for a re-read" + ); +} + +/// A config that cannot be read is not evidence that consent still holds, so +/// the cloud route is refused rather than taken on a possibly-stale grant. +#[tokio::test] +async fn an_unreadable_config_refuses_the_cloud_route() { + let dir = tempfile::tempdir().expect("tempdir"); + // A *directory* at the config path, not a missing file: `load_from_config_path` + // treats absence as "fresh install" and hands back defaults, which is a + // successful read and correctly not a refusal. Corruption is the case that + // actually errors, and it is the one where a held grant cannot be confirmed. + let config_path = dir.path().join("config.toml"); + std::fs::create_dir_all(&config_path).expect("directory at the config path"); + let mut snapshot = Config::default(); + snapshot.workspace_dir = dir.path().join("workspace"); + snapshot.config_path = config_path; + snapshot.local_ai.runtime_enabled = false; + snapshot.memory_tree.cloud_summarization_opt_in = true; + + let callbacks = ChatCallbacks(Arc::new(snapshot)); + match callbacks.config_for_role("summarization").await { + Err(error) => assert!( + error.to_string().contains("cloud_summarization_opt_in"), + "the refusal must name the setting it could not confirm: {error}" + ), + Ok(_) => panic!("an unconfirmable grant must not route memory to the cloud"), + } +} + +/// ...but only the cloud route. A local-AI user never owed consent, and the +/// ladder resolves their route without consulting the flag, so a transient +/// read failure must not break their summarization. +#[tokio::test] +async fn an_unreadable_config_leaves_the_local_route_alone() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.toml"); + std::fs::create_dir_all(&config_path).expect("directory at the config path"); + let mut snapshot = Config::default(); + snapshot.workspace_dir = dir.path().join("workspace"); + snapshot.config_path = config_path; + snapshot.local_ai.runtime_enabled = true; + snapshot.memory_tree.cloud_summarization_opt_in = false; + + let callbacks = ChatCallbacks(Arc::new(snapshot)); + assert!( + callbacks.config_for_role("summarization").await.is_ok(), + "local AI needs no consent, so a failed re-read must not refuse it" + ); +}