Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export const ja: typeof zhCN = {
masterToggle: '全体有効化',
currentDefault: '現在のデフォルト',
ariaSetDefault: 'デフォルトに設定',
saveFailed: '保存に失敗しました: {{error}}',
modes: {
raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' },
light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' },
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export const ko: typeof zhCN = {
masterToggle: '전체 활성화',
currentDefault: '현재 기본',
ariaSetDefault: '기본으로 설정',
saveFailed: '저장 실패: {{error}}',
modes: {
raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' },
light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' },
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export const zhCN = {
masterToggle: '整体启用',
currentDefault: '当前默认',
ariaSetDefault: '设为默认',
saveFailed: '保存失败:{{error}}',
modes: {
raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' },
light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' },
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const zhTW: typeof zhCN = {
masterToggle: '整體啓用',
currentDefault: '當前默認',
ariaSetDefault: '設爲默認',
saveFailed: '保存失敗:{{error}}',
modes: {
raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' },
light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' },
Expand Down
103 changes: 103 additions & 0 deletions openless-all/app/src/lib/stylePrefs.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
70 changes: 70 additions & 0 deletions openless-all/app/src/lib/stylePrefs.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
setPrefs: StylePrefsSetter,
onFailure: (message: string) => void,
rollback: StylePrefsRollback,
): Promise<boolean> {
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]);
}
70 changes: 62 additions & 8 deletions openless-all/app/src/pages/Style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -15,6 +21,7 @@ interface StyleDef {
}

const STYLE_IDS: PolishMode[] = ['raw', 'light', 'structured', 'formal'];
type StyleSaveErrorTarget = PolishMode | 'master';

export function Style() {
const { t } = useTranslation();
Expand All @@ -25,15 +32,27 @@ export function Style() {
sample: t(`style.modes.${id}.sample`),
}));
const [prefs, setPrefs] = useState<UserPreferences | null>(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) => {
Expand All @@ -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) {
Expand All @@ -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);
}
};

Expand Down Expand Up @@ -97,6 +135,14 @@ export function Style() {
}}
/>
</button>
{saveError?.target === 'master' && (
<span
role="alert"
style={{ fontSize: 11.5, color: 'var(--ol-red, #ef4444)', maxWidth: 220, lineHeight: 1.45 }}
>
{saveError.message}
</span>
)}
</div>
}
/>
Expand Down Expand Up @@ -176,6 +222,14 @@ export function Style() {
>
{s.sample}
</div>
{saveError?.target === s.id && (
<div
role="alert"
style={{ marginTop: 10, fontSize: 11.5, color: 'var(--ol-red, #ef4444)', lineHeight: 1.45 }}
>
{saveError.message}
</div>
)}
</div>
);
})}
Expand Down
Loading