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. */}
+
({
- 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