mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(web): task completion sound + browser notification (#359)
Merged per maintainer approval.
This commit is contained in:
parent
61cdc3fe4b
commit
68493b7b72
24 changed files with 1090 additions and 4 deletions
39
apps/web/public/od-notifications-sw.js
Normal file
39
apps/web/public/od-notifications-sw.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Browser service workers must be served as JavaScript files. This tiny
|
||||
// runtime exists only to display task-completion notifications and focus
|
||||
// the existing Open Design tab when the user clicks one.
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const data = event.notification.data || {};
|
||||
const targetUrl = typeof data.url === 'string' ? data.url : self.location.origin;
|
||||
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const sameOrigin = windows.find((client) => {
|
||||
try {
|
||||
return new URL(client.url).origin === self.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (sameOrigin) {
|
||||
if ('navigate' in sameOrigin) {
|
||||
try {
|
||||
await sameOrigin.navigate(targetUrl);
|
||||
} catch {
|
||||
/* focus the existing tab below */
|
||||
}
|
||||
}
|
||||
return sameOrigin.focus();
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl);
|
||||
}
|
||||
return undefined;
|
||||
})());
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ type IconName =
|
|||
| 'arrow-left'
|
||||
| 'arrow-up'
|
||||
| 'attach'
|
||||
| 'bell'
|
||||
| 'check'
|
||||
| 'chevron-down'
|
||||
| 'chevron-left'
|
||||
|
|
@ -93,6 +94,13 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
|
|||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
);
|
||||
case 'bell':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
);
|
||||
case 'check':
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
import { composeSystemPrompt } from '@open-design/contracts';
|
||||
import { navigate } from '../router';
|
||||
import { agentDisplayName } from '../utils/agentLabels';
|
||||
import { playSound, showCompletionNotification } from '../utils/notifications';
|
||||
import { DEFAULT_NOTIFICATIONS } from '../state/config';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import {
|
||||
createConversation,
|
||||
|
|
@ -247,6 +249,56 @@ export function ProjectView({
|
|||
reattachTextBuffersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
// Detect the streaming `true → false` edge so we can fire the optional
|
||||
// completion sound / desktop notification exactly once per turn. Initial
|
||||
// mount keeps `prevStreamingRef.current = false`, so loading historical
|
||||
// conversations (where `streaming` is also false) never triggers a stray
|
||||
// ding. `messages` is on the dep array so the latest assistant message's
|
||||
// runStatus is visible at the moment we edge-detect; the early-return
|
||||
// guarantees only the edge actually does anything.
|
||||
const prevStreamingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const wasStreaming = prevStreamingRef.current;
|
||||
prevStreamingRef.current = streaming;
|
||||
if (!(wasStreaming && !streaming)) return;
|
||||
|
||||
const last = [...messages].reverse().find((m) => m.role === 'assistant');
|
||||
if (!last) return;
|
||||
const status = last.runStatus;
|
||||
if (status !== 'succeeded' && status !== 'failed') return;
|
||||
|
||||
const cfg = config.notifications ?? DEFAULT_NOTIFICATIONS;
|
||||
if (cfg.soundEnabled) {
|
||||
playSound(status === 'succeeded' ? cfg.successSoundId : cfg.failureSoundId);
|
||||
}
|
||||
|
||||
if (cfg.desktopEnabled) {
|
||||
// Successes only interrupt when the user is on another tab/window.
|
||||
// Failures alert regardless — losing a long agent run silently is
|
||||
// worse than a small interruption when the page is in focus.
|
||||
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||||
const isFocused = typeof document === 'undefined' ? true : document.hasFocus();
|
||||
if (status === 'failed' || isHidden || !isFocused) {
|
||||
const title = status === 'succeeded'
|
||||
? t('notify.successTitle')
|
||||
: t('notify.failureTitle');
|
||||
const fallbackBody = status === 'succeeded'
|
||||
? t('notify.successBody')
|
||||
: t('notify.failureBody');
|
||||
const trimmed = (last.content ?? '').trim();
|
||||
const body = trimmed ? trimmed.slice(0, 80) : fallbackBody;
|
||||
void showCompletionNotification({
|
||||
status,
|
||||
title,
|
||||
body,
|
||||
onClick: () => {
|
||||
if (typeof window !== 'undefined') window.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [streaming, messages, config.notifications, t]);
|
||||
|
||||
// Hydrate the open-tabs state once per project. After this initial
|
||||
// load, every mutation flows through saveTabsState() which keeps DB +
|
||||
// local state coherent.
|
||||
|
|
@ -881,7 +933,7 @@ export function ProjectView({
|
|||
updateAssistant((prev) => ({
|
||||
...prev,
|
||||
endedAt: Date.now(),
|
||||
runStatus: prev.runId ? 'succeeded' : prev.runStatus,
|
||||
runStatus: config.mode === 'api' || prev.runId ? 'succeeded' : prev.runStatus,
|
||||
}));
|
||||
if (commentAttachments.length > 0) {
|
||||
void patchAttachedStatuses(commentAttachments, 'needs_review');
|
||||
|
|
@ -925,7 +977,9 @@ export function ProjectView({
|
|||
updateAssistant((prev) => ({
|
||||
...prev,
|
||||
endedAt: Date.now(),
|
||||
runStatus: prev.runId || isActiveRunStatus(prev.runStatus) ? 'failed' : prev.runStatus,
|
||||
runStatus: config.mode === 'api' || prev.runId || isActiveRunStatus(prev.runStatus)
|
||||
? 'failed'
|
||||
: prev.runStatus,
|
||||
}));
|
||||
if (commentAttachments.length > 0) {
|
||||
void patchAttachedStatuses(commentAttachments, 'failed');
|
||||
|
|
|
|||
|
|
@ -19,12 +19,22 @@ import type { AgentInfo, AppConfig, AppTheme, AppVersionInfo, ExecMode } from '.
|
|||
import { MEDIA_PROVIDERS } from '../media/models';
|
||||
import type { MediaProvider } from '../media/models';
|
||||
import { PetSettings } from './pet/PetSettings';
|
||||
import { DEFAULT_NOTIFICATIONS } from '../state/config';
|
||||
import {
|
||||
FAILURE_SOUNDS,
|
||||
SUCCESS_SOUNDS,
|
||||
notificationPermission,
|
||||
playSound,
|
||||
requestNotificationPermission,
|
||||
showCompletionNotification,
|
||||
} from '../utils/notifications';
|
||||
|
||||
export type SettingsSection =
|
||||
| 'execution'
|
||||
| 'media'
|
||||
| 'language'
|
||||
| 'appearance'
|
||||
| 'notifications'
|
||||
| 'pet'
|
||||
| 'about';
|
||||
|
||||
|
|
@ -295,6 +305,17 @@ export function SettingsDialog({
|
|||
<small>{t('settings.appearanceHint')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-nav-item${activeSection === 'notifications' ? ' active' : ''}`}
|
||||
onClick={() => setActiveSection('notifications')}
|
||||
>
|
||||
<Icon name="bell" size={18} />
|
||||
<span>
|
||||
<strong>{t('settings.notifications')}</strong>
|
||||
<small>{t('settings.notificationsHint')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-nav-item${activeSection === 'pet' ? ' active' : ''}`}
|
||||
|
|
@ -726,6 +747,10 @@ export function SettingsDialog({
|
|||
<AppearanceSection cfg={cfg} setCfg={setCfg} />
|
||||
) : null}
|
||||
|
||||
{activeSection === 'notifications' ? (
|
||||
<NotificationsSection cfg={cfg} setCfg={setCfg} />
|
||||
) : null}
|
||||
|
||||
{activeSection === 'pet' ? (
|
||||
<PetSettings cfg={cfg} setCfg={setCfg} />
|
||||
) : null}
|
||||
|
|
@ -942,3 +967,181 @@ function AppearanceSection({
|
|||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSection({
|
||||
cfg,
|
||||
setCfg,
|
||||
}: {
|
||||
cfg: AppConfig;
|
||||
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const notif = cfg.notifications ?? DEFAULT_NOTIFICATIONS;
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
|
||||
() => notificationPermission(),
|
||||
);
|
||||
const [testStatus, setTestStatus] = useState<ReturnType<typeof testNotificationStatusText> | null>(null);
|
||||
|
||||
const updateNotif = (
|
||||
patch: Partial<NonNullable<AppConfig['notifications']>>,
|
||||
) => {
|
||||
setCfg((c) => ({
|
||||
...c,
|
||||
notifications: { ...DEFAULT_NOTIFICATIONS, ...(c.notifications ?? {}), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleSound = () => {
|
||||
const next = !notif.soundEnabled;
|
||||
updateNotif({ soundEnabled: next });
|
||||
// Give the user immediate audible feedback when turning the master
|
||||
// switch on so they know which sound they're signing up for. Resuming
|
||||
// the AudioContext also bakes in their gesture for later auto-plays.
|
||||
if (next) playSound(notif.successSoundId);
|
||||
};
|
||||
|
||||
const toggleDesktop = async () => {
|
||||
if (notif.desktopEnabled) {
|
||||
updateNotif({ desktopEnabled: false });
|
||||
return;
|
||||
}
|
||||
const result = await requestNotificationPermission();
|
||||
setPermission(result);
|
||||
if (result === 'granted') {
|
||||
updateNotif({ desktopEnabled: true });
|
||||
} else {
|
||||
updateNotif({ desktopEnabled: false });
|
||||
}
|
||||
};
|
||||
|
||||
const sendTestNotification = async () => {
|
||||
const result = await showCompletionNotification({
|
||||
status: 'succeeded',
|
||||
title: t('notify.successTitle'),
|
||||
body: t('notify.successBody'),
|
||||
});
|
||||
setPermission(notificationPermission());
|
||||
setTestStatus(testNotificationStatusText(result));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3>{t('settings.notifications')}</h3>
|
||||
<p className="hint">{t('settings.notificationsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-subsection">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h4>{t('settings.notifyCompletionSound')}</h4>
|
||||
<p className="hint">{t('settings.notifyCompletionSoundHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seg-control" role="group" aria-label={t('settings.notifyCompletionSound')} style={{ '--seg-cols': 1 } as React.CSSProperties}>
|
||||
<button
|
||||
type="button"
|
||||
className={'seg-btn' + (notif.soundEnabled ? ' active' : '')}
|
||||
aria-pressed={notif.soundEnabled}
|
||||
onClick={toggleSound}
|
||||
>
|
||||
<span className="seg-title">{notif.soundEnabled ? t('common.active') : t('common.offline')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notif.soundEnabled ? (
|
||||
<>
|
||||
<div className="settings-field">
|
||||
<label>{t('settings.notifySuccessSound')}</label>
|
||||
<div className="seg-control" role="group" aria-label={t('settings.notifySuccessSound')} style={{ '--seg-cols': SUCCESS_SOUNDS.length } as React.CSSProperties}>
|
||||
{SUCCESS_SOUNDS.map((sound) => (
|
||||
<button
|
||||
key={sound.id}
|
||||
type="button"
|
||||
className={'seg-btn' + (notif.successSoundId === sound.id ? ' active' : '')}
|
||||
aria-pressed={notif.successSoundId === sound.id}
|
||||
onClick={() => {
|
||||
updateNotif({ successSoundId: sound.id });
|
||||
playSound(sound.id);
|
||||
}}
|
||||
>
|
||||
<span className="seg-title">{t(sound.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label>{t('settings.notifyFailureSound')}</label>
|
||||
<div className="seg-control" role="group" aria-label={t('settings.notifyFailureSound')} style={{ '--seg-cols': FAILURE_SOUNDS.length } as React.CSSProperties}>
|
||||
{FAILURE_SOUNDS.map((sound) => (
|
||||
<button
|
||||
key={sound.id}
|
||||
type="button"
|
||||
className={'seg-btn' + (notif.failureSoundId === sound.id ? ' active' : '')}
|
||||
aria-pressed={notif.failureSoundId === sound.id}
|
||||
onClick={() => {
|
||||
updateNotif({ failureSoundId: sound.id });
|
||||
playSound(sound.id);
|
||||
}}
|
||||
>
|
||||
<span className="seg-title">{t(sound.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="settings-subsection">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h4>{t('settings.notifyDesktop')}</h4>
|
||||
<p className="hint">{t('settings.notifyDesktopHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seg-control" role="group" aria-label={t('settings.notifyDesktop')} style={{ '--seg-cols': 1 } as React.CSSProperties}>
|
||||
<button
|
||||
type="button"
|
||||
className={'seg-btn' + (notif.desktopEnabled ? ' active' : '')}
|
||||
aria-pressed={notif.desktopEnabled}
|
||||
disabled={permission === 'unsupported'}
|
||||
onClick={() => { void toggleDesktop(); }}
|
||||
>
|
||||
<span className="seg-title">{notif.desktopEnabled ? t('common.active') : t('common.offline')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{permission === 'unsupported' ? (
|
||||
<p className="hint">{t('settings.notifyDesktopUnsupported')}</p>
|
||||
) : null}
|
||||
{permission === 'denied' ? (
|
||||
<p className="hint">{t('settings.notifyDesktopBlocked')}</p>
|
||||
) : null}
|
||||
{notif.desktopEnabled && permission === 'granted' ? (
|
||||
<>
|
||||
<button type="button" className="ghost" onClick={() => { void sendTestNotification(); }}>
|
||||
{t('settings.notifyTest')}
|
||||
</button>
|
||||
{testStatus ? <p className="hint" role="status">{t(testStatus)}</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function testNotificationStatusText(
|
||||
result: Awaited<ReturnType<typeof showCompletionNotification>>,
|
||||
):
|
||||
| 'settings.notifyTestSent'
|
||||
| 'settings.notifyDesktopBlocked'
|
||||
| 'settings.notifyDesktopUnsupported'
|
||||
| 'settings.notifyTestFailed' {
|
||||
if (result === 'shown') return 'settings.notifyTestSent';
|
||||
if (result === 'permission-denied') return 'settings.notifyDesktopBlocked';
|
||||
if (result === 'unsupported') return 'settings.notifyDesktopUnsupported';
|
||||
return 'settings.notifyTestFailed';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -786,4 +786,29 @@ export const ar: Dict = {
|
|||
'pet.communitySyncFailed': 'فشلت المزامنة: {error}',
|
||||
'pet.codexBundled': 'مدمج',
|
||||
'pet.codexBundledTitle': 'يأتي مع Open Design - لا حاجة للتحميل.',
|
||||
|
||||
'settings.notifications': 'الإشعارات',
|
||||
'settings.notificationsHint': 'صوت وإشعار سطح المكتب عند اكتمال المهمة',
|
||||
'settings.notifyCompletionSound': 'صوت الاكتمال',
|
||||
'settings.notifyCompletionSoundHint': 'يُشغَّل عند انتهاء الجولة. متوقف افتراضيًا.',
|
||||
'settings.notifySuccessSound': 'صوت النجاح',
|
||||
'settings.notifyFailureSound': 'صوت الفشل',
|
||||
'settings.notifyDesktop': 'إشعار سطح المكتب',
|
||||
'settings.notifyDesktopHint': 'يُرسَل عندما لا تكون النافذة في المقدمة.',
|
||||
'settings.notifyDesktopBlocked': 'تم حظر الإشعارات من المتصفح. اسمح بها من إعدادات الموقع.',
|
||||
'settings.notifyDesktopUnsupported': 'إشعارات سطح المكتب غير متاحة في هذه البيئة.',
|
||||
'settings.notifyTest': 'إرسال اختبار',
|
||||
'settings.notifyTestSent': 'تم إرسال إشعار الاختبار. إذا لم تظهر نافذة، فتحقق من إعدادات إشعارات المتصفح والنظام.',
|
||||
'settings.notifyTestFailed': 'فشل استدعاء الإشعار. تحقق من إعدادات إشعارات المتصفح والنظام.',
|
||||
'settings.notifySoundDing': 'دينج',
|
||||
'settings.notifySoundChime': 'جرس',
|
||||
'settings.notifySoundTwoToneUp': 'نغمتان صاعدتان',
|
||||
'settings.notifySoundPluck': 'نقرة وتر',
|
||||
'settings.notifySoundBuzz': 'طنين',
|
||||
'settings.notifySoundTwoToneDown': 'نغمتان هابطتان',
|
||||
'settings.notifySoundThud': 'دمدمة',
|
||||
'notify.successTitle': 'اكتملت المهمة',
|
||||
'notify.failureTitle': 'فشلت المهمة',
|
||||
'notify.successBody': 'انتهت جولة.',
|
||||
'notify.failureBody': 'انتهت المهمة بخطأ.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -786,4 +786,29 @@ export const de: Dict = {
|
|||
'pet.communitySyncFailed': 'Sync fehlgeschlagen: {error}',
|
||||
'pet.codexBundled': 'Mitgeliefert',
|
||||
'pet.codexBundledTitle': 'Wird mit Open Design ausgeliefert — kein Download nötig.',
|
||||
|
||||
'settings.notifications': 'Benachrichtigungen',
|
||||
'settings.notificationsHint': 'Ton und Desktop-Benachrichtigung beim Abschluss von Aufgaben',
|
||||
'settings.notifyCompletionSound': 'Abschluss-Ton',
|
||||
'settings.notifyCompletionSoundHint': 'Wird abgespielt, wenn eine Runde endet. Standardmäßig aus.',
|
||||
'settings.notifySuccessSound': 'Erfolgs-Ton',
|
||||
'settings.notifyFailureSound': 'Fehler-Ton',
|
||||
'settings.notifyDesktop': 'Desktop-Benachrichtigung',
|
||||
'settings.notifyDesktopHint': 'Wird gesendet, wenn das Fenster nicht im Vordergrund ist.',
|
||||
'settings.notifyDesktopBlocked': 'Vom Browser blockiert. Bitte in den Website-Einstellungen erlauben.',
|
||||
'settings.notifyDesktopUnsupported': 'Desktop-Benachrichtigungen werden in dieser Umgebung nicht unterstützt.',
|
||||
'settings.notifyTest': 'Test senden',
|
||||
'settings.notifyTestSent': 'Testbenachrichtigung gesendet. Wenn kein Banner erscheint, Browser- und Systembenachrichtigungen prüfen.',
|
||||
'settings.notifyTestFailed': 'Benachrichtigungsaufruf fehlgeschlagen. Browser- und Systembenachrichtigungen prüfen.',
|
||||
'settings.notifySoundDing': 'Ding',
|
||||
'settings.notifySoundChime': 'Glockenspiel',
|
||||
'settings.notifySoundTwoToneUp': 'Zweiton aufwärts',
|
||||
'settings.notifySoundPluck': 'Zupfen',
|
||||
'settings.notifySoundBuzz': 'Summen',
|
||||
'settings.notifySoundTwoToneDown': 'Zweiton abwärts',
|
||||
'settings.notifySoundThud': 'Dumpfer Schlag',
|
||||
'notify.successTitle': 'Aufgabe abgeschlossen',
|
||||
'notify.failureTitle': 'Aufgabe fehlgeschlagen',
|
||||
'notify.successBody': 'Eine Runde ist abgeschlossen.',
|
||||
'notify.failureBody': 'Die Aufgabe wurde mit einem Fehler beendet.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -786,4 +786,29 @@ export const en: Dict = {
|
|||
'pet.communitySyncFailed': 'Sync failed: {error}',
|
||||
'pet.codexBundled': 'Bundled',
|
||||
'pet.codexBundledTitle': 'Ships with Open Design — no download needed.',
|
||||
|
||||
'settings.notifications': 'Notifications',
|
||||
'settings.notificationsHint': 'Sound and desktop notification on task completion',
|
||||
'settings.notifyCompletionSound': 'Completion sound',
|
||||
'settings.notifyCompletionSoundHint': 'Plays when a turn finishes. Off by default.',
|
||||
'settings.notifySuccessSound': 'Success sound',
|
||||
'settings.notifyFailureSound': 'Failure sound',
|
||||
'settings.notifyDesktop': 'Desktop notification',
|
||||
'settings.notifyDesktopHint': 'Sent when the window is not focused.',
|
||||
'settings.notifyDesktopBlocked': 'Notifications blocked by the browser. Allow them in site settings.',
|
||||
'settings.notifyDesktopUnsupported': 'Desktop notifications unavailable in this environment.',
|
||||
'settings.notifyTest': 'Send test',
|
||||
'settings.notifyTestSent': 'Test notification sent. If no banner appears, check browser and system notification settings.',
|
||||
'settings.notifyTestFailed': 'Notification call failed. Check browser and system notification settings.',
|
||||
'settings.notifySoundDing': 'Ding',
|
||||
'settings.notifySoundChime': 'Chime',
|
||||
'settings.notifySoundTwoToneUp': 'Two-tone up',
|
||||
'settings.notifySoundPluck': 'Pluck',
|
||||
'settings.notifySoundBuzz': 'Buzz',
|
||||
'settings.notifySoundTwoToneDown': 'Two-tone down',
|
||||
'settings.notifySoundThud': 'Thud',
|
||||
'notify.successTitle': 'Task completed',
|
||||
'notify.failureTitle': 'Task failed',
|
||||
'notify.successBody': 'A turn has finished.',
|
||||
'notify.failureBody': 'The task ended with an error.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -787,4 +787,29 @@ export const esES: Dict = {
|
|||
'pet.communitySyncFailed': 'Error al sincronizar: {error}',
|
||||
'pet.codexBundled': 'Incluida',
|
||||
'pet.codexBundledTitle': 'Viene con Open Design — sin descarga.',
|
||||
|
||||
'settings.notifications': 'Notificaciones',
|
||||
'settings.notificationsHint': 'Sonido y notificación al completar la tarea',
|
||||
'settings.notifyCompletionSound': 'Sonido al completar',
|
||||
'settings.notifyCompletionSoundHint': 'Se reproduce al terminar un turno. Desactivado por defecto.',
|
||||
'settings.notifySuccessSound': 'Sonido de éxito',
|
||||
'settings.notifyFailureSound': 'Sonido de error',
|
||||
'settings.notifyDesktop': 'Notificación de escritorio',
|
||||
'settings.notifyDesktopHint': 'Se envía cuando la ventana no está en primer plano.',
|
||||
'settings.notifyDesktopBlocked': 'Bloqueadas por el navegador. Habilítalas en la configuración del sitio.',
|
||||
'settings.notifyDesktopUnsupported': 'Las notificaciones de escritorio no están disponibles en este entorno.',
|
||||
'settings.notifyTest': 'Enviar prueba',
|
||||
'settings.notifyTestSent': 'Notificación de prueba enviada. Si no aparece el aviso, revisa las notificaciones del navegador y del sistema.',
|
||||
'settings.notifyTestFailed': 'Falló la llamada de notificación. Revisa las notificaciones del navegador y del sistema.',
|
||||
'settings.notifySoundDing': 'Tilín',
|
||||
'settings.notifySoundChime': 'Carillón',
|
||||
'settings.notifySoundTwoToneUp': 'Dos tonos ascendente',
|
||||
'settings.notifySoundPluck': 'Pulsación',
|
||||
'settings.notifySoundBuzz': 'Zumbido',
|
||||
'settings.notifySoundTwoToneDown': 'Dos tonos descendente',
|
||||
'settings.notifySoundThud': 'Golpe',
|
||||
'notify.successTitle': 'Tarea completada',
|
||||
'notify.failureTitle': 'La tarea falló',
|
||||
'notify.successBody': 'Un turno ha terminado.',
|
||||
'notify.failureBody': 'La tarea terminó con un error.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -787,4 +787,29 @@ export const fa: Dict = {
|
|||
'pet.communitySyncFailed': 'خطا در همگامسازی: {error}',
|
||||
'pet.codexBundled': 'همراه',
|
||||
'pet.codexBundledTitle': 'همراه Open Design ارائه میشود — نیازی به دانلود نیست.',
|
||||
|
||||
'settings.notifications': 'اعلانها',
|
||||
'settings.notificationsHint': 'صدا و اعلان دسکتاپ هنگام تکمیل وظیفه',
|
||||
'settings.notifyCompletionSound': 'صدای تکمیل',
|
||||
'settings.notifyCompletionSoundHint': 'پس از اتمام یک نوبت پخش میشود. بهطور پیشفرض خاموش است.',
|
||||
'settings.notifySuccessSound': 'صدای موفقیت',
|
||||
'settings.notifyFailureSound': 'صدای خطا',
|
||||
'settings.notifyDesktop': 'اعلان دسکتاپ',
|
||||
'settings.notifyDesktopHint': 'هنگامی که پنجره فعال نیست ارسال میشود.',
|
||||
'settings.notifyDesktopBlocked': 'مرورگر اعلانها را مسدود کرده است. در تنظیمات سایت آنها را مجاز کنید.',
|
||||
'settings.notifyDesktopUnsupported': 'اعلانهای دسکتاپ در این محیط در دسترس نیستند.',
|
||||
'settings.notifyTest': 'ارسال آزمایشی',
|
||||
'settings.notifyTestSent': 'اعلان آزمایشی ارسال شد. اگر بنری نمایش داده نشد، تنظیمات اعلان مرورگر و سیستم را بررسی کنید.',
|
||||
'settings.notifyTestFailed': 'فراخوانی اعلان ناموفق بود. تنظیمات اعلان مرورگر و سیستم را بررسی کنید.',
|
||||
'settings.notifySoundDing': 'دینگ',
|
||||
'settings.notifySoundChime': 'زنگ',
|
||||
'settings.notifySoundTwoToneUp': 'دو نوای بالارونده',
|
||||
'settings.notifySoundPluck': 'مضراب',
|
||||
'settings.notifySoundBuzz': 'وزوز',
|
||||
'settings.notifySoundTwoToneDown': 'دو نوای پایینرونده',
|
||||
'settings.notifySoundThud': 'تالاپ',
|
||||
'notify.successTitle': 'وظیفه تکمیل شد',
|
||||
'notify.failureTitle': 'وظیفه ناموفق بود',
|
||||
'notify.successBody': 'یک نوبت به پایان رسید.',
|
||||
'notify.failureBody': 'وظیفه با خطا پایان یافت.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -796,4 +796,29 @@ export const hu: Dict = {
|
|||
'pet.communitySyncFailed': 'A szinkronizálás sikertelen: {error}',
|
||||
'pet.codexBundled': 'Beépített',
|
||||
'pet.codexBundledTitle': 'Az Open Designgal érkezik — letöltés nem szükséges.',
|
||||
|
||||
'settings.notifications': 'Értesítések',
|
||||
'settings.notificationsHint': 'Hang és asztali értesítés a feladat befejezésekor',
|
||||
'settings.notifyCompletionSound': 'Befejezési hang',
|
||||
'settings.notifyCompletionSoundHint': 'Megszólal egy kör végén. Alapértelmezetten kikapcsolva.',
|
||||
'settings.notifySuccessSound': 'Sikerhang',
|
||||
'settings.notifyFailureSound': 'Hibahang',
|
||||
'settings.notifyDesktop': 'Asztali értesítés',
|
||||
'settings.notifyDesktopHint': 'Akkor érkezik, ha az ablak nem aktív.',
|
||||
'settings.notifyDesktopBlocked': 'A böngésző letiltotta. Engedélyezd a webhely beállításaiban.',
|
||||
'settings.notifyDesktopUnsupported': 'Az asztali értesítések ebben a környezetben nem elérhetők.',
|
||||
'settings.notifyTest': 'Tesztküldés',
|
||||
'settings.notifyTestSent': 'Tesztértesítés elküldve. Ha nem jelenik meg banner, ellenőrizd a böngésző és a rendszer értesítési beállításait.',
|
||||
'settings.notifyTestFailed': 'Az értesítéshívás sikertelen. Ellenőrizd a böngésző és a rendszer értesítési beállításait.',
|
||||
'settings.notifySoundDing': 'Csilingelés',
|
||||
'settings.notifySoundChime': 'Csengő',
|
||||
'settings.notifySoundTwoToneUp': 'Kétszólamú emelkedő',
|
||||
'settings.notifySoundPluck': 'Pengetés',
|
||||
'settings.notifySoundBuzz': 'Zümmögés',
|
||||
'settings.notifySoundTwoToneDown': 'Kétszólamú ereszkedő',
|
||||
'settings.notifySoundThud': 'Tompa puffanás',
|
||||
'notify.successTitle': 'Feladat befejezve',
|
||||
'notify.failureTitle': 'A feladat meghiúsult',
|
||||
'notify.successBody': 'Egy kör befejeződött.',
|
||||
'notify.failureBody': 'A feladat hibával ért véget.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -785,4 +785,29 @@ export const ja: Dict = {
|
|||
'pet.communitySyncFailed': '同期に失敗しました: {error}',
|
||||
'pet.codexBundled': '同梱',
|
||||
'pet.codexBundledTitle': 'Open Design に同梱 — ダウンロード不要。',
|
||||
|
||||
'settings.notifications': '通知',
|
||||
'settings.notificationsHint': 'タスク完了時の効果音とデスクトップ通知',
|
||||
'settings.notifyCompletionSound': '完了サウンド',
|
||||
'settings.notifyCompletionSoundHint': '1ターン終了時に再生されます。既定ではオフです。',
|
||||
'settings.notifySuccessSound': '成功サウンド',
|
||||
'settings.notifyFailureSound': '失敗サウンド',
|
||||
'settings.notifyDesktop': 'デスクトップ通知',
|
||||
'settings.notifyDesktopHint': 'ウィンドウが非アクティブのときに送信されます。',
|
||||
'settings.notifyDesktopBlocked': 'ブラウザにブロックされています。サイト設定で許可してください。',
|
||||
'settings.notifyDesktopUnsupported': 'この環境ではデスクトップ通知に対応していません。',
|
||||
'settings.notifyTest': 'テスト送信',
|
||||
'settings.notifyTestSent': 'テスト通知を送信しました。バナーが出ない場合は、ブラウザとシステムの通知設定を確認してください。',
|
||||
'settings.notifyTestFailed': '通知の呼び出しに失敗しました。ブラウザとシステムの通知設定を確認してください。',
|
||||
'settings.notifySoundDing': 'ティン',
|
||||
'settings.notifySoundChime': 'チャイム',
|
||||
'settings.notifySoundTwoToneUp': '上昇2音',
|
||||
'settings.notifySoundPluck': 'プラック',
|
||||
'settings.notifySoundBuzz': 'ブザー',
|
||||
'settings.notifySoundTwoToneDown': '下降2音',
|
||||
'settings.notifySoundThud': 'ドスン',
|
||||
'notify.successTitle': 'タスクが完了しました',
|
||||
'notify.failureTitle': 'タスクが失敗しました',
|
||||
'notify.successBody': '1ターンが終了しました。',
|
||||
'notify.failureBody': 'タスクはエラーで終了しました。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -786,4 +786,29 @@ export const ko: Dict = {
|
|||
'pet.communitySyncFailed': '동기화 실패: {error}',
|
||||
'pet.codexBundled': '내장',
|
||||
'pet.codexBundledTitle': 'Open Design 에 내장 — 다운로드 불필요.',
|
||||
|
||||
'settings.notifications': '알림',
|
||||
'settings.notificationsHint': '작업 완료 시 효과음 및 데스크톱 알림',
|
||||
'settings.notifyCompletionSound': '완료 사운드',
|
||||
'settings.notifyCompletionSoundHint': '한 턴이 끝나면 재생됩니다. 기본값은 꺼짐입니다.',
|
||||
'settings.notifySuccessSound': '성공 사운드',
|
||||
'settings.notifyFailureSound': '실패 사운드',
|
||||
'settings.notifyDesktop': '데스크톱 알림',
|
||||
'settings.notifyDesktopHint': '창이 활성화되지 않았을 때 전송됩니다.',
|
||||
'settings.notifyDesktopBlocked': '브라우저에서 차단되었습니다. 사이트 설정에서 허용하세요.',
|
||||
'settings.notifyDesktopUnsupported': '이 환경에서는 데스크톱 알림을 사용할 수 없습니다.',
|
||||
'settings.notifyTest': '테스트 보내기',
|
||||
'settings.notifyTestSent': '테스트 알림을 보냈습니다. 배너가 보이지 않으면 브라우저와 시스템 알림 설정을 확인하세요.',
|
||||
'settings.notifyTestFailed': '알림 호출에 실패했습니다. 브라우저와 시스템 알림 설정을 확인하세요.',
|
||||
'settings.notifySoundDing': '딩',
|
||||
'settings.notifySoundChime': '차임벨',
|
||||
'settings.notifySoundTwoToneUp': '상승 2음',
|
||||
'settings.notifySoundPluck': '플럭',
|
||||
'settings.notifySoundBuzz': '버즈',
|
||||
'settings.notifySoundTwoToneDown': '하강 2음',
|
||||
'settings.notifySoundThud': '쿵',
|
||||
'notify.successTitle': '작업 완료',
|
||||
'notify.failureTitle': '작업 실패',
|
||||
'notify.successBody': '한 턴이 끝났습니다.',
|
||||
'notify.failureBody': '작업이 오류로 종료되었습니다.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -786,4 +786,29 @@ export const pl: Dict = {
|
|||
'pet.communitySyncFailed': 'Błąd synchronizacji: {error}',
|
||||
'pet.codexBundled': 'W zestawie',
|
||||
'pet.codexBundledTitle': 'Dostarczany z Open Design — bez pobierania.',
|
||||
|
||||
'settings.notifications': 'Powiadomienia',
|
||||
'settings.notificationsHint': 'Dźwięk i powiadomienie pulpitu po zakończeniu zadania',
|
||||
'settings.notifyCompletionSound': 'Dźwięk zakończenia',
|
||||
'settings.notifyCompletionSoundHint': 'Odtwarzane po zakończeniu tury. Domyślnie wyłączone.',
|
||||
'settings.notifySuccessSound': 'Dźwięk sukcesu',
|
||||
'settings.notifyFailureSound': 'Dźwięk błędu',
|
||||
'settings.notifyDesktop': 'Powiadomienie pulpitu',
|
||||
'settings.notifyDesktopHint': 'Wysyłane, gdy okno nie jest aktywne.',
|
||||
'settings.notifyDesktopBlocked': 'Zablokowane przez przeglądarkę. Włącz je w ustawieniach witryny.',
|
||||
'settings.notifyDesktopUnsupported': 'Powiadomienia pulpitu są niedostępne w tym środowisku.',
|
||||
'settings.notifyTest': 'Wyślij test',
|
||||
'settings.notifyTestSent': 'Powiadomienie testowe wysłane. Jeśli baner się nie pojawi, sprawdź ustawienia powiadomień przeglądarki i systemu.',
|
||||
'settings.notifyTestFailed': 'Wywołanie powiadomienia nie powiodło się. Sprawdź ustawienia powiadomień przeglądarki i systemu.',
|
||||
'settings.notifySoundDing': 'Dzyń',
|
||||
'settings.notifySoundChime': 'Dzwonek',
|
||||
'settings.notifySoundTwoToneUp': 'Dwuton rosnący',
|
||||
'settings.notifySoundPluck': 'Szarpnięcie',
|
||||
'settings.notifySoundBuzz': 'Brzęczenie',
|
||||
'settings.notifySoundTwoToneDown': 'Dwuton malejący',
|
||||
'settings.notifySoundThud': 'Łomot',
|
||||
'notify.successTitle': 'Zadanie ukończone',
|
||||
'notify.failureTitle': 'Zadanie nieudane',
|
||||
'notify.successBody': 'Tura zakończona.',
|
||||
'notify.failureBody': 'Zadanie zakończyło się błędem.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -785,4 +785,29 @@ export const ptBR: Dict = {
|
|||
'pet.communitySyncFailed': 'Falha na sincronização: {error}',
|
||||
'pet.codexBundled': 'Incluído',
|
||||
'pet.codexBundledTitle': 'Já vem com o Open Design — sem download.',
|
||||
|
||||
'settings.notifications': 'Notificações',
|
||||
'settings.notificationsHint': 'Som e notificação na conclusão da tarefa',
|
||||
'settings.notifyCompletionSound': 'Som de conclusão',
|
||||
'settings.notifyCompletionSoundHint': 'Toca ao terminar uma rodada. Desativado por padrão.',
|
||||
'settings.notifySuccessSound': 'Som de sucesso',
|
||||
'settings.notifyFailureSound': 'Som de falha',
|
||||
'settings.notifyDesktop': 'Notificação na área de trabalho',
|
||||
'settings.notifyDesktopHint': 'Enviada quando a janela não está em foco.',
|
||||
'settings.notifyDesktopBlocked': 'Bloqueadas pelo navegador. Habilite nas configurações do site.',
|
||||
'settings.notifyDesktopUnsupported': 'Notificações na área de trabalho indisponíveis neste ambiente.',
|
||||
'settings.notifyTest': 'Enviar teste',
|
||||
'settings.notifyTestSent': 'Notificação de teste enviada. Se nenhum banner aparecer, verifique as notificações do navegador e do sistema.',
|
||||
'settings.notifyTestFailed': 'Falha ao chamar a notificação. Verifique as notificações do navegador e do sistema.',
|
||||
'settings.notifySoundDing': 'Tilim',
|
||||
'settings.notifySoundChime': 'Carrilhão',
|
||||
'settings.notifySoundTwoToneUp': 'Dois tons crescente',
|
||||
'settings.notifySoundPluck': 'Dedilhado',
|
||||
'settings.notifySoundBuzz': 'Zumbido',
|
||||
'settings.notifySoundTwoToneDown': 'Dois tons descendente',
|
||||
'settings.notifySoundThud': 'Baque',
|
||||
'notify.successTitle': 'Tarefa concluída',
|
||||
'notify.failureTitle': 'Tarefa falhou',
|
||||
'notify.successBody': 'Uma rodada foi concluída.',
|
||||
'notify.failureBody': 'A tarefa terminou com erro.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -785,4 +785,29 @@ export const ru: Dict = {
|
|||
'pet.communitySyncFailed': 'Ошибка синхронизации: {error}',
|
||||
'pet.codexBundled': 'Встроен',
|
||||
'pet.codexBundledTitle': 'Поставляется с Open Design — загрузка не нужна.',
|
||||
|
||||
'settings.notifications': 'Уведомления',
|
||||
'settings.notificationsHint': 'Звук и уведомление при завершении задачи',
|
||||
'settings.notifyCompletionSound': 'Звук завершения',
|
||||
'settings.notifyCompletionSoundHint': 'Воспроизводится по завершении хода. По умолчанию выключено.',
|
||||
'settings.notifySuccessSound': 'Звук успеха',
|
||||
'settings.notifyFailureSound': 'Звук ошибки',
|
||||
'settings.notifyDesktop': 'Уведомление на рабочем столе',
|
||||
'settings.notifyDesktopHint': 'Отправляется, когда окно не активно.',
|
||||
'settings.notifyDesktopBlocked': 'Уведомления заблокированы браузером. Разрешите их в настройках сайта.',
|
||||
'settings.notifyDesktopUnsupported': 'Уведомления на рабочем столе недоступны в этой среде.',
|
||||
'settings.notifyTest': 'Отправить тест',
|
||||
'settings.notifyTestSent': 'Тестовое уведомление отправлено. Если баннер не появился, проверьте настройки уведомлений браузера и системы.',
|
||||
'settings.notifyTestFailed': 'Вызов уведомления не удался. Проверьте настройки уведомлений браузера и системы.',
|
||||
'settings.notifySoundDing': 'Динь',
|
||||
'settings.notifySoundChime': 'Колокольчик',
|
||||
'settings.notifySoundTwoToneUp': 'Двухтон вверх',
|
||||
'settings.notifySoundPluck': 'Щипок',
|
||||
'settings.notifySoundBuzz': 'Жужжание',
|
||||
'settings.notifySoundTwoToneDown': 'Двухтон вниз',
|
||||
'settings.notifySoundThud': 'Глухой удар',
|
||||
'notify.successTitle': 'Задача выполнена',
|
||||
'notify.failureTitle': 'Задача завершилась с ошибкой',
|
||||
'notify.successBody': 'Ход завершён.',
|
||||
'notify.failureBody': 'Задача завершилась с ошибкой.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -785,4 +785,29 @@ export const tr: Dict = {
|
|||
'pet.communitySyncFailed': 'Eşitleme başarısız: {error}',
|
||||
'pet.codexBundled': 'Yerleşik',
|
||||
'pet.codexBundledTitle': 'Open Design ile birlikte gelir — indirme gerekmez.',
|
||||
|
||||
'settings.notifications': 'Bildirimler',
|
||||
'settings.notificationsHint': 'Görev tamamlandığında ses ve masaüstü bildirimi',
|
||||
'settings.notifyCompletionSound': 'Tamamlanma sesi',
|
||||
'settings.notifyCompletionSoundHint': 'Bir tur sona erdiğinde çalar. Varsayılan olarak kapalı.',
|
||||
'settings.notifySuccessSound': 'Başarı sesi',
|
||||
'settings.notifyFailureSound': 'Hata sesi',
|
||||
'settings.notifyDesktop': 'Masaüstü bildirimi',
|
||||
'settings.notifyDesktopHint': 'Pencere etkin değilken gönderilir.',
|
||||
'settings.notifyDesktopBlocked': 'Tarayıcı bildirimleri engelledi. Site ayarlarından izin verin.',
|
||||
'settings.notifyDesktopUnsupported': 'Bu ortamda masaüstü bildirimleri kullanılamıyor.',
|
||||
'settings.notifyTest': 'Test gönder',
|
||||
'settings.notifyTestSent': 'Test bildirimi gönderildi. Banner görünmezse tarayıcı ve sistem bildirim ayarlarını kontrol edin.',
|
||||
'settings.notifyTestFailed': 'Bildirim çağrısı başarısız oldu. Tarayıcı ve sistem bildirim ayarlarını kontrol edin.',
|
||||
'settings.notifySoundDing': 'Çın',
|
||||
'settings.notifySoundChime': 'Çıngırak',
|
||||
'settings.notifySoundTwoToneUp': 'Yükselen iki ton',
|
||||
'settings.notifySoundPluck': 'Tıngırtı',
|
||||
'settings.notifySoundBuzz': 'Vızıltı',
|
||||
'settings.notifySoundTwoToneDown': 'Alçalan iki ton',
|
||||
'settings.notifySoundThud': 'Boğuk vuruş',
|
||||
'notify.successTitle': 'Görev tamamlandı',
|
||||
'notify.failureTitle': 'Görev başarısız oldu',
|
||||
'notify.successBody': 'Bir tur tamamlandı.',
|
||||
'notify.failureBody': 'Görev bir hata ile sona erdi.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -768,4 +768,29 @@ export const zhCN: Dict = {
|
|||
'pet.communitySyncFailed': '同步失败:{error}',
|
||||
'pet.codexBundled': '内置',
|
||||
'pet.codexBundledTitle': 'Open Design 内置宠物,无需下载。',
|
||||
|
||||
'settings.notifications': '通知',
|
||||
'settings.notificationsHint': '任务完成时的声音和桌面通知',
|
||||
'settings.notifyCompletionSound': '完成提示音',
|
||||
'settings.notifyCompletionSoundHint': '一轮任务结束时播放,默认关闭',
|
||||
'settings.notifySuccessSound': '成功音色',
|
||||
'settings.notifyFailureSound': '失败音色',
|
||||
'settings.notifyDesktop': '桌面通知',
|
||||
'settings.notifyDesktopHint': '窗口不在前台时弹出系统通知',
|
||||
'settings.notifyDesktopBlocked': '浏览器已拒绝通知权限,请在站点设置中开启',
|
||||
'settings.notifyDesktopUnsupported': '当前环境不支持桌面通知',
|
||||
'settings.notifyTest': '测试通知',
|
||||
'settings.notifyTestSent': '测试通知已发送;如果没有弹窗,请检查浏览器和系统通知设置。',
|
||||
'settings.notifyTestFailed': '通知调用失败,请检查浏览器和系统通知设置。',
|
||||
'settings.notifySoundDing': '叮',
|
||||
'settings.notifySoundChime': '风铃',
|
||||
'settings.notifySoundTwoToneUp': '上行双音',
|
||||
'settings.notifySoundPluck': '拨弦',
|
||||
'settings.notifySoundBuzz': '蜂鸣',
|
||||
'settings.notifySoundTwoToneDown': '下行双音',
|
||||
'settings.notifySoundThud': '低响',
|
||||
'notify.successTitle': '任务已完成',
|
||||
'notify.failureTitle': '任务失败',
|
||||
'notify.successBody': '一轮回答已经写完。',
|
||||
'notify.failureBody': '本轮任务出错,请查看错误信息。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -768,4 +768,29 @@ export const zhTW: Dict = {
|
|||
'pet.communitySyncFailed': '同步失敗:{error}',
|
||||
'pet.codexBundled': '內建',
|
||||
'pet.codexBundledTitle': 'Open Design 內建寵物,無需下載。',
|
||||
|
||||
'settings.notifications': '通知',
|
||||
'settings.notificationsHint': '任務完成時的音效和桌面通知',
|
||||
'settings.notifyCompletionSound': '完成提示音',
|
||||
'settings.notifyCompletionSoundHint': '一輪任務結束時播放,預設關閉',
|
||||
'settings.notifySuccessSound': '成功音色',
|
||||
'settings.notifyFailureSound': '失敗音色',
|
||||
'settings.notifyDesktop': '桌面通知',
|
||||
'settings.notifyDesktopHint': '視窗不在前景時跳出系統通知',
|
||||
'settings.notifyDesktopBlocked': '瀏覽器已拒絕通知權限,請在網站設定中開啟',
|
||||
'settings.notifyDesktopUnsupported': '當前環境不支援桌面通知',
|
||||
'settings.notifyTest': '測試通知',
|
||||
'settings.notifyTestSent': '測試通知已送出;如果沒有彈窗,請檢查瀏覽器和系統通知設定。',
|
||||
'settings.notifyTestFailed': '通知呼叫失敗,請檢查瀏覽器和系統通知設定。',
|
||||
'settings.notifySoundDing': '叮',
|
||||
'settings.notifySoundChime': '風鈴',
|
||||
'settings.notifySoundTwoToneUp': '上行雙音',
|
||||
'settings.notifySoundPluck': '撥弦',
|
||||
'settings.notifySoundBuzz': '蜂鳴',
|
||||
'settings.notifySoundTwoToneDown': '下行雙音',
|
||||
'settings.notifySoundThud': '低響',
|
||||
'notify.successTitle': '任務已完成',
|
||||
'notify.failureTitle': '任務失敗',
|
||||
'notify.successBody': '一輪回答已經寫完。',
|
||||
'notify.failureBody': '本輪任務出錯,請查看錯誤訊息。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -137,6 +137,32 @@ export interface Dict {
|
|||
'settings.runtimeDevelopment': string;
|
||||
'settings.versionUnavailable': string;
|
||||
|
||||
// Notifications (settings + system notifications)
|
||||
'settings.notifications': string;
|
||||
'settings.notificationsHint': string;
|
||||
'settings.notifyCompletionSound': string;
|
||||
'settings.notifyCompletionSoundHint': string;
|
||||
'settings.notifySuccessSound': string;
|
||||
'settings.notifyFailureSound': string;
|
||||
'settings.notifyDesktop': string;
|
||||
'settings.notifyDesktopHint': string;
|
||||
'settings.notifyDesktopBlocked': string;
|
||||
'settings.notifyDesktopUnsupported': string;
|
||||
'settings.notifyTest': string;
|
||||
'settings.notifyTestSent': string;
|
||||
'settings.notifyTestFailed': string;
|
||||
'settings.notifySoundDing': string;
|
||||
'settings.notifySoundChime': string;
|
||||
'settings.notifySoundTwoToneUp': string;
|
||||
'settings.notifySoundPluck': string;
|
||||
'settings.notifySoundBuzz': string;
|
||||
'settings.notifySoundTwoToneDown': string;
|
||||
'settings.notifySoundThud': string;
|
||||
'notify.successTitle': string;
|
||||
'notify.failureTitle': string;
|
||||
'notify.successBody': string;
|
||||
'notify.failureBody': string;
|
||||
|
||||
// Entry view / tabs
|
||||
'entry.tabDesigns': string;
|
||||
'entry.tabExamples': string;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import type {
|
|||
ApiProtocol,
|
||||
AppConfig,
|
||||
MediaProviderCredentials,
|
||||
NotificationsConfig,
|
||||
PetConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
DEFAULT_FAILURE_SOUND_ID,
|
||||
DEFAULT_SUCCESS_SOUND_ID,
|
||||
} from '../utils/notifications';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const CONFIG_MIGRATION_VERSION = 1;
|
||||
|
|
@ -13,6 +18,16 @@ const CONFIG_MIGRATION_VERSION = 1;
|
|||
// Hatched out of the box, but tucked away — the user has to go through
|
||||
// either the entry-view "adopt a pet" callout or Settings → Pets to
|
||||
// summon them. Keeps the workspace quiet for first-run users.
|
||||
// Both switches default off so first-run users are not greeted by a
|
||||
// surprise sound or a permission prompt; they can opt in from Settings →
|
||||
// Notifications when they want it.
|
||||
export const DEFAULT_NOTIFICATIONS: NotificationsConfig = {
|
||||
soundEnabled: false,
|
||||
successSoundId: DEFAULT_SUCCESS_SOUND_ID,
|
||||
failureSoundId: DEFAULT_FAILURE_SOUND_ID,
|
||||
desktopEnabled: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_PET: PetConfig = {
|
||||
adopted: false,
|
||||
enabled: false,
|
||||
|
|
@ -44,6 +59,7 @@ export const DEFAULT_CONFIG: AppConfig = {
|
|||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
pet: DEFAULT_PET,
|
||||
notifications: DEFAULT_NOTIFICATIONS,
|
||||
};
|
||||
|
||||
/** Well-known providers with pre-filled base URLs. */
|
||||
|
|
@ -163,6 +179,12 @@ function normalizePet(input: Partial<PetConfig> | undefined): PetConfig {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeNotifications(
|
||||
input: Partial<NotificationsConfig> | undefined,
|
||||
): NotificationsConfig {
|
||||
return { ...DEFAULT_NOTIFICATIONS, ...(input ?? {}) };
|
||||
}
|
||||
|
||||
function inferApiProtocol(model: string, baseUrl: string): ApiProtocol {
|
||||
try {
|
||||
return isOpenAICompatible(model, baseUrl) ? 'openai' : 'anthropic';
|
||||
|
|
@ -177,7 +199,13 @@ function inferApiProtocol(model: string, baseUrl: string): ApiProtocol {
|
|||
export function loadConfig(): AppConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET) };
|
||||
if (!raw) {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
pet: normalizePet(DEFAULT_PET),
|
||||
notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS),
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AppConfig>;
|
||||
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
|
||||
parsed,
|
||||
|
|
@ -189,6 +217,7 @@ export function loadConfig(): AppConfig {
|
|||
mediaProviders: { ...(parsed.mediaProviders ?? {}) },
|
||||
agentModels: { ...(parsed.agentModels ?? {}) },
|
||||
pet: normalizePet(parsed.pet),
|
||||
notifications: normalizeNotifications(parsed.notifications),
|
||||
};
|
||||
|
||||
if (parsed.configMigrationVersion !== CONFIG_MIGRATION_VERSION) {
|
||||
|
|
@ -212,7 +241,11 @@ export function loadConfig(): AppConfig {
|
|||
|
||||
return merged;
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET) };
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
pet: normalizePet(DEFAULT_PET),
|
||||
notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,18 @@ export interface PetCustom {
|
|||
atlas?: PetAtlasLayout;
|
||||
}
|
||||
|
||||
export interface NotificationsConfig {
|
||||
// Master switch for the completion sound. Default false — first-run users
|
||||
// hear nothing until they opt in.
|
||||
soundEnabled: boolean;
|
||||
// Sound id played when a turn ends with `runStatus === 'succeeded'`.
|
||||
successSoundId: string;
|
||||
// Sound id played when a turn ends with `runStatus === 'failed'`.
|
||||
failureSoundId: string;
|
||||
// Master switch for the browser Notification API banner. Default false.
|
||||
desktopEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PetConfig {
|
||||
// True once the user has explicitly picked a pet (built-in or custom).
|
||||
// Until then, the entry view shows an "adopt" callout to drive discovery.
|
||||
|
|
@ -157,6 +169,10 @@ export interface AppConfig {
|
|||
// the feature land at `undefined`, which the loader normalizes to a
|
||||
// safe default (un-adopted, hidden until the user opts in).
|
||||
pet?: PetConfig;
|
||||
// Optional task-completion sound + browser notification settings. Older
|
||||
// configs that pre-date the feature land at `undefined`, which the loader
|
||||
// normalizes to a safe default (everything off).
|
||||
notifications?: NotificationsConfig;
|
||||
}
|
||||
|
||||
export type AgentEvent = PersistedAgentEvent;
|
||||
|
|
|
|||
97
apps/web/src/utils/notifications.test.ts
Normal file
97
apps/web/src/utils/notifications.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { showCompletionNotification } from './notifications';
|
||||
|
||||
type NotificationOptionsWithRenotify = NotificationOptions & { renotify?: boolean };
|
||||
|
||||
class MockNotification {
|
||||
static permission: NotificationPermission = 'granted';
|
||||
static instances: MockNotification[] = [];
|
||||
|
||||
onclose: (() => void) | null = null;
|
||||
onclick: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: NotificationOptionsWithRenotify,
|
||||
) {
|
||||
MockNotification.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Fire synchronously so tests can observe cleanup without browser events.
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
MockNotification.permission = 'granted';
|
||||
MockNotification.instances = [];
|
||||
});
|
||||
|
||||
describe('showCompletionNotification', () => {
|
||||
it('creates a renotifying desktop notification when permission is granted', async () => {
|
||||
vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification);
|
||||
|
||||
const result = await showCompletionNotification({
|
||||
status: 'succeeded',
|
||||
title: 'Task completed',
|
||||
body: 'Done',
|
||||
});
|
||||
|
||||
expect(result).toBe('shown');
|
||||
expect(MockNotification.instances).toHaveLength(1);
|
||||
expect(MockNotification.instances[0]!.title).toBe('Task completed');
|
||||
expect(MockNotification.instances[0]!.options).toMatchObject({
|
||||
body: 'Done',
|
||||
tag: 'od-task-succeeded',
|
||||
renotify: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the service worker notification API when available', async () => {
|
||||
const showNotification = vi.fn().mockResolvedValue(undefined);
|
||||
const registration = { showNotification };
|
||||
const register = vi.fn().mockResolvedValue(registration);
|
||||
vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification);
|
||||
vi.stubGlobal('navigator', {
|
||||
serviceWorker: {
|
||||
register,
|
||||
ready: Promise.resolve(registration),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await showCompletionNotification({
|
||||
status: 'succeeded',
|
||||
title: 'Task completed',
|
||||
body: 'Done',
|
||||
});
|
||||
|
||||
expect(result).toBe('shown');
|
||||
expect(register).toHaveBeenCalledWith('/od-notifications-sw.js');
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
'Task completed',
|
||||
expect.objectContaining({
|
||||
body: 'Done',
|
||||
tag: 'od-task-succeeded',
|
||||
renotify: true,
|
||||
}),
|
||||
);
|
||||
expect(MockNotification.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not create a notification when permission is not granted', async () => {
|
||||
MockNotification.permission = 'denied';
|
||||
vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification);
|
||||
|
||||
const result = await showCompletionNotification({
|
||||
status: 'failed',
|
||||
title: 'Task failed',
|
||||
body: 'Error',
|
||||
});
|
||||
|
||||
expect(result).toBe('permission-denied');
|
||||
expect(MockNotification.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
258
apps/web/src/utils/notifications.ts
Normal file
258
apps/web/src/utils/notifications.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import type { Dict } from '../i18n/types';
|
||||
|
||||
export type SoundId = string;
|
||||
|
||||
export interface SoundOption {
|
||||
id: SoundId;
|
||||
labelKey: keyof Dict;
|
||||
}
|
||||
|
||||
export const SUCCESS_SOUNDS: SoundOption[] = [
|
||||
{ id: 'ding', labelKey: 'settings.notifySoundDing' },
|
||||
{ id: 'chime', labelKey: 'settings.notifySoundChime' },
|
||||
{ id: 'two-tone-up', labelKey: 'settings.notifySoundTwoToneUp' },
|
||||
{ id: 'pluck', labelKey: 'settings.notifySoundPluck' },
|
||||
];
|
||||
|
||||
export const FAILURE_SOUNDS: SoundOption[] = [
|
||||
{ id: 'buzz', labelKey: 'settings.notifySoundBuzz' },
|
||||
{ id: 'two-tone-down', labelKey: 'settings.notifySoundTwoToneDown' },
|
||||
{ id: 'thud', labelKey: 'settings.notifySoundThud' },
|
||||
];
|
||||
|
||||
export const DEFAULT_SUCCESS_SOUND_ID: SoundId = 'ding';
|
||||
export const DEFAULT_FAILURE_SOUND_ID: SoundId = 'buzz';
|
||||
|
||||
type AudioCtxCtor = typeof AudioContext;
|
||||
type NotificationOptionsWithBrowserExtensions = NotificationOptions & {
|
||||
renotify?: boolean;
|
||||
};
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
const activeNotifications = new Set<Notification>();
|
||||
const SERVICE_WORKER_URL = '/od-notifications-sw.js';
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const Ctor: AudioCtxCtor | undefined =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: AudioCtxCtor }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
if (!ctx) {
|
||||
try {
|
||||
ctx = new Ctor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (ctx && ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => {
|
||||
// Autoplay policy can refuse — fall through silently. The next
|
||||
// user-gesture-driven call will retry.
|
||||
});
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
interface ToneSpec {
|
||||
freq: number;
|
||||
type: OscillatorType;
|
||||
start: number;
|
||||
duration: number;
|
||||
gain?: number;
|
||||
// Optional lowpass cutoff applied via a BiquadFilter for plucky textures.
|
||||
lowpass?: number;
|
||||
}
|
||||
|
||||
function playTones(c: AudioContext, tones: ToneSpec[]): void {
|
||||
const now = c.currentTime;
|
||||
for (const tone of tones) {
|
||||
const osc = c.createOscillator();
|
||||
const gain = c.createGain();
|
||||
osc.type = tone.type;
|
||||
osc.frequency.value = tone.freq;
|
||||
const peak = tone.gain ?? 0.18;
|
||||
const startAt = now + tone.start;
|
||||
const endAt = startAt + tone.duration;
|
||||
// Short attack to avoid clicks; exponential-ish decay via linear ramp
|
||||
// to a near-zero value (exponentialRamp can't reach 0).
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.linearRampToValueAtTime(peak, startAt + Math.min(0.005, tone.duration * 0.2));
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, endAt);
|
||||
|
||||
let last: AudioNode = osc;
|
||||
if (tone.lowpass) {
|
||||
const lp = c.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = tone.lowpass;
|
||||
osc.connect(lp);
|
||||
last = lp;
|
||||
}
|
||||
last.connect(gain);
|
||||
gain.connect(c.destination);
|
||||
osc.start(startAt);
|
||||
osc.stop(endAt + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
const SOUND_PLAYERS: Record<SoundId, (c: AudioContext) => void> = {
|
||||
ding: (c) => {
|
||||
playTones(c, [{ freq: 880, type: 'sine', start: 0, duration: 0.25, gain: 0.22 }]);
|
||||
},
|
||||
chime: (c) => {
|
||||
playTones(c, [
|
||||
{ freq: 880, type: 'triangle', start: 0, duration: 0.4, gain: 0.18 },
|
||||
{ freq: 1320, type: 'triangle', start: 0, duration: 0.4, gain: 0.12 },
|
||||
]);
|
||||
},
|
||||
'two-tone-up': (c) => {
|
||||
playTones(c, [
|
||||
{ freq: 660, type: 'square', start: 0, duration: 0.08, gain: 0.16 },
|
||||
{ freq: 990, type: 'square', start: 0.09, duration: 0.08, gain: 0.16 },
|
||||
]);
|
||||
},
|
||||
pluck: (c) => {
|
||||
playTones(c, [
|
||||
{ freq: 220, type: 'sawtooth', start: 0, duration: 0.15, gain: 0.22, lowpass: 1200 },
|
||||
]);
|
||||
},
|
||||
buzz: (c) => {
|
||||
playTones(c, [
|
||||
{ freq: 165, type: 'square', start: 0, duration: 0.06, gain: 0.2 },
|
||||
{ freq: 165, type: 'square', start: 0.1, duration: 0.06, gain: 0.2 },
|
||||
{ freq: 165, type: 'square', start: 0.2, duration: 0.06, gain: 0.2 },
|
||||
]);
|
||||
},
|
||||
'two-tone-down': (c) => {
|
||||
playTones(c, [
|
||||
{ freq: 880, type: 'sine', start: 0, duration: 0.12, gain: 0.2 },
|
||||
{ freq: 440, type: 'sine', start: 0.13, duration: 0.12, gain: 0.2 },
|
||||
]);
|
||||
},
|
||||
thud: (c) => {
|
||||
playTones(c, [{ freq: 80, type: 'sine', start: 0, duration: 0.12, gain: 0.32 }]);
|
||||
},
|
||||
};
|
||||
|
||||
export function playSound(id: SoundId): void {
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
const player = SOUND_PLAYERS[id];
|
||||
if (!player) return;
|
||||
try {
|
||||
player(c);
|
||||
} catch {
|
||||
// A node creation / connection failure should never throw out to UI code.
|
||||
}
|
||||
}
|
||||
|
||||
export function previewSuccess(id: SoundId): void {
|
||||
playSound(id);
|
||||
}
|
||||
|
||||
export function previewFailure(id: SoundId): void {
|
||||
playSound(id);
|
||||
}
|
||||
|
||||
export function notificationPermission(): NotificationPermission | 'unsupported' {
|
||||
if (typeof Notification === 'undefined') return 'unsupported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<
|
||||
NotificationPermission | 'unsupported'
|
||||
> {
|
||||
if (typeof Notification === 'undefined') return 'unsupported';
|
||||
if (Notification.permission === 'granted' || Notification.permission === 'denied') {
|
||||
return Notification.permission;
|
||||
}
|
||||
try {
|
||||
return await Notification.requestPermission();
|
||||
} catch {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompletionNotificationOpts {
|
||||
status: 'succeeded' | 'failed';
|
||||
title: string;
|
||||
body: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export type CompletionNotificationResult =
|
||||
| 'shown'
|
||||
| 'unsupported'
|
||||
| 'permission-denied'
|
||||
| 'failed';
|
||||
|
||||
function notificationOptionsFor(
|
||||
opts: CompletionNotificationOpts,
|
||||
): NotificationOptionsWithBrowserExtensions {
|
||||
const tag = `od-task-${opts.status}`;
|
||||
return {
|
||||
body: opts.body,
|
||||
tag,
|
||||
renotify: true,
|
||||
data: {
|
||||
status: opts.status,
|
||||
url: typeof window === 'undefined' ? '/' : window.location.href,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function showViaServiceWorker(
|
||||
opts: CompletionNotificationOpts,
|
||||
): Promise<CompletionNotificationResult | null> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(SERVICE_WORKER_URL);
|
||||
const readyRegistration = await navigator.serviceWorker.ready.catch(() => registration);
|
||||
if (!readyRegistration.showNotification) return null;
|
||||
await readyRegistration.showNotification(opts.title, notificationOptionsFor(opts));
|
||||
return 'shown';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showViaConstructor(opts: CompletionNotificationOpts): CompletionNotificationResult {
|
||||
if (typeof Notification === 'undefined') return 'unsupported';
|
||||
if (Notification.permission !== 'granted') return 'permission-denied';
|
||||
try {
|
||||
const note = new Notification(opts.title, notificationOptionsFor(opts));
|
||||
activeNotifications.add(note);
|
||||
const release = () => {
|
||||
note.onclick = null;
|
||||
note.onclose = null;
|
||||
note.onerror = null;
|
||||
activeNotifications.delete(note);
|
||||
};
|
||||
note.onclick = () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') window.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
opts.onClick?.();
|
||||
try {
|
||||
note.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
note.onclose = release;
|
||||
note.onerror = release;
|
||||
return 'shown';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
export async function showCompletionNotification(
|
||||
opts: CompletionNotificationOpts,
|
||||
): Promise<CompletionNotificationResult> {
|
||||
if (typeof Notification === 'undefined') return 'unsupported';
|
||||
if (Notification.permission !== 'granted') return 'permission-denied';
|
||||
return (await showViaServiceWorker(opts)) ?? showViaConstructor(opts);
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ const allowedExactPaths = new Set([
|
|||
"scripts/import-prompt-templates.mjs",
|
||||
"scripts/postinstall.mjs",
|
||||
"apps/packaged/esbuild.config.mjs",
|
||||
// Browser service workers must be served as JavaScript files.
|
||||
"apps/web/public/od-notifications-sw.js",
|
||||
"scripts/bake-html-ppt-examples.mjs",
|
||||
"scripts/scaffold-html-ppt-skills.mjs",
|
||||
"scripts/sync-hyperframes-skill.mjs",
|
||||
|
|
|
|||
Loading…
Reference in a new issue