mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Add custom GIF mascot avatar override (#2347)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { CustomGifMascot } from '../../../features/human/Mascot';
|
||||
import { BackendMascot } from '../../../features/human/Mascot/backend/BackendMascot';
|
||||
import type { MascotDetail, MascotSummary } from '../../../features/human/Mascot/backend/types';
|
||||
import { getMascotPalette, type MascotColor } from '../../../features/human/Mascot/mascotPalette';
|
||||
@@ -9,13 +10,16 @@ import { fetchMascotList, getCachedMascotDetail } from '../../../services/mascot
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
DEFAULT_MASCOT_COLOR,
|
||||
isCustomMascotGifUrl,
|
||||
type MascotVoiceGender,
|
||||
selectCustomMascotGifUrl,
|
||||
selectEffectiveMascotVoiceId,
|
||||
selectMascotColor,
|
||||
selectMascotVoiceGender,
|
||||
selectMascotVoiceId,
|
||||
selectMascotVoiceUseLocaleDefault,
|
||||
selectSelectedMascotId,
|
||||
setCustomMascotGifUrl,
|
||||
setMascotColor,
|
||||
setMascotVoiceGender,
|
||||
setMascotVoiceId,
|
||||
@@ -52,6 +56,7 @@ const MascotPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const storedColor = useAppSelector(selectMascotColor);
|
||||
const selectedMascotId = useAppSelector(selectSelectedMascotId);
|
||||
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
|
||||
const storedVoiceId = useAppSelector(selectMascotVoiceId);
|
||||
const voiceGender = useAppSelector(selectMascotVoiceGender);
|
||||
const useLocaleDefault = useAppSelector(selectMascotVoiceUseLocaleDefault);
|
||||
@@ -64,6 +69,8 @@ const MascotPanel = () => {
|
||||
const [backendListError, setBackendListError] = useState<string | null>(null);
|
||||
const [activeDetail, setActiveDetail] = useState<MascotDetail | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [customGifDraft, setCustomGifDraft] = useState<string>(customMascotGifUrl ?? '');
|
||||
const [customGifError, setCustomGifError] = useState<string | null>(null);
|
||||
|
||||
// Voice picker state — paste-mode is sticky because we can't derive it
|
||||
// from the stored value alone (a curated preset id and "user is
|
||||
@@ -138,6 +145,35 @@ const MascotPanel = () => {
|
||||
|
||||
const handleSelectBackend = (id: string | null) => {
|
||||
dispatch(setSelectedMascotId(id));
|
||||
setCustomGifError(null);
|
||||
if (id == null) {
|
||||
setCustomGifDraft('');
|
||||
dispatch(setCustomMascotGifUrl(null));
|
||||
} else {
|
||||
setCustomGifDraft('');
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveCustomGif = () => {
|
||||
const trimmed = customGifDraft.trim();
|
||||
setCustomGifDraft(trimmed);
|
||||
if (trimmed.length === 0) {
|
||||
setCustomGifError(null);
|
||||
dispatch(setCustomMascotGifUrl(null));
|
||||
return;
|
||||
}
|
||||
if (!isCustomMascotGifUrl(trimmed)) {
|
||||
setCustomGifError(t('settings.mascot.customGifError'));
|
||||
return;
|
||||
}
|
||||
setCustomGifError(null);
|
||||
dispatch(setCustomMascotGifUrl(trimmed));
|
||||
};
|
||||
|
||||
const onResetCustomGif = () => {
|
||||
setCustomGifDraft('');
|
||||
setCustomGifError(null);
|
||||
dispatch(setCustomMascotGifUrl(null));
|
||||
};
|
||||
|
||||
// Filter the menu to colors the asset pipeline currently supports — guards
|
||||
@@ -455,6 +491,56 @@ const MascotPanel = () => {
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.mascot.characterHeading')}
|
||||
</h3>
|
||||
<div className="mb-3 bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('settings.mascot.customGifHeading')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t('settings.mascot.customGifLabel')}
|
||||
data-testid="mascot-custom-gif-input"
|
||||
value={customGifDraft}
|
||||
placeholder="https://example.com/avatar.gif"
|
||||
onChange={e => {
|
||||
setCustomGifDraft(e.target.value);
|
||||
setCustomGifError(null);
|
||||
}}
|
||||
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-custom-gif-save"
|
||||
onClick={onSaveCustomGif}
|
||||
disabled={customGifDraft.trim() === (customMascotGifUrl ?? '').trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-custom-gif-reset"
|
||||
onClick={onResetCustomGif}
|
||||
disabled={customMascotGifUrl == null && customGifDraft.trim().length === 0}
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
|
||||
{t('common.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{customGifError && (
|
||||
<p
|
||||
data-testid="mascot-custom-gif-error"
|
||||
className="text-xs text-coral-700 dark:text-coral-300">
|
||||
{customGifError}
|
||||
</p>
|
||||
)}
|
||||
{customMascotGifUrl && (
|
||||
<div className="flex justify-center rounded-lg border border-stone-100 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div style={{ width: 128, height: 128 }}>
|
||||
<CustomGifMascot src={customMascotGifUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
|
||||
{backendListError && (
|
||||
<p className="p-4 text-sm text-coral-700 dark:text-coral-300">
|
||||
@@ -477,14 +563,14 @@ const MascotPanel = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectBackend(null)}
|
||||
aria-pressed={selectedMascotId == null}
|
||||
aria-pressed={selectedMascotId == null && customMascotGifUrl == null}
|
||||
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 ${
|
||||
selectedMascotId == null
|
||||
selectedMascotId == null && customMascotGifUrl == null
|
||||
? 'bg-stone-50 dark:bg-neutral-800/60 font-medium'
|
||||
: ''
|
||||
}`}>
|
||||
<span>{t('settings.mascot.localDefault')}</span>
|
||||
{selectedMascotId == null && (
|
||||
{selectedMascotId == null && customMascotGifUrl == null && (
|
||||
<span className="text-[10px] uppercase text-primary-600 dark:text-primary-300">
|
||||
{t('settings.mascot.active')}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import mascotReducer, {
|
||||
DEFAULT_MASCOT_COLOR,
|
||||
setCustomMascotGifUrl,
|
||||
setMascotColor,
|
||||
setSelectedMascotId,
|
||||
} from '../../../../store/mascotSlice';
|
||||
@@ -222,5 +223,41 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => {
|
||||
fireEvent.click(localRow);
|
||||
expect(store.getState().mascot.selectedMascotId).toBeNull();
|
||||
});
|
||||
|
||||
it('saves a custom GIF avatar and previews it', () => {
|
||||
const { store } = renderPanel();
|
||||
fireEvent.change(screen.getByTestId('mascot-custom-gif-input'), {
|
||||
target: { value: ' https://example.com/avatar.gif ' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('mascot-custom-gif-save'));
|
||||
|
||||
expect(store.getState().mascot.customMascotGifUrl).toBe('https://example.com/avatar.gif');
|
||||
expect(screen.getByTestId('custom-gif-mascot')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/avatar.gif'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-GIF avatar sources in the panel', () => {
|
||||
const { store } = renderPanel();
|
||||
fireEvent.change(screen.getByTestId('mascot-custom-gif-input'), {
|
||||
target: { value: 'https://example.com/avatar.svg' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('mascot-custom-gif-save'));
|
||||
|
||||
expect(store.getState().mascot.customMascotGifUrl).toBeNull();
|
||||
expect(screen.getByTestId('mascot-custom-gif-error')).toHaveTextContent('HTTPS .gif');
|
||||
});
|
||||
|
||||
it('selecting a backend mascot clears the custom GIF avatar', async () => {
|
||||
const store = buildStore();
|
||||
store.dispatch(setCustomMascotGifUrl('https://example.com/avatar.gif'));
|
||||
fetchMascotListMock.mockResolvedValueOnce([summary]);
|
||||
renderPanel(store);
|
||||
fireEvent.click(await screen.findByTestId('backend-mascot-yellow'));
|
||||
|
||||
expect(store.getState().mascot.selectedMascotId).toBe('yellow');
|
||||
expect(store.getState().mascot.customMascotGifUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Provider } from 'react-redux';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import chatRuntimeReducer, { setToolTimelineForThread } from '../../store/chatRuntimeSlice';
|
||||
import mascotReducer from '../../store/mascotSlice';
|
||||
import mascotReducer, { setCustomMascotGifUrl } from '../../store/mascotSlice';
|
||||
import threadReducer, { setSelectedThread } from '../../store/threadSlice';
|
||||
// ── Static import (after mocks are hoisted) ──────────────────────────────
|
||||
import HumanPage from './HumanPage';
|
||||
@@ -25,6 +25,9 @@ vi.mock('../../pages/Conversations', () => ({
|
||||
|
||||
vi.mock('./Mascot', () => ({
|
||||
YellowMascot: () => <div data-testid="mascot-stub" />,
|
||||
CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
|
||||
<img data-testid="custom-gif-mascot" data-face={face} src={src} alt="" />
|
||||
),
|
||||
Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
|
||||
<div data-testid="ghosty-submascot" data-face={face} data-body-color={bodyColor} />
|
||||
),
|
||||
@@ -136,4 +139,17 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
|
||||
expect(screen.getByText('Researcher')).toBeInTheDocument();
|
||||
expect(screen.getByText('Iteration 1/3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom GIF mascot when one is configured', () => {
|
||||
const store = buildMinimalStore();
|
||||
store.dispatch(setCustomMascotGifUrl('https://example.com/avatar.gif'));
|
||||
|
||||
renderHumanPage(store);
|
||||
|
||||
expect(screen.getByTestId('custom-gif-mascot')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/avatar.gif'
|
||||
);
|
||||
expect(screen.queryByTestId('mascot-stub')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import Conversations from '../../pages/Conversations';
|
||||
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { selectMascotColor } from '../../store/mascotSlice';
|
||||
import { YellowMascot } from './Mascot';
|
||||
import { selectCustomMascotGifUrl, selectMascotColor } from '../../store/mascotSlice';
|
||||
import { CustomGifMascot, YellowMascot } from './Mascot';
|
||||
import { SubMascotLayer } from './SubMascotLayer';
|
||||
import { useHumanMascot } from './useHumanMascot';
|
||||
|
||||
@@ -29,6 +29,7 @@ const HumanPage = () => {
|
||||
// Visemes are intentionally unused — the YellowMascot has its own talking lipsync.
|
||||
const { face } = useHumanMascot({ speakReplies });
|
||||
const mascotColor = useAppSelector(selectMascotColor);
|
||||
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
|
||||
const subMascotTimeline = useAppSelector(state => {
|
||||
const threadId = state.thread.selectedThreadId ?? state.thread.activeThreadId;
|
||||
return threadId
|
||||
@@ -50,7 +51,11 @@ const HumanPage = () => {
|
||||
{/* Mascot stage — fills the area to the left of the reserved sidebar column. */}
|
||||
<div className="absolute inset-y-0 left-0 right-[436px] flex items-center justify-center">
|
||||
<div className="relative w-[min(80vh,90%)] aspect-square">
|
||||
<YellowMascot face={face} mascotColor={mascotColor} />
|
||||
{customMascotGifUrl ? (
|
||||
<CustomGifMascot src={customMascotGifUrl} face={face} />
|
||||
) : (
|
||||
<YellowMascot face={face} mascotColor={mascotColor} />
|
||||
)}
|
||||
<SubMascotLayer entries={subMascotTimeline} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CustomGifMascot } from './CustomGifMascot';
|
||||
|
||||
describe('CustomGifMascot', () => {
|
||||
it('renders a no-referrer animated avatar image', () => {
|
||||
render(<CustomGifMascot src="https://example.com/avatar.gif" face="speaking" />);
|
||||
|
||||
const mascot = screen.getByTestId('custom-gif-mascot') as HTMLImageElement;
|
||||
expect(mascot).toHaveAttribute('src', 'https://example.com/avatar.gif');
|
||||
expect(mascot).toHaveAttribute('data-face', 'speaking');
|
||||
expect(mascot).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
expect(mascot).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MascotFace } from './Ghosty';
|
||||
|
||||
export interface CustomGifMascotProps {
|
||||
src: string;
|
||||
face?: MascotFace;
|
||||
}
|
||||
|
||||
export function CustomGifMascot({ src, face = 'idle' }: CustomGifMascotProps) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-testid="custom-gif-mascot"
|
||||
data-face={face}
|
||||
referrerPolicy="no-referrer"
|
||||
draggable={false}
|
||||
className="block h-full w-full select-none object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export { Ghosty } from './Ghosty';
|
||||
export type { GhostyProps, MascotFace } from './Ghosty';
|
||||
export { CustomGifMascot } from './CustomGifMascot';
|
||||
export type { CustomGifMascotProps } from './CustomGifMascot';
|
||||
export { YellowMascot } from './YellowMascot';
|
||||
export type { YellowMascotProps } from './YellowMascot';
|
||||
export { lerpViseme, VISEMES, visemePath } from './visemes';
|
||||
|
||||
@@ -200,6 +200,11 @@ const ar5: TranslationMap = {
|
||||
'settings.mascot.active': 'نشط',
|
||||
'settings.mascot.characterDesc': 'وصف الشخصية',
|
||||
'settings.mascot.characterHeading': 'عنوان الشخصية',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'وصف اللون',
|
||||
'settings.mascot.colorHeading': 'عنوان اللون',
|
||||
'settings.mascot.loadingLibrary': 'جارٍ تحميل مكتبة OpenHuman…',
|
||||
|
||||
@@ -206,6 +206,11 @@ const bn5: TranslationMap = {
|
||||
'settings.mascot.active': 'সক্রিয়',
|
||||
'settings.mascot.characterDesc': 'চরিত্রের বিবরণ',
|
||||
'settings.mascot.characterHeading': 'চরিত্রের শিরোনাম',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'রঙের বিবরণ',
|
||||
'settings.mascot.colorHeading': 'রঙের শিরোনাম',
|
||||
'settings.mascot.loadingLibrary': 'OpenHuman লাইব্রেরি লোড হচ্ছে…',
|
||||
|
||||
@@ -204,6 +204,10 @@ const en5: TranslationMap = {
|
||||
'settings.mascot.active': 'Active',
|
||||
'settings.mascot.characterDesc': 'Character desc',
|
||||
'settings.mascot.characterHeading': 'Character heading',
|
||||
'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.colorDesc': 'Color desc',
|
||||
'settings.mascot.colorHeading': 'Color heading',
|
||||
'settings.mascot.loadingLibrary': 'Loading OpenHuman library…',
|
||||
|
||||
@@ -210,6 +210,11 @@ const es5: TranslationMap = {
|
||||
'settings.mascot.active': 'Activo',
|
||||
'settings.mascot.characterDesc': 'Descripción del personaje',
|
||||
'settings.mascot.characterHeading': 'Encabezado del personaje',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'Descripción del color',
|
||||
'settings.mascot.colorHeading': 'Encabezado del color',
|
||||
'settings.mascot.loadingLibrary': 'Cargando biblioteca de OpenHuman…',
|
||||
|
||||
@@ -212,6 +212,11 @@ const fr5: TranslationMap = {
|
||||
'settings.mascot.active': 'Actif',
|
||||
'settings.mascot.characterDesc': 'Description du personnage',
|
||||
'settings.mascot.characterHeading': 'Titre du personnage',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'Description de la couleur',
|
||||
'settings.mascot.colorHeading': 'Titre de la couleur',
|
||||
'settings.mascot.loadingLibrary': 'Chargement de la bibliothèque OpenHuman…',
|
||||
|
||||
@@ -206,6 +206,11 @@ const hi5: TranslationMap = {
|
||||
'settings.mascot.active': 'एक्टिव',
|
||||
'settings.mascot.characterDesc': 'कैरेक्टर विवरण',
|
||||
'settings.mascot.characterHeading': 'कैरेक्टर शीर्षक',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'रंग विवरण',
|
||||
'settings.mascot.colorHeading': 'रंग शीर्षक',
|
||||
'settings.mascot.loadingLibrary': 'OpenHuman लाइब्रेरी लोड हो रही है…',
|
||||
|
||||
@@ -206,6 +206,11 @@ const id5: TranslationMap = {
|
||||
'settings.mascot.active': 'Aktif',
|
||||
'settings.mascot.characterDesc': 'Deskripsi karakter',
|
||||
'settings.mascot.characterHeading': 'Judul karakter',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'Deskripsi warna',
|
||||
'settings.mascot.colorHeading': 'Judul warna',
|
||||
'settings.mascot.loadingLibrary': 'Memuat perpustakaan OpenHuman...',
|
||||
|
||||
@@ -210,6 +210,11 @@ const it5: TranslationMap = {
|
||||
'settings.mascot.active': 'Attivo',
|
||||
'settings.mascot.characterDesc': 'Descrizione personaggio',
|
||||
'settings.mascot.characterHeading': 'Intestazione personaggio',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'Descrizione colore',
|
||||
'settings.mascot.colorHeading': 'Intestazione colore',
|
||||
'settings.mascot.loadingLibrary': 'Caricamento libreria OpenHuman…',
|
||||
|
||||
@@ -167,6 +167,10 @@ const ko5: TranslationMap = {
|
||||
'settings.mascot.active': '활성',
|
||||
'settings.mascot.characterDesc': '캐릭터 설명',
|
||||
'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.colorDesc': '색상 설명',
|
||||
'settings.mascot.colorHeading': '색상 제목',
|
||||
'settings.mascot.loadingLibrary': 'OpenHuman 라이브러리 불러오는 중…',
|
||||
|
||||
@@ -211,6 +211,11 @@ const pt5: TranslationMap = {
|
||||
'settings.mascot.active': 'Ativo',
|
||||
'settings.mascot.characterDesc': 'Descrição do personagem',
|
||||
'settings.mascot.characterHeading': 'Título do personagem',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'Descrição de cor',
|
||||
'settings.mascot.colorHeading': 'Título de cor',
|
||||
'settings.mascot.loadingLibrary': 'Carregando biblioteca do OpenHuman…',
|
||||
|
||||
@@ -207,6 +207,11 @@ const ru5: TranslationMap = {
|
||||
'settings.mascot.active': 'Активно',
|
||||
'settings.mascot.characterDesc': 'Описание персонажа',
|
||||
'settings.mascot.characterHeading': 'Персонаж',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': 'Описание цвета',
|
||||
'settings.mascot.colorHeading': 'Цвет',
|
||||
'settings.mascot.loadingLibrary': 'Загрузка библиотеки OpenHuman…',
|
||||
|
||||
@@ -190,6 +190,11 @@ const zhCN5: TranslationMap = {
|
||||
'settings.mascot.active': '活跃',
|
||||
'settings.mascot.characterDesc': '选择你的 OpenHuman 角色',
|
||||
'settings.mascot.characterHeading': '角色',
|
||||
// TODO: translate custom GIF mascot strings.
|
||||
'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.colorDesc': '选择颜色方案',
|
||||
'settings.mascot.colorHeading': '颜色',
|
||||
'settings.mascot.loadingLibrary': '正在加载 OpenHuman 库…',
|
||||
|
||||
@@ -1999,6 +1999,10 @@ const en: TranslationMap = {
|
||||
'settings.mascot.active': 'Active',
|
||||
'settings.mascot.characterDesc': 'Choose your OpenHuman character.',
|
||||
'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.characterPreview': 'Preview',
|
||||
'settings.mascot.characterStates': 'states',
|
||||
'settings.mascot.characterVisemes': 'visemes',
|
||||
|
||||
@@ -1842,6 +1842,10 @@ const ko: TranslationMap = {
|
||||
'settings.mascot.active': '활성',
|
||||
'settings.mascot.characterDesc': 'OpenHuman 캐릭터를 선택하세요.',
|
||||
'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.characterPreview': '미리보기',
|
||||
'settings.mascot.characterStates': '상태',
|
||||
'settings.mascot.characterVisemes': '입 모양',
|
||||
|
||||
@@ -3,10 +3,14 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import reducer, {
|
||||
DEFAULT_MASCOT_COLOR,
|
||||
isCustomMascotGifUrl,
|
||||
MAX_CUSTOM_MASCOT_GIF_URL_LEN,
|
||||
MAX_MASCOT_VOICE_ID_LEN,
|
||||
selectCustomMascotGifUrl,
|
||||
selectMascotColor,
|
||||
selectMascotVoiceId,
|
||||
selectSelectedMascotId,
|
||||
setCustomMascotGifUrl,
|
||||
setMascotColor,
|
||||
setMascotVoiceId,
|
||||
setSelectedMascotId,
|
||||
@@ -199,4 +203,78 @@ describe('mascotSlice', () => {
|
||||
expect(state.selectedMascotId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom mascot GIF avatar', () => {
|
||||
it('defaults to null', () => {
|
||||
const state = reducer(undefined, { type: '@@INIT' });
|
||||
expect(state.customMascotGifUrl).toBeNull();
|
||||
expect(selectCustomMascotGifUrl({ mascot: state })).toBeNull();
|
||||
});
|
||||
|
||||
it('stores a trimmed HTTPS GIF URL', () => {
|
||||
const state = reducer(
|
||||
undefined,
|
||||
setCustomMascotGifUrl(' https://example.com/avatar.gif?size=2 ')
|
||||
);
|
||||
expect(state.customMascotGifUrl).toBe('https://example.com/avatar.gif?size=2');
|
||||
});
|
||||
|
||||
it('accepts local GIF 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', () => {
|
||||
expect(isCustomMascotGifUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isCustomMascotGifUrl('http://example.com/avatar.gif')).toBe(false);
|
||||
expect(isCustomMascotGifUrl('https://example.com/avatar.svg')).toBe(false);
|
||||
expect(isCustomMascotGifUrl('https://example.com/avatar.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects oversize avatar sources', () => {
|
||||
const tooLong = `https://example.com/${'x'.repeat(MAX_CUSTOM_MASCOT_GIF_URL_LEN)}.gif`;
|
||||
const state = reducer(undefined, setCustomMascotGifUrl(tooLong));
|
||||
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'));
|
||||
expect(state.customMascotGifUrl).toBe('https://example.com/avatar.gif');
|
||||
expect(state.selectedMascotId).toBeNull();
|
||||
});
|
||||
|
||||
it('clears custom GIF when a backend mascot is selected', () => {
|
||||
let state = reducer(undefined, setCustomMascotGifUrl('https://example.com/avatar.gif'));
|
||||
state = reducer(state, setSelectedMascotId('yellow'));
|
||||
expect(state.selectedMascotId).toBe('yellow');
|
||||
expect(state.customMascotGifUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('resetUserScopedState clears a custom GIF avatar', () => {
|
||||
let state = reducer(undefined, setCustomMascotGifUrl('https://example.com/avatar.gif'));
|
||||
state = reducer(state, resetUserScopedState());
|
||||
expect(state.customMascotGifUrl).toBeNull();
|
||||
});
|
||||
|
||||
const rehydrate = (key: string, payload?: unknown) => ({ type: REHYDRATE, key, payload });
|
||||
|
||||
it('restores a valid persisted custom GIF avatar', () => {
|
||||
const state = reducer(
|
||||
undefined,
|
||||
rehydrate('mascot', { customMascotGifUrl: 'https://example.com/avatar.gif' })
|
||||
);
|
||||
expect(state.customMascotGifUrl).toBe('https://example.com/avatar.gif');
|
||||
});
|
||||
|
||||
it('scrubs an invalid persisted custom GIF avatar back to null', () => {
|
||||
const state = reducer(
|
||||
undefined,
|
||||
rehydrate('mascot', { customMascotGifUrl: 'https://example.com/avatar.svg' })
|
||||
);
|
||||
expect(state.customMascotGifUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,12 +128,14 @@ const persistedNotificationReducer = persistReducer(notificationPersistConfig, n
|
||||
const threadPersistConfig = { key: 'thread', storage, whitelist: ['selectedThreadId'] };
|
||||
const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer);
|
||||
|
||||
// Mascot appearance + voice — color and voiceId preferences are per-user
|
||||
// so they travel with the account on logout/login rather than leaking
|
||||
// across users. `voiceId` is the user's chosen ElevenLabs voice for
|
||||
// reply speech (issue #1762); `null` falls back to the build-time
|
||||
// default in `app/src/utils/config.ts::MASCOT_VOICE_ID`.
|
||||
const mascotPersistConfig = { key: 'mascot', storage, whitelist: ['color', 'voiceId'] };
|
||||
// Persist only previously persisted mascot appearance fields plus the custom
|
||||
// GIF override added by this feature; leave existing non-persisted mascot
|
||||
// fields as runtime state to avoid changing refresh behavior.
|
||||
const mascotPersistConfig = {
|
||||
key: 'mascot',
|
||||
storage,
|
||||
whitelist: ['color', 'voiceId', 'customMascotGifUrl'],
|
||||
};
|
||||
const persistedMascotReducer = persistReducer(mascotPersistConfig, mascotReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DEFAULT_MASCOT_VOICE_GENDER: MascotVoiceGender = 'male';
|
||||
* is dropped at the reducer boundary.
|
||||
*/
|
||||
export const MAX_MASCOT_VOICE_ID_LEN = 128;
|
||||
export const MAX_CUSTOM_MASCOT_GIF_URL_LEN = 2048;
|
||||
|
||||
/**
|
||||
* Loose shape check for a stored mascot voice id. Issue #1762 lets users
|
||||
@@ -57,6 +58,27 @@ function isMascotVoiceId(value: unknown): value is string {
|
||||
);
|
||||
}
|
||||
|
||||
function hasGifPath(value: string): boolean {
|
||||
const [path = ''] = value.split(/[?#]/, 1);
|
||||
return path.toLowerCase().endsWith('.gif');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!hasGifPath(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('~/'));
|
||||
}
|
||||
}
|
||||
|
||||
function isMascotVoiceGender(value: unknown): value is MascotVoiceGender {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
@@ -96,6 +118,12 @@ export interface MascotState {
|
||||
* persisted blob bounded.
|
||||
*/
|
||||
selectedMascotId: string | null;
|
||||
/**
|
||||
* User-supplied animated avatar source. Kept as a plain validated
|
||||
* string so the renderer can fall back to YellowMascot whenever the
|
||||
* override is absent or scrubbed during rehydrate.
|
||||
*/
|
||||
customMascotGifUrl: string | null;
|
||||
}
|
||||
|
||||
const initialState: MascotState = {
|
||||
@@ -104,6 +132,7 @@ const initialState: MascotState = {
|
||||
voiceGender: DEFAULT_MASCOT_VOICE_GENDER,
|
||||
voiceUseLocaleDefault: false,
|
||||
selectedMascotId: null,
|
||||
customMascotGifUrl: null,
|
||||
};
|
||||
|
||||
function isMascotColor(value: unknown): value is MascotColor {
|
||||
@@ -132,10 +161,23 @@ const mascotSlice = createSlice({
|
||||
}
|
||||
if (isMascotVoiceId(action.payload)) {
|
||||
state.selectedMascotId = action.payload.trim();
|
||||
state.customMascotGifUrl = null;
|
||||
} else {
|
||||
state.selectedMascotId = null;
|
||||
}
|
||||
},
|
||||
setCustomMascotGifUrl(state, action: PayloadAction<string | null>) {
|
||||
if (action.payload == null) {
|
||||
state.customMascotGifUrl = null;
|
||||
return;
|
||||
}
|
||||
if (isCustomMascotGifUrl(action.payload)) {
|
||||
state.customMascotGifUrl = action.payload.trim();
|
||||
state.selectedMascotId = null;
|
||||
} else {
|
||||
state.customMascotGifUrl = null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set or clear the user-selected mascot voice id. Whitespace is
|
||||
* trimmed; empty / oversize / non-string values clear the override
|
||||
@@ -179,6 +221,7 @@ const mascotSlice = createSlice({
|
||||
voiceGender?: unknown;
|
||||
voiceUseLocaleDefault?: unknown;
|
||||
selectedMascotId?: unknown;
|
||||
customMascotGifUrl?: unknown;
|
||||
};
|
||||
};
|
||||
if (rehydrateAction.key !== 'mascot') return;
|
||||
@@ -191,6 +234,14 @@ const mascotSlice = createSlice({
|
||||
: isMascotVoiceId(restoredSelectedMascotId)
|
||||
? (restoredSelectedMascotId as string).trim()
|
||||
: null;
|
||||
const restoredCustomMascotGifUrl = rehydrateAction.payload?.customMascotGifUrl;
|
||||
state.customMascotGifUrl =
|
||||
restoredCustomMascotGifUrl == null
|
||||
? null
|
||||
: isCustomMascotGifUrl(restoredCustomMascotGifUrl)
|
||||
? (restoredCustomMascotGifUrl as string).trim()
|
||||
: null;
|
||||
if (state.customMascotGifUrl) state.selectedMascotId = null;
|
||||
// `voiceId` is optional in older persisted blobs (pre-#1762) — the
|
||||
// `null` fallback is the intended default and matches a fresh
|
||||
// install. Invalid values are scrubbed so a corrupted localStorage
|
||||
@@ -220,6 +271,7 @@ export const {
|
||||
setMascotVoiceGender,
|
||||
setMascotVoiceUseLocaleDefault,
|
||||
setSelectedMascotId,
|
||||
setCustomMascotGifUrl,
|
||||
} = mascotSlice.actions;
|
||||
|
||||
export const selectMascotColor = (state: { mascot: MascotState }): MascotColor =>
|
||||
@@ -237,6 +289,9 @@ export const selectMascotVoiceUseLocaleDefault = (state: { mascot: MascotState }
|
||||
export const selectSelectedMascotId = (state: { mascot: MascotState }): string | null =>
|
||||
state.mascot.selectedMascotId;
|
||||
|
||||
export const selectCustomMascotGifUrl = (state: { mascot: MascotState }): string | null =>
|
||||
state.mascot.customMascotGifUrl;
|
||||
|
||||
/**
|
||||
* Resolve the voice id the next reply will be synthesised with, taking
|
||||
* into account every mascot-voice setting plus the active locale. This
|
||||
|
||||
Reference in New Issue
Block a user