diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx index 457b08f40c..6b90db8ff2 100644 --- a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx @@ -22,14 +22,22 @@ import { const mockPipelineStatus = vi.fn(); const mockSetEnabled = vi.fn(); const mockSyncStatusList = vi.fn(); +const mockRetryFailed = vi.fn(); // #5324: the panel now navigates (budget CTA) and dispatches (escalating the // blocking cause to the shell-mounted UserErrorCenter). Stub both so the // suite keeps rendering the panel bare, without a Router or a Redux store. const mockNavigate = vi.fn(); const mockDispatch = vi.fn(); +// Analytics is a consent-gated side effect that reaches into the core-state +// snapshot; stub it so the panel renders bare and the retry-success path can be +// asserted without a real analytics pipeline. +const mockTrackAnalyticsEvent = vi.fn(); vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate })); vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch })); +vi.mock('../analytics', () => ({ + trackAnalyticsEvent: (...args: unknown[]) => mockTrackAnalyticsEvent(...args), +})); vi.mock('../../utils/tauriCommands', async importOriginal => { // Inherit everything else (types, sibling wrappers) verbatim so the panel @@ -41,6 +49,7 @@ vi.mock('../../utils/tauriCommands', async importOriginal => { memoryTreePipelineStatus: (...args: unknown[]) => mockPipelineStatus(...args), memoryTreeSetEnabled: (...args: unknown[]) => mockSetEnabled(...args), memorySyncStatusList: (...args: unknown[]) => mockSyncStatusList(...args), + memoryTreeRetryFailed: (...args: unknown[]) => mockRetryFailed(...args), }; }); @@ -73,6 +82,8 @@ describe('', () => { mockPipelineStatus.mockReset(); mockSetEnabled.mockReset(); mockSyncStatusList.mockReset(); + mockRetryFailed.mockReset(); + mockTrackAnalyticsEvent.mockReset(); mockSyncStatusList.mockResolvedValue([]); // default: empty, harmless to existing tests }); @@ -550,6 +561,125 @@ describe('', () => { expect(mockDispatch).toHaveBeenCalled(); }); }); + + // ── Retry-failed affordance ───────────────────────────────────────────── + + it('offers a retry when jobs are parked in failed', async () => { + mockPipelineStatus.mockResolvedValue( + payload({ + status: 'error', + reason: '29 unrecoverable failure(s) need action', + pipeline_jobs: { ready: 0, running: 0, failed: 29 }, + }) + ); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument(); + }); + }); + + /** + * The affordance keys off the failed-job counter, not off the blocking-cause + * banner. A failure the pipeline has already worked past no longer surfaces a + * remediation (the core withholds a superseded cause), but its rows still sit + * in `failed` and still need clearing — so the button must be reachable with + * no banner on screen. Without this the user is left in a permanent `error` + * state with no way out, which is the bug. + */ + it('offers the retry even when no blocking cause is surfaced', async () => { + mockPipelineStatus.mockResolvedValue( + payload({ + status: 'error', + reason: '29 unrecoverable failure(s) need action', + pipeline_jobs: { ready: 0, running: 0, failed: 29 }, + first_blocking_cause: null, + }) + ); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('memory-tree-blocking-cause')).not.toBeInTheDocument(); + }); + + it('hides the retry when nothing has failed', async () => { + mockPipelineStatus.mockResolvedValue(payload({ status: 'running' })); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/running/i); + }); + expect(screen.queryByTestId('memory-tree-retry-failed')).not.toBeInTheDocument(); + }); + + it('requeues the failed jobs, reports the count, and re-fetches', async () => { + mockPipelineStatus.mockResolvedValue( + payload({ + status: 'error', + reason: '29 unrecoverable failure(s) need action', + pipeline_jobs: { ready: 0, running: 0, failed: 29 }, + }) + ); + mockRetryFailed.mockResolvedValue({ requeued: 29 }); + const onToast = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument(); + }); + const callsBefore = mockPipelineStatus.mock.calls.length; + await act(async () => { + fireEvent.click(screen.getByTestId('memory-tree-retry-failed')); + }); + + expect(mockRetryFailed).toHaveBeenCalledTimes(1); + // Successful domain outcome is tracked with the privacy-safe count only. + expect(mockTrackAnalyticsEvent).toHaveBeenCalledWith('memory_tree_retry_succeeded', { + count: 29, + }); + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success', message: expect.stringContaining('29') }) + ); + }); + // Successful domain outcome is tracked with a privacy-safe count only. + expect(mockTrackAnalyticsEvent).toHaveBeenCalledWith('memory_tree_retry_succeeded', { + count: 29, + }); + await waitFor(() => { + expect(mockPipelineStatus.mock.calls.length).toBeGreaterThan(callsBefore); + }); + }); + + it('surfaces an error toast when the requeue fails', async () => { + mockPipelineStatus.mockResolvedValue( + payload({ + status: 'error', + reason: '29 unrecoverable failure(s) need action', + pipeline_jobs: { ready: 0, running: 0, failed: 29 }, + }) + ); + mockRetryFailed.mockRejectedValue(new Error('UNIQUE constraint failed')); + const onToast = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument(); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('memory-tree-retry-failed')); + }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'UNIQUE constraint failed' }) + ); + }); + // The button must stay usable so a transient failure is not a dead end. + expect(screen.getByTestId('memory-tree-retry-failed')).not.toBeDisabled(); + }); }); describe('integration health helpers', () => { diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx index 1cef30c3ca..41287b0201 100644 --- a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx @@ -31,8 +31,10 @@ import { type MemorySyncStatusRow, memoryTreePipelineStatus, type MemoryTreePipelineStatus, + memoryTreeRetryFailed, memoryTreeSetEnabled, } from '../../utils/tauriCommands'; +import { trackAnalyticsEvent } from '../analytics'; import Button from '../ui/Button'; /** Translator function shape exposed by `useT()`. */ @@ -338,6 +340,7 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { const dispatch = useAppDispatch(); const { status, integrations, loading, error, refresh } = useMemoryTreeStatus(); const [toggleBusy, setToggleBusy] = useState(false); + const [retryBusy, setRetryBusy] = useState(false); // #002 (FR-004): the single first blocking cause. Prefer the explicit // `first_blocking_cause`; fall back to the active degradation cause so older @@ -376,6 +379,46 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { } }, [status, toggleBusy, refresh, onToast, t]); + /** + * Requeue every terminally-failed job. + * + * An unrecoverable failure (auth, budget, dimension mismatch) is terminal by + * design — the worker never retries it — so a batch that failed under a + * since-fixed config stays parked forever and pins this panel on `error`. + * The `memory_tree_retry_failed` RPC existed for exactly this, but had no + * caller anywhere in the app, leaving the user with a permanent error state + * and no way to clear it. + */ + const handleRetryFailed = useCallback(async () => { + if (retryBusy) { + console.debug('[ui-flow][memory-tree-status] retryFailed: skipped busy=true'); + return; + } + console.debug('[ui-flow][memory-tree-status] retryFailed: entry'); + setRetryBusy(true); + console.debug('[ui-flow][memory-tree-status] retryFailed: busy=true rpc:start'); + try { + const { requeued } = await memoryTreeRetryFailed(); + console.debug('[ui-flow][memory-tree-status] retryFailed: rpc:ok requeued=%d', requeued); + // Record the successful domain outcome (not just the click). Privacy-safe: + // a non-identifying count only, no ids or user text. + trackAnalyticsEvent('memory_tree_retry_succeeded', { count: requeued }); + onToast?.({ + type: 'success', + title: t('memoryTree.status.retryFailedDone'), + message: t('memoryTree.status.retryFailedCount').replace('{count}', String(requeued)), + }); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[ui-flow][memory-tree-status] retryFailed: error %s', message); + onToast?.({ type: 'error', title: t('memoryTree.status.retryFailedError'), message }); + } finally { + console.debug('[ui-flow][memory-tree-status] retryFailed: busy=false exit'); + setRetryBusy(false); + } + }, [retryBusy, refresh, onToast, t]); + const statusKind = status?.status ?? 'idle'; // #5324: "Error — 936 unrecoverable failures need action" told the user // nothing they could act on. When the blocking cause is a spent embedding @@ -413,6 +456,12 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { // with a localized remediation. const degraded = status?.degraded; + // Parked failures are the one panel state the user can act on directly, and + // the affordance is keyed off the counter rather than off the blocking-cause + // banner: a failure the pipeline has already worked past no longer surfaces a + // remediation, but its rows still sit in `failed` and still need clearing. + const failedJobs = status?.pipeline_jobs.failed ?? 0; + const checked = !(status?.is_paused ?? false); const tileClass = @@ -513,6 +562,23 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { {status.reason ? (
{status.reason}
) : null} + {failedJobs > 0 ? ( +
+ +
+ ) : null} )} diff --git a/app/src/components/settings/panels/MascotPanel.tsx b/app/src/components/settings/panels/MascotPanel.tsx index cc2b423815..bef81d0fd0 100644 --- a/app/src/components/settings/panels/MascotPanel.tsx +++ b/app/src/components/settings/panels/MascotPanel.tsx @@ -8,12 +8,14 @@ import { type MascotColor, } from '../../../features/human/Mascot/mascotPalette'; import { synthesizeSpeech } from '../../../features/human/voice/ttsClient'; +import { fileToDataUri, isAllowedMimeType } from '../../../lib/attachments'; import { useT } from '../../../lib/i18n/I18nContext'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { DEFAULT_MASCOT_COLOR, isCustomMascotGifUrl, type MascotVoiceGender, + MAX_CUSTOM_MASCOT_AVATAR_UPLOAD_BYTES, selectCustomMascotGifUrl, selectCustomPrimaryColor, selectCustomSecondaryColor, @@ -90,8 +92,25 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => { loading: manifestLoading, error: manifestError, } = useMascotManifest(); - const [customGifDraft, setCustomGifDraft] = useState(customMascotGifUrl ?? ''); + // An uploaded avatar is stored as a base64 data URL on the same field the URL + // box writes. That string isn't meaningful or editable as text, so keep the + // URL box blank for it rather than dumping ~2 MB of base64 into the input on + // mount (issue #5360). Clearing an uploaded avatar is done via Reset. + const storedIsUploadedAvatar = customMascotGifUrl?.startsWith('data:') ?? false; + const [customGifDraft, setCustomGifDraft] = useState( + storedIsUploadedAvatar ? '' : (customMascotGifUrl ?? '') + ); const [customGifError, setCustomGifError] = useState(null); + // Hidden driven by the "Upload image" button, so the + // button can reuse the shared + {/* Upload a local image file (issue #5360). The hidden input is + driven by the styled button; its value is cleared after each pick + so choosing the same file twice still fires onChange. + + `accept` is the `image/*` wildcard rather than an explicit type + list. This app runs on CEF, whose built-in file-dialog runner + (there is no CefDialogHandler in the shell) does not expand an + enumerated accept list into selectable macOS file types: with + either `image/png,image/jpeg,…` or those MIMEs paired with + `.png,.jpg,…`, the native panel left every non-PNG image greyed + out and unselectable. The wildcard goes through CEF's + mime-table expansion instead and offers every known image type. + + The widened picker is not a widened contract: `isAllowedMimeType` + still gates the read, so a type outside the allowlist (SVG, most + importantly — it can carry inline scripts) is rejected with a + visible error rather than silently accepted. */} +
+ { + void onUploadAvatarFile(e.target.files?.[0]); + e.target.value = ''; + }} + /> + +
{customGifError && (

({ - mockNavigateBack: vi.fn(), - useMascotManifestMock: vi.fn(), - mockSynthesizeSpeech: vi.fn(), -})); +const { mockNavigateBack, useMascotManifestMock, mockSynthesizeSpeech, mockFileToDataUri } = + vi.hoisted(() => ({ + mockNavigateBack: vi.fn(), + useMascotManifestMock: vi.fn(), + mockSynthesizeSpeech: vi.fn(), + mockFileToDataUri: vi.fn(), + })); vi.mock('../../../../features/human/Mascot/manifest/useMascotManifest', () => ({ useMascotManifest: () => useMascotManifestMock(), })); +// Keep the real MIME/size guards (`isAllowedMimeType`, the byte cap); only the +// FileReader-backed data-URL read is stubbed so the upload path is assertable. +vi.mock('../../../../lib/attachments', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, fileToDataUri: (...args: unknown[]) => mockFileToDataUri(...args) }; +}); + vi.mock('../../../../features/human/voice/ttsClient', () => ({ synthesizeSpeech: (...args: unknown[]) => mockSynthesizeSpeech(...args), })); @@ -283,7 +292,21 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => { ); }); - it('rejects non-GIF avatar sources in the panel', () => { + it('accepts a PNG avatar URL in the panel (issue #5360)', () => { + const { store } = renderPanel(); + fireEvent.change(screen.getByTestId('mascot-custom-gif-input'), { + target: { value: 'https://example.com/avatar.png' }, + }); + fireEvent.click(screen.getByTestId('mascot-custom-gif-save')); + + expect(store.getState().mascot.customMascotGifUrl).toBe('https://example.com/avatar.png'); + expect(screen.getByTestId('custom-gif-mascot')).toHaveAttribute( + 'src', + 'https://example.com/avatar.png' + ); + }); + + it('rejects unsafe avatar sources in the panel', () => { const { store } = renderPanel(); fireEvent.change(screen.getByTestId('mascot-custom-gif-input'), { target: { value: 'https://example.com/avatar.svg' }, @@ -291,7 +314,105 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => { fireEvent.click(screen.getByTestId('mascot-custom-gif-save')); expect(store.getState().mascot.customMascotGifUrl).toBeNull(); - expect(screen.getByTestId('mascot-custom-gif-error')).toHaveTextContent('HTTPS .gif'); + expect(screen.getByTestId('mascot-custom-gif-error')).toHaveTextContent(/image URL/i); + }); + + it('uploads a local image file as a data-URL avatar (issue #5360)', async () => { + mockFileToDataUri.mockResolvedValue('data:image/png;base64,AAAA'); + const { store } = renderPanel(); + const file = new File(['x'], 'avatar.png', { type: 'image/png' }); + fireEvent.change(screen.getByTestId('mascot-custom-image-input'), { + target: { files: [file] }, + }); + + const preview = await screen.findByTestId('custom-gif-mascot'); + expect(store.getState().mascot.customMascotGifUrl).toBe('data:image/png;base64,AAAA'); + expect(preview).toHaveAttribute('src', 'data:image/png;base64,AAAA'); + expect(mockFileToDataUri).toHaveBeenCalledTimes(1); + }); + + it('uploads a BMP file as a data-URL avatar (issue #5360)', async () => { + mockFileToDataUri.mockResolvedValue('data:image/bmp;base64,AAAA'); + const { store } = renderPanel(); + const file = new File(['x'], 'avatar.bmp', { type: 'image/bmp' }); + fireEvent.change(screen.getByTestId('mascot-custom-image-input'), { + target: { files: [file] }, + }); + + const preview = await screen.findByTestId('custom-gif-mascot'); + expect(store.getState().mascot.customMascotGifUrl).toBe('data:image/bmp;base64,AAAA'); + expect(preview).toHaveAttribute('src', 'data:image/bmp;base64,AAAA'); + }); + + it('discards an upload superseded by Reset while the file was still reading', async () => { + // Hold the read open so Reset lands between the pick and the resolve — + // the slower read must not resurrect the avatar the user just cleared. + let resolveRead: (uri: string) => void = () => {}; + mockFileToDataUri.mockReturnValue( + new Promise(resolve => { + resolveRead = resolve; + }) + ); + const store = buildStore(); + store.dispatch(setCustomMascotGifUrl('https://example.com/old.gif')); + renderPanel(store); + + const file = new File(['x'], 'avatar.png', { type: 'image/png' }); + fireEvent.change(screen.getByTestId('mascot-custom-image-input'), { + target: { files: [file] }, + }); + fireEvent.click(screen.getByTestId('mascot-custom-gif-reset')); + expect(store.getState().mascot.customMascotGifUrl).toBeNull(); + + await act(async () => { + resolveRead('data:image/png;base64,AAAA'); + }); + + expect(store.getState().mascot.customMascotGifUrl).toBeNull(); + }); + + it('rejects an oversize upload without reading it', async () => { + const { store } = renderPanel(); + const big = new File(['x'], 'big.png', { type: 'image/png' }); + // 1.5 MB cap + 1 byte — override size rather than allocating the bytes. + Object.defineProperty(big, 'size', { value: 1.5 * 1024 * 1024 + 1 }); + fireEvent.change(screen.getByTestId('mascot-custom-image-input'), { + target: { files: [big] }, + }); + + await screen.findByTestId('mascot-custom-gif-error'); + expect(store.getState().mascot.customMascotGifUrl).toBeNull(); + expect(mockFileToDataUri).not.toHaveBeenCalled(); + }); + + it('rejects an upload with an unsupported type without reading it', async () => { + const { store } = renderPanel(); + const svg = new File([''], 'a.svg', { type: 'image/svg+xml' }); + fireEvent.change(screen.getByTestId('mascot-custom-image-input'), { + target: { files: [svg] }, + }); + + await screen.findByTestId('mascot-custom-gif-error'); + expect(store.getState().mascot.customMascotGifUrl).toBeNull(); + expect(mockFileToDataUri).not.toHaveBeenCalled(); + }); + + it('keeps the URL box blank for an uploaded data-URL avatar on mount (issue #5360)', () => { + const store = buildStore(); + store.dispatch(setCustomMascotGifUrl('data:image/png;base64,AAAA')); + renderPanel(store); + + // The ~2 MB base64 is not dumped into the URL box, but the avatar previews. + expect(screen.getByTestId('mascot-custom-gif-input')).toHaveValue(''); + expect(screen.getByTestId('custom-gif-mascot')).toHaveAttribute( + 'src', + 'data:image/png;base64,AAAA' + ); + // Empty box means "no URL change", so Save stays disabled (no accidental + // clear); Reset is the way to drop an uploaded avatar. + expect(screen.getByTestId('mascot-custom-gif-save')).toBeDisabled(); + fireEvent.click(screen.getByTestId('mascot-custom-gif-reset')); + expect(store.getState().mascot.customMascotGifUrl).toBeNull(); }); it('selecting a mascot clears the custom GIF avatar', () => { diff --git a/app/src/features/human/voice/useRealtimeVoiceSession.test.ts b/app/src/features/human/voice/useRealtimeVoiceSession.test.ts index cdbfcbfdef..d6226e44e5 100644 --- a/app/src/features/human/voice/useRealtimeVoiceSession.test.ts +++ b/app/src/features/human/voice/useRealtimeVoiceSession.test.ts @@ -45,6 +45,7 @@ describe('useRealtimeVoiceSession', () => { signedUrl: 'wss://x', connectionType: 'websocket', userId: 'tok-1', + customLlmExtraBody: { user: 'tok-1' }, overrides: { tts: { voiceId: 'v9' } }, }); @@ -52,6 +53,20 @@ describe('useRealtimeVoiceSession', () => { expect(result.current.state).toBe('active'); }); + // `userId` alone never reaches the Custom-LLM request the backend relay + // serves, so the relay cannot identify the caller and rejects the turn. + // `customLlmExtraBody` is the field that carries it there. + it('carries the relay token in customLlmExtraBody, not only in userId', async () => { + mockFetch.mockResolvedValueOnce({ signedUrl: 'wss://x', agentId: 'a1', userToken: 'tok-9' }); + const { result } = renderHook(() => useRealtimeVoiceSession()); + await act(async () => { + await result.current.start(); + }); + expect(startSession).toHaveBeenCalledWith( + expect.objectContaining({ customLlmExtraBody: { user: 'tok-9' } }) + ); + }); + it('falls back to the default mascot voice id', async () => { mockFetch.mockResolvedValueOnce({ signedUrl: 'wss://x', agentId: 'a1', userToken: 'tok-1' }); const { result } = renderHook(() => useRealtimeVoiceSession()); diff --git a/app/src/features/human/voice/useRealtimeVoiceSession.ts b/app/src/features/human/voice/useRealtimeVoiceSession.ts index 903e8896eb..32b489191c 100644 --- a/app/src/features/human/voice/useRealtimeVoiceSession.ts +++ b/app/src/features/human/voice/useRealtimeVoiceSession.ts @@ -76,12 +76,23 @@ export function useRealtimeVoiceSession(opts?: { voiceId?: string }): RealtimeVo try { const { signedUrl, userToken } = await fetchVoiceAgentSignedUrl(); log('start: signed url acquired, opening session'); - // `userId` is the identity binding the backend relay verifies (#5399); - // ElevenLabs forwards it as the Custom-LLM `user` field. + // `userId` is the identity binding the backend relay verifies (#5399). + // + // It rides the conversation-init event as `user_id`, but the provider does + // not put it on the Custom-LLM request body: a live capture of + // `POST /voice-agent/chat/completions` carried only + // [messages, model, max_tokens, stream, stream_options, temperature, tools], + // so every relayed turn was rejected for having no identity. + // + // `customLlmExtraBody` does reach that request — forwarded under an + // `elevenlabs_extra_body` key rather than merged into the top level, which + // is where the relay looks for it. `userId` stays for provider-side + // attribution. conversation.startSession({ signedUrl, connectionType: 'websocket', userId: userToken, + customLlmExtraBody: { user: userToken }, overrides: { tts: { voiceId: opts?.voiceId ?? MASCOT_VOICE_ID } }, }); } catch (err) { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 6978e9904e..254fce9cf2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1274,6 +1274,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'أبداً', 'memoryTree.status.fetchError': 'لم أستطع الحصول على وضعية شجرة الذاكرة', 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.retryFailed': 'إعادة تشغيل المهام الفاشلة', + 'memoryTree.status.retryFailedBusy': 'جارٍ إعادة المحاولة...', + 'memoryTree.status.retryFailedDone': 'تمت إعادة إدراج المهام الفاشلة', + 'memoryTree.status.retryFailedCount': 'تمت جدولة {count} مهمة للتشغيل من جديد.', + 'memoryTree.status.retryFailedError': 'تعذّرت إعادة إدراج المهام الفاشلة', 'memoryTree.status.toggleFailed': 'لا يمكن أن نهز السيرة الذاتية', 'memoryTree.status.justNow': 'الآن', 'memoryTree.status.secondsAgo': 'اكساكسوكس قبل', @@ -5718,10 +5723,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'مسودة', 'settings.mascot.characterHeading': 'عنوان الشخصية', 'settings.mascot.customGifError': - 'أدخل HTTPS .gif URL، أو الاسترجاع HTTP .gif URL، أو file:// .gif URL، أو مسار .gif المحلي.', - 'settings.mascot.customGifHeading': 'الصورة الرمزية GIF المخصصة', - 'settings.mascot.customGifLabel': 'الصورة الرمزية GIF المخصصة URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'أدخل رابط صورة HTTPS أو file:// أو رابطًا محليًا (PNG أو GIF أو JPEG أو WebP أو BMP)، أو ارفع ملفًا.', + 'settings.mascot.customGifHeading': 'صورة رمزية مخصصة', + 'settings.mascot.customGifLabel': 'رابط الصورة الرمزية المخصصة', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'رفع صورة', + 'settings.mascot.customGifInvalidType': + 'نوع الملف غير مدعوم. ارفع صورة PNG أو GIF أو JPEG أو WebP أو BMP.', + 'settings.mascot.customGifTooLarge': 'الصورة كبيرة جدًا. ارفع ملفًا حتى 1.5 ميغابايت.', + 'settings.mascot.customGifReadError': 'تعذّر قراءة الصورة. جرّب ملفًا آخر.', 'settings.mascot.characterPreview': 'معاينة', 'settings.mascot.characterStates': 'تنص على', 'settings.mascot.characterVisemes': 'vimeses', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index afa1147bf3..b1eca0499a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1305,6 +1305,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'কখনো নয়', 'memoryTree.status.fetchError': 'মেমরি প্রাপ্ত করতে ব্যর্থ', 'memoryTree.status.retry': 'পুনরায় চেষ্টা করুন', + 'memoryTree.status.retryFailed': 'ব্যর্থ কাজগুলো আবার চালান', + 'memoryTree.status.retryFailedBusy': 'আবার চেষ্টা করা হচ্ছে...', + 'memoryTree.status.retryFailedDone': 'ব্যর্থ কাজগুলো আবার সারিতে দেওয়া হয়েছে', + 'memoryTree.status.retryFailedCount': '{count}টি কাজ আবার চালানোর জন্য সারিতে রাখা হয়েছে।', + 'memoryTree.status.retryFailedError': 'ব্যর্থ কাজগুলো আবার সারিতে দেওয়া যায়নি', 'memoryTree.status.toggleFailed': 'স্বয়ংক্রিয়ভাবে সনাক্ত করা সম্ভব হয়নি', 'memoryTree.status.justNow': 'এখন', 'memoryTree.status.secondsAgo': 'xqxqx পূর্বে', @@ -5850,10 +5855,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'খসড়া', 'settings.mascot.characterHeading': 'চরিত্রের শিরোনাম', 'settings.mascot.customGifError': - 'একটি HTTPS .gif URL, লুপব্যাক HTTP .gif URL, file:// .gif URL, অথবা স্থানীয় .gif পাথ লিখুন।', - 'settings.mascot.customGifHeading': 'কাস্টম GIF অবতার', - 'settings.mascot.customGifLabel': 'কাস্টম GIF অবতার URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'একটি HTTPS, file://, বা স্থানীয় ইমেজ URL (PNG, GIF, JPEG, WebP, বা BMP) লিখুন, অথবা একটি ফাইল আপলোড করুন।', + 'settings.mascot.customGifHeading': 'কাস্টম ইমেজ অবতার', + 'settings.mascot.customGifLabel': 'কাস্টম ইমেজ অবতার URL', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'ইমেজ আপলোড করুন', + 'settings.mascot.customGifInvalidType': + 'অসমর্থিত ফাইলের ধরন। একটি PNG, GIF, JPEG, WebP, বা BMP ইমেজ আপলোড করুন।', + 'settings.mascot.customGifTooLarge': 'ইমেজটি খুব বড়। ১.৫ MB পর্যন্ত একটি ফাইল আপলোড করুন।', + 'settings.mascot.customGifReadError': 'ইমেজটি পড়া যায়নি। অন্য একটি ফাইল চেষ্টা করুন।', 'settings.mascot.characterPreview': 'পূর্বরূপ', 'settings.mascot.characterStates': 'স্টেটস', 'settings.mascot.characterVisemes': 'তুষারপাত', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index e214421647..3a82a80417 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1351,6 +1351,12 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Nie', 'memoryTree.status.fetchError': 'Speicherbaum-Status konnte nicht abgerufen werden', 'memoryTree.status.retry': 'Wiederholen', + 'memoryTree.status.retryFailed': 'Fehlgeschlagene Jobs erneut ausführen', + 'memoryTree.status.retryFailedBusy': 'Wird wiederholt...', + 'memoryTree.status.retryFailedDone': 'Fehlgeschlagene Jobs neu eingereiht', + 'memoryTree.status.retryFailedCount': 'Erneut eingereihte Jobs: {count}.', + 'memoryTree.status.retryFailedError': + 'Die fehlgeschlagenen Jobs konnten nicht neu eingereiht werden', 'memoryTree.status.toggleFailed': 'Automatische Synchronisierung konnte nicht umgeschaltet werden', 'memoryTree.status.justNow': 'gerade eben', @@ -6011,10 +6017,16 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Entwurf', 'settings.mascot.characterHeading': 'Zeichenüberschrift', 'settings.mascot.customGifError': - 'GIF konnte nicht geladen werden. Bitte überprüfe die URL und versuche es erneut.', - 'settings.mascot.customGifHeading': 'Benutzerdefinierter GIF-Avatar', - 'settings.mascot.customGifLabel': 'URL für benutzerdefinierten GIF-Avatar', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Gib eine HTTPS-, file:// oder lokale Bild-URL (PNG, GIF, JPEG, WebP oder BMP) ein oder lade eine Datei hoch.', + 'settings.mascot.customGifHeading': 'Benutzerdefinierter Bild-Avatar', + 'settings.mascot.customGifLabel': 'URL für benutzerdefinierten Bild-Avatar', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Bild hochladen', + 'settings.mascot.customGifInvalidType': + 'Nicht unterstützter Dateityp. Lade ein PNG-, GIF-, JPEG-, WebP- oder BMP-Bild hoch.', + 'settings.mascot.customGifTooLarge': 'Bild ist zu groß. Lade eine Datei bis 1,5 MB hoch.', + 'settings.mascot.customGifReadError': + 'Bild konnte nicht gelesen werden. Bitte versuche eine andere Datei.', 'settings.mascot.characterPreview': 'Vorschau', 'settings.mascot.characterStates': 'Staaten', 'settings.mascot.characterVisemes': 'Mundbilder', @@ -6125,7 +6137,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Herzlich, freundlich, für alle Altersgruppen geeignet', 'settings.persona.appearanceHeading': 'Avatar und Stimme', 'settings.persona.appearanceDesc': - 'Maskottchenfarbe, benutzerdefinierter GIF-Avatar und Antwortstimme werden in den Maskottcheneinstellungen konfiguriert.', + 'Maskottchenfarbe, benutzerdefinierter Bild-Avatar und Antwortstimme werden in den Maskottcheneinstellungen konfiguriert.', 'settings.persona.openMascotSettings': 'Öffnen Sie die Maskottchen-Einstellungen', 'settings.memoryWindow.balanced.badge': 'Empfohlen', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 80460c0bb6..aa9f760845 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1246,6 +1246,11 @@ const en: TranslationMap = { 'Memory processing encountered an issue. Check Connections → API keys for configuration.', 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.retryFailed': 'Retry failed jobs', + 'memoryTree.status.retryFailedBusy': 'Retrying...', + 'memoryTree.status.retryFailedDone': 'Failed jobs requeued', + 'memoryTree.status.retryFailedCount': 'Jobs queued to run again: {count}.', + 'memoryTree.status.retryFailedError': 'Could not requeue the failed jobs', 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", // Relative-time buckets surfaced by the last-sync tile. `{count}` is // replaced client-side at the call site (the runtime `t()` does not @@ -6448,10 +6453,15 @@ const en: TranslationMap = { 'settings.mascot.characterDraft': 'Draft', 'settings.mascot.characterHeading': 'Character', 'settings.mascot.customGifError': - 'Enter an HTTPS .gif URL, loopback HTTP .gif URL, file:// .gif URL, or local .gif path.', - 'settings.mascot.customGifHeading': 'Custom GIF avatar', - 'settings.mascot.customGifLabel': 'Custom GIF avatar URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Enter an HTTPS, file://, or local image URL (PNG, GIF, JPEG, WebP, or BMP), or upload a file.', + 'settings.mascot.customGifHeading': 'Custom image avatar', + 'settings.mascot.customGifLabel': 'Custom image avatar URL', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Upload image', + 'settings.mascot.customGifInvalidType': + 'Unsupported file type. Upload a PNG, GIF, JPEG, WebP, or BMP image.', + 'settings.mascot.customGifTooLarge': 'Image is too large. Upload a file up to 1.5 MB.', + 'settings.mascot.customGifReadError': 'Could not read that image. Please try another file.', 'settings.mascot.characterPreview': 'Preview', 'settings.mascot.characterStates': 'states', 'settings.mascot.characterVisemes': 'visemes', @@ -6559,7 +6569,7 @@ const en: TranslationMap = { 'settings.persona.templates.family.desc': 'Warm, friendly, safe for all ages', 'settings.persona.appearanceHeading': 'Avatar & Voice', 'settings.persona.appearanceDesc': - 'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.', + 'Mascot color, custom image avatar, and reply voice are configured in Mascot settings.', 'settings.persona.openMascotSettings': 'Open Mascot settings', 'settings.memoryWindow.balanced.badge': 'Recommended', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 48c6b2015d..c6c6fa1530 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1331,6 +1331,12 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Nunca', 'memoryTree.status.fetchError': 'No se pudo obtener el estado del Árbol de Memoria', 'memoryTree.status.retry': 'Rever', + 'memoryTree.status.retryFailed': 'Reintentar los trabajos fallidos', + 'memoryTree.status.retryFailedBusy': 'Reintentando...', + 'memoryTree.status.retryFailedDone': 'Trabajos fallidos añadidos de nuevo a la cola', + 'memoryTree.status.retryFailedCount': 'Trabajos en cola para ejecutarse de nuevo: {count}.', + 'memoryTree.status.retryFailedError': + 'No se pudieron volver a poner en cola los trabajos fallidos', 'memoryTree.status.toggleFailed': 'No se pudo activar la sincronización automática', 'memoryTree.status.justNow': 'justo ahora', 'memoryTree.status.secondsAgo': '{count}s hace', @@ -5959,10 +5965,16 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Borrador', 'settings.mascot.characterHeading': 'Encabezado del personaje', 'settings.mascot.customGifError': - 'Introduzca una ruta HTTPS .gif URL, loopback HTTP .gif URL, file:// .gif URL o ruta .gif local.', - 'settings.mascot.customGifHeading': 'Avatar GIF personalizado', - 'settings.mascot.customGifLabel': 'Avatar GIF personalizado URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Introduce una URL de imagen HTTPS, file:// o local (PNG, GIF, JPEG, WebP o BMP), o sube un archivo.', + 'settings.mascot.customGifHeading': 'Avatar de imagen personalizado', + 'settings.mascot.customGifLabel': 'URL del avatar de imagen personalizado', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Subir imagen', + 'settings.mascot.customGifInvalidType': + 'Tipo de archivo no compatible. Sube una imagen PNG, GIF, JPEG, WebP o BMP.', + 'settings.mascot.customGifTooLarge': + 'La imagen es demasiado grande. Sube un archivo de hasta 1,5 MB.', + 'settings.mascot.customGifReadError': 'No se pudo leer la imagen. Prueba con otro archivo.', 'settings.mascot.characterPreview': 'Vista previa', 'settings.mascot.characterStates': 'estados', 'settings.mascot.characterVisemes': 'visemas', @@ -6071,7 +6083,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Cálido, amable, seguro para todas las edades', 'settings.persona.appearanceHeading': 'Avatar y Voz', 'settings.persona.appearanceDesc': - 'El color de la mascota, el avatar personalizado GIF y la voz de respuesta se configuran en los ajustes de la mascota.', + 'El color de la mascota, el avatar de imagen personalizado y la voz de respuesta se configuran en los ajustes de la mascota.', 'settings.persona.openMascotSettings': 'Abrir la configuración de Mascota', 'settings.memoryWindow.balanced.badge': 'Recomendado', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4ce6308894..ab5b415407 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1344,6 +1344,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Jamais', 'memoryTree.status.fetchError': "Impossible de récupérer l'état de l'arborescence de mémoire", 'memoryTree.status.retry': 'Réessayer', + 'memoryTree.status.retryFailed': 'Relancer les tâches en échec', + 'memoryTree.status.retryFailedBusy': 'Nouvelle tentative...', + 'memoryTree.status.retryFailedDone': 'Tâches en échec remises en file', + 'memoryTree.status.retryFailedCount': "Tâches remises en file d'attente : {count}.", + 'memoryTree.status.retryFailedError': 'Impossible de remettre en file les tâches en échec', 'memoryTree.status.toggleFailed': "Impossible d'activer/désactiver la synchronisation automatique", 'memoryTree.status.justNow': "à l'instant", @@ -5990,10 +5995,16 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Brouillon', 'settings.mascot.characterHeading': 'Titre du personnage', 'settings.mascot.customGifError': - 'Entrez un HTTPS .gif URL, un bouclage HTTP .gif URL, un fichier:// .gif URL ou un chemin local .gif.', - 'settings.mascot.customGifHeading': 'Avatar GIF personnalisé', - 'settings.mascot.customGifLabel': 'Avatar GIF personnalisé URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Saisissez une URL d’image HTTPS, file:// ou locale (PNG, GIF, JPEG, WebP ou BMP), ou importez un fichier.', + 'settings.mascot.customGifHeading': 'Avatar image personnalisé', + 'settings.mascot.customGifLabel': 'URL de l’avatar image personnalisé', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Importer une image', + 'settings.mascot.customGifInvalidType': + 'Type de fichier non pris en charge. Importez une image PNG, GIF, JPEG, WebP ou BMP.', + 'settings.mascot.customGifTooLarge': + 'L’image est trop volumineuse. Importez un fichier de 1,5 Mo maximum.', + 'settings.mascot.customGifReadError': 'Impossible de lire cette image. Essayez un autre fichier.', 'settings.mascot.characterPreview': 'Aperçu', 'settings.mascot.characterStates': 'états', 'settings.mascot.characterVisemes': 'visèmes', @@ -6103,7 +6114,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Chaleureux, amical, adapté à tous les âges', 'settings.persona.appearanceHeading': 'Avatar et Voix', 'settings.persona.appearanceDesc': - "La couleur de la mascotte, l'avatar personnalisé GIF et la voix de réponse sont configurés dans les paramètres de la mascotte.", + "La couleur de la mascotte, l'avatar personnalisé en image et la voix de réponse sont configurés dans les paramètres de la mascotte.", 'settings.persona.openMascotSettings': 'Ouvrir les paramètres de Mascot', 'settings.memoryWindow.balanced.badge': 'Recommandé', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 3a7970224a..c3fce2d644 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1302,6 +1302,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'कभी नहीं', 'memoryTree.status.fetchError': 'स्मृति वृक्ष की स्थिति नहीं मिला', 'memoryTree.status.retry': 'रेस्त्री', + 'memoryTree.status.retryFailed': 'विफल कार्य दोबारा चलाएँ', + 'memoryTree.status.retryFailedBusy': 'दोबारा चलाया जा रहा है...', + 'memoryTree.status.retryFailedDone': 'विफल कार्य फिर से कतार में डाले गए', + 'memoryTree.status.retryFailedCount': '{count} कार्य दोबारा चलने के लिए कतार में हैं।', + 'memoryTree.status.retryFailedError': 'विफल कार्यों को फिर से कतार में नहीं डाला जा सका', 'memoryTree.status.toggleFailed': 'ऑटो सिंक को टॉगल नहीं कर सका', 'memoryTree.status.justNow': 'अभी', 'memoryTree.status.secondsAgo': '{count} पहले', @@ -5847,10 +5852,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'ड्राफ़्ट', 'settings.mascot.characterHeading': 'कैरेक्टर शीर्षक', 'settings.mascot.customGifError': - 'एक HTTPS .gif URL, लूपबैक HTTP .gif URL, फ़ाइल:// .gif URL, या स्थानीय .gif पथ दर्ज करें।', - 'settings.mascot.customGifHeading': 'कस्टम GIF अवतार', - 'settings.mascot.customGifLabel': 'कस्टम GIF अवतार URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'HTTPS, file://, या स्थानीय इमेज URL (PNG, GIF, JPEG, WebP, या BMP) दर्ज करें, या कोई फ़ाइल अपलोड करें।', + 'settings.mascot.customGifHeading': 'कस्टम इमेज अवतार', + 'settings.mascot.customGifLabel': 'कस्टम इमेज अवतार URL', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'इमेज अपलोड करें', + 'settings.mascot.customGifInvalidType': + 'असमर्थित फ़ाइल प्रकार। PNG, GIF, JPEG, WebP, या BMP इमेज अपलोड करें।', + 'settings.mascot.customGifTooLarge': 'इमेज बहुत बड़ी है। 1.5 MB तक की फ़ाइल अपलोड करें।', + 'settings.mascot.customGifReadError': 'यह इमेज पढ़ी नहीं जा सकी। कृपया दूसरी फ़ाइल आज़माएँ।', 'settings.mascot.characterPreview': 'पूर्वावलोकन', 'settings.mascot.characterStates': 'राज्य', 'settings.mascot.characterVisemes': 'हिंदी', @@ -5957,7 +5967,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'गर्मजोश, मिलनसार, हर उम्र के लिए सुरक्षित', 'settings.persona.appearanceHeading': 'अवतार और आवाज', 'settings.persona.appearanceDesc': - 'Mascot रंग, कस्टम GIF अवतार, और उत्तर आवाज Mascot सेटिंग्स में कॉन्फ़िगर किया गया है।', + 'Mascot रंग, कस्टम इमेज अवतार, और उत्तर आवाज Mascot सेटिंग्स में कॉन्फ़िगर किया गया है।', 'settings.persona.openMascotSettings': 'ओपन Mascot सेटिंग्स', 'settings.memoryWindow.balanced.badge': 'अनुशंसित', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ac6289e03c..ee7d673ad6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1316,6 +1316,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Tidak pernah', 'memoryTree.status.fetchError': 'Gagal mengambil status Pohon Memori', 'memoryTree.status.retry': 'Coba lagi', + 'memoryTree.status.retryFailed': 'Jalankan ulang tugas yang gagal', + 'memoryTree.status.retryFailedBusy': 'Mencoba lagi...', + 'memoryTree.status.retryFailedDone': 'Tugas yang gagal masuk antrean lagi', + 'memoryTree.status.retryFailedCount': '{count} tugas diantrekan untuk dijalankan ulang.', + 'memoryTree.status.retryFailedError': 'Tidak dapat mengantrekan ulang tugas yang gagal', 'memoryTree.status.toggleFailed': 'Gagal mengalihkan sinkronisasi otomatis', 'memoryTree.status.justNow': 'baru saja', 'memoryTree.status.secondsAgo': '{count} dtk lalu', @@ -5877,10 +5882,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Draf', 'settings.mascot.characterHeading': 'Judul karakter', 'settings.mascot.customGifError': - 'Masukkan HTTPS .gif URL, loopback HTTP .gif URL, file:// .gif URL, atau jalur .gif lokal.', - 'settings.mascot.customGifHeading': 'Avatar GIF khusus', - 'settings.mascot.customGifLabel': 'Avatar GIF khusus URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Masukkan URL gambar HTTPS, file://, atau lokal (PNG, GIF, JPEG, WebP, atau BMP), atau unggah berkas.', + 'settings.mascot.customGifHeading': 'Avatar gambar khusus', + 'settings.mascot.customGifLabel': 'URL avatar gambar khusus', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Unggah gambar', + 'settings.mascot.customGifInvalidType': + 'Tipe berkas tidak didukung. Unggah gambar PNG, GIF, JPEG, WebP, atau BMP.', + 'settings.mascot.customGifTooLarge': 'Gambar terlalu besar. Unggah berkas hingga 1,5 MB.', + 'settings.mascot.customGifReadError': 'Tidak dapat membaca gambar itu. Coba berkas lain.', 'settings.mascot.characterPreview': 'Pratinjau', 'settings.mascot.characterStates': 'status', 'settings.mascot.characterVisemes': 'visem', @@ -5988,7 +5998,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Hangat, ramah, aman untuk segala usia', 'settings.persona.appearanceHeading': 'Avatar & Suara', 'settings.persona.appearanceDesc': - 'Warna Mascot, avatar GIF kustom, dan suara balasan dikonfigurasi dalam pengaturan Mascot.', + 'Warna Mascot, avatar gambar kustom, dan suara balasan dikonfigurasi dalam pengaturan Mascot.', 'settings.persona.openMascotSettings': 'Buka pengaturan Mascot', 'settings.memoryWindow.balanced.badge': 'Direkomendasikan', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index a409df2f10..d4adccb572 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1335,6 +1335,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Mai', 'memoryTree.status.fetchError': "Impossibile recuperare lo stato dell'Albero della Memoria", 'memoryTree.status.retry': 'Riprova', + 'memoryTree.status.retryFailed': 'Riprova i lavori non riusciti', + 'memoryTree.status.retryFailedBusy': 'Nuovo tentativo...', + 'memoryTree.status.retryFailedDone': 'Lavori non riusciti rimessi in coda', + 'memoryTree.status.retryFailedCount': 'Lavori in coda per una nuova esecuzione: {count}.', + 'memoryTree.status.retryFailedError': 'Impossibile rimettere in coda i lavori non riusciti', 'memoryTree.status.toggleFailed': 'Impossibile attivare/disattivare la sincronizzazione automatica', 'memoryTree.status.justNow': 'proprio adesso', @@ -5947,10 +5952,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Bozza', 'settings.mascot.characterHeading': 'Intestazione personaggio', 'settings.mascot.customGifError': - 'Immettere un HTTPS .gif URL, loopback HTTP .gif URL, file:// .gif URL o un percorso .gif locale.', - 'settings.mascot.customGifHeading': 'Avatar GIF personalizzato', - 'settings.mascot.customGifLabel': 'Avatar GIF personalizzato URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Inserisci un URL immagine HTTPS, file:// o locale (PNG, GIF, JPEG, WebP o BMP), oppure carica un file.', + 'settings.mascot.customGifHeading': 'Avatar immagine personalizzato', + 'settings.mascot.customGifLabel': 'URL dell’avatar immagine personalizzato', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Carica immagine', + 'settings.mascot.customGifInvalidType': + 'Tipo di file non supportato. Carica un’immagine PNG, GIF, JPEG, WebP o BMP.', + 'settings.mascot.customGifTooLarge': 'Immagine troppo grande. Carica un file fino a 1,5 MB.', + 'settings.mascot.customGifReadError': 'Impossibile leggere l’immagine. Prova con un altro file.', 'settings.mascot.characterPreview': 'Anteprima', 'settings.mascot.characterStates': 'stati', 'settings.mascot.characterVisemes': 'visemi', @@ -6060,7 +6070,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Caloroso, amichevole, adatto a tutte le età', 'settings.persona.appearanceHeading': 'Avatar e Voce', 'settings.persona.appearanceDesc': - "Il colore della mascotte, l'avatar personalizzato GIF e la voce di risposta sono configurati nelle impostazioni della mascotte.", + "Il colore della mascotte, l'avatar immagine personalizzato e la voce di risposta sono configurati nelle impostazioni della mascotte.", 'settings.persona.openMascotSettings': 'Apri le impostazioni del Mascotte', 'settings.memoryWindow.balanced.badge': 'Consigliato', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 9f38ceebad..9874b3b1be 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1292,6 +1292,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': '없음', 'memoryTree.status.fetchError': '메모리 트리 상태를 가져올 수 없습니다.', 'memoryTree.status.retry': '다시 시도', + 'memoryTree.status.retryFailed': '실패한 작업 다시 실행', + 'memoryTree.status.retryFailedBusy': '다시 시도하는 중...', + 'memoryTree.status.retryFailedDone': '실패한 작업을 다시 대기열에 넣었습니다', + 'memoryTree.status.retryFailedCount': '{count}개 작업이 다시 실행되도록 대기열에 있습니다.', + 'memoryTree.status.retryFailedError': '실패한 작업을 다시 대기열에 넣지 못했습니다', 'memoryTree.status.toggleFailed': '자동 동기화를 전환할 수 없습니다.', 'memoryTree.status.justNow': '방금 전', 'memoryTree.status.secondsAgo': '{count}초 전', @@ -5780,10 +5785,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': '초안', 'settings.mascot.characterHeading': '캐릭터 제목', 'settings.mascot.customGifError': - 'HTTPS .gif URL, loopback HTTP .gif URL, file:// .gif URL 또는 로컬 .gif 경로를 입력하세요.', - 'settings.mascot.customGifHeading': '사용자 지정 GIF 아바타', - 'settings.mascot.customGifLabel': '사용자 지정 GIF 아바타 URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'HTTPS, file:// 또는 로컬 이미지 URL(PNG, GIF, JPEG, WebP 또는 BMP)을 입력하거나 파일을 업로드하세요.', + 'settings.mascot.customGifHeading': '사용자 지정 이미지 아바타', + 'settings.mascot.customGifLabel': '사용자 지정 이미지 아바타 URL', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': '이미지 업로드', + 'settings.mascot.customGifInvalidType': + '지원되지 않는 파일 형식입니다. PNG, GIF, JPEG, WebP 또는 BMP 이미지를 업로드하세요.', + 'settings.mascot.customGifTooLarge': '이미지가 너무 큽니다. 최대 1.5MB 파일을 업로드하세요.', + 'settings.mascot.customGifReadError': '이미지를 읽을 수 없습니다. 다른 파일을 시도하세요.', 'settings.mascot.characterPreview': '미리보기', 'settings.mascot.characterStates': '상태', 'settings.mascot.characterVisemes': '입 모양', @@ -5891,7 +5901,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': '따뜻하고 친근하며 모든 연령에 안전', 'settings.persona.appearanceHeading': '아바타 및 음성', 'settings.persona.appearanceDesc': - '마스코트 색상, 사용자 지정 GIF 아바타, 응답 음성은 마스코트 설정에서 구성합니다.', + '마스코트 색상, 사용자 지정 이미지 아바타, 응답 음성은 마스코트 설정에서 구성합니다.', 'settings.persona.openMascotSettings': '마스코트 설정 열기', 'settings.memoryWindow.balanced.badge': '추천', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index d9c1146d92..da4e8bb5ec 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1323,6 +1323,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Nigdy', 'memoryTree.status.fetchError': 'Nie udało się pobrać statusu drzewa pamięci', 'memoryTree.status.retry': 'Ponów', + 'memoryTree.status.retryFailed': 'Ponów nieudane zadania', + 'memoryTree.status.retryFailedBusy': 'Ponawianie...', + 'memoryTree.status.retryFailedDone': 'Nieudane zadania wróciły do kolejki', + 'memoryTree.status.retryFailedCount': 'W kolejce do ponownego uruchomienia: {count}.', + 'memoryTree.status.retryFailedError': 'Nie udało się ponowić nieudanych zadań', 'memoryTree.status.toggleFailed': 'Nie udało się przełączyć automatycznej synchronizacji', 'memoryTree.status.justNow': 'przed chwilą', 'memoryTree.status.secondsAgo': '{count} s temu', @@ -5937,10 +5942,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Wersja robocza', 'settings.mascot.characterHeading': 'Charakter', 'settings.mascot.customGifError': - 'Wprowadź URL .gif HTTPS, URL .gif loopback HTTP, URL file:// .gif lub lokalną ścieżkę .gif.', - 'settings.mascot.customGifHeading': 'Własny awatar GIF', - 'settings.mascot.customGifLabel': 'URL własnego awatara GIF', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Wprowadź adres URL obrazu HTTPS, file:// lub lokalny (PNG, GIF, JPEG, WebP lub BMP) albo prześlij plik.', + 'settings.mascot.customGifHeading': 'Własny awatar graficzny', + 'settings.mascot.customGifLabel': 'URL własnego awatara graficznego', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Prześlij obraz', + 'settings.mascot.customGifInvalidType': + 'Nieobsługiwany typ pliku. Prześlij obraz PNG, GIF, JPEG, WebP lub BMP.', + 'settings.mascot.customGifTooLarge': 'Obraz jest za duży. Prześlij plik do 1,5 MB.', + 'settings.mascot.customGifReadError': 'Nie udało się odczytać tego obrazu. Spróbuj innego pliku.', 'settings.mascot.characterPreview': 'Podgląd', 'settings.mascot.characterStates': 'stanów', 'settings.mascot.characterVisemes': 'wizemów', @@ -6047,7 +6057,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Ciepły, przyjazny, bezpieczny dla każdego wieku', 'settings.persona.appearanceHeading': 'Awatar i głos', 'settings.persona.appearanceDesc': - 'Kolor maskotki, własny awatar GIF i głos odpowiedzi są konfigurowane w ustawieniach Maskotki.', + 'Kolor maskotki, własny awatar obrazkowy i głos odpowiedzi są konfigurowane w ustawieniach Maskotki.', 'settings.persona.openMascotSettings': 'Otwórz ustawienia Maskotki', 'settings.memoryWindow.balanced.badge': 'Zalecane', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index a24a7f495e..c0c7551919 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1328,6 +1328,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Nunca', 'memoryTree.status.fetchError': 'Não foi possível buscar o status da Árvore de Memória', 'memoryTree.status.retry': 'Tentar novamente', + 'memoryTree.status.retryFailed': 'Repetir tarefas com falha', + 'memoryTree.status.retryFailedBusy': 'Tentando novamente...', + 'memoryTree.status.retryFailedDone': 'Tarefas com falha recolocadas na fila', + 'memoryTree.status.retryFailedCount': 'Tarefas na fila para executar de novo: {count}.', + 'memoryTree.status.retryFailedError': 'Não foi possível recolocar as tarefas com falha na fila', 'memoryTree.status.toggleFailed': 'Não foi possível ativar/desativar a sincronização automática', 'memoryTree.status.justNow': 'agora mesmo', 'memoryTree.status.secondsAgo': '{count}s atrás', @@ -5940,10 +5945,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Rascunho', 'settings.mascot.characterHeading': 'Título do personagem', 'settings.mascot.customGifError': - 'Insira um caminho HTTPS .gif URL, loopback HTTP .gif URL, arquivo:// .gif URL ou .gif local.', - 'settings.mascot.customGifHeading': 'Avatar GIF personalizado', - 'settings.mascot.customGifLabel': 'Avatar GIF personalizado URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Insira uma URL de imagem HTTPS, file:// ou local (PNG, GIF, JPEG, WebP ou BMP), ou envie um arquivo.', + 'settings.mascot.customGifHeading': 'Avatar de imagem personalizado', + 'settings.mascot.customGifLabel': 'URL do avatar de imagem personalizado', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Enviar imagem', + 'settings.mascot.customGifInvalidType': + 'Tipo de arquivo não suportado. Envie uma imagem PNG, GIF, JPEG, WebP ou BMP.', + 'settings.mascot.customGifTooLarge': 'A imagem é muito grande. Envie um arquivo de até 1,5 MB.', + 'settings.mascot.customGifReadError': 'Não foi possível ler essa imagem. Tente outro arquivo.', 'settings.mascot.characterPreview': 'Visualização', 'settings.mascot.characterStates': 'estados', 'settings.mascot.characterVisemes': 'visemas', @@ -6052,7 +6062,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Acolhedor, amigável, seguro para todas as idades', 'settings.persona.appearanceHeading': 'Avatar e Voz', 'settings.persona.appearanceDesc': - 'A cor do mascote, o avatar personalizado GIF e a voz de resposta são configurados nas configurações do mascote.', + 'A cor do mascote, o avatar de imagem personalizado e a voz de resposta são configurados nas configurações do mascote.', 'settings.persona.openMascotSettings': 'Abrir configurações do Mascote', 'settings.memoryWindow.balanced.badge': 'Recomendado', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 7e53ff481a..83f61f6272 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1316,6 +1316,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': 'Никогда', 'memoryTree.status.fetchError': 'Не удалось получить статус дерева памяти.', 'memoryTree.status.retry': 'Повторить попытку', + 'memoryTree.status.retryFailed': 'Повторить неудавшиеся задачи', + 'memoryTree.status.retryFailedBusy': 'Повторяем...', + 'memoryTree.status.retryFailedDone': 'Неудавшиеся задачи снова в очереди', + 'memoryTree.status.retryFailedCount': 'Задач в очереди на повторный запуск: {count}.', + 'memoryTree.status.retryFailedError': 'Не удалось вернуть неудавшиеся задачи в очередь', 'memoryTree.status.toggleFailed': 'Не удалось включить автосинхронизацию.', 'memoryTree.status.justNow': 'прямо сейчас', 'memoryTree.status.secondsAgo': '{count} сек. назад', @@ -5907,10 +5912,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': 'Черновик', 'settings.mascot.characterHeading': 'Персонаж', 'settings.mascot.customGifError': - 'Введите HTTPS .gif URL, петлевой путь HTTP .gif URL, file:// .gif URL или локальный путь .gif.', - 'settings.mascot.customGifHeading': 'Пользовательский аватар GIF', - 'settings.mascot.customGifLabel': 'Пользовательский аватар GIF URL', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + 'Введите HTTPS, file:// или локальный URL изображения (PNG, GIF, JPEG, WebP или BMP) либо загрузите файл.', + 'settings.mascot.customGifHeading': 'Пользовательский аватар-изображение', + 'settings.mascot.customGifLabel': 'URL пользовательского аватара-изображения', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': 'Загрузить изображение', + 'settings.mascot.customGifInvalidType': + 'Неподдерживаемый тип файла. Загрузите изображение PNG, GIF, JPEG, WebP или BMP.', + 'settings.mascot.customGifTooLarge': 'Изображение слишком большое. Загрузите файл до 1,5 МБ.', + 'settings.mascot.customGifReadError': 'Не удалось прочитать изображение. Попробуйте другой файл.', 'settings.mascot.characterPreview': 'Предварительный просмотр', 'settings.mascot.characterStates': 'содержит', 'settings.mascot.characterVisemes': 'виземы', @@ -6020,7 +6030,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.desc': 'Тёплый, дружелюбный, подходит для всех возрастов', 'settings.persona.appearanceHeading': 'Аватар и голос', 'settings.persona.appearanceDesc': - 'Цвет талисмана, пользовательский аватар GIF и голос ответа настраиваются в настройках талисмана.', + 'Цвет талисмана, пользовательский аватар-изображение и голос ответа настраиваются в настройках талисмана.', 'settings.persona.openMascotSettings': 'Открыть настройки талисмана', 'settings.memoryWindow.balanced.badge': 'Рекомендуется', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e3589ad8cb..0355b01188 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1239,6 +1239,11 @@ const messages: TranslationMap = { 'memoryTree.status.never': '从未', 'memoryTree.status.fetchError': '无法获取记忆树状态', 'memoryTree.status.retry': '重试', + 'memoryTree.status.retryFailed': '重试失败的任务', + 'memoryTree.status.retryFailedBusy': '正在重试...', + 'memoryTree.status.retryFailedDone': '失败的任务已重新排队', + 'memoryTree.status.retryFailedCount': '已有 {count} 个任务重新排队等待运行。', + 'memoryTree.status.retryFailedError': '无法将失败的任务重新排队', 'memoryTree.status.toggleFailed': '无法切换自动同步', 'memoryTree.status.justNow': '刚刚', 'memoryTree.status.secondsAgo': '{count} 秒前', @@ -5540,10 +5545,15 @@ const messages: TranslationMap = { 'settings.mascot.characterDraft': '草稿', 'settings.mascot.characterHeading': '角色', 'settings.mascot.customGifError': - '输入 HTTPS .gif 链接、本地回环 HTTP .gif 链接、file:// .gif 链接或本地 .gif 路径。', - 'settings.mascot.customGifHeading': '自定义 GIF 头像', - 'settings.mascot.customGifLabel': '自定义 GIF 头像链接', - 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.gif', + '输入 HTTPS、file:// 或本地图片链接(PNG、GIF、JPEG、WebP 或 BMP),或上传文件。', + 'settings.mascot.customGifHeading': '自定义图片头像', + 'settings.mascot.customGifLabel': '自定义图片头像链接', + 'settings.mascot.customGifPlaceholder': 'https://example.com/avatar.png', + 'settings.mascot.customGifUpload': '上传图片', + 'settings.mascot.customGifInvalidType': + '不支持的文件类型。请上传 PNG、GIF、JPEG、WebP 或 BMP 图片。', + 'settings.mascot.customGifTooLarge': '图片太大。请上传不超过 1.5 MB 的文件。', + 'settings.mascot.customGifReadError': '无法读取该图片。请尝试其他文件。', 'settings.mascot.characterPreview': '预览', 'settings.mascot.characterStates': '状态', 'settings.mascot.characterVisemes': '视素', @@ -5644,7 +5654,7 @@ const messages: TranslationMap = { 'settings.persona.templates.family.label': '家庭助手', 'settings.persona.templates.family.desc': '温暖、友好、老少皆宜', 'settings.persona.appearanceHeading': '头像和声音', - 'settings.persona.appearanceDesc': '吉祥物颜色、自定义 GIF 头像和回复声音在吉祥物设置中配置。', + 'settings.persona.appearanceDesc': '吉祥物颜色、自定义图片头像和回复声音在吉祥物设置中配置。', 'settings.persona.openMascotSettings': '打开吉祥物设置', 'settings.memoryWindow.balanced.badge': '推荐', 'settings.memoryWindow.balanced.hint': diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 3800002d94..19f9f92a6d 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -103,6 +103,7 @@ const ALLOWED_EVENT_NAMES = [ 'automation_run_started', 'automation_run_resumed', 'automation_run_cancelled', + 'memory_tree_retry_succeeded', 'skill_install', 'skill_uninstall', 'tab_bar_change', diff --git a/app/src/store/__tests__/mascotSlice.test.ts b/app/src/store/__tests__/mascotSlice.test.ts index bc671bf4e9..d59acb9233 100644 --- a/app/src/store/__tests__/mascotSlice.test.ts +++ b/app/src/store/__tests__/mascotSlice.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import reducer, { DEFAULT_MASCOT_COLOR, isCustomMascotGifUrl, + MAX_CUSTOM_MASCOT_AVATAR_DATA_URL_LEN, MAX_CUSTOM_MASCOT_GIF_URL_LEN, MAX_MASCOT_VOICE_ID_LEN, MAX_MASCOT_VOICES, @@ -227,26 +228,80 @@ describe('mascotSlice', () => { expect(state.customMascotGifUrl).toBe('https://example.com/avatar.gif?size=2'); }); - it('accepts local GIF paths and loopback HTTP URLs', () => { + it('accepts local image paths and loopback HTTP URLs', () => { expect(isCustomMascotGifUrl('/Users/me/avatar.gif')).toBe(true); expect(isCustomMascotGifUrl('~/Pictures/avatar.gif')).toBe(true); expect(isCustomMascotGifUrl('http://localhost/avatar.gif')).toBe(true); expect(isCustomMascotGifUrl('http://127.0.0.1/avatar.gif')).toBe(true); }); - it('rejects unsafe or non-GIF avatar sources', () => { + it('accepts PNG, JPEG, and WebP sources, not just GIF (issue #5360)', () => { + expect(isCustomMascotGifUrl('https://example.com/avatar.png')).toBe(true); + expect(isCustomMascotGifUrl('https://example.com/avatar.jpg')).toBe(true); + expect(isCustomMascotGifUrl('https://example.com/avatar.jpeg')).toBe(true); + expect(isCustomMascotGifUrl('https://example.com/avatar.webp')).toBe(true); + expect(isCustomMascotGifUrl('/Users/me/avatar.png')).toBe(true); + expect(isCustomMascotGifUrl('https://example.com/avatar.png?v=2')).toBe(true); + }); + + it('accepts base64 raster image data URLs (uploaded avatars)', () => { + // 1x1 transparent PNG / GIF — the shape FileReader.readAsDataURL emits. + const png = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + const gif = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + expect(isCustomMascotGifUrl(png)).toBe(true); + expect(isCustomMascotGifUrl(gif)).toBe(true); + const state = reducer(undefined, setCustomMascotGifUrl(png)); + expect(state.customMascotGifUrl).toBe(png); + }); + + it('rejects data URLs whose base64 payload is structurally invalid', () => { + // Base64 encodes whole 4-character quartets, optionally ending in one + // padded group. A payload that can't decode would persist an avatar no + // decoder can render, so it is rejected at the reducer boundary too. + const malformed = [ + 'data:image/png;base64,', // empty payload + 'data:image/png;base64,A', // 1 char — never a whole group + 'data:image/png;base64,A=', // 1 char + 1 pad + 'data:image/png;base64,AAAAA', // 5 chars — a stray trailing char + 'data:image/png;base64,AAAA=', // padding on a complete quartet + 'data:image/png;base64,A===', // over-padded + 'data:image/png;base64,AA=A', // padding mid-payload + 'data:image/png;base64,AA*A', // outside the base64 alphabet + ]; + for (const value of malformed) { + expect(isCustomMascotGifUrl(value)).toBe(false); + expect(reducer(undefined, setCustomMascotGifUrl(value)).customMascotGifUrl).toBeNull(); + } + // Both padded tail lengths stay valid. + expect(isCustomMascotGifUrl('data:image/png;base64,QQ==')).toBe(true); + expect(isCustomMascotGifUrl('data:image/png;base64,QUE=')).toBe(true); + }); + + it('rejects unsafe avatar sources', () => { expect(isCustomMascotGifUrl('javascript:alert(1)')).toBe(false); + // Non-loopback plain HTTP is still refused (no transport security). expect(isCustomMascotGifUrl('http://example.com/avatar.gif')).toBe(false); + // SVG can carry inline scripts — rejected as URL and as a data URL. expect(isCustomMascotGifUrl('https://example.com/avatar.svg')).toBe(false); - expect(isCustomMascotGifUrl('https://example.com/avatar.png')).toBe(false); + expect(isCustomMascotGifUrl('data:image/svg+xml;base64,PHN2Zy8+')).toBe(false); + // Non-image data URLs never qualify. + expect(isCustomMascotGifUrl('data:text/html;base64,PGgxPmhpPC9oMT4=')).toBe(false); }); it('rejects oversize avatar sources', () => { - const tooLong = `https://example.com/${'x'.repeat(MAX_CUSTOM_MASCOT_GIF_URL_LEN)}.gif`; + const tooLong = `https://example.com/${'x'.repeat(MAX_CUSTOM_MASCOT_GIF_URL_LEN)}.png`; const state = reducer(undefined, setCustomMascotGifUrl(tooLong)); expect(state.customMascotGifUrl).toBeNull(); }); + it('rejects an oversize data URL past the data-URL cap', () => { + const huge = `data:image/png;base64,${'A'.repeat(MAX_CUSTOM_MASCOT_AVATAR_DATA_URL_LEN)}`; + expect(isCustomMascotGifUrl(huge)).toBe(false); + const state = reducer(undefined, setCustomMascotGifUrl(huge)); + expect(state.customMascotGifUrl).toBeNull(); + }); + it('clears backend mascot id when a custom GIF is set', () => { let state = reducer(undefined, setSelectedMascotId('yellow')); state = reducer(state, setCustomMascotGifUrl('https://example.com/avatar.gif')); diff --git a/app/src/store/mascotSlice.ts b/app/src/store/mascotSlice.ts index 6d496455b2..356b807707 100644 --- a/app/src/store/mascotSlice.ts +++ b/app/src/store/mascotSlice.ts @@ -40,6 +40,28 @@ const DEFAULT_MASCOT_VOICE_GENDER: MascotVoiceGender = 'male'; export const MAX_MASCOT_VOICE_ID_LEN = 128; export const MAX_CUSTOM_MASCOT_GIF_URL_LEN = 2048; +/** + * Upper bound on the *source file* a user may upload as a custom image avatar + * (issue #5360). Uploaded avatars are inlined as base64 `data:image/…` strings + * inside the persisted `mascot` slice, which lives in the localStorage-backed + * `userScopedStorage`. localStorage is a shared, few-megabyte budget, so the + * cap is deliberately small — a large avatar would bloat the blob and, because + * `userScopedStorage.setItem` silently swallows QuotaExceededError, an oversize + * write drops the *entire* mascot slice (colour, voice, selection) rather than + * failing loudly. The UI enforces this before dispatch so the user sees a clear + * "too large" error instead of losing their settings. + */ +export const MAX_CUSTOM_MASCOT_AVATAR_UPLOAD_BYTES = Math.floor(1.5 * 1024 * 1024); + +/** + * Reducer-boundary backstop on an inlined base64 image data URL. base64 + * inflates the raw file by ~4/3, so a 1.5 MB upload yields ~2.1 MB of string; + * this cap (~2.2 MB) leaves headroom while still rejecting a hand-pasted or + * tampered data URL that skipped the UI's byte check. Plain http/https/file + * URLs keep the far tighter MAX_CUSTOM_MASCOT_GIF_URL_LEN. + */ +export const MAX_CUSTOM_MASCOT_AVATAR_DATA_URL_LEN = 2_200_000; + /** * Upper bound on how many per-mascot voice overrides we persist (issue * #4277). A user only ever drives two mascots in a meeting, but they may @@ -69,24 +91,82 @@ function isMascotVoiceId(value: unknown): value is string { ); } -function hasGifPath(value: string): boolean { +// Raster image extensions accepted for a custom avatar (issue #5360). `.svg` +// is deliberately absent — an SVG can carry inline scripts, so it stays a +// rejected avatar source even though the render path is a plain . +const CUSTOM_MASCOT_AVATAR_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']; + +// Matches a base64-encoded raster image data URL. `image/svg+xml` is excluded +// for the same script-injection reason. +// +// The payload requires *structurally valid* base64, not merely base64-ish +// characters: a run of whole 4-character quartets, optionally ending in one +// padded group (`xx==` or `xxx=`). A looser `[A-Za-z0-9+/]+={0,2}` would accept +// truncated payloads like `A=` — the reducer would then persist an avatar no +// image decoder can render, so the user sees a silently broken mascot rather +// than a rejection. The empty payload (`data:image/png;base64,`) is rejected +// too: every branch consumes at least one group. +// +// No ReDoS: the two top-level branches are disjoint (one ends in padding, one +// cannot), and each quantified group has a fixed 4-character width, so a +// failing match backtracks linearly. Callers still gate on length first. +const CUSTOM_MASCOT_AVATAR_DATA_URL_RE = + /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,(?:(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)|(?:[A-Za-z0-9+/]{4})+)$/; + +function hasImagePath(value: string): boolean { const [path = ''] = value.split(/[?#]/, 1); - return path.toLowerCase().endsWith('.gif'); + const lower = path.toLowerCase(); + return CUSTOM_MASCOT_AVATAR_EXTENSIONS.some(ext => lower.endsWith(ext)); +} + +function isCustomMascotAvatarDataUrl(value: string): boolean { + return ( + value.length <= MAX_CUSTOM_MASCOT_AVATAR_DATA_URL_LEN && + CUSTOM_MASCOT_AVATAR_DATA_URL_RE.test(value) + ); } +/** + * Coarse, privacy-safe label for where an avatar value came from, for logging. + * Deliberately derived from the value's *prefix* only — never its content — so + * a diagnostic can never leak a filename, a local path, or image bytes. + */ +function customMascotAvatarSourceCategory(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith('data:')) return 'data-url'; + if (trimmed.startsWith('https:')) return 'https'; + if (trimmed.startsWith('http:')) return 'http-loopback'; + if (trimmed.startsWith('file:')) return 'file-url'; + if (trimmed.startsWith('/') || trimmed.startsWith('~/')) return 'local-path'; + return 'other'; +} + +/** + * Accepts a custom mascot avatar source: a base64 raster-image data URL (from + * an uploaded PNG/GIF/JPEG/WebP/BMP, issue #5360), or an http(s)/file/relative + * URL pointing at one of those image types. The field name keeps its legacy + * `Gif` spelling for persistence compatibility — the stored value survives + * rehydrate — but the accepted set is now any safe raster image, not GIF only. + */ export function isCustomMascotGifUrl(value: unknown): value is string { if (typeof value !== 'string') return false; const trimmed = value.trim(); - if (trimmed.length === 0 || trimmed.length > MAX_CUSTOM_MASCOT_GIF_URL_LEN) return false; + if (trimmed.length === 0) return false; + + // Uploaded avatars are inlined as base64 data URLs; they get their own + // (much larger) length cap and a strict raster-only allowlist. + if (trimmed.startsWith('data:')) return isCustomMascotAvatarDataUrl(trimmed); + + if (trimmed.length > MAX_CUSTOM_MASCOT_GIF_URL_LEN) return false; try { const parsed = new URL(trimmed); - if (!hasGifPath(parsed.pathname)) return false; + if (!hasImagePath(parsed.pathname)) return false; if (parsed.protocol === 'https:' || parsed.protocol === 'file:') return true; if (parsed.protocol !== 'http:') return false; return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname); } catch { - return hasGifPath(trimmed) && (trimmed.startsWith('/') || trimmed.startsWith('~/')); + return hasImagePath(trimmed) && (trimmed.startsWith('/') || trimmed.startsWith('~/')); } } @@ -276,14 +356,27 @@ const mascotSlice = createSlice({ }, setCustomMascotGifUrl(state, action: PayloadAction) { if (action.payload == null) { + console.debug('[mascot-avatar] store: cleared'); state.customMascotGifUrl = null; return; } - if (isCustomMascotGifUrl(action.payload)) { - state.customMascotGifUrl = action.payload.trim(); + // Diagnostics carry the source *category* and length only — never the URL, + // local path, or data URL itself, any of which can hold a filename or + // the image bytes. A silent reject here is otherwise invisible: the + // reducer's failure mode is a cleared avatar, not an error. + const trimmed = action.payload.trim(); + const category = customMascotAvatarSourceCategory(trimmed); + // Read the length up front: `isCustomMascotGifUrl` is a `value is string` + // predicate, so the else-branch narrows an already-`string` argument to + // `never` and no property access survives there. + const length = trimmed.length; + if (isCustomMascotGifUrl(trimmed)) { + console.debug('[mascot-avatar] store: accepted', category, length); + state.customMascotGifUrl = trimmed; state.selectedMascotId = null; state.secondaryMascotId = null; } else { + console.debug('[mascot-avatar] store: rejected', category, length); state.customMascotGifUrl = null; } }, diff --git a/app/src/utils/tauriCommands/memoryTree.test.ts b/app/src/utils/tauriCommands/memoryTree.test.ts index 6bd19c3040..1d30f01543 100644 --- a/app/src/utils/tauriCommands/memoryTree.test.ts +++ b/app/src/utils/tauriCommands/memoryTree.test.ts @@ -21,6 +21,7 @@ import { memoryTreeObsidianVaultStatus, memoryTreeRecall, memoryTreeResetTree, + memoryTreeRetryFailed, memoryTreeSearch, memoryTreeSetLlm, memoryTreeTopEntities, @@ -462,3 +463,27 @@ describe('memorySyncStatusList', () => { expect(rows).toEqual([]); }); }); + +describe('memoryTreeRetryFailed', () => { + test('dispatches memory_tree_retry_failed with empty params and returns the count', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: { requeued: 5 }, logs: ['stub'] }); + + const out = await memoryTreeRetryFailed(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.memory_tree_retry_failed', + params: {}, + }); + expect(out).toEqual({ requeued: 5 }); + }); + + test('passes through bare-shape responses (no envelope) unchanged', async () => { + // Defensive path: a handler that stops emitting logs returns the bare + // value, which flows through `unwrapResult` untouched. + mockCallCoreRpc.mockResolvedValueOnce({ requeued: 0 }); + + const out = await memoryTreeRetryFailed(); + + expect(out).toEqual({ requeued: 0 }); + }); +}); diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts index 95ed58adc5..220e62809b 100644 --- a/app/src/utils/tauriCommands/memoryTree.ts +++ b/app/src/utils/tauriCommands/memoryTree.ts @@ -905,6 +905,34 @@ export async function memoryTreePipelineStatus(): Promise { + console.debug('[memory-tree-rpc] memoryTreeRetryFailed: entry'); + const resp = await callCoreRpc< + MemoryTreeRetryFailedResponse | ResultEnvelope + >({ method: 'openhuman.memory_tree_retry_failed', params: {} }); + const out = unwrapResult(resp); + console.debug('[memory-tree-rpc] memoryTreeRetryFailed: exit requeued=%d', out.requeued); + return out; +} + // ── memory_tree_set_enabled (#1856 Part 1) ─────────────────────────────── /** diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index fc4639db2f..fea994b297 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -29,6 +29,8 @@ pub use schema::{ default_projects_dir, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id, resolve_action_dir, user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID, }; +// Crate-internal: workspace→config-dir resolver reused by the cloud embedder. +pub(crate) use schema::resolve_config_dir_for_workspace; #[allow(unused_imports)] pub use schema::{ apply_runtime_proxy_to_builder, build_runtime_proxy_client, diff --git a/src/openhuman/config/schema/load/mod.rs b/src/openhuman/config/schema/load/mod.rs index 7f34f6184b..1a932ceb08 100644 --- a/src/openhuman/config/schema/load/mod.rs +++ b/src/openhuman/config/schema/load/mod.rs @@ -32,10 +32,14 @@ pub(crate) use dirs::default_root_dir_name_pub as default_root_dir_name; // re-export; only the load_tests module needs it visible at this level. #[cfg(test)] pub(crate) use dirs::read_active_user_id_checked; +// Non-test: the keyless cloud embedder (`inference::embeddings::cloud_adapter`) +// resolves its `OPENHUMAN_WORKSPACE` credential scope through the same +// workspace→config-dir mapping `config::load` uses, so a legacy `.../workspace` +// override lands on the sibling `.openhuman` root that holds `auth-profiles.json`. +pub(crate) use dirs::resolve_config_dir_for_workspace; #[cfg(test)] pub(crate) use dirs::{ - resolve_config_dir_for_workspace, resolve_runtime_config_dirs, - resolve_runtime_config_dirs_with, ConfigResolutionSource, + resolve_runtime_config_dirs, resolve_runtime_config_dirs_with, ConfigResolutionSource, }; // PathBuf and Config were in scope via `use super::*` in the original load.rs. #[cfg(test)] diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 808886d7b3..4642458936 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -28,6 +28,9 @@ pub use load::{ default_projects_dir, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id, resolve_action_dir, user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID, }; +// Crate-internal: the workspace→config-dir resolver, reused by the cloud +// embedder's keyless credential-scope resolution (mirrors `config::load`). +pub(crate) use load::resolve_config_dir_for_workspace; // Contract shared with `core::observability::expected_error_kind`: the loader // appends this marker to a config-read failure when the file's owner differs // from the reading process, and the classifier keys on it to keep that case diff --git a/src/openhuman/inference/embeddings/cloud_adapter.rs b/src/openhuman/inference/embeddings/cloud_adapter.rs index bef1a354c8..280fbd2bbc 100644 --- a/src/openhuman/inference/embeddings/cloud_adapter.rs +++ b/src/openhuman/inference/embeddings/cloud_adapter.rs @@ -52,16 +52,85 @@ impl OpenHumanCloudEmbedding { } } +/// Credential scope used when the caller passes `openhuman_dir = None`. +/// +/// `None` means "wherever this process keeps its credentials", and on a shipped +/// desktop that is **not** the root `~/.openhuman`. Sign-in stores the +/// `app-session` token through `AuthService::from_config`, whose state dir is +/// `config.config_path.parent()` — the user-scoped +/// `~/.openhuman/users//`. This function previously returned the root, +/// so every keyless managed embedder resolved a directory with no +/// `auth-profiles.json` in it and a signed-in user's embeds failed with +/// "No backend session for cloud embeddings" on every call. +/// +/// Resolution mirrors `config::load`'s own directory choice: +/// 1. `OPENHUMAN_WORKSPACE` when set — resolved through the **same** +/// workspace→config-dir mapping `config::load` uses +/// (`resolve_config_dir_for_workspace`), not the raw env value. A legacy +/// `.../workspace` override maps back to its sibling `.openhuman` root, which +/// is where `auth-profiles.json` actually lives; returning the workspace dir +/// itself would reintroduce the "No backend session" failure for that +/// deployment. +/// 2. otherwise `{root}/users/{active_user_id}`, falling back to the pre-login +/// user (`users/local`) when no user has signed in yet — the same directory +/// the pre-login config was written to, so a pre-login process still reads +/// its own store instead of an empty root. +/// +/// Callers holding a `&Config` should still pass the scope explicitly +/// (`create_embedding_provider_with_config`); this is the best available +/// resolution for the call sites that have no `Config` in scope. fn default_state_dir() -> PathBuf { + log::debug!("[embeddings::cloud] default credential scope: resolving"); if let Some(workspace) = std::env::var_os("OPENHUMAN_WORKSPACE") .filter(|value| !value.is_empty()) .map(PathBuf::from) { - return workspace; + // Never log the resolved path: it identifies the user's home layout. + log::debug!( + "[embeddings::cloud] default credential scope = OPENHUMAN_WORKSPACE-derived config dir (env-scoped deployment)" + ); + return env_workspace_state_dir(&workspace); } - directories::UserDirs::new() - .map(|dirs| dirs.home_dir().join(".openhuman")) - .unwrap_or_else(|| PathBuf::from(".openhuman")) + + let root = crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|error| { + log::warn!( + "[embeddings::cloud] could not resolve the openhuman root dir ({error}); \ + falling back to a relative .openhuman path" + ); + PathBuf::from(".openhuman") + }); + + // Never log the resolved path or the user id: both identify the user. + let user_id = crate::openhuman::config::read_active_user_id(&root); + log::debug!( + "[embeddings::cloud] default credential scope resolved = user-scoped dir (active_user_present={})", + user_id.is_some() + ); + user_scoped_state_dir(&root, user_id.as_deref()) +} + +/// Pure core of [`default_state_dir`]'s `OPENHUMAN_WORKSPACE` branch, split out +/// so the workspace→config-dir invariant is unit-testable without touching the +/// process environment. +/// +/// Mirrors `config::load`: the credential scope for a workspace override is the +/// config dir [`resolve_config_dir_for_workspace`] derives from it — for a +/// legacy `.../workspace` path that is the sibling `.openhuman` root (which +/// holds `auth-profiles.json`), **not** the workspace dir (which holds none). +fn env_workspace_state_dir(workspace: &std::path::Path) -> PathBuf { + let (config_dir, _workspace_dir) = + crate::openhuman::config::resolve_config_dir_for_workspace(workspace); + config_dir +} + +/// Pure core of [`default_state_dir`]'s non-env branch, split out so the +/// user-scoping invariant is unit-testable without a home directory or a real +/// `active_user.toml`. +fn user_scoped_state_dir(root: &std::path::Path, active_user_id: Option<&str>) -> PathBuf { + crate::openhuman::config::user_openhuman_dir( + root, + active_user_id.unwrap_or(crate::openhuman::config::PRE_LOGIN_USER_ID), + ) } #[async_trait] @@ -125,4 +194,70 @@ mod tests { "unexpected error: {err}" ); } + + /// The keyless credential scope must land in the **user-scoped** directory, + /// never the root. Sign-in writes `auth-profiles.json` to + /// `{root}/users//`; the root itself holds no such file, so the + /// previous root-returning implementation made every keyless managed + /// embedder fail with "No backend session for cloud embeddings" for a user + /// who was signed in. + #[test] + fn default_scope_is_the_active_user_dir_not_the_root() { + let root = std::path::Path::new("/tmp/openhuman-root"); + + let resolved = user_scoped_state_dir(root, Some("user-abc123")); + + assert_eq!( + resolved, + root.join("users").join("user-abc123"), + "managed embedder must read credentials from the active user's dir" + ); + assert_ne!( + resolved, root, + "the root dir holds no auth-profiles.json — resolving to it is the bug" + ); + } + + /// With no user signed in yet, the scope is the pre-login user dir — the + /// same directory the pre-login config and its credential store live in. + /// Falling back to the root here would reintroduce the same empty-store + /// failure one login earlier. + #[test] + fn default_scope_falls_back_to_the_pre_login_user_dir() { + let root = std::path::Path::new("/tmp/openhuman-root"); + + let resolved = user_scoped_state_dir(root, None); + + assert_eq!( + resolved, + root.join("users") + .join(crate::openhuman::config::PRE_LOGIN_USER_ID), + "a pre-login process must read its own store, not the empty root" + ); + } + + /// `OPENHUMAN_WORKSPACE` must resolve through the same workspace→config-dir + /// mapping `config::load` uses, not return the raw workspace path. A legacy + /// `/workspace` override keeps its credentials in the sibling + /// `/.openhuman` dir; returning the workspace dir itself would send the + /// keyless embedder to a directory with no `auth-profiles.json` and + /// reintroduce "No backend session" for that deployment. + #[test] + fn env_workspace_scope_is_the_config_dir_not_the_raw_workspace() { + // A path that does not exist on disk, so the resolver's `config.toml` + // probes both miss and the `"workspace"` basename rule decides. + let workspace = std::path::Path::new("/nonexistent-openhuman-test-root/workspace"); + + let resolved = env_workspace_state_dir(workspace); + + assert_eq!( + resolved, + std::path::Path::new("/nonexistent-openhuman-test-root/.openhuman"), + "a `.../workspace` override must resolve to its sibling .openhuman config dir" + ); + assert_ne!( + resolved, workspace, + "returning the raw workspace dir is the regression this guards against" + ); + } } diff --git a/src/openhuman/memory/tree/tree/rpc.rs b/src/openhuman/memory/tree/tree/rpc.rs index 933bb3105b..9f4becf731 100644 --- a/src/openhuman/memory/tree/tree/rpc.rs +++ b/src/openhuman/memory/tree/tree/rpc.rs @@ -643,33 +643,111 @@ pub async fn retry_failed_rpc(config: &Config) -> Result Result, String> { use crate::openhuman::memory::tree::health::{FailureClass, FailureCode, PipelineFailure}; - let row: Option<(Option, Option)> = - chunk_store::with_connection(config, |conn| { - conn.query_row( - "SELECT failure_reason, failure_class FROM mem_tree_jobs + // Read the newest failed row AND the success watermark on the SAME + // connection. `with_connection` holds the process-global connection mutex + // for the whole closure, so no job can settle between the two reads and + // flip the supersession decision (a race the #5427 review flagged). The + // watermark is only queried when the failed row carries a timestamp to + // compare against. + type FailureWatermark = (Option, Option, Option, Option); + let row: Option = chunk_store::with_connection(config, |conn| { + let failed: Option<(Option, Option, Option)> = conn + .query_row( + "SELECT failure_reason, failure_class, completed_at_ms FROM mem_tree_jobs WHERE status = 'failed' AND failure_reason IS NOT NULL ORDER BY completed_at_ms DESC LIMIT 1", [], - |r| Ok((r.get(0)?, r.get(1)?)), + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + + let Some((reason, class, failed_at_ms)) = failed else { + return Ok(None); + }; + + let last_success_ms: Option = if failed_at_ms.is_some() { + conn.query_row( + "SELECT MAX(completed_at_ms) FROM mem_tree_jobs WHERE status = 'done'", + [], + |r| r.get(0), ) .optional() - .map_err(Into::into) - }) - .map_err(|e| format!("latest_failed_job_failure: {e:#}"))?; + .map(Option::flatten)? + } else { + None + }; + + Ok(Some((reason, class, failed_at_ms, last_success_ms))) + }) + .map_err(|e| format!("latest_failed_job_failure: {e:#}"))?; - let Some((Some(reason), class)) = row else { + let Some((Some(reason), class, failed_at_ms, last_success_ms)) = row else { + log::debug!( + "[memory-tree][rpc] pipeline_status: no typed failed row present — no blocking cause" + ); return Ok(None); }; + + // Log every supersession branch, not only the withheld one, so the decision + // is greppable from the logs alone. + match failed_at_ms { + Some(failed_at_ms) + if last_success_ms.is_some_and(|success_ms| success_ms > failed_at_ms) => + { + log::debug!( + "[memory-tree][rpc] pipeline_status: withholding blocking cause reason={reason} \ + — the queue has completed a job since it failed (superseded)" + ); + return Ok(None); + } + Some(_) => { + log::debug!( + "[memory-tree][rpc] pipeline_status: blocking cause is live reason={reason} \ + — no successful settle since it failed" + ); + } + None => { + log::debug!( + "[memory-tree][rpc] pipeline_status: blocking cause reason={reason} has no \ + completion timestamp — surfacing unconditionally (legacy row)" + ); + } + } + let Some(code) = FailureCode::from_str(&reason) else { return Ok(None); }; @@ -1805,6 +1883,124 @@ mod tests { ); } + /// Plant one terminally-`failed` row carrying a typed reason, and + /// optionally one `done` row, at explicit timestamps. Returns nothing — the + /// tests read the derived cause back through `latest_failed_job_failure`. + fn plant_failed_and_done( + cfg: &Config, + reason: &str, + failed_at_ms: i64, + done_at_ms: Option, + ) { + use crate::openhuman::memory::queue::store as queue_store; + use crate::openhuman::memory::queue::types::{FlushStalePayload, NewJob}; + + let failed_job = + NewJob::flush_stale(&FlushStalePayload::default(), "2026-07-10", 3).unwrap(); + let failed_id = queue_store::enqueue(cfg, &failed_job) + .unwrap() + .expect("enqueue failed-row"); + + let done_id = done_at_ms.map(|_| { + let done_job = + NewJob::flush_stale(&FlushStalePayload::default(), "2026-08-06", 3).unwrap(); + queue_store::enqueue(cfg, &done_job) + .unwrap() + .expect("enqueue done-row") + }); + + chunk_store::with_connection(cfg, |conn| { + conn.execute( + "UPDATE mem_tree_jobs + SET status = 'failed', + failure_reason = ?2, + failure_class = 'unrecoverable', + completed_at_ms = ?3 + WHERE id = ?1", + rusqlite::params![failed_id, reason, failed_at_ms], + )?; + if let (Some(done_id), Some(done_at_ms)) = (done_id.as_ref(), done_at_ms) { + conn.execute( + "UPDATE mem_tree_jobs + SET status = 'done', completed_at_ms = ?2 + WHERE id = ?1", + rusqlite::params![done_id, done_at_ms], + )?; + } + Ok(()) + }) + .unwrap(); + } + + /// The active production defect: a signed-in user was told "No embeddings + /// credentials found. Log in to OpenHuman" because a batch of `auth_missing` + /// jobs had failed 27 days earlier and, being unrecoverable, was never + /// retried. The queue had been completing jobs the whole time since. + /// + /// A failure the pipeline has already worked past is not the current + /// blocking cause, so no remediation is surfaced for it. + #[test] + fn blocking_cause_is_withheld_once_the_queue_has_succeeded_since() { + let (_tmp, cfg) = test_config(); + let failed_at = 1_800_000_000_000_i64; + let succeeded_after = failed_at + 27 * 24 * 60 * 60 * 1000; + + plant_failed_and_done(&cfg, "auth_missing", failed_at, Some(succeeded_after)); + + assert!( + latest_failed_job_failure(&cfg).unwrap().is_none(), + "a month-old auth failure the queue has since worked past must not be \ + presented as the user's current problem" + ); + } + + /// The other half of the same rule: a failure with no successful settle + /// after it IS the current blocking cause and must still surface, otherwise + /// the fix would silence the diagnosis it exists to deliver. + #[test] + fn blocking_cause_surfaces_when_nothing_has_succeeded_since() { + use crate::openhuman::memory::tree::health::{FailureClass, FailureCode}; + + let (_tmp, cfg) = test_config(); + let succeeded_before = 1_800_000_000_000_i64; + let failed_after = succeeded_before + 60_000; + + plant_failed_and_done( + &cfg, + "budget_exhausted", + failed_after, + Some(succeeded_before), + ); + + let failure = latest_failed_job_failure(&cfg) + .unwrap() + .expect("a failure with no success after it is the live cause"); + assert_eq!(failure.code, FailureCode::BudgetExhausted); + assert_eq!(failure.class, FailureClass::Unrecoverable); + assert_eq!( + failure.remediation_key, + "memory.health.remediation.budget_exhausted" + ); + } + + /// A queue that has never completed anything has no watermark to compare + /// against, so the failure stands — this is the "broken from the first + /// sync" shape, where the diagnosis matters most. + #[test] + fn blocking_cause_surfaces_when_the_queue_has_never_succeeded() { + let (_tmp, cfg) = test_config(); + + plant_failed_and_done(&cfg, "auth_invalid", 1_800_000_000_000_i64, None); + + let failure = latest_failed_job_failure(&cfg) + .unwrap() + .expect("no successful settle exists to supersede this failure"); + assert_eq!( + failure.remediation_key, + "memory.health.remediation.auth_invalid" + ); + } + /// On a fresh workspace the panel must report `idle` with zero /// counters — the UI uses this to swap the loading skeleton for a /// "no memory yet" state. diff --git a/vendor/tinycortex b/vendor/tinycortex index e0a8738980..5fabcf18d9 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit e0a8738980965411f514f4a62c09f941efdea90c +Subproject commit 5fabcf18d9e3907d6b26b59528ad49cebfc1c271