mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
b7b8ba6bb8
commit
b399eabc09
@@ -149,6 +149,22 @@ const SettingsHome = () => {
|
||||
),
|
||||
onClick: () => navigateToSettings('mascot'),
|
||||
},
|
||||
{
|
||||
id: 'persona',
|
||||
title: t('settings.persona.menuTitle'),
|
||||
description: t('settings.persona.menuDesc'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('persona'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Features tile (Screen Awareness / Messaging Channels / Notifications /
|
||||
|
||||
@@ -156,6 +156,14 @@ describe('SettingsHome', () => {
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications');
|
||||
});
|
||||
|
||||
it('navigates to persona settings when Persona is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('Persona').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('persona');
|
||||
});
|
||||
|
||||
it('navigates to /notifications inbox when Alerts is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
@@ -27,4 +27,9 @@ describe('useSettingsNavigation breadcrumbs', () => {
|
||||
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/developer-options'] });
|
||||
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
|
||||
});
|
||||
|
||||
test('persona returns Settings (top-level)', () => {
|
||||
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/persona'] });
|
||||
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export type SettingsRoute =
|
||||
| 'notifications'
|
||||
| 'notification-routing'
|
||||
| 'mascot'
|
||||
| 'persona'
|
||||
| 'appearance'
|
||||
| 'intelligence'
|
||||
| 'webhooks-triggers'
|
||||
@@ -115,6 +116,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/notifications')) return 'notifications';
|
||||
if (path.includes('/settings/devices')) return 'devices';
|
||||
if (path.includes('/settings/mascot')) return 'mascot';
|
||||
if (path.includes('/settings/persona')) return 'persona';
|
||||
if (path.includes('/settings/appearance')) return 'appearance';
|
||||
if (path.includes('/settings/mcp-server')) return 'mcp-server';
|
||||
return 'home';
|
||||
@@ -241,6 +243,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'mascot':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Persona panel sits at the top level of Settings.
|
||||
case 'persona':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Appearance (theme) panel sits at the top level of Settings.
|
||||
case 'appearance':
|
||||
return [settingsCrumb];
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import PersonaPanel from './PersonaPanel';
|
||||
|
||||
const {
|
||||
mockNavigateBack,
|
||||
mockNavigateToSettings,
|
||||
readPersonaFileMock,
|
||||
writePersonaFileMock,
|
||||
resetPersonaFileMock,
|
||||
} = vi.hoisted(() => ({
|
||||
mockNavigateBack: vi.fn(),
|
||||
mockNavigateToSettings: vi.fn(),
|
||||
readPersonaFileMock: vi.fn(),
|
||||
writePersonaFileMock: vi.fn(),
|
||||
resetPersonaFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/personaFilesApi', () => ({
|
||||
PERSONA_FILE_SOUL: 'SOUL.md',
|
||||
readPersonaFile: (...args: unknown[]) => readPersonaFileMock(...args),
|
||||
writePersonaFile: (...args: unknown[]) => writePersonaFileMock(...args),
|
||||
resetPersonaFile: (...args: unknown[]) => resetPersonaFileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: mockNavigateBack,
|
||||
navigateToSettings: mockNavigateToSettings,
|
||||
breadcrumbs: [{ label: 'Settings' }],
|
||||
}),
|
||||
}));
|
||||
|
||||
const soulFile = (overrides: Record<string, unknown> = {}) => ({
|
||||
filename: 'SOUL.md',
|
||||
contents: 'You are helpful.',
|
||||
is_default: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PersonaPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readPersonaFileMock.mockResolvedValue(soulFile());
|
||||
writePersonaFileMock.mockImplementation((_name: string, contents: string) =>
|
||||
Promise.resolve(soulFile({ contents, is_default: false }))
|
||||
);
|
||||
resetPersonaFileMock.mockResolvedValue(
|
||||
soulFile({ contents: 'default soul', is_default: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('loads SOUL.md contents into the editor on mount', async () => {
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('You are helpful.');
|
||||
});
|
||||
expect(readPersonaFileMock).toHaveBeenCalledWith('SOUL.md');
|
||||
});
|
||||
|
||||
it('persists the display name to the store on save', async () => {
|
||||
const { store } = renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByTestId('persona-display-name-input'), {
|
||||
target: { value: 'Nova' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('persona-description-input'), {
|
||||
target: { value: 'Calm and concise.' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('persona-identity-save'));
|
||||
|
||||
expect(store.getState().persona.displayName).toBe('Nova');
|
||||
expect(store.getState().persona.description).toBe('Calm and concise.');
|
||||
});
|
||||
|
||||
it('keeps the identity save button disabled until a field changes', async () => {
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('persona-identity-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('writes edited SOUL.md contents over RPC', async () => {
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByTestId('persona-soul-editor'), {
|
||||
target: { value: 'You are calm and concise.' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('persona-soul-save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writePersonaFileMock).toHaveBeenCalledWith('SOUL.md', 'You are calm and concise.');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a save error when the write RPC fails', async () => {
|
||||
writePersonaFileMock.mockRejectedValue(new Error('disk full'));
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByTestId('persona-soul-editor'), { target: { value: 'edited' } });
|
||||
fireEvent.click(screen.getByTestId('persona-soul-save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('disk full');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a reset error when the reset RPC fails', async () => {
|
||||
readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false }));
|
||||
resetPersonaFileMock.mockRejectedValue(new Error('reset boom'));
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('persona-soul-reset'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('reset boom');
|
||||
});
|
||||
});
|
||||
|
||||
it('resets SOUL.md to the bundled default', async () => {
|
||||
// Start from a non-default file so the Reset button is enabled.
|
||||
readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false }));
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('persona-soul-reset'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPersonaFileMock).toHaveBeenCalledWith('SOUL.md');
|
||||
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('default soul');
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Reset while the file is already the bundled default', async () => {
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('persona-soul-reset')).toBeDisabled();
|
||||
expect(screen.getByTestId('persona-soul-default-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a load error', async () => {
|
||||
readPersonaFileMock.mockRejectedValue(new Error('boom'));
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('boom');
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to mascot settings for avatar & voice', async () => {
|
||||
renderWithProviders(<PersonaPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('persona-open-mascot'));
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import debug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
PERSONA_FILE_SOUL,
|
||||
readPersonaFile,
|
||||
resetPersonaFile,
|
||||
writePersonaFile,
|
||||
} from '../../../services/api/personaFilesApi';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
MAX_PERSONA_DESCRIPTION_LEN,
|
||||
MAX_PERSONA_DISPLAY_NAME_LEN,
|
||||
selectPersonaDescription,
|
||||
selectPersonaDisplayName,
|
||||
setPersonaDescription,
|
||||
setPersonaDisplayName,
|
||||
} from '../../../store/personaSlice';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const log = debug('persona:panel');
|
||||
|
||||
const PersonaPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const storedDisplayName = useAppSelector(selectPersonaDisplayName);
|
||||
const storedDescription = useAppSelector(selectPersonaDescription);
|
||||
|
||||
const [nameDraft, setNameDraft] = useState(storedDisplayName);
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(storedDescription);
|
||||
|
||||
// Re-sync drafts when the store is reset externally (e.g. resetUserScopedState
|
||||
// during an identity flip) so Save can't write stale values into a clean store.
|
||||
useEffect(() => {
|
||||
setNameDraft(storedDisplayName);
|
||||
}, [storedDisplayName]);
|
||||
useEffect(() => {
|
||||
setDescriptionDraft(storedDescription);
|
||||
}, [storedDescription]);
|
||||
|
||||
// SOUL.md editor state. The file is loaded over RPC on mount; `isDefault`
|
||||
// tracks whether the current on-disk copy is the bundled prompt so the UI can
|
||||
// disable Reset when there is nothing to restore.
|
||||
const [soulDraft, setSoulDraft] = useState('');
|
||||
const [soulSaved, setSoulSaved] = useState('');
|
||||
const [soulIsDefault, setSoulIsDefault] = useState(true);
|
||||
const [soulLoading, setSoulLoading] = useState(true);
|
||||
const [soulError, setSoulError] = useState<string | null>(null);
|
||||
const [soulBusy, setSoulBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
log('[ui-flow] soul.load:start file=%s', PERSONA_FILE_SOUL);
|
||||
readPersonaFile(PERSONA_FILE_SOUL)
|
||||
.then(file => {
|
||||
if (cancelled) return;
|
||||
setSoulDraft(file.contents);
|
||||
setSoulSaved(file.contents);
|
||||
setSoulIsDefault(file.is_default);
|
||||
setSoulError(null);
|
||||
log('[ui-flow] soul.load:ok is_default=%s', file.is_default);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
log('[ui-flow] soul.load:error %s', err instanceof Error ? err.message : err);
|
||||
setSoulError(err instanceof Error ? err.message : 'Could not load SOUL.md');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSoulLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Load once on mount — `t` is intentionally excluded so a locale change
|
||||
// does not re-fetch and overwrite unsaved edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const nameDirty = nameDraft.trim() !== storedDisplayName;
|
||||
const descriptionDirty = descriptionDraft.trim() !== storedDescription;
|
||||
const identityDirty = nameDirty || descriptionDirty;
|
||||
|
||||
const onSaveIdentity = () => {
|
||||
if (nameDirty) dispatch(setPersonaDisplayName(nameDraft));
|
||||
if (descriptionDirty) dispatch(setPersonaDescription(descriptionDraft));
|
||||
};
|
||||
|
||||
const soulDirty = soulDraft !== soulSaved;
|
||||
|
||||
const onSaveSoul = async () => {
|
||||
setSoulBusy(true);
|
||||
setSoulError(null);
|
||||
log('[ui-flow] soul.save:start bytes=%d', soulDraft.length);
|
||||
try {
|
||||
const file = await writePersonaFile(PERSONA_FILE_SOUL, soulDraft);
|
||||
setSoulDraft(file.contents);
|
||||
setSoulSaved(file.contents);
|
||||
setSoulIsDefault(file.is_default);
|
||||
log('[ui-flow] soul.save:ok');
|
||||
} catch (err) {
|
||||
log('[ui-flow] soul.save:error %s', err instanceof Error ? err.message : err);
|
||||
setSoulError(err instanceof Error ? err.message : t('settings.persona.soul.saveError'));
|
||||
} finally {
|
||||
setSoulBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onResetSoul = async () => {
|
||||
setSoulBusy(true);
|
||||
setSoulError(null);
|
||||
log('[ui-flow] soul.reset:start');
|
||||
try {
|
||||
const file = await resetPersonaFile(PERSONA_FILE_SOUL);
|
||||
setSoulDraft(file.contents);
|
||||
setSoulSaved(file.contents);
|
||||
setSoulIsDefault(file.is_default);
|
||||
log('[ui-flow] soul.reset:ok');
|
||||
} catch (err) {
|
||||
log('[ui-flow] soul.reset:error %s', err instanceof Error ? err.message : err);
|
||||
setSoulError(err instanceof Error ? err.message : t('settings.persona.soul.resetError'));
|
||||
} finally {
|
||||
setSoulBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t('settings.persona.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* ── Identity ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.persona.identityHeading')}
|
||||
</h3>
|
||||
<div className="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.persona.displayNameLabel')}
|
||||
</span>
|
||||
<input
|
||||
aria-label={t('settings.persona.displayNameLabel')}
|
||||
data-testid="persona-display-name-input"
|
||||
value={nameDraft}
|
||||
maxLength={MAX_PERSONA_DISPLAY_NAME_LEN}
|
||||
placeholder={t('settings.persona.displayNamePlaceholder')}
|
||||
onChange={e => setNameDraft(e.target.value)}
|
||||
className="w-full 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"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('settings.persona.descriptionLabel')}
|
||||
</span>
|
||||
<textarea
|
||||
aria-label={t('settings.persona.descriptionLabel')}
|
||||
data-testid="persona-description-input"
|
||||
value={descriptionDraft}
|
||||
maxLength={MAX_PERSONA_DESCRIPTION_LEN}
|
||||
rows={3}
|
||||
placeholder={t('settings.persona.descriptionPlaceholder')}
|
||||
onChange={e => setDescriptionDraft(e.target.value)}
|
||||
className="w-full resize-y 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"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="persona-identity-save"
|
||||
onClick={onSaveIdentity}
|
||||
disabled={!identityDirty}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
|
||||
{t('settings.persona.identityDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Personality (SOUL.md) ────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.persona.soul.heading')}
|
||||
</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
|
||||
{soulLoading ? (
|
||||
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('common.loading')}</p>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
aria-label={t('settings.persona.soul.editorLabel')}
|
||||
data-testid="persona-soul-editor"
|
||||
value={soulDraft}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
onChange={e => setSoulDraft(e.target.value)}
|
||||
className="w-full resize-y rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs leading-relaxed text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="persona-soul-save"
|
||||
onClick={() => void onSaveSoul()}
|
||||
disabled={soulBusy || !soulDirty}
|
||||
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="persona-soul-reset"
|
||||
onClick={() => void onResetSoul()}
|
||||
disabled={soulBusy || soulIsDefault}
|
||||
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('settings.persona.soul.reset')}
|
||||
</button>
|
||||
{soulIsDefault && (
|
||||
<span
|
||||
data-testid="persona-soul-default-badge"
|
||||
className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.persona.soul.usingDefault')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{soulError && (
|
||||
<p
|
||||
data-testid="persona-soul-error"
|
||||
className="text-xs text-coral-700 dark:text-coral-300">
|
||||
{soulError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
|
||||
{t('settings.persona.soul.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Appearance & Voice (handled in Mascot settings) ──────── */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.persona.appearanceHeading')}
|
||||
</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="persona-open-mascot"
|
||||
onClick={() => navigateToSettings('mascot')}
|
||||
className="flex w-full items-center justify-between text-left text-sm text-stone-700 dark:text-neutral-200 hover:text-primary-700 dark:hover:text-primary-300">
|
||||
<span>{t('settings.persona.openMascotSettings')}</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
|
||||
{t('settings.persona.appearanceDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonaPanel;
|
||||
@@ -504,6 +504,30 @@ const ar5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'ملف التكوين',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP محدد العميل',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc': 'وضع التوجيه ومشغلات التكامل وأرشيف محفوظات التشغيل.',
|
||||
'settings.appearance.tabBarHeading': 'شريط علامات التبويب السفلي',
|
||||
|
||||
@@ -511,6 +511,30 @@ const bn5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'কনফিগার ফাইল',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP ক্লায়েন্ট নির্বাচক',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc': 'রাউটিং মোড, ইন্টিগ্রেশন ট্রিগার, এবং ট্রিগার ইতিহাস।',
|
||||
'settings.appearance.tabBarHeading': 'নীচের ট্যাব বার',
|
||||
|
||||
@@ -533,6 +533,30 @@ const de5: TranslationMap = {
|
||||
'settings.mascot.colorYellow': 'Gelb',
|
||||
'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar',
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing-Modus, Integrations-Trigger und Trigger-Verlaufsarchiv.',
|
||||
|
||||
@@ -517,6 +517,30 @@ const en5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'common.breadcrumb': 'Breadcrumb',
|
||||
'settings.betaBuild': 'Beta build - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -520,6 +520,30 @@ const es5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Archivo de configuración',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP selector de cliente',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Modo de enrutamiento, activadores de integración y archivo de historial de activadores.',
|
||||
|
||||
@@ -524,6 +524,30 @@ const fr5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Fichier de configuration',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP sélecteur de client',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
"Mode de routage, déclencheurs d'intégration et archive de l'historique des déclencheurs.",
|
||||
|
||||
@@ -514,6 +514,30 @@ const hi5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'जेड',
|
||||
'settings.mcpServer.configFilePath': 'कॉन्फ़िग फ़ाइल',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP ग्राहक चयनकर्ता',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc': 'रूटिंग मोड, एकीकरण ट्रिगर, और ट्रिगर इतिहास संग्रह।',
|
||||
'settings.appearance.tabBarHeading': 'निचला टैब बार',
|
||||
|
||||
@@ -514,6 +514,30 @@ const id5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'File konfigurasi',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'Pemilih klien MCP',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Mode perutean, pemicu integrasi, dan arsip riwayat pemicu.',
|
||||
|
||||
@@ -520,6 +520,30 @@ const it5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'File di configurazione',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP selettore client',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Modalità di routing, trigger di integrazione e archivio cronologico dei trigger.',
|
||||
|
||||
@@ -473,6 +473,30 @@ const ko5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': '구성 파일',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP 클라이언트 선택기',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.ai.title': 'AI 구성',
|
||||
'settings.developerMenu.ai.desc': '클라우드 공급자, 로컬 Ollama 모델 및 워크로드별 라우팅',
|
||||
'settings.developerMenu.screenAwareness.title': '화면 인식',
|
||||
|
||||
@@ -521,6 +521,30 @@ const pt5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Arquivo de configuração',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP seletor de cliente',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Modo de roteamento, gatilhos de integração e arquivo de histórico de gatilhos.',
|
||||
|
||||
@@ -517,6 +517,30 @@ const ru5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Файл конфигурации',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP селектор клиента',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Режим маршрутизации, триггеры интеграции и архив истории триггеров.',
|
||||
|
||||
@@ -483,6 +483,30 @@ const zhCN5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': '泽德',
|
||||
'settings.mcpServer.configFilePath': '配置文件',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP 客户端选择器',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc': '路由模式、集成触发器和触发器历史存档。',
|
||||
'settings.appearance.tabBarHeading': '底部标签栏',
|
||||
|
||||
@@ -3048,6 +3048,30 @@ const en: TranslationMap = {
|
||||
'settings.mascot.voice.useLocaleDefault': 'Match the app language',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Auto-pick a voice for the current interface language.',
|
||||
'settings.persona.title': 'Persona',
|
||||
'settings.persona.menuTitle': 'Persona',
|
||||
'settings.persona.menuDesc':
|
||||
'Name, personality, avatar, and voice — your assistant as one identity',
|
||||
'settings.persona.identityHeading': 'Identity',
|
||||
'settings.persona.identityDesc':
|
||||
'A display name and short description for your assistant. Shown in the app; does not change how the assistant reasons.',
|
||||
'settings.persona.displayNameLabel': 'Display name',
|
||||
'settings.persona.displayNamePlaceholder': 'e.g. Nova',
|
||||
'settings.persona.descriptionLabel': 'Description',
|
||||
'settings.persona.descriptionPlaceholder': 'e.g. A calm, concise assistant for my team.',
|
||||
'settings.persona.soul.heading': 'Personality (SOUL.md)',
|
||||
'settings.persona.soul.desc':
|
||||
'The personality prompt the assistant follows in every conversation. Edits are saved to your workspace and take effect on the next reply.',
|
||||
'settings.persona.soul.editorLabel': 'SOUL.md contents',
|
||||
'settings.persona.soul.reset': 'Reset to default',
|
||||
'settings.persona.soul.usingDefault': 'Using the bundled default',
|
||||
'settings.persona.soul.loadError': 'Could not load SOUL.md',
|
||||
'settings.persona.soul.saveError': 'Could not save SOUL.md',
|
||||
'settings.persona.soul.resetError': 'Could not reset SOUL.md',
|
||||
'settings.persona.appearanceHeading': 'Avatar & Voice',
|
||||
'settings.persona.appearanceDesc':
|
||||
'Mascot color, custom GIF avatar, and reply voice are configured in Mascot settings.',
|
||||
'settings.persona.openMascotSettings': 'Open Mascot settings',
|
||||
'settings.memoryWindow.balanced.badge': 'Recommended',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Sensible default — good continuity without burning extra tokens on every run.',
|
||||
|
||||
@@ -26,6 +26,7 @@ import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
|
||||
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
|
||||
import MigrationPanel from '../components/settings/panels/MigrationPanel';
|
||||
import NotificationsTabbedPanel from '../components/settings/panels/NotificationsTabbedPanel';
|
||||
import PersonaPanel from '../components/settings/panels/PersonaPanel';
|
||||
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
|
||||
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
|
||||
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
|
||||
@@ -408,6 +409,7 @@ const Settings = () => {
|
||||
<Route path="voice" element={wrapSettingsPage(<VoicePanel />)} />
|
||||
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
|
||||
<Route path="mascot" element={wrapSettingsPage(<MascotPanel />)} />
|
||||
<Route path="persona" element={wrapSettingsPage(<PersonaPanel />)} />
|
||||
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
|
||||
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
|
||||
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
PERSONA_FILE_SOUL,
|
||||
readPersonaFile,
|
||||
resetPersonaFile,
|
||||
type WorkspaceFile,
|
||||
writePersonaFile,
|
||||
} from './personaFilesApi';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
|
||||
}));
|
||||
|
||||
const file: WorkspaceFile = {
|
||||
filename: PERSONA_FILE_SOUL,
|
||||
contents: 'You are helpful.',
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
describe('personaFilesApi', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
it('readPersonaFile calls the read RPC with the filename and returns the file', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce(file);
|
||||
const result = await readPersonaFile('SOUL.md');
|
||||
expect(result).toEqual(file);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workspace_file_read',
|
||||
params: { filename: 'SOUL.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('writePersonaFile passes filename + contents to the write RPC', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ...file, contents: 'new' });
|
||||
const result = await writePersonaFile('SOUL.md', 'new');
|
||||
expect(result.contents).toBe('new');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workspace_file_write',
|
||||
params: { filename: 'SOUL.md', contents: 'new' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resetPersonaFile calls the reset RPC with the filename', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ...file, is_default: true });
|
||||
const result = await resetPersonaFile('SOUL.md');
|
||||
expect(result.is_default).toBe(true);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workspace_file_reset',
|
||||
params: { filename: 'SOUL.md' },
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates RPC errors from each method', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('boom'));
|
||||
await expect(readPersonaFile('SOUL.md')).rejects.toThrow('boom');
|
||||
await expect(writePersonaFile('SOUL.md', 'x')).rejects.toThrow('boom');
|
||||
await expect(resetPersonaFile('SOUL.md')).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('handles non-Error rejections without throwing in the logger', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue('stringy failure');
|
||||
await expect(readPersonaFile('SOUL.md')).rejects.toBe('stringy failure');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Persona file façade for the Settings → Persona panel (issue #2345).
|
||||
*
|
||||
* Wraps the core's workspace-file JSON-RPC surface so the panel never imports
|
||||
* `coreRpcClient` directly. Only the bundled persona prompt files are editable;
|
||||
* the core enforces the same allowlist, so an unknown name is rejected on both
|
||||
* sides.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('persona:files');
|
||||
|
||||
/** Files the Persona panel may read / edit / reset. Mirrors the core allowlist. */
|
||||
export const PERSONA_FILE_SOUL = 'SOUL.md';
|
||||
|
||||
/** Shape returned by every `openhuman.workspace_file_*` method. */
|
||||
export interface WorkspaceFile {
|
||||
filename: string;
|
||||
contents: string;
|
||||
/** True when the contents are the bundled default (missing on read, or reset). */
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export async function readPersonaFile(filename: string): Promise<WorkspaceFile> {
|
||||
log('[rpc] read:start file=%s', filename);
|
||||
try {
|
||||
const file = await callCoreRpc<WorkspaceFile>({
|
||||
method: 'openhuman.workspace_file_read',
|
||||
params: { filename },
|
||||
});
|
||||
log(
|
||||
'[rpc] read:ok file=%s is_default=%s bytes=%d',
|
||||
filename,
|
||||
file.is_default,
|
||||
file.contents.length
|
||||
);
|
||||
return file;
|
||||
} catch (err) {
|
||||
log('[rpc] read:error file=%s err=%s', filename, err instanceof Error ? err.message : err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePersonaFile(filename: string, contents: string): Promise<WorkspaceFile> {
|
||||
log('[rpc] write:start file=%s bytes=%d', filename, contents.length);
|
||||
try {
|
||||
const file = await callCoreRpc<WorkspaceFile>({
|
||||
method: 'openhuman.workspace_file_write',
|
||||
params: { filename, contents },
|
||||
});
|
||||
log('[rpc] write:ok file=%s', filename);
|
||||
return file;
|
||||
} catch (err) {
|
||||
log('[rpc] write:error file=%s err=%s', filename, err instanceof Error ? err.message : err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPersonaFile(filename: string): Promise<WorkspaceFile> {
|
||||
log('[rpc] reset:start file=%s', filename);
|
||||
try {
|
||||
const file = await callCoreRpc<WorkspaceFile>({
|
||||
method: 'openhuman.workspace_file_reset',
|
||||
params: { filename },
|
||||
});
|
||||
log('[rpc] reset:ok file=%s', filename);
|
||||
return file;
|
||||
} catch (err) {
|
||||
log('[rpc] reset:error file=%s err=%s', filename, err instanceof Error ? err.message : err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import coreModeReducer from './coreModeSlice';
|
||||
import localeReducer from './localeSlice';
|
||||
import mascotReducer from './mascotSlice';
|
||||
import notificationReducer from './notificationSlice';
|
||||
import personaReducer from './personaSlice';
|
||||
import providerSurfacesReducer from './providerSurfaceSlice';
|
||||
import socketReducer from './socketSlice';
|
||||
import themeReducer from './themeSlice';
|
||||
@@ -142,6 +143,12 @@ const mascotPersistConfig = {
|
||||
};
|
||||
const persistedMascotReducer = persistReducer(mascotPersistConfig, mascotReducer);
|
||||
|
||||
// Persona Pack v1 (issue #2345): persist the cosmetic display name + description
|
||||
// per user, mirroring how mascot appearance is stored. SOUL.md lives on disk and
|
||||
// is round-tripped over RPC, so it is intentionally not in this slice.
|
||||
const personaPersistConfig = { key: 'persona', storage, whitelist: ['displayName', 'description'] };
|
||||
const persistedPersonaReducer = persistReducer(personaPersistConfig, personaReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
socket: socketReducer,
|
||||
@@ -157,6 +164,7 @@ export const store = configureStore({
|
||||
coreMode: persistedCoreModeReducer,
|
||||
locale: persistedLocaleReducer,
|
||||
mascot: persistedMascotReducer,
|
||||
persona: persistedPersonaReducer,
|
||||
theme: persistedThemeReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { REHYDRATE } from 'redux-persist';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import reducer, {
|
||||
MAX_PERSONA_DESCRIPTION_LEN,
|
||||
MAX_PERSONA_DISPLAY_NAME_LEN,
|
||||
resetPersona,
|
||||
selectPersonaDescription,
|
||||
selectPersonaDisplayName,
|
||||
setPersonaDescription,
|
||||
setPersonaDisplayName,
|
||||
} from './personaSlice';
|
||||
import { resetUserScopedState } from './resetActions';
|
||||
|
||||
describe('personaSlice', () => {
|
||||
it('starts empty', () => {
|
||||
const state = reducer(undefined, { type: '@@INIT' });
|
||||
expect(state.displayName).toBe('');
|
||||
expect(state.description).toBe('');
|
||||
});
|
||||
|
||||
it('setPersonaDisplayName trims and stores the value', () => {
|
||||
const state = reducer(undefined, setPersonaDisplayName(' Nova '));
|
||||
expect(state.displayName).toBe('Nova');
|
||||
});
|
||||
|
||||
it('setPersonaDescription trims and stores the value', () => {
|
||||
const state = reducer(undefined, setPersonaDescription(' Calm and concise. '));
|
||||
expect(state.description).toBe('Calm and concise.');
|
||||
});
|
||||
|
||||
it('rejects an over-length display name, leaving the prior value intact', () => {
|
||||
const seeded = reducer(undefined, setPersonaDisplayName('Nova'));
|
||||
const tooLong = 'x'.repeat(MAX_PERSONA_DISPLAY_NAME_LEN + 1);
|
||||
const after = reducer(seeded, setPersonaDisplayName(tooLong));
|
||||
expect(after.displayName).toBe('Nova');
|
||||
});
|
||||
|
||||
it('rejects an over-length description, leaving the prior value intact', () => {
|
||||
const seeded = reducer(undefined, setPersonaDescription('original'));
|
||||
const tooLong = 'x'.repeat(MAX_PERSONA_DESCRIPTION_LEN + 1);
|
||||
const after = reducer(seeded, setPersonaDescription(tooLong));
|
||||
expect(after.description).toBe('original');
|
||||
});
|
||||
|
||||
it('accepts a display name exactly at the length limit', () => {
|
||||
const atLimit = 'x'.repeat(MAX_PERSONA_DISPLAY_NAME_LEN);
|
||||
const state = reducer(undefined, setPersonaDisplayName(atLimit));
|
||||
expect(state.displayName).toBe(atLimit);
|
||||
});
|
||||
|
||||
it('allows clearing a field with an empty string', () => {
|
||||
const seeded = reducer(undefined, setPersonaDisplayName('Nova'));
|
||||
const cleared = reducer(seeded, setPersonaDisplayName(' '));
|
||||
expect(cleared.displayName).toBe('');
|
||||
});
|
||||
|
||||
it('resetPersona clears both fields', () => {
|
||||
let state = reducer(undefined, setPersonaDisplayName('Nova'));
|
||||
state = reducer(state, setPersonaDescription('Calm.'));
|
||||
const after = reducer(state, resetPersona());
|
||||
expect(after.displayName).toBe('');
|
||||
expect(after.description).toBe('');
|
||||
});
|
||||
|
||||
it('resets to initial state on resetUserScopedState', () => {
|
||||
let state = reducer(undefined, setPersonaDisplayName('Nova'));
|
||||
state = reducer(state, setPersonaDescription('Calm.'));
|
||||
const after = reducer(state, resetUserScopedState());
|
||||
expect(after.displayName).toBe('');
|
||||
expect(after.description).toBe('');
|
||||
});
|
||||
|
||||
describe('REHYDRATE', () => {
|
||||
it('restores valid persisted fields', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: REHYDRATE,
|
||||
key: 'persona',
|
||||
payload: { displayName: ' Nova ', description: ' Calm. ' },
|
||||
});
|
||||
expect(state.displayName).toBe('Nova');
|
||||
expect(state.description).toBe('Calm.');
|
||||
});
|
||||
|
||||
it('scrubs over-length or non-string persisted fields to empty', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: REHYDRATE,
|
||||
key: 'persona',
|
||||
payload: { displayName: 'x'.repeat(MAX_PERSONA_DISPLAY_NAME_LEN + 1), description: 42 },
|
||||
});
|
||||
expect(state.displayName).toBe('');
|
||||
expect(state.description).toBe('');
|
||||
});
|
||||
|
||||
it('ignores rehydrate for other slices', () => {
|
||||
const seeded = reducer(undefined, setPersonaDisplayName('Nova'));
|
||||
const after = reducer(seeded, {
|
||||
type: REHYDRATE,
|
||||
key: 'mascot',
|
||||
payload: { displayName: 'overwritten' },
|
||||
});
|
||||
expect(after.displayName).toBe('Nova');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectors', () => {
|
||||
it('read the current fields', () => {
|
||||
let persona = reducer(undefined, setPersonaDisplayName('Nova'));
|
||||
persona = reducer(persona, setPersonaDescription('Calm.'));
|
||||
expect(selectPersonaDisplayName({ persona })).toBe('Nova');
|
||||
expect(selectPersonaDescription({ persona })).toBe('Calm.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { REHYDRATE } from 'redux-persist';
|
||||
|
||||
import { resetUserScopedState } from './resetActions';
|
||||
|
||||
/**
|
||||
* Persona Pack v1 (issue #2345) — lightweight identity metadata the user sets
|
||||
* for their assistant. The actual personality lives in the editable `SOUL.md`
|
||||
* prompt (handled over RPC in the Persona settings panel); this slice only
|
||||
* holds the cosmetic display name + description, persisted locally the same way
|
||||
* mascot preferences are.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Caps on the persisted persona strings. These are display fields, not prompts,
|
||||
* so the limits are generous-but-bounded purely to stop a stray multi-megabyte
|
||||
* paste from landing in localStorage. Oversize input is rejected at the reducer
|
||||
* boundary rather than truncated, so the user keeps editing their draft.
|
||||
*/
|
||||
export const MAX_PERSONA_DISPLAY_NAME_LEN = 80;
|
||||
export const MAX_PERSONA_DESCRIPTION_LEN = 500;
|
||||
|
||||
export interface PersonaState {
|
||||
/** User-facing name for the assistant persona, or `''` when unset. */
|
||||
displayName: string;
|
||||
/** Short free-text description of the persona, or `''` when unset. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
const initialState: PersonaState = { displayName: '', description: '' };
|
||||
|
||||
/**
|
||||
* Normalize a persona string: coerce non-strings to `''`, trim, and reject
|
||||
* (return `null`) when it exceeds `maxLen` so the caller can leave the prior
|
||||
* value in place. An empty/whitespace-only string is a valid "cleared" value.
|
||||
*/
|
||||
function normalizePersonaField(value: unknown, maxLen: number): string | null {
|
||||
if (typeof value !== 'string') return '';
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > maxLen) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const personaSlice = createSlice({
|
||||
name: 'persona',
|
||||
initialState,
|
||||
reducers: {
|
||||
setPersonaDisplayName(state, action: PayloadAction<string>) {
|
||||
const next = normalizePersonaField(action.payload, MAX_PERSONA_DISPLAY_NAME_LEN);
|
||||
if (next !== null) state.displayName = next;
|
||||
},
|
||||
setPersonaDescription(state, action: PayloadAction<string>) {
|
||||
const next = normalizePersonaField(action.payload, MAX_PERSONA_DESCRIPTION_LEN);
|
||||
if (next !== null) state.description = next;
|
||||
},
|
||||
/** Clear both persona fields back to their unset defaults. */
|
||||
resetPersona(state) {
|
||||
state.displayName = '';
|
||||
state.description = '';
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(resetUserScopedState, () => initialState);
|
||||
// Scrub anything unexpected (corrupted blob, a future build that dropped a
|
||||
// field) on rehydrate so the persisted value can never poison the UI.
|
||||
builder.addCase(REHYDRATE, (state, action) => {
|
||||
const rehydrateAction = action as {
|
||||
type: typeof REHYDRATE;
|
||||
key: string;
|
||||
payload?: { displayName?: unknown; description?: unknown };
|
||||
};
|
||||
if (rehydrateAction.key !== 'persona') return;
|
||||
state.displayName =
|
||||
normalizePersonaField(rehydrateAction.payload?.displayName, MAX_PERSONA_DISPLAY_NAME_LEN) ??
|
||||
'';
|
||||
state.description =
|
||||
normalizePersonaField(rehydrateAction.payload?.description, MAX_PERSONA_DESCRIPTION_LEN) ??
|
||||
'';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setPersonaDisplayName, setPersonaDescription, resetPersona } = personaSlice.actions;
|
||||
|
||||
export const selectPersonaDisplayName = (state: { persona: PersonaState }): string =>
|
||||
state.persona.displayName;
|
||||
|
||||
export const selectPersonaDescription = (state: { persona: PersonaState }): string =>
|
||||
state.persona.description;
|
||||
|
||||
export { personaSlice };
|
||||
export default personaSlice.reducer;
|
||||
@@ -16,6 +16,7 @@ import connectivityReducer from '../store/connectivitySlice';
|
||||
import coreModeReducer from '../store/coreModeSlice';
|
||||
import localeReducer from '../store/localeSlice';
|
||||
import mascotReducer from '../store/mascotSlice';
|
||||
import personaReducer from '../store/personaSlice';
|
||||
import socketReducer from '../store/socketSlice';
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,9 @@ import socketReducer from '../store/socketSlice';
|
||||
*
|
||||
* `mascot` is wired in for the mascot voice picker (issue #1762): the
|
||||
* VoicePanel reads + dispatches against this slice, and useSelector
|
||||
* would throw on a missing reducer without a stub here.
|
||||
* would throw on a missing reducer without a stub here. `persona` is wired
|
||||
* in for the same reason (issue #2345): PersonaPanel reads + dispatches
|
||||
* against it.
|
||||
*/
|
||||
const testRootReducer = combineReducers({
|
||||
channelConnections: channelConnectionsReducer,
|
||||
@@ -33,6 +36,7 @@ const testRootReducer = combineReducers({
|
||||
coreMode: coreModeReducer,
|
||||
locale: localeReducer,
|
||||
mascot: mascotReducer,
|
||||
persona: personaReducer,
|
||||
socket: socketReducer,
|
||||
});
|
||||
|
||||
|
||||
@@ -220,6 +220,14 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
| 5.3.3 | Voice Command Execution | WD | `voice-mode.spec.ts` | ✅ | |
|
||||
| 5.3.4 | Mascot Voice Selection | VU | `app/src/store/__tests__/mascotSlice.test.ts`, `app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`, `app/src/features/human/useHumanMascot.test.ts` (this PR) | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (#1762), `synthesizeSpeech` voiceId override propagation |
|
||||
|
||||
### 5.4 Persona
|
||||
|
||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||
| ----- | ----------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------- |
|
||||
| 5.4.1 | Persona Name & Description | VU | `app/src/store/personaSlice.test.ts`, `app/src/components/settings/panels/PersonaPanel.test.tsx` (this PR) | ✅ | Slice validation + persist REHYDRATE scrub; Settings identity fields persist on save (#2345) |
|
||||
| 5.4.2 | SOUL.md Edit & Reset | RU+VU | `src/openhuman/workspace/rpc.rs`, `app/src/components/settings/panels/PersonaPanel.test.tsx` (this PR) | ✅ | Core read/write/reset with allowlist + size cap; panel loads, saves, resets over RPC (#2345) |
|
||||
| 5.4.3 | Persona Settings Surface | VU | `app/src/components/settings/panels/PersonaPanel.test.tsx` (this PR) | ✅ | Bundles identity + SOUL.md + link to Mascot avatar/voice (#2345) |
|
||||
|
||||
---
|
||||
|
||||
## 6. System Tools & Agent Capabilities
|
||||
@@ -494,11 +502,11 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
|
||||
| Status | Count |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| ✅ Covered | 66 |
|
||||
| ✅ Covered | 69 |
|
||||
| 🟡 Partial | 27 |
|
||||
| ❌ Missing | 26 |
|
||||
| 🚫 Manual smoke | 11 |
|
||||
| **Total leaves** | **131 explicit + nested = 202 product features** |
|
||||
| **Total leaves** | **134 explicit + nested = 205 product features** |
|
||||
|
||||
PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test.
|
||||
Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke).
|
||||
|
||||
@@ -1022,6 +1022,16 @@ const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Stable,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "settings.persona_pack",
|
||||
name: "Persona Pack",
|
||||
domain: "settings",
|
||||
category: CapabilityCategory::Settings,
|
||||
description: "Personalize the assistant as one identity: set a display name and description, edit or reset the SOUL.md personality prompt, and reach mascot avatar and voice settings — all from a single Persona surface.",
|
||||
how_to: "Settings > Persona",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
},
|
||||
Capability {
|
||||
id: "settings.manage_privacy_analytics",
|
||||
name: "Manage Privacy and Analytics",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Workspace layout and bootstrap files (CLI `init` and similar entrypoints).
|
||||
|
||||
pub mod ops;
|
||||
pub mod rpc;
|
||||
mod schemas;
|
||||
|
||||
pub use ops::*;
|
||||
|
||||
@@ -10,6 +10,21 @@ const BOOTSTRAP_FILES: [(&str, &str); 2] = [
|
||||
("IDENTITY.md", include_str!("../agent/prompts/IDENTITY.md")),
|
||||
];
|
||||
|
||||
/// Bundled default contents for a bootstrap workspace file, or `None` when the
|
||||
/// name is not one of the files shipped in [`BOOTSTRAP_FILES`].
|
||||
///
|
||||
/// This is the single source of truth for both "which workspace files may be
|
||||
/// edited from the Persona surface" and "what to restore on reset" — the
|
||||
/// Persona Pack RPCs (`src/openhuman/workspace/rpc.rs`) treat membership here
|
||||
/// as the editable allowlist so a caller can never read or clobber an
|
||||
/// arbitrary path under the workspace.
|
||||
pub fn bundled_default_contents(filename: &str) -> Option<&'static str> {
|
||||
BOOTSTRAP_FILES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == filename)
|
||||
.map(|(_, contents)| *contents)
|
||||
}
|
||||
|
||||
fn ensure_workspace_file(
|
||||
workspace_dir: &Path,
|
||||
filename: &str,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Read / edit / reset for the bundled persona prompt files (`SOUL.md`,
|
||||
//! `IDENTITY.md`) that drive the agent's personality.
|
||||
//!
|
||||
//! Backs the Persona Pack settings surface (issue #2345). The editable set is
|
||||
//! restricted to the bundled bootstrap files (see
|
||||
//! [`crate::openhuman::workspace::ops::bundled_default_contents`]) so a caller
|
||||
//! can never read or overwrite an arbitrary path under the workspace.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::openhuman::workspace::ops::bundled_default_contents;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Hard cap on the size accepted by [`write_workspace_file`] and tolerated by
|
||||
/// [`read_workspace_file`]. `SOUL.md` / `IDENTITY.md` are prose prompts
|
||||
/// measured in kilobytes; the cap bounds both a runaway paste from the UI and a
|
||||
/// pathologically large file dropped on disk by another process (which would
|
||||
/// otherwise be slurped fully into memory and shipped over RPC).
|
||||
pub const MAX_WORKSPACE_FILE_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// A single editable persona file plus the metadata the settings UI needs to
|
||||
/// render and round-trip it. The absolute on-disk path is deliberately **not**
|
||||
/// part of this payload — the UI never needs it, and returning it would leak
|
||||
/// host filesystem layout to every RPC caller.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkspaceFile {
|
||||
/// Allowlisted file name (e.g. `SOUL.md`).
|
||||
pub filename: String,
|
||||
/// Current effective contents.
|
||||
pub contents: String,
|
||||
/// `true` when `contents` came from the bundled default rather than a file
|
||||
/// on disk — i.e. the workspace copy was missing (read) or has just been
|
||||
/// restored (reset). Lets the UI show a "using default" affordance.
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// Resolve the bundled default for `filename`, rejecting any name that is not
|
||||
/// part of the editable allowlist.
|
||||
fn ensure_editable(filename: &str) -> Result<&'static str, String> {
|
||||
bundled_default_contents(filename).ok_or_else(|| {
|
||||
log::debug!("[workspace][rpc] rejected non-editable filename='{filename}'");
|
||||
format!("'{filename}' is not an editable workspace file")
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an editable persona file. When the workspace copy is missing (e.g. a
|
||||
/// fresh install that has not run `init` yet) the bundled default is returned
|
||||
/// with `is_default = true` so the editor always shows the effective prompt.
|
||||
/// A file larger than [`MAX_WORKSPACE_FILE_BYTES`] is refused rather than
|
||||
/// loaded into memory.
|
||||
pub fn read_workspace_file(
|
||||
workspace_dir: &Path,
|
||||
filename: &str,
|
||||
) -> Result<RpcOutcome<WorkspaceFile>, String> {
|
||||
let default_contents = ensure_editable(filename)?;
|
||||
let path = workspace_dir.join(filename);
|
||||
|
||||
// Open first, then enforce the cap on the *opened handle* via a bounded
|
||||
// reader. A prior `metadata().len()` check would be TOCTOU-prone: another
|
||||
// process could grow or swap the file between the stat and the read, so an
|
||||
// oversized file could still be slurped fully into memory. Reading through
|
||||
// `take(cap + 1)` caps the bytes we will ever hold regardless of races.
|
||||
let file = match std::fs::File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
log::debug!(
|
||||
"[workspace][rpc] read fallback-to-default file='{filename}' (missing on disk)"
|
||||
);
|
||||
return Ok(RpcOutcome::new(
|
||||
WorkspaceFile {
|
||||
filename: filename.to_string(),
|
||||
contents: default_contents.to_string(),
|
||||
is_default: true,
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"[workspace][rpc] read open-failed file='{filename}' path='{}': {e}",
|
||||
path.display()
|
||||
);
|
||||
return Err(format!("failed to read {filename}: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
file.take(MAX_WORKSPACE_FILE_BYTES + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| {
|
||||
log::debug!(
|
||||
"[workspace][rpc] read failed file='{filename}' path='{}': {e}",
|
||||
path.display()
|
||||
);
|
||||
format!("failed to read {filename}: {e}")
|
||||
})?;
|
||||
// Hitting the extra byte means the file exceeds the cap; refuse it.
|
||||
if buf.len() as u64 > MAX_WORKSPACE_FILE_BYTES {
|
||||
log::debug!(
|
||||
"[workspace][rpc] read refused file='{filename}' (over cap={MAX_WORKSPACE_FILE_BYTES})"
|
||||
);
|
||||
return Err(format!(
|
||||
"{filename} is too large to edit (over the {MAX_WORKSPACE_FILE_BYTES}-byte limit)"
|
||||
));
|
||||
}
|
||||
|
||||
let contents = String::from_utf8(buf).map_err(|e| {
|
||||
log::debug!("[workspace][rpc] read rejected non-utf8 file='{filename}': {e}");
|
||||
format!("failed to read {filename}: contents are not valid UTF-8")
|
||||
})?;
|
||||
log::debug!(
|
||||
"[workspace][rpc] read ok file='{filename}' bytes={}",
|
||||
contents.len()
|
||||
);
|
||||
Ok(RpcOutcome::new(
|
||||
WorkspaceFile {
|
||||
filename: filename.to_string(),
|
||||
contents,
|
||||
is_default: false,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Overwrite an editable persona file with user-supplied contents. Rejects
|
||||
/// non-allowlisted names and anything over [`MAX_WORKSPACE_FILE_BYTES`]; the
|
||||
/// workspace directory is created if it does not yet exist.
|
||||
pub fn write_workspace_file(
|
||||
workspace_dir: &Path,
|
||||
filename: &str,
|
||||
contents: &str,
|
||||
) -> Result<RpcOutcome<WorkspaceFile>, String> {
|
||||
ensure_editable(filename)?;
|
||||
if contents.len() as u64 > MAX_WORKSPACE_FILE_BYTES {
|
||||
log::debug!(
|
||||
"[workspace][rpc] write refused file='{filename}' bytes={} cap={MAX_WORKSPACE_FILE_BYTES}",
|
||||
contents.len()
|
||||
);
|
||||
return Err(format!(
|
||||
"contents for {filename} exceed the {MAX_WORKSPACE_FILE_BYTES}-byte limit"
|
||||
));
|
||||
}
|
||||
let path = workspace_dir.join(filename);
|
||||
std::fs::create_dir_all(workspace_dir).map_err(|e| {
|
||||
log::debug!(
|
||||
"[workspace][rpc] write mkdir-failed dir='{}': {e}",
|
||||
workspace_dir.display()
|
||||
);
|
||||
format!("failed to prepare the workspace directory: {e}")
|
||||
})?;
|
||||
std::fs::write(&path, contents).map_err(|e| {
|
||||
log::debug!(
|
||||
"[workspace][rpc] write failed file='{filename}' path='{}': {e}",
|
||||
path.display()
|
||||
);
|
||||
format!("failed to write {filename}: {e}")
|
||||
})?;
|
||||
log::debug!(
|
||||
"[workspace][rpc] write ok file='{filename}' bytes={}",
|
||||
contents.len()
|
||||
);
|
||||
Ok(RpcOutcome::new(
|
||||
WorkspaceFile {
|
||||
filename: filename.to_string(),
|
||||
contents: contents.to_string(),
|
||||
is_default: false,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Restore an editable persona file to its bundled default and return the
|
||||
/// restored contents.
|
||||
pub fn reset_workspace_file(
|
||||
workspace_dir: &Path,
|
||||
filename: &str,
|
||||
) -> Result<RpcOutcome<WorkspaceFile>, String> {
|
||||
let default_contents = ensure_editable(filename)?;
|
||||
let path = workspace_dir.join(filename);
|
||||
std::fs::create_dir_all(workspace_dir).map_err(|e| {
|
||||
log::debug!(
|
||||
"[workspace][rpc] reset mkdir-failed dir='{}': {e}",
|
||||
workspace_dir.display()
|
||||
);
|
||||
format!("failed to prepare the workspace directory: {e}")
|
||||
})?;
|
||||
std::fs::write(&path, default_contents).map_err(|e| {
|
||||
log::debug!(
|
||||
"[workspace][rpc] reset failed file='{filename}' path='{}': {e}",
|
||||
path.display()
|
||||
);
|
||||
format!("failed to reset {filename}: {e}")
|
||||
})?;
|
||||
log::debug!("[workspace][rpc] reset ok file='{filename}' (restored bundled default)");
|
||||
Ok(RpcOutcome::new(
|
||||
WorkspaceFile {
|
||||
filename: filename.to_string(),
|
||||
contents: default_contents.to_string(),
|
||||
is_default: true,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn read_returns_bundled_default_when_file_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let outcome = read_workspace_file(tmp.path(), "SOUL.md").expect("read should succeed");
|
||||
let file = outcome.value;
|
||||
assert!(file.is_default, "missing file should report the default");
|
||||
assert!(!file.contents.trim().is_empty());
|
||||
assert_eq!(file.filename, "SOUL.md");
|
||||
assert_eq!(
|
||||
file.contents,
|
||||
bundled_default_contents("SOUL.md").unwrap(),
|
||||
"default read must match the bundled prompt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_returns_on_disk_contents_when_present() {
|
||||
let tmp = tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("SOUL.md"), "custom soul").unwrap();
|
||||
let file = read_workspace_file(tmp.path(), "SOUL.md")
|
||||
.expect("read ok")
|
||||
.value;
|
||||
assert!(!file.is_default);
|
||||
assert_eq!(file.contents, "custom soul");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_refuses_oversize_file_on_disk() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let huge = "a".repeat((MAX_WORKSPACE_FILE_BYTES + 1) as usize);
|
||||
std::fs::write(tmp.path().join("SOUL.md"), &huge).unwrap();
|
||||
let err = read_workspace_file(tmp.path(), "SOUL.md").unwrap_err();
|
||||
assert!(err.contains("too large"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_accepts_file_exactly_at_the_size_limit() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let at_limit = "a".repeat(MAX_WORKSPACE_FILE_BYTES as usize);
|
||||
std::fs::write(tmp.path().join("SOUL.md"), &at_limit).unwrap();
|
||||
let file = read_workspace_file(tmp.path(), "SOUL.md")
|
||||
.expect("exactly-at-limit read should succeed")
|
||||
.value;
|
||||
assert_eq!(file.contents.len(), MAX_WORKSPACE_FILE_BYTES as usize);
|
||||
assert!(!file.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_non_utf8_file() {
|
||||
let tmp = tempdir().unwrap();
|
||||
// A lone 0xFF byte is never valid UTF-8.
|
||||
std::fs::write(tmp.path().join("SOUL.md"), [0xff_u8, 0xfe, 0xfd]).unwrap();
|
||||
let err = read_workspace_file(tmp.path(), "SOUL.md").unwrap_err();
|
||||
assert!(err.contains("UTF-8"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_round_trips() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let written = write_workspace_file(tmp.path(), "SOUL.md", "You are calm and concise.")
|
||||
.expect("write ok")
|
||||
.value;
|
||||
assert!(!written.is_default);
|
||||
assert_eq!(written.contents, "You are calm and concise.");
|
||||
|
||||
let read = read_workspace_file(tmp.path(), "SOUL.md")
|
||||
.expect("read ok")
|
||||
.value;
|
||||
assert_eq!(read.contents, "You are calm and concise.");
|
||||
assert!(!read.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_workspace_dir_if_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let nested = tmp.path().join("does/not/exist/yet");
|
||||
let written = write_workspace_file(&nested, "IDENTITY.md", "id")
|
||||
.expect("write should create the dir")
|
||||
.value;
|
||||
assert_eq!(written.contents, "id");
|
||||
assert!(nested.join("IDENTITY.md").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restores_bundled_default() {
|
||||
let tmp = tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("SOUL.md"), "corrupted").unwrap();
|
||||
let reset = reset_workspace_file(tmp.path(), "SOUL.md")
|
||||
.expect("reset ok")
|
||||
.value;
|
||||
assert!(reset.is_default);
|
||||
assert_eq!(reset.contents, bundled_default_contents("SOUL.md").unwrap());
|
||||
let on_disk = std::fs::read_to_string(tmp.path().join("SOUL.md")).unwrap();
|
||||
assert_eq!(on_disk, bundled_default_contents("SOUL.md").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_allowlisted_filename_is_rejected_for_every_op() {
|
||||
let tmp = tempdir().unwrap();
|
||||
for name in ["secrets.txt", "../escape.md", "MEMORY.md", "soul.md"] {
|
||||
assert!(read_workspace_file(tmp.path(), name).is_err());
|
||||
assert!(write_workspace_file(tmp.path(), name, "x").is_err());
|
||||
assert!(reset_workspace_file(tmp.path(), name).is_err());
|
||||
}
|
||||
// The rejection must not have written anything to disk.
|
||||
assert!(!tmp.path().join("MEMORY.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_oversize_contents() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let huge = "a".repeat((MAX_WORKSPACE_FILE_BYTES + 1) as usize);
|
||||
let err = write_workspace_file(tmp.path(), "SOUL.md", &huge).unwrap_err();
|
||||
assert!(err.contains("limit"), "unexpected error: {err}");
|
||||
assert!(
|
||||
!tmp.path().join("SOUL.md").exists(),
|
||||
"oversize write must not touch disk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_accepts_contents_at_the_size_limit() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let at_limit = "a".repeat(MAX_WORKSPACE_FILE_BYTES as usize);
|
||||
let written = write_workspace_file(tmp.path(), "SOUL.md", &at_limit)
|
||||
.expect("exactly-at-limit write should succeed")
|
||||
.value;
|
||||
assert_eq!(written.contents.len(), MAX_WORKSPACE_FILE_BYTES as usize);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,214 @@
|
||||
use crate::core::all::RegisteredController;
|
||||
use crate::core::ControllerSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::openhuman::workspace::rpc as workspace_rpc;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
Vec::new()
|
||||
vec![
|
||||
schemas("file_read"),
|
||||
schemas("file_write"),
|
||||
schemas("file_reset"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
Vec::new()
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("file_read"),
|
||||
handler: handle_file_read,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("file_write"),
|
||||
handler: handle_file_write,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("file_reset"),
|
||||
handler: handle_file_reset,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filename_input() -> FieldSchema {
|
||||
FieldSchema {
|
||||
name: "filename",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Editable persona file name; must be SOUL.md or IDENTITY.md.",
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_file_outputs() -> Vec<FieldSchema> {
|
||||
vec![
|
||||
FieldSchema {
|
||||
name: "filename",
|
||||
ty: TypeSchema::String,
|
||||
comment: "The resolved persona file name.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "contents",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Current effective contents of the file.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "is_default",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the contents are the bundled default (file missing on read, or just reset).",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"file_read" => ControllerSchema {
|
||||
namespace: "workspace",
|
||||
function: "file_read",
|
||||
description: "Read an editable persona file (SOUL.md / IDENTITY.md), falling back to the bundled default when the workspace copy is missing.",
|
||||
inputs: vec![filename_input()],
|
||||
outputs: workspace_file_outputs(),
|
||||
},
|
||||
"file_write" => ControllerSchema {
|
||||
namespace: "workspace",
|
||||
function: "file_write",
|
||||
description: "Overwrite an editable persona file with new contents.",
|
||||
inputs: vec![
|
||||
filename_input(),
|
||||
FieldSchema {
|
||||
name: "contents",
|
||||
ty: TypeSchema::String,
|
||||
comment: "New file contents (size-capped server-side).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: workspace_file_outputs(),
|
||||
},
|
||||
"file_reset" => ControllerSchema {
|
||||
namespace: "workspace",
|
||||
function: "file_reset",
|
||||
description: "Restore an editable persona file to its bundled default.",
|
||||
inputs: vec![filename_input()],
|
||||
outputs: workspace_file_outputs(),
|
||||
},
|
||||
_other => ControllerSchema {
|
||||
namespace: "workspace",
|
||||
function: "unknown",
|
||||
description: "Unknown workspace controller function.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "function",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Unknown function requested for schema lookup.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_file_read(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let filename = read_required::<String>(¶ms, "filename")?;
|
||||
let filename = filename.trim();
|
||||
log::debug!("[workspace][rpc] handle_file_read filename='{filename}'");
|
||||
let result = workspace_rpc::read_workspace_file(&config.workspace_dir, filename);
|
||||
if let Err(ref e) = result {
|
||||
log::debug!("[workspace][rpc] handle_file_read error filename='{filename}': {e}");
|
||||
}
|
||||
to_json(result?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_file_write(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let filename = read_required::<String>(¶ms, "filename")?;
|
||||
let contents = read_required::<String>(¶ms, "contents")?;
|
||||
let filename = filename.trim();
|
||||
log::debug!(
|
||||
"[workspace][rpc] handle_file_write filename='{filename}' bytes={}",
|
||||
contents.len()
|
||||
);
|
||||
let result =
|
||||
workspace_rpc::write_workspace_file(&config.workspace_dir, filename, &contents);
|
||||
if let Err(ref e) = result {
|
||||
log::debug!("[workspace][rpc] handle_file_write error filename='{filename}': {e}");
|
||||
}
|
||||
to_json(result?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_file_reset(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let filename = read_required::<String>(¶ms, "filename")?;
|
||||
let filename = filename.trim();
|
||||
log::debug!("[workspace][rpc] handle_file_reset filename='{filename}'");
|
||||
let result = workspace_rpc::reset_workspace_file(&config.workspace_dir, filename);
|
||||
if let Err(ref e) = result {
|
||||
log::debug!("[workspace][rpc] handle_file_reset error filename='{filename}': {e}");
|
||||
}
|
||||
to_json(result?)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
|
||||
let value = params
|
||||
.get(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("missing required param '{key}'"))?;
|
||||
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_schemas_use_workspace_namespace() {
|
||||
let all = all_controller_schemas();
|
||||
assert_eq!(all.len(), 3);
|
||||
for schema in &all {
|
||||
assert_eq!(schema.namespace, "workspace");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_controllers_expose_expected_rpc_methods() {
|
||||
let methods: Vec<String> = all_registered_controllers()
|
||||
.iter()
|
||||
.map(|c| c.rpc_method_name())
|
||||
.collect();
|
||||
assert!(methods.contains(&"openhuman.workspace_file_read".to_string()));
|
||||
assert!(methods.contains(&"openhuman.workspace_file_write".to_string()));
|
||||
assert!(methods.contains(&"openhuman.workspace_file_reset".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_write_schema_requires_filename_and_contents() {
|
||||
let schema = schemas("file_write");
|
||||
let input_names: Vec<&str> = schema.inputs.iter().map(|f| f.name).collect();
|
||||
assert!(input_names.contains(&"filename"));
|
||||
assert!(input_names.contains(&"contents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_function_yields_unknown_schema() {
|
||||
let schema = schemas("nope");
|
||||
assert_eq!(schema.function, "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user