Add custom GIF mascot avatar override (#2347)

This commit is contained in:
Srinivas Vaddi
2026-05-23 00:57:15 -07:00
committed by GitHub
parent 48548a223b
commit c6f5a8bb35
24 changed files with 397 additions and 13 deletions
@@ -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();
});
});
});
+17 -1
View File
@@ -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();
});
});
+8 -3
View File
@@ -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"
/>
);
}
+2
View File
@@ -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';
+5
View File
@@ -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…',
+5
View File
@@ -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 লাইব্রেরি লোড হচ্ছে…',
+4
View File
@@ -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…',
+5
View File
@@ -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…',
+5
View File
@@ -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…',
+5
View File
@@ -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 लाइब्रेरी लोड हो रही है…',
+5
View File
@@ -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...',
+5
View File
@@ -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…',
+4
View File
@@ -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 라이브러리 불러오는 중…',
+5
View File
@@ -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…',
+5
View File
@@ -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…',
+5
View File
@@ -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 库…',
+4
View File
@@ -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',
+4
View File
@@ -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();
});
});
});
+8 -6
View File
@@ -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({
+55
View File
@@ -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