diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 30843cd9..fd2ad82d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -186,6 +186,7 @@ export const en: typeof zhCN = { masterToggle: 'Master switch', currentDefault: 'Current default', ariaSetDefault: 'Set as default', + saveFailed: 'Save failed: {{error}}', modes: { raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." }, light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." }, diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 27790919..4b37da84 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -188,6 +188,7 @@ export const ja: typeof zhCN = { masterToggle: '全体有効化', currentDefault: '現在のデフォルト', ariaSetDefault: 'デフォルトに設定', + saveFailed: '保存に失敗しました: {{error}}', modes: { raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' }, light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index a517a03f..052832ef 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -188,6 +188,7 @@ export const ko: typeof zhCN = { masterToggle: '전체 활성화', currentDefault: '현재 기본', ariaSetDefault: '기본으로 설정', + saveFailed: '저장 실패: {{error}}', modes: { raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' }, light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index db99cebc..7dc1c2d5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -184,6 +184,7 @@ export const zhCN = { masterToggle: '整体启用', currentDefault: '当前默认', ariaSetDefault: '设为默认', + saveFailed: '保存失败:{{error}}', modes: { raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' }, light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e457f8cb..75e4af24 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -186,6 +186,7 @@ export const zhTW: typeof zhCN = { masterToggle: '整體啓用', currentDefault: '當前默認', ariaSetDefault: '設爲默認', + saveFailed: '保存失敗:{{error}}', modes: { raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' }, light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' }, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts new file mode 100644 index 00000000..02eb50fd --- /dev/null +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -0,0 +1,103 @@ +import { + persistStylePreferenceChange, + rollbackStyleEnabledChange, + rollbackWholeStylePreferences, +} from './stylePrefs'; +import type { UserPreferences } from './types'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +const previousPrefs: UserPreferences = { + hotkey: { trigger: 'rightOption', mode: 'toggle' }, + dictationHotkey: { primary: 'RightOption', modifiers: [] }, + defaultMode: 'light', + enabledModes: ['raw', 'light', 'structured'], + launchAtLogin: false, + showCapsule: true, + muteDuringRecording: false, + microphoneDeviceName: '', + activeAsrProvider: 'volcengine', + activeLlmProvider: 'ark', + restoreClipboardAfterPaste: true, + allowNonTsfInsertionFallback: true, + workingLanguages: ['简体中文'], + translationTargetLanguage: '', + chineseScriptPreference: 'auto', + outputLanguagePreference: 'auto', + qaHotkey: null, + qaSaveHistory: false, + customComboHotkey: null, + translationHotkey: { primary: 'Shift', modifiers: [] }, + switchStyleHotkey: { primary: 'S', modifiers: ['alt'] }, + openAppHotkey: { primary: 'O', modifiers: ['alt'] }, + localAsrActiveModel: '', + localAsrMirror: 'huggingface', + localAsrKeepLoadedSecs: 300, + foundryLocalAsrModel: '', + foundryLocalAsrLanguageHint: '', + foundryLocalAsrKeepLoadedSecs: 300, +}; + +const nextPrefs: UserPreferences = { + ...previousPrefs, + enabledModes: [], +}; + +const states: UserPreferences[] = []; +const errors: string[] = []; +let firstCurrentPrefs: UserPreferences | null = previousPrefs; +const saved = await persistStylePreferenceChange( + nextPrefs, + async () => { + throw 'disk full'; + }, + update => { + firstCurrentPrefs = typeof update === 'function' ? update(firstCurrentPrefs) : update; + if (firstCurrentPrefs) states.push(firstCurrentPrefs); + }, + message => errors.push(message), + rollbackWholeStylePreferences(previousPrefs, nextPrefs), +); + +assert(saved === false, 'setSettings reject should report save failure'); +assert(states.length === 2, `expected optimistic state then rollback, got ${states.length} updates`); +assert(states[0] === nextPrefs, 'first state update should be the optimistic next prefs'); +assert( + states[1].enabledModes.join(',') === previousPrefs.enabledModes.join(','), + 'second state update should roll back enabled modes to previous prefs', +); +assert(errors[0] === 'disk full', `expected backend error message, got ${errors[0]}`); + +let currentPrefs: UserPreferences | null = previousPrefs; +const disableLightPrefs: UserPreferences = { + ...previousPrefs, + enabledModes: ['raw', 'structured'], +}; +const disableStructuredAfterLightPrefs: UserPreferences = { + ...previousPrefs, + enabledModes: ['raw'], +}; +const overlapSaved = await persistStylePreferenceChange( + disableLightPrefs, + async () => { + currentPrefs = disableStructuredAfterLightPrefs; + throw 'slow failure'; + }, + update => { + currentPrefs = typeof update === 'function' ? update(currentPrefs) : update; + }, + () => undefined, + rollbackStyleEnabledChange('light', previousPrefs, disableLightPrefs), +); + +assert(overlapSaved === false, 'overlapped style save should still report failure'); +assert( + currentPrefs?.enabledModes.includes('light') === true, + 'failed light toggle should roll back only the light mode', +); +assert( + currentPrefs?.enabledModes.includes('structured') === false, + 'failed light toggle should preserve newer structured edit', +); diff --git a/openless-all/app/src/lib/stylePrefs.ts b/openless-all/app/src/lib/stylePrefs.ts new file mode 100644 index 00000000..96cc8d70 --- /dev/null +++ b/openless-all/app/src/lib/stylePrefs.ts @@ -0,0 +1,70 @@ +import type { PolishMode, UserPreferences } from './types'; + +type StylePrefsState = UserPreferences | null; +type StylePrefsSetter = (prefs: StylePrefsState | ((current: StylePrefsState) => StylePrefsState)) => void; +type StylePrefsRollback = (current: StylePrefsState) => StylePrefsState; + +export function styleSaveErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return String(error); +} + +export async function persistStylePreferenceChange( + nextPrefs: UserPreferences, + persist: () => Promise, + setPrefs: StylePrefsSetter, + onFailure: (message: string) => void, + rollback: StylePrefsRollback, +): Promise { + setPrefs(nextPrefs); + try { + await persist(); + return true; + } catch (error) { + setPrefs(current => rollback(current)); + onFailure(styleSaveErrorMessage(error)); + return false; + } +} + +export function rollbackDefaultModeChange( + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + return current => { + if (!current || current.defaultMode !== nextPrefs.defaultMode) return current; + return { ...current, defaultMode: previousPrefs.defaultMode }; + }; +} + +export function rollbackStyleEnabledChange( + mode: PolishMode, + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + const previousEnabled = previousPrefs.enabledModes.includes(mode); + const nextEnabled = nextPrefs.enabledModes.includes(mode); + return current => { + if (!current || current.enabledModes.includes(mode) !== nextEnabled) return current; + const enabledModes = previousEnabled + ? Array.from(new Set([...current.enabledModes, mode])) + : current.enabledModes.filter(m => m !== mode); + return { ...current, enabledModes }; + }; +} + +export function rollbackWholeStylePreferences( + previousPrefs: UserPreferences, + nextPrefs: UserPreferences, +): StylePrefsRollback { + return current => { + if (!current || !sameModes(current.enabledModes, nextPrefs.enabledModes)) return current; + return { ...current, enabledModes: previousPrefs.enabledModes }; + }; +} + +function sameModes(left: PolishMode[], right: PolishMode[]): boolean { + if (left.length !== right.length) return false; + return left.every((mode, index) => mode === right[index]); +} diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index f53497da..1a78948b 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -5,6 +5,12 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from '../lib/ipc'; import type { PolishMode, UserPreferences } from '../lib/types'; +import { + persistStylePreferenceChange, + rollbackDefaultModeChange, + rollbackStyleEnabledChange, + rollbackWholeStylePreferences, +} from '../lib/stylePrefs'; import { PageHeader, Pill } from './_atoms'; interface StyleDef { @@ -15,6 +21,7 @@ interface StyleDef { } const STYLE_IDS: PolishMode[] = ['raw', 'light', 'structured', 'formal']; +type StyleSaveErrorTarget = PolishMode | 'master'; export function Style() { const { t } = useTranslation(); @@ -25,15 +32,27 @@ export function Style() { sample: t(`style.modes.${id}.sample`), })); const [prefs, setPrefs] = useState(null); + const [saveError, setSaveError] = useState<{ target: StyleSaveErrorTarget; message: string } | null>(null); useEffect(() => { getSettings().then(setPrefs); }, []); + const showSaveError = (target: StyleSaveErrorTarget, error: string) => { + setSaveError({ target, message: t('style.saveFailed', { error }) }); + }; + const onPickDefault = async (mode: PolishMode) => { if (!prefs) return; - setPrefs({ ...prefs, defaultMode: mode }); - await setDefaultPolishMode(mode); + const next = { ...prefs, defaultMode: mode }; + const saved = await persistStylePreferenceChange( + next, + () => setDefaultPolishMode(mode), + setPrefs, + error => showSaveError(mode, error), + rollbackDefaultModeChange(prefs, next), + ); + if (saved) setSaveError(null); }; const onToggleEnabled = async (mode: PolishMode) => { @@ -42,8 +61,15 @@ export function Style() { const nextEnabled = enabled ? [...prefs.enabledModes, mode] : prefs.enabledModes.filter(m => m !== mode); - setPrefs({ ...prefs, enabledModes: nextEnabled }); - await setStyleEnabled(mode, enabled); + const next = { ...prefs, enabledModes: nextEnabled }; + const saved = await persistStylePreferenceChange( + next, + () => setStyleEnabled(mode, enabled), + setPrefs, + error => showSaveError(mode, error), + rollbackStyleEnabledChange(mode, prefs, next), + ); + if (saved) setSaveError(null); }; if (!prefs) { @@ -63,12 +89,24 @@ export function Style() { if (masterEnabled) { // 全部关闭 → 留 raw 和当前 default 兜底 const next = { ...prefs, enabledModes: [] as PolishMode[] }; - setPrefs(next); - await setSettings(next); + const saved = await persistStylePreferenceChange( + next, + () => setSettings(next), + setPrefs, + error => showSaveError('master', error), + rollbackWholeStylePreferences(prefs, next), + ); + if (saved) setSaveError(null); } else { const next = { ...prefs, enabledModes: ['raw', 'light', 'structured', 'formal'] as PolishMode[] }; - setPrefs(next); - await setSettings(next); + const saved = await persistStylePreferenceChange( + next, + () => setSettings(next), + setPrefs, + error => showSaveError('master', error), + rollbackWholeStylePreferences(prefs, next), + ); + if (saved) setSaveError(null); } }; @@ -97,6 +135,14 @@ export function Style() { }} /> + {saveError?.target === 'master' && ( + + {saveError.message} + + )} } /> @@ -176,6 +222,14 @@ export function Style() { > {s.sample} + {saveError?.target === s.id && ( +
+ {saveError.message} +
+ )} ); })}